Commit b1c1076d46b9

Vincent Demeester <vincent@sbr.pm>
2026-02-05 22:35:38
feat(ai-storage): add auto-save transcript and background recovery
Added automatic session recovery for unsaved pi sessions. On shutdown, saves conversation transcript to .pending/ directory. On next session start, spawns background process to summarize and recover using pi's configured model (prefers gemini-2.0-flash for cost efficiency). New commands: /pending-sessions, /recover-sessions for manual control. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 54c2af1
Changed files (2)
dots
pi
agent
extensions
dots/pi/agent/extensions/ai-storage/index.ts
@@ -21,10 +21,11 @@
  */
 
 import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { writeFile, mkdir, appendFile, readFile } from "node:fs/promises";
+import { writeFile, mkdir, appendFile, readFile, readdir, unlink } from "node:fs/promises";
 import { existsSync } from "node:fs";
-import { join } from "node:path";
+import { join, dirname } from "node:path";
 import { homedir, hostname } from "node:os";
+import { spawn } from "node:child_process";
 import * as chrono from "chrono-node";
 
 export default function (pi: ExtensionAPI) {
@@ -34,6 +35,10 @@ export default function (pi: ExtensionAPI) {
 	const RESEARCH_DIR = join(AI_DATA_DIR, "research");
 	const PLANS_DIR = join(AI_DATA_DIR, "plans");
 	const LEARNINGS_DIR = join(AI_DATA_DIR, "learnings");
+	const PENDING_DIR = join(AI_DATA_DIR, ".pending");
+
+	// Path to the background summarizer script (sibling to this file)
+	const SUMMARIZER_SCRIPT = join(dirname(import.meta.url.replace("file://", "")), "summarizer.ts");
 
 	// Track current session file across multiple saves
 	let currentSessionFile: string | null = null;
@@ -424,7 +429,7 @@ export default function (pi: ExtensionAPI) {
 		},
 	});
 
-	// Log session start and reset session tracking
+	// Log session start, reset session tracking, and check for pending transcripts
 	pi.on("session_start", async (_event, ctx) => {
 		try {
 			// Reset session tracking on new session
@@ -434,11 +439,91 @@ export default function (pi: ExtensionAPI) {
 			await logSessionStart();
 			// Show hint
 			ctx.ui.setStatus("session", ctx.ui.theme.fg("dim", "๐Ÿ’พ /save-session"));
+
+			// Check for pending transcripts and recover them in background
+			await recoverPendingTranscripts(ctx);
 		} catch (error) {
 			// Silent failure
 		}
 	});
 
+	// Save transcript on session shutdown if not already saved
+	pi.on("session_shutdown", async (event, ctx) => {
+		try {
+			// Skip if session was already saved manually
+			if (currentSessionFile) {
+				return;
+			}
+
+			// Get conversation messages from context
+			const messages = ctx.messages || [];
+			if (messages.length < 2) {
+				// Not enough content to save (just greeting or empty)
+				return;
+			}
+
+			// Create pending transcript
+			const transcript = {
+				savedAt: new Date().toISOString(),
+				cwd: process.cwd(),
+				host: hostname(),
+				tool: detectTool(),
+				messageCount: messages.length,
+				messages: messages.map((m: any) => ({
+					role: m.role,
+					content: typeof m.content === "string" ? m.content : JSON.stringify(m.content),
+				})),
+			};
+
+			await mkdir(PENDING_DIR, { recursive: true });
+			const filename = `${Date.now()}.json`;
+			const filepath = join(PENDING_DIR, filename);
+			await writeFile(filepath, JSON.stringify(transcript, null, 2), "utf-8");
+
+			// Log that we saved a pending transcript
+			await appendToSessionLog(`Pending transcript saved: ${filename}`);
+		} catch (error) {
+			// Silent failure - don't interrupt shutdown
+		}
+	});
+
+	// Recover pending transcripts by spawning background summarizer
+	async function recoverPendingTranscripts(ctx: any) {
+		try {
+			if (!existsSync(PENDING_DIR)) {
+				return;
+			}
+
+			const files = await readdir(PENDING_DIR);
+			const pendingFiles = files.filter((f) => f.endsWith(".json"));
+
+			if (pendingFiles.length === 0) {
+				return;
+			}
+
+			// Notify user
+			ctx.ui.notify(`๐Ÿ“ Recovering ${pendingFiles.length} unsaved session(s) in background...`, "info");
+
+			// Spawn background process for each pending file
+			for (const file of pendingFiles) {
+				const filepath = join(PENDING_DIR, file);
+
+				// Spawn summarizer as detached background process
+				const child = spawn("bun", ["run", SUMMARIZER_SCRIPT, filepath], {
+					detached: true,
+					stdio: "ignore",
+					env: {
+						...process.env,
+						AI_SESSIONS_DIR: SESSIONS_DIR,
+					},
+				});
+				child.unref();
+			}
+		} catch (error) {
+			// Silent failure
+		}
+	}
+
 	// Try to find a lighter model for session summaries
 	async function findLightModel(ctx: any): Promise<any | null> {
 		const lightModels = [
@@ -796,4 +881,98 @@ After generating the summary, use the save_session_to_history tool to save it${c
 			}
 		},
 	});
