# KHAELOR V1 — System Architecture > **Status:** Phase 1 deliverable — the definitive system design for KHAELOR V1. > **Inputs:** `CLAUDE.md` (product spec), `docs/research/KHAELOR_ARCHITECTURE_DECISIONS.md` (ADR-1…17, binding), `docs/research/COMPARATIVE_ARCHITECTURE.md` (evidence). > **Companion:** `docs/EVENT_MODEL.md` (the complete typed event vocabulary — normative for every event named here). > > Phase 2 implementation starts from this document. Where this document and an ADR disagree, the ADR wins and this document must be corrected. --- ## 1. Overview KHAELOR V1 is a single-process, terminal-native, Anthropic-only agent (ADR-1, ADR-17). Its architecture rests on five load-bearing decisions: 1. **A tiny, state-derived kernel** (ADR-2). The loop re-derives "what next" from recorded session state each iteration. The kernel coordinates; services own everything else. 2. **An event-sourced session** (ADR-3, ADR-4). One append-only JSONL file per session is the sole source of truth. UI state, LLM message history, cost, and metadata are all projections. Resume = replay. 3. **First-class streaming** (ADR-5). Everything is an event; deltas flow ephemerally to the TUI, completed blocks flow durably to the log. There is no non-streaming path. 4. **Prompt-cache byte-stability as an invariant** (ADR-7). History is never rewritten; compaction — recorded as an event — is the sole sanctioned break. 5. **One world seam** (ADR-13). Tools act on the world only through `Workspace`. No `node:fs` / `node:child_process` outside `src/workspace/` (and the narrow, listed exemptions). ### 1.1 System diagram (refined from CLAUDE.md §4) ``` ┌─────────────────────────────┐ │ KHAELOR TUI │ │ composer · timeline · bars │ └────┬───────────────▲────────┘ KernelCommand│ │ coalesced events (~16 ms) │ │ ┌─────────────────────────────▼───────────────┴─────────────────────────────┐ │ EVENT BUS (typed, in-process) │ │ durable events ──► SessionStore (JSONL append, write-ahead) │ │ ephemeral deltas ──► TUI coalescer only │ └────────────▲───────────────────▲──────────────────────▲───────────────────┘ │ │ │ ┌───────┴────────┐ ┌───────┴────────┐ ┌─────────┴─────────┐ │ Session Engine │ │ Agent Kernel │ │ Process Manager │ │ log·projections│◄─┤ (state-derived│ │ (background procs)│ │ resume=replay │ │ loop, ADR-2) │ └─────────▲─────────┘ └───────▲────────┘ └───┬───┬───┬────┘ │ │ │ │ │ │ │ ┌────────┘ │ └─────────┐ │ │ ▼ ▼ ▼ │ │ ┌──────────┐ ┌──────────┐ ┌───────────┐ │ │ │ Context │ │ Model │ │ Tool │──┘ │ │ Engine │ │ Runtime │ │ Runtime │──► Permissions │ │ (ADR-6/7)│ │ (ADR-10) │ │ (ADR-8) │ (ADR-9) │ └────┬─────┘ └────┬─────┘ └─────┬─────┘ │ │ │ │ └──────┤ Anthropic API ▼ ▼ (official SDK) ┌───────────┐ ┌─────────────┐ │ Workspace │──► files · processes │ Repository │───────────────►│ (ADR-13) │ │ Intelligence│ └───────────┘ └─────────────┘ ``` Reading the diagram: - The **TUI never calls the engine directly.** It emits `KernelCommand`s (submit message, interrupt, approve permission, run slash command) and consumes events. Nothing imports the TUI (see §2). - The **event bus is the only "API"** (ADR-17). Durable events are appended to the session log *before* being published (write-ahead, see `EVENT_MODEL.md §5`). - The **kernel** touches exactly five services: Context Engine, Model Runtime, Tool Runtime, Session Engine (via its projection handle), and the verification gate. Permissions, workspace, and repository intelligence sit behind those services. --- ## 2. Module Map and Dependency Rules ### 2.1 Dependency direction rules (normative) Modules are arranged in strict layers. **An arrow means "may import"; anything not listed is forbidden.** ESLint (`import/no-restricted-paths` or `eslint-plugin-boundaries`) enforces this in CI alongside the header check. ``` Layer 0 shared (imports nothing) Layer 1 config · workspace · anthropic · session Layer 2 permissions · repository · tools · context Layer 3 agent Layer 4 tui Layer 5 cli (composition root) ``` Rules, explicitly: 1. **`shared` imports nothing** (except `node:` builtins for pure utilities). The event vocabulary, envelope types, the `EventBus` interface, `KernelCommand`, `Result`, and error base types live here — this is what keeps the graph acyclic, since every layer speaks in these types. 2. **Nothing imports `tui` except `cli`.** The TUI is a pure consumer of events and producer of `KernelCommand`s. 3. **`tui` may import only `shared` and `config`** (theme/keybinding types). It renders from its own view-model, built by folding events (see `EVENT_MODEL.md §6.1`). It never imports `agent`, `session`, `tools`, or `anthropic`. 4. **`tools` may import `workspace`, `repository`, and `shared` only.** Tools never check permissions themselves (the Tool Runtime gates them, §5.4) and never touch Node fs/process globals. 5. **`workspace` is the only module that imports `node:fs` and `node:child_process`.** Exemptions (each individually listed in the lint rule config): `session/store` (its own log file I/O), `config/loader` (config file I/O), `cli` (bootstrap), `shared/logging` (log files under `~/.khaelor/logs/`). 6. **`anthropic` imports `shared` only** (plus the `@anthropic-ai/sdk` package). Config values are passed in; it never reads config or sessions. 7. **`session` imports `shared` only.** It owns the JSONL store and all projections. 8. **`context` may import `session` (projection types), `repository`, `anthropic` (the `ModelClient` interface, for the auxiliary compaction model), and `shared`.** 9. **`agent` may import `anthropic`, `tools`, `context`, `session`, `permissions`, `workspace`, and `shared`.** It is the only module that wires services together below `cli`. 10. **`cli` may import everything.** It is the composition root: it constructs the bus, store, workspace, runtimes, kernel, and TUI, and wires `KernelCommand` dispatch. 11. **No module imports another module's internals** — only its `index.ts` barrel. Deep imports fail lint. 12. **No cycles, ever.** A cycle is a build failure, not a warning. ### 2.2 Module responsibilities and public interfaces Interface sketches below are the *public barrel* of each module — real, compilable-looking signatures that Phase 2–5 implement. Event types referenced (e.g. `DurableEvent`) are defined normatively in `EVENT_MODEL.md`. #### `src/shared/` — Layer 0 Event vocabulary and envelopes, event bus, kernel commands, shared result/error types, logging, ids (ULID), token/byte utilities. **No business logic.** ```ts // shared/events.ts — full definitions in EVENT_MODEL.md export type DurableEvent = /* discriminated union, EVENT_MODEL.md §4 */; export type EphemeralEvent = /* discriminated union, EVENT_MODEL.md §4 */; export type KhaelorEvent = DurableEvent | EphemeralEvent; export interface EventBus { publishDurable(e: DurableEventInput): DurableEvent; // append-then-publish; assigns seq/id/ts publishEphemeral(e: EphemeralEvent): void; on( type: T, handler: (e: Extract) => void, ): Unsubscribe; onAny(handler: (e: KhaelorEvent) => void): Unsubscribe; } // shared/commands.ts — the TUI→engine channel export type KernelCommand = | { kind: "submit-message"; text: string; mentions: FileMention[] } | { kind: "steer"; text: string } | { kind: "interrupt" } | { kind: "permission-response"; requestId: string; decision: "once" | "always" | "deny" } | { kind: "slash"; command: string; args: string } | { kind: "shell"; command: string } // "!git status" composer shortcut | { kind: "quit" }; export type Unsubscribe = () => void; export type Result = { ok: true; value: T } | { ok: false; error: E }; ``` #### `src/config/` — Layer 1 Schema, loading, precedence merge (CLI flags → `.khaelor/config.json` → `~/.khaelor/config.json` → env → defaults), secret hygiene (never log/echo keys), `KHAELOR.md` / `CLAUDE.md` / `AGENTS.md` instruction-file discovery with precedence. ```ts export interface KhaelorConfig { model: string; // Anthropic model id — never hard-coded lists (CLAUDE.md §6) auxModel: string; // cheaper model for compaction summaries (ADR-10) thinking: "off" | "adaptive" | "always"; maxOutputTokens: number; permissions: Record; // capability → allow|ask|deny theme: ThemeConfig; } export function loadConfig(argv: ParsedArgs, cwd: string): Promise; export function discoverInstructions(cwd: string): Promise; // ordered, global → nested export function persistPermissionGrant(scope: "project", rule: PermissionRule): Promise; // ADR-9 ``` #### `src/workspace/` — Layer 1 The world seam (ADR-13). Exactly four methods plus process spawning support used by the Process Manager. `LocalWorkspace` is the only V1 implementation. ```ts export interface Workspace { cwd(): string; readFile(path: string): Promise; writeFile(path: string, content: string): Promise; // atomic: tmp + rename exec(command: Command): Promise; } export interface Command { cmd: string; // run via user shell for `bash` tool cwd?: string; timeoutMs: number; // hard ceiling; ADR-8: long commands redirect to `process` env?: Record; signal?: AbortSignal; } export interface ProcessResult { exitCode: number | null; // null = killed by timeout/signal stdout: string; stderr: string; durationMs: number; truncated: boolean; } export class LocalWorkspace implements Workspace { /* only module touching node:fs/child_process */ } // Tool-side helpers PARAMETERIZED BY Workspace (never fattening the interface — ADR-13): export function statFile(ws: Workspace, path: string): Promise; export function fileExists(ws: Workspace, path: string): Promise; ``` #### `src/anthropic/` — Layer 1 `ModelClient` over the official SDK (ADR-10). Streaming, typed errors, retry/backoff, cancellation, honest usage. See §7. #### `src/session/` — Layer 1 Event log store (JSONL), projections, resume-by-replay, session metadata, checkpoint types. See §6. #### `src/permissions/` — Layer 2 Capability policy and evaluator (ADR-9). ```ts export type Capability = | "file.read" | "file.write.project" | "process.execute" | "network.access" | "git.modify" | "filesystem.outsideProject"; export type PermissionAction = "allow" | "ask" | "deny"; export interface PermissionRule { capability: Capability; pattern: string; action: PermissionAction; } export interface PermissionRequest { capability: Capability; descriptor: string; // e.g. "bash: npm install", "write: src/agent/kernel.ts" toolUseId: string; suggestion?: PermissionRule; // "always allow `git push *`" — omitted when the command } // contains shell operators (Hermes guard, ADR-9) export interface PermissionEvaluator { evaluate(req: PermissionRequest): PermissionAction; // last-match-wins over rules + hardline floor addGrant(rule: PermissionRule): Promise; // persists via config (ADR-9) } ``` The evaluator is deterministic: a small unbypassable deny floor (checked against de-obfuscated command variants) → configured rules, last match wins with wildcards → default `ask` for unmatched destructive capabilities. Denials produce feedback text delivered to the model as an observation (ADR-9, rejection-with-feedback). #### `src/repository/` — Layer 2 Repository intelligence, exposed **only** through the Context Engine (CLAUDE.md §11) and the tools that wrap it. V1: filesystem map, git status/diff/baseline, ripgrep search, frecency-ranked file finding for `@` mentions. No AST/embeddings/symbol index. ```ts export interface RepositoryIndex { fileMap(opts?: { maxEntries?: number }): Promise; // respects .gitignore/.khaelorignore gitStatus(): Promise; // branch, dirty, untracked gitDiff(paths?: string[]): Promise; recordBaseline(when: "session-start" | "pre-first-edit"): Promise; // ADR-15 attributeChanges(): Promise<{ khaelor: string[]; preExisting: string[] }>; search(query: RipgrepQuery): Promise; // bounded, structured findFiles(fuzzy: string, limit: number): Promise; // frecency-ranked noteAccess(path: string): void; // feeds frecency + recency } ``` #### `src/tools/` — Layer 2 The seven primitives (ADR-8): `read · write · edit · grep · glob · bash · process`, plus the registry and the process manager. Each tool: ≤5 parameters, rich description, structured result with model-facing text and UI-facing metadata. ```ts export interface ToolDefinition { name: ToolName; description: string; // guidance prose lives here, not in params inputSchema: JSONSchema; // ≤5 properties capability: (input: In, ctx: ToolContext) => Capability; // drives permission evaluation execute: (input: In, ctx: ToolContext) => Promise; } export interface ToolContext { workspace: Workspace; repo: RepositoryIndex; processes: ProcessManager; signal: AbortSignal; // turn-scoped; aborts in-flight tools (ADR-11) emitOutput: (chunk: string) => void; // → ephemeral ToolOutput events budget: OutputBudget; // per-result + per-turn caps, spill-to-file (ADR-8) } export interface ToolResult { modelText: string; // budgeted head/tail-truncated text with omission markers isError: boolean; spillFile?: string; // full output path under ~/.khaelor/spill/, model can read/grep it ui: ToolUiMeta; // diffStats, matchCount, exitCode… for collapsed rendering filesModified?: FileModification[]; } export interface ToolRegistry { definitions(): ToolDefinition[]; // data-driven — the ADR-16 seam get(name: string): ToolDefinition | undefined; } export interface ProcessManager { // model-facing via the `process` tool (ADR-8) start(cmd: string, opts: { cwd?: string; name?: string }): ManagedProcess; list(): ManagedProcessInfo[]; read(id: string, opts?: { offset?: number; limit?: number }): ProcessReadResult; // rolling 200K buffer write(id: string, input: string): void; stop(id: string): Promise; // process-group kill (mini hygiene, ADR-8) stopAll(): Promise; // shutdown only — NOT on interrupt (ADR-11) } ``` `edit` implements the replacer cascade (exact → line-trimmed → block-anchor → whitespace-normalized → indentation-flexible → escape-normalized → trimmed-boundary → context-aware → multi-occurrence) with uniqueness and disproportionate-match guards, CRLF/BOM preservation, atomic writes, and OpenHands-grade failure messages (line-number hints, "maybe you meant", post-edit snippet). Every successful `write`/`edit` emits a durable `FileModified` event carrying diff stats and a capped unified diff. #### `src/context/` — Layer 2 The Context Engine (ADR-6/7). See §8. #### `src/agent/` — Layer 3 `AgentKernel` (the loop), `deriveNext` (pure state → decision), the stream-event reducer, the Tool Runtime (permission gate + execution + observation recording), the verification gate (ADR-12), steering queue, interruption controller. See §5. #### `src/tui/` — Layer 4 Terminal UI: app shell, timeline, composer, markdown renderer (settled-block streaming), diff viewer, tool views, palettes, status bar, permission panel, coalescer. Framework selected by the Phase 1 spike (ADR-14); this module's *external contract* is framework-independent: ```ts export interface TuiApp { start(io: { events: EventBus; dispatch: (c: KernelCommand) => void; config: ResolvedConfig }): Promise; stop(): Promise; } ``` Internally, a `Coalescer` batches ephemeral deltas at ~16 ms (contract in `EVENT_MODEL.md §7`); the view-model is a fold over events; rendering uses settled-block incremental markdown with a bounded live region (hard caps on live-region chars/lines). No 100-message scrollback cliff. #### `src/cli/` — Layer 5 Entry point, argv parsing, config resolution, composition root, lifecycle (signals, terminal setup/teardown, crash-safe restore), lazy-import discipline (§13), `khaelor --debug` logging switch. --- ## 3. Data & Control Flow (one turn, end to end) ``` user types → composer → KernelCommand{submit-message} → cli dispatcher → durable UserMessageCreated appended + published → kernel loop wakes (it is the sole consumer of "work exists" state) → ContextEngine.selectContext(projection) → ModelRequest (byte-stable tiers, ADR-7) → ModelClient.stream(request, signal) deltas → ephemeral ModelTextDelta / ModelThinkingDelta / ToolInputDelta → TUI coalescer settled → durable ModelTextBlockCompleted / ModelThinkingBlockCompleted / ToolRequested finish → durable ModelResponseCompleted {usage, stopReason} → for each ToolRequested (sequential, block order): Tool Runtime: capability → PermissionEvaluator ask → durable PermissionRequested → TUI panel → PermissionGranted/Denied durable ToolApproved → ToolStarted → execute (ephemeral ToolOutput chunks) → durable ToolCompleted | ToolFailed | ToolCancelled (+ FileModified / ProcessStarted …) → steering queue drained at the post-tool-batch seam → durable SteeringInjected → ContextEngine.onTurnComplete(usage) → maybe durable ContextPruned / ContextCompacted → loop re-derives: more tool calls? overflow? stop? → on stop with unverified code changes → verification gate (≤2 nudges) → TaskCompleted{evidence} ``` Every durable event is appended to the JSONL **before** subscribers see it. The TUI, the session store, and the LLM history projection all consume the same stream — streaming, history, and state cannot diverge. --- ## 4. The Agent Kernel (ADR-2) The kernel is a small loop that re-derives "what next" from recorded session state each iteration. Exit conditions derive from state, never from in-memory flags. Target size: a few hundred lines including the reducer — anything larger is carrying non-kernel work. ### 4.1 Kernel state Per ADR-2 / mini's rule ("if a field isn't consulted by the loop itself, it belongs to a service"), the kernel holds exactly: ```ts interface KernelDeps { session: SessionHandle; // projection access + durable publish context: ContextEngine; model: ModelClient; tools: ToolRuntime; // registry + permission gate + executor verifier: VerificationGate; // ADR-12 steering: SteeringQueue; // ADR-11 bus: EventBus; } interface KernelRunState { turnSignal: AbortController; // the cancellation root for this run (ADR-11) budget: { iterations: number; verificationAttempts: number }; } ``` ### 4.2 The loop (precise pseudocode) ```ts async function runTurn(deps: KernelDeps, run: KernelRunState): Promise { while (true) { // 1. Re-derive what to do from RECORDED state — never from loop-local flags. const state = deps.session.projection(); // in-memory, event-derived (rebuilt from const next = deriveNext(state, run.budget); // JSONL only on resume — ADR-2 trade-off) switch (next.kind) { case "inject-steering": { // Safe seam: after tool results / before the next model call (ADR-11). deps.session.publish(steeringInjected(next.queued, next.seam)); continue; } case "compact": { // Proactive (token budget) or reactive (overflow error recorded). ADR-6. const checkpoint = await deps.context.compress(state, run.turnSignal.signal); deps.session.publish(contextCompacted(checkpoint)); // durable; replay-deterministic continue; } case "call-model": { const ctx = await deps.context.selectContext(state); // byte-stable tiers (ADR-7) try { for await (const ev of deps.model.stream(ctx.request, run.turnSignal.signal)) { reduceModelEvent(ev, deps); // pure reducer: deltas → ephemeral publish; } // settled blocks / tool_use / usage → durable publish } catch (err) { const klass = classifyModelError(err); // typed taxonomy (ADR-10) deps.session.publish(modelRequestFailed(klass)); if (klass.kind === "context-overflow") continue; // → deriveNext yields "compact" if (klass.kind === "cancelled") continue; // → deriveNext sees Interrupted if (klass.retryable) continue; // ModelClient already backed off return { kind: "failed", error: klass }; // fatal → TaskFailed recorded by caller } continue; } case "execute-tools": { // Sequential, block order. The runtime gates permissions, executes, and records // ToolApproved/Started/Completed/Failed/Cancelled + FileModified/Process* itself. await deps.tools.executeBatch(next.pending, run.turnSignal.signal); deps.context.onTurnComplete(deps.session.projection().lastUsage); // ADR-6 observation continue; } case "verify": { // Model stopped; code changed this turn; no fresh verification evidence (ADR-12). if (run.budget.verificationAttempts >= 2) return { kind: "done", withheld: next.candidate }; run.budget.verificationAttempts++; deps.session.publish(verificationRequested(next.detectedChecks, next.candidate)); continue; // nudge is a synthetic message → call-model } case "interrupted": return { kind: "interrupted" }; // Interrupted event is already durable case "done": return { kind: "done" }; } } } ``` ### 4.3 `deriveNext` — a pure function of recorded state ```ts function deriveNext(s: SessionProjection, budget: Budget): NextAction { if (s.interruptedSinceLastModelCall) return { kind: "interrupted" }; if (s.pendingToolCalls.length > 0) return { kind: "execute-tools", pending: s.pendingToolCalls }; if (s.queuedSteering.length > 0 && s.atSafeSeam) return { kind: "inject-steering", ... }; if (s.contextOverflowRecorded || s.tokensUsed > s.compactionThreshold) return { kind: "compact" }; if (s.lastStop === "end_turn") { const gate = needsVerification(s); // code changed this turn ∧ no fresh evidence, if (gate.required) // documentation-only changes filtered (ADR-12) return { kind: "verify", ...gate }; return { kind: "done" }; } if (isDoomLoop(s)) return { kind: "verify-with-user" }; // 3 byte-identical if (budget.iterations <= 0) return { kind: "done" }; // consecutive calls → ask return { kind: "call-model" }; } ``` Properties this buys (OpenCode evidence, ADR-2): a crashed process resumes mid-conversation because nothing lives only in loop-local variables; steering and interruption are just state the next iteration observes; compaction is a task the loop derives, not a side effect buried in a handler. The stream reducer (`reduceModelEvent`) is a separate pure component from the loop. ### 4.4 What is *not* in the kernel Retry/backoff (Model Runtime), permission evaluation (Tool Runtime → Permissions), output budgeting (tools), compaction algorithm (Context Engine), rendering (TUI), persistence mechanics (Session Store), git baselines (Repository). The doom-loop check and the verification gate are the only kernel-adjacent policies, and both are pure functions over the projection. --- ## 5. Session Engine (ADR-3) ### 5.1 Storage layout ``` ~/.khaelor/ sessions// .jsonl # append-only durable event log — THE truth .meta.json # projection cache (title, usage, cost, updatedAt) — always rebuildable spill/ # oversized tool output (size-capped) logs/ # developer logs (never the TUI) ``` `project-hash` = hash of the git root (or cwd when not a repo). Session ids are ULIDs — lexicographic order = creation order. ### 5.2 The store ```ts export interface SessionStore { create(meta: NewSessionMeta): Promise; open(sessionId: string): Promise; // resume = replay (§5.4) list(projectHash: string): Promise; // reads meta caches; rebuilds stale ones } export interface SessionHandle { readonly id: string; publish(e: DurableEventInput): DurableEvent; // append (write-ahead) + bus publish; assigns seq projection(): SessionProjection; // in-memory, maintained by the same reducer meta(): SessionMeta; } export interface SessionMeta { // CLAUDE.md §8 id: string; title: string; project: string; createdAt: number; updatedAt: number; model: string; tokenUsage: UsageTotals; // incl. cache read/write — real API numbers only cost: number; gitBranch: string; workingDirectory: string; } ``` Durability discipline (mini's `finally` rule, ADR-3): the log is appended at every loop boundary — after every durable event, before the bus publishes it. Append atomicity, fsync policy, corruption recovery (truncated last line), and the versioning strategy are specified normatively in `EVENT_MODEL.md §5`. ### 5.3 Projections All state is derived. Five projections, each a fold over the durable stream (full rules in `EVENT_MODEL.md §6`): | Projection | Consumers | Notes | |---|---|---| | `SessionProjection` | kernel (`deriveNext`) | pending tool calls, queued steering, last stop reason, overflow flag, files changed this turn, verification evidence | | `LlmHistory` | Context Engine | byte-stable Anthropic `messages[]`; re-applies `ContextCompacted`/`ContextPruned` deterministically | | `Timeline` | TUI view-model | messages, tool cards, diffs, status | | `UsageTotals` | `/cost`, status bar | summed from `ModelResponseCompleted.usage` only — never invented (Absolute Rule #4) | | `FileChangeSet` | `/diff`, verification gate, attribution | vs. `BaselineRecorded` (ADR-15) | Projections are caches, never truth (ADR-3 concern): each must tolerate being stale or deleted and rebuild from the log. ### 5.4 Resume `open()` replays the JSONL through the same reducer that maintains live projections. Resume contract (OpenHands-derived, ADR-3): tools add-only, model swappable. On replay, any `ToolRequested` without a terminal result gets a synthetic durable `ToolCancelled` appended **at resume time** so the LLM history is protocol-valid (`EVENT_MODEL.md §6.5`). Background processes do not survive the process; `ProcessExited{cause:"khaelor-shutdown"}` is recorded at shutdown, and resume renders them as exited. Long-session replay cost is bounded by `ContextCompacted` events, which act as natural snapshots for `LlmHistory`; if `Timeline` replay is ever measured slow, periodic snapshot events are the sanctioned fix — not a database. ### 5.5 Session commands `/sessions` (list via meta caches) · `/resume` · `/new` · `/rename` (durable `SessionRenamed`) · `/clear` (new session; never truncates a log). `/branch` and `/rewind` are post-V1; the envelope reserves `parentId?` (ADR-3). --- ## 6. Context Engine (ADR-6, ADR-7) ### 6.1 Interface Hermes' verbs, exactly four: ```ts export interface ContextEngine { /** Assemble the model request from the projection. V1: pass-through hook for selection * (no retrieval/topic routing yet — the verb exists so repository intelligence can * plug in without interface change). */ selectContext(s: SessionProjection): Promise; /** Summarize-compact. Emits nothing itself — returns the checkpoint the kernel records * as a durable ContextCompacted event. Cuts ONLY at pairing-safe indices. */ compress(s: SessionProjection, signal: AbortSignal): Promise; /** Observation hook: real usage from the last response updates budget accounting. */ onTurnComplete(usage: ModelUsage): void; /** Cheap, deterministic, no-LLM: blank old tool results (protect newest N tokens). * Returns the toolUseIds to prune; kernel records ContextPruned. Runs BEFORE compress. */ pruneToolResults(s: SessionProjection): PruneDecision; } export interface BuiltContext { request: ModelRequest; // system tiers + messages + tools + cache breakpoints stats: ContextStats; // per-section token estimates → /context inspector } ``` ### 6.2 Triggers — token-based, from real usage only - **Proactive:** after each response, `onTurnComplete` compares `usage.inputTokens + usage.outputTokens` against `usableWindow = modelWindow − reservedOutput − compactionBuffer`. Crossing the threshold sets the projection's compaction flag; `deriveNext` yields `compact` at the next safe boundary. Never event-count triggers (OpenHands' named weakness). - **Reactive:** a `context-overflow` typed model error (ADR-10) records `ModelRequestFailed{kind:"context-overflow"}`; `deriveNext` routes to `compact` — never a blind retry. - **Order:** `pruneToolResults` first (cheap, deterministic — protect the newest ~40K tokens of tool output); `compress` only if still over budget. ### 6.3 Compaction-as-event `ContextCompacted` is a durable event carrying the checkpoint and the exact cut range. `LlmHistory` re-applies it deterministically on every rebuild — replay-safe, inspectable (`/context`, `/compact`), and itself re-compactable later. Cut indices are chosen only where every `tool_use` before the cut has its `tool_result` before the cut (pairing safety — `EVENT_MODEL.md §6.5`). Compaction summaries route to the configured `auxModel` (ADR-10). ### 6.4 Checkpoint format (structured YAML — CLAUDE.md §12) ```yaml objective: completed: - current_state: important_files: - path: src/context/engine.ts reason: changes: - failed_attempts: - decisions: - running_processes: - id: command: npm run dev status: running next_steps: - raw_evidence: # preserved verbatim when summarization would destroy it - label: content: | ``` `failed_attempts`, `decisions`, and `running_processes` exist precisely because free-text summaries destroy them (ADR-6 trade-off). ### 6.5 Prompt-cache byte-stability rules (ADR-7 — enforced by tests) 1. **The system prompt is built once per session** in stable tiers: `[identity/behavior] → [tool guidance] → [project instructions]`. It is never re-rendered mid-session. `cache_control` breakpoints are placed deliberately at tier ends. 2. **History is never rewritten.** Assistant blocks, tool results, and user messages are replayed byte-exact. `ContextCompacted` (and deterministic `ContextPruned` with a fixed placeholder string) are the only sanctioned breaks — both durable events, so every rebuild produces identical bytes. 3. **Volatile context** (timestamps, git status, running-process lists, per-turn repository context) is injected **only into the API copy of the current user message**, never interleaved into history, never in the cached system tiers. 4. **Model or instruction changes** mid-session (`ModelChanged`) start a new cache lineage; that is accepted and visible. 5. Cache read/write tokens surface in `/cost` from real usage fields — cache health is observable, and a regression is a bug. --- ## 7. Model Runtime (ADR-10) ```ts export interface ModelClient { stream(request: ModelRequest, signal: AbortSignal): AsyncIterable; countTokens?(request: ModelRequest): Promise; // best-effort budgeting aid } export interface ModelRequest { model: string; system: SystemTier[]; // stable tiers with cache_control breakpoints messages: AnthropicMessage[]; // byte-stable projection (§6.5) tools: ToolSchema[]; maxOutputTokens: number; thinking?: ThinkingConfig; } export type ModelEvent = | { type: "started"; requestId: string } | { type: "text-delta"; blockIndex: number; text: string } | { type: "thinking-delta"; blockIndex: number; text: string } | { type: "block-completed"; blockIndex: number; block: CompletedBlock } // text | thinking(+signature) | tool_use | { type: "finished"; stopReason: StopReason; usage: ModelUsage }; export interface ModelUsage { // real API fields ONLY — never estimated (Rule #4) inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; } ``` **Error taxonomy** (typed, kernel branches on it): ```ts export type ModelErrorKind = | "retryable" // 429 / 5xx / network — retried inside ModelClient with jittered // exponential backoff and a retry budget; surfaced only on exhaustion | "context-overflow" // routed to Context Engine (reactive compaction) — NEVER retried blindly | "auth" // fatal; actionable message; never logs the key | "invalid-request" // fatal; a KHAELOR bug — surfaced loudly | "cancelled"; // AbortSignal fired — not an error path, folds into Interrupted state export class ModelError extends Error { kind: ModelErrorKind; retryable: boolean; status?: number; requestId?: string; } ``` **Cancellation** is AbortSignal end to end: turn controller → SDK request → the async iterable throws `ModelError{kind:"cancelled"}`. No thread flags, no scattered checks (ADR-11). **Accounting:** `finished.usage` is the only source for `/cost` and compaction budgets. Two configured model ids share this one interface: `model` (main) and `auxModel` (compaction) — no provider framework, no second implementation. --- ## 8. Interruption & Steering (ADR-11) **Interrupt (`Esc`):** 1. TUI dispatches `KernelCommand{interrupt}` → durable `Interrupted` event → `run.turnSignal.abort()`. 2. The model stream aborts; in-flight tools receive the signal, get a 250 ms grace, then are cancelled. 3. Every dangling `tool_use` is closed with a synthetic cancelled `tool_result` — recorded as durable `ToolCancelled` — so history is **protocol-valid at all times**. 4. Background `process`-managed processes are **not** killed (explicitly long-lived). 5. The loop's next `deriveNext` observes the interrupted state and returns; the session is intact and immediately usable. **Steering:** text typed while the agent works becomes a durable `SteeringQueued` event, shown as `Queued instruction`. The queue drains at exactly two seams — post-tool-batch and pre-model-call — recorded as `SteeringInjected` and appended into the API-copy content alongside tool results (never breaking role alternation). A long uninterrupted text stream cannot be steered until it completes or is cancelled — accepted price (ADR-11). --- ## 9. Completion & Verification (ADR-12) When the model stops (`end_turn`) and the `FileChangeSet` shows code changed this turn without fresh verification evidence, the gate: 1. Detects relevant check commands from the repository (package scripts, test configs) — never blind full suites. 2. Records `VerificationRequested{detectedChecks, attempt}` and **withholds the candidate answer** (preserved — budget exhaustion returns it rather than losing it). 3. Injects a synthetic evidence-bearing nudge; max 2 attempts; documentation-only changes are filtered out. 4. On acceptance records `TaskCompleted` carrying: ```ts interface CompletionEvidence { objective: string; changedFiles: string[]; checks: CheckResult[]; // command, exitCode, summary — real results only unresolvedIssues: string[]; } ``` Completion is inferred from stop-without-tool-calls plus this gate (no finish tool in V1); if dogfooding shows ambiguity, an explicit finish signal is addable without kernel changes (ADR-12 concern, recorded). --- ## 10. Permissions (ADR-9) — placement summary Evaluation happens in exactly one place: the Tool Runtime, between `ToolRequested` and `ToolStarted`. Flow: capability derivation from tool input → hardline deny floor → last-match-wins rules → `allow` (durable `ToolApproved`) / `ask` (durable `PermissionRequested` → inline panel → `PermissionGranted{scope}` or `PermissionDenied`) / `deny`. "Always allow" persists a scoped rule to project config **and** records the grant event. Denial text returns to the model as the tool result observation (course correction, not dead end). "Silence is not consent": an unanswered request never auto-approves. Full model in the Phase 1 `PERMISSION_MODEL.md`. --- ## 11. Concurrency Model **One Node process, one event loop** (ADR-1, ADR-17). Concurrency is structured async, not threads. Concurrent at any moment: | Activity | Mechanism | Notes | |---|---|---| | Model stream consumption | async iterable | one at a time per session | | Tool execution | async, **sequential within a batch** in V1 | parallel read-only tools = post-V1 optimization, measured first | | Background processes | child processes + stream I/O | rolling buffers; outlive turns, not the process | | TUI input handling | stdin events | always responsive — never awaited behind engine work | | TUI rendering | 16 ms coalescer flush | bounded live region keeps render work small | | Log appends | serialized per-session write queue | write-ahead of publish | **Cancellation tree:** `session controller → turn controller → { model request, each tool execution }`. `Esc` aborts the turn controller only. `SIGINT`/quit aborts the session controller, stops background processes (`stopAll`), flushes the log, restores the terminal. **Recorded risk (ADR-17):** render work and tool I/O share the event loop. Mitigations: bounded live region + coalescing keep render slices small; the bus boundary is clean so moving the engine into a `worker_thread` later is a packaging change. If Phase 8 measurements show contention, that is the sanctioned escape hatch — measure first. **Ordering guarantee:** for a given session, durable events are appended and published in `seq` order; ephemeral deltas for a block are always delivered to the TUI before the durable event that settles that block (`EVENT_MODEL.md §7`). --- ## 12. Startup Sequence & Performance Budget ### 12.1 Cold-start sequence ``` t0 node boots; cli entry parses argv (no framework, hand-rolled — zero-dep parse) t1 load config files (2 small JSON reads) + env; NO dotenv autoload; no network t2 initialize terminal + render the startup shell (header, prompt) — FIRST PAINT ── everything below is lazy / background ── bg git status + branch (async → fills status bar when ready) bg session store init (create/open lazily on first message or /resume) bg instruction-file discovery (KHAELOR.md etc.) lazy @anthropic-ai/sdk — imported on first model call lazy markdown/highlight/diff — imported on first render that needs them lazy ripgrep spawn — on first grep/glob/@-mention ``` The Anthropic key is validated on first use, not at startup (a missing key renders an actionable inline message, not a boot failure). ### 12.2 Budgets (measured in CI where practical; ADR-1 lazy-import discipline) | Metric | Budget | |---|---| | Cold start → first paint, interactive prompt | **< 150 ms** | | Keystroke → echo (input latency) | **< 16 ms** | | Render frame during full-speed token stream | **< 16 ms** (one flush per frame) | | Tool dispatch overhead (gate + record, excl. tool work) | < 5 ms | | Session resume, 10K durable events | < 500 ms | | Memory, 4-hour session | bounded (capped live region, virtualized scrollback, rolling process buffers) | A startup benchmark script and an import-graph check (no heavy module in the boot path) are part of Phase 2's definition of done. No spinner-driven UX: status lines show real activity (`● Reading src/session/store.ts`), never fabricated progress (Absolute Rule #4). --- ## 13. V1 Non-Goals and Extension Seams (ADR-16, ADR-17) Explicitly **not** in V1 — with the seam that keeps each addable without kernel rewrites. Boundaries only; no premature abstraction beyond what is listed. | Post-V1 capability | V1 seam (already present) | What is NOT built now | |---|---|---| | Subagents | every event carries `sessionId`; tool registry is data-driven; child = new session with derived permissions | no task tool, no depth/budget machinery, no child UI | | Memory | `ContextEngine.selectContext` is the injection point; frozen-snapshot rule inherited from ADR-7 | no providers, no background review, no curation | | Skills | instruction-file discovery + data-driven registry | no skill format, loading, or self-generation | | MCP | `ToolDefinition`/`ToolRegistry` are plain data | no client, transport, or config surface | | Multi-provider | `ModelClient` is the single boundary | no adapter framework, no second implementation | | Docker/SSH remote | 4-method `Workspace`; strategy will be "run the core remotely", not per-syscall proxying | no `Workspace` implementations beyond local | | Shadow-git snapshots / revert | baselines + snapshot hashes representable as events | no shadow repo machinery | | Session branch/rewind | linear log; envelope reserves `parentId?` | no tree invariants | | Daemon / IDE / `khaelor serve` | the typed bus vocabulary is the only "API"; engine↔TUI boundary is command/event only | no server, sockets, or RPC | | `worker_thread` engine split | clean bus boundary (§11) | not until Phase 8 measurements demand it | Also not V1: OpenAI/Gemini/OpenRouter/local models, browser automation, computer vision, cloud execution, web UI, multi-user, plugins, marketplace (CLAUDE.md §23). --- ## 14. Cross-References - `docs/EVENT_MODEL.md` — normative event vocabulary, JSONL format, projection rules, coalescing contract, bus API. - `docs/research/KHAELOR_ARCHITECTURE_DECISIONS.md` — ADR-1…17 (binding rationale). - Phase 1 remaining deliverables: `TUI_DESIGN.md` (incl. the ADR-14 framework spike report), `TOOL_PROTOCOL.md`, `PERMISSION_MODEL.md`. --- *Author: Simon-Pierre Boucher · contact@spboucher.ai*