/** * File-backed evidence for a goal, assembled in one place. * * Lives here rather than in the runtime because it reaches in both directions: the * ledger and the goal store are core, while the metrics and payload writers are in * `src/optimize`, which is allowed to depend on the runtime and never the reverse. A * host is the layer that gets to know about all of them. * * Opening is deferred on purpose. A chat session registers its tools before the operator * has said what they want, so the directory is created on the first write rather than at * startup - which also means a session that never does anything leaves nothing behind. */ import path from "node:path"; import { Ledger, type LedgerEvent, type LedgerInput, type LedgerSink } from "../core/ledger.ts"; import { coreRoot, goalPaths } from "../core/paths.ts"; import { GoalStore } from "../core/state.ts"; import { resolveGoalId, type GoalIdRef } from "./pi-session-goal.ts"; import { factsFrom, type Evidence, type FactStore } from "../runtime/evidence.ts"; import type { MetricRecord, MetricsSink, PayloadRecord, PayloadSink } from "../runtime/metrics.ts"; import { FilePayloadLog, FileRecorder } from "../optimize/recorder.ts"; export interface FileEvidenceOptions { /** Defaults to the core data root, which is what the product uses. */ root?: string; /** String, or a getter filled on Pi `session_start`. Paths resolve on first write. */ goalId: GoalIdRef; /** Recorded on the goal the first time it is opened, for a human reading the file. */ goal?: string; entityId?: string; } /** Opens once, on first use, and shares that one open across everything after it. */ function once(open: () => Promise): () => Promise { let pending: Promise | undefined; return () => (pending ??= open()); } export function fileEvidence(options: FileEvidenceOptions): Evidence { const root = coreRoot(options.root); const id = () => resolveGoalId(options.goalId); const pathsOf = () => goalPaths(root, id()); const ledgerOnce = once(() => Ledger.open(root, id())); const ledger: LedgerSink = { get artifactsDir() { return pathsOf().artifactsDir; }, async append(input: LedgerInput): Promise { return (await ledgerOnce()).append(input); }, async read() { return (await ledgerOnce()).read(); }, }; const storeOnce = once(() => GoalStore.open(root, id(), options.goal)); const facts: FactStore = { async mergeGoalFacts(next: Record) { return (await storeOnce()).mergeGoalFacts(next); }, }; const recorderOnce = once(() => FileRecorder.open(pathsOf().metricsFile)); const metrics: MetricsSink = { record(record: MetricRecord) { // Fire and forget: metering must never be in the way of the work it measures, and // a lost metric is a worse outcome than a stalled action only on paper. void recorderOnce().then((recorder) => recorder.record(record)); }, async flush() { await (await recorderOnce()).flush(); }, }; const payloadsOnce = once(() => FilePayloadLog.open(pathsOf().payloadsFile)); const payloads: PayloadSink = { write(record: PayloadRecord) { void payloadsOnce().then((log) => log.write(record)); }, async flush() { await (await payloadsOnce()).flush(); }, }; return { ledger, facts, metrics, payloads, goal: { root, get goalId() { return id(); }, }, get screenshotDir() { return pathsOf().artifactsDir; }, ...(options.entityId ? { entityId: options.entityId } : {}), }; } /** Evidence for an already-open goal store, which the CLI and the suite have. */ export function evidenceForGoal(options: { root: string; goalId: string; ledger: Ledger; store: GoalStore; metrics: MetricsSink; payloads: PayloadSink; entityId?: string; }): Evidence { return { ledger: options.ledger, facts: factsFrom(options.store), metrics: options.metrics, payloads: options.payloads, goal: { root: options.root, goalId: options.goalId }, screenshotDir: options.ledger.artifactsDir, ...(options.entityId ? { entityId: options.entityId } : {}), }; } /** Where a goal's files are, for a message telling the operator where to look. */ export function goalDir(root: string | undefined, goalId: string): string { return path.dirname(goalPaths(coreRoot(root), goalId).eventsFile); }