+
+	// Register /recover-sessions command for manual recovery
+	pi.registerCommand("recover-sessions", {
+		description: "Manually recover pending unsaved sessions",
+		handler: async (_args, ctx) => {
+			try {
+				if (!existsSync(PENDING_DIR)) {
+					ctx.ui.notify("No pending sessions to recover", "info");
+					return;
+				}
+
+				const files = await readdir(PENDING_DIR);
+				const pendingFiles = files.filter((f) => f.endsWith(".json"));
+
+				if (pendingFiles.length === 0) {
+					ctx.ui.notify("No pending sessions to recover", "info");
+					return;
+				}
+
+				ctx.ui.notify(`๐Ÿ”„ Recovering ${pendingFiles.length} session(s) in background...`, "info");
+
+				// Spawn background process for each pending file
+				for (const file of pendingFiles) {
+					const filepath = join(PENDING_DIR, file);
+
+					const child = spawn("bun", ["run", SUMMARIZER_SCRIPT, filepath], {
+						detached: true,
+						stdio: "ignore",
+						env: {
+							...process.env,
+							AI_SESSIONS_DIR: SESSIONS_DIR,
+						},
+					});
+					child.unref();
+				}
+
+				ctx.ui.notify(`Started recovery for ${pendingFiles.length} session(s)`, "success");
+			} catch (error) {
+				ctx.ui.notify(`Error: ${error}`, "error");
+			}
+		},
+	});
+
+	// Register /pending-sessions command to list pending transcripts
+	pi.registerCommand("pending-sessions", {
+		description: "List pending unsaved session transcripts",
+		handler: async (_args, ctx) => {
+			const theme = ctx.ui.theme;
+
+			try {
+				if (!existsSync(PENDING_DIR)) {
+					ctx.ui.notify("No pending sessions", "info");
+					return;
+				}
+
+				const files = await readdir(PENDING_DIR);
+				const pendingFiles = files.filter((f) => f.endsWith(".json"));
+
+				if (pendingFiles.length === 0) {
+					ctx.ui.notify("No pending sessions", "info");
+					return;
+				}
+
+				const header = theme.bold(`๐Ÿ“ Pending Sessions (${pendingFiles.length})`);
+				const separator = theme.fg("dim", "โ”€".repeat(40));
+
+				const fileLines = await Promise.all(
+					pendingFiles.slice(0, 10).map(async (f) => {
+						const filepath = join(PENDING_DIR, f);
+						try {
+							const content = await readFile(filepath, "utf-8");
+							const transcript = JSON.parse(content);
+							const date = transcript.savedAt?.split("T")[0] || "unknown";
+							const msgCount = transcript.messageCount || 0;
+							return `  ${theme.fg("accent", "โ€ข")} ${theme.fg("dim", date)} ${msgCount} messages\n    ${theme.fg("dim", filepath)}`;
+						} catch {
+							return `  ${theme.fg("error", "โ€ข")} ${f} (unreadable)`;
+						}
+					})
+				);
+
+				const widgetLines = [header, separator, ...fileLines];
+				if (pendingFiles.length > 10) {
+					widgetLines.push(theme.fg("warning", `  (showing 10 of ${pendingFiles.length})`));
+				}
+				widgetLines.push("", theme.fg("dim", "Use /recover-sessions to process them"));
+
+				ctx.ui.setWidget("pending-sessions", widgetLines);
+				setTimeout(() => ctx.ui.setWidget("pending-sessions", undefined), 15000);
+			} catch (error) {
+				ctx.ui.notify(`Error: ${error}`, "error");
+			}
+		},
+	});
 }
