Commit 2e7616134cbd

Vincent Demeester <vincent@sbr.pm>
2026-02-05 16:37:49
feat(ai): add unified AI agent storage infrastructure
Implemented shared storage for AI coding tools (Claude, pi, opencode) with syncthing synchronization and XDG-compliant directory structure. - Added ~/.local/share/ai-sync/ as syncthing folder with symlinks from ~/.local/share/ai/ for sessions, plans, learnings, and research - Added ~/.config/ai/skills symlink to shared claude skills - Configured syncthing ai-sync folder for kyushu and aomi machines - Restructured pi session-history extension as npm package with chrono-node for natural language date parsing - Enhanced /list-sessions with date ranges, natural language, and ripgrep search - /save-session now auto-sends prompt and uses lighter model - Fixed timezone bug in date formatting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fceb606
dots/pi/agent/extensions/session-history/index.ts
@@ -0,0 +1,607 @@
+/**
+ * Session History Extension for Pi
+ *
+ * Integrates pi with unified AI agent storage.
+ * Auto-detects the tool being used (pi, claude, copilot, etc.)
+ *
+ * Features:
+ * - /save-session: Triggers AI to generate and save session summary
+ * - /session-log: Shows today's session activity
+ * - Auto-logs session starts
+ *
+ * Storage locations (XDG-compliant):
+ * - Sessions: ~/.local/share/ai/sessions/YYYY-MM/*.md
+ * - Plans: ~/.local/share/ai/plans/*.md (no date prefix)
+ * - Learnings: ~/.local/share/ai/learnings/YYYY-MM/*.md
+ * - Research: ~/.local/share/ai/research/YYYY-MM/*.md
+ *
+ * Compatible with Claude Code, pi, opencode, and other AI coding tools.
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { writeFile, mkdir, appendFile, readFile } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { homedir, hostname } from "node:os";
+import * as chrono from "chrono-node";
+
+export default function (pi: ExtensionAPI) {
+	// Unified AI agent storage (XDG_DATA_HOME/ai)
+	const AI_DATA_DIR = join(homedir(), ".local", "share", "ai");
+	const SESSIONS_DIR = join(AI_DATA_DIR, "sessions");
+
+	// Track current session file across multiple saves
+	let currentSessionFile: string | null = null;
+	let currentSessionDescription: string | null = null;
+	// Track model to restore after session save
+	let modelToRestore: any | null = null;
+
+	/**
+	 * Detect which tool is being used
+	 */
+	function detectTool(): string {
+		// Check environment variables
+		if (process.env.CLAUDE_PROJECT_DIR || process.env.CLAUDE_AGENT_TYPE) {
+			return "claude";
+		}
+		if (process.env.PI_VERSION || process.env.PI_PROJECT_DIR) {
+			return "pi";
+		}
+		// Check if running under specific tools
+		const execPath = process.argv0 || "";
+		if (execPath.includes("copilot")) {
+			return "copilot";
+		}
+		if (execPath.includes("cursor")) {
+			return "cursor";
+		}
+		if (execPath.includes("pi")) {
+			return "pi";
+		}
+		if (execPath.includes("claude")) {
+			return "claude";
+		}
+
+		// Default to pi since this is a pi extension
+		return "pi";
+	}
+
+	function getDateInfo() {
+		const now = new Date();
+		const year = now.getFullYear();
+		const month = String(now.getMonth() + 1).padStart(2, "0");
+		const day = String(now.getDate()).padStart(2, "0");
+		const hours = String(now.getHours()).padStart(2, "0");
+		const minutes = String(now.getMinutes()).padStart(2, "0");
+		const seconds = String(now.getSeconds()).padStart(2, "0");
+
+		return {
+			yearMonth: `${year}-${month}`,
+			date: `${year}-${month}-${day}`,
+			timestamp: `${year}-${month}-${day}T${hours}:${minutes}:${seconds}+01:00`,
+			time: `${hours}:${minutes}`,
+		};
+	}
+
+	async function logSessionStart() {
+		const tool = detectTool();
+		const { yearMonth, date, timestamp } = getDateInfo();
+		const sessionDir = join(SESSIONS_DIR, yearMonth);
+		const logFile = join(sessionDir, `${date}_session-log.txt`);
+
+		await mkdir(sessionDir, { recursive: true });
+		await appendFile(logFile, `${timestamp} - Session started (${tool})\n`);
+	}
+
+	async function appendToSessionLog(message: string) {
+		const { yearMonth, date, timestamp } = getDateInfo();
+		const sessionDir = join(SESSIONS_DIR, yearMonth);
+		const logFile = join(sessionDir, `${date}_session-log.txt`);
+
+		await mkdir(sessionDir, { recursive: true });
+		await appendFile(logFile, `${timestamp} - ${message}\n`);
+	}
+
+	// Internal command to actually save the session (called by AI after generating summary)
+	pi.registerTool({
+		name: "save_session_to_history",
+		label: "Save Session to History",
+		description:
+			"Saves a session summary to ~/.local/share/ai/sessions/. Works with any AI coding tool (pi, claude, copilot, cursor). Auto-detects which tool is being used. If called multiple times in the same session, updates the existing file.",
+		parameters: {
+			type: "object",
+			properties: {
+				description: {
+					type: "string",
+					description: "Brief description for the filename (e.g., 'added-hostname-extension')",
+				},
+				content: {
+					type: "string",
+					description: "Full markdown content following the Session Entry template from history-system.md",
+				},
+			},
+			required: ["description", "content"],
+		},
+		async execute(toolCallId, params, signal, onUpdate, ctx) {
+			// Helper to restore model after save
+			const restoreModel = async () => {
+				if (modelToRestore) {
+					await pi.setModel(modelToRestore);
+					ctx.ui?.notify?.(`Restored model: ${modelToRestore.name || modelToRestore.id}`, "info");
+					modelToRestore = null;
+				}
+			};
+
+			try {
+				const { description, content } = params;
+				const { yearMonth, date, time } = getDateInfo();
+				const sessionDir = join(SESSIONS_DIR, yearMonth);
+
+				// Sanitize filename
+				const slug = description
+					.toLowerCase()
+					.replace(/[^a-z0-9]+/g, "-")
+					.replace(/^-+|-+$/g, "")
+					.substring(0, 60);
+
+				const filename = `${date}-${slug}.md`;
+				const filepath = join(sessionDir, filename);
+
+				// Create directory
+				await mkdir(sessionDir, { recursive: true });
+
+				// Check if we're updating the current session or creating a new one
+				const isUpdate = currentSessionFile === filepath;
+
+				if (isUpdate) {
+					// Read existing content
+					const existingContent = await readFile(filepath, "utf-8");
+
+					// Check if we should append or replace
+					// If content has changed significantly, replace; otherwise append
+					if (content.length > existingContent.length * 1.2 || content.includes("## Update")) {
+						// Replace with new version
+						await writeFile(filepath, content, "utf-8");
+						await appendToSessionLog(`Session updated: ${filename}`);
+						await restoreModel();
+
+						return {
+							content: [
+								{
+									type: "text",
+									text: `โœ“ Session updated: ${filepath}`,
+								},
+							],
+							details: { updated: true },
+						};
+					} else {
+						// Append update section
+						const updateSection = `\n\n---\n\n## Update (${time})\n\n${content
+							.split("\n")
+							.filter((line) => !line.startsWith("#") && !line.startsWith("**Date:**") && !line.startsWith("**Time:**"))
+							.join("\n")}`;
+
+						await appendFile(filepath, updateSection);
+						await appendToSessionLog(`Session updated: ${filename}`);
+						await restoreModel();
+
+						return {
+							content: [
+								{
+									type: "text",
+									text: `โœ“ Session updated (appended): ${filepath}`,
+								},
+							],
+							details: { updated: true, appended: true },
+						};
+					}
+				} else {
+					// New session file
+					await writeFile(filepath, content, "utf-8");
+
+					// Track this as the current session
+					currentSessionFile = filepath;
+					currentSessionDescription = description;
+
+					// Append to session log
+					await appendToSessionLog(`Session saved: ${filename}`);
+					await restoreModel();
+
+					return {
+						content: [
+							{
+								type: "text",
+								text: `โœ“ Session saved to: ${filepath}`,
+							},
+						],
+						details: { created: true },
+					};
+				}
+			} catch (error) {
+				await restoreModel();
+				return {
+					content: [
+						{
+							type: "text",
+							text: `Error saving session: ${error}`,
+						},
+					],
+					details: { error: String(error) },
+				};
+			}
+		},
+	});
+
+	// Log session start and reset session tracking
+	pi.on("session_start", async (_event, ctx) => {
+		try {
+			// Reset session tracking on new session
+			currentSessionFile = null;
+			currentSessionDescription = null;
+
+			await logSessionStart();
+			// Show hint
+			ctx.ui.setStatus("session", ctx.ui.theme.fg("dim", "๐Ÿ’พ /save-session"));
+		} catch (error) {
+			// Silent failure
+		}
+	});
+
+	// Try to find a lighter model for session summaries
+	async function findLightModel(ctx: any): Promise<any | null> {
+		const lightModels = [
+			// Prefer cheaper/faster models for summarization
+			{ provider: "google", id: "gemini-2.5-pro" },
+			{ provider: "google", id: "gemini-2.0-flash" },
+			{ provider: "google", id: "gemini-1.5-flash" },
+			{ provider: "github-copilot", id: "gpt-4o" },
+			{ provider: "github-copilot", id: "gpt-4" },
+			{ provider: "anthropic", id: "claude-3-5-haiku" },
+			{ provider: "anthropic", id: "claude-haiku-3" },
+		];
+
+		for (const { provider, id } of lightModels) {
+			const model = ctx.modelRegistry.find(provider, id);
+			if (model) {
+				return model;
+			}
+		}
+		return null;
+	}
+
+	// Register /save-session command
+	pi.registerCommand("save-session", {
+		description: "Generate and save a session summary to history (auto-detects tool)",
+		handler: async (_args, ctx) => {
+			const tool = detectTool();
+			const { date, time } = getDateInfo();
+			const host = hostname();
+
+			// Try to use a lighter model for summarization
+			const currentModel = ctx.model;
+			const lightModel = await findLightModel(ctx);
+
+			if (lightModel && lightModel.id !== currentModel?.id) {
+				const success = await pi.setModel(lightModel);
+				if (success) {
+					// Store current model to restore after save
+					modelToRestore = currentModel;
+					ctx.ui.notify(`Using ${lightModel.name || lightModel.id} for summary`, "info");
+				}
+			}
+
+			// Build the prompt for the AI
+			const prompt = `Please generate ${currentSessionFile ? "an updated" : "a"} session summary for this conversation.
+
+${
+	currentSessionFile
+		? `This session has already been saved. Please review what additional work was done and either:
+1. Generate a complete updated summary (if significant new work was done)
+2. Or generate just an update section with the new accomplishments
+
+Current session: ${currentSessionDescription}`
+		: ""
+}
+
+The summary should include:
+- A descriptive title
+- What was accomplished
+- Files that were changed
+- Commands that were run
+- The outcome
+- Any next steps
+
+Use this template format:
+
+\`\`\`markdown
+# Session: <Description>
+
+**Date:** ${date}
+**Time:** ${time}
+**Host:** ${host}
+**Tool:** ${tool}
+
+## Summary
+Brief description of what was accomplished.
+
+## What Was Accomplished
+- Task 1
+- Task 2
+
+## Files Changed
+- \`path/to/file\` - Description of change
+
+## Commands Run
+\`\`\`bash
+# Key commands executed
+\`\`\`
+
+## Outcome
+Result of the session.
+
+## Next Steps
+- [ ] TODO 1
+- [ ] TODO 2
+
+### Tags
+#${tool} #relevant-tags
+\`\`\`
+
+After generating the summary, use the save_session_to_history tool to save it${currentSessionFile ? " (it will automatically update the existing session file)" : " with an appropriate filename description"}.`;
+
+			// Send the prompt automatically (triggers AI response)
+			pi.sendUserMessage(prompt);
+		},
+	});
+
+	// Parse natural language date expressions using chrono-node
+	function parseDateSpec(spec: string): { start: Date; end: Date; label: string } | null {
+		const now = new Date();
+		const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+		const normalized = spec.toLowerCase().trim();
+
+		// Check for date range: YYYY-MM-DD..YYYY-MM-DD (not handled by chrono)
+		const rangeMatch = spec.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/);
+		if (rangeMatch) {
+			return {
+				start: new Date(rangeMatch[1]),
+				end: new Date(rangeMatch[2]),
+				label: `${rangeMatch[1]} to ${rangeMatch[2]}`,
+			};
+		}
+
+		// Special handling for range expressions
+		if (normalized.includes(" to ") || normalized.includes(" through ") || normalized.includes(" until ")) {
+			const results = chrono.parse(spec, now);
+			if (results.length >= 2) {
+				return {
+					start: results[0].start.date(),
+					end: results[1].start.date(),
+					label: spec,
+				};
+			}
+			// Single result with end date
+			if (results.length === 1 && results[0].end) {
+				return {
+					start: results[0].start.date(),
+					end: results[0].end.date(),
+					label: spec,
+				};
+			}
+		}
+
+		// Use chrono to parse the date expression
+		const results = chrono.parse(spec, now);
+
+		if (results.length > 0) {
+			const result = results[0];
+			const start = result.start.date();
+			// If chrono found an end date (e.g., "Sep 12-13"), use it
+			const end = result.end ? result.end.date() : start;
+
+			// Normalize to start of day
+			const startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate());
+			const endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate());
+
+			return {
+				start: startDay,
+				end: endDay,
+				label: spec,
+			};
+		}
+
+		return null;
+	}
+
+	// Format date as YYYY-MM-DD (local time, not UTC)
+	function formatDate(d: Date): string {
+		const year = d.getFullYear();
+		const month = String(d.getMonth() + 1).padStart(2, "0");
+		const day = String(d.getDate()).padStart(2, "0");
+		return `${year}-${month}-${day}`;
+	}
+
+	// Get all dates in range
+	function getDatesInRange(start: Date, end: Date): string[] {
+		const dates: string[] = [];
+		const current = new Date(start);
+		while (current <= end) {
+			dates.push(formatDate(current));
+			current.setDate(current.getDate() + 1);
+		}
+		return dates;
+	}
+
+	// Register /list-sessions command
+	pi.registerCommand("list-sessions", {
+		description: "List sessions. Usage: /list-sessions [today|yesterday|last week|last N days|YYYY-MM-DD|YYYY-MM-DD..YYYY-MM-DD|search <query>]",
+		handler: async (args, ctx) => {
+			const theme = ctx.ui.theme;
+			const argStr = (args || "").trim();
+
+			// Check for search mode
+			if (argStr.toLowerCase().startsWith("search ")) {
+				const query = argStr.slice(7).trim();
+				if (!query) {
+					ctx.ui.notify("Usage: /list-sessions search <query>", "error");
+					return;
+				}
+
+				try {
+					const { execSync } = await import("node:child_process");
+					// Use ripgrep to search sessions
+					const rgResult = execSync(
+						`rg -l -i "${query.replace(/"/g, '\\"')}" "${SESSIONS_DIR}" 2>/dev/null || true`,
+						{ encoding: "utf-8", maxBuffer: 1024 * 1024 }
+					).trim();
+
+					if (!rgResult) {
+						ctx.ui.notify(`No sessions found matching "${query}"`, "info");
+						return;
+					}
+
+					const files = rgResult.split("\n").filter(Boolean).slice(0, 20); // Limit to 20 results
+
+					const header = theme.bold(`๐Ÿ” Sessions matching "${query}"`);
+					const separator = theme.fg("dim", "โ”€".repeat(50));
+
+					const fileLines = files.map((filepath) => {
+						const filename = filepath.split("/").pop() || "";
+						const date = filename.slice(0, 10);
+						const desc = filename.slice(11).replace(".md", "").replace(/-/g, " ");
+						return `  ${theme.fg("accent", "โ€ข")} ${theme.fg("dim", date)} ${desc}\n    ${theme.fg("dim", filepath)}`;
+					});
+
+					const widgetLines = [header, separator, ...fileLines];
+					if (files.length === 20) {
+						widgetLines.push(theme.fg("warning", "  (showing first 20 results)"));
+					}
+					ctx.ui.setWidget("list-sessions", widgetLines);
+
+					setTimeout(() => ctx.ui.setWidget("list-sessions", undefined), 20000);
+				} catch (error) {
+					ctx.ui.notify(`Search error: ${error}`, "error");
+				}
+				return;
+			}
+
+			// Parse date spec (default to today)
+			const dateSpec = parseDateSpec(argStr || "today");
+			if (!dateSpec) {
+				ctx.ui.notify(
+					"Invalid date. Use: today, yesterday, last week, last N days, YYYY-MM-DD, or YYYY-MM-DD..YYYY-MM-DD",
+					"error"
+				);
+				return;
+			}
+
+			const { start, end, label } = dateSpec;
+			const dates = getDatesInRange(start, end);
+
+			try {
+				const { readdir } = await import("node:fs/promises");
+				const allFiles: { date: string; file: string; path: string }[] = [];
+
+				// Get unique year-months to check
+				const yearMonths = [...new Set(dates.map((d) => d.slice(0, 7)))];
+
+				for (const ym of yearMonths) {
+					const sessionDir = join(SESSIONS_DIR, ym);
+					if (!existsSync(sessionDir)) continue;
+
+					const files = await readdir(sessionDir);
+					for (const f of files) {
+						if (!f.endsWith(".md")) continue;
+						const fileDate = f.slice(0, 10);
+						if (dates.includes(fileDate)) {
+							allFiles.push({
+								date: fileDate,
+								file: f,
+								path: join(sessionDir, f),
+							});
+						}
+					}
+				}
+
+				allFiles.sort((a, b) => b.file.localeCompare(a.file)); // Newest first
+
+				if (allFiles.length === 0) {
+					ctx.ui.notify(`No sessions found for ${label}`, "info");
+					return;
+				}
+
+				const header = theme.bold(`๐Ÿ“‹ Sessions - ${label} (${allFiles.length})`);
+				const separator = theme.fg("dim", "โ”€".repeat(50));
+
+				const fileLines = allFiles.slice(0, 30).map(({ date, file, path }) => {
+					const desc = file.slice(11).replace(".md", "").replace(/-/g, " ");
+					return `  ${theme.fg("accent", "โ€ข")} ${theme.fg("dim", date)} ${desc}\n    ${theme.fg("dim", path)}`;
+				});
+
+				const widgetLines = [header, separator, ...fileLines];
+				if (allFiles.length > 30) {
+					widgetLines.push(theme.fg("warning", `  (showing 30 of ${allFiles.length})`));
+				}
+				ctx.ui.setWidget("list-sessions", widgetLines);
+
+				setTimeout(() => ctx.ui.setWidget("list-sessions", undefined), 20000);
+			} catch (error) {
+				ctx.ui.notify(`Error listing sessions: ${error}`, "error");
+			}
+		},
+	});
+
+	// Register /session-log command
+	pi.registerCommand("session-log", {
+		description: "View today's session log",
+		handler: async (_args, ctx) => {
+			const { yearMonth, date } = getDateInfo();
+			const logFile = join(SESSIONS_DIR, yearMonth, `${date}_session-log.txt`);
+
+			if (!existsSync(logFile)) {
+				ctx.ui.notify("No session log for today", "info");
+				return;
+			}
+
+			try {
+				const content = await readFile(logFile, "utf-8");
+				const lines = content.trim().split("\n");
+				const theme = ctx.ui.theme;
+
+				// Format each log entry with colors
+				const formattedLines = lines.map((line) => {
+					// Parse: YYYY-MM-DDTHH:MM:SS+TZ - Message
+					const match = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2})\s*-\s*(.+)$/);
+					if (match) {
+						const [, timestamp, message] = match;
+						// Extract just the time part for display
+						const time = timestamp.slice(11, 16);
+						// Color based on message type
+						let styledMessage = message;
+						if (message.includes("started")) {
+							styledMessage = theme.fg("accent", message);
+						} else if (message.includes("saved")) {
+							styledMessage = theme.fg("success", message);
+						} else if (message.includes("updated")) {
+							styledMessage = theme.fg("warning", message);
+						}
+						return `${theme.fg("dim", time)} ${styledMessage}`;
+					}
+					return theme.fg("dim", line);
+				});
+
+				// Display as widget
+				const header = theme.bold(`๐Ÿ“‹ Session Log - ${date}`);
+				const widgetLines = [header, theme.fg("dim", "โ”€".repeat(40)), ...formattedLines];
+				ctx.ui.setWidget("session-log", widgetLines);
+
+				// Auto-dismiss after 10 seconds
+				setTimeout(() => {
+					ctx.ui.setWidget("session-log", undefined);
+				}, 10000);
+			} catch (error) {
+				ctx.ui.notify(`Error: ${error}`, "error");
+			}
+		},
+	});
+}
dots/pi/agent/extensions/session-history/package-lock.json
@@ -0,0 +1,24 @@
+{
+  "name": "session-history",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "session-history",
+      "version": "1.0.0",
+      "dependencies": {
+        "chrono-node": "^2.9.0"
+      }
+    },
+    "node_modules/chrono-node": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.9.0.tgz",
+      "integrity": "sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ==",
+      "license": "MIT",
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      }
+    }
+  }
+}
dots/pi/agent/extensions/session-history/package.json
@@ -0,0 +1,12 @@
+{
+  "name": "session-history",
+  "version": "1.0.0",
+  "description": "Session history management for pi with unified AI agent storage",
+  "type": "module",
+  "dependencies": {
+    "chrono-node": "^2.9.0"
+  },
+  "pi": {
+    "extensions": ["./index.ts"]
+  }
+}
dots/pi/agent/extensions/session-history.ts
@@ -1,320 +0,0 @@
-/**
- * Session History Extension for Pi
- *
- * Integrates pi with Claude Code's history system.
- * Auto-detects the tool being used (pi, claude, copilot, etc.)
- *
- * Features:
- * - /save-session: Triggers AI to generate and save session summary
- * - /session-log: Shows today's session activity
- * - Auto-logs session starts
- *
- * Compatible with Claude Code's hook system and history structure.
- */
-
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { writeFile, mkdir, appendFile, readFile } from "node:fs/promises";
-import { existsSync } from "node:fs";
-import { join } from "node:path";
-import { homedir, hostname } from "node:os";
-
-export default function (pi: ExtensionAPI) {
-	const HISTORY_DIR = join(homedir(), ".config", "claude", "history");
-	const SESSIONS_DIR = join(HISTORY_DIR, "sessions");
-
-	// Track current session file across multiple saves
-	let currentSessionFile: string | null = null;
-	let currentSessionDescription: string | null = null;
-
-	/**
-	 * Detect which tool is being used
-	 */
-	function detectTool(): string {
-		// Check environment variables
-		if (process.env.CLAUDE_PROJECT_DIR || process.env.CLAUDE_AGENT_TYPE) {
-			return "claude";
-		}
-		if (process.env.PI_VERSION || process.env.PI_PROJECT_DIR) {
-			return "pi";
-		}
-		// Check if running under specific tools
-		const execPath = process.argv0 || "";
-		if (execPath.includes("copilot")) {
-			return "copilot";
-		}
-		if (execPath.includes("cursor")) {
-			return "cursor";
-		}
-		if (execPath.includes("pi")) {
-			return "pi";
-		}
-		if (execPath.includes("claude")) {
-			return "claude";
-		}
-
-		// Default to pi since this is a pi extension
-		return "pi";
-	}
-
-	function getDateInfo() {
-		const now = new Date();
-		const year = now.getFullYear();
-		const month = String(now.getMonth() + 1).padStart(2, "0");
-		const day = String(now.getDate()).padStart(2, "0");
-		const hours = String(now.getHours()).padStart(2, "0");
-		const minutes = String(now.getMinutes()).padStart(2, "0");
-		const seconds = String(now.getSeconds()).padStart(2, "0");
-
-		return {
-			yearMonth: `${year}-${month}`,
-			date: `${year}-${month}-${day}`,
-			timestamp: `${year}-${month}-${day}T${hours}:${minutes}:${seconds}+01:00`,
-			time: `${hours}:${minutes}`,
-		};
-	}
-
-	async function logSessionStart() {
-		const tool = detectTool();
-		const { yearMonth, date, timestamp } = getDateInfo();
-		const sessionDir = join(SESSIONS_DIR, yearMonth);
-		const logFile = join(sessionDir, `${date}_session-log.txt`);
-
-		await mkdir(sessionDir, { recursive: true });
-		await appendFile(logFile, `${timestamp} - Session started (${tool})\n`);
-	}
-
-	async function appendToSessionLog(message: string) {
-		const { yearMonth, date, timestamp } = getDateInfo();
-		const sessionDir = join(SESSIONS_DIR, yearMonth);
-		const logFile = join(sessionDir, `${date}_session-log.txt`);
-
-		await mkdir(sessionDir, { recursive: true });
-		await appendFile(logFile, `${timestamp} - ${message}\n`);
-	}
-
-	// Internal command to actually save the session (called by AI after generating summary)
-	pi.registerTool({
-		name: "save_session_to_history",
-		label: "Save Session to History",
-		description:
-			"Saves a session summary to ~/.config/claude/history/sessions/. Works with any AI coding tool (pi, claude, copilot, cursor). Auto-detects which tool is being used. If called multiple times in the same session, updates the existing file.",
-		parameters: {
-			type: "object",
-			properties: {
-				description: {
-					type: "string",
-					description: "Brief description for the filename (e.g., 'added-hostname-extension')",
-				},
-				content: {
-					type: "string",
-					description: "Full markdown content following the Session Entry template from history-system.md",
-				},
-			},
-			required: ["description", "content"],
-		},
-		async execute(toolCallId, params, signal, onUpdate, ctx) {
-			try {
-				const { description, content } = params;
-				const { yearMonth, date, time } = getDateInfo();
-				const sessionDir = join(SESSIONS_DIR, yearMonth);
-
-				// Sanitize filename
-				const slug = description
-					.toLowerCase()
-					.replace(/[^a-z0-9]+/g, "-")
-					.replace(/^-+|-+$/g, "")
-					.substring(0, 60);
-
-				const filename = `${date}-${slug}.md`;
-				const filepath = join(sessionDir, filename);
-
-				// Create directory
-				await mkdir(sessionDir, { recursive: true });
-
-				// Check if we're updating the current session or creating a new one
-				const isUpdate = currentSessionFile === filepath;
-
-				if (isUpdate) {
-					// Read existing content
-					const existingContent = await readFile(filepath, "utf-8");
-
-					// Check if we should append or replace
-					// If content has changed significantly, replace; otherwise append
-					if (content.length > existingContent.length * 1.2 || content.includes("## Update")) {
-						// Replace with new version
-						await writeFile(filepath, content, "utf-8");
-						await appendToSessionLog(`Session updated: ${filename}`);
-
-						return {
-							content: [
-								{
-									type: "text",
-									text: `โœ“ Session updated: ${filepath}`,
-								},
-							],
-							details: { updated: true },
-						};
-					} else {
-						// Append update section
-						const updateSection = `\n\n---\n\n## Update (${time})\n\n${content
-							.split("\n")
-							.filter((line) => !line.startsWith("#") && !line.startsWith("**Date:**") && !line.startsWith("**Time:**"))
-							.join("\n")}`;
-
-						await appendFile(filepath, updateSection);
-						await appendToSessionLog(`Session updated: ${filename}`);
-
-						return {
-							content: [
-								{
-									type: "text",
-									text: `โœ“ Session updated (appended): ${filepath}`,
-								},
-							],
-							details: { updated: true, appended: true },
-						};
-					}
-				} else {
-					// New session file
-					await writeFile(filepath, content, "utf-8");
-
-					// Track this as the current session
-					currentSessionFile = filepath;
-					currentSessionDescription = description;
-
-					// Append to session log
-					await appendToSessionLog(`Session saved: ${filename}`);
-
-					return {
-						content: [
-							{
-								type: "text",
-								text: `โœ“ Session saved to: ${filepath}`,
-							},
-						],
-						details: { created: true },
-					};
-				}
-			} catch (error) {
-				return {
-					content: [
-						{
-							type: "text",
-							text: `Error saving session: ${error}`,
-						},
-					],
-					details: { error: String(error) },
-				};
-			}
-		},
-	});
-
-	// Log session start and reset session tracking
-	pi.on("session_start", async (_event, ctx) => {
-		try {
-			// Reset session tracking on new session
-			currentSessionFile = null;
-			currentSessionDescription = null;
-
-			await logSessionStart();
-			// Show hint
-			ctx.ui.setStatus("session", ctx.ui.theme.fg("dim", "๐Ÿ’พ /save-session"));
-		} catch (error) {
-			// Silent failure
-		}
-	});
-
-	// Register /save-session command
-	pi.registerCommand("save-session", {
-		description: "Generate and save a session summary to history (auto-detects tool)",
-		handler: async (_args, ctx) => {
-			const tool = detectTool();
-
-			// This triggers a message that prompts the AI to generate a summary
-			ctx.ui.notify("Generating session summary...", "info");
-
-			// Insert a prompt for the AI
-			const prompt = `Please generate ${currentSessionFile ? "an updated" : "a"} session summary for this conversation following the Session Entry template from the CORE skill's history-system.md.
-
-${
-	currentSessionFile
-		? `This session has already been saved. Please review what additional work was done and either:
-1. Generate a complete updated summary (if significant new work was done)
-2. Or generate just an update section with the new accomplishments
-
-Current session: ${currentSessionDescription}`
-		: ""
-}
-
-The summary should include:
-- A descriptive title
-- What was accomplished
-- Files that were changed
-- Commands that were run
-- The outcome
-- Any next steps
-
-Use the Session Entry template format:
-
-\`\`\`markdown
-# Session: <Description>
-
-**Date:** ${getDateInfo().date}
-**Time:** ${getDateInfo().time}
-**Host:** ${hostname()}
-**Tool:** ${tool}
-
-## Summary
-Brief description of what was accomplished.
-
-## What Was Accomplished
-- Task 1
-- Task 2
-
-## Files Changed
-- \`path/to/file\` - Description of change
-
-## Commands Run
-\`\`\`bash
-# Key commands executed
-\`\`\`
-
-## Outcome
-Result of the session.
-
-## Next Steps
-- [ ] TODO 1
-- [ ] TODO 2
-
-### Tags
-#${tool} #relevant-tags
-\`\`\`
-
-After generating the summary, use the save_session_to_history tool to save it${currentSessionFile ? " (it will automatically update the existing session file)" : " with an appropriate filename description"}.`;
-
-			// Set the editor text to trigger the AI
-			ctx.ui.setEditorText(prompt);
-		},
-	});
-
-	// Register /session-log command
-	pi.registerCommand("session-log", {
-		description: "View today's session log",
-		handler: async (_args, ctx) => {
-			const { yearMonth, date } = getDateInfo();
-			const logFile = join(SESSIONS_DIR, yearMonth, `${date}_session-log.txt`);
-
-			if (!existsSync(logFile)) {
-				ctx.ui.notify("No session log for today", "info");
-				return;
-			}
-
-			try {
-				const content = await readFile(logFile, "utf-8");
-				console.log("\n๐Ÿ“‹ Session log:\n" + content);
-			} catch (error) {
-				ctx.ui.notify(`Error: ${error}`, "error");
-			}
-		},
-	});
-}
dots/Makefile
@@ -54,13 +54,14 @@ lazypr : ~/.config/lazypr/config.toml
 all += gh-news
 gh-news : ~/.config/gh-news/config.toml
 
