/** * KHAELOR * File: src/context/checkpoint.ts * Description: Structured compaction checkpoint (CLAUDE.md §12) — deterministic YAML-ish serialization and parsing, no yaml dependency. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { KhaelorError, err, ok } from "../shared/index.js"; import type { Result } from "../shared/index.js"; // ───────────────────────────── checkpoint shape ───────────────────────────── export interface CheckpointFileRef { path: string; reason: string; } export interface CheckpointProcess { id: string; command: string; status: string; } export interface CheckpointEvidence { label: string; content: string; } /** The structured checkpoint (ARCHITECTURE.md §6.4 / CLAUDE.md §12). */ export interface Checkpoint { objective: string; completed: string[]; currentState: string; importantFiles: CheckpointFileRef[]; changes: string[]; failedAttempts: string[]; decisions: string[]; runningProcesses: CheckpointProcess[]; nextSteps: string[]; /** Preserved verbatim when summarization would destroy it. Omitted when empty. */ rawEvidence?: CheckpointEvidence[]; } /** An all-empty checkpoint — the deterministic base for lenient parsing. */ export function emptyCheckpoint(): Checkpoint { return { objective: "", completed: [], currentState: "", importantFiles: [], changes: [], failedAttempts: [], decisions: [], runningProcesses: [], nextSteps: [], }; } // ───────────────────────────── serialization ───────────────────────────── const ITEM_INDENT = " "; const FIELD_INDENT = " "; /** Plain scalars must survive our own parser unambiguously; anything else is quoted or a block. */ function canBePlain(text: string): boolean { if (text.length === 0) return false; if (text !== text.trim()) return false; if (text.includes("\n") || text.includes("\r")) return false; const first = text[0] as string; if ("-|>#\"'[]{}&*!%@`,?:".includes(first)) return false; if (text.includes(": ")) return false; if (text.endsWith(":")) return false; return true; } /** Multi-line strings without carriage returns or trailing newlines use `|-` blocks. */ function canBeBlock(text: string): boolean { return text.includes("\n") && !text.includes("\r") && !text.endsWith("\n"); } /** * Emit `: ` — plain when safe, `|-` block for clean * multi-line text, JSON-quoted otherwise. `blockIndent` prefixes block lines. */ function emitScalar(lines: string[], prefix: string, key: string, value: string, blockIndent: string): void { if (canBePlain(value)) { lines.push(`${prefix}${key}: ${value}`); return; } if (canBeBlock(value)) { lines.push(`${prefix}${key}: |-`); for (const line of value.split("\n")) { lines.push(line.length === 0 ? "" : blockIndent + line); } return; } lines.push(`${prefix}${key}: ${JSON.stringify(value)}`); } function emitStringList(lines: string[], key: string, items: readonly string[]): void { if (items.length === 0) { lines.push(`${key}: []`); return; } lines.push(`${key}:`); for (const item of items) { if (canBePlain(item)) { lines.push(`${ITEM_INDENT}- ${item}`); } else if (canBeBlock(item)) { lines.push(`${ITEM_INDENT}- |-`); for (const line of item.split("\n")) { lines.push(line.length === 0 ? "" : FIELD_INDENT + line); } } else { lines.push(`${ITEM_INDENT}- ${JSON.stringify(item)}`); } } } function emitObjectList( lines: string[], key: string, items: readonly Record[], fields: readonly string[], ): void { if (items.length === 0) { lines.push(`${key}: []`); return; } lines.push(`${key}:`); for (const item of items) { let first = true; for (const field of fields) { const value = item[field] ?? ""; const prefix = first ? `${ITEM_INDENT}- ` : FIELD_INDENT; emitScalar(lines, prefix, field, value, FIELD_INDENT + " "); first = false; } } } /** * Deterministic serialization: fixed key order, fixed scalar-style rules — * the same checkpoint always produces byte-identical text (replay safety, * ADR-6/7). `raw_evidence` is omitted entirely when absent or empty. */ export function serializeCheckpoint(checkpoint: Checkpoint): string { const lines: string[] = []; emitScalar(lines, "", "objective", checkpoint.objective, ITEM_INDENT); emitStringList(lines, "completed", checkpoint.completed); emitScalar(lines, "", "current_state", checkpoint.currentState, ITEM_INDENT); emitObjectList( lines, "important_files", checkpoint.importantFiles.map((f) => ({ path: f.path, reason: f.reason })), ["path", "reason"], ); emitStringList(lines, "changes", checkpoint.changes); emitStringList(lines, "failed_attempts", checkpoint.failedAttempts); emitStringList(lines, "decisions", checkpoint.decisions); emitObjectList( lines, "running_processes", checkpoint.runningProcesses.map((p) => ({ id: p.id, command: p.command, status: p.status })), ["id", "command", "status"], ); emitStringList(lines, "next_steps", checkpoint.nextSteps); if (checkpoint.rawEvidence !== undefined && checkpoint.rawEvidence.length > 0) { emitObjectList( lines, "raw_evidence", checkpoint.rawEvidence.map((e) => ({ label: e.label, content: e.content })), ["label", "content"], ); } return lines.join("\n") + "\n"; } // ───────────────────────────── parsing ───────────────────────────── interface Cursor { lines: string[]; index: number; } function parseError(message: string, line: number): KhaelorError { return new KhaelorError("invalid-event", `checkpoint parse error at line ${line + 1}: ${message}`, { line: line + 1, }); } /** Consume a `|-` block whose content lines are prefixed by `blockIndent`. */ function readBlock(cursor: Cursor, blockIndent: string): string { const content: string[] = []; let pendingEmpty = 0; while (cursor.index < cursor.lines.length) { const line = cursor.lines[cursor.index] as string; if (line.length === 0) { pendingEmpty += 1; cursor.index += 1; continue; } if (!line.startsWith(blockIndent)) break; for (; pendingEmpty > 0; pendingEmpty--) content.push(""); content.push(line.slice(blockIndent.length)); cursor.index += 1; } // Trailing buffered empty lines were separators, not block content // (strings with trailing newlines are JSON-quoted by the serializer). cursor.index -= pendingEmpty; return content.join("\n"); } /** Parse the value after `key:` given the trailing rest of that line. */ function readScalar(cursor: Cursor, rest: string, blockIndent: string, atLine: number): Result { if (rest === "|-") return ok(readBlock(cursor, blockIndent)); if (rest.startsWith('"')) { try { const parsed: unknown = JSON.parse(rest); if (typeof parsed !== "string") return err(parseError("quoted scalar is not a string", atLine)); return ok(parsed); } catch { return err(parseError("invalid quoted scalar", atLine)); } } return ok(rest); } function readStringList(cursor: Cursor, rest: string, atLine: number): Result { if (rest === "[]") return ok([]); if (rest !== "") return err(parseError("expected empty rest, [] or list items", atLine)); const items: string[] = []; while (cursor.index < cursor.lines.length) { const line = cursor.lines[cursor.index] as string; if (!line.startsWith(`${ITEM_INDENT}- `)) break; const lineNo = cursor.index; const itemRest = line.slice(ITEM_INDENT.length + 2); cursor.index += 1; const value = readScalar(cursor, itemRest, FIELD_INDENT, lineNo); if (!value.ok) return value; items.push(value.value); } return ok(items); } function readObjectList( cursor: Cursor, rest: string, fields: readonly string[], atLine: number, ): Result[], KhaelorError> { if (rest === "[]") return ok([]); if (rest !== "") return err(parseError("expected empty rest, [] or list items", atLine)); const items: Record[] = []; while (cursor.index < cursor.lines.length) { const line = cursor.lines[cursor.index] as string; if (!line.startsWith(`${ITEM_INDENT}- `)) break; const item: Record = {}; let fieldLine = line.slice(ITEM_INDENT.length + 2); let expectContinuation = false; for (;;) { const lineNo = cursor.index; const colon = fieldLine.indexOf(":"); if (colon === -1) return err(parseError("expected `field: value` in list item", lineNo)); const field = fieldLine.slice(0, colon); if (!fields.includes(field)) return err(parseError(`unknown field "${field}"`, lineNo)); const fieldRest = fieldLine.slice(colon + 1).replace(/^ /, ""); cursor.index += 1; const value = readScalar(cursor, fieldRest, FIELD_INDENT + " ", lineNo); if (!value.ok) return value; item[field] = value.value; const next = cursor.lines[cursor.index]; expectContinuation = next !== undefined && next.startsWith(FIELD_INDENT) && !next.startsWith(FIELD_INDENT + " "); if (!expectContinuation) break; fieldLine = (next as string).slice(FIELD_INDENT.length); } items.push(item); } return ok(items); } const STRING_KEYS = new Set(["objective", "current_state"]); const STRING_LIST_KEYS = new Set(["completed", "changes", "failed_attempts", "decisions", "next_steps"]); const OBJECT_LIST_FIELDS: Record = { important_files: ["path", "reason"], running_processes: ["id", "command", "status"], raw_evidence: ["label", "content"], }; /** * Parse checkpoint text produced by `serializeCheckpoint` (or model output in * the same format). Unknown top-level keys are skipped with their indented * continuation lines (forward tolerance); malformed known sections error. */ export function parseCheckpoint(text: string): Result { const cursor: Cursor = { lines: text.split("\n"), index: 0 }; const checkpoint = emptyCheckpoint(); let sawAnyKey = false; while (cursor.index < cursor.lines.length) { const line = cursor.lines[cursor.index] as string; if (line.trim().length === 0) { cursor.index += 1; continue; } if (line.startsWith(" ") || line.startsWith("\t")) { return err(parseError("unexpected indented line outside a section", cursor.index)); } const match = /^([a-z_]+):(.*)$/.exec(line); if (!match) return err(parseError("expected `key:` line", cursor.index)); const key = match[1] as string; const rest = (match[2] as string).replace(/^ /, ""); const atLine = cursor.index; cursor.index += 1; if (STRING_KEYS.has(key)) { const value = readScalar(cursor, rest, ITEM_INDENT, atLine); if (!value.ok) return value; if (key === "objective") checkpoint.objective = value.value; else checkpoint.currentState = value.value; sawAnyKey = true; } else if (STRING_LIST_KEYS.has(key)) { const value = readStringList(cursor, rest, atLine); if (!value.ok) return value; if (key === "completed") checkpoint.completed = value.value; else if (key === "changes") checkpoint.changes = value.value; else if (key === "failed_attempts") checkpoint.failedAttempts = value.value; else if (key === "decisions") checkpoint.decisions = value.value; else checkpoint.nextSteps = value.value; sawAnyKey = true; } else if (key in OBJECT_LIST_FIELDS) { const fields = OBJECT_LIST_FIELDS[key] as readonly string[]; const value = readObjectList(cursor, rest, fields, atLine); if (!value.ok) return value; if (key === "important_files") { checkpoint.importantFiles = value.value.map((o) => ({ path: o["path"] ?? "", reason: o["reason"] ?? "", })); } else if (key === "running_processes") { checkpoint.runningProcesses = value.value.map((o) => ({ id: o["id"] ?? "", command: o["command"] ?? "", status: o["status"] ?? "", })); } else if (value.value.length > 0) { checkpoint.rawEvidence = value.value.map((o) => ({ label: o["label"] ?? "", content: o["content"] ?? "", })); } sawAnyKey = true; } else { // Unknown key: skip its indented continuation lines (tolerant reader). while (cursor.index < cursor.lines.length) { const next = cursor.lines[cursor.index] as string; if (next.length > 0 && !next.startsWith(" ")) break; cursor.index += 1; } } } if (!sawAnyKey) return err(parseError("no checkpoint keys found", 0)); return ok(checkpoint); }