// 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;
}