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%
12.9 KB · 369 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/context/checkpoint.ts4 * Description: Structured compaction checkpoint (CLAUDE.md §12) — deterministic YAML-ish serialization and parsing, no yaml dependency.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { KhaelorError, err, ok } from "../shared/index.js";11import type { Result } from "../shared/index.js";1213// ───────────────────────────── checkpoint shape ─────────────────────────────1415export interface CheckpointFileRef {16  path: string;17  reason: string;18}1920export interface CheckpointProcess {21  id: string;22  command: string;23  status: string;24}2526export interface CheckpointEvidence {27  label: string;28  content: string;29}3031/** The structured checkpoint (ARCHITECTURE.md §6.4 / CLAUDE.md §12). */32export interface Checkpoint {33  objective: string;34  completed: string[];35  currentState: string;36  importantFiles: CheckpointFileRef[];37  changes: string[];38  failedAttempts: string[];39  decisions: string[];40  runningProcesses: CheckpointProcess[];41  nextSteps: string[];42  /** Preserved verbatim when summarization would destroy it. Omitted when empty. */43  rawEvidence?: CheckpointEvidence[];44}4546/** An all-empty checkpoint — the deterministic base for lenient parsing. */47export function emptyCheckpoint(): Checkpoint {48  return {49    objective: "",50    completed: [],51    currentState: "",52    importantFiles: [],53    changes: [],54    failedAttempts: [],55    decisions: [],56    runningProcesses: [],57    nextSteps: [],58  };59}6061// ───────────────────────────── serialization ─────────────────────────────6263const ITEM_INDENT = "  ";64const FIELD_INDENT = "    ";6566/** Plain scalars must survive our own parser unambiguously; anything else is quoted or a block. */67function canBePlain(text: string): boolean {68  if (text.length === 0) return false;69  if (text !== text.trim()) return false;70  if (text.includes("\n") || text.includes("\r")) return false;71  const first = text[0] as string;72  if ("-|>#\"'[]{}&*!%@`,?:".includes(first)) return false;73  if (text.includes(": ")) return false;74  if (text.endsWith(":")) return false;75  return true;76}7778/** Multi-line strings without carriage returns or trailing newlines use `|-` blocks. */79function canBeBlock(text: string): boolean {80  return text.includes("\n") && !text.includes("\r") && !text.endsWith("\n");81}8283/**84 * Emit `<prefix><key>: <scalar>` — plain when safe, `|-` block for clean85 * multi-line text, JSON-quoted otherwise. `blockIndent` prefixes block lines.86 */87function emitScalar(lines: string[], prefix: string, key: string, value: string, blockIndent: string): void {88  if (canBePlain(value)) {89    lines.push(`${prefix}${key}: ${value}`);90    return;91  }92  if (canBeBlock(value)) {93    lines.push(`${prefix}${key}: |-`);94    for (const line of value.split("\n")) {95      lines.push(line.length === 0 ? "" : blockIndent + line);96    }97    return;98  }99  lines.push(`${prefix}${key}: ${JSON.stringify(value)}`);100}101102function emitStringList(lines: string[], key: string, items: readonly string[]): void {103  if (items.length === 0) {104    lines.push(`${key}: []`);105    return;106  }107  lines.push(`${key}:`);108  for (const item of items) {109    if (canBePlain(item)) {110      lines.push(`${ITEM_INDENT}- ${item}`);111    } else if (canBeBlock(item)) {112      lines.push(`${ITEM_INDENT}- |-`);113      for (const line of item.split("\n")) {114        lines.push(line.length === 0 ? "" : FIELD_INDENT + line);115      }116    } else {117      lines.push(`${ITEM_INDENT}- ${JSON.stringify(item)}`);118    }119  }120}121122function emitObjectList(123  lines: string[],124  key: string,125  items: readonly Record<string, string>[],126  fields: readonly string[],127): void {128  if (items.length === 0) {129    lines.push(`${key}: []`);130    return;131  }132  lines.push(`${key}:`);133  for (const item of items) {134    let first = true;135    for (const field of fields) {136      const value = item[field] ?? "";137      const prefix = first ? `${ITEM_INDENT}- ` : FIELD_INDENT;138      emitScalar(lines, prefix, field, value, FIELD_INDENT + "  ");139      first = false;140    }141  }142}143144/**145 * Deterministic serialization: fixed key order, fixed scalar-style rules —146 * the same checkpoint always produces byte-identical text (replay safety,147 * ADR-6/7). `raw_evidence` is omitted entirely when absent or empty.148 */149export function serializeCheckpoint(checkpoint: Checkpoint): string {150  const lines: string[] = [];151  emitScalar(lines, "", "objective", checkpoint.objective, ITEM_INDENT);152  emitStringList(lines, "completed", checkpoint.completed);153  emitScalar(lines, "", "current_state", checkpoint.currentState, ITEM_INDENT);154  emitObjectList(155    lines,156    "important_files",157    checkpoint.importantFiles.map((f) => ({ path: f.path, reason: f.reason })),158    ["path", "reason"],159  );160  emitStringList(lines, "changes", checkpoint.changes);161  emitStringList(lines, "failed_attempts", checkpoint.failedAttempts);162  emitStringList(lines, "decisions", checkpoint.decisions);163  emitObjectList(164    lines,165    "running_processes",166    checkpoint.runningProcesses.map((p) => ({ id: p.id, command: p.command, status: p.status })),167    ["id", "command", "status"],168  );169  emitStringList(lines, "next_steps", checkpoint.nextSteps);170  if (checkpoint.rawEvidence !== undefined && checkpoint.rawEvidence.length > 0) {171    emitObjectList(172      lines,173      "raw_evidence",174      checkpoint.rawEvidence.map((e) => ({ label: e.label, content: e.content })),175      ["label", "content"],176    );177  }178  return lines.join("\n") + "\n";179}180181// ───────────────────────────── parsing ─────────────────────────────182183interface Cursor {184  lines: string[];185  index: number;186}187188function parseError(message: string, line: number): KhaelorError {189  return new KhaelorError("invalid-event", `checkpoint parse error at line ${line + 1}: ${message}`, {190    line: line + 1,191  });192}193194/** Consume a `|-` block whose content lines are prefixed by `blockIndent`. */195function readBlock(cursor: Cursor, blockIndent: string): string {196  const content: string[] = [];197  let pendingEmpty = 0;198  while (cursor.index < cursor.lines.length) {199    const line = cursor.lines[cursor.index] as string;200    if (line.length === 0) {201      pendingEmpty += 1;202      cursor.index += 1;203      continue;204    }205    if (!line.startsWith(blockIndent)) break;206    for (; pendingEmpty > 0; pendingEmpty--) content.push("");207    content.push(line.slice(blockIndent.length));208    cursor.index += 1;209  }210  // Trailing buffered empty lines were separators, not block content211  // (strings with trailing newlines are JSON-quoted by the serializer).212  cursor.index -= pendingEmpty;213  return content.join("\n");214}215216/** Parse the value after `key:` given the trailing rest of that line. */217function readScalar(cursor: Cursor, rest: string, blockIndent: string, atLine: number): Result<string, KhaelorError> {218  if (rest === "|-") return ok(readBlock(cursor, blockIndent));219  if (rest.startsWith('"')) {220    try {221      const parsed: unknown = JSON.parse(rest);222      if (typeof parsed !== "string") return err(parseError("quoted scalar is not a string", atLine));223      return ok(parsed);224    } catch {225      return err(parseError("invalid quoted scalar", atLine));226    }227  }228  return ok(rest);229}230231function readStringList(cursor: Cursor, rest: string, atLine: number): Result<string[], KhaelorError> {232  if (rest === "[]") return ok([]);233  if (rest !== "") return err(parseError("expected empty rest, [] or list items", atLine));234  const items: string[] = [];235  while (cursor.index < cursor.lines.length) {236    const line = cursor.lines[cursor.index] as string;237    if (!line.startsWith(`${ITEM_INDENT}- `)) break;238    const lineNo = cursor.index;239    const itemRest = line.slice(ITEM_INDENT.length + 2);240    cursor.index += 1;241    const value = readScalar(cursor, itemRest, FIELD_INDENT, lineNo);242    if (!value.ok) return value;243    items.push(value.value);244  }245  return ok(items);246}247248function readObjectList(249  cursor: Cursor,250  rest: string,251  fields: readonly string[],252  atLine: number,253): Result<Record<string, string>[], KhaelorError> {254  if (rest === "[]") return ok([]);255  if (rest !== "") return err(parseError("expected empty rest, [] or list items", atLine));256  const items: Record<string, string>[] = [];257  while (cursor.index < cursor.lines.length) {258    const line = cursor.lines[cursor.index] as string;259    if (!line.startsWith(`${ITEM_INDENT}- `)) break;260    const item: Record<string, string> = {};261    let fieldLine = line.slice(ITEM_INDENT.length + 2);262    let expectContinuation = false;263    for (;;) {264      const lineNo = cursor.index;265      const colon = fieldLine.indexOf(":");266      if (colon === -1) return err(parseError("expected `field: value` in list item", lineNo));267      const field = fieldLine.slice(0, colon);268      if (!fields.includes(field)) return err(parseError(`unknown field "${field}"`, lineNo));269      const fieldRest = fieldLine.slice(colon + 1).replace(/^ /, "");270      cursor.index += 1;271      const value = readScalar(cursor, fieldRest, FIELD_INDENT + "  ", lineNo);272      if (!value.ok) return value;273      item[field] = value.value;274      const next = cursor.lines[cursor.index];275      expectContinuation =276        next !== undefined && next.startsWith(FIELD_INDENT) && !next.startsWith(FIELD_INDENT + " ");277      if (!expectContinuation) break;278      fieldLine = (next as string).slice(FIELD_INDENT.length);279    }280    items.push(item);281  }282  return ok(items);283}284285const STRING_KEYS = new Set(["objective", "current_state"]);286const STRING_LIST_KEYS = new Set(["completed", "changes", "failed_attempts", "decisions", "next_steps"]);287const OBJECT_LIST_FIELDS: Record<string, readonly string[]> = {288  important_files: ["path", "reason"],289  running_processes: ["id", "command", "status"],290  raw_evidence: ["label", "content"],291};292293/**294 * Parse checkpoint text produced by `serializeCheckpoint` (or model output in295 * the same format). Unknown top-level keys are skipped with their indented296 * continuation lines (forward tolerance); malformed known sections error.297 */298export function parseCheckpoint(text: string): Result<Checkpoint, KhaelorError> {299  const cursor: Cursor = { lines: text.split("\n"), index: 0 };300  const checkpoint = emptyCheckpoint();301  let sawAnyKey = false;302303  while (cursor.index < cursor.lines.length) {304    const line = cursor.lines[cursor.index] as string;305    if (line.trim().length === 0) {306      cursor.index += 1;307      continue;308    }309    if (line.startsWith(" ") || line.startsWith("\t")) {310      return err(parseError("unexpected indented line outside a section", cursor.index));311    }312    const match = /^([a-z_]+):(.*)$/.exec(line);313    if (!match) return err(parseError("expected `key:` line", cursor.index));314    const key = match[1] as string;315    const rest = (match[2] as string).replace(/^ /, "");316    const atLine = cursor.index;317    cursor.index += 1;318319    if (STRING_KEYS.has(key)) {320      const value = readScalar(cursor, rest, ITEM_INDENT, atLine);321      if (!value.ok) return value;322      if (key === "objective") checkpoint.objective = value.value;323      else checkpoint.currentState = value.value;324      sawAnyKey = true;325    } else if (STRING_LIST_KEYS.has(key)) {326      const value = readStringList(cursor, rest, atLine);327      if (!value.ok) return value;328      if (key === "completed") checkpoint.completed = value.value;329      else if (key === "changes") checkpoint.changes = value.value;330      else if (key === "failed_attempts") checkpoint.failedAttempts = value.value;331      else if (key === "decisions") checkpoint.decisions = value.value;332      else checkpoint.nextSteps = value.value;333      sawAnyKey = true;334    } else if (key in OBJECT_LIST_FIELDS) {335      const fields = OBJECT_LIST_FIELDS[key] as readonly string[];336      const value = readObjectList(cursor, rest, fields, atLine);337      if (!value.ok) return value;338      if (key === "important_files") {339        checkpoint.importantFiles = value.value.map((o) => ({340          path: o["path"] ?? "",341          reason: o["reason"] ?? "",342        }));343      } else if (key === "running_processes") {344        checkpoint.runningProcesses = value.value.map((o) => ({345          id: o["id"] ?? "",346          command: o["command"] ?? "",347          status: o["status"] ?? "",348        }));349      } else if (value.value.length > 0) {350        checkpoint.rawEvidence = value.value.map((o) => ({351          label: o["label"] ?? "",352          content: o["content"] ?? "",353        }));354      }355      sawAnyKey = true;356    } else {357      // Unknown key: skip its indented continuation lines (tolerant reader).358      while (cursor.index < cursor.lines.length) {359        const next = cursor.lines[cursor.index] as string;360        if (next.length > 0 && !next.startsWith(" ")) break;361        cursor.index += 1;362      }363    }364  }365366  if (!sawAnyKey) return err(parseError("no checkpoint keys found", 0));367  return ok(checkpoint);368}369