spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/context/compaction.ts4 * Description: Compression pipeline — deterministic prune selection, pairing-safe cut selection, summarize-the-middle via the aux model (ARCHITECTURE.md §6.2–6.4).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type { ModelClient, ModelRequest } from "../anthropic/index.js";11import { buildConversation, isPairingSafeCut } from "../session/index.js";12import type { ConversationMessage, DurableEvent } from "../session/index.js";13import { KhaelorError } from "../shared/index.js";14import { estimateTokens } from "./budget.js";15import { emptyCheckpoint, parseCheckpoint, serializeCheckpoint } from "./checkpoint.js";16import type { Checkpoint, CheckpointProcess } from "./checkpoint.js";1718// ───────────────────────────── shared vocabulary ─────────────────────────────1920/**21 * The FIXED placeholder replacing pruned tool results. The value used is22 * recorded on every ContextPruned event, so replay stays byte-exact even if23 * this default ever changes (EVENT_MODEL.md §4).24 */25export const PRUNE_PLACEHOLDER =26 "[Tool result pruned to reclaim context — re-run the tool if this output is needed again.]";2728/** What the kernel records as a durable `ContextPruned` event. */29export interface PruneDecision {30 toolUseIds: string[];31 placeholder: string;32 tokensReclaimedEstimate: number;33}3435/** What the kernel records as a durable `ContextCompacted` event (payload-shaped). */36export interface CompactionCheckpoint {37 checkpointYaml: string;38 cut: { fromSeq: number; toSeq: number };39 trigger: "proactive-token-budget" | "reactive-overflow" | "user-command";40 tokensBefore: number;41 summaryModel: string;42}4344export interface CompactionOptions {45 /** Newest tool-output tokens (estimate) protected from pruning. Default ~40K (ARCHITECTURE.md §6.2). */46 protectRecentToolTokens: number;47 /** Recent conversation tokens (estimate) protected from the compaction cut. */48 protectTailTokens: number;49 /** Output budget for the summarization call — deliberately small. */50 summaryMaxOutputTokens: number;51 /** Per-block cap in the summarization transcript. */52 maxBlockChars: number;53 /** Total transcript cap (head + tail retained around a truncation marker). */54 maxTranscriptChars: number;55}5657export const DEFAULT_COMPACTION_OPTIONS: Readonly<CompactionOptions> = Object.freeze({58 protectRecentToolTokens: 40_000,59 protectTailTokens: 20_000,60 summaryMaxOutputTokens: 2_000,61 maxBlockChars: 4_000,62 maxTranscriptChars: 300_000,63});6465// ───────────────────────────── prune selection ─────────────────────────────6667interface SeqRange {68 fromSeq: number;69 toSeq: number;70}7172function compactedRanges(events: readonly DurableEvent[]): SeqRange[] {73 const ranges: SeqRange[] = [];74 for (const event of events) {75 if (event.type === "context.compacted") ranges.push(event.payload.cut);76 }77 return ranges;78}7980function inAnyRange(seq: number, ranges: readonly SeqRange[]): boolean {81 return ranges.some((r) => seq >= r.fromSeq && seq <= r.toSeq);82}8384function alreadyPrunedIds(events: readonly DurableEvent[]): Set<string> {85 const ids = new Set<string>();86 for (const event of events) {87 if (event.type === "context.pruned") {88 for (const id of event.payload.toolUseIds) ids.add(id);89 }90 }91 return ids;92}9394/**95 * Cheap, deterministic, no-LLM first-line reduction (ARCHITECTURE.md §6.1):96 * select old tool results to blank with the fixed placeholder, protecting the97 * newest `protectRecentToolTokens` (estimated) of tool output. Results already98 * pruned or consumed by an earlier compaction are skipped.99 */100export function selectPruneCandidates(101 events: readonly DurableEvent[],102 options: CompactionOptions = { ...DEFAULT_COMPACTION_OPTIONS },103): PruneDecision {104 const ranges = compactedRanges(events);105 const pruned = alreadyPrunedIds(events);106 const placeholderTokens = estimateTokens(PRUNE_PLACEHOLDER);107108 const results: { toolUseId: string; tokens: number }[] = [];109 for (const event of events) {110 if (event.type !== "tool.completed" && event.type !== "tool.failed" && event.type !== "tool.cancelled") {111 continue;112 }113 if (pruned.has(event.payload.toolUseId)) continue;114 if (inAnyRange(event.seq, ranges)) continue;115 results.push({116 toolUseId: event.payload.toolUseId,117 tokens: estimateTokens(event.payload.modelText),118 });119 }120121 // Protect the newest N tokens of tool output; everything older is a candidate.122 let protectedTokens = 0;123 let protectFromIndex = results.length;124 for (let i = results.length - 1; i >= 0; i--) {125 protectedTokens += (results[i] as { tokens: number }).tokens;126 if (protectedTokens > options.protectRecentToolTokens) break;127 protectFromIndex = i;128 }129130 const toolUseIds: string[] = [];131 let reclaimed = 0;132 for (let i = 0; i < protectFromIndex; i++) {133 const result = results[i] as { toolUseId: string; tokens: number };134 if (result.tokens <= placeholderTokens) continue; // nothing to reclaim135 toolUseIds.push(result.toolUseId);136 reclaimed += result.tokens - placeholderTokens;137 }138139 return { toolUseIds, placeholder: PRUNE_PLACEHOLDER, tokensReclaimedEstimate: reclaimed };140}141142// ───────────────────────────── cut selection ─────────────────────────────143144/** Event types that contribute content to the LlmHistory projection. */145const CONVERSATION_EVENT_TYPES = new Set<DurableEvent["type"]>([146 "user.message-created",147 "user.steering-injected",148 "model.text-block-completed",149 "model.thinking-block-completed",150 "tool.requested",151 "tool.completed",152 "tool.failed",153 "tool.cancelled",154 "context.compacted",155]);156157function conversationTextEstimate(event: DurableEvent): number {158 switch (event.type) {159 case "user.message-created":160 return estimateTokens(event.payload.text);161 case "model.text-block-completed":162 return estimateTokens(event.payload.text);163 case "model.thinking-block-completed":164 return estimateTokens(event.payload.thinking);165 case "tool.requested":166 return estimateTokens(JSON.stringify(event.payload.input));167 case "tool.completed":168 case "tool.failed":169 case "tool.cancelled":170 return estimateTokens(event.payload.modelText);171 case "context.compacted":172 return estimateTokens(event.payload.checkpointYaml);173 default:174 return 0;175 }176}177178/**179 * Choose the compaction cut: protect the head (the first user message — the180 * original objective, Hermes-derived design) and the recent tail (at minimum181 * everything from the latest user message onward, and at least182 * `protectTailTokens` of estimated recent content). The cut lands ONLY at183 * pairing-safe indices — `toSeq` walks down until every tool_use inside the184 * cut has its tool_result inside the cut (EVENT_MODEL.md §6.5.3).185 *186 * Returns null when no compactable middle exists.187 */188export function selectCompactionCut(189 events: readonly DurableEvent[],190 options: CompactionOptions = { ...DEFAULT_COMPACTION_OPTIONS },191): SeqRange | null {192 const conversation = events.filter((e) => CONVERSATION_EVENT_TYPES.has(e.type));193 if (conversation.length === 0) return null;194195 const firstUser = conversation.find((e) => e.type === "user.message-created");196 if (firstUser === undefined) return null;197198 // Tail floor 1: everything from the latest user message onward stays.199 let lastUserSeq = -1;200 for (const event of conversation) {201 if (event.type === "user.message-created") lastUserSeq = event.seq;202 }203204 // Tail floor 2: at least protectTailTokens of estimated recent content stays.205 let tailTokens = 0;206 let tailStartSeq = Number.POSITIVE_INFINITY;207 for (let i = conversation.length - 1; i >= 0; i--) {208 const event = conversation[i] as DurableEvent;209 if (event.seq <= firstUser.seq) break;210 tailStartSeq = event.seq;211 tailTokens += conversationTextEstimate(event);212 if (tailTokens >= options.protectTailTokens) break;213 }214 const protectedFromSeq = Math.min(tailStartSeq, lastUserSeq === firstUser.seq ? tailStartSeq : lastUserSeq);215216 const candidates = conversation217 .filter((e) => e.seq > firstUser.seq && e.seq < protectedFromSeq)218 .map((e) => e.seq);219 if (candidates.length === 0) return null;220221 const fromSeq = candidates[0] as number;222 for (let i = candidates.length - 1; i >= 0; i--) {223 const toSeq = candidates[i] as number;224 if (toSeq < fromSeq) break;225 if (isPairingSafeCut(events, { fromSeq, toSeq })) return { fromSeq, toSeq };226 }227 return null;228}229230// ───────────────────────────── middle extraction and transcript ─────────────────────────────231232/**233 * The durable events whose conversation content falls inside the cut, plus234 * the events required to reproduce that content deterministically:235 * steering-queued texts (referenced by injections) and every ContextPruned236 * event (placeholder application is id-targeted and idempotent) — so the237 * summarizer sees pruned placeholders, never the reclaimed output238 * (prune-first ordering, ARCHITECTURE.md §6.2).239 */240export function extractCutEvents(events: readonly DurableEvent[], cut: SeqRange): DurableEvent[] {241 const middle: DurableEvent[] = [];242 const prunes: DurableEvent[] = [];243 for (const event of events) {244 if (event.type === "user.steering-queued") {245 middle.push(event);246 } else if (event.type === "context.pruned") {247 prunes.push(event);248 } else if (249 CONVERSATION_EVENT_TYPES.has(event.type) &&250 event.seq >= cut.fromSeq &&251 event.seq <= cut.toSeq252 ) {253 middle.push(event);254 }255 }256 return [...middle, ...prunes];257}258259function capText(text: string, maxChars: number): string {260 if (text.length <= maxChars) return text;261 return text.slice(0, maxChars) + "\n… [truncated]";262}263264/** Render the middle messages as a plain-text transcript for the summarizer. */265export function renderTranscript(266 messages: readonly ConversationMessage[],267 options: CompactionOptions = { ...DEFAULT_COMPACTION_OPTIONS },268): string {269 const parts: string[] = [];270 for (const message of messages) {271 for (const block of message.content) {272 if (block.type === "text") {273 parts.push(`[${message.role}]\n${capText(block.text, options.maxBlockChars)}`);274 } else if (block.type === "tool_use") {275 parts.push(276 `[tool_use ${block.name} ${block.id}]\n${capText(JSON.stringify(block.input), options.maxBlockChars)}`,277 );278 } else if (block.type === "tool_result") {279 const marker = block.is_error === true ? " (error)" : "";280 parts.push(`[tool_result ${block.tool_use_id}${marker}]\n${capText(block.content, options.maxBlockChars)}`);281 }282 // thinking blocks are omitted from the summarization transcript.283 }284 }285 const transcript = parts.join("\n\n");286 if (transcript.length <= options.maxTranscriptChars) return transcript;287 const headChars = Math.floor(options.maxTranscriptChars * 0.4);288 const tailChars = Math.floor(options.maxTranscriptChars * 0.5);289 return (290 transcript.slice(0, headChars) +291 "\n\n[… transcript truncated for summarization …]\n\n" +292 transcript.slice(transcript.length - tailChars)293 );294}295296// ───────────────────────────── deterministic checkpoint fields ─────────────────────────────297298/** `running_processes` derives from durable events, never from the LLM (ADR-6). */299export function collectRunningProcesses(events: readonly DurableEvent[]): CheckpointProcess[] {300 const running = new Map<string, CheckpointProcess>();301 for (const event of events) {302 if (event.type === "process.started") {303 running.set(event.payload.processId, {304 id: event.payload.processId,305 command: event.payload.command,306 status: "running",307 });308 } else if (event.type === "process.exited") {309 running.delete(event.payload.processId);310 }311 }312 return [...running.values()];313}314315// ───────────────────────────── summarization ─────────────────────────────316317const 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):318319objective: <the user's current objective, one paragraph>320completed:321 - <finished sub-goal>322current_state: <where the work stands right now>323important_files:324 - path: <file path>325 reason: <why it matters to remaining work>326changes:327 - <file-level change made so far>328failed_attempts:329 - <approach tried and abandoned, with why>330decisions:331 - <decision taken and rationale>332next_steps:333 - <concrete next action>334335Use \`key: []\` for sections with no entries. Be specific and factual; never invent results.`;336337export interface SummarizeDeps {338 modelClient: ModelClient;339 /** The configured auxModel (ADR-10) — compaction summaries never use the main model. */340 model: string;341 maxOutputTokens: number;342}343344/**345 * Summarize the transcript via the injected ModelClient. Small output budget,346 * cancellable end to end: the AbortSignal propagates into the stream and the347 * call rejects with the client's typed cancellation error.348 */349export async function summarizeToCheckpointText(350 deps: SummarizeDeps,351 transcript: string,352 signal?: AbortSignal,353): Promise<string> {354 const request: ModelRequest = {355 model: deps.model,356 system: [{ name: "compaction", text: SUMMARY_SYSTEM_PROMPT }],357 messages: [358 {359 role: "user",360 content: [361 {362 type: "text",363 text: `Transcript of the conversation segment to checkpoint:\n\n${transcript}\n\nProduce the checkpoint now.`,364 },365 ],366 },367 ],368 tools: [],369 maxOutputTokens: deps.maxOutputTokens,370 };371372 const completedBlocks: string[] = [];373 let deltas = "";374 for await (const event of deps.modelClient.stream(request, signal)) {375 if (event.type === "text-block-completed") completedBlocks.push(event.text);376 else if (event.type === "text-delta") deltas += event.text;377 }378 return completedBlocks.length > 0 ? completedBlocks.join("\n") : deltas;379}380381/** Strip code fences and leading prose so the parser sees the checkpoint body. */382function extractCheckpointBody(raw: string): string {383 let text = raw.trim();384 const fence = /^```[a-zA-Z]*\n([\s\S]*?)\n```$/.exec(text);385 if (fence) text = (fence[1] as string).trim();386 const start = text.search(/^objective:/m);387 return start > 0 ? text.slice(start) : text;388}389390/** Lenient parse: structured when possible, otherwise the raw summary becomes `current_state`. */391export function parseCheckpointLenient(raw: string): Checkpoint {392 const parsed = parseCheckpoint(extractCheckpointBody(raw));393 if (parsed.ok) return parsed.value;394 const fallback = emptyCheckpoint();395 fallback.currentState = raw.trim();396 return fallback;397}398399// ───────────────────────────── the pipeline ─────────────────────────────400401export interface CompressPipelineInput {402 events: readonly DurableEvent[];403 cut: SeqRange;404 trigger: CompactionCheckpoint["trigger"];405 /** From real usage accounting (ContextBudget.lastTotalTokens). */406 tokensBefore: number;407 signal?: AbortSignal;408}409410/**411 * The compression pipeline (ARCHITECTURE.md §6.2 order): the middle slice is412 * built from events with recorded prunes already applied (prune-first), then413 * summarized via the aux model into the structured checkpoint. Deterministic414 * fields (`running_processes`) come from events, not the LLM.415 */416export async function compressEvents(417 deps: SummarizeDeps,418 input: CompressPipelineInput,419 options: CompactionOptions = { ...DEFAULT_COMPACTION_OPTIONS },420): Promise<CompactionCheckpoint> {421 if (!isPairingSafeCut(input.events, input.cut)) {422 throw new KhaelorError("pairing-violation", "compaction cut is not pairing-safe", {423 cut: input.cut,424 });425 }426 const middle = extractCutEvents(input.events, input.cut);427 const transcript = renderTranscript(buildConversation(middle), options);428 const raw = await summarizeToCheckpointText(429 { ...deps, maxOutputTokens: options.summaryMaxOutputTokens },430 transcript,431 input.signal,432 );433 const checkpoint = parseCheckpointLenient(raw);434 checkpoint.runningProcesses = collectRunningProcesses(input.events);435 return {436 checkpointYaml: serializeCheckpoint(checkpoint),437 cut: input.cut,438 trigger: input.trigger,439 tokensBefore: input.tokensBefore,440 summaryModel: deps.model,441 };442}443