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/engine.ts4 * Description: The Context Engine — Hermes' four verbs over the byte-stable history projection (ARCHITECTURE.md §6).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type {11 AnthropicMessage,12 ContentBlockParam,13 ModelClient,14 ModelRequest,15 ModelUsage,16 SystemTier,17 ThinkingConfig,18 ToolSchema,19} from "../anthropic/index.js";20import { buildConversation } from "../session/index.js";21import type { ConversationMessage, DurableEvent } from "../session/index.js";22import { KhaelorError } from "../shared/index.js";23import { estimateTokens } from "./budget.js";24import type { ContextBudget } from "./budget.js";25import {26 DEFAULT_COMPACTION_OPTIONS,27 compressEvents,28 selectCompactionCut,29 selectPruneCandidates,30} from "./compaction.js";31import type { CompactionCheckpoint, CompactionOptions, PruneDecision } from "./compaction.js";3233// ───────────────────────────── inputs and outputs ─────────────────────────────3435/**36 * One named block of per-turn volatile context (git status, running-process37 * list, mention contents…). Injected ONLY into the API copy of the current38 * user message — never into recorded history, never into the cached system39 * tiers (ARCHITECTURE.md §6.5 rule 3).40 */41export interface VolatileSection {42 name: string;43 text: string;44}4546/**47 * The session state the engine consumes. The durable event stream (in seq48 * order) is the projection source; `volatile` is the current turn's49 * non-recorded context.50 */51export interface ContextSessionState {52 events: readonly DurableEvent[];53 volatile?: readonly VolatileSection[];54}5556/** Per-section token ESTIMATES for the /context inspector — labeled as estimates. */57export interface ContextStats {58 estimatedInputTokens: number;59 sections: { name: string; estimatedTokens: number }[];60}6162export interface BuiltContext {63 request: ModelRequest;64 stats: ContextStats;65}6667/** The four verbs (ARCHITECTURE.md §6.1). */68export interface ContextEngine {69 /** Assemble the model request from session state. V1: pass-through selection hook. */70 selectContext(state: ContextSessionState): Promise<BuiltContext>;71 /** Summarize-compact. Returns the checkpoint the kernel records as ContextCompacted. */72 compress(state: ContextSessionState, signal?: AbortSignal): Promise<CompactionCheckpoint>;73 /** Observation hook: real usage from the last response updates budget accounting. */74 onTurnComplete(usage: ModelUsage): void;75 /** Cheap, deterministic, no-LLM prune decision. Runs BEFORE compress. */76 pruneToolResults(state: ContextSessionState): PruneDecision;77}7879// ───────────────────────────── construction ─────────────────────────────8081export interface ContextEngineOptions {82 /** Main model id — the request target. */83 model: string;84 /** Aux model id — compaction summaries only (ADR-10). */85 auxModel: string;86 maxOutputTokens: number;87 /** Built once per session (buildSystemPrompt) — held verbatim, never re-rendered. */88 systemTiers: SystemTier[];89 tools: ToolSchema[];90 /** Injected client used exclusively for compaction summarization. */91 modelClient: ModelClient;92 budget: ContextBudget;93 thinking?: ThinkingConfig;94 compaction?: Partial<CompactionOptions>;95}9697const VOLATILE_HEADER = "[Volatile context — current turn only, not part of the conversation]";9899function toContentBlockParam(block: ConversationMessage["content"][number]): ContentBlockParam {100 switch (block.type) {101 case "text":102 return { type: "text", text: block.text };103 case "thinking":104 return { type: "thinking", thinking: block.thinking, signature: block.signature };105 case "tool_use":106 return { type: "tool_use", id: block.id, name: block.name, input: block.input };107 case "tool_result": {108 const result: ContentBlockParam = {109 type: "tool_result",110 tool_use_id: block.tool_use_id,111 content: block.content,112 };113 if (block.is_error === true) result.is_error = true;114 return result;115 }116 }117}118119function renderVolatile(sections: readonly VolatileSection[]): string {120 const parts = sections.map((s) => `<context name="${s.name}">\n${s.text}\n</context>`);121 return `${VOLATILE_HEADER}\n${parts.join("\n")}`;122}123124// ───────────────────────────── the engine ─────────────────────────────125126/**127 * V1 Context Engine. History is a deterministic projection of durable events128 * (byte-stable: same events ⇒ same bytes on every call — ARCHITECTURE.md129 * §6.5 rule 2); compaction and pruning are decisions returned to the kernel,130 * which records them as durable events. The engine itself emits nothing.131 */132export class KhaelorContextEngine implements ContextEngine {133 readonly #model: string;134 readonly #auxModel: string;135 readonly #maxOutputTokens: number;136 readonly #systemTiers: readonly SystemTier[];137 readonly #tools: readonly ToolSchema[];138 readonly #modelClient: ModelClient;139 readonly #budget: ContextBudget;140 readonly #thinking: ThinkingConfig | undefined;141 readonly #compaction: CompactionOptions;142143 constructor(options: ContextEngineOptions) {144 this.#model = options.model;145 this.#auxModel = options.auxModel;146 this.#maxOutputTokens = options.maxOutputTokens;147 // Frozen verbatim at construction: built once per session, byte-stable.148 this.#systemTiers = Object.freeze(options.systemTiers.map((t) => ({ ...t })));149 this.#tools = options.tools;150 this.#modelClient = options.modelClient;151 this.#budget = options.budget;152 this.#thinking = options.thinking;153 this.#compaction = { ...DEFAULT_COMPACTION_OPTIONS, ...(options.compaction ?? {}) };154 }155156 /** Budget accessor for the kernel's deriveNext (compaction flag source). */157 get budget(): ContextBudget {158 return this.#budget;159 }160161 async selectContext(state: ContextSessionState): Promise<BuiltContext> {162 const history = buildConversation(state.events).map(163 (message): AnthropicMessage => ({164 role: message.role,165 content: message.content.map(toContentBlockParam),166 }),167 );168169 // Volatile context goes ONLY into the API copy of the current message —170 // the history projection above is never mutated (§6.5 rule 3).171 let messages = history;172 let volatileText = "";173 const volatile = state.volatile ?? [];174 if (volatile.length > 0) {175 volatileText = renderVolatile(volatile);176 const last = history[history.length - 1];177 if (last !== undefined && last.role === "user") {178 messages = [179 ...history.slice(0, -1),180 { role: "user", content: [...last.content, { type: "text", text: volatileText }] },181 ];182 } else {183 messages = [...history, { role: "user", content: [{ type: "text", text: volatileText }] }];184 }185 }186187 const request: ModelRequest = {188 model: this.#model,189 system: [...this.#systemTiers],190 messages,191 tools: [...this.#tools],192 maxOutputTokens: this.#maxOutputTokens,193 ...(this.#thinking !== undefined ? { thinking: this.#thinking } : {}),194 };195196 const sections: ContextStats["sections"] = this.#systemTiers.map((tier) => ({197 name: `system:${tier.name}`,198 estimatedTokens: estimateTokens(tier.text),199 }));200 sections.push({201 name: "tools",202 estimatedTokens: estimateTokens(JSON.stringify(this.#tools)),203 });204 sections.push({205 name: "history",206 estimatedTokens: estimateTokens(JSON.stringify(history)),207 });208 if (volatileText.length > 0) {209 sections.push({ name: "volatile", estimatedTokens: estimateTokens(volatileText) });210 }211212 return {213 request,214 stats: {215 estimatedInputTokens: sections.reduce((sum, s) => sum + s.estimatedTokens, 0),216 sections,217 },218 };219 }220221 pruneToolResults(state: ContextSessionState): PruneDecision {222 return selectPruneCandidates(state.events, this.#compaction);223 }224225 onTurnComplete(usage: ModelUsage): void {226 this.#budget.onUsage(usage);227 }228229 async compress(state: ContextSessionState, signal?: AbortSignal): Promise<CompactionCheckpoint> {230 const cut = selectCompactionCut(state.events, this.#compaction);231 if (cut === null) {232 throw new KhaelorError(233 "internal",234 "no pairing-safe compaction cut available — conversation too small to compact",235 );236 }237 return compressEvents(238 { modelClient: this.#modelClient, model: this.#auxModel, maxOutputTokens: this.#compaction.summaryMaxOutputTokens },239 {240 events: state.events,241 cut,242 trigger: this.#deriveTrigger(state.events),243 tokensBefore: this.#budget.lastTotalTokens,244 ...(signal !== undefined ? { signal } : {}),245 },246 this.#compaction,247 );248 }249250 /** Reactive when an unresolved context-overflow is recorded; proactive when over budget; else user-initiated. */251 #deriveTrigger(events: readonly DurableEvent[]): CompactionCheckpoint["trigger"] {252 for (let i = events.length - 1; i >= 0; i--) {253 const event = events[i] as DurableEvent;254 if (event.type === "context.compacted") break;255 if (event.type === "model.request-failed" && event.payload.kind === "context-overflow") {256 return "reactive-overflow";257 }258 }259 if (this.#budget.shouldCompact()) return "proactive-token-budget";260 return "user-command";261 }262}263