faberun 0.12.0 → 0.13.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.
@@ -0,0 +1,674 @@
1
+ /**
2
+ * Session metering over a provider transcript: what the controller reads from
3
+ * a still-growing stdout log while a turn runs -- provider requests (turns),
4
+ * tool calls, cache reads, completion -- and the bounded incremental parser
5
+ * behind it. Separate from the harness adapters because every adapter owns
6
+ * one stream shape and this module has to know all of them: it is the one
7
+ * place that says what "progress" and "a request" mean for claude, codex,
8
+ * agy and dsh alike. Its readers are the engine (stall detection, the live
9
+ * budget) and the usage ledger; no adapter imports it.
10
+ */
11
+ import { canonicalUsage, eventItem, extractJson } from "./protocol.mjs";
12
+ import { finite } from "../util.mjs";
13
+
14
+ /** @typedef {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean, requests: number, contextFirst: number|null, contextMax: number|null, contextLast: number|null, contextSum: number}} SessionTotals */
15
+ /**
16
+ * The per-request ledger one transcript proves: provider requests that
17
+ * reported usage, and the context each re-sent -- uncached input (cache
18
+ * writes included) plus cache reads -- for the first, largest and last of
19
+ * them, with their sum. Persisted on the invocation and in usage.jsonl.
20
+ * measured 2026-09-20 over 149 stored claude worker turns: median 49
21
+ * requests, context growing 1.9x within a turn, 3.3M tokens re-sent per
22
+ * turn; the two 600-request turns re-sent 190M and 167M.
23
+ * Null request fields mean the stream carried no usage per request (codex
24
+ * reports per turn, zcode emits one document), never that none were made.
25
+ * @typedef {{turns: number, toolCalls: number, requests: number|null, contextFirst: number|null, contextMax: number|null, contextLast: number|null, contextSum: number|null, completed: boolean}} SessionLedger
26
+ */
27
+
28
+ /** The harnesses whose streams carry usage per provider request; the others meter turns and tool calls only. */
29
+ const PER_REQUEST_USAGE = new Set(["claude", "dsh", "agy", "exec-jsonl", "replay"]);
30
+
31
+ /**
32
+ * Best-effort input-token meter over a still-growing transcript. The
33
+ * controller never owns the provider stream (the gate writes stdout straight
34
+ * to the log fd), so budget enforcement polls this instead. Lenient by
35
+ * design: unparsable or partial lines count as zero, and providers that only
36
+ * report usage at completion (exec-jsonl, replay) meter as 0 mid-run.
37
+ *
38
+ * @param {string} harness
39
+ * @param {string} stdout bounded transcript tail
40
+ * @returns {{inputTokens: number|null, cacheReadInputTokens: number|null}}
41
+ */
42
+ export function liveUsage(harness, stdout) {
43
+ if (harness === "exec-jsonl" || harness === "replay") {
44
+ // Completion-only harnesses: usage arrives in the terminal envelope, which
45
+ // the close path normalizes, never in a mid-run live observation.
46
+ return { inputTokens: null, cacheReadInputTokens: null };
47
+ }
48
+ const events = parsedEvents(stdout);
49
+ if (harness === "codex") {
50
+ // turn.completed usage is cumulative for the session; the last one wins.
51
+ // Codex counts input_tokens with their cached portion included, so the
52
+ // uncached total is what the ledger calls `inputTokens`.
53
+ const records = events
54
+ .filter((event) => event?.type === "turn.completed" && event.usage && typeof event.usage === "object")
55
+ .map((event) => {
56
+ const rawInput = finite(event.usage.input_tokens ?? event.usage.inputTokens);
57
+ if (rawInput === null) return null;
58
+ const cacheReadInputTokens = finite(
59
+ event.usage.cached_input_tokens ?? event.usage.cacheReadInputTokens ?? event.usage.cache_read_tokens,
60
+ ) ?? 0;
61
+ return { inputTokens: Math.max(0, rawInput - cacheReadInputTokens), cacheReadInputTokens };
62
+ })
63
+ .filter((record) => record !== null);
64
+ if (!records.length) return { inputTokens: null, cacheReadInputTokens: null };
65
+ return records.reduce((best, record) => (
66
+ record.inputTokens + record.cacheReadInputTokens > best.inputTokens + best.cacheReadInputTokens ? record : best
67
+ ));
68
+ }
69
+ if (harness === "claude") {
70
+ // The terminal result event carries the session total; before it lands,
71
+ // sum per-request assistant usage (each request re-reads full context).
72
+ // Claude's input_tokens already exclude cache reads.
73
+ const resultEvent = events.findLast((event) => event?.type === "result");
74
+ const resultUsage = resultEvent?.usage && typeof resultEvent.usage === "object"
75
+ ? finite(resultEvent.usage.input_tokens ?? resultEvent.usage.inputTokens)
76
+ : null;
77
+ if (resultUsage !== null) {
78
+ return {
79
+ inputTokens: resultUsage,
80
+ cacheReadInputTokens: finite(resultEvent.usage.cache_read_input_tokens ?? resultEvent.usage.cacheReadInputTokens) ?? null,
81
+ };
82
+ }
83
+ return {
84
+ inputTokens: events.reduce((sum, event) => {
85
+ if (event?.type !== "assistant") return sum;
86
+ const usage = event.message?.usage;
87
+ const value = usage && typeof usage === "object" ? finite(usage.input_tokens ?? usage.inputTokens) : null;
88
+ return sum + (value ?? 0);
89
+ }, 0) || null,
90
+ cacheReadInputTokens: null,
91
+ };
92
+ }
93
+ if (harness === "dsh") {
94
+ // The terminal event carries the harness's own session total; before it
95
+ // lands, sum the per-message usage the runner forwards.
96
+ const terminal = events.findLast((event) => event?.type === "dsh.completed" || event?.type === "dsh.failed");
97
+ const terminalUsage = terminal ? canonicalUsage(terminal.usage) : null;
98
+ if (terminalUsage && terminalUsage.inputTokens !== null) {
99
+ return { inputTokens: terminalUsage.inputTokens, cacheReadInputTokens: terminalUsage.cacheReadInputTokens };
100
+ }
101
+ return sumRequestUsage(events.filter((event) => event?.type === "dsh.message").map((event) => event.usage));
102
+ }
103
+ if (harness === "agy") {
104
+ const resultRecord = events.map((event) => agyResult(event)).findLast((result) => result !== null);
105
+ const resultUsage = resultRecord ? canonicalUsage(resultRecord.usage) : null;
106
+ if (resultUsage && resultUsage.inputTokens !== null) {
107
+ return { inputTokens: resultUsage.inputTokens, cacheReadInputTokens: resultUsage.cacheReadInputTokens };
108
+ }
109
+ return sumRequestUsage(events.map((event) => agyStep(event))
110
+ .filter((step) => step !== null && step.step_type === "agent_response" && step.state === "DONE")
111
+ .map((step) => step?.usage));
112
+ }
113
+ return { inputTokens: null, cacheReadInputTokens: null };
114
+ }
115
+
116
+ /**
117
+ * Sum per-request usage records into the live meter's shape; no record with
118
+ * a measured input leaves the meter null rather than a plausible zero.
119
+ *
120
+ * @param {unknown[]} usages
121
+ * @returns {{inputTokens: number|null, cacheReadInputTokens: number|null}}
122
+ */
123
+ function sumRequestUsage(usages) {
124
+ let inputTokens = null;
125
+ let cacheReadInputTokens = 0;
126
+ for (const raw of usages) {
127
+ const usage = canonicalUsage(raw);
128
+ if (usage.inputTokens === null) continue;
129
+ inputTokens = (inputTokens ?? 0) + usage.inputTokens;
130
+ cacheReadInputTokens += usage.cacheReadInputTokens ?? 0;
131
+ }
132
+ return inputTokens === null ? { inputTokens: null, cacheReadInputTokens: null } : { inputTokens, cacheReadInputTokens };
133
+ }
134
+
135
+ /**
136
+ * Budgeted live meter: one number, with cache reads weighted by the campaign
137
+ * policy so it is comparable with the persisted ledger. A provider that only
138
+ * reports usage at completion (exec-jsonl, replay) meters as 0 mid-run.
139
+ *
140
+ * @param {string} harness
141
+ * @param {string} stdout bounded transcript tail
142
+ * @param {number} [cacheReadWeight] cached-to-uncached rate ratio, default 1
143
+ * @returns {number}
144
+ */
145
+ export function liveInputTokens(harness, stdout, cacheReadWeight = 1) {
146
+ const usage = liveUsage(harness, stdout);
147
+ if (usage.inputTokens === null) return 0;
148
+ const weighted = usage.inputTokens + (usage.cacheReadInputTokens ?? 0) * cacheReadWeight;
149
+ return Math.round(weighted * 1000) / 1000;
150
+ }
151
+
152
+ /**
153
+ * Parse each JSONL line independently. A bounded transcript tail can start or
154
+ * end mid-line, so unparsable lines are skipped rather than failing the live
155
+ * observation.
156
+ *
157
+ * @param {string} stdout
158
+ */
159
+ function parsedEvents(stdout) {
160
+ return String(stdout).split(/\r?\n/u).flatMap((line) => {
161
+ try {
162
+ return [JSON.parse(line)];
163
+ } catch {
164
+ return [];
165
+ }
166
+ });
167
+ }
168
+
169
+ /** Codex item types whose completion proves one tool invocation. */
170
+ const CODEX_TOOL_ITEM_TYPES = new Set(["tool_call", "command_execution", "mcp_tool_call", "web_search", "file_change"]);
171
+
172
+ /**
173
+ * Session evidence from a bounded live transcript: completed turns, cache-read
174
+ * input, tool invocations, and whether the harness's terminal record has been
175
+ * folded. Each harness exposes only what its own events prove, and anything
176
+ * unparsable or unsupported meters as zero — a live observation never throws.
177
+ *
178
+ * @param {string} harness
179
+ * @param {string} stdout bounded transcript tail
180
+ * @returns {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}}
181
+ */
182
+ export function liveSessionMetrics(harness, stdout) {
183
+ const parser = new SessionMetricsParser(harness);
184
+ parser.push(String(stdout));
185
+ parser.flush();
186
+ return parser.metrics();
187
+ }
188
+
189
+ /** Retention bound for one streamed record: records at or below it parse whole. */
190
+ const SESSION_RECORD_MAX_BYTES = 64 * 1024;
191
+
192
+ /** Fragment evidence kept for a record that outgrew the retention bound. */
193
+ const SESSION_FRAGMENT_BYTES = SESSION_RECORD_MAX_BYTES / 2;
194
+
195
+ /** Claude-family content-block needle proving one tool invocation. */
196
+ const TOOL_USE_NEEDLE = Buffer.from('"type":"tool_use"', "utf8");
197
+
198
+ /** Cache-read evidence spellings across harness streams. */
199
+ const CACHE_READ_PATTERN = /"(?:cache_read_input_tokens|cached_input_tokens|cacheReadInputTokens)":(\d+)/gu;
200
+
201
+ /**
202
+ * Bounded incremental session-metrics parser: fold fixed-size chunks into
203
+ * running rotation totals without ever holding a buffer that scales with the
204
+ * unread transcript. Records within `SESSION_RECORD_MAX_BYTES` parse whole;
205
+ * a larger record keeps head and tail fragments plus streamed needle counts,
206
+ * so its turn and usage evidence still lands in the totals instead of being
207
+ * silently skipped.
208
+ */
209
+ export class SessionMetricsParser {
210
+ /**
211
+ * @param {string} harness
212
+ * @param {{turns?: number, cacheReadInputTokens?: number, toolCalls?: number, completed?: boolean}} [previous]
213
+ */
214
+ constructor(harness, previous = {}) {
215
+ this.harness = harness;
216
+ /** @type {SessionTotals} */
217
+ this.totals = {
218
+ turns: previous.turns ?? 0,
219
+ cacheReadInputTokens: previous.cacheReadInputTokens ?? 0,
220
+ toolCalls: previous.toolCalls ?? 0,
221
+ completed: previous.completed === true,
222
+ requests: 0,
223
+ contextFirst: null,
224
+ contextMax: null,
225
+ contextLast: null,
226
+ contextSum: 0,
227
+ };
228
+ /** @type {string|null} */
229
+ this.continuationId = null;
230
+ /** @type {string|null} Most recent folded item-completed type, for the codex completion rule. */
231
+ this.lastItemType = null;
232
+ /** @type {string|null} Text of the most recent folded agent message, for the codex completion rule. */
233
+ this.lastAgentText = null;
234
+ /** @type {Buffer} */
235
+ this.pending = Buffer.alloc(0);
236
+ /** @type {{head: Buffer, tail: Buffer, streamedToolUse: number, carry: Buffer}|null} */
237
+ this.oversized = null;
238
+ }
239
+
240
+ /**
241
+ * Fold every newline-terminated record in one chunk. A trailing partial
242
+ * record stays buffered (bounded) for the next chunk.
243
+ *
244
+ * @param {string|Buffer} chunk
245
+ */
246
+ push(chunk) {
247
+ let data = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
248
+ while (data.length > 0) {
249
+ const newline = data.indexOf(10);
250
+ if (newline < 0) {
251
+ this.absorb(data);
252
+ return;
253
+ }
254
+ this.absorb(data.subarray(0, newline));
255
+ this.completeRecord();
256
+ data = data.subarray(newline + 1);
257
+ }
258
+ }
259
+
260
+ /** Fold the buffered partial record as if a newline had ended it. */
261
+ flush() {
262
+ if (this.pending.length > 0 || this.oversized) this.completeRecord();
263
+ }
264
+
265
+ /** @returns {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}} */
266
+ metrics() {
267
+ return {
268
+ turns: this.totals.turns,
269
+ cacheReadInputTokens: this.totals.cacheReadInputTokens,
270
+ toolCalls: this.totals.toolCalls,
271
+ completed: this.totals.completed === true,
272
+ };
273
+ }
274
+
275
+ /**
276
+ * The per-request ledger folded so far. The claude, dsh, agy, exec-jsonl and
277
+ * replay streams carry usage per provider request; codex reports it cumulatively
278
+ * per turn and zcode emits one document per invocation, so for those the
279
+ * request count and the context sizes are unknown, not zero. Measured
280
+ * 2026-09-20 in the orchestration-arms campaign: four writer arms on codex
281
+ * and zcode were reported at zero requests until this distinction was made.
282
+ *
283
+ * @returns {SessionLedger}
284
+ */
285
+ session() {
286
+ const totals = this.totals;
287
+ const metered = PER_REQUEST_USAGE.has(this.harness);
288
+ return {
289
+ turns: totals.turns,
290
+ toolCalls: totals.toolCalls,
291
+ requests: metered ? totals.requests : null,
292
+ contextFirst: metered ? totals.contextFirst : null,
293
+ contextMax: metered ? totals.contextMax : null,
294
+ contextLast: metered ? totals.contextLast : null,
295
+ contextSum: metered && totals.requests > 0 ? totals.contextSum : null,
296
+ completed: totals.completed === true,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Retain one piece of a record still under assembly. Once the record
302
+ * outgrows the retention bound, only its head and a rolling tail are kept;
303
+ * the bytes leaving the tail are scanned for tool_use evidence instead of
304
+ * being buffered.
305
+ *
306
+ * @param {Buffer} piece
307
+ */
308
+ absorb(piece) {
309
+ if (this.oversized) {
310
+ const window = Buffer.concat([this.oversized.tail, piece]);
311
+ const keep = window.subarray(Math.max(0, window.length - SESSION_FRAGMENT_BYTES));
312
+ const dropped = window.subarray(0, window.length - keep.length);
313
+ if (this.harness === "claude") {
314
+ const counted = countWithCarry(dropped, this.oversized.carry, TOOL_USE_NEEDLE);
315
+ this.oversized.streamedToolUse += counted.hits;
316
+ this.oversized.carry = counted.carry;
317
+ }
318
+ this.oversized.tail = keep;
319
+ return;
320
+ }
321
+ if (this.pending.length + piece.length <= SESSION_RECORD_MAX_BYTES) {
322
+ // Copy: `piece` may be a view of a scratch buffer the caller reuses for
323
+ // the next read, which would corrupt a record buffered mid-chunk.
324
+ this.pending = this.pending.length > 0 ? Buffer.concat([this.pending, piece]) : Buffer.from(piece);
325
+ return;
326
+ }
327
+ const whole = Buffer.concat([this.pending, piece]);
328
+ this.pending = Buffer.alloc(0);
329
+ const head = whole.subarray(0, Math.min(SESSION_FRAGMENT_BYTES, whole.length));
330
+ const tail = whole.subarray(Math.max(0, whole.length - SESSION_FRAGMENT_BYTES));
331
+ /** @type {{head: Buffer, tail: Buffer, streamedToolUse: number, carry: Buffer}} */
332
+ const oversized = { head, tail, streamedToolUse: 0, carry: Buffer.alloc(0) };
333
+ if (this.harness === "claude") {
334
+ // Count from the record start up to where the rolling tail takes over,
335
+ // so a needle straddling any region boundary is counted exactly once.
336
+ const counted = countWithCarry(whole.subarray(0, Math.max(0, whole.length - tail.length)), oversized.carry, TOOL_USE_NEEDLE);
337
+ oversized.streamedToolUse = counted.hits;
338
+ oversized.carry = counted.carry;
339
+ }
340
+ this.oversized = oversized;
341
+ }
342
+
343
+ /** Fold the assembled record into the running totals. */
344
+ completeRecord() {
345
+ const oversized = this.oversized;
346
+ if (oversized) {
347
+ this.oversized = null;
348
+ /** @type {{head: string, tail: string, toolUse: number}} */
349
+ let fragments;
350
+ if (this.harness === "claude") {
351
+ const counted = countWithCarry(oversized.tail, oversized.carry, TOOL_USE_NEEDLE);
352
+ fragments = {
353
+ head: decodeFragment(oversized.head),
354
+ tail: decodeFragment(oversized.tail),
355
+ toolUse: oversized.streamedToolUse + counted.hits,
356
+ };
357
+ } else {
358
+ fragments = { head: decodeFragment(oversized.head), tail: decodeFragment(oversized.tail), toolUse: 0 };
359
+ }
360
+ foldFragmentRecord(this.harness, this.totals, fragments);
361
+ this.continuationId ??= fragmentContinuationId(this.harness, fragments);
362
+ return;
363
+ }
364
+ const line = this.pending.toString("utf8");
365
+ this.pending = Buffer.alloc(0);
366
+ let event;
367
+ try {
368
+ event = JSON.parse(line);
369
+ } catch {
370
+ return;
371
+ }
372
+ if (!event || typeof event !== "object" || Array.isArray(event)) return;
373
+ const record = /** @type {Record<string, unknown>} */ (event);
374
+ foldRecord(this.harness, this.totals, record);
375
+ this.continuationId ??= recordContinuationId(this.harness, record);
376
+ this.foldCompletionEvidence(record);
377
+ }
378
+
379
+ /**
380
+ * Fold the completion evidence one parsed record proves into the sticky
381
+ * totals. A harness is completed when its terminal record was folded; for
382
+ * codex that means a turn.completed that ends the turn with the
383
+ * result-carrying final agent message, so a live observation never treats a
384
+ * still-working or already-answered session ambiguously.
385
+ *
386
+ * @param {Record<string, unknown>} record
387
+ */
388
+ foldCompletionEvidence(record) {
389
+ const totals = this.totals;
390
+ if (this.harness === "codex") {
391
+ if (record.type === "turn.completed" && this.lastItemType === "agent_message"
392
+ && extractJson(this.lastAgentText) !== null) {
393
+ totals.completed = true;
394
+ }
395
+ } else if (this.harness === "claude" && record.type === "result") {
396
+ totals.completed = true;
397
+ } else if (this.harness === "dsh" && (record.type === "dsh.completed" || record.type === "dsh.failed")) {
398
+ totals.completed = true;
399
+ } else if (this.harness === "agy" && record.event === "result") {
400
+ totals.completed = true;
401
+ } else if (this.harness === "exec-jsonl" && record.type === "run.completed") {
402
+ totals.completed = true;
403
+ } else if (this.harness === "replay" && typeof record.status === "string") {
404
+ // The replay envelope is the terminal record: the bin emits exactly one
405
+ // envelope line per invocation, so folding one proves completion.
406
+ totals.completed = true;
407
+ }
408
+ const item = eventItem(record);
409
+ if (record.type === "item.completed" && item) {
410
+ this.lastItemType = String(item.type ?? "");
411
+ this.lastAgentText = this.lastItemType === "agent_message" && typeof item.text === "string"
412
+ ? item.text
413
+ : null;
414
+ } else {
415
+ this.lastItemType = null;
416
+ this.lastAgentText = null;
417
+ }
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Fold one parsed record into the running totals. Turn and tool counts are
423
+ * additive; cache-read is a running max for Codex (each turn.completed
424
+ * counter is already cumulative) and additive for claude-style streams until
425
+ * a terminal result event carries the authoritative session total.
426
+ *
427
+ * @param {string} harness
428
+ * @param {SessionTotals} totals
429
+ * @param {Record<string, unknown>} record
430
+ */
431
+ function foldRecord(harness, totals, record) {
432
+ if (harness === "codex") {
433
+ if (record.type === "turn.completed") {
434
+ totals.turns += 1;
435
+ totals.cacheReadInputTokens = Math.max(totals.cacheReadInputTokens, canonicalUsage(record.usage).cacheReadInputTokens ?? 0);
436
+ } else if (record.type === "item.completed" && CODEX_TOOL_ITEM_TYPES.has(String(eventItem(record)?.type))) {
437
+ totals.toolCalls += 1;
438
+ }
439
+ return;
440
+ }
441
+ if (harness === "claude") {
442
+ if (record.type === "assistant") {
443
+ const message = /** @type {Record<string, unknown>} */ (record.message ?? {});
444
+ totals.turns += 1;
445
+ totals.cacheReadInputTokens += canonicalUsage(message.usage).cacheReadInputTokens ?? 0;
446
+ foldRequestUsage(totals, canonicalUsage(message.usage));
447
+ totals.toolCalls += Array.isArray(message.content)
448
+ ? message.content.filter((/** @type {{type?: unknown}} */ block) => block?.type === "tool_use").length
449
+ : 0;
450
+ } else if (record.type === "result") {
451
+ const sessionTotal = canonicalUsage(record.usage).cacheReadInputTokens;
452
+ if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
453
+ }
454
+ return;
455
+ }
456
+ if (harness === "dsh") {
457
+ // The runner folds the sdk firehose to one `dsh.message` per assistant
458
+ // message (with that message's usage) and one `dsh.tool` per tool call.
459
+ // measured 2026-09-20: before this branch a dsh turn metered zero events,
460
+ // so once progress became event-only (668f6c1) the stall detector cut
461
+ // every dsh worker at the contract's stallTimeoutSec regardless of
462
+ // activity -- three kills on 2026-09-16 at 903s against a 900s limit.
463
+ if (record.type === "dsh.message") {
464
+ totals.turns += 1;
465
+ totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
466
+ foldRequestUsage(totals, canonicalUsage(record.usage));
467
+ } else if (record.type === "dsh.tool") {
468
+ totals.toolCalls += 1;
469
+ } else if (record.type === "dsh.completed" || record.type === "dsh.failed") {
470
+ const sessionTotal = canonicalUsage(record.usage).cacheReadInputTokens;
471
+ if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
472
+ }
473
+ return;
474
+ }
475
+ if (harness === "agy") {
476
+ // agy streams one `step_update` per step: an agent_response reaching DONE
477
+ // is one provider request carrying its own usage, a tool step reaching
478
+ // DONE or ERROR is one tool call, and the terminal `result` carries the
479
+ // session total (measured 2026-09-20 over 6 agy invocations: 140 DONE and
480
+ // 4 ERROR tool steps, 166 DONE agent responses).
481
+ const step = agyStep(record);
482
+ if (step) {
483
+ if (step.step_type === "agent_response" && step.state === "DONE") {
484
+ totals.turns += 1;
485
+ totals.cacheReadInputTokens += canonicalUsage(step.usage).cacheReadInputTokens ?? 0;
486
+ foldRequestUsage(totals, canonicalUsage(step.usage));
487
+ } else if (step.step_type === "tool" && (step.state === "DONE" || step.state === "ERROR")) {
488
+ totals.toolCalls += 1;
489
+ }
490
+ } else if (record.event === "result") {
491
+ const sessionTotal = canonicalUsage(agyResult(record)?.usage).cacheReadInputTokens;
492
+ if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
493
+ }
494
+ return;
495
+ }
496
+ if (harness === "exec-jsonl" && record.type === "run.completed") {
497
+ // The protocol carries no tool events; only a completed run proves a turn.
498
+ totals.turns += 1;
499
+ totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
500
+ foldRequestUsage(totals, canonicalUsage(record.usage));
501
+ }
502
+ if (harness === "replay" && typeof record.status === "string") {
503
+ // A replayed envelope is the whole invocation: one completed turn, no
504
+ // tool events, usage only in the terminal record.
505
+ totals.turns += 1;
506
+ totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
507
+ foldRequestUsage(totals, canonicalUsage(record.usage));
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Fold one provider request's usage into the per-request ledger. A request
513
+ * with no measured input is not one the ledger can count.
514
+ *
515
+ * @param {SessionTotals} totals
516
+ * @param {{inputTokens: number|null, cacheReadInputTokens: number|null}} usage
517
+ */
518
+ function foldRequestUsage(totals, usage) {
519
+ if (usage.inputTokens === null) return;
520
+ const context = usage.inputTokens + (usage.cacheReadInputTokens ?? 0);
521
+ totals.requests += 1;
522
+ totals.contextFirst ??= context;
523
+ totals.contextMax = Math.max(totals.contextMax ?? 0, context);
524
+ totals.contextLast = context;
525
+ totals.contextSum += context;
526
+ }
527
+
528
+ /** @param {Record<string, unknown>} record @returns {Record<string, any>|null} the agy step_update payload, if this record is one */
529
+ function agyStep(record) {
530
+ if (record.event !== "step_update") return null;
531
+ const step = record.step_update;
532
+ return step && typeof step === "object" && !Array.isArray(step) ? /** @type {Record<string, any>} */ (step) : null;
533
+ }
534
+
535
+ /** @param {Record<string, unknown>} record @returns {Record<string, any>|null} the agy terminal result payload, if this record is one */
536
+ function agyResult(record) {
537
+ if (record.event !== "result") return null;
538
+ const result = record.result;
539
+ return result && typeof result === "object" && !Array.isArray(result) ? /** @type {Record<string, any>} */ (result) : null;
540
+ }
541
+
542
+ /**
543
+ * Fold the head-plus-tail fragments of one record that outgrew the retention
544
+ * bound: the same evidence foldRecord extracts, read as fragments so an
545
+ * oversized record is never silently skipped.
546
+ *
547
+ * @param {string} harness
548
+ * @param {SessionTotals} totals
549
+ * @param {{head: string, tail: string, toolUse: number}} fragments
550
+ */
551
+ function foldFragmentRecord(harness, totals, fragments) {
552
+ const text = `${fragments.head}\n${fragments.tail}`;
553
+ if (harness === "claude") {
554
+ if (text.includes('"type":"assistant"')) {
555
+ totals.turns += 1;
556
+ totals.toolCalls += fragments.toolUse;
557
+ const cacheRead = lastCacheRead(text);
558
+ if (cacheRead !== null) totals.cacheReadInputTokens += cacheRead;
559
+ } else if (text.includes('"type":"result"')) {
560
+ const sessionTotal = lastCacheRead(text);
561
+ if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
562
+ totals.completed = true;
563
+ }
564
+ return;
565
+ }
566
+ if (harness === "codex") {
567
+ if (text.includes('"type":"turn.completed"')) {
568
+ totals.turns += 1;
569
+ const cacheRead = lastCacheRead(text);
570
+ totals.cacheReadInputTokens = Math.max(totals.cacheReadInputTokens, cacheRead ?? 0);
571
+ // Fragment approximation of the parsed-record completion rule: the
572
+ // turn ends with the final agent message when that message appears
573
+ // before the completed marker in the retained head and tail.
574
+ const agentAt = text.indexOf('"type":"agent_message"');
575
+ if (agentAt >= 0 && agentAt < text.indexOf('"type":"turn.completed"')) totals.completed = true;
576
+ } else if (text.includes('"type":"item.completed"') && [...CODEX_TOOL_ITEM_TYPES].some((type) => text.includes(`"type":"${type}"`))) {
577
+ totals.toolCalls += 1;
578
+ }
579
+ return;
580
+ }
581
+ if (harness === "exec-jsonl" && text.includes('"type":"run.completed"')) {
582
+ totals.turns += 1;
583
+ const cacheRead = lastCacheRead(text);
584
+ if (cacheRead !== null) totals.cacheReadInputTokens += cacheRead;
585
+ totals.completed = true;
586
+ }
587
+ }
588
+
589
+ /**
590
+ * The provider session identity one record proves.
591
+ *
592
+ * @param {string} harness
593
+ * @param {Record<string, unknown>} record
594
+ * @returns {string|null}
595
+ */
596
+ function recordContinuationId(harness, record) {
597
+ if (harness === "codex") {
598
+ return record.type === "thread.started" && typeof record.thread_id === "string" ? record.thread_id : null;
599
+ }
600
+ if (harness === "claude") {
601
+ return record.type === "result" && typeof record.session_id === "string" ? record.session_id : null;
602
+ }
603
+ if (harness === "exec-jsonl" && (record.type === "run.started" || record.type === "run.completed")) {
604
+ return typeof record.continuationId === "string" ? record.continuationId : null;
605
+ }
606
+ return null;
607
+ }
608
+
609
+ /**
610
+ * The provider session identity one record's fragments prove.
611
+ *
612
+ * @param {string} harness
613
+ * @param {{head: string, tail: string}} fragments
614
+ * @returns {string|null}
615
+ */
616
+ function fragmentContinuationId(harness, fragments) {
617
+ const text = `${fragments.head}\n${fragments.tail}`;
618
+ const pattern = harness === "codex"
619
+ ? /"thread_id":"([^"]+)"/u
620
+ : harness === "claude"
621
+ ? /"session_id":"([^"]+)"/u
622
+ : /"continuationId":"([^"]+)"/u;
623
+ const match = pattern.exec(text);
624
+ return match ? match[1] : null;
625
+ }
626
+
627
+ /**
628
+ * Count needle occurrences in one region, keeping the trailing bytes that
629
+ * could complete a needle in the next region so a straddling needle is
630
+ * counted exactly once.
631
+ *
632
+ * @param {Buffer} region
633
+ * @param {Buffer} carry
634
+ * @param {Buffer} needle
635
+ * @returns {{hits: number, carry: Buffer}}
636
+ */
637
+ function countWithCarry(region, carry, needle) {
638
+ const stream = carry.length > 0 ? Buffer.concat([carry, region]) : region;
639
+ return { hits: countNeedle(stream, needle), carry: stream.subarray(Math.max(0, stream.length - (needle.length - 1))) };
640
+ }
641
+
642
+ /**
643
+ * @param {Buffer} haystack
644
+ * @param {Buffer} needle
645
+ * @returns {number}
646
+ */
647
+ function countNeedle(haystack, needle) {
648
+ let hits = 0;
649
+ for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + needle.length)) hits += 1;
650
+ return hits;
651
+ }
652
+
653
+ /**
654
+ * Decode a retained fragment without splitting a UTF-8 sequence.
655
+ *
656
+ * @param {Buffer} fragment
657
+ * @returns {string}
658
+ */
659
+ function decodeFragment(fragment) {
660
+ let start = 0;
661
+ while (start < fragment.length && (fragment[start] & 0xc0) === 0x80) start += 1;
662
+ return fragment.toString("utf8", start);
663
+ }
664
+
665
+ /**
666
+ * The last cache-read number in a fragment text, or null.
667
+ *
668
+ * @param {string} text
669
+ * @returns {number|null}
670
+ */
671
+ function lastCacheRead(text) {
672
+ const matches = [...text.matchAll(CACHE_READ_PATTERN)];
673
+ return matches.length > 0 ? Number(matches.at(-1)?.[1]) : null;
674
+ }