import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname } from "node:path"; import type { ActiveRun, KdPhase } from "./types.ts"; import { runLedgerPath } from "./paths.ts"; export type KdLedgerEventType = | "run.created" | "run.switched" | "run.finished" | "context.recorded" | "question.asked" | "question.answered" | "fact.revised" | "product.updated" | "risk.updated" | "mode.updated" | "artifact.updated" | "phase.advance.blocked" | "phase.advanced" | "gate.refreshed" | "gate.requested" | "gate.decision" | "gate.blocked" | "gate.warning" | "input.received" | "input.auto_answered" | "tool.blocked" | "tool.succeeded" | "tool.result.recorded" | "action.committed" | "write.transaction.recorded" | "source.anchor.recorded" | "verify.recorded"; export interface KdLedgerEvent { seq: number; type: KdLedgerEventType; runId: string; phase: KdPhase; timestamp: string; summary: string; data?: Record; } export function appendLedgerEvent( cwd: string, run: ActiveRun, input: Omit & { phase?: KdPhase }, ): KdLedgerEvent { const event: KdLedgerEvent = { seq: nextLedgerSeq(cwd, run), type: input.type, runId: run.id, phase: input.phase ?? run.phase, timestamp: new Date().toISOString(), summary: input.summary.trim(), data: sanitizeLedgerData(input.data), }; const path = runLedgerPath(cwd, run); mkdirSync(dirname(path), { recursive: true }); appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8"); return event; } export function readLedgerEvents(cwd: string, run: ActiveRun): KdLedgerEvent[] { const path = runLedgerPath(cwd, run); if (!existsSync(path)) return []; return readFileSync(path, "utf8") .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .flatMap(parseLedgerLine); } export function formatLedgerEvents(events: KdLedgerEvent[], limit = 20): string { const recent = events.slice(Math.max(0, events.length - Math.max(1, limit))); if (recent.length === 0) return "无。"; return recent .map((event) => { const data = event.data && Object.keys(event.data).length > 0 ? ` ${JSON.stringify(event.data)}` : ""; return `- #${event.seq} [${event.phase}] ${event.type}:${event.summary}${data}`; }) .join("\n"); } function nextLedgerSeq(cwd: string, run: ActiveRun): number { const events = readLedgerEvents(cwd, run); return events.length === 0 ? 1 : Math.max(...events.map((event) => event.seq)) + 1; } function parseLedgerLine(line: string): KdLedgerEvent[] { try { const parsed = JSON.parse(line) as Partial; if (!parsed || typeof parsed !== "object") return []; if (typeof parsed.seq !== "number" || !Number.isFinite(parsed.seq)) return []; if (typeof parsed.type !== "string" || typeof parsed.runId !== "string" || typeof parsed.phase !== "string") return []; if (typeof parsed.timestamp !== "string" || typeof parsed.summary !== "string") return []; return [ { seq: Math.trunc(parsed.seq), type: parsed.type as KdLedgerEventType, runId: parsed.runId, phase: parsed.phase as KdPhase, timestamp: parsed.timestamp, summary: parsed.summary, data: sanitizeLedgerData(parsed.data), }, ]; } catch { return []; } } function sanitizeLedgerData(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const out: Record = {}; for (const [key, item] of Object.entries(value)) { if (!key.trim()) continue; if (item == null || typeof item === "string" || typeof item === "number" || typeof item === "boolean") { out[key] = item; continue; } if (Array.isArray(item)) { out[key] = item.filter((entry) => entry == null || typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean"); } } return Object.keys(out).length > 0 ? out : undefined; }