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%
15.2 KB · 490 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 {33  PermissionService,34  normalizePermissionsSection,35} from "../permissions/index.js";36import type { PermissionAsker, PermissionRule } from "../permissions/index.js";37import { GitService } from "../repository/index.js";38import { SessionEventBus, SessionLog, defaultSessionsDir } from "../session/index.js";39import type { DurableEventInput, ToolName } from "../session/index.js";40import { createDefaultToolRegistry } from "../tools/index.js";41import type { ToolRegistry } from "../tools/index.js";42import {43  InMemoryFileTimeRegistry,44  LocalProcessManager,45  LocalWorkspace,46} from "../workspace/index.js";47import type { ManagedProcess, ProcessEventSink } from "../workspace/index.js";48import { KHAELOR_VERSION } from "./args.js";49import type { FileLogger } from "./logger.js";50import { projectHash } from "./sessions.js";5152// ───────────────────────── session context ─────────────────────────5354export interface SessionContext {55  sessionId: string;56  hash: string;57  log: SessionLog;58  bus: SessionEventBus;59  session: EventLogSession;60  resumed: boolean;61}6263export interface OpenSessionOptions {64  cwd: string;65  sessionsDir?: string;66  /** Resume this session id; omitted → create a fresh session. */67  resumeId?: string;68  logger: FileLogger;69}7071/**72 * Create or resume the session log and wire the write-ahead bus + the73 * kernel's event view. Resume recovery (synthetic ToolCancelled for dangling74 * tool_use) is recorded durably here, at open time (EVENT_MODEL.md §6.5.2).75 */76export async function openSessionContext(options: OpenSessionOptions): Promise<SessionContext> {77  const sessionsDir = options.sessionsDir ?? defaultSessionsDir();78  const hash = projectHash(options.cwd);79  const log =80    options.resumeId !== undefined81      ? await SessionLog.open({ projectHash: hash, sessionId: options.resumeId, sessionsDir })82      : await SessionLog.create({ projectHash: hash, sessionsDir });83  if (log.recovery !== null) {84    options.logger.log("warn", "session log torn-line recovery", {85      tornFile: log.recovery.tornFile,86      truncatedTo: log.recovery.truncatedTo,87    });88  }89  const bus = new SessionEventBus({90    sessionId: log.sessionId,91    appender: log,92    onHandlerError: (error, event) => {93      options.logger.error("event handler failed", {94        eventType: event.type,95        error: error instanceof Error ? error.message : String(error),96      });97    },98  });99  const session = new EventLogSession({100    sessionId: log.sessionId,101    bus,102    replayed: log.replayedEvents,103  });104  return {105    sessionId: log.sessionId,106    hash,107    log,108    bus,109    session,110    resumed: options.resumeId !== undefined,111  };112}113114// ───────────────────────── the engine ─────────────────────────115116/** Inputs to the byte-stable system prompt — frozen at assembly (ADR-7). */117export interface SystemPromptArgs {118  workingDirectory: string;119  toolNames: readonly string[];120  instructions: Parameters<typeof buildSystemPrompt>[0]["instructions"];121}122123export interface AssembleEngineOptions {124  config: ResolvedConfig;125  cwd: string;126  context: SessionContext;127  /** TUI permission panel callback; absent → non-interactive (asks resolve deny). */128  asker?: PermissionAsker;129  logger: FileLogger;130  /** User dir for KHAELOR.md discovery. Default ~/.khaelor. */131  userDir?: string;132  /** Data root for spill/process logs. Default ~/.khaelor. */133  dataDir?: string;134}135136/**137 * Everything the CLI drives: the wired services plus a single-turn runner.138 * Built per session; `switchModel` rebuilds only the model-scoped pieces139 * (budget + context engine — a new prompt-cache lineage, ADR-7 rule 4).140 */141export class Engine {142  readonly session: EventLogSession;143  readonly log: SessionLog;144  readonly bus: SessionEventBus;145  readonly workspace: LocalWorkspace;146  readonly processes: LocalProcessManager;147  readonly git: GitService;148  readonly registry: ToolRegistry;149  readonly permissions: PermissionService;150  readonly steering: SteeringQueue;151  readonly interruption: InterruptionController;152  readonly verifier: VerificationGate;153  readonly modelClient: AnthropicModelClient;154  readonly executor: ToolExecutor;155  readonly logger: FileLogger;156157  contextEngine: KhaelorContextEngine;158  budget: ContextBudget;159  model: string;160161  readonly #config: ResolvedConfig;162  readonly #systemArgs: SystemPromptArgs;163  readonly #bridge: ProcessBridge;164  #turnActive = false;165  #shutdownStarted = false;166167  constructor(args: {168    config: ResolvedConfig;169    context: SessionContext;170    workspace: LocalWorkspace;171    processes: LocalProcessManager;172    git: GitService;173    registry: ToolRegistry;174    permissions: PermissionService;175    modelClient: AnthropicModelClient;176    executor: ToolExecutor;177    contextEngine: KhaelorContextEngine;178    budget: ContextBudget;179    logger: FileLogger;180    systemArgs: SystemPromptArgs;181    bridge: ProcessBridge;182  }) {183    this.#config = args.config;184    this.session = args.context.session;185    this.log = args.context.log;186    this.bus = args.context.bus;187    this.workspace = args.workspace;188    this.processes = args.processes;189    this.git = args.git;190    this.registry = args.registry;191    this.permissions = args.permissions;192    this.modelClient = args.modelClient;193    this.executor = args.executor;194    this.contextEngine = args.contextEngine;195    this.budget = args.budget;196    this.logger = args.logger;197    this.model = args.config.model;198    this.#systemArgs = args.systemArgs;199    this.#bridge = args.bridge;200    this.steering = new SteeringQueue(this.session);201    this.interruption = new InterruptionController(this.session);202    this.verifier = new VerificationGate({203      workspace: this.workspace,204      attributor: { attributeChanges: () => this.git.attributeChanges() },205    });206  }207208  get turnActive(): boolean {209    return this.#turnActive;210  }211212  /** Run one agent turn over the recorded state. Never runs two concurrently. */213  async runTurn(): Promise<TurnOutcome> {214    if (this.#turnActive) return { kind: "idle" };215    this.#turnActive = true;216    try {217      const kernel = new AgentKernel({218        session: this.session,219        context: this.contextEngine,220        model: this.modelClient,221        executor: this.executor,222        verifier: this.verifier,223        steering: this.steering,224        interruption: this.interruption,225        compaction: this.budget,226      });227      return await kernel.runTurn();228    } finally {229      this.#turnActive = false;230    }231  }232233  /** Switch the main model mid-session — durable ModelChanged + fresh budget/engine. */234  switchModel(to: string, reason: "user" | "config"): void {235    if (to === this.model) return;236    const from = this.model;237    this.model = to;238    this.budget = new ContextBudget({239      model: to,240      reservedOutputTokens: this.#config.maxOutputTokens,241    });242    this.contextEngine = buildContextEngine({243      config: this.#config,244      model: to,245      modelClient: this.modelClient,246      budget: this.budget,247      registry: this.registry,248      systemArgs: this.#systemArgs,249    });250    this.session.publishDurable({251      type: "session.model-changed",252      payload: { from, to, reason },253    });254  }255256  /** Record the git baseline (session start) when inside a repository. */257  async captureBaseline(): Promise<void> {258    const result = await this.git.recordBaseline("session-start");259    if (result.kind === "ok") {260      this.session.publishDurable({261        type: "git.baseline-recorded",262        payload: { when: "session-start", baseline: result.value },263      });264    } else if (result.kind === "error") {265      this.logger.log("warn", "git baseline capture failed", { message: result.message });266    }267  }268269  /** Orderly shutdown: stop background processes, flush + close the log. */270  async shutdown(): Promise<void> {271    if (this.#shutdownStarted) return;272    this.#shutdownStarted = true;273    this.#bridge.shuttingDown = true;274    try {275      await this.processes.stopAll();276    } catch (error) {277      this.logger.error("stopAll failed during shutdown", {278        error: error instanceof Error ? error.message : String(error),279      });280    }281    try {282      await this.log.close();283    } catch (error) {284      this.logger.error("session log close failed", {285        error: error instanceof Error ? error.message : String(error),286      });287    }288  }289}290291// ───────────────────────── assembly ─────────────────────────292293function thinkingFor(config: ResolvedConfig): ThinkingConfig | undefined {294  // "adaptive"/"always" → omit: current Anthropic models run adaptive thinking295  // by default and reject fixed budgets. "off" → explicit disabled.296  return config.thinking === "off" ? { mode: "disabled" } : undefined;297}298299function buildContextEngine(args: {300  config: ResolvedConfig;301  model: string;302  modelClient: AnthropicModelClient;303  budget: ContextBudget;304  registry: ToolRegistry;305  systemArgs: SystemPromptArgs;306}): KhaelorContextEngine {307  const thinking = thinkingFor(args.config);308  return new KhaelorContextEngine({309    model: args.model,310    auxModel: args.config.auxModel,311    maxOutputTokens: args.config.maxOutputTokens,312    systemTiers: buildSystemPrompt(args.systemArgs),313    tools: args.registry.list().map((tool) => ({314      name: tool.name,315      description: tool.description,316      inputSchema: tool.inputSchema as unknown as Record<string, unknown>,317    })),318    modelClient: args.modelClient,319    budget: args.budget,320    ...(thinking !== undefined ? { thinking } : {}),321  });322}323324/** Bridge ProcessManager lifecycle to durable events; tracks the owning tool call. */325class ProcessBridge implements ProcessEventSink {326  currentToolUseId = "";327  shuttingDown = false;328  readonly #session: EventLogSession;329330  constructor(session: EventLogSession) {331    this.#session = session;332  }333334  onProcessStarted(process: ManagedProcess): void {335    this.#session.publishDurable({336      type: "process.started",337      payload: {338        processId: process.id,339        pid: process.pid,340        command: process.command,341        cwd: process.cwd,342        toolUseId: this.currentToolUseId,343      },344    });345  }346347  onProcessExited(process: ManagedProcess): void {348    const cause =349      this.shuttingDown350        ? "khaelor-shutdown"351        : process.status === "failed"352          ? "crashed"353          : process.status === "stopped"354            ? "stopped-by-tool"355            : "exited";356    this.#session.publishDurable({357      type: "process.exited",358      payload: {359        processId: process.id,360        exitCode: process.exitCode,361        cause,362        durationMs: Math.max(0, Date.now() - process.startedAt),363      },364    });365  }366}367368/**369 * Wire the full engine around an open session context. Publishes the370 * SessionStarted / SessionResumed event and performs resume recovery.371 */372export async function assembleEngine(options: AssembleEngineOptions): Promise<Engine> {373  const { config, cwd, context } = options;374  const dataDir = options.dataDir ?? join(homedir(), ".khaelor");375  const userDir = options.userDir ?? join(homedir(), ".khaelor");376377  const workspace = new LocalWorkspace(cwd);378  const fileTimes = new InMemoryFileTimeRegistry();379  const git = new GitService(workspace);380  const registry = createDefaultToolRegistry();381  const toolNames = registry.list().map((tool) => tool.name);382383  const bridge = new ProcessBridge(context.session);384  context.bus.on("tool.started", (event) => {385    bridge.currentToolUseId = event.payload.toolUseId;386  });387  const processes = new LocalProcessManager({388    logDir: join(dataDir, "process-logs", context.sessionId),389    eventSink: bridge,390  });391392  // Config permissions (shorthand, nested, and rules forms) become project-layer rules.393  let projectRules: readonly PermissionRule[] = [];394  const normalized = normalizePermissionsSection(config.permissions, "project");395  if (normalized.ok) {396    projectRules = normalized.value;397  } else {398    options.logger.log("warn", "invalid permissions config ignored", {399      message: normalized.error.message,400    });401  }402  const permissions = new PermissionService({403    rules: { project: projectRules },404    ...(options.asker !== undefined ? { asker: options.asker } : {}),405    // "Always allow in this project" grants persist to .khaelor/config.json406    // (PERMISSION_MODEL.md §6.2) — they are ordinary rules on the next load.407    persister: {408      persist: (rule) => persistPermissionGrant(rule, { projectDir: cwd }),409    },410    publish: (event) => {411      context.session.publishDurable(event as DurableEventInput);412    },413  });414415  const executor = new ToolExecutor({416    session: context.session,417    registry,418    permissions,419    workspace,420    fileTimes,421    processes,422    spillDir: join(dataDir, "spill"),423    home: homedir(),424  });425426  const instructions = await discoverProjectInstructions(workspace, { userDir });427  const systemArgs = { workingDirectory: cwd, toolNames, instructions };428429  const modelClient = new AnthropicModelClient({ apiKey: config.apiKey ?? "" });430  const budget = new ContextBudget({431    model: config.model,432    reservedOutputTokens: config.maxOutputTokens,433  });434  const contextEngine = buildContextEngine({435    config,436    model: config.model,437    modelClient,438    budget,439    registry,440    systemArgs,441  });442443  const engine = new Engine({444    config,445    context,446    workspace,447    processes,448    git,449    registry,450    permissions,451    modelClient,452    executor,453    contextEngine,454    budget,455    logger: options.logger,456    systemArgs,457    bridge,458  });459460  // Session lifecycle event + resume recovery — recorded before any turn runs.461  if (context.resumed) {462    context.session.publishDurable({463      type: "session.resumed",464      payload: {465        khaelorVersion: KHAELOR_VERSION,466        replayedSeq: context.log.seqCursor - 1,467        model: config.model,468        toolNames: toolNames as ToolName[],469      },470    });471    recoverDanglingOnResume(context.session);472  } else {473    const branch = await git.currentBranch();474    context.session.publishDurable({475      type: "session.started",476      payload: {477        title: "",478        projectHash: context.hash,479        workingDirectory: cwd,480        gitBranch: branch.kind === "ok" ? branch.value : null,481        model: config.model,482        auxModel: config.auxModel,483        khaelorVersion: KHAELOR_VERSION,484      },485    });486  }487488  return engine;489}490