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/cli/engine.ts4 * Description: Composition root for the agent engine — session log, bus, workspace, tools, permissions, context, model, kernel.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { homedir } from "node:os";11import { join } from "node:path";12import { AnthropicModelClient } from "../anthropic/index.js";13import type { ThinkingConfig } from "../anthropic/index.js";14import {15 AgentKernel,16 EventLogSession,17 InterruptionController,18 SteeringQueue,19 ToolExecutor,20 VerificationGate,21 recoverDanglingOnResume,22} from "../agent/index.js";23import type { TurnOutcome } from "../agent/index.js";24import { persistPermissionGrant } from "../config/index.js";25import type { ResolvedConfig } from "../config/index.js";26import {27 ContextBudget,28 KhaelorContextEngine,29 buildSystemPrompt,30 discoverProjectInstructions,31} from "../context/index.js";32import { MEMORY_FILE } from "../memory/index.js";33import {34 PermissionService,35 normalizePermissionsSection,36} from "../permissions/index.js";37import type { PermissionAsker, PermissionRule } from "../permissions/index.js";38import { PhaseService } from "../phases/index.js";39import type { PhaseApprovalAsker } from "../phases/index.js";40import { RepoGraphService } from "../repograph/index.js";41import { GitService } from "../repository/index.js";42import { SessionEventBus, SessionLog, defaultSessionsDir } from "../session/index.js";43import type { DurableEventInput, ToolName } from "../session/index.js";44import { createDefaultToolRegistry } from "../tools/index.js";45import type { ToolRegistry } from "../tools/index.js";46import { VerifyRunner, loadVerifyConfig } from "../verify/index.js";47import {48 InMemoryFileTimeRegistry,49 LocalProcessManager,50 LocalWorkspace,51} from "../workspace/index.js";52import type { ManagedProcess, ProcessEventSink } from "../workspace/index.js";53import { KHAELOR_VERSION } from "./args.js";54import type { FileLogger } from "./logger.js";55import { projectHash } from "./sessions.js";5657// ───────────────────────── session context ─────────────────────────5859export interface SessionContext {60 sessionId: string;61 hash: string;62 log: SessionLog;63 bus: SessionEventBus;64 session: EventLogSession;65 resumed: boolean;66}6768export interface OpenSessionOptions {69 cwd: string;70 sessionsDir?: string;71 /** Resume this session id; omitted → create a fresh session. */72 resumeId?: string;73 logger: FileLogger;74}7576/**77 * Create or resume the session log and wire the write-ahead bus + the78 * kernel's event view. Resume recovery (synthetic ToolCancelled for dangling79 * tool_use) is recorded durably here, at open time (EVENT_MODEL.md §6.5.2).80 */81export async function openSessionContext(options: OpenSessionOptions): Promise<SessionContext> {82 const sessionsDir = options.sessionsDir ?? defaultSessionsDir();83 const hash = projectHash(options.cwd);84 const log =85 options.resumeId !== undefined86 ? await SessionLog.open({ projectHash: hash, sessionId: options.resumeId, sessionsDir })87 : await SessionLog.create({ projectHash: hash, sessionsDir });88 if (log.recovery !== null) {89 options.logger.log("warn", "session log torn-line recovery", {90 tornFile: log.recovery.tornFile,91 truncatedTo: log.recovery.truncatedTo,92 });93 }94 const bus = new SessionEventBus({95 sessionId: log.sessionId,96 appender: log,97 onHandlerError: (error, event) => {98 options.logger.error("event handler failed", {99 eventType: event.type,100 error: error instanceof Error ? error.message : String(error),101 });102 },103 });104 const session = new EventLogSession({105 sessionId: log.sessionId,106 bus,107 replayed: log.replayedEvents,108 });109 return {110 sessionId: log.sessionId,111 hash,112 log,113 bus,114 session,115 resumed: options.resumeId !== undefined,116 };117}118119// ───────────────────────── the engine ─────────────────────────120121/** Inputs to the byte-stable system prompt — frozen at assembly (ADR-7). */122export interface SystemPromptArgs {123 workingDirectory: string;124 toolNames: readonly string[];125 instructions: Parameters<typeof buildSystemPrompt>[0]["instructions"];126}127128export interface AssembleEngineOptions {129 config: ResolvedConfig;130 cwd: string;131 context: SessionContext;132 /** TUI permission panel callback; absent → non-interactive (asks resolve deny). */133 asker?: PermissionAsker;134 /** TUI design-approval panel (strict gate mode); absent → large designs stay pending. */135 designAsker?: PhaseApprovalAsker;136 logger: FileLogger;137 /** User dir for KHAELOR.md discovery. Default ~/.khaelor. */138 userDir?: string;139 /** Data root for spill/process logs. Default ~/.khaelor. */140 dataDir?: string;141}142143/**144 * Everything the CLI drives: the wired services plus a single-turn runner.145 * Built per session; `switchModel` rebuilds only the model-scoped pieces146 * (budget + context engine — a new prompt-cache lineage, ADR-7 rule 4).147 */148export class Engine {149 readonly session: EventLogSession;150 readonly log: SessionLog;151 readonly bus: SessionEventBus;152 readonly workspace: LocalWorkspace;153 readonly processes: LocalProcessManager;154 readonly git: GitService;155 readonly registry: ToolRegistry;156 readonly permissions: PermissionService;157 readonly steering: SteeringQueue;158 readonly interruption: InterruptionController;159 readonly verifier: VerificationGate;160 readonly modelClient: AnthropicModelClient;161 readonly executor: ToolExecutor;162 readonly logger: FileLogger;163 readonly phases: PhaseService;164 readonly repograph: RepoGraphService;165 readonly verifyRunner: VerifyRunner;166167 contextEngine: KhaelorContextEngine;168 budget: ContextBudget;169 model: string;170171 readonly #config: ResolvedConfig;172 readonly #systemArgs: SystemPromptArgs;173 readonly #bridge: ProcessBridge;174 #turnActive = false;175 #shutdownStarted = false;176177 constructor(args: {178 config: ResolvedConfig;179 context: SessionContext;180 workspace: LocalWorkspace;181 processes: LocalProcessManager;182 git: GitService;183 registry: ToolRegistry;184 permissions: PermissionService;185 modelClient: AnthropicModelClient;186 executor: ToolExecutor;187 contextEngine: KhaelorContextEngine;188 budget: ContextBudget;189 logger: FileLogger;190 systemArgs: SystemPromptArgs;191 bridge: ProcessBridge;192 phases: PhaseService;193 repograph: RepoGraphService;194 verifyRunner: VerifyRunner;195 }) {196 this.#config = args.config;197 this.session = args.context.session;198 this.log = args.context.log;199 this.bus = args.context.bus;200 this.workspace = args.workspace;201 this.processes = args.processes;202 this.git = args.git;203 this.registry = args.registry;204 this.permissions = args.permissions;205 this.modelClient = args.modelClient;206 this.executor = args.executor;207 this.contextEngine = args.contextEngine;208 this.budget = args.budget;209 this.logger = args.logger;210 this.model = args.config.model;211 this.#systemArgs = args.systemArgs;212 this.#bridge = args.bridge;213 this.phases = args.phases;214 this.repograph = args.repograph;215 this.verifyRunner = args.verifyRunner;216 this.steering = new SteeringQueue(this.session);217 this.interruption = new InterruptionController(this.session);218 this.verifier = new VerificationGate({219 workspace: this.workspace,220 attributor: { attributeChanges: () => this.git.attributeChanges() },221 });222 }223224 get turnActive(): boolean {225 return this.#turnActive;226 }227228 /** Run one agent turn over the recorded state. Never runs two concurrently. */229 async runTurn(): Promise<TurnOutcome> {230 if (this.#turnActive) return { kind: "idle" };231 this.#turnActive = true;232 try {233 const kernel = new AgentKernel({234 session: this.session,235 context: this.contextEngine,236 model: this.modelClient,237 executor: this.executor,238 verifier: this.verifier,239 steering: this.steering,240 interruption: this.interruption,241 compaction: this.budget,242 });243 return await kernel.runTurn();244 } finally {245 this.#turnActive = false;246 }247 }248249 /** Switch the main model mid-session — durable ModelChanged + fresh budget/engine. */250 switchModel(to: string, reason: "user" | "config"): void {251 if (to === this.model) return;252 const from = this.model;253 this.model = to;254 this.budget = new ContextBudget({255 model: to,256 reservedOutputTokens: this.#config.maxOutputTokens,257 });258 this.contextEngine = buildContextEngine({259 config: this.#config,260 model: to,261 modelClient: this.modelClient,262 budget: this.budget,263 registry: this.registry,264 systemArgs: this.#systemArgs,265 });266 this.session.publishDurable({267 type: "session.model-changed",268 payload: { from, to, reason },269 });270 }271272 /** Record the git baseline (session start) when inside a repository. */273 async captureBaseline(): Promise<void> {274 const result = await this.git.recordBaseline("session-start");275 if (result.kind === "ok") {276 this.session.publishDurable({277 type: "git.baseline-recorded",278 payload: { when: "session-start", baseline: result.value },279 });280 } else if (result.kind === "error") {281 this.logger.log("warn", "git baseline capture failed", { message: result.message });282 }283 }284285 /** Orderly shutdown: stop background processes, flush + close the log. */286 async shutdown(): Promise<void> {287 if (this.#shutdownStarted) return;288 this.#shutdownStarted = true;289 this.#bridge.shuttingDown = true;290 try {291 await this.processes.stopAll();292 } catch (error) {293 this.logger.error("stopAll failed during shutdown", {294 error: error instanceof Error ? error.message : String(error),295 });296 }297 try {298 await this.log.close();299 } catch (error) {300 this.logger.error("session log close failed", {301 error: error instanceof Error ? error.message : String(error),302 });303 }304 }305}306307// ───────────────────────── assembly ─────────────────────────308309function thinkingFor(config: ResolvedConfig): ThinkingConfig | undefined {310 // "adaptive"/"always" → omit: current Anthropic models run adaptive thinking311 // by default and reject fixed budgets. "off" → explicit disabled.312 return config.thinking === "off" ? { mode: "disabled" } : undefined;313}314315function buildContextEngine(args: {316 config: ResolvedConfig;317 model: string;318 modelClient: AnthropicModelClient;319 budget: ContextBudget;320 registry: ToolRegistry;321 systemArgs: SystemPromptArgs;322}): KhaelorContextEngine {323 const thinking = thinkingFor(args.config);324 return new KhaelorContextEngine({325 model: args.model,326 auxModel: args.config.auxModel,327 maxOutputTokens: args.config.maxOutputTokens,328 systemTiers: buildSystemPrompt(args.systemArgs),329 tools: args.registry.list().map((tool) => ({330 name: tool.name,331 description: tool.description,332 inputSchema: tool.inputSchema as unknown as Record<string, unknown>,333 })),334 modelClient: args.modelClient,335 budget: args.budget,336 ...(thinking !== undefined ? { thinking } : {}),337 });338}339340/** Bridge ProcessManager lifecycle to durable events; tracks the owning tool call. */341class ProcessBridge implements ProcessEventSink {342 currentToolUseId = "";343 shuttingDown = false;344 readonly #session: EventLogSession;345346 constructor(session: EventLogSession) {347 this.#session = session;348 }349350 onProcessStarted(process: ManagedProcess): void {351 this.#session.publishDurable({352 type: "process.started",353 payload: {354 processId: process.id,355 pid: process.pid,356 command: process.command,357 cwd: process.cwd,358 toolUseId: this.currentToolUseId,359 },360 });361 }362363 onProcessExited(process: ManagedProcess): void {364 const cause =365 this.shuttingDown366 ? "khaelor-shutdown"367 : process.status === "failed"368 ? "crashed"369 : process.status === "stopped"370 ? "stopped-by-tool"371 : "exited";372 this.#session.publishDurable({373 type: "process.exited",374 payload: {375 processId: process.id,376 exitCode: process.exitCode,377 cause,378 durationMs: Math.max(0, Date.now() - process.startedAt),379 },380 });381 }382}383384/**385 * Wire the full engine around an open session context. Publishes the386 * SessionStarted / SessionResumed event and performs resume recovery.387 */388export async function assembleEngine(options: AssembleEngineOptions): Promise<Engine> {389 const { config, cwd, context } = options;390 const dataDir = options.dataDir ?? join(homedir(), ".khaelor");391 const userDir = options.userDir ?? join(homedir(), ".khaelor");392393 const workspace = new LocalWorkspace(cwd);394 const fileTimes = new InMemoryFileTimeRegistry();395 const git = new GitService(workspace);396 const registry = createDefaultToolRegistry();397 const toolNames = registry.list().map((tool) => tool.name);398399 const bridge = new ProcessBridge(context.session);400 context.bus.on("tool.started", (event) => {401 bridge.currentToolUseId = event.payload.toolUseId;402 });403 const processes = new LocalProcessManager({404 logDir: join(dataDir, "process-logs", context.sessionId),405 eventSink: bridge,406 });407408 // Config permissions (shorthand, nested, and rules forms) become project-layer rules.409 let projectRules: readonly PermissionRule[] = [];410 const normalized = normalizePermissionsSection(config.permissions, "project");411 if (normalized.ok) {412 projectRules = normalized.value;413 } else {414 options.logger.log("warn", "invalid permissions config ignored", {415 message: normalized.error.message,416 });417 }418 const permissions = new PermissionService({419 rules: { project: projectRules },420 ...(options.asker !== undefined ? { asker: options.asker } : {}),421 // "Always allow in this project" grants persist to .khaelor/config.json422 // (PERMISSION_MODEL.md §6.2) — they are ordinary rules on the next load.423 persister: {424 persist: (rule) => persistPermissionGrant(rule, { projectDir: cwd }),425 },426 publish: (event) => {427 context.session.publishDurable(event as DurableEventInput);428 },429 });430431 // ── v2 services around the kernel: phases, semantic index, native verify ──432 const phases = new PhaseService({433 session: context.session,434 config: { mode: config.gate.mode, autoApprove: { ...config.gate.autoApprove } },435 projectRoot: cwd,436 ...(options.designAsker !== undefined ? { asker: options.designAsker } : {}),437 });438 const repograph = new RepoGraphService({ workspace });439 // Background warm-up (visible cost stays out of the first tool call).440 void repograph.ensureIndexed().catch((error: unknown) => {441 options.logger.log("warn", "repograph initial index failed", { error: String(error) });442 });443 const verifyConfig = await loadVerifyConfig(workspace);444 const verifyRunner = new VerifyRunner({445 workspace,446 session: context.session,447 config: verifyConfig,448 });449450 const executor = new ToolExecutor({451 session: context.session,452 registry,453 permissions,454 workspace,455 fileTimes,456 processes,457 spillDir: join(dataDir, "spill"),458 home: homedir(),459 phases,460 repograph,461 verify: verifyRunner,462 });463464 const instructions = await discoverProjectInstructions(workspace, { userDir });465 // Project memory (v2 §5): auto-maintained facts join the instruction tier.466 try {467 const memoryPath = join(cwd, MEMORY_FILE);468 const memoryContent = await workspace.readFile(memoryPath);469 if (memoryContent.trim().length > 0) {470 instructions.push({ path: memoryPath, scope: "project", content: memoryContent });471 }472 } catch {473 // no project memory yet474 }475 const systemArgs = { workingDirectory: cwd, toolNames, instructions };476477 const modelClient = new AnthropicModelClient({ apiKey: config.apiKey ?? "" });478 const budget = new ContextBudget({479 model: config.model,480 reservedOutputTokens: config.maxOutputTokens,481 });482 const contextEngine = buildContextEngine({483 config,484 model: config.model,485 modelClient,486 budget,487 registry,488 systemArgs,489 });490491 const engine = new Engine({492 config,493 context,494 workspace,495 processes,496 git,497 registry,498 permissions,499 modelClient,500 executor,501 contextEngine,502 budget,503 logger: options.logger,504 systemArgs,505 bridge,506 phases,507 repograph,508 verifyRunner,509 });510511 // Session lifecycle event + resume recovery — recorded before any turn runs.512 if (context.resumed) {513 context.session.publishDurable({514 type: "session.resumed",515 payload: {516 khaelorVersion: KHAELOR_VERSION,517 replayedSeq: context.log.seqCursor - 1,518 model: config.model,519 toolNames: toolNames as ToolName[],520 },521 });522 recoverDanglingOnResume(context.session);523 } else {524 const branch = await git.currentBranch();525 context.session.publishDurable({526 type: "session.started",527 payload: {528 title: "",529 projectHash: context.hash,530 workingDirectory: cwd,531 gitBranch: branch.kind === "ok" ? branch.value : null,532 model: config.model,533 auxModel: config.auxModel,534 khaelorVersion: KHAELOR_VERSION,535 },536 });537 // Gated sessions open in understand (v2 §1).538 phases.ensureStarted();539 }540541 return engine;542}543