/** * KHAELOR * File: src/context/engine.ts * Description: The Context Engine — Hermes' four verbs over the byte-stable history projection (ARCHITECTURE.md §6). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { AnthropicMessage, ContentBlockParam, ModelClient, ModelRequest, ModelUsage, SystemTier, ThinkingConfig, ToolSchema, } from "../anthropic/index.js"; import { buildConversation } from "../session/index.js"; import type { ConversationMessage, DurableEvent } from "../session/index.js"; import { KhaelorError } from "../shared/index.js"; import { estimateTokens } from "./budget.js"; import type { ContextBudget } from "./budget.js"; import { DEFAULT_COMPACTION_OPTIONS, compressEvents, selectCompactionCut, selectPruneCandidates, } from "./compaction.js"; import type { CompactionCheckpoint, CompactionOptions, PruneDecision } from "./compaction.js"; // ───────────────────────────── inputs and outputs ───────────────────────────── /** * One named block of per-turn volatile context (git status, running-process * list, mention contents…). Injected ONLY into the API copy of the current * user message — never into recorded history, never into the cached system * tiers (ARCHITECTURE.md §6.5 rule 3). */ export interface VolatileSection { name: string; text: string; } /** * The session state the engine consumes. The durable event stream (in seq * order) is the projection source; `volatile` is the current turn's * non-recorded context. */ export interface ContextSessionState { events: readonly DurableEvent[]; volatile?: readonly VolatileSection[]; } /** Per-section token ESTIMATES for the /context inspector — labeled as estimates. */ export interface ContextStats { estimatedInputTokens: number; sections: { name: string; estimatedTokens: number }[]; } export interface BuiltContext { request: ModelRequest; stats: ContextStats; } /** The four verbs (ARCHITECTURE.md §6.1). */ export interface ContextEngine { /** Assemble the model request from session state. V1: pass-through selection hook. */ selectContext(state: ContextSessionState): Promise; /** Summarize-compact. Returns the checkpoint the kernel records as ContextCompacted. */ compress(state: ContextSessionState, signal?: AbortSignal): Promise; /** Observation hook: real usage from the last response updates budget accounting. */ onTurnComplete(usage: ModelUsage): void; /** Cheap, deterministic, no-LLM prune decision. Runs BEFORE compress. */ pruneToolResults(state: ContextSessionState): PruneDecision; } // ───────────────────────────── construction ───────────────────────────── export interface ContextEngineOptions { /** Main model id — the request target. */ model: string; /** Aux model id — compaction summaries only (ADR-10). */ auxModel: string; maxOutputTokens: number; /** Built once per session (buildSystemPrompt) — held verbatim, never re-rendered. */ systemTiers: SystemTier[]; tools: ToolSchema[]; /** Injected client used exclusively for compaction summarization. */ modelClient: ModelClient; budget: ContextBudget; thinking?: ThinkingConfig; compaction?: Partial; } const VOLATILE_HEADER = "[Volatile context — current turn only, not part of the conversation]"; function toContentBlockParam(block: ConversationMessage["content"][number]): ContentBlockParam { switch (block.type) { case "text": return { type: "text", text: block.text }; case "thinking": return { type: "thinking", thinking: block.thinking, signature: block.signature }; case "tool_use": return { type: "tool_use", id: block.id, name: block.name, input: block.input }; case "tool_result": { const result: ContentBlockParam = { type: "tool_result", tool_use_id: block.tool_use_id, content: block.content, }; if (block.is_error === true) result.is_error = true; return result; } } } function renderVolatile(sections: readonly VolatileSection[]): string { const parts = sections.map((s) => `\n${s.text}\n`); return `${VOLATILE_HEADER}\n${parts.join("\n")}`; } // ───────────────────────────── the engine ───────────────────────────── /** * V1 Context Engine. History is a deterministic projection of durable events * (byte-stable: same events ⇒ same bytes on every call — ARCHITECTURE.md * §6.5 rule 2); compaction and pruning are decisions returned to the kernel, * which records them as durable events. The engine itself emits nothing. */ export class KhaelorContextEngine implements ContextEngine { readonly #model: string; readonly #auxModel: string; readonly #maxOutputTokens: number; readonly #systemTiers: readonly SystemTier[]; readonly #tools: readonly ToolSchema[]; readonly #modelClient: ModelClient; readonly #budget: ContextBudget; readonly #thinking: ThinkingConfig | undefined; readonly #compaction: CompactionOptions; constructor(options: ContextEngineOptions) { this.#model = options.model; this.#auxModel = options.auxModel; this.#maxOutputTokens = options.maxOutputTokens; // Frozen verbatim at construction: built once per session, byte-stable. this.#systemTiers = Object.freeze(options.systemTiers.map((t) => ({ ...t }))); this.#tools = options.tools; this.#modelClient = options.modelClient; this.#budget = options.budget; this.#thinking = options.thinking; this.#compaction = { ...DEFAULT_COMPACTION_OPTIONS, ...(options.compaction ?? {}) }; } /** Budget accessor for the kernel's deriveNext (compaction flag source). */ get budget(): ContextBudget { return this.#budget; } async selectContext(state: ContextSessionState): Promise { const history = buildConversation(state.events).map( (message): AnthropicMessage => ({ role: message.role, content: message.content.map(toContentBlockParam), }), ); // Volatile context goes ONLY into the API copy of the current message — // the history projection above is never mutated (§6.5 rule 3). let messages = history; let volatileText = ""; const volatile = state.volatile ?? []; if (volatile.length > 0) { volatileText = renderVolatile(volatile); const last = history[history.length - 1]; if (last !== undefined && last.role === "user") { messages = [ ...history.slice(0, -1), { role: "user", content: [...last.content, { type: "text", text: volatileText }] }, ]; } else { messages = [...history, { role: "user", content: [{ type: "text", text: volatileText }] }]; } } const request: ModelRequest = { model: this.#model, system: [...this.#systemTiers], messages, tools: [...this.#tools], maxOutputTokens: this.#maxOutputTokens, ...(this.#thinking !== undefined ? { thinking: this.#thinking } : {}), }; const sections: ContextStats["sections"] = this.#systemTiers.map((tier) => ({ name: `system:${tier.name}`, estimatedTokens: estimateTokens(tier.text), })); sections.push({ name: "tools", estimatedTokens: estimateTokens(JSON.stringify(this.#tools)), }); sections.push({ name: "history", estimatedTokens: estimateTokens(JSON.stringify(history)), }); if (volatileText.length > 0) { sections.push({ name: "volatile", estimatedTokens: estimateTokens(volatileText) }); } return { request, stats: { estimatedInputTokens: sections.reduce((sum, s) => sum + s.estimatedTokens, 0), sections, }, }; } pruneToolResults(state: ContextSessionState): PruneDecision { return selectPruneCandidates(state.events, this.#compaction); } onTurnComplete(usage: ModelUsage): void { this.#budget.onUsage(usage); } async compress(state: ContextSessionState, signal?: AbortSignal): Promise { const cut = selectCompactionCut(state.events, this.#compaction); if (cut === null) { throw new KhaelorError( "internal", "no pairing-safe compaction cut available — conversation too small to compact", ); } return compressEvents( { modelClient: this.#modelClient, model: this.#auxModel, maxOutputTokens: this.#compaction.summaryMaxOutputTokens }, { events: state.events, cut, trigger: this.#deriveTrigger(state.events), tokensBefore: this.#budget.lastTotalTokens, ...(signal !== undefined ? { signal } : {}), }, this.#compaction, ); } /** Reactive when an unresolved context-overflow is recorded; proactive when over budget; else user-initiated. */ #deriveTrigger(events: readonly DurableEvent[]): CompactionCheckpoint["trigger"] { for (let i = events.length - 1; i >= 0; i--) { const event = events[i] as DurableEvent; if (event.type === "context.compacted") break; if (event.type === "model.request-failed" && event.payload.kind === "context-overflow") { return "reactive-overflow"; } } if (this.#budget.shouldCompact()) return "proactive-token-budget"; return "user-command"; } }