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/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