-all += git-template copilot-hooks opencode-plugin pi-agent agent-skills agent-skill-manager-bin
+all += git-template copilot-hooks opencode-plugin pi-agent agent-skills agent-skill-manager-bin ai-config
 git-template : ~/.config/git/template
 copilot-hooks : ~/.config/copilot-hooks
 opencode-plugin : ~/.config/opencode/plugin
 pi-agent : ~/.pi/agent/extensions ~/.pi/agent/AGENTS.md ~/.pi/agent/README.md
 agent-skills : ~/.config/agent-skills
 agent-skill-manager-bin : ~/bin/agent-skill-manager
+ai-config : ~/.config/ai/skills
 
 # Agent skill manager tool
 ~/bin/agent-skill-manager : $(dotfiles)/config/agent-skills/agent-skill-manager force
@@ -92,6 +93,13 @@ agent-skill-manager-bin : ~/bin/agent-skill-manager
 	@mkdir -p ~/.pi/agent
 	@ln -snf $(dotfiles)/pi/agent/README.md ~/.pi/agent/README.md
 
+# Unified AI agent config - symlink skills to claude skills (shared)
+# Later can move to dots/config/ai/skills/ as canonical location
+~/.config/ai/skills : force
+	@echo "๐Ÿ”— Symlinking ~/.config/ai/skills -> ~/.config/claude/skills (shared)"
+	@mkdir -p ~/.config/ai
+	@ln -snf ~/.config/claude/skills ~/.config/ai/skills
+
 # Generate ntfy client.yml from template with passage secrets injected
 ~/.config/ntfy/client.yml : $(dotfiles)/config/ntfy/client.yml.in $(dotfiles)/config/ntfy/ntfy-update-config force
 	@echo "โš™๏ธ  Generating $$@ from template with passage secrets"
