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}