// src/context-mode/snapshot-builder.ts import type { EventStore, TrackedEvent } from "./event-store.js"; const CAPS = { tasks: 10, decisions: 5, files: 20, errors: 3, git: 5, }; /** Escape all 5 XML special characters in user data */ function escapeXML(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function latestFirst(events: TrackedEvent[]): TrackedEvent[] { return [...events].sort((a, b) => { const byTimestamp = b.timestamp - a.timestamp; if (byTimestamp !== 0) return byTimestamp; return (b.id ?? 0) - (a.id ?? 0); }); } interface SnapshotOpts { compactCount?: number; searchTool?: string; searchAvailable?: boolean; } /** Build a resume snapshot from tracked events for a session */ export function buildResumeSnapshot( eventStore: EventStore, sessionId: string, opts?: SnapshotOpts, ): string { const counts = eventStore.getEventCounts(sessionId); const hasAnyEvents = Object.values(counts).some((c) => c > 0); if (!hasAnyEvents) return ""; if (opts?.searchAvailable) { return buildReferenceSnapshot(eventStore, sessionId, opts); } return buildFallbackSnapshot(eventStore, sessionId); } // --------------------------------------------------------------------------- // Reference-based format (supi-context-mode MCP available) // --------------------------------------------------------------------------- function buildReferenceSnapshot(eventStore: EventStore, sessionId: string, opts: SnapshotOpts): string { const compactCount = opts.compactCount ?? 0; const now = new Date().toISOString(); const sections: string[] = [ ``, " ", " Each section below contains a summary of prior work.", " For FULL DETAILS, run the exact tool call shown under each section.", " Do NOT ask the user to re-explain prior work. Search first.", " ", ]; let hasSections = false; // --- rules --- const ruleEvents = eventStore.getEvents(sessionId, { categories: ["rule"] }); if (ruleEvents.length > 0) { const files = new Set(); for (const r of ruleEvents) { const data = safeParse(r.data); const file = typeof data?.file === "string" ? data.file : typeof data?.path === "string" ? data.path : null; if (file) files.add(file); } if (files.size > 0) { const fileList = [...files]; sections.push(""); sections.push(` `); sections.push(` Loaded ${fileList.length} project rule files: ${fileList.map(escapeXML).join(", ")}`); sections.push(` For full details:`); sections.push(` ctx_search(queries: [${fileList.map((f) => `"${escapeXML(f)}"`).join(", ")}], source: "session-events")`); sections.push(` `); hasSections = true; } } // --- files --- const fileEvents = latestFirst(eventStore.getEvents(sessionId, { categories: ["file"], limit: 200 })); if (fileEvents.length > 0) { const edited = new Set(); const read = new Set(); const modSeen = new Set(); const readSeen = new Set(); let maskedStale = 0; for (const f of fileEvents) { const data = safeParse(f.data); const p = typeof data?.path === "string" ? data.path : null; if (!p) continue; const op = data?.op; const sourceKey = typeof data?.sourceHash === "string" ? data.sourceHash : p.replace(/\\/g, "/"); if (op === "edit" || op === "write") { if (modSeen.has(sourceKey)) { maskedStale += 1; continue; } modSeen.add(sourceKey); edited.add(p); } else if (op === "read") { if (modSeen.has(sourceKey)) { maskedStale += 1; continue; } if (readSeen.has(sourceKey)) { maskedStale += 1; continue; } readSeen.add(sourceKey); read.add(p); } } // Modifications dominate: do not double-list a path that was both edited and read. for (const p of edited) read.delete(p); if (edited.size > 0 || read.size > 0) { sections.push(""); sections.push(` `); if (edited.size > 0) sections.push(` Edited: ${[...edited].map(escapeXML).join(", ")}`); if (read.size > 0) sections.push(` Read: ${[...read].map(escapeXML).join(", ")}`); if (maskedStale > 0) sections.push(` Masked stale observations: ${maskedStale}`); const queryPaths = [...edited, ...read].slice(0, 5); sections.push(` For full details:`); sections.push(` ctx_search(queries: [${queryPaths.map((p) => `"${escapeXML(p)}"`).join(", ")}], source: "session-events")`); sections.push(` `); hasSections = true; } } // --- tasks --- const tasks = eventStore.getEvents(sessionId, { categories: ["task"], limit: CAPS.tasks }); if (tasks.length > 0) { const summaries: string[] = []; for (const t of tasks) { const data = safeParse(t.data); const content = extractTaskContent(data); if (content) summaries.push(escapeXML(content.slice(0, 100))); } if (summaries.length > 0) { sections.push(""); sections.push(` `); for (const s of summaries) sections.push(` ${s}`); sections.push(` For full details:`); sections.push(` ctx_search(queries: ["task", "todo"], source: "session-events")`); sections.push(` `); hasSections = true; } } // --- decisions --- const decisions = eventStore.getEvents(sessionId, { categories: ["decision"], limit: CAPS.decisions }); if (decisions.length > 0) { const summaries: string[] = []; for (const d of decisions) { const data = safeParse(d.data); const prompt = typeof data?.prompt === "string" ? data.prompt.slice(0, 100) : ""; if (prompt) summaries.push(escapeXML(prompt)); } if (summaries.length > 0) { sections.push(""); sections.push(` `); for (const s of summaries) sections.push(` ${s}`); sections.push(` `); hasSections = true; } } // --- errors --- const errors = eventStore.getEvents(sessionId, { categories: ["error"], limit: CAPS.errors }); if (errors.length > 0) { const summaries: string[] = []; for (const e of errors) { const data = safeParse(e.data); const summary = formatErrorSummary(data); if (summary) summaries.push(escapeXML(summary.slice(0, 150))); } if (summaries.length > 0) { sections.push(""); sections.push(` `); for (const s of summaries) sections.push(` ${s}`); sections.push(` `); hasSections = true; } } // --- git --- const gitEvents = eventStore.getEvents(sessionId, { categories: ["git"], limit: CAPS.git }); if (gitEvents.length > 0) { const summaries: string[] = []; for (const g of gitEvents) { const data = safeParse(g.data); const cmd = typeof data?.command === "string" ? data.command.slice(0, 100) : ""; if (cmd) summaries.push(escapeXML(cmd)); } if (summaries.length > 0) { sections.push(""); sections.push(` `); for (const s of summaries) sections.push(` ${s}`); sections.push(` `); hasSections = true; } } // --- skills --- const skillEvents = eventStore.getEvents(sessionId, { categories: ["skill"] }); if (skillEvents.length > 0) { const names = new Set(); for (const s of skillEvents) { const data = safeParse(s.data); const name = typeof data?.name === "string" ? data.name : typeof data?.skill === "string" ? data.skill : null; if (name) names.add(name); } if (names.size > 0) { sections.push(""); sections.push(` `); sections.push(` Activated: ${[...names].map(escapeXML).join(", ")}`); sections.push(` `); hasSections = true; } } // --- intent --- const intentEvents = eventStore.getEvents(sessionId, { categories: ["intent"], limit: 1 }); if (intentEvents.length > 0) { const data = safeParse(intentEvents[0].data); const mode = typeof data?.mode === "string" ? data.mode : typeof data?.intent === "string" ? data.intent : null; if (mode) { sections.push(""); sections.push(` Session mode: ${escapeXML(mode)}`); hasSections = true; } } // --- env --- const envEvents = eventStore.getEvents(sessionId, { categories: ["env"] }); if (envEvents.length > 0) { const details: string[] = []; for (const e of envEvents) { const data = safeParse(e.data); const detail = typeof data?.detail === "string" ? data.detail : typeof data?.env === "string" ? data.env : null; if (detail) details.push(escapeXML(detail.slice(0, 100))); } if (details.length > 0) { sections.push(""); sections.push(` `); for (const d of details) sections.push(` ${d}`); sections.push(` `); hasSections = true; } } // --- cwd --- const cwdEvents = eventStore.getEvents(sessionId, { categories: ["cwd"], limit: 1 }); if (cwdEvents.length > 0) { const data = safeParse(cwdEvents[0].data); const cwd = typeof data?.cwd === "string" ? data.cwd : typeof data?.path === "string" ? data.path : null; if (cwd) { sections.push(""); sections.push(` ${escapeXML(cwd)}`); hasSections = true; } } sections.push(""); if (!hasSections) return ""; return sections.join("\n"); } // --------------------------------------------------------------------------- // Fallback inline-truncated format (no supi-context-mode MCP) // --------------------------------------------------------------------------- function buildFallbackSnapshot(eventStore: EventStore, sessionId: string): string { const sections: string[] = [""]; // Last request const prompts = eventStore.getEvents(sessionId, { categories: ["prompt"], limit: 1 }); if (prompts.length > 0) { const data = safeParse(prompts[0].data); const prompt = typeof data?.prompt === "string" ? escapeXML(data.prompt.slice(0, 200)) : ""; if (prompt) { sections.push(` ${prompt}`); } } // Pending tasks const tasks = eventStore.getEvents(sessionId, { categories: ["task"], limit: CAPS.tasks }); if (tasks.length > 0) { sections.push(" "); for (const t of tasks) { const data = safeParse(t.data); const content = extractTaskContent(data); if (content) sections.push(` - ${escapeXML(content.slice(0, 100))}`); } sections.push(" "); } // Key decisions const decisions = eventStore.getEvents(sessionId, { categories: ["decision"], limit: CAPS.decisions }); if (decisions.length > 0) { sections.push(" "); for (const d of decisions) { const data = safeParse(d.data); const prompt = typeof data?.prompt === "string" ? escapeXML(data.prompt.slice(0, 100)) : ""; if (prompt) sections.push(` - ${prompt}`); } sections.push(" "); } // Files modified (write/edit only, deduplicated) const fileEvents = latestFirst(eventStore.getEvents(sessionId, { categories: ["file"], limit: 200 })); const modifiedPaths = new Set(); const seenSources = new Set(); let maskedStaleFiles = 0; for (const f of fileEvents) { const data = safeParse(f.data); const p = typeof data?.path === "string" ? data.path : null; if (!p) continue; if (data?.op !== "edit" && data?.op !== "write") continue; const sourceKey = typeof data?.sourceHash === "string" ? data.sourceHash : p.replace(/\\/g, "/"); if (seenSources.has(sourceKey)) { maskedStaleFiles += 1; continue; } seenSources.add(sourceKey); modifiedPaths.add(p); } if (modifiedPaths.size > 0) { sections.push(" "); const paths = [...modifiedPaths].slice(0, CAPS.files); for (const p of paths) sections.push(` - ${escapeXML(p)}`); if (maskedStaleFiles > 0) sections.push(` - stale observations masked: ${maskedStaleFiles}`); sections.push(" "); } // Recent errors const errors = eventStore.getEvents(sessionId, { categories: ["error"], limit: CAPS.errors }); if (errors.length > 0) { sections.push(" "); for (const e of errors) { const data = safeParse(e.data); const summary = formatErrorSummary(data); if (summary) sections.push(` - ${escapeXML(summary.slice(0, 150))}`); } sections.push(" "); } // Git state const gitEvents = eventStore.getEvents(sessionId, { categories: ["git"], limit: CAPS.git }); if (gitEvents.length > 0) { sections.push(" "); for (const g of gitEvents) { const data = safeParse(g.data); const cmd = typeof data?.command === "string" ? escapeXML(data.command.slice(0, 100)) : ""; if (cmd) sections.push(` - ${cmd}`); } sections.push(" "); } sections.push(""); // If only the wrapper tags exist (no inner sections), return empty if (sections.length <= 2) return ""; return sections.join("\n"); } function safeParse(json: string): Record | null { try { return JSON.parse(json); } catch { return null; } } function extractTaskContent(data: Record | null): string | null { if (!data?.input) return null; const input = data.input as Record; if (!Array.isArray(input.ops)) return JSON.stringify(input).slice(0, 100); const parts: string[] = []; for (const rawOp of input.ops as Array>) { if (!rawOp || typeof rawOp !== "object") continue; const verb = typeof rawOp.op === "string" ? rawOp.op : "task"; if (verb === "init" && Array.isArray(rawOp.list)) { for (const phase of rawOp.list as Array>) { const items = Array.isArray(phase?.items) ? phase.items : []; for (const item of items) { if (typeof item === "string" && item) parts.push(`init: ${item}`); else if (item && typeof item === "object" && typeof (item as { label?: unknown }).label === "string") { parts.push(`init: ${(item as { label: string }).label}`); } } } } else if (verb === "replace" && Array.isArray(rawOp.phases)) { // Legacy `todo_write` shape (pre-14.5.11): `op:"replace", phases:[{ name, tasks:[{ content }] }]`. // Persisted event rows still carry this shape until 7-day retention expires; we keep // read-side compatibility here so resume snapshots remain truthful for those rows. for (const phase of rawOp.phases as Array>) { const tasks = Array.isArray(phase?.tasks) ? phase.tasks : []; for (const task of tasks) { const content = task && typeof task === "object" && typeof (task as { content?: unknown }).content === "string" ? (task as { content: string }).content : ""; if (content) parts.push(`replace: ${content}`); } } } else if (verb === "append" && Array.isArray(rawOp.items)) { for (const item of rawOp.items) { if (typeof item === "string" && item) parts.push(`append: ${item}`); else if (item && typeof item === "object" && typeof (item as { label?: unknown }).label === "string") { // Legacy append items shaped as objects with `label`. parts.push(`append: ${(item as { label: string }).label}`); } } } else if (verb === "note") { const text = typeof rawOp.text === "string" ? rawOp.text : ""; if (text) parts.push(`note: ${text}`); } else { const legacyContent = typeof rawOp.content === "string" ? rawOp.content : ""; if (legacyContent) { parts.push(`${verb}: ${legacyContent}`); continue; } const target = (typeof rawOp.task === "string" && rawOp.task) || (typeof rawOp.phase === "string" && rawOp.phase) || "all"; parts.push(`${verb}: ${target}`); } } // Preserve the existing 100-char cap so prompts stay bounded. return parts.length > 0 ? parts.join("; ").slice(0, 100) : null; } function formatErrorSummary(data: Record | null): string | null { if (!data) return null; const command = typeof data.command === "string" ? data.command : ""; const toolName = typeof data.toolName === "string" ? data.toolName : ""; const exitCode = typeof data.exitCode === "number" ? ` (exit ${data.exitCode})` : ""; const prefix = command || toolName; return prefix ? `${prefix}${exitCode}` : null; }