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