faberun 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/src/campaign/campaign-brief.mjs +17 -6
  3. package/src/campaign/chain.mjs +124 -8
  4. package/src/campaign/ledger.mjs +19 -7
  5. package/src/campaign/metrics-command.mjs +68 -9
  6. package/src/campaign/metrics.mjs +75 -45
  7. package/src/campaign/record.mjs +8 -1
  8. package/src/campaign/reserve.mjs +340 -0
  9. package/src/campaign/spend.mjs +229 -0
  10. package/src/campaign/unpark.mjs +54 -1
  11. package/src/campaign/watch.mjs +314 -37
  12. package/src/cli/brand.mjs +2 -1
  13. package/src/cli.mjs +52 -2
  14. package/src/contract/judge-envelope.mjs +17 -2
  15. package/src/contract/scope-findings.mjs +3 -1
  16. package/src/contract/task-packet.mjs +40 -5
  17. package/src/contract/verification.mjs +67 -1
  18. package/src/engine/aux-admission.mjs +124 -0
  19. package/src/engine/capacity.mjs +78 -0
  20. package/src/engine/dispatch.mjs +116 -23
  21. package/src/engine/failure-cause.mjs +59 -0
  22. package/src/engine/lifecycle.mjs +3 -2
  23. package/src/engine/phase-session.mjs +56 -10
  24. package/src/engine/prompts.mjs +57 -2
  25. package/src/engine/reserve-admission.mjs +99 -0
  26. package/src/engine/resume.mjs +3 -1
  27. package/src/engine/review.mjs +21 -0
  28. package/src/engine/run-command.mjs +166 -11
  29. package/src/engine/scheduler.mjs +92 -88
  30. package/src/engine/settle.mjs +15 -8
  31. package/src/engine/settlement-overlap.mjs +143 -0
  32. package/src/engine/verify.mjs +131 -7
  33. package/src/harnesses/session-metrics.mjs +112 -20
  34. package/src/notify/index.mjs +86 -10
  35. package/src/plan/freeze.mjs +7 -2
  36. package/src/plan/human-step.mjs +36 -0
  37. package/src/plan/pipeline.mjs +53 -22
  38. package/src/plan/preflight.mjs +81 -5
  39. package/src/plan/progress.mjs +101 -1
  40. package/src/plan/repo-facts.mjs +403 -49
  41. package/src/plan/resolve.mjs +7 -0
  42. package/src/plan/revise-context.mjs +108 -0
  43. package/src/plan/rounds.mjs +180 -45
  44. package/src/plan/sizing.mjs +33 -1
  45. package/src/plan/template.mjs +134 -22
  46. package/src/repo/integrate.mjs +47 -5
  47. package/src/report/campaign-brief-estimate.mjs +47 -1
  48. package/src/report/campaign-brief.mjs +31 -2
  49. package/src/report/locale.mjs +20 -0
  50. package/src/report/message.mjs +13 -1
  51. package/src/report/metrics-report.mjs +38 -0
  52. package/src/report/progress.mjs +194 -10
  53. package/src/report/render.mjs +1 -1
  54. package/src/report/report-html.mjs +235 -0
  55. package/src/report/report-video.mjs +323 -0
  56. package/src/run/usage-windows.mjs +99 -4
  57. package/src/run/usage.mjs +69 -7
  58. package/src/web/api.mjs +179 -24
  59. package/src/web/app.mjs +27 -2
  60. package/src/web/server.mjs +32 -0