dots/pi/agent/extensions/ai-storage/summarizer.ts
@@ -0,0 +1,248 @@
+#!/usr/bin/env bun
+/**
+ * Background Session Summarizer
+ *
+ * Reads a pending transcript, summarizes it using pi's configured model,
+ * saves to sessions directory, and cleans up.
+ *
+ * Usage: bun run summarizer.ts <path-to-pending-transcript.json>
+ *
+ * Environment:
+ * - AI_SESSIONS_DIR: Where to save session summaries (optional, defaults to ~/.local/share/ai/sessions)
+ *
+ * Uses pi's own configuration for model/provider selection via `pi -p` (non-interactive mode).
+ */
+
+import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
+import { join } from "node:path";
+import { homedir } from "node:os";
+import { execSync, spawnSync } from "node:child_process";
+
+interface Transcript {
+	savedAt: string;
+	cwd: string;
+	host: string;
+	tool: string;
+	messageCount: number;
+	messages: Array<{ role: string; content: string }>;
+}
+
+// Paths
+const SESSIONS_DIR = process.env.AI_SESSIONS_DIR || join(homedir(), ".local", "share", "ai", "sessions");
+
+// Get the pending transcript path from command line
+const transcriptPath = process.argv[2];
+if (!transcriptPath) {
+	console.error("Usage: bun run summarizer.ts <path-to-pending-transcript.json>");
+	process.exit(1);
+}
+
+// Desktop notification helper
+function notify(title: string, body: string) {
+	try {
+		execSync(`notify-send -u low -t 5000 -a "ai-storage" "${title}" "${body}"`, { stdio: "ignore" });
+	} catch {
+		// Ignore notification failures
+	}
+}
+
+// Summarize using pi's configured model (non-interactive mode)
+function summarizeWithPi(transcript: Transcript): string | null {
+	const conversationText = transcript.messages
+		.slice(0, 50) // Limit to first 50 messages to avoid token limits
+		.map((m) => `${m.role.toUpperCase()}: ${m.content.slice(0, 2000)}`) // Truncate long messages
+		.join("\n\n---\n\n");
+
+	const prompt = `Summarize this AI coding session conversation into a structured markdown document.
+
+Session metadata:
+- Date: ${transcript.savedAt.split("T")[0]}
+- Time: ${transcript.savedAt.split("T")[1]?.slice(0, 5) || "unknown"}
+- Host: ${transcript.host}
+- Tool: ${transcript.tool}
+- Working directory: ${transcript.cwd}
+
+Conversation:
+${conversationText}
+
+Generate a summary in this exact format (output ONLY the markdown, no explanation):
+
+# Session: <Brief descriptive title>
+
+**Date:** ${transcript.savedAt.split("T")[0]}
+**Time:** ${transcript.savedAt.split("T")[1]?.slice(0, 5) || "unknown"}
+**Host:** ${transcript.host}
+**Tool:** ${transcript.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
+#${transcript.tool} #auto-recovered #relevant-tags
+
+---
+*This session was auto-recovered from an unsaved transcript.*`;
+
+	try {
+		// Use pi in non-interactive mode with a lightweight model preference
+		// Try gemini-2.0-flash first (fast and cheap), fall back to default
+		const result = spawnSync(
+			"pi",
+			[
+				"-p", // Non-interactive mode
+				"--provider", "google", // Prefer Google for cost
+				"--model", "gemini-2.0-flash", // Fast and cheap
+				prompt,
+			],
+			{
+				encoding: "utf-8",
+				timeout: 120000, // 2 minute timeout
+				maxBuffer: 10 * 1024 * 1024, // 10MB buffer
+				env: {
+					...process.env,
+					// Disable extensions to avoid recursion/side effects
+					PI_DISABLE_EXTENSIONS: "1",
+				},
+			}
+		);
+
+		if (result.status === 0 && result.stdout) {
+			return result.stdout.trim();
+		}
+
+		// If specific model failed, try with default config
+		console.log("Gemini flash failed, trying default model...");
+		const fallbackResult = spawnSync("pi", ["-p", prompt], {
+			encoding: "utf-8",
+			timeout: 120000,
+			maxBuffer: 10 * 1024 * 1024,
+			env: {
+				...process.env,
+				PI_DISABLE_EXTENSIONS: "1",
+			},
+		});
+
+		if (fallbackResult.status === 0 && fallbackResult.stdout) {
+			return fallbackResult.stdout.trim();
+		}
+
+		console.error("Pi summarization failed:", result.stderr || fallbackResult.stderr);
+		return null;
+	} catch (error) {
+		console.error("Pi execution error:", error);
+		return null;
+	}
+}
+
+// Generate a simple summary without AI (last resort fallback)
+function generateFallbackSummary(transcript: Transcript): string {
+	const date = transcript.savedAt.split("T")[0];
+	const time = transcript.savedAt.split("T")[1]?.slice(0, 5) || "unknown";
+
+	// Extract some context from messages
+	const userMessages = transcript.messages.filter((m) => m.role === "user");
+	const firstUserMessage = userMessages[0]?.content?.slice(0, 500) || "No user message";
+	const lastUserMessage = userMessages[userMessages.length - 1]?.content?.slice(0, 500) || "";
+
+	return `# Session: Auto-recovered session (needs review)
+
+**Date:** ${date}
+**Time:** ${time}
+**Host:** ${transcript.host}
+**Tool:** ${transcript.tool}
+
+## Summary
+This session was auto-recovered from an unsaved transcript. AI summarization failed - manual review recommended.
+
+## Context
+- **Working directory:** \`${transcript.cwd}\`
+- **Message count:** ${transcript.messageCount}
+
+## First User Message
+> ${firstUserMessage}${firstUserMessage.length >= 500 ? "..." : ""}
+
+${lastUserMessage && lastUserMessage !== firstUserMessage ? `## Last User Message\n> ${lastUserMessage}${lastUserMessage.length >= 500 ? "..." : ""}` : ""}
+
+### Tags
+#${transcript.tool} #auto-recovered #needs-review
+
+---
+*This session was auto-recovered without AI summarization. Please review and update manually.*
+`;
+}
+
+// Main
+async function main() {
+	try {
+		// Read transcript
+		const content = await readFile(transcriptPath, "utf-8");
+		const transcript: Transcript = JSON.parse(content);
+
+		console.log(`Processing transcript: ${transcriptPath}`);
+		console.log(`  Messages: ${transcript.messageCount}, Host: ${transcript.host}`);
+
+		// Try to summarize with pi
+		let summary = summarizeWithPi(transcript);
+		let usedFallback = false;
+
+		if (!summary) {
+			console.log("AI summarization failed, using fallback template");
+			summary = generateFallbackSummary(transcript);
+			usedFallback = true;
+		}
+
+		// Extract title from summary for filename
+		const titleMatch = summary.match(/^# Session: (.+)$/m);
+		const title = titleMatch?.[1] || "auto-recovered-session";
+		const slug = title
+			.toLowerCase()
+			.replace(/[^a-z0-9]+/g, "-")
+			.replace(/^-+|-+$/g, "")
+			.substring(0, 60);
+
+		// Determine output path
+		const date = transcript.savedAt.split("T")[0];
+		const yearMonth = date.slice(0, 7);
+		const sessionDir = join(SESSIONS_DIR, yearMonth);
+		const filename = `${date}-${slug}.md`;
+		const filepath = join(sessionDir, filename);
+
+		// Save summary
+		await mkdir(sessionDir, { recursive: true });
+		await writeFile(filepath, summary, "utf-8");
+
+		// Delete pending file
+		await unlink(transcriptPath);
+
+		// Notify user
+		const notifyMsg = usedFallback ? `Recovered (needs review): ${filename}` : `Recovered: ${filename}`;
+		notify("Session Recovered", notifyMsg);
+
+		console.log(`โœ“ Recovered session: ${filepath}`);
+	} catch (error) {
+		console.error("Failed to recover session:", error);
+		notify("Session Recovery Failed", String(error));
+		process.exit(1);
+	}
+}
+
+main();