Commit 992b2ec9f00a

Vincent Demeester <vincent@sbr.pm>
2026-02-13 09:27:03
feat(ai-storage): add session search tools and refactor
Added list_saved_sessions and read_saved_session as AI-invokable tools so the model can search curated session summaries directly. Extracted shared helpers (searchSessionsByQuery, listSessionsByDateRange) to deduplicate logic between tools and /list-sessions command. Updated AGENTS.md to guide session lookup toward saved sessions over raw JSONL threads.
1 parent f50d485
Changed files (2)
dots
pi
agent
extensions
ai-storage
dots/pi/agent/extensions/ai-storage/index.ts
@@ -4,12 +4,16 @@
  * Unified storage for AI-generated artifacts across all AI coding tools.
  * Auto-detects the tool being used (pi, claude, copilot, cursor, etc.)
  *
- * Features:
+ * AI-invokable tools:
+ * - save_session_to_history: Save conversation summaries
+ * - save_research, save_plan, save_learning: Save various documents
+ * - list_saved_sessions: Search and list past sessions by date/content
+ * - read_saved_session: Read full session markdown content
+ *
+ * User commands:
  * - /save-session: Generate and save conversation summaries
  * - /session-log: View today's session activity
  * - /list-sessions: Browse and search session history
- * - Automatic saving of research, plans, and learnings via AI tools
- * - Auto-logs session starts
  *
  * Storage locations (XDG-compliant):
  * - Sessions: ~/.local/share/ai/sessions/YYYY-MM/*.md
@@ -450,6 +454,216 @@ export default function (pi: ExtensionAPI) {
 		},
 	});
 
+	// ========================================================================
+	// Shared session search helpers (used by both tools and commands)
+	// ========================================================================
+
+	interface SessionFile {
+		date: string;
+		file: string;
+		path: string;
+		desc: string;
+	}
+
+	/** Search sessions by content using ripgrep. Returns matching file paths. */
+	async function searchSessionsByQuery(query: string, limit: number = 20): Promise<SessionFile[]> {
+		const { execSync } = await import("node:child_process");
+		const rgResult = execSync(
+			`rg -l -i "${query.replace(/"/g, '\\"')}" "${SESSIONS_DIR}" 2>/dev/null || true`,
+			{ encoding: "utf-8", maxBuffer: 1024 * 1024 }
+		).trim();
+
+		if (!rgResult) return [];
+
+		return rgResult
+			.split("\n")
+			.filter(Boolean)
+			.slice(0, limit)
+			.map((filepath) => {
+				const filename = filepath.split("/").pop() || "";
+				const date = filename.slice(0, 10);
+				const desc = filename.slice(11).replace(".md", "").replace(/-/g, " ");
+				return { date, file: filename, path: filepath, desc };
+			});
+	}
+
+	/** List sessions by date range. Returns matching files sorted newest-first. */
+	async function listSessionsByDateRange(
+		dateRange: string,
+		limit: number = 20,
+	): Promise<{ files: SessionFile[]; total: number; label: string } | null> {
+		const dateSpec = parseDateSpec(dateRange);
+		if (!dateSpec) return null;
+
+		const { start, end, label } = dateSpec;
+		const dates = getDatesInRange(start, end);
+
+		const allFiles: SessionFile[] = [];
+		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 dirFiles = await readdir(sessionDir);
+			for (const f of dirFiles) {
+				if (!f.endsWith(".md")) continue;
+				const fileDate = f.slice(0, 10);
+				if (dates.includes(fileDate)) {
+					const desc = f.slice(11).replace(".md", "").replace(/-/g, " ");
+					allFiles.push({ date: fileDate, file: f, path: join(sessionDir, f), desc });
+				}
+			}
+		}
+
+		allFiles.sort((a, b) => b.file.localeCompare(a.file));
+		return { files: allFiles.slice(0, limit), total: allFiles.length, label };
+	}
+
+	// Tool to list/search saved sessions
+	pi.registerTool({
+		name: "list_saved_sessions",
+		label: "List Saved Sessions",
+		description:
+			"Search and list saved session summaries from ~/.local/share/ai/sessions/. Use for finding past work by date or content. Sessions are curated markdown summaries with context and learnings.",
+		parameters: {
+			type: "object",
+			properties: {
+				query: {
+					type: "string",
+					description: "Optional search query to filter sessions by content (uses ripgrep). If omitted, lists recent sessions.",
+				},
+				dateRange: {
+					type: "string",
+					description: "Date range: 'today', 'yesterday', 'last week', 'last 7 days', 'YYYY-MM-DD', or 'YYYY-MM-DD..YYYY-MM-DD'. Default: 'last 7 days'",
+				},
+				limit: {
+					type: "number",
+					description: "Maximum number of sessions to return (default: 20)",
+				},
+			},
+			required: [],
+		},
+		async execute(toolCallId, params, signal, onUpdate, ctx) {
+			try {
+				const { query, dateRange = "last 7 days", limit = 20 } = params;
+
+				if (query) {
+					const files = await searchSessionsByQuery(query, limit);
+					if (files.length === 0) {
+						return {
+							content: [{ type: "text", text: `No sessions found matching "${query}"` }],
+							details: { count: 0 },
+						};
+					}
+
+					const lines = [`Found ${files.length} session(s) matching "${query}":\n`];
+					for (const { date, desc, path } of files) {
+						lines.push(`- **${date}**: ${desc}`);
+						lines.push(`  Path: ${path}`);
+					}
+					if (files.length === limit) lines.push(`\n(showing first ${limit} results)`);
+
+					return {
+						content: [{ type: "text", text: lines.join("\n") }],
+						details: { count: files.length, files: files.map(f => f.path) },
+					};
+				}
+
+				const result = await listSessionsByDateRange(dateRange, limit);
+				if (!result) {
+					return {
+						content: [{ type: "text", text: `Invalid date range: ${dateRange}. Use: today, yesterday, last week, last N days, YYYY-MM-DD, or YYYY-MM-DD..YYYY-MM-DD` }],
+						details: { error: "invalid_date_range" },
+					};
+				}
+
+				const { files, total, label } = result;
+				if (total === 0) {
+					return {
+						content: [{ type: "text", text: `No sessions found for ${label}` }],
+						details: { count: 0 },
+					};
+				}
+
+				const lines = [`Found ${total} session(s) for ${label}:\n`];
+				for (const { date, desc, path } of files) {
+					lines.push(`- **${date}**: ${desc}`);
+					lines.push(`  Path: ${path}`);
+				}
+				if (total > limit) lines.push(`\n(showing ${limit} of ${total})`);
+
+				return {
+					content: [{ type: "text", text: lines.join("\n") }],
+					details: { count: total, files: files.map(f => f.path) },
+				};
+			} catch (error) {
+				return {
+					content: [{ type: "text", text: `Error listing sessions: ${error}` }],
+					details: { error: String(error) },
+				};
+			}
+		},
+	});
+
+	// Tool to read a saved session
+	pi.registerTool({
+		name: "read_saved_session",
+		label: "Read Saved Session",
+		description:
+			"Read the full content of a saved session summary. Use the file path from list_saved_sessions.",
+		parameters: {
+			type: "object",
+			properties: {
+				path: {
+					type: "string",
+					description: "Full file path to the session markdown file (from list_saved_sessions)",
+				},
+			},
+			required: ["path"],
+		},
+		async execute(toolCallId, params, signal, onUpdate, ctx) {
+			try {
+				const { path: filepath } = params;
+
+				if (!existsSync(filepath)) {
+					return {
+						content: [
+							{
+								type: "text",
+								text: `Session file not found: ${filepath}`,
+							},
+						],
+						details: { error: "file_not_found" },
+					};
+				}
+
+				const content = await readFile(filepath, "utf-8");
+				const filename = filepath.split("/").pop() || "";
+
+				return {
+					content: [
+						{
+							type: "text",
+							text: `# ${filename}\n\n${content}`,
+						},
+					],
+					details: { path: filepath },
+				};
+			} catch (error) {
+				return {
+					content: [
+						{
+							type: "text",
+							text: `Error reading session: ${error}`,
+						},
+					],
+					details: { error: String(error) },
+				};
+			}
+		},
+	});
+
 	// Log session start, reset session tracking, and check for pending transcripts
 	pi.on("session_start", async (_event, ctx) => {
 		try {
@@ -759,12 +973,30 @@ After generating the summary, use the save_session_to_history tool to save it${c
 		return dates;
 	}
 
-	// Register /list-sessions command
+	// Register /list-sessions command (uses shared helpers)
 	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 argStr = (args || "").trim();
 
+			/** Format session files as markdown for the custom renderer */
+			function formatSessionList(title: string, files: SessionFile[], total: number, limit: number): string {
+				const lines: string[] = [];
+				lines.push(`## ๐Ÿ“‹ ${title}`);
+				lines.push("");
+				lines.push(`*${total} result(s)*`);
+				lines.push("");
+				for (const { date, desc, path } of files) {
+					lines.push(`- **${date}** ${desc}`);
+					lines.push(`  \`${path}\``);
+				}
+				if (total > limit) {
+					lines.push("");
+					lines.push(`*(showing ${limit} of ${total})*`);
+				}
+				return lines.join("\n");
+			}
+
 			// Check for search mode
 			if (argStr.toLowerCase().startsWith("search ")) {
 				const query = argStr.slice(7).trim();
@@ -774,43 +1006,14 @@ After generating the summary, use the save_session_to_history tool to save it${c
 				}
 
 				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) {
+					const files = await searchSessionsByQuery(query, 20);
+					if (files.length === 0) {
 						ctx.ui.notify(`No sessions found matching "${query}"`, "info");
 						return;
 					}
-
-					const files = rgResult.split("\n").filter(Boolean).slice(0, 20); // Limit to 20 results
-
-					// Build markdown content
-					const lines: string[] = [];
-					lines.push(`## ๐Ÿ” Sessions matching "${query}"`);
-					lines.push("");
-					lines.push(`*${files.length} result(s)*`);
-					lines.push("");
-
-					for (const filepath of files) {
-						const filename = filepath.split("/").pop() || "";
-						const date = filename.slice(0, 10);
-						const desc = filename.slice(11).replace(".md", "").replace(/-/g, " ");
-						lines.push(`- **${date}** ${desc}`);
-						lines.push(`  \`${filepath}\``);
-					}
-
-					if (files.length === 20) {
-						lines.push("");
-						lines.push("*(showing first 20 results)*");
-					}
-
 					pi.sendMessage({
 						customType: "ai-storage-sessions",
-						content: lines.join("\n"),
+						content: formatSessionList(`Sessions matching "${query}"`, files, files.length, 20),
 						display: true,
 					});
 				} catch (error) {
@@ -819,73 +1022,26 @@ After generating the summary, use the save_session_to_history tool to save it${c
 				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);
-
+			// Date-based listing (default to today)
 			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),
-							});
-						}
-					}
+				const result = await listSessionsByDateRange(argStr || "today", 30);
+				if (!result) {
+					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;
 				}
 
