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%
16.3 KB · 600 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/session/events.ts4 * Description: The complete typed event catalog — envelopes, 38 event types, durable/ephemeral split (EVENT_MODEL.md).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910/** Schema version written on every durable JSONL line (EVENT_MODEL.md §5.4). */11export const EVENT_SCHEMA_VERSION = 1 as const;12export type EventSchemaVersion = typeof EVENT_SCHEMA_VERSION;1314// ───────────────────────────── envelopes ─────────────────────────────1516/** Durable envelope — one JSONL line per event. */17export interface Durable<T extends string, P> {18  v: EventSchemaVersion; // schema version of this event line19  id: string; // ULID — globally unique, time-ordered20  sessionId: string;21  seq: number; // monotonic per session, gapless, assigned at append time22  ts: number; // epoch milliseconds23  parentId?: string; // reserved, always absent in V1 (ADR-3: linear log, tree-ready)24  type: T;25  payload: P;26}2728/** Ephemeral envelope — bus-only, never persisted. No seq (no log position), no v. */29export interface Ephemeral<T extends string, P> {30  id: string; // ULID (correlation/debugging)31  sessionId: string;32  ts: number;33  type: T;34  payload: P;35}3637// ───────────────────────── shared payload types ─────────────────────────3839export type StopReason = "end_turn" | "tool_use" | "max_tokens" | "refusal";4041export interface ModelUsage {42  // real API fields only43  inputTokens: number;44  outputTokens: number;45  cacheReadTokens: number;46  cacheWriteTokens: number;47}4849export interface DiffStats {50  added: number;51  removed: number;52}5354export interface CheckResult {55  command: string;56  exitCode: number;57  summary: string; // e.g. "148 passed", "typecheck passed"58  durationMs: number;59}6061export interface CompletionEvidence {62  // CLAUDE.md §1763  objective: string;64  changedFiles: string[];65  checks: CheckResult[];66  unresolvedIssues: string[];67}6869export interface GitBaseline {70  branch: string;71  dirtyFiles: string[];72  untrackedFiles: string[];73  diffHash: string; // hash of `git diff` output at capture time74}7576export type ModelErrorKind =77  | "retryable"78  | "context-overflow"79  | "auth"80  | "invalid-request"81  | "cancelled";8283export type ToolName = "read" | "write" | "edit" | "grep" | "glob" | "bash" | "process";8485// ─────────────────────── session lifecycle (durable) ───────────────────────8687export type SessionStarted = Durable<88  "session.started",89  {90    title: string;91    projectHash: string;92    workingDirectory: string;93    gitBranch: string | null;94    model: string;95    auxModel: string;96    khaelorVersion: string;97  }98>;99100export type SessionResumed = Durable<101  "session.resumed",102  {103    khaelorVersion: string;104    replayedSeq: number; // highest seq replayed105    model: string; // active model after resume (may differ — swappable)106    toolNames: ToolName[]; // verified add-only vs. original set (ADR-3 resume contract)107  }108>;109110export type SessionRenamed = Durable<"session.renamed", { title: string }>;111112export type ModelChanged = Durable<113  "session.model-changed",114  {115    from: string;116    to: string;117    reason: "user" | "config";118  }119>;120121export type BaselineRecorded = Durable<122  "git.baseline-recorded",123  {124    when: "session-start" | "pre-first-edit";125    baseline: GitBaseline;126  }127>;128129// ───────────────────────── user input (durable) ─────────────────────────130131export type UserMessageCreated = Durable<132  "user.message-created",133  {134    text: string; // byte-exact — enters LLM history verbatim135    mentions: { path: string; range?: { start: number; end: number } }[];136  }137>;138139export type SteeringQueued = Durable<"user.steering-queued", { text: string }>;140141export type SteeringInjected = Durable<142  "user.steering-injected",143  {144    queuedEventId: string; // id of the SteeringQueued event145    seam: "post-tool-batch" | "pre-model-call";146    afterSeq: number; // injection position in history — makes replay exact (ADR-7)147  }148>;149150export type Interrupted = Durable<151  "user.interrupted",152  {153    scope: "turn"; // V1: Esc aborts the turn (model stream + in-flight tools)154    pendingToolUseIds: string[]; // tools that will be closed via ToolCancelled155  }156>;157158// ──────────────────────────── model stream ────────────────────────────159160export type ModelRequestStarted = Durable<161  "model.request-started",162  {163    requestId: string; // correlates all blocks/usage of this call164    model: string;165    purpose: "main" | "compaction" | "verification-nudge";166    contextStats: {167      // for /context history — estimates labeled as such168      estimatedInputTokens: number;169      sections: { name: string; estimatedTokens: number }[];170    };171  }172>;173174export type ModelTextDelta = Ephemeral<175  "model.text-delta",176  {177    requestId: string;178    blockIndex: number;179    text: string;180  }181>;182183export type ModelThinkingDelta = Ephemeral<184  "model.thinking-delta",185  {186    requestId: string;187    blockIndex: number;188    text: string;189  }190>;191192export type ToolCallStarted = Ephemeral<193  "model.tool-call-started",194  {195    requestId: string;196    blockIndex: number;197    toolUseId: string;198    toolName: ToolName;199  }200>;201202export type ToolInputDelta = Ephemeral<203  "model.tool-input-delta",204  {205    requestId: string;206    blockIndex: number;207    toolUseId: string;208    partialJson: string;209  }210>;211212export type ModelTextBlockCompleted = Durable<213  "model.text-block-completed",214  {215    requestId: string;216    blockIndex: number;217    text: string; // byte-exact settled block218  }219>;220221export type ModelThinkingBlockCompleted = Durable<222  "model.thinking-block-completed",223  {224    requestId: string;225    blockIndex: number;226    thinking: string;227    signature: string; // required for byte-exact API replay in tool loops228  }229>;230231export type ToolRequested = Durable<232  "tool.requested",233  {234    requestId: string;235    blockIndex: number;236    toolUseId: string; // Anthropic tool_use id — pairing key (§6.5)237    toolName: ToolName;238    input: unknown; // complete parsed input — byte-exact via canonical JSON (§5.1)239  }240>;241242export type ModelResponseCompleted = Durable<243  "model.response-completed",244  {245    requestId: string;246    stopReason: StopReason;247    usage: ModelUsage; // REAL API usage — sole source for cost/compaction248    durationMs: number;249  }250>;251252export type ModelRequestFailed = Durable<253  "model.request-failed",254  {255    requestId: string;256    kind: ModelErrorKind;257    message: string; // redacted — never headers/keys258    status?: number;259    retriesExhausted: boolean;260  }261>;262263// ─────────────────────── permissions (durable) ───────────────────────264265export type PermissionRequested = Durable<266  "permission.requested",267  {268    permissionRequestId: string;269    toolUseId: string;270    capability: string; // Capability271    descriptor: string; // human-meaningful: "Run `npm install` in ~/dev/project"272    suggestion?: { capability: string; pattern: string }; // "always allow git push *" (ADR-9)273  }274>;275276export type PermissionGranted = Durable<277  "permission.granted",278  {279    permissionRequestId: string;280    scope: "once" | "always-project"; // "always" also persists a config rule (ADR-9)281  }282>;283284export type PermissionDenied = Durable<285  "permission.denied",286  {287    permissionRequestId: string;288    source: "user" | "policy" | "hardline" | "timeout"; // silence is not consent (ADR-9)289    feedback: string; // returned to the model as the tool observation290  }291>;292293// ─────────────── tool execution (durable + one ephemeral) ───────────────294295export type ToolApproved = Durable<296  "tool.approved",297  {298    toolUseId: string;299    via: "policy-allow" | "user-once" | "user-always" | "rule";300  }301>;302303export type ToolStarted = Durable<304  "tool.started",305  {306    toolUseId: string;307    toolName: ToolName;308  }309>;310311export type ToolOutput = Ephemeral<312  "tool.output",313  {314    toolUseId: string;315    chunk: string; // live chunk, ANSI-stripped for TUI316  }317>;318319export type ToolCompleted = Durable<320  "tool.completed",321  {322    toolUseId: string;323    modelText: string; // byte-exact tool_result content (budget-truncated w/ markers)324    spillFile?: string; // full output under ~/.khaelor/spill/ (ADR-8)325    durationMs: number;326    ui: {327      // presentation DATA, not presentation (Rule 3)328      kind: "read" | "search" | "edit" | "exec" | "process";329      summary: string; // e.g. `Search "ContextEngine" · 14 matches`330      diffStats?: DiffStats;331      exitCode?: number;332      matchCount?: number;333    };334  }335>;336337export type ToolFailed = Durable<338  "tool.failed",339  {340    toolUseId: string;341    modelText: string; // byte-exact is_error tool_result content342    errorKind:343      | "invalid-input"344      | "not-found"345      | "ambiguous-edit"346      | "exec-error"347      | "timeout"348      | "permission-denied"349      | "internal";350    durationMs: number;351  }352>;353354export type ToolCancelled = Durable<355  "tool.cancelled",356  {357    toolUseId: string;358    reason: "interrupted" | "resume-recovery" | "shutdown";359    modelText: string; // synthetic result, e.g. "[Tool execution cancelled by user]"360  }361>;362363// ─────────────────────── file activity (durable) ───────────────────────364365export type FileRead = Durable<366  "file.read",367  {368    path: string; // relative to workspace cwd369    range?: { start: number; end: number };370    bytes: number;371    mtimeMs: number; // external-modification detection on later writes372    toolUseId: string;373  }374>;375376export type FileModified = Durable<377  "file.modified",378  {379    path: string;380    operation: "write" | "edit";381    diffStats: DiffStats;382    diff?: string; // unified diff, capped (default 32 KiB) with truncation marker383    toolUseId: string;384  }385>;386387// ─────────────── processes (durable + one ephemeral) ───────────────388389export type ProcessStarted = Durable<390  "process.started",391  {392    processId: string; // KHAELOR id (stable across PID reuse)393    pid: number;394    command: string;395    cwd: string;396    name?: string;397    toolUseId: string;398  }399>;400401export type ProcessOutput = Ephemeral<402  "process.output",403  {404    processId: string;405    stream: "stdout" | "stderr";406    chunk: string;407  }408>;409410export type ProcessExited = Durable<411  "process.exited",412  {413    processId: string;414    exitCode: number | null; // null = signal-killed415    cause: "exited" | "stopped-by-tool" | "khaelor-shutdown" | "crashed";416    durationMs: number;417  }418>;419420// ─────────────────────── context engine (durable) ───────────────────────421422export type ContextPruned = Durable<423  "context.pruned",424  {425    toolUseIds: string[]; // results blanked with the FIXED placeholder string426    placeholder: string; // recorded so replay is byte-exact even if default changes427    tokensReclaimedEstimate: number;428  }429>;430431export type ContextCompacted = Durable<432  "context.compacted",433  {434    checkpointYaml: string; // the structured checkpoint (ARCHITECTURE.md §6.4), verbatim435    cut: { fromSeq: number; toSeq: number }; // replaced range — pairing-safe boundary (§6.5)436    trigger: "proactive-token-budget" | "reactive-overflow" | "user-command";437    tokensBefore: number; // from real usage accounting438    summaryModel: string; // the auxModel used439  }440>;441442// ─────────────────────── completion (durable) ───────────────────────443444export type VerificationRequested = Durable<445  "task.verification-requested",446  {447    attempt: 1 | 2;448    detectedChecks: string[]; // e.g. ["npm test", "npx tsc --noEmit"]449    withheldCandidateSeq: number; // seq of the withheld answer's final text block (ADR-12)450  }451>;452453export type TaskCompleted = Durable<454  "task.completed",455  {456    evidence: CompletionEvidence;457  }458>;459460export type TaskFailed = Durable<461  "task.failed",462  {463    reason: "model-fatal-error" | "iteration-budget-exhausted" | "user-abandoned";464    detail: string;465  }466>;467468// ───────────────────────────── unions ─────────────────────────────469470export type DurableEvent =471  | SessionStarted472  | SessionResumed473  | SessionRenamed474  | ModelChanged475  | BaselineRecorded476  | UserMessageCreated477  | SteeringQueued478  | SteeringInjected479  | Interrupted480  | ModelRequestStarted481  | ModelTextBlockCompleted482  | ModelThinkingBlockCompleted483  | ToolRequested484  | ModelResponseCompleted485  | ModelRequestFailed486  | PermissionRequested487  | PermissionGranted488  | PermissionDenied489  | ToolApproved490  | ToolStarted491  | ToolCompleted492  | ToolFailed493  | ToolCancelled494  | FileRead495  | FileModified496  | ProcessStarted497  | ProcessExited498  | ContextPruned499  | ContextCompacted500  | VerificationRequested501  | TaskCompleted502  | TaskFailed;503504export type EphemeralEvent =505  | ModelTextDelta506  | ModelThinkingDelta507  | ToolCallStarted508  | ToolInputDelta509  | ToolOutput510  | ProcessOutput;511512export type KhaelorEvent = DurableEvent | EphemeralEvent;513514export type DurableEventType = DurableEvent["type"];515export type EphemeralEventType = EphemeralEvent["type"];516517/** What producers hand to the bus/store; envelope fields are assigned at append time. */518export type DurableEventInput = {519  [T in DurableEventType]: { type: T; payload: Extract<DurableEvent, { type: T }>["payload"] };520}[DurableEventType];521522/** What producers hand to the bus for ephemeral publication. */523export type EphemeralEventInput = {524  [T in EphemeralEventType]: { type: T; payload: Extract<EphemeralEvent, { type: T }>["payload"] };525}[EphemeralEventType];526527// ─────────────── durable/ephemeral classification (runtime) ───────────────528529const DURABLE_TYPE_LIST = [530  "session.started",531  "session.resumed",532  "session.renamed",533  "session.model-changed",534  "git.baseline-recorded",535  "user.message-created",536  "user.steering-queued",537  "user.steering-injected",538  "user.interrupted",539  "model.request-started",540  "model.text-block-completed",541  "model.thinking-block-completed",542  "tool.requested",543  "model.response-completed",544  "model.request-failed",545  "permission.requested",546  "permission.granted",547  "permission.denied",548  "tool.approved",549  "tool.started",550  "tool.completed",551  "tool.failed",552  "tool.cancelled",553  "file.read",554  "file.modified",555  "process.started",556  "process.exited",557  "context.pruned",558  "context.compacted",559  "task.verification-requested",560  "task.completed",561  "task.failed",562] as const satisfies readonly DurableEventType[];563564const EPHEMERAL_TYPE_LIST = [565  "model.text-delta",566  "model.thinking-delta",567  "model.tool-call-started",568  "model.tool-input-delta",569  "tool.output",570  "process.output",571] as const satisfies readonly EphemeralEventType[];572573type AssertNever<T extends never> = T;574/** Compile-time exhaustiveness guards: fail to compile if a union member is missing from a list. */575export type _DurableListIsExhaustive = AssertNever<576  Exclude<DurableEventType, (typeof DURABLE_TYPE_LIST)[number]>577>;578export type _EphemeralListIsExhaustive = AssertNever<579  Exclude<EphemeralEventType, (typeof EPHEMERAL_TYPE_LIST)[number]>580>;581582/** The 32 durable event types (EVENT_MODEL.md §3). */583export const DURABLE_EVENT_TYPES: ReadonlySet<DurableEventType> = new Set(DURABLE_TYPE_LIST);584585/** The 6 ephemeral event types (EVENT_MODEL.md §3). */586export const EPHEMERAL_EVENT_TYPES: ReadonlySet<EphemeralEventType> = new Set(EPHEMERAL_TYPE_LIST);587588export function isDurableEventType(type: string): type is DurableEventType {589  return DURABLE_EVENT_TYPES.has(type as DurableEventType);590}591592export function isEphemeralEventType(type: string): type is EphemeralEventType {593  return EPHEMERAL_EVENT_TYPES.has(type as EphemeralEventType);594}595596/** Runtime classification of a full event by its `type` string. */597export function isDurableEvent(event: KhaelorEvent): event is DurableEvent {598  return isDurableEventType(event.type);599}600