home/common/dev/ai.nix
@@ -6,14 +6,46 @@
 }:
 let
   claudeSyncDir = "${config.xdg.dataHome}/claude-sync";
+  # Unified AI agent storage (XDG-compliant)
+  # Physical storage in ai-sync (syncthing folder), symlinked to ai/
+  aiSyncDir = "${config.xdg.dataHome}/ai-sync";
 in
 {
-  # Ensure claude-sync directory structure exists
+  # Ensure claude-sync directory structure exists (legacy, still used by claude)
   xdg.dataFile = {
     "claude-sync/history/.keep".text = "";
     "claude-sync/projects/.keep".text = "";
     "claude-sync/todos/.keep".text = "";
     "claude-sync/plans/.keep".text = "";
+
+    # Unified AI agent data storage (syncthing folder)
+    # Sessions: markdown summaries from all AI tools
+    "ai-sync/sessions/.keep".text = "";
+    # Plans: implementation plans (topic-based, no dates)
+    "ai-sync/plans/.keep".text = "";
+    # Learnings: problem-solving narratives
+    "ai-sync/learnings/.keep".text = "";
+    # Research: research documents
+    "ai-sync/research/.keep".text = "";
+
+    # Unified AI agent data - symlink to synced location
+    # All AI tools (claude, pi, opencode) write to ~/.local/share/ai/
+    "ai/sessions" = {
+      source = config.lib.file.mkOutOfStoreSymlink "${aiSyncDir}/sessions";
+      force = true;
+    };
+    "ai/plans" = {
+      source = config.lib.file.mkOutOfStoreSymlink "${aiSyncDir}/plans";
+      force = true;
+    };
+    "ai/learnings" = {
+      source = config.lib.file.mkOutOfStoreSymlink "${aiSyncDir}/learnings";
+      force = true;
+    };
+    "ai/research" = {
+      source = config.lib.file.mkOutOfStoreSymlink "${aiSyncDir}/research";
+      force = true;
+    };
   };
 
   # Symlink claude directories to synced location
@@ -35,6 +67,10 @@ in
       source = config.lib.file.mkOutOfStoreSymlink "${claudeSyncDir}/plans";
       force = true;
     };
