Commit b1c1076d46b9
Changed files (2)
dots
pi
agent
extensions
ai-storage
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();