/** * src/engine/ledger.ts — hash-only, body-free delegation ledger (node/fs). * * Ported/adapted from the ZOB harness `tools-delegation/helpers.ts` ledger * helpers: `DELEGATION_LEDGER_RAW_KEYS`, `bodyFreeDelegationLedgerEntry`, * `appendLedgerFile`, `delegationLedgerMeta`. The ledger is I1 hash-only: * every persisted entry has `bodyStored:false` and replaces raw-body keys * (`body|prompt|task|output|stderr|error|errorMessage|errors|gateErrors`) * with `Hashes` (sha-256) + `Count`. * * The ledger dir is injectable (default `/.pi/logs/runs`), so tests * write to a temp dir and never touch the real `.pi/logs/runs`. * * Zero @earendil-works/* imports; uses only node:fs and node:path. */ import { appendFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { sha256 } from "../core/hashing.js"; import type { DelegationRunMode, DelegationRunSource } from "./runs.js"; /** Raw-body keys that must never be persisted to a ledger entry. */ export const DELEGATION_LEDGER_RAW_KEYS = new Set(["body", "prompt", "task", "output", "stderr", "error", "errorMessage", "errors", "gateErrors"]); /** * Recursively strip raw-body keys from an entry, replacing each STRING (or * string-array) body with `Hashes` (sha-256 of each string body) + * `Count`. Non-string scalars under a body-like key (numbers/booleans, * e.g. the `usage.output` token counter) are NOT bodies — they persist * as-is so ledger `end` usage totals stay honest (P3). Always appends * `bodyStored:false`, `promptBodiesStored:false`, `outputBodiesStored:false`. * Never mutates the input. */ export function bodyFreeDelegationLedgerEntry(entry: Record): Record { const sanitize = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(sanitize); if (!value || typeof value !== "object") return value; const output: Record = {}; for (const [key, nested] of Object.entries(value as Record)) { if (DELEGATION_LEDGER_RAW_KEYS.has(key)) { const bodies = Array.isArray(nested) ? nested.filter((item): item is string => typeof item === "string") : typeof nested === "string" ? [nested] : []; if (bodies.length > 0) { output[`${key}Hashes`] = bodies.map((body) => sha256(body)); output[`${key}Count`] = bodies.length; continue; } // P3: numeric/boolean scalars under a body-like key are counters // (usage.output tokens), not bodies — keep them verbatim. if (typeof nested === "number" || typeof nested === "boolean") output[key] = nested; continue; } output[key] = sanitize(nested); } return output; }; return { ...(sanitize(entry) as Record), bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, }; } export interface AppendLedgerOptions { /** Override the ledger directory (default `/.pi/logs/runs`). */ dir?: string; /** Inject a clock for deterministic test file names / timestamps. */ now?: () => Date; } /** * Append one body-free ledger entry as a JSONL line to * `/.jsonl` (day = UTC YYYY-MM-DD). The dir is injectable via * `options.dir`; the default is `/.pi/logs/runs`. The persisted * line is `{...bodyFree(entry), timestamp: ISO}`. */ export function appendLedgerFile(repoRoot: string, entry: Record, options: AppendLedgerOptions = {}): void { const dir = options.dir ?? join(repoRoot, ".pi", "logs", "runs"); mkdirSync(dir, { recursive: true }); const now = options.now?.() ?? new Date(); const day = now.toISOString().slice(0, 10); const bodyFree = bodyFreeDelegationLedgerEntry(entry); appendFileSync(join(dir, `${day}.jsonl`), `${JSON.stringify({ ...bodyFree, timestamp: now.toISOString() })}\n`, "utf8"); } /** Metadata prefix identifying a delegation ledger entry's source and mode. */ export function delegationLedgerMeta( source: DelegationRunSource, parentToolCallId: string | undefined, delegationMode: DelegationRunMode, index?: number, ): Record { return { source, parentToolCallId, delegationMode, index }; } /** * True when a ledger entry still contains a raw-body VALUE (string or array) * under a body-like key. Numeric/boolean scalars (counters such as * usage.output) are not bodies and do not flip this (P3). */ export function hasRawBodyKey(value: Record): boolean { return Object.entries(value).some( ([key, nested]) => DELEGATION_LEDGER_RAW_KEYS.has(key) && (typeof nested === "string" || Array.isArray(nested)), ); }