flake-update-20260505
   1/**
   2 * AI Storage Extension for Pi
   3 *
   4 * Unified storage for AI-generated artifacts across all AI coding tools.
   5 * Auto-detects the tool being used (pi, claude, copilot, cursor, etc.)
   6 *
   7 * AI-invokable tools:
   8 * - save_session_to_history: Save conversation summaries
   9 * - save_research, save_plan, save_learning: Save various documents
  10 * - list_saved_sessions: Search and list past sessions by date/content
  11 * - read_saved_session: Read full session markdown content
  12 *
  13 * User commands:
  14 * - /save-session: Generate and save conversation summaries
  15 * - /session-log: View today's session activity
  16 * - /list-sessions: Browse and search session history
  17 *
  18 * Storage locations (XDG-compliant):
  19 * - Sessions: ~/.local/share/ai/sessions/YYYY-MM/*.md
  20 * - Research: ~/.local/share/ai/research/YYYY-MM/*.md
  21 * - Plans: ~/.local/share/ai/plans/*.md (no date prefix, timeless)
  22 * - Learnings: ~/.local/share/ai/learnings/YYYY-MM/*.md
  23 *
  24 * Compatible with Claude Code, pi, opencode, cursor, and other AI coding tools.
  25 */
  26
  27import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
  28import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
  29import { Box, Markdown, matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
  30import { Type } from "@sinclair/typebox";
  31import { writeFile, mkdir, appendFile, readFile, readdir, unlink, stat } from "node:fs/promises";
  32import { existsSync, openSync, readdirSync } from "node:fs";
  33import { join, dirname } from "node:path";
  34import { homedir, hostname } from "node:os";
  35import { spawn } from "node:child_process";
  36import * as chrono from "chrono-node";
  37
  38export default function (pi: ExtensionAPI) {
  39	// Unified AI agent storage (XDG_DATA_HOME/ai)
  40	const AI_DATA_DIR = join(homedir(), ".local", "share", "ai");
  41	const SESSIONS_DIR = join(AI_DATA_DIR, "sessions");
  42	const RESEARCH_DIR = join(AI_DATA_DIR, "research");
  43	const PLANS_DIR = join(AI_DATA_DIR, "plans");
  44	const LEARNINGS_DIR = join(AI_DATA_DIR, "learnings");
  45	const PENDING_DIR = join(AI_DATA_DIR, ".pending");
  46
  47	// Register custom message renderer for session listings
  48	pi.registerMessageRenderer("ai-storage-sessions", (message, { expanded }, theme) => {
  49		const mdTheme = getMarkdownTheme();
  50		// Use toolSuccessBg for a distinct background (blueish/greenish depending on theme)
  51		const box = new Box(1, 1, (t) => theme.bg("toolSuccessBg", t));
  52		const content = typeof message.content === "string" ? message.content : "";
  53		box.addChild(new Markdown(content, 0, 0, mdTheme));
  54		return box;
  55	});
  56
  57	// Path to the background summarizer script (sibling to this file)
  58	const SUMMARIZER_SCRIPT = join(dirname(import.meta.url.replace("file://", "")), "summarizer.ts");
  59
  60	// Track current session file across multiple saves
  61	let currentSessionFile: string | null = null;
  62	let currentSessionDescription: string | null = null;
  63	// Track model to restore after session save
  64	let modelToRestore: any | null = null;
  65
  66	/**
  67	 * Detect which tool is being used
  68	 */
  69	function detectTool(): string {
  70		// Check environment variables
  71		if (process.env.CLAUDE_PROJECT_DIR || process.env.CLAUDE_AGENT_TYPE) {
  72			return "claude";
  73		}
  74		if (process.env.PI_VERSION || process.env.PI_PROJECT_DIR) {
  75			return "pi";
  76		}
  77		// Check if running under specific tools
  78		const execPath = process.argv0 || "";
  79		if (execPath.includes("copilot")) {
  80			return "copilot";
  81		}
  82		if (execPath.includes("cursor")) {
  83			return "cursor";
  84		}
  85		if (execPath.includes("pi")) {
  86			return "pi";
  87		}
  88		if (execPath.includes("claude")) {
  89			return "claude";
  90		}
  91
  92		// Default to pi since this is a pi extension
  93		return "pi";
  94	}
  95
  96	function getDateInfo() {
  97		const now = new Date();
  98		const year = now.getFullYear();
  99		const month = String(now.getMonth() + 1).padStart(2, "0");
 100		const day = String(now.getDate()).padStart(2, "0");
 101		const hours = String(now.getHours()).padStart(2, "0");
 102		const minutes = String(now.getMinutes()).padStart(2, "0");
 103		const seconds = String(now.getSeconds()).padStart(2, "0");
 104
 105		return {
 106			yearMonth: `${year}-${month}`,
 107			date: `${year}-${month}-${day}`,
 108			timestamp: `${year}-${month}-${day}T${hours}:${minutes}:${seconds}+01:00`,
 109			time: `${hours}:${minutes}`,
 110		};
 111	}
 112
 113	/** Session-log filename includes hostname to avoid syncthing conflicts
 114	 *  when multiple machines append to the same daily log file. */
 115	function sessionLogFile(sessionDir: string, date: string): string {
 116		return join(sessionDir, `${date}_session-log_${hostname()}.txt`);
 117	}
 118
 119	async function logSessionStart() {
 120		const tool = detectTool();
 121		const { yearMonth, date, timestamp } = getDateInfo();
 122		const sessionDir = join(SESSIONS_DIR, yearMonth);
 123		const logFile = sessionLogFile(sessionDir, date);
 124
 125		await mkdir(sessionDir, { recursive: true });
 126		await appendFile(logFile, `${timestamp} - Session started (${tool})\n`);
 127	}
 128
 129	async function appendToSessionLog(message: string) {
 130		const { yearMonth, date, timestamp } = getDateInfo();
 131		const sessionDir = join(SESSIONS_DIR, yearMonth);
 132		const logFile = sessionLogFile(sessionDir, date);
 133
 134		await mkdir(sessionDir, { recursive: true });
 135		await appendFile(logFile, `${timestamp} - ${message}\n`);
 136	}
 137
 138	/**
 139	 * Inject a **Session:** link into the markdown content metadata block.
 140	 * Inserts it after **Directory:** if found, otherwise appends after the
 141	 * last **Key:** metadata line. This lets you trace any summary back to
 142	 * the raw JSONL for a full `/export` later.
 143	 */
 144	function injectSessionLink(content: string, sessionFile: string | null): string {
 145		if (!sessionFile) return content;
 146
 147		// Don't duplicate if already present
 148		if (content.includes("**Session:**")) return content;
 149
 150		// Try to insert after **Directory:** line
 151		const directoryPattern = /^(\*\*Directory:\*\*.*)$/m;
 152		if (directoryPattern.test(content)) {
 153			return content.replace(directoryPattern, `$1\n**Session:** \`${sessionFile}\``);
 154		}
 155
 156		// Fallback: insert after the last **Key:** metadata line in the header
 157		const metadataPattern = /^(\*\*\w[^*]*\*\*:.*)$/gm;
 158		let lastMatch: RegExpExecArray | null = null;
 159		let match: RegExpExecArray | null;
 160		while ((match = metadataPattern.exec(content)) !== null) {
 161			lastMatch = match;
 162		}
 163		if (lastMatch) {
 164			const insertPos = lastMatch.index + lastMatch[0].length;
 165			return content.slice(0, insertPos) + `\n**Session:** \`${sessionFile}\`` + content.slice(insertPos);
 166		}
 167
 168		return content;
 169	}
 170
 171	// Internal command to actually save the session (called by AI after generating summary)
 172	pi.registerTool({
 173		name: "save_session_to_history",
 174		label: "Save Session to History",
 175		description:
 176			"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. Content MUST include **Project:** and **Directory:** metadata fields.",
 177		parameters: Type.Object({
 178			description: Type.String({ description: "Brief description for the filename (e.g., 'added-hostname-extension')" }),
 179			content: Type.String({ description: "Full markdown content. MUST include metadata header with **Date:**, **Tool:**, **Project:** (org/repo or path), and **Directory:** (working directory) fields for proper categorization." }),
 180		}),
 181		async execute(toolCallId, params, signal, onUpdate, ctx) {
 182			// Helper to restore model after save
 183			const restoreModel = async () => {
 184				if (modelToRestore) {
 185					await pi.setModel(modelToRestore);
 186					ctx.ui?.notify?.(`Restored model: ${modelToRestore.name || modelToRestore.id}`, "info");
 187					modelToRestore = null;
 188				}
 189			};
 190
 191			try {
 192				const { description, content: rawContent } = params;
 193				const { yearMonth, date, time } = getDateInfo();
 194				const sessionDir = join(SESSIONS_DIR, yearMonth);
 195
 196				// Inject raw session file link into the content metadata
 197				const sessionFile = ctx.sessionManager?.getSessionFile?.() || null;
 198				const content = injectSessionLink(rawContent, sessionFile);
 199
 200				// Create directory
 201				await mkdir(sessionDir, { recursive: true });
 202
 203				// If we already saved this session, always update the same file
 204				// (ignore new description from AI)
 205				let filepath: string;
 206				let filename: string;
 207				
 208				if (currentSessionFile) {
 209					filepath = currentSessionFile;
 210					filename = filepath.split("/").pop() || "";
 211				} else {
 212					// Sanitize filename for new session
 213					const slug = description
 214						.toLowerCase()
 215						.replace(/[^a-z0-9]+/g, "-")
 216						.replace(/^-+|-+$/g, "")
 217						.substring(0, 60);
 218
 219					filename = `${date}-${slug}.md`;
 220					filepath = join(sessionDir, filename);
 221				}
 222
 223				// Check if we're updating the current session or creating a new one
 224				const isUpdate = currentSessionFile !== null;
 225
 226				if (isUpdate) {
 227					// Read existing content
 228					const existingContent = await readFile(filepath, "utf-8");
 229
 230					// Check if we should append or replace
 231					// If content has changed significantly, replace; otherwise append
 232					if (content.length > existingContent.length * 1.2 || content.includes("## Update")) {
 233						// Replace with new version
 234						await writeFile(filepath, content, "utf-8");
 235						await appendToSessionLog(`Session updated: ${filename}`);
 236						await restoreModel();
 237
 238						return {
 239							content: [
 240								{
 241									type: "text",
 242									text: `✓ Session updated: ${filepath}`,
 243								},
 244							],
 245							details: { updated: true },
 246						};
 247					} else {
 248						// Append update section
 249						const updateSection = `\n\n---\n\n## Update (${time})\n\n${content
 250							.split("\n")
 251							.filter((line) => !line.startsWith("#") && !line.startsWith("**Date:**") && !line.startsWith("**Time:**"))
 252							.join("\n")}`;
 253
 254						await appendFile(filepath, updateSection);
 255						await appendToSessionLog(`Session updated: ${filename}`);
 256						await restoreModel();
 257
 258						return {
 259							content: [
 260								{
 261									type: "text",
 262									text: `✓ Session updated (appended): ${filepath}`,
 263								},
 264							],
 265							details: { updated: true, appended: true },
 266						};
 267					}
 268				} else {
 269					// New session file
 270					await writeFile(filepath, content, "utf-8");
 271
 272					// Track this as the current session
 273					currentSessionFile = filepath;
 274					currentSessionDescription = description;
 275
 276					// Append to session log
 277					await appendToSessionLog(`Session saved: ${filename}`);
 278					await restoreModel();
 279
 280					return {
 281						content: [
 282							{
 283								type: "text",
 284								text: `✓ Session saved to: ${filepath}`,
 285							},
 286						],
 287						details: { created: true },
 288					};
 289				}
 290			} catch (error) {
 291				await restoreModel();
 292				return {
 293					content: [
 294						{
 295							type: "text",
 296							text: `Error saving session: ${error}`,
 297						},
 298					],
 299					details: { error: String(error) },
 300				};
 301			}
 302		},
 303	});
 304
 305	// Tool to save research documents
 306	pi.registerTool({
 307		name: "save_research",
 308		label: "Save Research",
 309		description:
 310			"Saves research findings to ~/.local/share/ai/research/YYYY-MM/. Use for exploratory research, technical investigation, or background study on a topic. Research is date-organized for temporal context. Content should include **Project:** or **Repository:** metadata when the research relates to a specific project.",
 311		parameters: Type.Object({
 312			title: Type.String({ description: "Brief title for the research (e.g., 'llm-cost-tracking')" }),
 313			content: Type.String({ description: "Full markdown content of the research document" }),
 314		}),
 315		async execute(toolCallId, params, signal, onUpdate, ctx) {
 316			try {
 317				const { title, content } = params;
 318				const { yearMonth, date } = getDateInfo();
 319				const researchDir = join(RESEARCH_DIR, yearMonth);
 320
 321				// Sanitize filename
 322				const slug = title
 323					.toLowerCase()
 324					.replace(/[^a-z0-9]+/g, "-")
 325					.replace(/^-+|-+$/g, "")
 326					.substring(0, 60);
 327
 328				const filename = `${date}-${slug}.md`;
 329				const filepath = join(researchDir, filename);
 330
 331				await mkdir(researchDir, { recursive: true });
 332				await writeFile(filepath, content, "utf-8");
 333				await appendToSessionLog(`Research saved: ${filename}`);
 334
 335				return {
 336					content: [
 337						{
 338							type: "text",
 339							text: `✓ Research saved to: ${filepath}`,
 340						},
 341					],
 342					details: { created: true },
 343				};
 344			} catch (error) {
 345				return {
 346					content: [
 347						{
 348							type: "text",
 349							text: `Error saving research: ${error}`,
 350						},
 351					],
 352					details: { error: String(error) },
 353				};
 354			}
 355		},
 356	});
 357
 358	// Tool to save plans
 359	pi.registerTool({
 360		name: "save_plan",
 361		label: "Save Plan",
 362		description:
 363			"Saves a plan to ~/.local/share/ai/plans/. Use for project plans, roadmaps, or structured action plans. Plans are NOT date-organized as they represent timeless strategies. Content should include **Project:** metadata to identify the target project.",
 364		parameters: Type.Object({
 365			title: Type.String({ description: "Brief title for the plan (e.g., 'homelab-migration-plan')" }),
 366			content: Type.String({ description: "Full markdown content of the plan" }),
 367		}),
 368		async execute(toolCallId, params, signal, onUpdate, ctx) {
 369			try {
 370				const { title, content } = params;
 371
 372				// Sanitize filename (no date prefix for plans)
 373				const slug = title
 374					.toLowerCase()
 375					.replace(/[^a-z0-9]+/g, "-")
 376					.replace(/^-+|-+$/g, "")
 377					.substring(0, 60);
 378
 379				const filename = `${slug}.md`;
 380				const filepath = join(PLANS_DIR, filename);
 381
 382				await mkdir(PLANS_DIR, { recursive: true });
 383				await writeFile(filepath, content, "utf-8");
 384				await appendToSessionLog(`Plan saved: ${filename}`);
 385
 386				return {
 387					content: [
 388						{
 389							type: "text",
 390							text: `✓ Plan saved to: ${filepath}`,
 391						},
 392					],
 393					details: { created: true },
 394				};
 395			} catch (error) {
 396				return {
 397					content: [
 398						{
 399							type: "text",
 400							text: `Error saving plan: ${error}`,
 401						},
 402					],
 403					details: { error: String(error) },
 404				};
 405			}
 406		},
 407	});
 408
 409	// Tool to save learnings
 410	pi.registerTool({
 411		name: "save_learning",
 412		label: "Save Learning",
 413		description:
 414			"Saves a learning or insight to ~/.local/share/ai/learnings/YYYY-MM/. Use for lessons learned, insights gained, or knowledge discoveries. Learnings are date-organized to track knowledge evolution. Content should include **Project:** metadata when the learning relates to a specific project.",
 415		parameters: Type.Object({
 416			title: Type.String({ description: "Brief title for the learning (e.g., 'nix-flake-patterns')" }),
 417			content: Type.String({ description: "Full markdown content describing the learning or insight" }),
 418		}),
 419		async execute(toolCallId, params, signal, onUpdate, ctx) {
 420			try {
 421				const { title, content } = params;
 422				const { yearMonth, date } = getDateInfo();
 423				const learningsDir = join(LEARNINGS_DIR, yearMonth);
 424
 425				// Sanitize filename
 426				const slug = title
 427					.toLowerCase()
 428					.replace(/[^a-z0-9]+/g, "-")
 429					.replace(/^-+|-+$/g, "")
 430					.substring(0, 60);
 431
 432				const filename = `${date}-${slug}.md`;
 433				const filepath = join(learningsDir, filename);
 434
 435				await mkdir(learningsDir, { recursive: true });
 436				await writeFile(filepath, content, "utf-8");
 437				await appendToSessionLog(`Learning saved: ${filename}`);
 438
 439				return {
 440					content: [
 441						{
 442							type: "text",
 443							text: `✓ Learning saved to: ${filepath}`,
 444						},
 445					],
 446					details: { created: true },
 447				};
 448			} catch (error) {
 449				return {
 450					content: [
 451						{
 452							type: "text",
 453							text: `Error saving learning: ${error}`,
 454						},
 455					],
 456					details: { error: String(error) },
 457				};
 458			}
 459		},
 460	});
 461
 462	// ========================================================================
 463	// Shared session search helpers (used by both tools and commands)
 464	// ========================================================================
 465
 466	interface SessionFile {
 467		date: string;
 468		file: string;
 469		path: string;
 470		desc: string;
 471	}
 472
 473	/** Search sessions by content using ripgrep. Returns matching file paths. */
 474	async function searchSessionsByQuery(query: string, limit: number = 20): Promise<SessionFile[]> {
 475		const { execSync } = await import("node:child_process");
 476		const rgResult = execSync(
 477			`rg -l -i "${query.replace(/"/g, '\\"')}" "${SESSIONS_DIR}" 2>/dev/null || true`,
 478			{ encoding: "utf-8", maxBuffer: 1024 * 1024 }
 479		).trim();
 480
 481		if (!rgResult) return [];
 482
 483		return rgResult
 484			.split("\n")
 485			.filter(Boolean)
 486			.slice(0, limit)
 487			.map((filepath) => {
 488				const filename = filepath.split("/").pop() || "";
 489				const date = filename.slice(0, 10);
 490				const desc = filename.slice(11).replace(".md", "").replace(/-/g, " ");
 491				return { date, file: filename, path: filepath, desc };
 492			});
 493	}
 494
 495	/** List sessions by date range. Returns matching files sorted newest-first. */
 496	async function listSessionsByDateRange(
 497		dateRange: string,
 498		limit: number = 20,
 499	): Promise<{ files: SessionFile[]; total: number; label: string } | null> {
 500		const dateSpec = parseDateSpec(dateRange);
 501		if (!dateSpec) return null;
 502
 503		const { start, end, label } = dateSpec;
 504		const dates = getDatesInRange(start, end);
 505
 506		const allFiles: SessionFile[] = [];
 507		const yearMonths = [...new Set(dates.map((d) => d.slice(0, 7)))];
 508
 509		for (const ym of yearMonths) {
 510			const sessionDir = join(SESSIONS_DIR, ym);
 511			if (!existsSync(sessionDir)) continue;
 512
 513			const dirFiles = await readdir(sessionDir);
 514			for (const f of dirFiles) {
 515				if (!f.endsWith(".md")) continue;
 516				const fileDate = f.slice(0, 10);
 517				if (dates.includes(fileDate)) {
 518					const desc = f.slice(11).replace(".md", "").replace(/-/g, " ");
 519					allFiles.push({ date: fileDate, file: f, path: join(sessionDir, f), desc });
 520				}
 521			}
 522		}
 523
 524		allFiles.sort((a, b) => b.file.localeCompare(a.file));
 525		return { files: allFiles.slice(0, limit), total: allFiles.length, label };
 526	}
 527
 528	// Tool to list/search saved sessions
 529	pi.registerTool({
 530		name: "list_saved_sessions",
 531		label: "List Saved Sessions",
 532		description:
 533			"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.",
 534		parameters: Type.Object({
 535			query: Type.Optional(Type.String({ description: "Optional search query to filter sessions by content (uses ripgrep). If omitted, lists recent sessions." })),
 536			dateRange: Type.Optional(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'" })),
 537			limit: Type.Optional(Type.Number({ description: "Maximum number of sessions to return (default: 20)" })),
 538		}),
 539		async execute(toolCallId, params, signal, onUpdate, ctx) {
 540			try {
 541				const { query, dateRange = "last 7 days", limit = 20 } = params;
 542
 543				if (query) {
 544					const files = await searchSessionsByQuery(query, limit);
 545					if (files.length === 0) {
 546						return {
 547							content: [{ type: "text", text: `No sessions found matching "${query}"` }],
 548							details: { count: 0 },
 549						};
 550					}
 551
 552					const lines = [`Found ${files.length} session(s) matching "${query}":\n`];
 553					for (const { date, desc, path } of files) {
 554						lines.push(`- **${date}**: ${desc}`);
 555						lines.push(`  Path: ${path}`);
 556					}
 557					if (files.length === limit) lines.push(`\n(showing first ${limit} results)`);
 558
 559					return {
 560						content: [{ type: "text", text: lines.join("\n") }],
 561						details: { count: files.length, files: files.map(f => f.path) },
 562					};
 563				}
 564
 565				const result = await listSessionsByDateRange(dateRange, limit);
 566				if (!result) {
 567					return {
 568						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` }],
 569						details: { error: "invalid_date_range" },
 570					};
 571				}
 572
 573				const { files, total, label } = result;
 574				if (total === 0) {
 575					return {
 576						content: [{ type: "text", text: `No sessions found for ${label}` }],
 577						details: { count: 0 },
 578					};
 579				}
 580
 581				const lines = [`Found ${total} session(s) for ${label}:\n`];
 582				for (const { date, desc, path } of files) {
 583					lines.push(`- **${date}**: ${desc}`);
 584					lines.push(`  Path: ${path}`);
 585				}
 586				if (total > limit) lines.push(`\n(showing ${limit} of ${total})`);
 587
 588				return {
 589					content: [{ type: "text", text: lines.join("\n") }],
 590					details: { count: total, files: files.map(f => f.path) },
 591				};
 592			} catch (error) {
 593				return {
 594					content: [{ type: "text", text: `Error listing sessions: ${error}` }],
 595					details: { error: String(error) },
 596				};
 597			}
 598		},
 599	});
 600
 601	// Tool to read a saved session
 602	pi.registerTool({
 603		name: "read_saved_session",
 604		label: "Read Saved Session",
 605		description:
 606			"Read the full content of a saved session summary. Use the file path from list_saved_sessions.",
 607		parameters: Type.Object({
 608			path: Type.String({ description: "Full file path to the session markdown file (from list_saved_sessions)" }),
 609		}),
 610		async execute(toolCallId, params, signal, onUpdate, ctx) {
 611			try {
 612				const { path: filepath } = params;
 613
 614				if (!existsSync(filepath)) {
 615					return {
 616						content: [
 617							{
 618								type: "text",
 619								text: `Session file not found: ${filepath}`,
 620							},
 621						],
 622						details: { error: "file_not_found" },
 623					};
 624				}
 625
 626				const content = await readFile(filepath, "utf-8");
 627				const filename = filepath.split("/").pop() || "";
 628
 629				return {
 630					content: [
 631						{
 632							type: "text",
 633							text: `# ${filename}\n\n${content}`,
 634						},
 635					],
 636					details: { path: filepath },
 637				};
 638			} catch (error) {
 639				return {
 640					content: [
 641						{
 642							type: "text",
 643							text: `Error reading session: ${error}`,
 644						},
 645					],
 646					details: { error: String(error) },
 647				};
 648			}
 649		},
 650	});
 651
 652	// Log session start, reset session tracking, and check for pending transcripts
 653	pi.on("session_start", async (_event, ctx) => {
 654		try {
 655			// Reset session tracking on new session
 656			currentSessionFile = null;
 657			currentSessionDescription = null;
 658
 659			await logSessionStart();
 660			// Show hint
 661			// ctx.ui.setStatus("session", ...) - removed to declutter footer
 662
 663			// Check for pending transcripts and recover them in background
 664			await recoverPendingTranscripts(ctx);
 665		} catch (error) {
 666			// Silent failure
 667		}
 668	});
 669
 670	// Save transcript on session shutdown if not already saved
 671	pi.on("session_shutdown", async (_event, ctx) => {
 672		try {
 673			// If session was explicitly saved, export HTML alongside the summary
 674			if (currentSessionFile) {
 675				const rawSessionPath = ctx.sessionManager?.getSessionFile?.();
 676				if (rawSessionPath && existsSync(rawSessionPath)) {
 677					const summaryFilename = currentSessionFile.split("/").pop() || "";
 678					const exportsDir = join(dirname(currentSessionFile), "exports");
 679					const htmlPath = join(exportsDir, summaryFilename.replace(/\.md$/, ".html"));
 680					try {
 681						await mkdir(exportsDir, { recursive: true });
 682						const { execSync } = await import("node:child_process");
 683						execSync(`pi --no-extensions --export "${rawSessionPath}" "${htmlPath}"`, {
 684							encoding: "utf-8",
 685							timeout: 30000,
 686						});
 687						// Inject export link back into the summary .md
 688						try {
 689							const summaryContent = await readFile(currentSessionFile!, "utf-8");
 690							if (!summaryContent.includes("**Export:**")) {
 691								const updated = summaryContent.replace(
 692									/^(\*\*Session:\*\*.*)$/m,
 693									`$1\n**Export:** \`${htmlPath}\``
 694								);
 695								if (updated !== summaryContent) {
 696									await writeFile(currentSessionFile!, updated, "utf-8");
 697								}
 698							}
 699						} catch {
 700							// Non-fatal: export file exists, link is a convenience
 701						}
 702						if (ctx.hasUI) {
 703							ctx.ui.notify(`✓ Session exported: ${htmlPath}`, "info");
 704						}
 705					} catch (exportErr) {
 706						// Non-fatal: summary is already saved, export is a bonus
 707						if (ctx.hasUI) {
 708							ctx.ui.notify(`HTML export failed (summary saved): ${exportErr}`, "warning");
 709						}
 710					}
 711				}
 712				return;
 713			}
 714
 715			// Get conversation entries from session manager (correct API)
 716			const entries = ctx.sessionManager?.getEntries() || [];
 717			const messages = entries
 718				.filter((e: any) => e.type === "message")
 719				.map((e: any) => e.message);
 720
 721			if (messages.length < 2) {
 722				// Not enough content to save (just greeting or empty)
 723				return;
 724			}
 725
 726			// Create pending transcript
 727			const transcript = {
 728				savedAt: new Date().toISOString(),
 729				cwd: process.cwd(),
 730				host: hostname(),
 731				tool: detectTool(),
 732				messageCount: messages.length,
 733				messages: messages.map((m: any) => {
 734					let content = m.content;
 735					if (Array.isArray(content)) {
 736						// Extract text from content blocks
 737						content = content
 738							.filter((c: any) => c.type === "text")
 739							.map((c: any) => c.text)
 740							.join("\n");
 741					}
 742					return {
 743						role: m.role,
 744						content: typeof content === "string" ? content : JSON.stringify(content),
 745					};
 746				}),
 747			};
 748
 749			await mkdir(PENDING_DIR, { recursive: true });
 750			const filename = `${Date.now()}.json`;
 751			const filepath = join(PENDING_DIR, filename);
 752			await writeFile(filepath, JSON.stringify(transcript, null, 2), "utf-8");
 753
 754			// Log that we saved a pending transcript
 755			await appendToSessionLog(`Pending transcript saved: ${filename}`);
 756
 757			// Notify user (if UI available)
 758			if (ctx.hasUI) {
 759				ctx.ui.notify(`📝 Session transcript saved for recovery`, "info");
 760			}
 761		} catch (error) {
 762			// Log error but don't interrupt shutdown
 763			if (ctx.hasUI) {
 764				ctx.ui.notify(`Auto-save failed: ${error}`, "error");
 765			}
 766		}
 767	});
 768
 769	// Check if a PID is still alive
 770	function isPidAlive(pid: number): boolean {
 771		try {
 772			process.kill(pid, 0);
 773			return true;
 774		} catch {
 775			return false;
 776		}
 777	}
 778
 779	// Clean up stale lock and log files in the pending directory
 780	const LOCK_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
 781
 782	async function cleanupStaleLocks() {
 783		try {
 784			const files = await readdir(PENDING_DIR);
 785			const lockFiles = files.filter((f) => f.endsWith(".lock"));
 786
 787			for (const lockFile of lockFiles) {
 788				const lockPath = join(PENDING_DIR, lockFile);
 789				try {
 790					const content = await readFile(lockPath, "utf-8");
 791					const pid = parseInt(content.trim(), 10);
 792					const pidDead = isNaN(pid) || !isPidAlive(pid);
 793
 794					if (!pidDead) {
 795						// PID alive — check age in case it's hung
 796						const lockStat = await stat(lockPath);
 797						if (Date.now() - lockStat.mtimeMs <= LOCK_MAX_AGE_MS) continue;
 798					}
 799
 800					// Stale lock — remove it and its log file
 801					await unlink(lockPath).catch(() => {});
 802					const logPath = lockPath.replace(/\.lock$/, ".log");
 803					await unlink(logPath).catch(() => {});
 804				} catch {
 805					await unlink(lockPath).catch(() => {});
 806				}
 807			}
 808
 809			// Clean up orphaned .log files (no matching .json or .lock)
 810			const remaining = await readdir(PENDING_DIR);
 811			for (const f of remaining.filter((f) => f.endsWith(".log"))) {
 812				const base = f.replace(/\.log$/, "");
 813				if (!remaining.includes(base) && !remaining.includes(`${base}.lock`)) {
 814					await unlink(join(PENDING_DIR, f)).catch(() => {});
 815				}
 816			}
 817		} catch {
 818			// Silent failure
 819		}
 820	}
 821
 822	// Recover pending transcripts by spawning background summarizer
 823	async function recoverPendingTranscripts(ctx: any) {
 824		try {
 825			if (!existsSync(PENDING_DIR)) {
 826				return;
 827			}
 828
 829			// Clean up stale locks from crashed/killed summarizers
 830			await cleanupStaleLocks();
 831
 832			const files = await readdir(PENDING_DIR);
 833			const pendingFiles = files.filter((f) => f.endsWith(".json"));
 834
 835			if (pendingFiles.length === 0) {
 836				return;
 837			}
 838
 839			// Notify user
 840			ctx.ui.notify(`📝 Recovering ${pendingFiles.length} unsaved session(s) in background...`, "info");
 841
 842			// Spawn background process for each pending file
 843			for (const file of pendingFiles) {
 844				const filepath = join(PENDING_DIR, file);
 845
 846				// Log file for debugging
 847				const logFile = join(PENDING_DIR, `${file}.log`);
 848				const logFd = openSync(logFile, "a");
 849
 850				// Spawn summarizer as detached background process
 851				const child = spawn("bun", ["run", SUMMARIZER_SCRIPT, filepath], {
 852					detached: true,
 853					stdio: ["ignore", logFd, logFd],
 854					env: {
 855						...process.env,
 856						AI_SESSIONS_DIR: SESSIONS_DIR,
 857					},
 858				});
 859				child.unref();
 860			}
 861		} catch (error) {
 862			// Silent failure
 863		}
 864	}
 865
 866	// Try to find a lighter model for session summaries
 867	async function findLightModel(ctx: any): Promise<any | null> {
 868		const lightModels = [
 869			// Prefer cheaper/faster models for summarization
 870			{ provider: "google", id: "gemini-2.5-pro" },
 871			{ provider: "google", id: "gemini-2.0-flash" },
 872			{ provider: "google", id: "gemini-1.5-flash" },
 873			{ provider: "github-copilot", id: "gpt-4o" },
 874			{ provider: "github-copilot", id: "gpt-4" },
 875			{ provider: "anthropic", id: "claude-3-5-haiku" },
 876			{ provider: "anthropic", id: "claude-haiku-3" },
 877		];
 878
 879		for (const { provider, id } of lightModels) {
 880			const model = ctx.modelRegistry.find(provider, id);
 881			if (model) {
 882				return model;
 883			}
 884		}
 885		return null;
 886	}
 887
 888	// Register /save-session command
 889	pi.registerCommand("save-session", {
 890		description: "Generate and save a session summary to history (auto-detects tool)",
 891		handler: async (_args, ctx) => {
 892			const tool = detectTool();
 893			const { date, time } = getDateInfo();
 894			const host = hostname();
 895
 896			// Try to use a lighter model for summarization
 897			const currentModel = ctx.model;
 898			const lightModel = await findLightModel(ctx);
 899
 900			if (lightModel && lightModel.id !== currentModel?.id) {
 901				const success = await pi.setModel(lightModel);
 902				if (success) {
 903					// Store current model to restore after save
 904					modelToRestore = currentModel;
 905					ctx.ui.notify(`Using ${lightModel.name || lightModel.id} for summary`, "info");
 906				}
 907			}
 908
 909			const cwd = process.cwd();
 910
 911			// Try to detect git remote for project identification
 912			let gitRemote = "";
 913			try {
 914				const { execSync } = await import("node:child_process");
 915				gitRemote = execSync("git remote get-url origin 2>/dev/null", {
 916					encoding: "utf-8",
 917					cwd,
 918					timeout: 3000,
 919				}).trim();
 920			} catch {
 921				// Not a git repo or no remote
 922			}
 923
 924			// Build the prompt for the AI
 925			const prompt = `Please generate ${currentSessionFile ? "an updated" : "a"} session summary for this conversation.
 926
 927${
 928	currentSessionFile
 929		? `This session has already been saved. Please review what additional work was done and either:
 9301. Generate a complete updated summary (if significant new work was done)
 9312. Or generate just an update section with the new accomplishments
 932
 933Current session: ${currentSessionDescription}`
 934		: ""
 935}
 936
 937The summary should include:
 938- A descriptive title
 939- What was accomplished
 940- Files that were changed
 941- Commands that were run
 942- The outcome
 943- Any next steps
 944
 945IMPORTANT: The metadata header MUST include **Project:** — this is REQUIRED for filtering.
 946${gitRemote ? `Git remote: ${gitRemote}` : ""}
 947Infer the project name as org/repo (e.g. "tektoncd/pipeline", "vdemeester/chisel") from the git remote, working directory, or conversation context.
 948If working in a git worktree, use the real project name, NOT the worktree path.
 949If no project can be inferred, use the working directory path (e.g. "~/src/home").
 950
 951Use this template format:
 952
 953\`\`\`markdown
 954# Session: <Description>
 955
 956**Date:** ${date}
 957**Time:** ${time}
 958**Host:** ${host}
 959**Tool:** ${tool}
 960**Project:** <REQUIRED: org/repo or ~/path>
 961**Directory:** ${cwd}
 962
 963## Summary
 964Brief description of what was accomplished.
 965
 966## What Was Accomplished
 967- Task 1
 968- Task 2
 969
 970## Files Changed
 971- \`path/to/file\` - Description of change
 972
 973## Commands Run
 974\`\`\`bash
 975# Key commands executed
 976\`\`\`
 977
 978## Outcome
 979Result of the session.
 980
 981## Next Steps
 982- [ ] TODO 1
 983- [ ] TODO 2
 984
 985### Tags
 986#${tool} #relevant-tags
 987\`\`\`
 988
 989After 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"}.`;
 990
 991			// Send the prompt automatically (triggers AI response)
 992			pi.sendUserMessage(prompt);
 993		},
 994	});
 995
 996	// Parse natural language date expressions using chrono-node
 997	function parseDateSpec(spec: string): { start: Date; end: Date; label: string } | null {
 998		const now = new Date();
 999		const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1000		const normalized = spec.toLowerCase().trim();
1001
1002		// Check for date range: YYYY-MM-DD..YYYY-MM-DD (not handled by chrono)
1003		const rangeMatch = spec.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/);
1004		if (rangeMatch) {
1005			return {
1006				start: new Date(rangeMatch[1]),
1007				end: new Date(rangeMatch[2]),
1008				label: `${rangeMatch[1]} to ${rangeMatch[2]}`,
1009			};
1010		}
1011
1012		// Special handling for range expressions
1013		if (normalized.includes(" to ") || normalized.includes(" through ") || normalized.includes(" until ")) {
1014			const results = chrono.parse(spec, now);
1015			if (results.length >= 2) {
1016				return {
1017					start: results[0].start.date(),
1018					end: results[1].start.date(),
1019					label: spec,
1020				};
1021			}
1022			// Single result with end date
1023			if (results.length === 1 && results[0].end) {
1024				return {
1025					start: results[0].start.date(),
1026					end: results[0].end.date(),
1027					label: spec,
1028				};
1029			}
1030		}
1031
1032		// Use chrono to parse the date expression
1033		const results = chrono.parse(spec, now);
1034
1035		if (results.length > 0) {
1036			const result = results[0];
1037			const start = result.start.date();
1038			// If chrono found an end date (e.g., "Sep 12-13"), use it
1039			const end = result.end ? result.end.date() : start;
1040
1041			// Normalize to start of day
1042			const startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate());
1043			const endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate());
1044
1045			return {
1046				start: startDay,
1047				end: endDay,
1048				label: spec,
1049			};
1050		}
1051
1052		return null;
1053	}
1054
1055	// Format date as YYYY-MM-DD (local time, not UTC)
1056	function formatDate(d: Date): string {
1057		const year = d.getFullYear();
1058		const month = String(d.getMonth() + 1).padStart(2, "0");
1059		const day = String(d.getDate()).padStart(2, "0");
1060		return `${year}-${month}-${day}`;
1061	}
1062
1063	// Get all dates in range
1064	function getDatesInRange(start: Date, end: Date): string[] {
1065		const dates: string[] = [];
1066		const current = new Date(start);
1067		while (current <= end) {
1068			dates.push(formatDate(current));
1069			current.setDate(current.getDate() + 1);
1070		}
1071		return dates;
1072	}
1073
1074	// Register /list-sessions command (uses shared helpers)
1075	pi.registerCommand("list-sessions", {
1076		description: "List sessions. Usage: /list-sessions [today|yesterday|last week|last N days|YYYY-MM-DD|YYYY-MM-DD..YYYY-MM-DD|search <query>]",
1077		handler: async (args, ctx) => {
1078			const argStr = (args || "").trim();
1079
1080			/** Format session files as markdown for the custom renderer */
1081			function formatSessionList(title: string, files: SessionFile[], total: number, limit: number): string {
1082				const lines: string[] = [];
1083				lines.push(`## 📋 ${title}`);
1084				lines.push("");
1085				lines.push(`*${total} result(s)*`);
1086				lines.push("");
1087				for (const { date, desc, path } of files) {
1088					lines.push(`- **${date}** ${desc}`);
1089					lines.push(`  \`${path}\``);
1090				}
1091				if (total > limit) {
1092					lines.push("");
1093					lines.push(`*(showing ${limit} of ${total})*`);
1094				}
1095				return lines.join("\n");
1096			}
1097
1098			// Check for search mode
1099			if (argStr.toLowerCase().startsWith("search ")) {
1100				const query = argStr.slice(7).trim();
1101				if (!query) {
1102					ctx.ui.notify("Usage: /list-sessions search <query>", "error");
1103					return;
1104				}
1105
1106				try {
1107					const files = await searchSessionsByQuery(query, 20);
1108					if (files.length === 0) {
1109						ctx.ui.notify(`No sessions found matching "${query}"`, "info");
1110						return;
1111					}
1112					pi.sendMessage({
1113						customType: "ai-storage-sessions",
1114						content: formatSessionList(`Sessions matching "${query}"`, files, files.length, 20),
1115						display: true,
1116					});
1117				} catch (error) {
1118					ctx.ui.notify(`Search error: ${error}`, "error");
1119				}
1120				return;
1121			}
1122
1123			// Date-based listing (default to today)
1124			try {
1125				const result = await listSessionsByDateRange(argStr || "today", 30);
1126				if (!result) {
1127					ctx.ui.notify(
1128						"Invalid date. Use: today, yesterday, last week, last N days, YYYY-MM-DD, or YYYY-MM-DD..YYYY-MM-DD",
1129						"error"
1130					);
1131					return;
1132				}
1133
1134				const { files, total, label } = result;
1135				if (total === 0) {
1136					ctx.ui.notify(`No sessions found for ${label}`, "info");
1137					return;
1138				}
1139
1140				pi.sendMessage({
1141					customType: "ai-storage-sessions",
1142					content: formatSessionList(`Sessions - ${label}`, files, total, 30),
1143					display: true,
1144				});
1145			} catch (error) {
1146				ctx.ui.notify(`Error listing sessions: ${error}`, "error");
1147			}
1148		},
1149	});
1150
1151	// Register /session-log command
1152	pi.registerCommand("session-log", {
1153		description: "View today's session log",
1154		handler: async (_args, ctx) => {
1155			const { yearMonth, date } = getDateInfo();
1156			const sessionDir = join(SESSIONS_DIR, yearMonth);
1157
1158			// Read all session-log files for today (one per hostname)
1159			let allContent = "";
1160			if (existsSync(sessionDir)) {
1161				const files = await readdir(sessionDir);
1162				const logFiles = files.filter((f) => f.startsWith(`${date}_session-log`) && f.endsWith(".txt"));
1163				for (const f of logFiles) {
1164					try {
1165						allContent += await readFile(join(sessionDir, f), "utf-8");
1166					} catch {}
1167				}
1168			}
1169
1170			if (!allContent.trim()) {
1171				ctx.ui.notify("No session log for today", "info");
1172				return;
1173			}
1174
1175			try {
1176				const content = allContent;
1177				const lines = content.trim().split("\n").reverse(); // Most recent first
1178				const theme = ctx.ui.theme;
1179
1180				// Format each log entry with colors
1181				const formattedLines = lines.map((line) => {
1182					// Parse: YYYY-MM-DDTHH:MM:SS+TZ - Message
1183					const match = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2})\s*-\s*(.+)$/);
1184					if (match) {
1185						const [, timestamp, message] = match;
1186						// Extract just the time part for display
1187						const time = timestamp.slice(11, 16);
1188						// Color based on message type
1189						let styledMessage = message;
1190						if (message.includes("started")) {
1191							styledMessage = theme.fg("accent", message);
1192						} else if (message.includes("saved")) {
1193							styledMessage = theme.fg("success", message);
1194						} else if (message.includes("updated")) {
1195							styledMessage = theme.fg("warning", message);
1196						}
1197						return `${theme.fg("dim", time)} ${styledMessage}`;
1198					}
1199					return theme.fg("dim", line);
1200				});
1201
1202				// Display as widget
1203				const header = theme.bold(`📋 Session Log - ${date}`);
1204				const widgetLines = [header, theme.fg("dim", "─".repeat(40)), ...formattedLines];
1205				ctx.ui.setWidget("session-log", widgetLines);
1206
1207				// Auto-dismiss after 10 seconds
1208				setTimeout(() => {
1209					ctx.ui.setWidget("session-log", undefined);
1210				}, 10000);
1211			} catch (error) {
1212				ctx.ui.notify(`Error: ${error}`, "error");
1213			}
1214		},
1215	});
1216
1217	// ========================================================================
1218	// Session picker overlay component
1219	// ========================================================================
1220
1221	function collectSessionFiles(months: number = 3): SessionFile[] {
1222		const allFiles: SessionFile[] = [];
1223		const now = new Date();
1224
1225		for (let i = 0; i < months; i++) {
1226			const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
1227			const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
1228			const sessionDir = join(SESSIONS_DIR, ym);
1229
1230			if (!existsSync(sessionDir)) continue;
1231
1232			const dirFiles = readdirSync(sessionDir);
1233			for (const f of dirFiles) {
1234				if (!f.endsWith(".md") || f.includes("session-log")) continue;
1235				const fullPath = join(sessionDir, f);
1236				const date = f.slice(0, 10);
1237				const desc = f.slice(11).replace(".md", "").replace(/-/g, " ");
1238				allFiles.push({ date, file: f, path: fullPath, desc });
1239			}
1240		}
1241
1242		// Newest first
1243		allFiles.sort((a, b) => b.file.localeCompare(a.file));
1244		return allFiles;
1245	}
1246
1247	function fuzzyMatch(query: string, text: string): number {
1248		const lq = query.toLowerCase();
1249		const lt = text.toLowerCase();
1250		if (lt.includes(lq)) return 100 + (lq.length / lt.length) * 50;
1251
1252		let score = 0, qi = 0, bonus = 0;
1253		for (let i = 0; i < lt.length && qi < lq.length; i++) {
1254			if (lt[i] === lq[qi]) { score += 10 + bonus; bonus += 5; qi++; }
1255			else { bonus = 0; }
1256		}
1257		return qi === lq.length ? score : 0;
1258	}
1259
1260	class SessionPickerComponent {
1261		private readonly maxVisible = 20;
1262		private allFiles: SessionFile[];
1263		private filtered: SessionFile[];
1264		private selected = 0;
1265		private query = "";
1266		private done: (result: string | null) => void;
1267		private previewCache = new Map<string, string[]>();
1268		private previewScroll = 0;
1269
1270		constructor(files: SessionFile[], done: (result: string | null) => void) {
1271			this.allFiles = files;
1272			this.filtered = files;
1273			this.done = done;
1274		}
1275
1276		handleInput(data: string): void {
1277			if (matchesKey(data, "escape")) {
1278				this.done(null);
1279				return;
1280			}
1281
1282			if (matchesKey(data, "return")) {
1283				const entry = this.filtered[this.selected];
1284				this.done(entry ? entry.path : null);
1285				return;
1286			}
1287
1288			if (matchesKey(data, "up")) {
1289				if (this.filtered.length > 0) {
1290					this.selected = this.selected === 0 ? this.filtered.length - 1 : this.selected - 1;
1291					this.previewScroll = 0;
1292				}
1293				return;
1294			}
1295
1296			if (matchesKey(data, "down")) {
1297				if (this.filtered.length > 0) {
1298					this.selected = this.selected === this.filtered.length - 1 ? 0 : this.selected + 1;
1299					this.previewScroll = 0;
1300				}
1301				return;
1302			}
1303
1304			if (matchesKey(data, "pageUp")) {
1305				this.previewScroll = Math.max(0, this.previewScroll - this.maxVisible);
1306				return;
1307			}
1308
1309			if (matchesKey(data, "pageDown")) {
1310				const entry = this.filtered[this.selected];
1311				if (entry) {
1312					const lines = this.getPreviewLines(entry.path);
1313					const maxScroll = Math.max(0, lines.length - this.maxVisible);
1314					this.previewScroll = Math.min(this.previewScroll + this.maxVisible, maxScroll);
1315				}
1316				return;
1317			}
1318
1319			if (matchesKey(data, "backspace")) {
1320				if (this.query.length > 0) {
1321					this.query = this.query.slice(0, -1);
1322					this.updateFilter();
1323				}
1324				return;
1325			}
1326
1327			if (data.length === 1 && data.charCodeAt(0) >= 32) {
1328				this.query += data;
1329				this.updateFilter();
1330			}
1331		}
1332
1333		private updateFilter(): void {
1334			if (!this.query.trim()) {
1335				this.filtered = this.allFiles;
1336			} else {
1337				const scored = this.allFiles
1338					.map((f) => ({ f, score: Math.max(fuzzyMatch(this.query, f.desc), fuzzyMatch(this.query, f.date)) }))
1339					.filter((x) => x.score > 0)
1340					.sort((a, b) => b.score - a.score);
1341				this.filtered = scored.map((x) => x.f);
1342			}
1343			this.selected = 0;
1344			this.previewScroll = 0;
1345		}
1346
1347		private getPreviewLines(filePath: string): string[] {
1348			if (this.previewCache.has(filePath)) {
1349				return this.previewCache.get(filePath)!;
1350			}
1351			try {
1352				const { readFileSync } = require("node:fs") as typeof import("node:fs");
1353				let content = readFileSync(filePath, "utf-8");
1354				// Strip ANSI escape sequences, OSC sequences, and other terminal controls
1355				// that may be embedded in recovered session transcripts
1356				content = content.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, ""); // OSC sequences
1357				content = content.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); // CSI sequences
1358				content = content.replace(/\x1b[^[\]]/g, ""); // Other ESC sequences
1359				const lines = content.split("\n");
1360				this.previewCache.set(filePath, lines);
1361				return lines;
1362			} catch {
1363				const fallback = ["(unable to read file)"];
1364				this.previewCache.set(filePath, fallback);
1365				return fallback;
1366			}
1367		}
1368
1369		render(width: number): string[] {
1370			// Layout: totalW includes outer borders
1371			// │<leftW>│<rightW>│ = 3 border chars + leftW + rightW = totalW
1372			const maxW = width; // hard limit — no line may exceed this
1373			const totalW = Math.min(width - 2, 140);
1374			const innerW = totalW - 2; // for full-width rows (outer borders only)
1375			const leftW = Math.min(42, Math.floor(totalW * 0.35));
1376			const rightW = totalW - 3 - leftW; // 3 = left border + middle border + right border
1377			const lines: string[] = [];
1378
1379			const c = (code: string, text: string) => code ? `\x1b[${code}m${text}\x1b[0m` : text;
1380			const border = (s: string) => c("2", s);
1381			const accent = (s: string) => c("36", s);
1382			const dim = (s: string) => c("2", s);
1383			const muted = (s: string) => c("90", s);
1384			const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
1385
1386			// Truncate with ANSI awareness, then pad to exact width
1387			const trunc = (s: string, w: number) => truncateToWidth(s, w, "…");
1388			const fit = (s: string, w: number) => truncateToWidth(s, w, "…", true);
1389
1390			const splitRow = (left: string, right: string) =>
1391				border("│") + fit(left, leftW) + border("│") + fit(right, rightW) + border("│");
1392
1393			const fullRow = (content: string) =>
1394				border("│") + fit(content, innerW) + border("│");
1395
1396			// ── Top border ──
1397			const titleText = " Export Session ";
1398			const bLen = Math.max(0, innerW - visibleWidth(titleText));
1399			const lB = Math.floor(bLen / 2);
1400			lines.push(border("╭" + "─".repeat(lB)) + accent(titleText) + border("─".repeat(bLen - lB) + "╮"));
1401
1402			// ── Search bar (full width) ──
1403			const prompt = accent("❯ ");
1404			const queryDisplay = this.query || dim("Type to filter...");
1405			lines.push(fullRow(` ${prompt}${queryDisplay}`));
1406
1407			// ── Divider with T-junction for middle column ──
1408			lines.push(border("├" + "─".repeat(leftW) + "┬" + "─".repeat(rightW) + "┤"));
1409
1410			// ── Get preview content for selected entry ──
1411			const selectedEntry = this.filtered[this.selected];
1412			let previewLines: string[] = [];
1413			if (selectedEntry) {
1414				previewLines = this.getPreviewLines(selectedEntry.path);
1415			}
1416
1417			// ── Body rows (list + preview side by side) ──
1418			const startIndex = Math.max(
1419				0,
1420				Math.min(this.selected - Math.floor(this.maxVisible / 2), this.filtered.length - this.maxVisible)
1421			);
1422
1423			for (let i = 0; i < this.maxVisible; i++) {
1424				// Left pane: session list
1425				let leftContent = "";
1426				const idx = startIndex + i;
1427				if (idx < this.filtered.length) {
1428					const entry = this.filtered[idx];
1429					const isSel = idx === this.selected;
1430					const prefix = isSel ? accent("▶ ") : "  ";
1431					const dateStr = dim(entry.date.slice(5)); // MM-DD to save space
1432					const descStr = trunc(entry.desc, leftW - 10);
1433					const name = isSel ? bold(accent(descStr)) : descStr;
1434					leftContent = `${prefix}${dateStr} ${name}`;
1435				} else if (i === 0 && this.filtered.length === 0) {
1436					leftContent = dim(" No matches");
1437				}
1438
1439				// Right pane: preview (with scroll offset)
1440				let rightContent = "";
1441				const previewIdx = i + this.previewScroll;
1442				if (previewIdx < previewLines.length) {
1443					const rawLine = previewLines[previewIdx].replace(/\t/g, "  ");
1444					const maxTxt = rightW - 2;
1445					if (rawLine.startsWith("# ")) {
1446						rightContent = " " + bold(accent(trunc(rawLine, maxTxt)));
1447					} else if (rawLine.startsWith("## ")) {
1448						rightContent = " " + accent(trunc(rawLine, maxTxt));
1449					} else if (rawLine.match(/^\*\*\w/)) {
1450						rightContent = " " + muted(trunc(rawLine, maxTxt));
1451					} else if (rawLine.startsWith("- ")) {
1452						rightContent = " " + trunc(rawLine, maxTxt);
1453					} else {
1454						rightContent = " " + dim(trunc(rawLine, maxTxt));
1455					}
1456				} else if (i === 0 && !selectedEntry) {
1457					rightContent = dim(" No session selected");
1458				}
1459
1460				lines.push(splitRow(leftContent, rightContent));
1461			}
1462
1463			// ── Footer ──
1464			lines.push(border("├" + "─".repeat(leftW) + "┴" + "─".repeat(rightW) + "┤"));
1465			let countStr = "";
1466			if (this.filtered.length > this.maxVisible) {
1467				const shown = `${startIndex + 1}-${Math.min(startIndex + this.maxVisible, this.filtered.length)}`;
1468				countStr = ` ${shown} of ${this.filtered.length}`;
1469			} else if (this.filtered.length > 0) {
1470				countStr = ` ${this.filtered.length} session${this.filtered.length === 1 ? "" : "s"}`;
1471			}
1472			const hints = "↑↓ navigate  pgup/dn preview  enter select  esc cancel";
1473			lines.push(fullRow(dim(countStr + " ".repeat(Math.max(2, innerW - visibleWidth(countStr) - visibleWidth(hints))) + hints)));
1474
1475			// ── Bottom border ──
1476			lines.push(border(`${"─".repeat(innerW)}`));
1477
1478			return this.clampLines(lines, maxW);
1479		}
1480
1481		// Safety: clamp every line to terminal width
1482		private clampLines(lines: string[], maxW: number): string[] {
1483			return lines.map((line) => {
1484				if (visibleWidth(line) > maxW) {
1485					return truncateToWidth(line, maxW, "…");
1486				}
1487				return line;
1488			});
1489		}
1490
1491		invalidate(): void {}
1492		dispose(): void {}
1493	}
1494
1495	// Register /session-export command
1496	// NOTE: Cannot use "export-session" because the built-in /export handler
1497	// catches anything starting with "/export" before extension commands run.
1498	pi.registerCommand("session-export", {
1499		description: "Export a saved session to HTML. Usage: /session-export [path] or interactive picker",
1500		handler: async (args, ctx) => {
1501			const argStr = (args || "").trim();
1502
1503			let summaryPath: string | null = null;
1504
1505			if (argStr) {
1506				// Direct path provided
1507				summaryPath = argStr;
1508			} else {
1509				// Interactive overlay picker
1510				const files = collectSessionFiles(3);
1511				if (files.length === 0) {
1512					ctx.ui.notify("No sessions found", "info");
1513					return;
1514				}
1515
1516				summaryPath = await ctx.ui.custom<string | null>(
1517					(_tui, _theme, _kb, done) => new SessionPickerComponent(files, done),
1518					{ overlay: true }
1519				);
1520
1521				if (!summaryPath) return;
1522			}
1523
1524			if (!summaryPath || !existsSync(summaryPath)) {
1525				ctx.ui.notify(`Summary not found: ${summaryPath}`, "error");
1526				return;
1527			}
1528
1529			// Read summary and extract **Session:** link
1530			try {
1531				const summaryContent = await readFile(summaryPath, "utf-8");
1532				const sessionMatch = summaryContent.match(/^\*\*Session:\*\*\s*`([^`]+)`/m);
1533
1534				if (!sessionMatch) {
1535					ctx.ui.notify("No **Session:** link found in summary. Older sessions may not have this metadata.", "warning");
1536					return;
1537				}
1538
1539				const rawSessionPath = sessionMatch[1];
1540				if (!existsSync(rawSessionPath)) {
1541					ctx.ui.notify(`Raw session file not found: ${rawSessionPath}`, "error");
1542					return;
1543				}
1544
1545				// Derive HTML output path (same dir as summary, .html extension)
1546				const summaryFilename = summaryPath.split("/").pop() || "";
1547				const exportsDir = join(dirname(summaryPath), "exports");
1548				const htmlPath = join(exportsDir, summaryFilename.replace(/\.md$/, ".html"));
1549
1550				await mkdir(exportsDir, { recursive: true });
1551				ctx.ui.notify(`Exporting session to HTML...`, "info");
1552
1553				const { execSync } = await import("node:child_process");
1554				execSync(`pi --export "${rawSessionPath}" "${htmlPath}"`, {
1555					encoding: "utf-8",
1556					timeout: 30000,
1557				});
1558
1559				ctx.ui.notify(`✓ Exported to: ${htmlPath}`, "info");
1560			} catch (error) {
1561				ctx.ui.notify(`Export failed: ${error}`, "error");
1562			}
1563		},
1564	});
1565
1566	// Register /recover-sessions command for manual recovery
1567	pi.registerCommand("recover-sessions", {
1568		description: "Manually recover pending unsaved sessions",
1569		handler: async (_args, ctx) => {
1570			try {
1571				if (!existsSync(PENDING_DIR)) {
1572					ctx.ui.notify("No pending sessions to recover", "info");
1573					return;
1574				}
1575
1576				const files = await readdir(PENDING_DIR);
1577				const pendingFiles = files.filter((f) => f.endsWith(".json"));
1578
1579				if (pendingFiles.length === 0) {
1580					ctx.ui.notify("No pending sessions to recover", "info");
1581					return;
1582				}
1583
1584				ctx.ui.notify(`🔄 Recovering ${pendingFiles.length} session(s) in background...`, "info");
1585
1586				// Spawn background process for each pending file
1587				for (const file of pendingFiles) {
1588					const filepath = join(PENDING_DIR, file);
1589
1590					// Log file for debugging
1591					const logFile = join(PENDING_DIR, `${file}.log`);
1592					const logFd = openSync(logFile, "a");
1593
1594					const child = spawn("bun", ["run", SUMMARIZER_SCRIPT, filepath], {
1595						detached: true,
1596						stdio: ["ignore", logFd, logFd],
1597						env: {
1598							...process.env,
1599							AI_SESSIONS_DIR: SESSIONS_DIR,
1600						},
1601					});
1602					child.unref();
1603				}
1604
1605				ctx.ui.notify(`Started recovery for ${pendingFiles.length} session(s)`, "info");
1606				ctx.ui.notify(`Logs: ${PENDING_DIR}/*.log`, "info");
1607			} catch (error) {
1608				ctx.ui.notify(`Error: ${error}`, "error");
1609			}
1610		},
1611	});
1612
1613	// Register /pending-sessions command to list pending transcripts
1614	pi.registerCommand("pending-sessions", {
1615		description: "List pending unsaved session transcripts",
1616		handler: async (_args, ctx) => {
1617			const theme = ctx.ui.theme;
1618
1619			try {
1620				if (!existsSync(PENDING_DIR)) {
1621					ctx.ui.notify("No pending sessions", "info");
1622					return;
1623				}
1624
1625				const files = await readdir(PENDING_DIR);
1626				const pendingFiles = files.filter((f) => f.endsWith(".json"));
1627
1628				if (pendingFiles.length === 0) {
1629					ctx.ui.notify("No pending sessions", "info");
1630					return;
1631				}
1632
1633				const header = theme.bold(`📝 Pending Sessions (${pendingFiles.length})`);
1634				const separator = theme.fg("dim", "─".repeat(40));
1635
1636				const fileLines = await Promise.all(
1637					pendingFiles.slice(0, 10).map(async (f) => {
1638						const filepath = join(PENDING_DIR, f);
1639						try {
1640							const content = await readFile(filepath, "utf-8");
1641							const transcript = JSON.parse(content);
1642							const date = transcript.savedAt?.split("T")[0] || "unknown";
1643							const msgCount = transcript.messageCount || 0;
1644							return `  ${theme.fg("accent", "•")} ${theme.fg("dim", date)} ${msgCount} messages\n    ${theme.fg("dim", filepath)}`;
1645						} catch {
1646							return `  ${theme.fg("error", "•")} ${f} (unreadable)`;
1647						}
1648					})
1649				);
1650
1651				const widgetLines = [header, separator, ...fileLines];
1652				if (pendingFiles.length > 10) {
1653					widgetLines.push(theme.fg("warning", `  (showing 10 of ${pendingFiles.length})`));
1654				}
1655				widgetLines.push("", theme.fg("dim", "Use /recover-sessions to process them"));
1656
1657				ctx.ui.setWidget("pending-sessions", widgetLines);
1658				setTimeout(() => ctx.ui.setWidget("pending-sessions", undefined), 15000);
1659			} catch (error) {
1660				ctx.ui.notify(`Error: ${error}`, "error");
1661			}
1662		},
1663	});
1664}