+
+    # Unified AI agent config
+    # Skills will be symlinked via dots/Makefile (shared markdown skills)
+    "ai/.keep".text = "";
   };
 
   home.packages = with pkgs; [
.gitignore
@@ -29,3 +29,4 @@ hardware-configuration.nix
 /tools/gcal-to-org/gcal-to-org
 .playwright-mcp/
 .claude/skills/
+node_modules/
globals.nix
@@ -45,6 +45,10 @@ _: {
       id = "claude-sync"; # new consolidated folder
       path = "/home/vincent/.local/share/claude-sync";
     };
+    ai-sync = {
+      id = "ai-sync"; # unified AI agent storage (sessions, plans, learnings, research)
+      path = "/home/vincent/.local/share/ai-sync";
+    };
   };
   net = {
     dns = {
@@ -186,6 +190,7 @@ _: {
           screenshots = { };
           wallpapers = { };
           claude-sync = { };
+          ai-sync = { };
           # TODO: implement paused or filter theses
           # photos = {
           #   type = "receiveonly";
@@ -223,6 +228,7 @@ _: {
           screenshots = { };
           wallpapers = { };
           claude-sync = { };
+          ai-sync = { };
           # photos = {
           #   type = "receiveonly";
           #   paused = true; # TODO: implement this, start as paused