flake-update-20260505
  1#!/usr/bin/env bun
  2/**
  3 * Background Session Summarizer
  4 *
  5 * Reads a pending transcript, summarizes it using pi's configured model,
  6 * saves to sessions directory, and cleans up.
  7 *
  8 * Usage: bun run summarizer.ts <path-to-pending-transcript.json>
  9 *
 10 * Environment:
 11 * - AI_SESSIONS_DIR: Where to save session summaries (optional, defaults to ~/.local/share/ai/sessions)
 12 *
 13 * Uses pi's own configuration for model/provider selection via `pi -p` (non-interactive mode).
 14 */
 15
 16import { readFile, writeFile, mkdir, unlink, stat } from "node:fs/promises";
 17import { existsSync } from "node:fs";
 18import { join } from "node:path";
 19import { homedir } from "node:os";
 20import { execSync, spawnSync } from "node:child_process";
 21
 22interface Transcript {
 23	savedAt: string;
 24	cwd: string;
 25	host: string;
 26	tool: string;
 27	messageCount: number;
 28	messages: Array<{ role: string; content: string }>;
 29}
 30
 31// Paths
 32const SESSIONS_DIR = process.env.AI_SESSIONS_DIR || join(homedir(), ".local", "share", "ai", "sessions");
 33
 34// Get the pending transcript path from command line
 35const transcriptPath = process.argv[2];
 36if (!transcriptPath) {
 37	console.error("Usage: bun run summarizer.ts <path-to-pending-transcript.json>");
 38	process.exit(1);
 39}
 40
 41// Desktop notification helper
 42function notify(title: string, body: string) {
 43	try {
 44		execSync(`notify-send -u low -t 5000 -a "ai-storage" "${title}" "${body}"`, { stdio: "ignore" });
 45	} catch {
 46		// Ignore notification failures
 47	}
 48}
 49
 50// Summarize using pi's configured model (non-interactive mode)
 51function summarizeWithPi(transcript: Transcript): string | null {
 52	const conversationText = transcript.messages
 53		.slice(0, 50) // Limit to first 50 messages to avoid token limits
 54		.filter((m) => m.content) // Skip messages with undefined content
 55		.map((m) => `${m.role.toUpperCase()}: ${m.content.slice(0, 2000)}`) // Truncate long messages
 56		.join("\n\n---\n\n");
 57
 58	// Try to detect git remote from the working directory
 59	let gitRemote = "";
 60	try {
 61		gitRemote = execSync(`git -C "${transcript.cwd}" remote get-url origin 2>/dev/null`, {
 62			encoding: "utf-8",
 63			timeout: 3000,
 64		}).trim();
 65	} catch {
 66		// Not a git repo or no remote
 67	}
 68
 69	const prompt = `Based on this AI coding session, generate a brief, descriptive title of 3-5 words.
 70Then, create a structured markdown summary.
 71
 72Session metadata:
 73- Date: ${transcript.savedAt.split("T")[0]}
 74- Time: ${transcript.savedAt.split("T")[1]?.slice(0, 5) || "unknown"}
 75- Host: ${transcript.host}
 76- Tool: ${transcript.tool}
 77- Working directory: ${transcript.cwd}
 78${gitRemote ? `- Git remote: ${gitRemote}` : ""}
 79
 80Conversation:
 81${conversationText}
 82
 83IMPORTANT: The metadata header MUST include **Project:** — this is REQUIRED.
 84Infer the project name as org/repo (e.g. "tektoncd/pipeline", "vdemeester/chisel") from the git remote, working directory, or conversation context.
 85If working in a git worktree, use the real project name, NOT the worktree path.
 86
 87Output ONLY the markdown, with this exact format:
 88
 89# Session: <3-5 Word Title>
 90
 91**Date:** ${transcript.savedAt.split("T")[0]}
 92**Time:** ${transcript.savedAt.split("T")[1]?.slice(0, 5) || "unknown"}
 93**Host:** ${transcript.host}
 94**Tool:** ${transcript.tool}
 95**Project:** <REQUIRED: org/repo or ~/path>
 96**Directory:** ${transcript.cwd}
 97
 98## Summary
 99Brief description of what was accomplished.
100
101## What Was Accomplished
102- Task 1
103- Task 2
104
105## Files Changed
106- \`path/to/file\` - Description of change
107
108## Commands Run
109\`\`\`bash
110# Key commands executed
111\`\`\`
112
113## Outcome
114Result of the session.
115
116## Next Steps
117- [ ] TODO 1
118- [ ] TODO 2
119
120### Tags
121#${transcript.tool} #auto-recovered #relevant-tags
122
123---
124*This session was auto-recovered from an unsaved transcript.*`;
125
126	try {
127		// Use pi in non-interactive mode with a lightweight model preference
128		// Try github-copilot first (already authenticated via gh CLI), fall back to default
129		const result = spawnSync(
130			"pi",
131			[
132				"-p", // Non-interactive mode
133				"--no-extensions", // CRITICAL: prevent recursive summarization
134				"--provider", "github-copilot",
135				"--model", "gpt-5-mini", // Fast and cheap
136				prompt,
137			],
138			{
139				encoding: "utf-8",
140				timeout: 120000, // 2 minute timeout
141				maxBuffer: 10 * 1024 * 1024, // 10MB buffer
142				env: {
143					...process.env,
144				},
145
146			}
147		);
148
149		console.log("Ran pi with github-copilot/gpt-5-mini, status:", result.status);
150
151		if (result.status === 0 && result.stdout) {
152			return result.stdout.trim();
153		}
154
155		console.log("github-copilot/gpt-5-mini failed, trying default model...");
156		console.log("Stderr from gpt-5-mini attempt:", result.stderr);
157
158		// If specific model failed, try with default config
159		const fallbackResult = spawnSync("pi", ["-p", "--no-extensions", prompt], {
160			encoding: "utf-8",
161			timeout: 120000,
162			maxBuffer: 10 * 1024 * 1024,
163		});
164
165		console.log("Ran pi with default model, status:", fallbackResult.status);
166
167		if (fallbackResult.status === 0 && fallbackResult.stdout) {
168			return fallbackResult.stdout.trim();
169		}
170
171		console.error("Pi summarization failed:", result.stderr || fallbackResult.stderr);
172		console.error("Stderr from default model attempt:", fallbackResult.stderr);
173		return null;
174	} catch (error) {
175		console.error("Pi execution error:", error);
176		return null;
177	}
178}
179
180// Generate a simple summary without AI (last resort fallback)
181function generateFallbackSummary(transcript: Transcript): string {
182	const date = transcript.savedAt.split("T")[0];
183	const time = transcript.savedAt.split("T")[1]?.slice(0, 5) || "unknown";
184
185	// Extract some context from messages
186	const userMessages = transcript.messages.filter((m) => m.role === "user");
187	const firstUserMessage = userMessages[0]?.content?.slice(0, 500) || "No user message";
188	const lastUserMessage = userMessages[userMessages.length - 1]?.content?.slice(0, 500) || "";
189
190	return `# Session: Auto-recovered session (needs review)
191
192**Date:** ${date}
193**Time:** ${time}
194**Host:** ${transcript.host}
195**Tool:** ${transcript.tool}
196**Directory:** ${transcript.cwd}
197
198## Summary
199This session was auto-recovered from an unsaved transcript. AI summarization failed - manual review recommended.
200
201## Context
202- **Message count:** ${transcript.messageCount}
203
204## First User Message
205> ${firstUserMessage}${firstUserMessage.length >= 500 ? "..." : ""}
206
207${lastUserMessage && lastUserMessage !== firstUserMessage ? `## Last User Message\n> ${lastUserMessage}${lastUserMessage.length >= 500 ? "..." : ""}` : ""}
208
209### Tags
210#${transcript.tool} #auto-recovered #needs-review
211
212---
213*This session was auto-recovered without AI summarization. Please review and update manually.*
214`;
215}
216
217// Strip terminal control codes (OSC sequences, ANSI escapes, etc.)
218function stripControlCodes(text: string): string {
219	return text
220		// Remove OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL)
221		// Pattern: \x1b] followed by anything until \x1b\ or \x07
222		.replace(/\x1b\][^\x07\x1b]*(?:\x1b\\|\x07)/g, "")
223		// Also handle bare ] sequences without leading ESC (seen in output)
224		.replace(/\][0-9;:=\w]+(?:\\|\x1b\\)/g, "")
225		// Remove ANSI escape sequences (CSI)
226		.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
227		.trim();
228}
229
230// Check if a PID is still alive
231function isPidAlive(pid: number): boolean {
232	try {
233		process.kill(pid, 0); // Signal 0 = just check existence
234		return true;
235	} catch {
236		return false;
237	}
238}
239
240// Check if a lock file is stale (process dead or lock older than maxAge)
241const LOCK_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
242
243async function isStaleLock(lockFile: string): Promise<boolean> {
244	try {
245		const content = await readFile(lockFile, "utf-8");
246		const pid = parseInt(content.trim(), 10);
247
248		// If PID is not alive, lock is stale
249		if (!isNaN(pid) && !isPidAlive(pid)) {
250			console.log(`Stale lock detected: PID ${pid} is dead`);
251			return true;
252		}
253
254		// If lock is older than max age, consider it stale (process may be hung)
255		const lockStat = await stat(lockFile);
256		const ageMs = Date.now() - lockStat.mtimeMs;
257		if (ageMs > LOCK_MAX_AGE_MS) {
258			console.log(`Stale lock detected: lock is ${Math.round(ageMs / 1000)}s old (max ${LOCK_MAX_AGE_MS / 1000}s)`);
259			return true;
260		}
261
262		return false;
263	} catch {
264		// Can't read lock file - treat as stale
265		return true;
266	}
267}
268
269// Main
270async function main() {
271	const lockFile = `${transcriptPath}.lock`;
272	
273	try {
274		// Check if already being processed
275		if (existsSync(lockFile)) {
276			if (await isStaleLock(lockFile)) {
277				console.log(`Removing stale lock: ${lockFile}`);
278				await unlink(lockFile).catch(() => {});
279			} else {
280				console.log(`Already being processed (lock exists, PID alive): ${transcriptPath}`);
281				process.exit(0);
282			}
283		}
284		
285		// Check if pending file still exists
286		if (!existsSync(transcriptPath)) {
287			console.log(`Pending file already processed: ${transcriptPath}`);
288			process.exit(0);
289		}
290		
291		// Create lock file
292		await writeFile(lockFile, String(process.pid), "utf-8");
293		
294		// Read transcript
295		const content = await readFile(transcriptPath, "utf-8");
296		const transcript: Transcript = JSON.parse(content);
297
298		console.log(`Processing transcript: ${transcriptPath}`);
299		console.log(`  Messages: ${transcript.messageCount}, Host: ${transcript.host}`);
300
301		// Try to summarize with pi
302		let summary = summarizeWithPi(transcript);
303		let usedFallback = false;
304
305		if (!summary) {
306			console.log("AI summarization failed, using fallback template");
307			summary = generateFallbackSummary(transcript);
308			usedFallback = true;
309		}
310
311		// Strip terminal control codes from output
312		summary = stripControlCodes(summary);
313
314		// Extract title from summary for filename
315		const titleMatch = summary.match(/^# Session: (.+)$/m);
316		const title = titleMatch?.[1] || "auto-recovered-session";
317		const slug = title
318			.toLowerCase()
319			.replace(/[^a-z0-9]+/g, "-")
320			.replace(/^-+|-+$/g, "")
321			.substring(0, 60);
322
323		// Determine output path
324		const date = transcript.savedAt.split("T")[0];
325		const yearMonth = date.slice(0, 7);
326		const sessionDir = join(SESSIONS_DIR, yearMonth);
327		
328		// Find unique filename (add suffix if needed)
329		await mkdir(sessionDir, { recursive: true });
330		let filename = `${date}-${slug}.md`;
331		let filepath = join(sessionDir, filename);
332		let suffix = 1;
333		
334		while (existsSync(filepath)) {
335			filename = `${date}-${slug}-${suffix}.md`;
336			filepath = join(sessionDir, filename);
337			suffix++;
338		}
339
340		// Save summary
341		await writeFile(filepath, summary, "utf-8");
342
343		// Delete pending file, lock, and log file
344		await unlink(transcriptPath);
345		await unlink(lockFile).catch(() => {});
346		await unlink(`${transcriptPath}.log`).catch(() => {});
347
348		// Notify user
349		const notifyMsg = usedFallback ? `Recovered (needs review): ${filename}` : `Recovered: ${filename}`;
350		notify("Session Recovered", notifyMsg);
351
352		console.log(`✓ Recovered session: ${filepath}`);
353	} catch (error) {
354		// Clean up lock file on error
355		await unlink(lockFile).catch(() => {});
356		console.error("Failed to recover session:", error);
357		notify("Session Recovery Failed", String(error));
358		process.exit(1);
359	}
360}
361
362main();