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.
- package/package.json +1 -1
- package/skills/faberun/references/contract.md +10 -10
- package/src/contract/assert.mjs +5 -1
- package/src/contract/index.mjs +43 -7
- package/src/contract/runtime.mjs +4 -1
- package/src/contract/snapshot.mjs +25 -1
- package/src/contract/task-packet.mjs +5 -1
- package/src/engine/backoff.mjs +2 -1
- package/src/engine/capacity.mjs +74 -0
- package/src/engine/dispatch.mjs +12 -186
- package/src/engine/lifecycle.mjs +7 -2
- package/src/engine/phase-session.mjs +217 -0
- package/src/engine/process.mjs +26 -108
- package/src/engine/scheduler.mjs +15 -3
- package/src/engine/settle.mjs +11 -2
- package/src/engine/transcript.mjs +145 -0
- package/src/harnesses/claude/index.mjs +5 -0
- package/src/harnesses/dsh/runner.mjs +38 -3
- package/src/harnesses/exec-jsonl/index.mjs +0 -499
- package/src/harnesses/index.mjs +55 -8
- package/src/harnesses/protocol.mjs +22 -2
- package/src/harnesses/session-metrics.mjs +674 -0
- package/src/host/tool-policy-decisions.mjs +78 -21
- package/src/host/tool-policy-hook.mjs +12 -3
- package/src/plan/freeze.mjs +24 -6
- package/src/plan/pipeline.mjs +232 -67
- package/src/plan/sizing.mjs +0 -0
- package/src/plan/template.mjs +110 -8
- package/src/run/operations.mjs +1 -1
- package/src/run/usage.mjs +9 -3
- package/src/util.mjs +0 -0
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Transcript, the only thing this process writes to stdout:
|
|
15
15
|
* {"type":"dsh.started","sessionId":string}
|
|
16
|
-
* {"type":"dsh.
|
|
16
|
+
* {"type":"dsh.tool","name":string,"target":string|null} one per tool call
|
|
17
|
+
* {"type":"dsh.message","text":string,"usage"?:Usage} one per non-empty assistant message,
|
|
18
|
+
* with that message's own usage when the harness sent it
|
|
17
19
|
* {"type":"dsh.completed","sessionId":string,"result":string,"usage":Usage}
|
|
18
20
|
* {"type":"dsh.failed","sessionId":string,"kind":string,"error":{...},"usage":Usage}
|
|
19
21
|
* where Usage is `{inputTokens, outputTokens, cacheReadInputTokens}` with
|
|
@@ -99,6 +101,27 @@ function addUsage(totals, usage) {
|
|
|
99
101
|
return observed;
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The file, path, command or pattern a tool call names, bounded: the
|
|
106
|
+
* controller's live meter counts the call and the run page shows the target.
|
|
107
|
+
*
|
|
108
|
+
* @param {unknown} rawArguments the harness's JSON-encoded tool arguments
|
|
109
|
+
* @returns {string|null}
|
|
110
|
+
*/
|
|
111
|
+
function toolTarget(rawArguments) {
|
|
112
|
+
if (typeof rawArguments !== "string" || !rawArguments) return null;
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(rawArguments);
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
120
|
+
const record = /** @type {Record<string, unknown>} */ (parsed);
|
|
121
|
+
const value = record.file_path ?? record.path ?? record.command ?? record.pattern ?? null;
|
|
122
|
+
return typeof value === "string" ? value.slice(0, 200) : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
102
125
|
/** @param {unknown} reason @returns {{kind: string, error: {code: string, message: string, retryAfterMs?: number} | null}} */
|
|
103
126
|
function describeReason(reason) {
|
|
104
127
|
const record = reason && typeof reason === "object" ? /** @type {Record<string, unknown>} */ (reason) : {};
|
|
@@ -149,9 +172,21 @@ function fail(kind, error, exitCode = 1) {
|
|
|
149
172
|
|
|
150
173
|
/** @param {Record<string, any>} event */
|
|
151
174
|
function fold(event) {
|
|
175
|
+
if (event.type === "tool/call") {
|
|
176
|
+
// Forwarded, not folded away: a tool call is the liveness evidence the
|
|
177
|
+
// controller's stall detector counts, and the step the run page renders.
|
|
178
|
+
const data = event.data && typeof event.data === "object" ? /** @type {Record<string, unknown>} */ (event.data) : {};
|
|
179
|
+
emit({ type: "dsh.tool", name: typeof data.name === "string" ? data.name : "tool", target: toolTarget(data.arguments) });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
152
182
|
if (event.type === "assistant/message") {
|
|
153
183
|
const data = event.data && typeof event.data === "object" ? /** @type {Record<string, unknown>} */ (event.data) : {};
|
|
154
|
-
|
|
184
|
+
const messageUsage = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 };
|
|
185
|
+
const observed = addUsage(messageUsage, data.usage);
|
|
186
|
+
if (observed) {
|
|
187
|
+
addUsage(usage, data.usage);
|
|
188
|
+
sawUsage = true;
|
|
189
|
+
}
|
|
155
190
|
const message = data.message && typeof data.message === "object" ? /** @type {Record<string, unknown>} */ (data.message) : {};
|
|
156
191
|
const blocks = Array.isArray(message.content) ? message.content : [];
|
|
157
192
|
const text = blocks.map((block) => (block && typeof block === "object" && typeof block.text === "string" ? block.text : "")).join("");
|
|
@@ -159,7 +194,7 @@ function fold(event) {
|
|
|
159
194
|
texts.push(text);
|
|
160
195
|
// Every message, not only the last: a judge that returns two verdicts
|
|
161
196
|
// must be visible as two, and the adapter counts them here.
|
|
162
|
-
emit({ type: "dsh.message", text });
|
|
197
|
+
emit({ type: "dsh.message", text, ...(observed ? { usage: messageUsage } : {}) });
|
|
163
198
|
}
|
|
164
199
|
return;
|
|
165
200
|
}
|
|
@@ -22,8 +22,6 @@
|
|
|
22
22
|
|
|
23
23
|
import {
|
|
24
24
|
canonicalUsage,
|
|
25
|
-
eventItem,
|
|
26
|
-
extractJson,
|
|
27
25
|
failed,
|
|
28
26
|
parseJsonLines,
|
|
29
27
|
parseVersion,
|
|
@@ -259,503 +257,6 @@ function validateError(value, label) {
|
|
|
259
257
|
if (typeof record.message !== "string" || !record.message.trim()) throw new TypeError(`${label}.message must be a non-empty string`);
|
|
260
258
|
}
|
|
261
259
|
|
|
262
|
-
/**
|
|
263
|
-
* Best-effort input-token meter over a still-growing transcript. The
|
|
264
|
-
* controller never owns the provider stream (the gate writes stdout straight
|
|
265
|
-
* to the log fd), so budget enforcement polls this instead. Lenient by
|
|
266
|
-
* design: unparsable or partial lines count as zero, and providers that only
|
|
267
|
-
* report usage at completion (agy, exec-jsonl, replay) meter as 0 mid-run.
|
|
268
|
-
*
|
|
269
|
-
* @param {string} harness
|
|
270
|
-
* @param {string} stdout bounded transcript tail
|
|
271
|
-
* @returns {{inputTokens: number|null, cacheReadInputTokens: number|null}}
|
|
272
|
-
*/
|
|
273
|
-
export function liveUsage(harness, stdout) {
|
|
274
|
-
if (harness === "exec-jsonl" || harness === "replay") {
|
|
275
|
-
// Completion-only harnesses: usage arrives in the terminal envelope, which
|
|
276
|
-
// the close path normalizes, never in a mid-run live observation.
|
|
277
|
-
return { inputTokens: null, cacheReadInputTokens: null };
|
|
278
|
-
}
|
|
279
|
-
const events = parsedEvents(stdout);
|
|
280
|
-
if (harness === "codex") {
|
|
281
|
-
// turn.completed usage is cumulative for the session; the last one wins.
|
|
282
|
-
// Codex counts input_tokens with their cached portion included, so the
|
|
283
|
-
// uncached total is what the ledger calls `inputTokens`.
|
|
284
|
-
const records = events
|
|
285
|
-
.filter((event) => event?.type === "turn.completed" && event.usage && typeof event.usage === "object")
|
|
286
|
-
.map((event) => {
|
|
287
|
-
const rawInput = finite(event.usage.input_tokens ?? event.usage.inputTokens);
|
|
288
|
-
if (rawInput === null) return null;
|
|
289
|
-
const cacheReadInputTokens = finite(
|
|
290
|
-
event.usage.cached_input_tokens ?? event.usage.cacheReadInputTokens ?? event.usage.cache_read_tokens,
|
|
291
|
-
) ?? 0;
|
|
292
|
-
return { inputTokens: Math.max(0, rawInput - cacheReadInputTokens), cacheReadInputTokens };
|
|
293
|
-
})
|
|
294
|
-
.filter((record) => record !== null);
|
|
295
|
-
if (!records.length) return { inputTokens: null, cacheReadInputTokens: null };
|
|
296
|
-
return records.reduce((best, record) => (
|
|
297
|
-
record.inputTokens + record.cacheReadInputTokens > best.inputTokens + best.cacheReadInputTokens ? record : best
|
|
298
|
-
));
|
|
299
|
-
}
|
|
300
|
-
if (harness === "claude") {
|
|
301
|
-
// The terminal result event carries the session total; before it lands,
|
|
302
|
-
// sum per-request assistant usage (each request re-reads full context).
|
|
303
|
-
// Claude's input_tokens already exclude cache reads.
|
|
304
|
-
const resultEvent = events.findLast((event) => event?.type === "result");
|
|
305
|
-
const resultUsage = resultEvent?.usage && typeof resultEvent.usage === "object"
|
|
306
|
-
? finite(resultEvent.usage.input_tokens ?? resultEvent.usage.inputTokens)
|
|
307
|
-
: null;
|
|
308
|
-
if (resultUsage !== null) {
|
|
309
|
-
return {
|
|
310
|
-
inputTokens: resultUsage,
|
|
311
|
-
cacheReadInputTokens: finite(resultEvent.usage.cache_read_input_tokens ?? resultEvent.usage.cacheReadInputTokens) ?? null,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
return {
|
|
315
|
-
inputTokens: events.reduce((sum, event) => {
|
|
316
|
-
if (event?.type !== "assistant") return sum;
|
|
317
|
-
const usage = event.message?.usage;
|
|
318
|
-
const value = usage && typeof usage === "object" ? finite(usage.input_tokens ?? usage.inputTokens) : null;
|
|
319
|
-
return sum + (value ?? 0);
|
|
320
|
-
}, 0) || null,
|
|
321
|
-
cacheReadInputTokens: null,
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
return { inputTokens: null, cacheReadInputTokens: null };
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Budgeted live meter: one number, with cache reads weighted by the campaign
|
|
329
|
-
* policy so it is comparable with the persisted ledger. A provider that only
|
|
330
|
-
* reports usage at completion (agy, exec-jsonl, replay) meters as 0 mid-run.
|
|
331
|
-
*
|
|
332
|
-
* @param {string} harness
|
|
333
|
-
* @param {string} stdout bounded transcript tail
|
|
334
|
-
* @param {number} [cacheReadWeight] cached-to-uncached rate ratio, default 1
|
|
335
|
-
* @returns {number}
|
|
336
|
-
*/
|
|
337
|
-
export function liveInputTokens(harness, stdout, cacheReadWeight = 1) {
|
|
338
|
-
const usage = liveUsage(harness, stdout);
|
|
339
|
-
if (usage.inputTokens === null) return 0;
|
|
340
|
-
const weighted = usage.inputTokens + (usage.cacheReadInputTokens ?? 0) * cacheReadWeight;
|
|
341
|
-
return Math.round(weighted * 1000) / 1000;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Parse each JSONL line independently. A bounded transcript tail can start or
|
|
346
|
-
* end mid-line, so unparsable lines are skipped rather than failing the live
|
|
347
|
-
* observation.
|
|
348
|
-
*
|
|
349
|
-
* @param {string} stdout
|
|
350
|
-
*/
|
|
351
|
-
function parsedEvents(stdout) {
|
|
352
|
-
return String(stdout).split(/\r?\n/u).flatMap((line) => {
|
|
353
|
-
try {
|
|
354
|
-
return [JSON.parse(line)];
|
|
355
|
-
} catch {
|
|
356
|
-
return [];
|
|
357
|
-
}
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** Codex item types whose completion proves one tool invocation. */
|
|
362
|
-
const CODEX_TOOL_ITEM_TYPES = new Set(["tool_call", "command_execution", "mcp_tool_call", "web_search", "file_change"]);
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Session evidence from a bounded live transcript: completed turns, cache-read
|
|
366
|
-
* input, tool invocations, and whether the harness's terminal record has been
|
|
367
|
-
* folded. Each harness exposes only what its own events prove, and anything
|
|
368
|
-
* unparsable or unsupported meters as zero — a live observation never throws.
|
|
369
|
-
*
|
|
370
|
-
* @param {string} harness
|
|
371
|
-
* @param {string} stdout bounded transcript tail
|
|
372
|
-
* @returns {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}}
|
|
373
|
-
*/
|
|
374
|
-
export function liveSessionMetrics(harness, stdout) {
|
|
375
|
-
const parser = new SessionMetricsParser(harness);
|
|
376
|
-
parser.push(String(stdout));
|
|
377
|
-
parser.flush();
|
|
378
|
-
return parser.metrics();
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/** Retention bound for one streamed record: records at or below it parse whole. */
|
|
382
|
-
const SESSION_RECORD_MAX_BYTES = 64 * 1024;
|
|
383
|
-
|
|
384
|
-
/** Fragment evidence kept for a record that outgrew the retention bound. */
|
|
385
|
-
const SESSION_FRAGMENT_BYTES = SESSION_RECORD_MAX_BYTES / 2;
|
|
386
|
-
|
|
387
|
-
/** Claude-family content-block needle proving one tool invocation. */
|
|
388
|
-
const TOOL_USE_NEEDLE = Buffer.from('"type":"tool_use"', "utf8");
|
|
389
|
-
|
|
390
|
-
/** Cache-read evidence spellings across harness streams. */
|
|
391
|
-
const CACHE_READ_PATTERN = /"(?:cache_read_input_tokens|cached_input_tokens|cacheReadInputTokens)":(\d+)/gu;
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* Bounded incremental session-metrics parser: fold fixed-size chunks into
|
|
395
|
-
* running rotation totals without ever holding a buffer that scales with the
|
|
396
|
-
* unread transcript. Records within `SESSION_RECORD_MAX_BYTES` parse whole;
|
|
397
|
-
* a larger record keeps head and tail fragments plus streamed needle counts,
|
|
398
|
-
* so its turn and usage evidence still lands in the totals instead of being
|
|
399
|
-
* silently skipped.
|
|
400
|
-
*/
|
|
401
|
-
export class SessionMetricsParser {
|
|
402
|
-
/**
|
|
403
|
-
* @param {string} harness
|
|
404
|
-
* @param {{turns?: number, cacheReadInputTokens?: number, toolCalls?: number, completed?: boolean}} [previous]
|
|
405
|
-
*/
|
|
406
|
-
constructor(harness, previous = {}) {
|
|
407
|
-
this.harness = harness;
|
|
408
|
-
this.totals = {
|
|
409
|
-
turns: previous.turns ?? 0,
|
|
410
|
-
cacheReadInputTokens: previous.cacheReadInputTokens ?? 0,
|
|
411
|
-
toolCalls: previous.toolCalls ?? 0,
|
|
412
|
-
completed: previous.completed === true,
|
|
413
|
-
};
|
|
414
|
-
/** @type {string|null} */
|
|
415
|
-
this.continuationId = null;
|
|
416
|
-
/** @type {string|null} Most recent folded item-completed type, for the codex completion rule. */
|
|
417
|
-
this.lastItemType = null;
|
|
418
|
-
/** @type {string|null} Text of the most recent folded agent message, for the codex completion rule. */
|
|
419
|
-
this.lastAgentText = null;
|
|
420
|
-
/** @type {Buffer} */
|
|
421
|
-
this.pending = Buffer.alloc(0);
|
|
422
|
-
/** @type {{head: Buffer, tail: Buffer, streamedToolUse: number, carry: Buffer}|null} */
|
|
423
|
-
this.oversized = null;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Fold every newline-terminated record in one chunk. A trailing partial
|
|
428
|
-
* record stays buffered (bounded) for the next chunk.
|
|
429
|
-
*
|
|
430
|
-
* @param {string|Buffer} chunk
|
|
431
|
-
*/
|
|
432
|
-
push(chunk) {
|
|
433
|
-
let data = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
434
|
-
while (data.length > 0) {
|
|
435
|
-
const newline = data.indexOf(10);
|
|
436
|
-
if (newline < 0) {
|
|
437
|
-
this.absorb(data);
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
this.absorb(data.subarray(0, newline));
|
|
441
|
-
this.completeRecord();
|
|
442
|
-
data = data.subarray(newline + 1);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/** Fold the buffered partial record as if a newline had ended it. */
|
|
447
|
-
flush() {
|
|
448
|
-
if (this.pending.length > 0 || this.oversized) this.completeRecord();
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/** @returns {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}} */
|
|
452
|
-
metrics() {
|
|
453
|
-
return {
|
|
454
|
-
turns: this.totals.turns,
|
|
455
|
-
cacheReadInputTokens: this.totals.cacheReadInputTokens,
|
|
456
|
-
toolCalls: this.totals.toolCalls,
|
|
457
|
-
completed: this.totals.completed === true,
|
|
458
|
-
};
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* Retain one piece of a record still under assembly. Once the record
|
|
463
|
-
* outgrows the retention bound, only its head and a rolling tail are kept;
|
|
464
|
-
* the bytes leaving the tail are scanned for tool_use evidence instead of
|
|
465
|
-
* being buffered.
|
|
466
|
-
*
|
|
467
|
-
* @param {Buffer} piece
|
|
468
|
-
*/
|
|
469
|
-
absorb(piece) {
|
|
470
|
-
if (this.oversized) {
|
|
471
|
-
const window = Buffer.concat([this.oversized.tail, piece]);
|
|
472
|
-
const keep = window.subarray(Math.max(0, window.length - SESSION_FRAGMENT_BYTES));
|
|
473
|
-
const dropped = window.subarray(0, window.length - keep.length);
|
|
474
|
-
if (this.harness === "claude") {
|
|
475
|
-
const counted = countWithCarry(dropped, this.oversized.carry, TOOL_USE_NEEDLE);
|
|
476
|
-
this.oversized.streamedToolUse += counted.hits;
|
|
477
|
-
this.oversized.carry = counted.carry;
|
|
478
|
-
}
|
|
479
|
-
this.oversized.tail = keep;
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
if (this.pending.length + piece.length <= SESSION_RECORD_MAX_BYTES) {
|
|
483
|
-
// Copy: `piece` may be a view of a scratch buffer the caller reuses for
|
|
484
|
-
// the next read, which would corrupt a record buffered mid-chunk.
|
|
485
|
-
this.pending = this.pending.length > 0 ? Buffer.concat([this.pending, piece]) : Buffer.from(piece);
|
|
486
|
-
return;
|
|
487
|
-
}
|
|
488
|
-
const whole = Buffer.concat([this.pending, piece]);
|
|
489
|
-
this.pending = Buffer.alloc(0);
|
|
490
|
-
const head = whole.subarray(0, Math.min(SESSION_FRAGMENT_BYTES, whole.length));
|
|
491
|
-
const tail = whole.subarray(Math.max(0, whole.length - SESSION_FRAGMENT_BYTES));
|
|
492
|
-
/** @type {{head: Buffer, tail: Buffer, streamedToolUse: number, carry: Buffer}} */
|
|
493
|
-
const oversized = { head, tail, streamedToolUse: 0, carry: Buffer.alloc(0) };
|
|
494
|
-
if (this.harness === "claude") {
|
|
495
|
-
// Count from the record start up to where the rolling tail takes over,
|
|
496
|
-
// so a needle straddling any region boundary is counted exactly once.
|
|
497
|
-
const counted = countWithCarry(whole.subarray(0, Math.max(0, whole.length - tail.length)), oversized.carry, TOOL_USE_NEEDLE);
|
|
498
|
-
oversized.streamedToolUse = counted.hits;
|
|
499
|
-
oversized.carry = counted.carry;
|
|
500
|
-
}
|
|
501
|
-
this.oversized = oversized;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/** Fold the assembled record into the running totals. */
|
|
505
|
-
completeRecord() {
|
|
506
|
-
const oversized = this.oversized;
|
|
507
|
-
if (oversized) {
|
|
508
|
-
this.oversized = null;
|
|
509
|
-
/** @type {{head: string, tail: string, toolUse: number}} */
|
|
510
|
-
let fragments;
|
|
511
|
-
if (this.harness === "claude") {
|
|
512
|
-
const counted = countWithCarry(oversized.tail, oversized.carry, TOOL_USE_NEEDLE);
|
|
513
|
-
fragments = {
|
|
514
|
-
head: decodeFragment(oversized.head),
|
|
515
|
-
tail: decodeFragment(oversized.tail),
|
|
516
|
-
toolUse: oversized.streamedToolUse + counted.hits,
|
|
517
|
-
};
|
|
518
|
-
} else {
|
|
519
|
-
fragments = { head: decodeFragment(oversized.head), tail: decodeFragment(oversized.tail), toolUse: 0 };
|
|
520
|
-
}
|
|
521
|
-
foldFragmentRecord(this.harness, this.totals, fragments);
|
|
522
|
-
this.continuationId ??= fragmentContinuationId(this.harness, fragments);
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
const line = this.pending.toString("utf8");
|
|
526
|
-
this.pending = Buffer.alloc(0);
|
|
527
|
-
let event;
|
|
528
|
-
try {
|
|
529
|
-
event = JSON.parse(line);
|
|
530
|
-
} catch {
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
if (!event || typeof event !== "object" || Array.isArray(event)) return;
|
|
534
|
-
const record = /** @type {Record<string, unknown>} */ (event);
|
|
535
|
-
foldRecord(this.harness, this.totals, record);
|
|
536
|
-
this.continuationId ??= recordContinuationId(this.harness, record);
|
|
537
|
-
this.foldCompletionEvidence(record);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Fold the completion evidence one parsed record proves into the sticky
|
|
542
|
-
* totals. A harness is completed when its terminal record was folded; for
|
|
543
|
-
* codex that means a turn.completed that ends the turn with the
|
|
544
|
-
* result-carrying final agent message, so a live observation never treats a
|
|
545
|
-
* still-working or already-answered session ambiguously.
|
|
546
|
-
*
|
|
547
|
-
* @param {Record<string, unknown>} record
|
|
548
|
-
*/
|
|
549
|
-
foldCompletionEvidence(record) {
|
|
550
|
-
const totals = this.totals;
|
|
551
|
-
if (this.harness === "codex") {
|
|
552
|
-
if (record.type === "turn.completed" && this.lastItemType === "agent_message"
|
|
553
|
-
&& extractJson(this.lastAgentText) !== null) {
|
|
554
|
-
totals.completed = true;
|
|
555
|
-
}
|
|
556
|
-
} else if (this.harness === "claude" && record.type === "result") {
|
|
557
|
-
totals.completed = true;
|
|
558
|
-
} else if (this.harness === "exec-jsonl" && record.type === "run.completed") {
|
|
559
|
-
totals.completed = true;
|
|
560
|
-
} else if (this.harness === "replay" && typeof record.status === "string") {
|
|
561
|
-
// The replay envelope is the terminal record: the bin emits exactly one
|
|
562
|
-
// envelope line per invocation, so folding one proves completion.
|
|
563
|
-
totals.completed = true;
|
|
564
|
-
}
|
|
565
|
-
const item = eventItem(record);
|
|
566
|
-
if (record.type === "item.completed" && item) {
|
|
567
|
-
this.lastItemType = String(item.type ?? "");
|
|
568
|
-
this.lastAgentText = this.lastItemType === "agent_message" && typeof item.text === "string"
|
|
569
|
-
? item.text
|
|
570
|
-
: null;
|
|
571
|
-
} else {
|
|
572
|
-
this.lastItemType = null;
|
|
573
|
-
this.lastAgentText = null;
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* Fold one parsed record into the running totals. Turn and tool counts are
|
|
580
|
-
* additive; cache-read is a running max for Codex (each turn.completed
|
|
581
|
-
* counter is already cumulative) and additive for claude-style streams until
|
|
582
|
-
* a terminal result event carries the authoritative session total.
|
|
583
|
-
*
|
|
584
|
-
* @param {string} harness
|
|
585
|
-
* @param {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}} totals
|
|
586
|
-
* @param {Record<string, unknown>} record
|
|
587
|
-
*/
|
|
588
|
-
function foldRecord(harness, totals, record) {
|
|
589
|
-
if (harness === "codex") {
|
|
590
|
-
if (record.type === "turn.completed") {
|
|
591
|
-
totals.turns += 1;
|
|
592
|
-
totals.cacheReadInputTokens = Math.max(totals.cacheReadInputTokens, canonicalUsage(record.usage).cacheReadInputTokens ?? 0);
|
|
593
|
-
} else if (record.type === "item.completed" && CODEX_TOOL_ITEM_TYPES.has(String(eventItem(record)?.type))) {
|
|
594
|
-
totals.toolCalls += 1;
|
|
595
|
-
}
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
if (harness === "claude") {
|
|
599
|
-
if (record.type === "assistant") {
|
|
600
|
-
const message = /** @type {Record<string, unknown>} */ (record.message ?? {});
|
|
601
|
-
totals.turns += 1;
|
|
602
|
-
totals.cacheReadInputTokens += canonicalUsage(message.usage).cacheReadInputTokens ?? 0;
|
|
603
|
-
totals.toolCalls += Array.isArray(message.content)
|
|
604
|
-
? message.content.filter((/** @type {{type?: unknown}} */ block) => block?.type === "tool_use").length
|
|
605
|
-
: 0;
|
|
606
|
-
} else if (record.type === "result") {
|
|
607
|
-
const sessionTotal = canonicalUsage(record.usage).cacheReadInputTokens;
|
|
608
|
-
if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
|
|
609
|
-
}
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
|
-
if (harness === "exec-jsonl" && record.type === "run.completed") {
|
|
613
|
-
// The protocol carries no tool events; only a completed run proves a turn.
|
|
614
|
-
totals.turns += 1;
|
|
615
|
-
totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
|
|
616
|
-
}
|
|
617
|
-
if (harness === "replay" && typeof record.status === "string") {
|
|
618
|
-
// A replayed envelope is the whole invocation: one completed turn, no
|
|
619
|
-
// tool events, usage only in the terminal record.
|
|
620
|
-
totals.turns += 1;
|
|
621
|
-
totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* Fold the head-plus-tail fragments of one record that outgrew the retention
|
|
627
|
-
* bound: the same evidence foldRecord extracts, read as fragments so an
|
|
628
|
-
* oversized record is never silently skipped.
|
|
629
|
-
*
|
|
630
|
-
* @param {string} harness
|
|
631
|
-
* @param {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}} totals
|
|
632
|
-
* @param {{head: string, tail: string, toolUse: number}} fragments
|
|
633
|
-
*/
|
|
634
|
-
function foldFragmentRecord(harness, totals, fragments) {
|
|
635
|
-
const text = `${fragments.head}\n${fragments.tail}`;
|
|
636
|
-
if (harness === "claude") {
|
|
637
|
-
if (text.includes('"type":"assistant"')) {
|
|
638
|
-
totals.turns += 1;
|
|
639
|
-
totals.toolCalls += fragments.toolUse;
|
|
640
|
-
const cacheRead = lastCacheRead(text);
|
|
641
|
-
if (cacheRead !== null) totals.cacheReadInputTokens += cacheRead;
|
|
642
|
-
} else if (text.includes('"type":"result"')) {
|
|
643
|
-
const sessionTotal = lastCacheRead(text);
|
|
644
|
-
if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
|
|
645
|
-
totals.completed = true;
|
|
646
|
-
}
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
if (harness === "codex") {
|
|
650
|
-
if (text.includes('"type":"turn.completed"')) {
|
|
651
|
-
totals.turns += 1;
|
|
652
|
-
const cacheRead = lastCacheRead(text);
|
|
653
|
-
totals.cacheReadInputTokens = Math.max(totals.cacheReadInputTokens, cacheRead ?? 0);
|
|
654
|
-
// Fragment approximation of the parsed-record completion rule: the
|
|
655
|
-
// turn ends with the final agent message when that message appears
|
|
656
|
-
// before the completed marker in the retained head and tail.
|
|
657
|
-
const agentAt = text.indexOf('"type":"agent_message"');
|
|
658
|
-
if (agentAt >= 0 && agentAt < text.indexOf('"type":"turn.completed"')) totals.completed = true;
|
|
659
|
-
} else if (text.includes('"type":"item.completed"') && [...CODEX_TOOL_ITEM_TYPES].some((type) => text.includes(`"type":"${type}"`))) {
|
|
660
|
-
totals.toolCalls += 1;
|
|
661
|
-
}
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
if (harness === "exec-jsonl" && text.includes('"type":"run.completed"')) {
|
|
665
|
-
totals.turns += 1;
|
|
666
|
-
const cacheRead = lastCacheRead(text);
|
|
667
|
-
if (cacheRead !== null) totals.cacheReadInputTokens += cacheRead;
|
|
668
|
-
totals.completed = true;
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/**
|
|
673
|
-
* The provider session identity one record proves.
|
|
674
|
-
*
|
|
675
|
-
* @param {string} harness
|
|
676
|
-
* @param {Record<string, unknown>} record
|
|
677
|
-
* @returns {string|null}
|
|
678
|
-
*/
|
|
679
|
-
function recordContinuationId(harness, record) {
|
|
680
|
-
if (harness === "codex") {
|
|
681
|
-
return record.type === "thread.started" && typeof record.thread_id === "string" ? record.thread_id : null;
|
|
682
|
-
}
|
|
683
|
-
if (harness === "claude") {
|
|
684
|
-
return record.type === "result" && typeof record.session_id === "string" ? record.session_id : null;
|
|
685
|
-
}
|
|
686
|
-
if (harness === "exec-jsonl" && (record.type === "run.started" || record.type === "run.completed")) {
|
|
687
|
-
return typeof record.continuationId === "string" ? record.continuationId : null;
|
|
688
|
-
}
|
|
689
|
-
return null;
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
/**
|
|
693
|
-
* The provider session identity one record's fragments prove.
|
|
694
|
-
*
|
|
695
|
-
* @param {string} harness
|
|
696
|
-
* @param {{head: string, tail: string}} fragments
|
|
697
|
-
* @returns {string|null}
|
|
698
|
-
*/
|
|
699
|
-
function fragmentContinuationId(harness, fragments) {
|
|
700
|
-
const text = `${fragments.head}\n${fragments.tail}`;
|
|
701
|
-
const pattern = harness === "codex"
|
|
702
|
-
? /"thread_id":"([^"]+)"/u
|
|
703
|
-
: harness === "claude"
|
|
704
|
-
? /"session_id":"([^"]+)"/u
|
|
705
|
-
: /"continuationId":"([^"]+)"/u;
|
|
706
|
-
const match = pattern.exec(text);
|
|
707
|
-
return match ? match[1] : null;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
/**
|
|
711
|
-
* Count needle occurrences in one region, keeping the trailing bytes that
|
|
712
|
-
* could complete a needle in the next region so a straddling needle is
|
|
713
|
-
* counted exactly once.
|
|
714
|
-
*
|
|
715
|
-
* @param {Buffer} region
|
|
716
|
-
* @param {Buffer} carry
|
|
717
|
-
* @param {Buffer} needle
|
|
718
|
-
* @returns {{hits: number, carry: Buffer}}
|
|
719
|
-
*/
|
|
720
|
-
function countWithCarry(region, carry, needle) {
|
|
721
|
-
const stream = carry.length > 0 ? Buffer.concat([carry, region]) : region;
|
|
722
|
-
return { hits: countNeedle(stream, needle), carry: stream.subarray(Math.max(0, stream.length - (needle.length - 1))) };
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* @param {Buffer} haystack
|
|
727
|
-
* @param {Buffer} needle
|
|
728
|
-
* @returns {number}
|
|
729
|
-
*/
|
|
730
|
-
function countNeedle(haystack, needle) {
|
|
731
|
-
let hits = 0;
|
|
732
|
-
for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + needle.length)) hits += 1;
|
|
733
|
-
return hits;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* Decode a retained fragment without splitting a UTF-8 sequence.
|
|
738
|
-
*
|
|
739
|
-
* @param {Buffer} fragment
|
|
740
|
-
* @returns {string}
|
|
741
|
-
*/
|
|
742
|
-
function decodeFragment(fragment) {
|
|
743
|
-
let start = 0;
|
|
744
|
-
while (start < fragment.length && (fragment[start] & 0xc0) === 0x80) start += 1;
|
|
745
|
-
return fragment.toString("utf8", start);
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
/**
|
|
749
|
-
* The last cache-read number in a fragment text, or null.
|
|
750
|
-
*
|
|
751
|
-
* @param {string} text
|
|
752
|
-
* @returns {number|null}
|
|
753
|
-
*/
|
|
754
|
-
function lastCacheRead(text) {
|
|
755
|
-
const matches = [...text.matchAll(CACHE_READ_PATTERN)];
|
|
756
|
-
return matches.length > 0 ? Number(matches.at(-1)?.[1]) : null;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
260
|
/**
|
|
760
261
|
* Bound one tool result to at most `maxBytes` UTF-8 bytes, keeping the head
|
|
761
262
|
* and the tail around an omission marker. This is the reference head+tail
|