-				allFiles.sort((a, b) => b.file.localeCompare(a.file)); // Newest first
-
-				if (allFiles.length === 0) {
+				const { files, total, label } = result;
+				if (total === 0) {
 					ctx.ui.notify(`No sessions found for ${label}`, "info");
 					return;
 				}
 
-				// Build markdown content
-				const lines: string[] = [];
-				lines.push(`## ๐Ÿ“‹ Sessions - ${label}`);
-				lines.push("");
-				lines.push(`*${allFiles.length} session(s)*`);
-				lines.push("");
-
-				const displayFiles = allFiles.slice(0, 30);
-				for (const { date, file, path } of displayFiles) {
-					const desc = file.slice(11).replace(".md", "").replace(/-/g, " ");
-					lines.push(`- **${date}** ${desc}`);
-					lines.push(`  \`${path}\``);
-				}
-
-				if (allFiles.length > 30) {
-					lines.push("");
-					lines.push(`*(showing 30 of ${allFiles.length})*`);
-				}
-
 				pi.sendMessage({
 					customType: "ai-storage-sessions",
-					content: lines.join("\n"),
+					content: formatSessionList(`Sessions - ${label}`, files, total, 30),
 					display: true,
 				});
 			} catch (error) {
dots/pi/agent/AGENTS.md
@@ -34,6 +34,16 @@ Skills from `~/.config/claude/skills/` are compatible with pi. Key skills:
 - **Analysis vs Action:** If asked to analyze, do analysis only
 - **Org files:** NEVER edit `.org` files directly โ€” use `emacsclient` or `org_todo` tool
 
+## Session History and Search
+
+When users refer to past work, choose the appropriate tool:
+
+- **Saved sessions** (past tense: "yesterday's session", "last week's work"): Use `list_saved_sessions` and `read_saved_session` tools to search curated markdown summaries in `~/.local/share/ai/sessions/`. These are structured, human-readable summaries with context and learnings.
+
+- **Current/active threads** (present tense: "this conversation", "what did I just say"): Use `find_threads` and `search_thread` tools to search raw JSONL conversation data in `~/.pi/agent/sessions/`. Useful for debugging or finding exact conversation details.
+
+Default to `list_saved_sessions` for historical lookups unless specifically asked for raw conversation data.
+
 ## Response Patterns
 
 1. **Understand**: Clarify the task and requirements