import type { CtxVarsConfig } from "./config.ts"; export interface CandidateInfo { id: string; /** Stable store ref (var_N) when the variable already exists. */ varRef?: string; kind: string; sizeTokens: number; text: string; /** Set when the main agent recorded a binding decision for this entry. */ bindingDecision?: string; /** Set when this entry was previously pinned with a decayable pin. */ previouslyPinned?: boolean; } export interface ActiveArchiveInfo { ref: string; summary: string; } export interface SnapshotItem { text: string; refs: string[]; } export interface SnapshotTopic { name: string; summary: string; refs: string[]; } /** Structured state returned by the compaction model. The extension owns the * final Markdown layout; the model supplies facts and grouping only. */ export interface ContextSnapshot { currentTask: string; currentState: SnapshotItem[]; constraints: SnapshotItem[]; keyDecisions: SnapshotItem[]; openItems: SnapshotItem[]; topics: SnapshotTopic[]; } export interface CompactionPacket { candidates: CandidateInfo[]; activeArchives: ActiveArchiveInfo[]; rollupTargets: ActiveArchiveInfo[]; previousSummary: string | null; } const SYSTEM_PROMPT = `You are the compaction agent for a session using variable-based context management. Every conversation entry is preserved in a persistent variable store. Your actions control only what remains in ACTIVE model context. Nothing is deleted. Actions per candidate: - "pin": keep FULL content visible verbatim. Use only when exact content is required now. - "archive": preserve a useful per-entry summary in the store. - "drop": show no individual representation. Content remains searchable in the store. You must also return a structured CONTEXT SNAPSHOT. It is not a transcript. It describes what the main agent needs to continue the work now. Candidate policy: 1. Keep user requirements, constraints, decisions, verified facts, exact values, useful paths, completed changes, failures that affect the plan, and unresolved questions. 2. DROP procedural noise: file reads, line-printing, directory listings, command acknowledgements, repeated status messages, and tool output whose useful result is already recorded elsewhere. 3. MERGE related information in snapshot topics. Never reproduce one snapshot bullet per message. 4. Tool results should contribute facts, not narration such as "read 64 lines" or "printed lines 1-16". 5. Dead ends should be dropped unless the failure prevents repeating an important mistake; then keep one short fact. 6. MAIN AGENT DECISION (binding) must be followed exactly. If binding archive has no supplied summary, write the summary. 7. Pins you choose must use pin_decay=true. Only an explicit binding main-agent decision can create a persistent pin. A decayable pin is re-decided normally; a persistent pin is rendered separately by the extension. 8. Every candidate id must appear exactly once in decisions. Keep per-entry archive summaries dense (normally one or two sentences). 9. depends_on may contain up to five var_N references that are necessary to understand the entry. Snapshot policy: 1. current_task: one concise statement of the active task. 2. current_state: facts about what is implemented, observed, or currently true. Do not describe tool usage. 3. constraints: requirements which still govern the work. 4. key_decisions: decisions and reasons that affect future work. 5. open_items: concrete unfinished work, risks, or questions. 6. topics: synthesize related active knowledge. Order topics by importance. Do not make a topic for each variable. 7. Attach only useful var_N refs. References provide traceability and retrieval; prose must remain understandable without reading them. 8. Carry forward relevant state from the previous snapshot. Remove completed or stale procedural details. 9. Do not include pinned verbatim content in the snapshot. The extension inserts it from the store. 10. Stay within the visible snapshot budget stated below. Reply with ONLY JSON, with this shape: { "decisions": [ {"id":"","action":"pin|archive|drop","summary":"...","depends_on":["var_12"],"pin_decay":true} ], "snapshot": { "current_task":"...", "current_state":[{"text":"...","refs":["var_12"]}], "constraints":[{"text":"...","refs":["var_4"]}], "key_decisions":[{"text":"...","refs":["var_8"]}], "open_items":[{"text":"...","refs":[]}], "topics":[{"name":"Message identity","summary":"...","refs":["var_12","var_14"]}] }, "rollup":{"summary":"..."} | null }`; function candidateLines(c: CandidateInfo): string { const flags: string[] = []; if (c.bindingDecision) flags.push(`MAIN AGENT DECISION (binding): ${c.bindingDecision}`); if (c.previouslyPinned) flags.push("previously pinned (decayable)"); const flagText = flags.length ? ` [${flags.join("; ")}]` : ""; const varText = c.varRef ? ` var=${c.varRef}` : ""; return `### id=${c.id}${varText} kind=${c.kind} ~${c.sizeTokens}t${flagText}\n${c.text}`; } export function buildCompactionMessages(packet: CompactionPacket, cfg: CtxVarsConfig) { const parts: string[] = [ `## Visible snapshot budget\nTarget at most ~${cfg.summaryTokenBudget} tokens, excluding verbatim persistent pins. Use at most ${cfg.snapshotMaxTopics} active topics.`, ]; if (packet.previousSummary) { parts.push(`## Previous compacted context\n${packet.previousSummary.slice(0, cfg.summaryTokenBudget * 4)}`); } if (packet.rollupTargets.length > 0) { parts.push( `## Historical rollup request\nCompress these OLD archive summaries into one dense historical memory entry. Set rollup.summary.\n` + packet.rollupTargets.map((a) => `- [${a.ref}] ${a.summary}`).join("\n"), ); } else { parts.push("## Historical rollup request\nNone. Set rollup to null."); } parts.push(`## Candidates leaving raw context (oldest first)\n${packet.candidates.map(candidateLines).join("\n\n") || "(none)"}`); if (packet.activeArchives.length > 0) { parts.push( `## Existing active archive summaries\nUse these as source material for the updated snapshot. Do not copy them one by one.\n${packet.activeArchives .map((a) => `- [${a.ref}] ${a.summary}`) .join("\n")}`, ); } else { parts.push("## Existing active archive summaries\n(none)"); } const now = Date.now(); return [ { role: "user" as const, content: [{ type: "text" as const, text: `${SYSTEM_PROMPT}\n\n${parts.join("\n\n")}` }], timestamp: now, }, ]; } export interface ParsedDecision { id: string; action: "pin" | "archive" | "drop"; summary?: string; depends_on?: string[]; pin_decay?: boolean; } export interface ParsedOutput { decisions: ParsedDecision[]; snapshot: ContextSnapshot; rollup: { summary: string } | null; } const EMPTY_SNAPSHOT: ContextSnapshot = { currentTask: "", currentState: [], constraints: [], keyDecisions: [], openItems: [], topics: [], }; function refsOf(value: unknown): string[] { if (!Array.isArray(value)) return []; const seen = new Set(); const refs: string[] = []; for (const raw of value) { const ref = String(raw); if (!/^var_\d+$/.test(ref) || seen.has(ref)) continue; seen.add(ref); refs.push(ref); if (refs.length >= 12) break; } return refs; } function itemsOf(value: unknown): SnapshotItem[] { if (!Array.isArray(value)) return []; const result: SnapshotItem[] = []; for (const raw of value) { if (typeof raw === "string") { if (raw.trim()) result.push({ text: raw.trim(), refs: [] }); } else if (raw && typeof raw === "object") { const item = raw as { text?: unknown; refs?: unknown }; const text = typeof item.text === "string" ? item.text.trim() : ""; if (text) result.push({ text, refs: refsOf(item.refs) }); } } return result; } function snapshotOf(value: unknown): ContextSnapshot { if (!value || typeof value !== "object") return { ...EMPTY_SNAPSHOT }; const raw = value as Record; const topics: SnapshotTopic[] = []; if (Array.isArray(raw.topics)) { for (const value of raw.topics) { if (!value || typeof value !== "object") continue; const t = value as { name?: unknown; summary?: unknown; refs?: unknown }; const name = typeof t.name === "string" ? t.name.trim() : ""; const summary = typeof t.summary === "string" ? t.summary.trim() : ""; if (name && summary) topics.push({ name, summary, refs: refsOf(t.refs) }); } } return { currentTask: typeof raw.current_task === "string" ? raw.current_task.trim() : "", currentState: itemsOf(raw.current_state), constraints: itemsOf(raw.constraints), keyDecisions: itemsOf(raw.key_decisions), openItems: itemsOf(raw.open_items), topics, }; } export function parseCompactionOutput(text: string): ParsedOutput | null { let cleaned = text.trim(); const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/); if (fence) cleaned = fence[1].trim(); const start = cleaned.indexOf("{"); const end = cleaned.lastIndexOf("}"); if (start === -1 || end === -1) return null; try { const parsed = JSON.parse(cleaned.slice(start, end + 1)) as Record; if (!Array.isArray(parsed.decisions)) return null; const decisions: ParsedDecision[] = []; for (const raw of parsed.decisions) { const d = raw as Partial | null | undefined; if (!d || typeof d !== "object") continue; const id = d.id ? String(d.id) : ""; if (!id) continue; decisions.push({ id, action: d.action === "pin" || d.action === "drop" ? d.action : "archive", summary: typeof d.summary === "string" ? d.summary : undefined, depends_on: refsOf(d.depends_on), pin_decay: d.pin_decay !== false, }); } const rollupRaw = parsed.rollup as { summary?: unknown } | null | undefined; const rollupSummary = rollupRaw && typeof rollupRaw.summary === "string" ? rollupRaw.summary.trim() : ""; return { decisions, snapshot: snapshotOf(parsed.snapshot), rollup: rollupSummary ? { summary: rollupSummary } : null, }; } catch { return null; } }