@@ -0,0 +1,323 @@
1
+ /**
2
+ * The run report's video boundary: the reading ladder's fourth rung, an
3
+ * animated explainer. It is programmatic, in the 3Blue1Brown sense — the scene
4
+ * is a pure function of the report's facts and a time, rendered as SVG frames,
5
+ * rasterised by librsvg and assembled with ffmpeg around an ElevenLabs
6
+ * narration — rather than manim, which no machine in the fleet has. The
7
+ * narration script (text) is the durable source; the mp3 and mp4 are derived
8
+ * copies, so a missing key, voice, rasteriser or ffmpeg is a named failure that
9
+ * leaves the script behind and changes nothing else.
10
+ *
11
+ * Every seam (the ElevenLabs HTTP call, the rasteriser, ffprobe and ffmpeg) is
12
+ * injectable, so a test proves the whole pipeline with fakes and never spends
13
+ * a real synthesis credit.
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { NOTIFY_LANG_ENV } from "../notify/index.mjs";
20
+ import { renderReportJson } from "./render.mjs";
21
+ import { escapeSvgText, layoutGraphNodes, readContractDependsOn, STATUS_COLOURS } from "./report-html.mjs";
22
+ import { compactCost } from "../util.mjs";
23
+ import { writeTextAtomic } from "../run/store.mjs";
24
+
25
+ /** The durable source and its derived copies, beside `STATUS.md` in the run directory. */
26
+ const NARRATION_FILE = "report.narration.txt";
27
+ const VIDEO_FILE = "report.mp4";
28
+
29
+ /** Video frame rate; the animation is gradual fades, so this is plenty smooth. */
30
+ const FPS = 15;
31
+ const WIDTH = 1280;
32
+ const HEIGHT = 720;
33
+
34
+ /** @typedef {import("./report-html.mjs").GraphNode} GraphNode */
35
+ /** @typedef {{run: string, summary: string, totals: {costUsd: number|null}, nodes: {id: string, status: string}[]}} VideoPayload */
36
+ /** @typedef {{title: string, nodes: GraphNode[], done: number, total: number, cost: string|null, attention: string[]}} Scene */
37
+ /** @typedef {{apiKey: string, voice: string, model: string}} ElevenLabsOptions */
38
+ /** @typedef {{t: number}} Frame */
39
+
40
+ /**
41
+ * A stable error surface for callers and CLI reporting.
42
+ */
43
+ export class ReportVideoError extends Error {
44
+ /** @param {string} code @param {string} message @param {{cause?: unknown}} [options] */
45
+ constructor(code, message, options = {}) {
46
+ super(message, options);
47
+ this.name = "ReportVideoError";
48
+ this.code = code;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The spoken script, deterministic from the report's own facts, in the
54
+ * operator's language. The title is the run id; the body names the counts, the
55
+ * attention nodes and the spend. No model is asked to phrase it — the same
56
+ * discipline as the watcher's progress line, so the same run yields the same
57
+ * narration.
58
+ *
59
+ * @param {VideoPayload} payload
60
+ * @param {NodeJS.ProcessEnv} env
61
+ * @returns {string}
62
+ */
63
+ export function buildNarration(payload, env = process.env) {
64
+ const lang = (env[NOTIFY_LANG_ENV] ?? "").trim() === "en" ? "en" : "pt";
65
+ const attention = payload.nodes.filter((node) => !QUIET.has(node.status)).map((node) => node.id);
66
+ const done = doneCount(payload);
67
+ const cost = payload.totals.costUsd === null ? null : String(payload.totals.costUsd);
68
+ if (lang === "en") {
69
+ const attentionLine = attention.length ? `${attention.length} need${attention.length === 1 ? "s" : ""} you: ${attention.join(", ")}.` : "Nothing needs you.";
70
+ return `Report for run ${payload.run}. ${done} of ${payload.nodes.length} nodes done. ${attentionLine} Total cost: ${cost ?? "unknown"} dollars.`;
71
+ }
72
+ const attentionLine = attention.length ? `${attention.length} ${attention.length === 1 ? "nó precisa" : "nós precisam"} de você: ${attention.join(", ")}.` : "Nada precisa de você.";
73
+ return `Relatório da run ${payload.run}. ${done} de ${payload.nodes.length} nós concluídos. ${attentionLine} Custo total: ${cost ?? "desconhecido"} dólares.`;
74
+ }
75
+
76
+ /** Node states that wait on a person; everything else is quiet. */
77
+ const QUIET = new Set(["pending", "running", "done", "no-op"]);
78
+
79
+ /** @param {VideoPayload} payload @returns {number} */
80
+ function doneCount(payload) {
81
+ return payload.nodes.filter((node) => node.status === "done" || node.status === "no-op").length;
82
+ }
83
+
84
+ /**
85
+ * One SVG frame of the scene at a normalised time `t` in [0, 1]. The work
86
+ * graph builds up node by node in topological order, each fading in with its
87
+ * status colour, while the title holds and the progress strip and spend
88
+ * surface near the end. Self-contained: inline colours, no external reference.
89
+ *
90
+ * @param {Scene} scene
91
+ * @param {Frame} frame
92
+ * @returns {string}
93
+ */
94
+ export function renderReportSceneSvg(scene, frame) {
95
+ const t = clamp(frame.t, 0, 1);
96
+ const titleOpacity = ramp(t, 0.02, 0.08);
97
+ const footerOpacity = ramp(t, 0.55, 0.08);
98
+
99
+ const laidOut = layoutGraphNodes(scene.nodes);
100
+ const revealOf = revealSchedule(laidOut);
101
+ const maxDepth = laidOut.reduce((max, node) => Math.max(max, node.depth), 0);
102
+ const maxRow = laidOut.reduce((max, node) => Math.max(max, node.row), 0);
103
+ const naturalW = GRAPH_MARGIN * 2 + (maxDepth + 1) * GRAPH_COL_WIDTH;
104
+ const naturalH = GRAPH_MARGIN * 2 + (maxRow + 1) * GRAPH_ROW_HEIGHT;
105
+ const regionW = WIDTH - 160;
106
+ const regionH = 400;
107
+ const scale = Math.min(regionW / naturalW, regionH / naturalH, 1);
108
+ const tx = (WIDTH - naturalW * scale) / 2;
109
+ const ty = 120 + (regionH - naturalH * scale) / 2;
110
+
111
+ const byId = new Map(laidOut.map((node) => [node.id, node]));
112
+ const edges = laidOut.flatMap((node) => (node.dependsOn ?? [])
113
+ .filter((/** @type {string} */ dep) => byId.has(dep))
114
+ .map((/** @type {string} */ dep) => {
115
+ const from = posOf(/** @type {{depth: number, row: number}} */ (byId.get(dep)));
116
+ const to = posOf(node);
117
+ const x1 = from.x + GRAPH_BOX_W;
118
+ const y1 = from.y + GRAPH_BOX_H / 2;
119
+ const x2 = to.x;
120
+ const y2 = to.y + GRAPH_BOX_H / 2;
121
+ const midX = (x1 + x2) / 2;
122
+ const opacity = ramp(t, revealOf.get(node.id) ?? 0, FADE);
123
+ return `<path d="M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}" fill="none" stroke="#D97B4F" stroke-width="2" opacity="${opacity}"/>`;
124
+ })).join("");
125
+ const boxes = laidOut.map((node, index) => {
126
+ const { x, y } = posOf(node);
127
+ const colour = STATUS_COLOURS[node.status] ?? "#1F1F1F";
128
+ const opacity = ramp(t, revealOf.get(node.id) ?? 0, FADE);
129
+ const clipId = `clip${index}`;
130
+ return `<g opacity="${opacity}"><rect x="${x}" y="${y}" width="${GRAPH_BOX_W}" height="${GRAPH_BOX_H}" rx="8" fill="#F4E9D8" stroke="${colour}" stroke-width="2"/><clipPath id="${clipId}"><rect x="${x}" y="${y}" width="${GRAPH_BOX_W}" height="${GRAPH_BOX_H}"/></clipPath><g clip-path="url(#${clipId})"><text x="${x + 10}" y="${y + 24}" font-family="ui-monospace, monospace" font-size="14" fill="#1F1F1F">${escapeSvgText(node.id)}</text><text x="${x + 10}" y="${y + 44}" font-family="ui-sans-serif, sans-serif" font-size="13" fill="${colour}">${escapeSvgText(node.status)}</text></g></g>`;
131
+ }).join("");
132
+
133
+ const barW = WIDTH - 160;
134
+ const fillW = Math.round((scene.done / Math.max(1, scene.total)) * barW);
135
+ const statusText = `${scene.done}/${scene.total} · ${scene.cost ?? "-"}`;
136
+ const attentionText = scene.attention.length ? `⚠ ${scene.attention.join(", ")}` : "";
137
+
138
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" width="${WIDTH}" height="${HEIGHT}">`
139
+ + `<rect width="${WIDTH}" height="${HEIGHT}" fill="#F4E9D8"/>`
140
+ + `<text x="80" y="86" font-family="ui-sans-serif, sans-serif" font-size="40" fill="#1F1F1F" opacity="${titleOpacity}">${escapeSvgText(scene.title)}</text>`
141
+ + `<g transform="translate(${tx} ${ty}) scale(${scale})">${edges}${boxes}</g>`
142
+ + `<g opacity="${footerOpacity}">`
143
+ + `<rect x="80" y="${HEIGHT - 120}" width="${barW}" height="20" rx="10" fill="#1F1F1F" opacity="0.12"/>`
144
+ + `<rect x="80" y="${HEIGHT - 120}" width="${fillW}" height="20" rx="10" fill="#B5522A"/>`
145
+ + `<text x="80" y="${HEIGHT - 140}" font-family="ui-sans-serif, sans-serif" font-size="26" fill="#1F1F1F">${escapeSvgText(statusText)}</text>`
146
+ + (attentionText ? `<text x="80" y="${HEIGHT - 70}" font-family="ui-sans-serif, sans-serif" font-size="22" fill="#B5522A">${escapeSvgText(attentionText)}</text>` : "")
147
+ + `</g>`
148
+ + `</svg>`;
149
+ }
150
+
151
+ const GRAPH_COL_WIDTH = 260;
152
+ const GRAPH_ROW_HEIGHT = 84;
153
+ const GRAPH_BOX_W = 220;
154
+ const GRAPH_BOX_H = 56;
155
+ const GRAPH_MARGIN = 16;
156
+ /** How long one node takes to fade in, in normalised time. */
157
+ const FADE = 0.06;
158
+
159
+ /**
160
+ * The reveal instant of each node, in topological order (depth first, then
161
+ * row): the first fades in at 0.12 and the last at 0.80, leaving a tail for
162
+ * the counters.
163
+ *
164
+ * @param {(GraphNode & {depth: number, row: number})[]} laidOut
165
+ * @returns {Map<string, number>}
166
+ */
167
+ function revealSchedule(laidOut) {
168
+ const order = [...laidOut].sort((a, b) => a.depth - b.depth || a.row - b.row);
169
+ return new Map(order.map((node, index) => [node.id, 0.12 + 0.68 * (index / Math.max(1, laidOut.length - 1))]));
170
+ }
171
+
172
+ /** @param {{depth: number, row: number}} node @returns {{x: number, y: number}} */
173
+ function posOf(node) {
174
+ return { x: GRAPH_MARGIN + node.depth * GRAPH_COL_WIDTH, y: GRAPH_MARGIN + node.row * GRAPH_ROW_HEIGHT };
175
+ }
176
+
177
+ /** @param {number} t @param {number} start @param {number} dur @returns {number} */
178
+ function ramp(t, start, dur) {
179
+ return clamp((t - start) / dur, 0, 1);
180
+ }
181
+
182
+ /** @param {number} v @param {number} lo @param {number} hi @returns {number} */
183
+ function clamp(v, lo, hi) {
184
+ return Math.max(lo, Math.min(hi, v));
185
+ }
186
+
187
+ /**
188
+ * Synthesize the narration as MP3 bytes through ElevenLabs. The key is read
189
+ * from the environment, never stored; the request carries it only as a header.
190
+ *
191
+ * @param {string} text
192
+ * @param {ElevenLabsOptions} options
193
+ * @param {{fetch?: typeof fetch}} [io]
194
+ * @returns {Promise<Buffer>}
195
+ */
196
+ export async function synthesizeSpeech(text, options, io = {}) {
197
+ const fetchImpl = io.fetch ?? fetch;
198
+ const url = `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(options.voice)}`;
199
+ let response;
200
+ try {
201
+ response = await fetchImpl(url, {
202
+ method: "POST",
203
+ headers: { "xi-api-key": options.apiKey, "Content-Type": "application/json" },
204
+ body: JSON.stringify({
205
+ text,
206
+ model_id: options.model,
207
+ voice_settings: { stability: 0.5, similarity_boost: 0.75 },
208
+ }),
209
+ });
210
+ } catch (error) {
211
+ throw new ReportVideoError("VIDEO_TTS_FAILED", `ElevenLabs request failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
212
+ }
213
+ if (!response.ok) {
214
+ throw new ReportVideoError("VIDEO_TTS_FAILED", `ElevenLabs returned HTTP ${response.status}`);
215
+ }
216
+ const bytes = Buffer.from(await response.arrayBuffer());
217
+ if (bytes.length === 0) throw new ReportVideoError("VIDEO_TTS_FAILED", "ElevenLabs returned no audio");
218
+ return bytes;
219
+ }
220
+
221
+ /** @param {string} command @param {string[]} args @returns {import("node:child_process").SpawnSyncReturns<string>} */
222
+ function spawnRun(command, args) {
223
+ return spawnSync(command, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
224
+ }
225
+
226
+ /**
227
+ * The duration of the narration in milliseconds, read by ffprobe so the frames
228
+ * fill exactly the audio.
229
+ *
230
+ * @param {string} audioPath
231
+ * @param {(command: string, args: string[]) => import("node:child_process").SpawnSyncReturns<string>} run
232
+ * @returns {number}
233
+ */
234
+ function probeDurationMs(audioPath, run) {
235
+ const result = run("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", audioPath]);
236
+ const seconds = Number((result.stdout ?? "").trim());
237
+ if (result.status !== 0 || !Number.isFinite(seconds) || seconds <= 0) {
238
+ throw new ReportVideoError("VIDEO_PROBE_FAILED", "could not measure the narration duration");
239
+ }
240
+ return Math.round(seconds * 1000);
241
+ }
242
+
243
+ /**
244
+ * The full pipeline: narration script (durable) → ElevenLabs MP3 → SVG frames
245
+ * → PNG frames via rsvg-convert → an MP4 via ffmpeg. The script is written
246
+ * first and survives every later failure; a missing key, voice or tool is a
247
+ * named code on the result, with any partial video removed, rather than a
248
+ * throw — the same contract as `generateReportHtml`.
249
+ *
250
+ * @param {string} runDir
251
+ * @param {{apiKey?: string, voice?: string, model?: string, env?: NodeJS.ProcessEnv, synthesize?: typeof synthesizeSpeech, run?: (command: string, args: string[]) => import("node:child_process").SpawnSyncReturns<string>}} options
252
+ * @returns {Promise<{narrationPath: string, videoPath: string|null, code: string|null}>}
253
+ */
254
+ export async function generateReportVideo(runDir, options) {
255
+ const env = options.env ?? process.env;
256
+ const run = options.run ?? spawnRun;
257
+ const synthesize = options.synthesize ?? synthesizeSpeech;
258
+
259
+ const payload = /** @type {VideoPayload} */ (JSON.parse(renderReportJson(runDir)));
260
+ const scene = buildScene(runDir, payload);
261
+ const narration = buildNarration(payload, env);
262
+ const narrationPath = join(runDir, NARRATION_FILE);
263
+ const videoPath = join(runDir, VIDEO_FILE);
264
+ writeTextAtomic(narrationPath, narration);
265
+
266
+ try {
267
+ const apiKey = options.apiKey || env.ELEVENLABS_API_KEY || "";
268
+ const voice = options.voice || env.ELEVENLABS_VOICE || "";
269
+ if (!apiKey) throw new ReportVideoError("VIDEO_KEY_MISSING", "ELEVENLABS_API_KEY is not set");
270
+ if (!voice) throw new ReportVideoError("VIDEO_VOICE_MISSING", "ELEVENLABS_VOICE is not set");
271
+
272
+ const audio = await synthesize(narration, { apiKey, voice, model: options.model ?? "eleven_multilingual_v2" });
273
+ const stage = mkdtempSync(join(tmpdir(), "report-video-"));
274
+ try {
275
+ const audioPath = join(stage, "narration.mp3");
276
+ writeFileSync(audioPath, audio);
277
+ const durationMs = probeDurationMs(audioPath, run);
278
+ const frameCount = Math.max(2, Math.round((durationMs / 1000) * FPS));
279
+ const framesDir = join(stage, "frames");
280
+ mkdirSync(framesDir, { recursive: true });
281
+ for (let i = 0; i < frameCount; i += 1) {
282
+ const svg = renderReportSceneSvg(scene, { t: frameCount === 1 ? 1 : i / (frameCount - 1) });
283
+ const base = join(framesDir, `frame-${String(i).padStart(4, "0")}`);
284
+ writeFileSync(`${base}.svg`, svg, "utf8");
285
+ const raster = run("rsvg-convert", ["-w", String(WIDTH), "-h", String(HEIGHT), `${base}.svg`, "-o", `${base}.png`]);
286
+ if (raster.status !== 0) throw new ReportVideoError("VIDEO_RASTER_FAILED", `rsvg-convert failed: ${raster.stderr || raster.stdout}`);
287
+ }
288
+ const assemble = run("ffmpeg", [
289
+ "-y", "-framerate", String(FPS), "-i", join(framesDir, "frame-%04d.png"),
290
+ "-i", audioPath, "-shortest", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", videoPath,
291
+ ]);
292
+ if (assemble.status !== 0) throw new ReportVideoError("VIDEO_ASSEMBLE_FAILED", `ffmpeg failed: ${assemble.stderr || assemble.stdout}`);
293
+ return { narrationPath, videoPath, code: null };
294
+ } finally {
295
+ rmSync(stage, { recursive: true, force: true });
296
+ }
297
+ } catch (error) {
298
+ rmSync(videoPath, { force: true });
299
+ const code = error instanceof ReportVideoError ? error.code : "REPORT_VIDEO_FAILED";
300
+ return { narrationPath, videoPath: null, code };
301
+ }
302
+ }
303
+
304
+ /**
305
+ * The scene the frames render from: the report's graph, its counts, its spend
306
+ * and the nodes that wait on a person.
307
+ *
308
+ * @param {string} runDir
309
+ * @param {VideoPayload} payload
310
+ * @returns {Scene}
311
+ */
312
+ function buildScene(runDir, payload) {
313
+ const dependsOn = readContractDependsOn(runDir);
314
+ const nodes = payload.nodes.map((node) => ({ id: node.id, status: node.status, dependsOn: dependsOn.get(node.id) ?? [] }));
315
+ return {
316
+ title: payload.run,
317
+ nodes,
318
+ done: doneCount(payload),
319
+ total: payload.nodes.length,
320
+ cost: typeof payload.totals.costUsd === "number" ? compactCost(payload.totals.costUsd) : null,
321
+ attention: payload.nodes.filter((node) => !QUIET.has(node.status)).map((node) => node.id),
322
+ };
323
+ }
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * The machine-wide record of how much of each account's provider-reported
3
- * usage window is spent. It is separate from the availability store because
4
- * it answers a different question: availability says whether a provider
5
- * answered, this says how close the account is to refusing. Measured
3
+ * usage window is spent, and how much usage the account has been asked to
4
+ * spend between window reads. It is separate from the availability store
5
+ * because it answers a different question: availability says whether a
6
+ * provider answered, this says how close the account is to refusing. Measured
6
7
  * 2026-09-24: the Codex week went from 86% to 94% in one afternoon of judge
7
8
  * readings, and nothing read it until the owner looked.
8
9
  */
@@ -11,9 +12,11 @@ import { dirname } from "node:path";
11
12
  import { usageWindowsPath } from "./paths.mjs";
12
13
  import { writeJsonAtomic } from "./store.mjs";
13
14
  import { codexHome, codexUsageWindows } from "../harnesses/codex/usage-window.mjs";
15
+ import { addMeasured } from "../harnesses/session-metrics.mjs";
14
16
 
15
17
  /** @typedef {import("../harnesses/codex/usage-window.mjs").UsageWindow} UsageWindow */
16
- /** @typedef {{windows: UsageWindow[], observedAt: string}} AccountWindows */
18
+ /** @typedef {import("../harnesses/session-metrics.mjs").UsageCounters} UsageCounters */
19
+ /** @typedef {{windows: UsageWindow[], observedAt: string, totals?: UsageCounters}} AccountWindows */
17
20
 
18
21
  /**
19
22
  * @param {string} account the account key, e.g. `codex:<CODEX_HOME>`
@@ -80,3 +83,95 @@ export function recordInvocationWindows(runtime, continuationId, options = {}) {
80
83
  if (!account || !continuationId) return;
81
84
  recordUsageWindows(account, codexUsageWindows(continuationId, options), options.now);
82
85
  }
86
+
87
+ /** @returns {UsageCounters} */
88
+ function emptyCounters() {
89
+ return { bytes: null, inputTokens: null, cacheReadInputTokens: null, cacheWriteInputTokens: null, outputTokens: null };
90
+ }
91
+
92
+ /**
93
+ * Two counter sets into one: a measured value wins over an unmeasured one,
94
+ * and a counter neither side measured stays null. Every field is named -- the
95
+ * totals are never indexed by a dynamic counter name.
96
+ *
97
+ * @param {UsageCounters|undefined} left
98
+ * @param {UsageCounters} right
99
+ * @returns {UsageCounters}
100
+ */
101
+ function addCounters(left, right) {
102
+ return {
103
+ bytes: addMeasured(left?.bytes ?? null, right.bytes),
104
+ inputTokens: addMeasured(left?.inputTokens ?? null, right.inputTokens),
105
+ cacheReadInputTokens: addMeasured(left?.cacheReadInputTokens ?? null, right.cacheReadInputTokens),
106
+ cacheWriteInputTokens: addMeasured(left?.cacheWriteInputTokens ?? null, right.cacheWriteInputTokens),
107
+ outputTokens: addMeasured(left?.outputTokens ?? null, right.outputTokens),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * The strict rollup of per-node counter sets: a counter is summed only when
113
+ * every entry measured it, so a rollup over partly-measured nodes is null
114
+ * rather than a partial sum -- the same refusal the cost evidence makes for
115
+ * an unpriced invocation.
116
+ *
117
+ * @param {(UsageCounters|null|undefined)[]} counters
118
+ * @returns {UsageCounters}
119
+ */
120
+ export function sumCounters(counters) {
121
+ /** @param {number|null} left @param {number|null} right @returns {number|null} */
122
+ const strict = (left, right) => left === null || right === null ? null : left + right;
123
+ const [first] = counters;
124
+ /** @type {UsageCounters} */
125
+ let totals = {
126
+ bytes: first?.bytes ?? null,
127
+ inputTokens: first?.inputTokens ?? null,
128
+ cacheReadInputTokens: first?.cacheReadInputTokens ?? null,
129
+ cacheWriteInputTokens: first?.cacheWriteInputTokens ?? null,
130
+ outputTokens: first?.outputTokens ?? null,
131
+ };
132
+ for (const entry of counters.slice(1)) {
133
+ totals = {
134
+ bytes: strict(totals.bytes, entry?.bytes ?? null),
135
+ inputTokens: strict(totals.inputTokens, entry?.inputTokens ?? null),
136
+ cacheReadInputTokens: strict(totals.cacheReadInputTokens, entry?.cacheReadInputTokens ?? null),
137
+ cacheWriteInputTokens: strict(totals.cacheWriteInputTokens, entry?.cacheWriteInputTokens ?? null),
138
+ outputTokens: strict(totals.outputTokens, entry?.outputTokens ?? null),
139
+ };
140
+ }
141
+ return totals;
142
+ }
143
+
144
+ /**
145
+ * Roll one finished invocation's measured counters onto its account's running
146
+ * totals, the machine-wide record of what each account has been asked to
147
+ * spend between window reads. A counter the invocation did not measure leaves
148
+ * the running total alone, and a harness that reports no account adds nothing.
149
+ *
150
+ * @param {{harness: string}} runtime
151
+ * @param {UsageCounters} counters
152
+ * @param {{env?: Record<string, string|undefined>, now?: number}} [options]
153
+ * @returns {void}
154
+ */
155
+ export function addAccountUsage(runtime, counters, options = {}) {
156
+ const account = usageAccountOf(runtime, options.env);
157
+ if (!account) return;
158
+ const store = loadStore();
159
+ const existing = store.accounts[account];
160
+ store.accounts[account] = existing && Array.isArray(existing.windows)
161
+ ? { windows: existing.windows, observedAt: existing.observedAt, totals: addCounters(existing.totals, counters) }
162
+ : { windows: [], observedAt: new Date(options.now ?? Date.now()).toISOString(), totals: addCounters(emptyCounters(), counters) };
163
+ const path = usageWindowsPath();
164
+ mkdirSync(dirname(path), { recursive: true });
165
+ writeJsonAtomic(path, store);
166
+ }
167
+
168
+ /**
169
+ * The account's rolled-up invocation counters, or null before anything was
170
+ * rolled onto it.
171
+ *
172
+ * @param {string} account
173
+ * @returns {UsageCounters|null}
174
+ */
175
+ export function readAccountUsage(account) {
176
+ return loadStore().accounts[account]?.totals ?? null;
177
+ }
package/src/run/usage.mjs CHANGED
@@ -9,14 +9,14 @@
9
9
  import { appendJsonl, writeJsonAtomic } from "./store.mjs";
10
10
  import { basename, join } from "node:path";
11
11
  import { errorMessage, finite, stableJson } from "../util.mjs";
12
- import { existsSync, readdirSync, readFileSync } from "node:fs";
13
- import { liveUsage } from "../harnesses/session-metrics.mjs";
12
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
+ import { liveUsage, liveUsageCounters } from "../harnesses/session-metrics.mjs";
14
14
 
15
15
  import { priceUsage } from "../engine/process.mjs";
16
16
  import { readBoundedTail, sessionLedger } from "../engine/transcript.mjs";
17
17
  import { writeNode } from "../engine/state.mjs";
18
18
  import { harnessCapabilities, normalizeProviderResult } from "../harnesses/index.mjs";
19
- import { recordInvocationWindows } from "./usage-windows.mjs";
19
+ import { addAccountUsage, recordInvocationWindows, sumCounters } from "./usage-windows.mjs";
20
20
 
21
21
  // `priceUsage` is defined beside `invocationResult`, the second source point,
22
22
  // and re-exported here so the ledger's public surface is unchanged. This module
@@ -32,6 +32,7 @@ export { priceUsage };
32
32
  /** @typedef {ProviderEnvelope & {costProvenance?: "priced"}} PricedEnvelope */
33
33
  /** @typedef {{kind: "adopted"|"rejudge"|"restart"|"reconciled"|"exhausted"|"stalled", phase?: "worker"|"judge", result?: unknown, usage?: Usage, costUsd?: number|null, costProvenance?: "priced", exhaustedUntil?: string|null, error?: {code: string, message: string}|null, invocationId?: string, reason?: string}} RecoveryOutcome */
34
34
  /** @typedef {import("../contract/index.mjs").Usage} Usage */
35
+ /** @typedef {import("../harnesses/session-metrics.mjs").UsageCounters} UsageCounters */
35
36
  /** @typedef {"no-usage-stream"|"model-not-priced"|"provider-reported-nothing"|"invocation-killed"} UnknownCostReason */
36
37
 
37
38
  /** The only reasons a newly-written unknown cost may carry. `legacy` is read-only compatibility for older ledgers. */
@@ -91,9 +92,11 @@ export function recordInvocationUsage(job, options = {}) {
91
92
  // the single priced object every later copy spreads from.
92
93
  try {
93
94
  recordInvocationWindows(job.runtime, envelope.continuationId);
95
+ addAccountUsage(job.runtime, invocationCounters(job.invocation, boundedStdout));
94
96
  } catch {
95
- // The window record is telemetry about the account, never a reason to fail
96
- // the invocation it rode on: a missing or unreadable session file means no reading.
97
+ // The window record and the account rollup are telemetry about the
98
+ // account, never a reason to fail the invocation it rode on: a missing or
99
+ // unreadable session file means no reading.
97
100
  }
98
101
  const priced = priceUsage(job.runtime, envelope.usage, envelope.costUsd);
99
102
  envelope = { ...envelope, costUsd: priced.costUsd, costProvenance: priced.costProvenance };
@@ -108,6 +111,36 @@ export function recordInvocationUsage(job, options = {}) {
108
111
  return envelope;
109
112
  }
110
113
  const USAGE_LOG_NAME = "usage.jsonl";
114
+ /**
115
+ * The five measured counters one invocation proves: the recorded usage, with
116
+ * cache writes and any unrecorded counter mapped from the harness-reported
117
+ * session metrics on the transcript tail, plus the prompt's byte size. A
118
+ * counter nothing reports stays null, never a plausible zero.
119
+ * @param {Invocation|undefined|null} invocation
120
+ * @param {string} stdout bounded transcript tail, empty when none survives
121
+ * @returns {UsageCounters}
122
+ */
123
+ function invocationCounters(invocation, stdout) {
124
+ const usage = /** @type {Record<string, unknown>} */ (invocation?.usage ?? {});
125
+ const observed = stdout ? liveUsageCounters(String(invocation?.harness ?? ""), stdout) : null;
126
+ return {
127
+ bytes: promptBytes(invocation?.promptPath),
128
+ inputTokens: finite(usage.inputTokens) ?? observed?.inputTokens ?? null,
129
+ cacheReadInputTokens: finite(usage.cacheReadInputTokens) ?? observed?.cacheReadInputTokens ?? null,
130
+ cacheWriteInputTokens: observed?.cacheWriteInputTokens ?? null,
131
+ outputTokens: finite(usage.outputTokens) ?? observed?.outputTokens ?? null,
132
+ };
133
+ }
134
+ /** @param {unknown} promptPath @returns {number|null} the dispatched prompt's byte size, when its file survives */
135
+ function promptBytes(promptPath) {
136
+ if (typeof promptPath !== "string" || !promptPath) return null;
137
+ try {
138
+ return statSync(promptPath).size;
139
+ } catch {
140
+ // A prompt file the run archived or cleaned away has no measured size.
141
+ return null;
142
+ }
143
+ }
111
144
  /** @param {Usage|undefined} usage @returns {boolean} */
112
145
  function hasMeasuredUsage(usage) {
113
146
  return Boolean(usage && [usage.inputTokens, usage.outputTokens, usage.cacheReadInputTokens]
@@ -230,6 +263,15 @@ export function usageRecordIds(runDir) {
230
263
  export function appendUsageRecord(runDir, invocation, options = {}) {
231
264
  if (!invocation?.id || usageRecordIds(runDir).has(invocation.id)) return;
232
265
  const usage = /** @type {Usage} */ (invocation.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null });
266
+ /** @type {string} */
267
+ let transcript = "";
268
+ try {
269
+ transcript = invocation.stdoutPath ? readBoundedTail(invocation.stdoutPath) : "";
270
+ } catch {
271
+ // A transcript the run already capped or cleaned away carries no session
272
+ // metrics; the counters it would map stay unrecorded.
273
+ }
274
+ const counters = invocationCounters(invocation, transcript);
233
275
  const costProvenance = invocation.costProvenance ?? (typeof invocation.costUsd === "number" ? "provider" : "unknown");
234
276
  const unknownReason = costProvenance === "unknown"
235
277
  ? unknownCostReason(invocation, usage, options)
@@ -245,6 +287,12 @@ export function appendUsageRecord(runDir, invocation, options = {}) {
245
287
  inputTokens: typeof usage.inputTokens === "number" ? usage.inputTokens : null,
246
288
  cacheReadInputTokens: typeof usage.cacheReadInputTokens === "number" ? usage.cacheReadInputTokens : null,
247
289
  outputTokens: typeof usage.outputTokens === "number" ? usage.outputTokens : null,
290
+ // The R1 counters ride the same record as the priced cost, per role and
291
+ // attempt: bytes from the dispatched prompt file, cache writes from the
292
+ // transcript's session metrics. An unmeasured counter is omitted, so a
293
+ // pre-counter record keeps its exact old shape.
294
+ ...(counters.bytes === null ? {} : { bytes: counters.bytes }),
295
+ ...(counters.cacheWriteInputTokens === null ? {} : { cacheWriteInputTokens: counters.cacheWriteInputTokens }),
248
296
  costUsd: typeof invocation.costUsd === "number" ? invocation.costUsd : null,
249
297
  // A persisted `priced` marker wins; otherwise the pre-Phase-4 rule applies
250
298
  // unchanged: a reported number is `provider`, absence is `unknown`.
@@ -482,7 +530,7 @@ function listNodeSnapshotFiles(runDir) {
482
530
  // without recorded evidence stays null so the estimate can say
483
531
  // `insufficient data` instead of inventing a zero.
484
532
 
485
- /** @typedef {{runtimeId: string|null, model: string|null, costUsd: number|null, costProvenance: string|null}} CompletedExecutionRole */
533
+ /** @typedef {{runtimeId: string|null, model: string|null, costUsd: number|null, costProvenance: string|null, usage: UsageCounters|null}} CompletedExecutionRole */
486
534
  /**
487
535
  * One completed execution node, with the worker (and optional judge) role
488
536
  * evidence the estimate compares against a planned assignment. `costUsd` is
@@ -692,7 +740,21 @@ function roleEvidence(role, usageRecords, invocations, contract, contractNode) {
692
740
  const model = typeof last.model === "string" && last.model ? last.model : declaredModel;
693
741
  const priced = source.every((record) => typeof record.costUsd === "number" && Number.isFinite(record.costUsd) && record.costProvenance === "priced");
694
742
  const costUsd = priced ? source.reduce((total, record) => total + /** @type {number} */ (record.costUsd), 0) : null;
695
- return { runtimeId, model, costUsd, costProvenance: costUsd === null ? null : "priced" };
743
+ return {
744
+ runtimeId,
745
+ model,
746
+ costUsd,
747
+ costProvenance: costUsd === null ? null : "priced",
748
+ // The role's R1 counters, strict like the cost: summed only when every
749
+ // invocation in the role measured each counter, else null.
750
+ usage: sumCounters(source.map((record) => ({
751
+ bytes: finite(record.bytes),
752
+ inputTokens: finite(record.inputTokens),
753
+ cacheReadInputTokens: finite(record.cacheReadInputTokens),
754
+ cacheWriteInputTokens: finite(record.cacheWriteInputTokens),
755
+ outputTokens: finite(record.outputTokens),
756
+ }))),
757
+ };
696
758
  }
697
759
 
698
760
  /**