SPB Git

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%
16.8 KB · 443 lines typescript
Raw Blame History
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