/** * KHAELOR * File: src/context/compaction.ts * Description: Compression pipeline — deterministic prune selection, pairing-safe cut selection, summarize-the-middle via the aux model (ARCHITECTURE.md §6.2–6.4). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { ModelClient, ModelRequest } from "../anthropic/index.js"; import { buildConversation, isPairingSafeCut } from "../session/index.js"; import type { ConversationMessage, DurableEvent } from "../session/index.js"; import { KhaelorError } from "../shared/index.js"; import { estimateTokens } from "./budget.js"; import { emptyCheckpoint, parseCheckpoint, serializeCheckpoint } from "./checkpoint.js"; import type { Checkpoint, CheckpointProcess } from "./checkpoint.js"; // ───────────────────────────── shared vocabulary ───────────────────────────── /** * The FIXED placeholder replacing pruned tool results. The value used is * recorded on every ContextPruned event, so replay stays byte-exact even if * this default ever changes (EVENT_MODEL.md §4). */ export const PRUNE_PLACEHOLDER = "[Tool result pruned to reclaim context — re-run the tool if this output is needed again.]"; /** What the kernel records as a durable `ContextPruned` event. */ export interface PruneDecision { toolUseIds: string[]; placeholder: string; tokensReclaimedEstimate: number; } /** What the kernel records as a durable `ContextCompacted` event (payload-shaped). */ export interface CompactionCheckpoint { checkpointYaml: string; cut: { fromSeq: number; toSeq: number }; trigger: "proactive-token-budget" | "reactive-overflow" | "user-command"; tokensBefore: number; summaryModel: string; } export interface CompactionOptions { /** Newest tool-output tokens (estimate) protected from pruning. Default ~40K (ARCHITECTURE.md §6.2). */ protectRecentToolTokens: number; /** Recent conversation tokens (estimate) protected from the compaction cut. */ protectTailTokens: number; /** Output budget for the summarization call — deliberately small. */ summaryMaxOutputTokens: number; /** Per-block cap in the summarization transcript. */ maxBlockChars: number; /** Total transcript cap (head + tail retained around a truncation marker). */ maxTranscriptChars: number; } export const DEFAULT_COMPACTION_OPTIONS: Readonly = Object.freeze({ protectRecentToolTokens: 40_000, protectTailTokens: 20_000, summaryMaxOutputTokens: 2_000, maxBlockChars: 4_000, maxTranscriptChars: 300_000, }); // ───────────────────────────── prune selection ───────────────────────────── interface SeqRange { fromSeq: number; toSeq: number; } function compactedRanges(events: readonly DurableEvent[]): SeqRange[] { const ranges: SeqRange[] = []; for (const event of events) { if (event.type === "context.compacted") ranges.push(event.payload.cut); } return ranges; } function inAnyRange(seq: number, ranges: readonly SeqRange[]): boolean { return ranges.some((r) => seq >= r.fromSeq && seq <= r.toSeq); } function alreadyPrunedIds(events: readonly DurableEvent[]): Set { const ids = new Set(); for (const event of events) { if (event.type === "context.pruned") { for (const id of event.payload.toolUseIds) ids.add(id); } } return ids; } /** * Cheap, deterministic, no-LLM first-line reduction (ARCHITECTURE.md §6.1): * select old tool results to blank with the fixed placeholder, protecting the * newest `protectRecentToolTokens` (estimated) of tool output. Results already * pruned or consumed by an earlier compaction are skipped. */ export function selectPruneCandidates( events: readonly DurableEvent[], options: CompactionOptions = { ...DEFAULT_COMPACTION_OPTIONS }, ): PruneDecision { const ranges = compactedRanges(events); const pruned = alreadyPrunedIds(events); const placeholderTokens = estimateTokens(PRUNE_PLACEHOLDER); const results: { toolUseId: string; tokens: number }[] = []; for (const event of events) { if (event.type !== "tool.completed" && event.type !== "tool.failed" && event.type !== "tool.cancelled") { continue; } if (pruned.has(event.payload.toolUseId)) continue; if (inAnyRange(event.seq, ranges)) continue; results.push({ toolUseId: event.payload.toolUseId, tokens: estimateTokens(event.payload.modelText), }); } // Protect the newest N tokens of tool output; everything older is a candidate. let protectedTokens = 0; let protectFromIndex = results.length; for (let i = results.length - 1; i >= 0; i--) { protectedTokens += (results[i] as { tokens: number }).tokens; if (protectedTokens > options.protectRecentToolTokens) break; protectFromIndex = i; } const toolUseIds: string[] = []; let reclaimed = 0; for (let i = 0; i < protectFromIndex; i++) { const result = results[i] as { toolUseId: string; tokens: number }; if (result.tokens <= placeholderTokens) continue; // nothing to reclaim toolUseIds.push(result.toolUseId); reclaimed += result.tokens - placeholderTokens; } return { toolUseIds, placeholder: PRUNE_PLACEHOLDER, tokensReclaimedEstimate: reclaimed }; } // ───────────────────────────── cut selection ───────────────────────────── /** Event types that contribute content to the LlmHistory projection. */ const CONVERSATION_EVENT_TYPES = new Set([ "user.message-created", "user.steering-injected", "model.text-block-completed", "model.thinking-block-completed", "tool.requested", "tool.completed", "tool.failed", "tool.cancelled", "context.compacted", ]); function conversationTextEstimate(event: DurableEvent): number { switch (event.type) { case "user.message-created": return estimateTokens(event.payload.text); case "model.text-block-completed": return estimateTokens(event.payload.text); case "model.thinking-block-completed": return estimateTokens(event.payload.thinking); case "tool.requested": return estimateTokens(JSON.stringify(event.payload.input)); case "tool.completed": case "tool.failed": case "tool.cancelled": return estimateTokens(event.payload.modelText); case "context.compacted": return estimateTokens(event.payload.checkpointYaml); default: return 0; } } /** * Choose the compaction cut: protect the head (the first user message — the * original objective, Hermes-derived design) and the recent tail (at minimum * everything from the latest user message onward, and at least * `protectTailTokens` of estimated recent content). The cut lands ONLY at * pairing-safe indices — `toSeq` walks down until every tool_use inside the * cut has its tool_result inside the cut (EVENT_MODEL.md §6.5.3). * * Returns null when no compactable middle exists. */ export function selectCompactionCut( events: readonly DurableEvent[], options: CompactionOptions = { ...DEFAULT_COMPACTION_OPTIONS }, ): SeqRange | null { const conversation = events.filter((e) => CONVERSATION_EVENT_TYPES.has(e.type)); if (conversation.length === 0) return null; const firstUser = conversation.find((e) => e.type === "user.message-created"); if (firstUser === undefined) return null; // Tail floor 1: everything from the latest user message onward stays. let lastUserSeq = -1; for (const event of conversation) { if (event.type === "user.message-created") lastUserSeq = event.seq; } // Tail floor 2: at least protectTailTokens of estimated recent content stays. let tailTokens = 0; let tailStartSeq = Number.POSITIVE_INFINITY; for (let i = conversation.length - 1; i >= 0; i--) { const event = conversation[i] as DurableEvent; if (event.seq <= firstUser.seq) break; tailStartSeq = event.seq; tailTokens += conversationTextEstimate(event); if (tailTokens >= options.protectTailTokens) break; } const protectedFromSeq = Math.min(tailStartSeq, lastUserSeq === firstUser.seq ? tailStartSeq : lastUserSeq); const candidates = conversation .filter((e) => e.seq > firstUser.seq && e.seq < protectedFromSeq) .map((e) => e.seq); if (candidates.length === 0) return null; const fromSeq = candidates[0] as number; for (let i = candidates.length - 1; i >= 0; i--) { const toSeq = candidates[i] as number; if (toSeq < fromSeq) break; if (isPairingSafeCut(events, { fromSeq, toSeq })) return { fromSeq, toSeq }; } return null; } // ───────────────────────────── middle extraction and transcript ───────────────────────────── /** * The durable events whose conversation content falls inside the cut, plus * the events required to reproduce that content deterministically: * steering-queued texts (referenced by injections) and every ContextPruned * event (placeholder application is id-targeted and idempotent) — so the * summarizer sees pruned placeholders, never the reclaimed output * (prune-first ordering, ARCHITECTURE.md §6.2). */ export function extractCutEvents(events: readonly DurableEvent[], cut: SeqRange): DurableEvent[] { const middle: DurableEvent[] = []; const prunes: DurableEvent[] = []; for (const event of events) { if (event.type === "user.steering-queued") { middle.push(event); } else if (event.type === "context.pruned") { prunes.push(event); } else if ( CONVERSATION_EVENT_TYPES.has(event.type) && event.seq >= cut.fromSeq && event.seq <= cut.toSeq ) { middle.push(event); } } return [...middle, ...prunes]; } function capText(text: string, maxChars: number): string { if (text.length <= maxChars) return text; return text.slice(0, maxChars) + "\n… [truncated]"; } /** Render the middle messages as a plain-text transcript for the summarizer. */ export function renderTranscript( messages: readonly ConversationMessage[], options: CompactionOptions = { ...DEFAULT_COMPACTION_OPTIONS }, ): string { const parts: string[] = []; for (const message of messages) { for (const block of message.content) { if (block.type === "text") { parts.push(`[${message.role}]\n${capText(block.text, options.maxBlockChars)}`); } else if (block.type === "tool_use") { parts.push( `[tool_use ${block.name} ${block.id}]\n${capText(JSON.stringify(block.input), options.maxBlockChars)}`, ); } else if (block.type === "tool_result") { const marker = block.is_error === true ? " (error)" : ""; parts.push(`[tool_result ${block.tool_use_id}${marker}]\n${capText(block.content, options.maxBlockChars)}`); } // thinking blocks are omitted from the summarization transcript. } } const transcript = parts.join("\n\n"); if (transcript.length <= options.maxTranscriptChars) return transcript; const headChars = Math.floor(options.maxTranscriptChars * 0.4); const tailChars = Math.floor(options.maxTranscriptChars * 0.5); return ( transcript.slice(0, headChars) + "\n\n[… transcript truncated for summarization …]\n\n" + transcript.slice(transcript.length - tailChars) ); } // ───────────────────────────── deterministic checkpoint fields ───────────────────────────── /** `running_processes` derives from durable events, never from the LLM (ADR-6). */ export function collectRunningProcesses(events: readonly DurableEvent[]): CheckpointProcess[] { const running = new Map(); for (const event of events) { if (event.type === "process.started") { running.set(event.payload.processId, { id: event.payload.processId, command: event.payload.command, status: "running", }); } else if (event.type === "process.exited") { running.delete(event.payload.processId); } } return [...running.values()]; } // ───────────────────────────── summarization ───────────────────────────── const SUMMARY_SYSTEM_PROMPT = `You summarize an engineering agent's conversation so it can continue with less context. Respond with ONLY a structured checkpoint in exactly this format (plain text, no code fences): objective: completed: - current_state: important_files: - path: reason: changes: - failed_attempts: - decisions: - next_steps: - Use \`key: []\` for sections with no entries. Be specific and factual; never invent results.`; export interface SummarizeDeps { modelClient: ModelClient; /** The configured auxModel (ADR-10) — compaction summaries never use the main model. */ model: string; maxOutputTokens: number; } /** * Summarize the transcript via the injected ModelClient. Small output budget, * cancellable end to end: the AbortSignal propagates into the stream and the * call rejects with the client's typed cancellation error. */ export async function summarizeToCheckpointText( deps: SummarizeDeps, transcript: string, signal?: AbortSignal, ): Promise { const request: ModelRequest = { model: deps.model, system: [{ name: "compaction", text: SUMMARY_SYSTEM_PROMPT }], messages: [ { role: "user", content: [ { type: "text", text: `Transcript of the conversation segment to checkpoint:\n\n${transcript}\n\nProduce the checkpoint now.`, }, ], }, ], tools: [], maxOutputTokens: deps.maxOutputTokens, }; const completedBlocks: string[] = []; let deltas = ""; for await (const event of deps.modelClient.stream(request, signal)) { if (event.type === "text-block-completed") completedBlocks.push(event.text); else if (event.type === "text-delta") deltas += event.text; } return completedBlocks.length > 0 ? completedBlocks.join("\n") : deltas; } /** Strip code fences and leading prose so the parser sees the checkpoint body. */ function extractCheckpointBody(raw: string): string { let text = raw.trim(); const fence = /^```[a-zA-Z]*\n([\s\S]*?)\n```$/.exec(text); if (fence) text = (fence[1] as string).trim(); const start = text.search(/^objective:/m); return start > 0 ? text.slice(start) : text; } /** Lenient parse: structured when possible, otherwise the raw summary becomes `current_state`. */ export function parseCheckpointLenient(raw: string): Checkpoint { const parsed = parseCheckpoint(extractCheckpointBody(raw)); if (parsed.ok) return parsed.value; const fallback = emptyCheckpoint(); fallback.currentState = raw.trim(); return fallback; } // ───────────────────────────── the pipeline ───────────────────────────── export interface CompressPipelineInput { events: readonly DurableEvent[]; cut: SeqRange; trigger: CompactionCheckpoint["trigger"]; /** From real usage accounting (ContextBudget.lastTotalTokens). */ tokensBefore: number; signal?: AbortSignal; } /** * The compression pipeline (ARCHITECTURE.md §6.2 order): the middle slice is * built from events with recorded prunes already applied (prune-first), then * summarized via the aux model into the structured checkpoint. Deterministic * fields (`running_processes`) come from events, not the LLM. */ export async function compressEvents( deps: SummarizeDeps, input: CompressPipelineInput, options: CompactionOptions = { ...DEFAULT_COMPACTION_OPTIONS }, ): Promise { if (!isPairingSafeCut(input.events, input.cut)) { throw new KhaelorError("pairing-violation", "compaction cut is not pairing-safe", { cut: input.cut, }); } const middle = extractCutEvents(input.events, input.cut); const transcript = renderTranscript(buildConversation(middle), options); const raw = await summarizeToCheckpointText( { ...deps, maxOutputTokens: options.summaryMaxOutputTokens }, transcript, input.signal, ); const checkpoint = parseCheckpointLenient(raw); checkpoint.runningProcesses = collectRunningProcesses(input.events); return { checkpointYaml: serializeCheckpoint(checkpoint), cut: input.cut, trigger: input.trigger, tokensBefore: input.tokensBefore, summaryModel: deps.model, }; }