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