faberun 0.3.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic exec-jsonl adapter protocol.
|
|
3
|
+
*
|
|
4
|
+
* The executable receives one UTF-8 JSON line on stdin:
|
|
5
|
+
* `{schemaVersion:1,type:"run.request",model,prompt,structuredOutput,
|
|
6
|
+
* outputSchema,continuationId}`. The request deliberately carries no tool
|
|
7
|
+
* policy: an
|
|
8
|
+
* arbitrary wrapper executable cannot prove enforcement, so the mechanical
|
|
9
|
+
* policy travels only where a hook surface can enforce it (claude). It
|
|
10
|
+
* writes JSONL events to stdout:
|
|
11
|
+
* `run.started` (optional), `message` (zero or more), then exactly one
|
|
12
|
+
* `run.completed` or `run.failed` event. Events must appear in that order,
|
|
13
|
+
* with no unknown fields. A completed event is
|
|
14
|
+
* `{schemaVersion:1,type:"run.completed",result,continuationId,usage,costUsd}`;
|
|
15
|
+
* `result` is required and may be any JSON value. A failed event is
|
|
16
|
+
* `{schemaVersion:1,type:"run.failed",error:{code,message}}`.
|
|
17
|
+
*
|
|
18
|
+
* Stderr is diagnostic only. Unknown or malformed output is rejected by the
|
|
19
|
+
* runtime normalizer; wrappers should emit this protocol rather than making
|
|
20
|
+
* scheduler-specific provider branches.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
canonicalUsage,
|
|
25
|
+
eventItem,
|
|
26
|
+
extractJson,
|
|
27
|
+
failed,
|
|
28
|
+
parseJsonLines,
|
|
29
|
+
parseVersion,
|
|
30
|
+
} from "../protocol.mjs";
|
|
31
|
+
import { rejectUnknown } from "../../contract/assert.mjs";
|
|
32
|
+
import { finite } from "../../util.mjs";
|
|
33
|
+
|
|
34
|
+
export { HARNESS_OUTPUT_LIMIT_BYTES } from "../protocol.mjs";
|
|
35
|
+
|
|
36
|
+
export const EXEC_JSONL_PROTOCOL = Object.freeze({
|
|
37
|
+
schemaVersion: 1,
|
|
38
|
+
requestType: "run.request",
|
|
39
|
+
completedType: "run.completed",
|
|
40
|
+
failedType: "run.failed",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Exact tool-output bound (UTF-8 bytes) carried by the toolPolicy contract. */
|
|
44
|
+
export const TOOL_OUTPUT_LIMIT_BYTES = 8192;
|
|
45
|
+
|
|
46
|
+
const EVENT_FIELDS = Object.freeze({
|
|
47
|
+
"run.started": new Set(["schemaVersion", "type", "continuationId"]),
|
|
48
|
+
message: new Set(["schemaVersion", "type", "text"]),
|
|
49
|
+
"run.completed": new Set(["schemaVersion", "type", "result", "continuationId", "usage", "costUsd"]),
|
|
50
|
+
"run.failed": new Set(["schemaVersion", "type", "error"]),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const EVENT_TYPES = new Set(Object.keys(EVENT_FIELDS));
|
|
54
|
+
|
|
55
|
+
/** @typedef {import("../index.mjs").HarnessAdapter} HarnessAdapter */
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @type {HarnessAdapter}
|
|
59
|
+
*/
|
|
60
|
+
export const execJsonlHarness = {
|
|
61
|
+
capabilities: {
|
|
62
|
+
structuredOutput: true,
|
|
63
|
+
promptTransport: "stdin",
|
|
64
|
+
sandbox: false,
|
|
65
|
+
permissions: false,
|
|
66
|
+
continuation: true,
|
|
67
|
+
tokenBudget: true,
|
|
68
|
+
costBudget: false,
|
|
69
|
+
usage: true,
|
|
70
|
+
cost: true,
|
|
71
|
+
// An arbitrary wrapper executable cannot honestly advertise mechanical
|
|
72
|
+
// tool-policy enforcement; the request carries none.
|
|
73
|
+
toolPolicy: false,
|
|
74
|
+
// The protocol allows zero `message` events before the terminal one, so
|
|
75
|
+
// an arbitrary wrapper cannot honestly advertise incremental output either.
|
|
76
|
+
streamsOutput: false,
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// The wrapper protocol exposes no permission mode.
|
|
80
|
+
permissionExecution: null,
|
|
81
|
+
|
|
82
|
+
/** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string} */
|
|
83
|
+
executable(runtime) {
|
|
84
|
+
return process.env.FABERUN_EXEC_JSONL_BIN ?? runtime.executable ?? "exec-jsonl";
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string[]} */
|
|
88
|
+
versionArgs(runtime) {
|
|
89
|
+
return runtime.versionArgs ?? ["--version"];
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
parseVersion,
|
|
93
|
+
|
|
94
|
+
/** @param {import("../index.mjs").HarnessRuntime} runtime @param {string} prompt @param {import("../index.mjs").CommandOptions} options @returns {import("../index.mjs").HarnessCommand} */
|
|
95
|
+
command(runtime, prompt, options) {
|
|
96
|
+
const request = {
|
|
97
|
+
schemaVersion: 1,
|
|
98
|
+
type: "run.request",
|
|
99
|
+
model: runtime.model,
|
|
100
|
+
prompt,
|
|
101
|
+
structuredOutput: Boolean(options.schema || options.schemaPath),
|
|
102
|
+
outputSchema: options.schema ?? options.schemaPath ?? null,
|
|
103
|
+
continuationId: options.continuationId ?? null,
|
|
104
|
+
};
|
|
105
|
+
const args = runtime.args ?? [];
|
|
106
|
+
return {
|
|
107
|
+
executable: this.executable(runtime),
|
|
108
|
+
args: [...args],
|
|
109
|
+
promptTransport: "stdin",
|
|
110
|
+
input: `${JSON.stringify(request)}\n`,
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
normalize: normalizeExecJsonlResult,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const harness = execJsonlHarness;
|
|
118
|
+
export default execJsonlHarness;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} stdout
|
|
122
|
+
* @param {number|null} exitCode
|
|
123
|
+
* @param {string|null} signal
|
|
124
|
+
* @returns {import("../index.mjs").ProviderEnvelope}
|
|
125
|
+
*/
|
|
126
|
+
export function normalizeExecJsonlResult(stdout, exitCode, signal) {
|
|
127
|
+
if (signal) return failed("canceled", `provider ended after ${signal}`, "canceled");
|
|
128
|
+
let events;
|
|
129
|
+
try {
|
|
130
|
+
events = parseJsonLines(stdout, "exec-jsonl");
|
|
131
|
+
validateExecJsonlEvents(events);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return failed("invalid_protocol", error instanceof Error ? error.message : String(error));
|
|
134
|
+
}
|
|
135
|
+
const lastEvent = events.at(-1);
|
|
136
|
+
if (!lastEvent) return failed("invalid_protocol", "exec-jsonl emitted no events");
|
|
137
|
+
const terminal = /** @type {Record<string, unknown>} */ (lastEvent);
|
|
138
|
+
if (terminal.type === "run.failed") {
|
|
139
|
+
const error = /** @type {Record<string, unknown>|undefined} */ (terminal.error);
|
|
140
|
+
return failed(
|
|
141
|
+
typeof error?.code === "string" ? error.code : "provider_error",
|
|
142
|
+
typeof error?.message === "string" ? error.message : "exec-jsonl failed",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (exitCode !== 0) return failed("provider_error", `exec-jsonl exited with code ${exitCode}`);
|
|
146
|
+
const rawResult = terminal.result;
|
|
147
|
+
const result = typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult);
|
|
148
|
+
return {
|
|
149
|
+
status: result.trim() ? "done" : "no-op",
|
|
150
|
+
result,
|
|
151
|
+
continuationId: typeof terminal.continuationId === "string" ? terminal.continuationId : null,
|
|
152
|
+
usage: canonicalUsage(terminal.usage),
|
|
153
|
+
costUsd: finite(terminal.costUsd),
|
|
154
|
+
error: null,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {Record<string, unknown>[]} events
|
|
160
|
+
*/
|
|
161
|
+
function validateExecJsonlEvents(events) {
|
|
162
|
+
if (!events.length) throw new TypeError("exec-jsonl emitted no events");
|
|
163
|
+
let terminalCount = 0;
|
|
164
|
+
let phase = "start";
|
|
165
|
+
for (const [index, event] of events.entries()) {
|
|
166
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
167
|
+
throw new TypeError(`exec-jsonl event ${index + 1} must be an object`);
|
|
168
|
+
}
|
|
169
|
+
if (event.schemaVersion !== 1) {
|
|
170
|
+
throw new TypeError(`exec-jsonl event ${index + 1} schemaVersion must be 1`);
|
|
171
|
+
}
|
|
172
|
+
const type = /** @type {keyof typeof EVENT_FIELDS} */ (event.type);
|
|
173
|
+
if (!EVENT_TYPES.has(type)) {
|
|
174
|
+
throw new TypeError(`exec-jsonl event ${index + 1} type is unknown`);
|
|
175
|
+
}
|
|
176
|
+
rejectUnknown(event, EVENT_FIELDS[type], `exec-jsonl event ${index + 1}`);
|
|
177
|
+
if (type === "run.started") {
|
|
178
|
+
if (phase !== "start") throw new TypeError("exec-jsonl run.started must be the first event");
|
|
179
|
+
phase = "messages";
|
|
180
|
+
validateContinuationId(event.continuationId, `exec-jsonl event ${index + 1}.continuationId`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (type === "message") {
|
|
184
|
+
if (phase === "terminal") throw new TypeError("exec-jsonl message cannot follow a terminal event");
|
|
185
|
+
phase = "messages";
|
|
186
|
+
if (typeof event.text !== "string") throw new TypeError(`exec-jsonl event ${index + 1}.text must be a string`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (phase === "terminal") throw new TypeError("exec-jsonl emitted multiple terminal events");
|
|
190
|
+
phase = "terminal";
|
|
191
|
+
terminalCount += 1;
|
|
192
|
+
if (event.type === "run.completed") {
|
|
193
|
+
if (!Object.hasOwn(event, "result")) throw new TypeError("exec-jsonl run.completed.result is required");
|
|
194
|
+
validateContinuationId(event.continuationId, `exec-jsonl event ${index + 1}.continuationId`);
|
|
195
|
+
validateUsage(event.usage, `exec-jsonl event ${index + 1}.usage`);
|
|
196
|
+
validateCost(event.costUsd, `exec-jsonl event ${index + 1}.costUsd`);
|
|
197
|
+
} else {
|
|
198
|
+
validateError(event.error, `exec-jsonl event ${index + 1}.error`);
|
|
199
|
+
}
|
|
200
|
+
if (index !== events.length - 1) {
|
|
201
|
+
if (events.slice(index + 1).some((next) => next?.type === "run.completed" || next?.type === "run.failed")) {
|
|
202
|
+
throw new TypeError("exec-jsonl emitted multiple terminal events");
|
|
203
|
+
}
|
|
204
|
+
throw new TypeError("exec-jsonl terminal event must be last");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (terminalCount !== 1) throw new TypeError("exec-jsonl requires exactly one terminal event");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @param {unknown} value
|
|
212
|
+
* @param {string} label
|
|
213
|
+
*/
|
|
214
|
+
function validateContinuationId(value, label) {
|
|
215
|
+
if (value !== undefined && value !== null && typeof value !== "string") {
|
|
216
|
+
throw new TypeError(`${label} must be a string or null`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @param {unknown} value
|
|
222
|
+
* @param {string} label
|
|
223
|
+
*/
|
|
224
|
+
function validateUsage(value, label) {
|
|
225
|
+
if (value === undefined) return;
|
|
226
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${label} must be an object`);
|
|
227
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
228
|
+
const allowed = new Set(["inputTokens", "outputTokens", "cacheReadInputTokens"]);
|
|
229
|
+
rejectUnknown(record, allowed, label);
|
|
230
|
+
for (const key of allowed) {
|
|
231
|
+
const raw = record[key];
|
|
232
|
+
if (raw !== undefined && raw !== null && (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0)) {
|
|
233
|
+
throw new TypeError(`${label}.${key} must be a non-negative integer or null`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @param {unknown} value
|
|
240
|
+
* @param {string} label
|
|
241
|
+
*/
|
|
242
|
+
function validateCost(value, label) {
|
|
243
|
+
if (value !== undefined && value !== null && (typeof value !== "number" || !Number.isFinite(value) || value < 0)) {
|
|
244
|
+
throw new TypeError(`${label} must be a non-negative number or null`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param {unknown} value
|
|
250
|
+
* @param {string} label
|
|
251
|
+
*/
|
|
252
|
+
function validateError(value, label) {
|
|
253
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${label} must be an object`);
|
|
254
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
255
|
+
rejectUnknown(record, new Set(["code", "message"]), label);
|
|
256
|
+
if (typeof record.code !== "string" || !record.code.trim()) throw new TypeError(`${label}.code must be a non-empty string`);
|
|
257
|
+
if (typeof record.message !== "string" || !record.message.trim()) throw new TypeError(`${label}.message must be a non-empty string`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Best-effort input-token meter over a still-growing transcript. The
|
|
262
|
+
* controller never owns the provider stream (the gate writes stdout straight
|
|
263
|
+
* to the log fd), so budget enforcement polls this instead. Lenient by
|
|
264
|
+
* design: unparsable or partial lines count as zero, and providers that only
|
|
265
|
+
* report usage at completion (agy, exec-jsonl, replay) meter as 0 mid-run.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} harness
|
|
268
|
+
* @param {string} stdout bounded transcript tail
|
|
269
|
+
* @returns {{inputTokens: number|null, cacheReadInputTokens: number|null}}
|
|
270
|
+
*/
|
|
271
|
+
export function liveUsage(harness, stdout) {
|
|
272
|
+
if (harness === "exec-jsonl" || harness === "replay") {
|
|
273
|
+
// Completion-only harnesses: usage arrives in the terminal envelope, which
|
|
274
|
+
// the close path normalizes, never in a mid-run live observation.
|
|
275
|
+
return { inputTokens: null, cacheReadInputTokens: null };
|
|
276
|
+
}
|
|
277
|
+
const events = parsedEvents(stdout);
|
|
278
|
+
if (harness === "codex") {
|
|
279
|
+
// turn.completed usage is cumulative for the session; the last one wins.
|
|
280
|
+
// Codex counts input_tokens with their cached portion included, so the
|
|
281
|
+
// uncached total is what the ledger calls `inputTokens`.
|
|
282
|
+
const records = events
|
|
283
|
+
.filter((event) => event?.type === "turn.completed" && event.usage && typeof event.usage === "object")
|
|
284
|
+
.map((event) => {
|
|
285
|
+
const rawInput = finite(event.usage.input_tokens ?? event.usage.inputTokens);
|
|
286
|
+
if (rawInput === null) return null;
|
|
287
|
+
const cacheReadInputTokens = finite(
|
|
288
|
+
event.usage.cached_input_tokens ?? event.usage.cacheReadInputTokens ?? event.usage.cache_read_tokens,
|
|
289
|
+
) ?? 0;
|
|
290
|
+
return { inputTokens: Math.max(0, rawInput - cacheReadInputTokens), cacheReadInputTokens };
|
|
291
|
+
})
|
|
292
|
+
.filter((record) => record !== null);
|
|
293
|
+
if (!records.length) return { inputTokens: null, cacheReadInputTokens: null };
|
|
294
|
+
return records.reduce((best, record) => (
|
|
295
|
+
record.inputTokens + record.cacheReadInputTokens > best.inputTokens + best.cacheReadInputTokens ? record : best
|
|
296
|
+
));
|
|
297
|
+
}
|
|
298
|
+
if (harness === "claude") {
|
|
299
|
+
// The terminal result event carries the session total; before it lands,
|
|
300
|
+
// sum per-request assistant usage (each request re-reads full context).
|
|
301
|
+
// Claude's input_tokens already exclude cache reads.
|
|
302
|
+
const resultEvent = events.findLast((event) => event?.type === "result");
|
|
303
|
+
const resultUsage = resultEvent?.usage && typeof resultEvent.usage === "object"
|
|
304
|
+
? finite(resultEvent.usage.input_tokens ?? resultEvent.usage.inputTokens)
|
|
305
|
+
: null;
|
|
306
|
+
if (resultUsage !== null) {
|
|
307
|
+
return {
|
|
308
|
+
inputTokens: resultUsage,
|
|
309
|
+
cacheReadInputTokens: finite(resultEvent.usage.cache_read_input_tokens ?? resultEvent.usage.cacheReadInputTokens) ?? null,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
inputTokens: events.reduce((sum, event) => {
|
|
314
|
+
if (event?.type !== "assistant") return sum;
|
|
315
|
+
const usage = event.message?.usage;
|
|
316
|
+
const value = usage && typeof usage === "object" ? finite(usage.input_tokens ?? usage.inputTokens) : null;
|
|
317
|
+
return sum + (value ?? 0);
|
|
318
|
+
}, 0) || null,
|
|
319
|
+
cacheReadInputTokens: null,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return { inputTokens: null, cacheReadInputTokens: null };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Budgeted live meter: one number, with cache reads weighted by the campaign
|
|
327
|
+
* policy so it is comparable with the persisted ledger. A provider that only
|
|
328
|
+
* reports usage at completion (agy, exec-jsonl, replay) meters as 0 mid-run.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} harness
|
|
331
|
+
* @param {string} stdout bounded transcript tail
|
|
332
|
+
* @param {number} [cacheReadWeight] cached-to-uncached rate ratio, default 1
|
|
333
|
+
* @returns {number}
|
|
334
|
+
*/
|
|
335
|
+
export function liveInputTokens(harness, stdout, cacheReadWeight = 1) {
|
|
336
|
+
const usage = liveUsage(harness, stdout);
|
|
337
|
+
if (usage.inputTokens === null) return 0;
|
|
338
|
+
const weighted = usage.inputTokens + (usage.cacheReadInputTokens ?? 0) * cacheReadWeight;
|
|
339
|
+
return Math.round(weighted * 1000) / 1000;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Parse each JSONL line independently. A bounded transcript tail can start or
|
|
344
|
+
* end mid-line, so unparsable lines are skipped rather than failing the live
|
|
345
|
+
* observation.
|
|
346
|
+
*
|
|
347
|
+
* @param {string} stdout
|
|
348
|
+
*/
|
|
349
|
+
function parsedEvents(stdout) {
|
|
350
|
+
return String(stdout).split(/\r?\n/u).flatMap((line) => {
|
|
351
|
+
try {
|
|
352
|
+
return [JSON.parse(line)];
|
|
353
|
+
} catch {
|
|
354
|
+
return [];
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Codex item types whose completion proves one tool invocation. */
|
|
360
|
+
const CODEX_TOOL_ITEM_TYPES = new Set(["tool_call", "command_execution", "mcp_tool_call", "web_search", "file_change"]);
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Session evidence from a bounded live transcript: completed turns, cache-read
|
|
364
|
+
* input, tool invocations, and whether the harness's terminal record has been
|
|
365
|
+
* folded. Each harness exposes only what its own events prove, and anything
|
|
366
|
+
* unparsable or unsupported meters as zero — a live observation never throws.
|
|
367
|
+
*
|
|
368
|
+
* @param {string} harness
|
|
369
|
+
* @param {string} stdout bounded transcript tail
|
|
370
|
+
* @returns {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}}
|
|
371
|
+
*/
|
|
372
|
+
export function liveSessionMetrics(harness, stdout) {
|
|
373
|
+
const parser = new SessionMetricsParser(harness);
|
|
374
|
+
parser.push(String(stdout));
|
|
375
|
+
parser.flush();
|
|
376
|
+
return parser.metrics();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Retention bound for one streamed record: records at or below it parse whole. */
|
|
380
|
+
const SESSION_RECORD_MAX_BYTES = 64 * 1024;
|
|
381
|
+
|
|
382
|
+
/** Fragment evidence kept for a record that outgrew the retention bound. */
|
|
383
|
+
const SESSION_FRAGMENT_BYTES = SESSION_RECORD_MAX_BYTES / 2;
|
|
384
|
+
|
|
385
|
+
/** Claude-family content-block needle proving one tool invocation. */
|
|
386
|
+
const TOOL_USE_NEEDLE = Buffer.from('"type":"tool_use"', "utf8");
|
|
387
|
+
|
|
388
|
+
/** Cache-read evidence spellings across harness streams. */
|
|
389
|
+
const CACHE_READ_PATTERN = /"(?:cache_read_input_tokens|cached_input_tokens|cacheReadInputTokens)":(\d+)/gu;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Bounded incremental session-metrics parser: fold fixed-size chunks into
|
|
393
|
+
* running rotation totals without ever holding a buffer that scales with the
|
|
394
|
+
* unread transcript. Records within `SESSION_RECORD_MAX_BYTES` parse whole;
|
|
395
|
+
* a larger record keeps head and tail fragments plus streamed needle counts,
|
|
396
|
+
* so its turn and usage evidence still lands in the totals instead of being
|
|
397
|
+
* silently skipped.
|
|
398
|
+
*/
|
|
399
|
+
export class SessionMetricsParser {
|
|
400
|
+
/**
|
|
401
|
+
* @param {string} harness
|
|
402
|
+
* @param {{turns?: number, cacheReadInputTokens?: number, toolCalls?: number, completed?: boolean}} [previous]
|
|
403
|
+
*/
|
|
404
|
+
constructor(harness, previous = {}) {
|
|
405
|
+
this.harness = harness;
|
|
406
|
+
this.totals = {
|
|
407
|
+
turns: previous.turns ?? 0,
|
|
408
|
+
cacheReadInputTokens: previous.cacheReadInputTokens ?? 0,
|
|
409
|
+
toolCalls: previous.toolCalls ?? 0,
|
|
410
|
+
completed: previous.completed === true,
|
|
411
|
+
};
|
|
412
|
+
/** @type {string|null} */
|
|
413
|
+
this.continuationId = null;
|
|
414
|
+
/** @type {string|null} Most recent folded item-completed type, for the codex completion rule. */
|
|
415
|
+
this.lastItemType = null;
|
|
416
|
+
/** @type {string|null} Text of the most recent folded agent message, for the codex completion rule. */
|
|
417
|
+
this.lastAgentText = null;
|
|
418
|
+
/** @type {Buffer} */
|
|
419
|
+
this.pending = Buffer.alloc(0);
|
|
420
|
+
/** @type {{head: Buffer, tail: Buffer, streamedToolUse: number, carry: Buffer}|null} */
|
|
421
|
+
this.oversized = null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Fold every newline-terminated record in one chunk. A trailing partial
|
|
426
|
+
* record stays buffered (bounded) for the next chunk.
|
|
427
|
+
*
|
|
428
|
+
* @param {string|Buffer} chunk
|
|
429
|
+
*/
|
|
430
|
+
push(chunk) {
|
|
431
|
+
let data = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
432
|
+
while (data.length > 0) {
|
|
433
|
+
const newline = data.indexOf(10);
|
|
434
|
+
if (newline < 0) {
|
|
435
|
+
this.absorb(data);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.absorb(data.subarray(0, newline));
|
|
439
|
+
this.completeRecord();
|
|
440
|
+
data = data.subarray(newline + 1);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Fold the buffered partial record as if a newline had ended it. */
|
|
445
|
+
flush() {
|
|
446
|
+
if (this.pending.length > 0 || this.oversized) this.completeRecord();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** @returns {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}} */
|
|
450
|
+
metrics() {
|
|
451
|
+
return {
|
|
452
|
+
turns: this.totals.turns,
|
|
453
|
+
cacheReadInputTokens: this.totals.cacheReadInputTokens,
|
|
454
|
+
toolCalls: this.totals.toolCalls,
|
|
455
|
+
completed: this.totals.completed === true,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Retain one piece of a record still under assembly. Once the record
|
|
461
|
+
* outgrows the retention bound, only its head and a rolling tail are kept;
|
|
462
|
+
* the bytes leaving the tail are scanned for tool_use evidence instead of
|
|
463
|
+
* being buffered.
|
|
464
|
+
*
|
|
465
|
+
* @param {Buffer} piece
|
|
466
|
+
*/
|
|
467
|
+
absorb(piece) {
|
|
468
|
+
if (this.oversized) {
|
|
469
|
+
const window = Buffer.concat([this.oversized.tail, piece]);
|
|
470
|
+
const keep = window.subarray(Math.max(0, window.length - SESSION_FRAGMENT_BYTES));
|
|
471
|
+
const dropped = window.subarray(0, window.length - keep.length);
|
|
472
|
+
if (this.harness === "claude") {
|
|
473
|
+
const counted = countWithCarry(dropped, this.oversized.carry, TOOL_USE_NEEDLE);
|
|
474
|
+
this.oversized.streamedToolUse += counted.hits;
|
|
475
|
+
this.oversized.carry = counted.carry;
|
|
476
|
+
}
|
|
477
|
+
this.oversized.tail = keep;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (this.pending.length + piece.length <= SESSION_RECORD_MAX_BYTES) {
|
|
481
|
+
// Copy: `piece` may be a view of a scratch buffer the caller reuses for
|
|
482
|
+
// the next read, which would corrupt a record buffered mid-chunk.
|
|
483
|
+
this.pending = this.pending.length > 0 ? Buffer.concat([this.pending, piece]) : Buffer.from(piece);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const whole = Buffer.concat([this.pending, piece]);
|
|
487
|
+
this.pending = Buffer.alloc(0);
|
|
488
|
+
const head = whole.subarray(0, Math.min(SESSION_FRAGMENT_BYTES, whole.length));
|
|
489
|
+
const tail = whole.subarray(Math.max(0, whole.length - SESSION_FRAGMENT_BYTES));
|
|
490
|
+
/** @type {{head: Buffer, tail: Buffer, streamedToolUse: number, carry: Buffer}} */
|
|
491
|
+
const oversized = { head, tail, streamedToolUse: 0, carry: Buffer.alloc(0) };
|
|
492
|
+
if (this.harness === "claude") {
|
|
493
|
+
// Count from the record start up to where the rolling tail takes over,
|
|
494
|
+
// so a needle straddling any region boundary is counted exactly once.
|
|
495
|
+
const counted = countWithCarry(whole.subarray(0, Math.max(0, whole.length - tail.length)), oversized.carry, TOOL_USE_NEEDLE);
|
|
496
|
+
oversized.streamedToolUse = counted.hits;
|
|
497
|
+
oversized.carry = counted.carry;
|
|
498
|
+
}
|
|
499
|
+
this.oversized = oversized;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Fold the assembled record into the running totals. */
|
|
503
|
+
completeRecord() {
|
|
504
|
+
const oversized = this.oversized;
|
|
505
|
+
if (oversized) {
|
|
506
|
+
this.oversized = null;
|
|
507
|
+
/** @type {{head: string, tail: string, toolUse: number}} */
|
|
508
|
+
let fragments;
|
|
509
|
+
if (this.harness === "claude") {
|
|
510
|
+
const counted = countWithCarry(oversized.tail, oversized.carry, TOOL_USE_NEEDLE);
|
|
511
|
+
fragments = {
|
|
512
|
+
head: decodeFragment(oversized.head),
|
|
513
|
+
tail: decodeFragment(oversized.tail),
|
|
514
|
+
toolUse: oversized.streamedToolUse + counted.hits,
|
|
515
|
+
};
|
|
516
|
+
} else {
|
|
517
|
+
fragments = { head: decodeFragment(oversized.head), tail: decodeFragment(oversized.tail), toolUse: 0 };
|
|
518
|
+
}
|
|
519
|
+
foldFragmentRecord(this.harness, this.totals, fragments);
|
|
520
|
+
this.continuationId ??= fragmentContinuationId(this.harness, fragments);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const line = this.pending.toString("utf8");
|
|
524
|
+
this.pending = Buffer.alloc(0);
|
|
525
|
+
let event;
|
|
526
|
+
try {
|
|
527
|
+
event = JSON.parse(line);
|
|
528
|
+
} catch {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) return;
|
|
532
|
+
const record = /** @type {Record<string, unknown>} */ (event);
|
|
533
|
+
foldRecord(this.harness, this.totals, record);
|
|
534
|
+
this.continuationId ??= recordContinuationId(this.harness, record);
|
|
535
|
+
this.foldCompletionEvidence(record);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Fold the completion evidence one parsed record proves into the sticky
|
|
540
|
+
* totals. A harness is completed when its terminal record was folded; for
|
|
541
|
+
* codex that means a turn.completed that ends the turn with the
|
|
542
|
+
* result-carrying final agent message, so a live observation never treats a
|
|
543
|
+
* still-working or already-answered session ambiguously.
|
|
544
|
+
*
|
|
545
|
+
* @param {Record<string, unknown>} record
|
|
546
|
+
*/
|
|
547
|
+
foldCompletionEvidence(record) {
|
|
548
|
+
const totals = this.totals;
|
|
549
|
+
if (this.harness === "codex") {
|
|
550
|
+
if (record.type === "turn.completed" && this.lastItemType === "agent_message"
|
|
551
|
+
&& extractJson(this.lastAgentText) !== null) {
|
|
552
|
+
totals.completed = true;
|
|
553
|
+
}
|
|
554
|
+
} else if (this.harness === "claude" && record.type === "result") {
|
|
555
|
+
totals.completed = true;
|
|
556
|
+
} else if (this.harness === "exec-jsonl" && record.type === "run.completed") {
|
|
557
|
+
totals.completed = true;
|
|
558
|
+
} else if (this.harness === "replay" && typeof record.status === "string") {
|
|
559
|
+
// The replay envelope is the terminal record: the bin emits exactly one
|
|
560
|
+
// envelope line per invocation, so folding one proves completion.
|
|
561
|
+
totals.completed = true;
|
|
562
|
+
}
|
|
563
|
+
const item = eventItem(record);
|
|
564
|
+
if (record.type === "item.completed" && item) {
|
|
565
|
+
this.lastItemType = String(item.type ?? "");
|
|
566
|
+
this.lastAgentText = this.lastItemType === "agent_message" && typeof item.text === "string"
|
|
567
|
+
? item.text
|
|
568
|
+
: null;
|
|
569
|
+
} else {
|
|
570
|
+
this.lastItemType = null;
|
|
571
|
+
this.lastAgentText = null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Fold one parsed record into the running totals. Turn and tool counts are
|
|
578
|
+
* additive; cache-read is a running max for Codex (each turn.completed
|
|
579
|
+
* counter is already cumulative) and additive for claude-style streams until
|
|
580
|
+
* a terminal result event carries the authoritative session total.
|
|
581
|
+
*
|
|
582
|
+
* @param {string} harness
|
|
583
|
+
* @param {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}} totals
|
|
584
|
+
* @param {Record<string, unknown>} record
|
|
585
|
+
*/
|
|
586
|
+
function foldRecord(harness, totals, record) {
|
|
587
|
+
if (harness === "codex") {
|
|
588
|
+
if (record.type === "turn.completed") {
|
|
589
|
+
totals.turns += 1;
|
|
590
|
+
totals.cacheReadInputTokens = Math.max(totals.cacheReadInputTokens, canonicalUsage(record.usage).cacheReadInputTokens ?? 0);
|
|
591
|
+
} else if (record.type === "item.completed" && CODEX_TOOL_ITEM_TYPES.has(String(eventItem(record)?.type))) {
|
|
592
|
+
totals.toolCalls += 1;
|
|
593
|
+
}
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (harness === "claude") {
|
|
597
|
+
if (record.type === "assistant") {
|
|
598
|
+
const message = /** @type {Record<string, unknown>} */ (record.message ?? {});
|
|
599
|
+
totals.turns += 1;
|
|
600
|
+
totals.cacheReadInputTokens += canonicalUsage(message.usage).cacheReadInputTokens ?? 0;
|
|
601
|
+
totals.toolCalls += Array.isArray(message.content)
|
|
602
|
+
? message.content.filter((/** @type {{type?: unknown}} */ block) => block?.type === "tool_use").length
|
|
603
|
+
: 0;
|
|
604
|
+
} else if (record.type === "result") {
|
|
605
|
+
const sessionTotal = canonicalUsage(record.usage).cacheReadInputTokens;
|
|
606
|
+
if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
|
|
607
|
+
}
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (harness === "exec-jsonl" && record.type === "run.completed") {
|
|
611
|
+
// The protocol carries no tool events; only a completed run proves a turn.
|
|
612
|
+
totals.turns += 1;
|
|
613
|
+
totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
|
|
614
|
+
}
|
|
615
|
+
if (harness === "replay" && typeof record.status === "string") {
|
|
616
|
+
// A replayed envelope is the whole invocation: one completed turn, no
|
|
617
|
+
// tool events, usage only in the terminal record.
|
|
618
|
+
totals.turns += 1;
|
|
619
|
+
totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Fold the head-plus-tail fragments of one record that outgrew the retention
|
|
625
|
+
* bound: the same evidence foldRecord extracts, read as fragments so an
|
|
626
|
+
* oversized record is never silently skipped.
|
|
627
|
+
*
|
|
628
|
+
* @param {string} harness
|
|
629
|
+
* @param {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}} totals
|
|
630
|
+
* @param {{head: string, tail: string, toolUse: number}} fragments
|
|
631
|
+
*/
|
|
632
|
+
function foldFragmentRecord(harness, totals, fragments) {
|
|
633
|
+
const text = `${fragments.head}\n${fragments.tail}`;
|
|
634
|
+
if (harness === "claude") {
|
|
635
|
+
if (text.includes('"type":"assistant"')) {
|
|
636
|
+
totals.turns += 1;
|
|
637
|
+
totals.toolCalls += fragments.toolUse;
|
|
638
|
+
const cacheRead = lastCacheRead(text);
|
|
639
|
+
if (cacheRead !== null) totals.cacheReadInputTokens += cacheRead;
|
|
640
|
+
} else if (text.includes('"type":"result"')) {
|
|
641
|
+
const sessionTotal = lastCacheRead(text);
|
|
642
|
+
if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
|
|
643
|
+
totals.completed = true;
|
|
644
|
+
}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (harness === "codex") {
|
|
648
|
+
if (text.includes('"type":"turn.completed"')) {
|
|
649
|
+
totals.turns += 1;
|
|
650
|
+
const cacheRead = lastCacheRead(text);
|
|
651
|
+
totals.cacheReadInputTokens = Math.max(totals.cacheReadInputTokens, cacheRead ?? 0);
|
|
652
|
+
// Fragment approximation of the parsed-record completion rule: the
|
|
653
|
+
// turn ends with the final agent message when that message appears
|
|
654
|
+
// before the completed marker in the retained head and tail.
|
|
655
|
+
const agentAt = text.indexOf('"type":"agent_message"');
|
|
656
|
+
if (agentAt >= 0 && agentAt < text.indexOf('"type":"turn.completed"')) totals.completed = true;
|
|
657
|
+
} else if (text.includes('"type":"item.completed"') && [...CODEX_TOOL_ITEM_TYPES].some((type) => text.includes(`"type":"${type}"`))) {
|
|
658
|
+
totals.toolCalls += 1;
|
|
659
|
+
}
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (harness === "exec-jsonl" && text.includes('"type":"run.completed"')) {
|
|
663
|
+
totals.turns += 1;
|
|
664
|
+
const cacheRead = lastCacheRead(text);
|
|
665
|
+
if (cacheRead !== null) totals.cacheReadInputTokens += cacheRead;
|
|
666
|
+
totals.completed = true;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* The provider session identity one record proves.
|
|
672
|
+
*
|
|
673
|
+
* @param {string} harness
|
|
674
|
+
* @param {Record<string, unknown>} record
|
|
675
|
+
* @returns {string|null}
|
|
676
|
+
*/
|
|
677
|
+
function recordContinuationId(harness, record) {
|
|
678
|
+
if (harness === "codex") {
|
|
679
|
+
return record.type === "thread.started" && typeof record.thread_id === "string" ? record.thread_id : null;
|
|
680
|
+
}
|
|
681
|
+
if (harness === "claude") {
|
|
682
|
+
return record.type === "result" && typeof record.session_id === "string" ? record.session_id : null;
|
|
683
|
+
}
|
|
684
|
+
if (harness === "exec-jsonl" && (record.type === "run.started" || record.type === "run.completed")) {
|
|
685
|
+
return typeof record.continuationId === "string" ? record.continuationId : null;
|
|
686
|
+
}
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* The provider session identity one record's fragments prove.
|
|
692
|
+
*
|
|
693
|
+
* @param {string} harness
|
|
694
|
+
* @param {{head: string, tail: string}} fragments
|
|
695
|
+
* @returns {string|null}
|
|
696
|
+
*/
|
|
697
|
+
function fragmentContinuationId(harness, fragments) {
|
|
698
|
+
const text = `${fragments.head}\n${fragments.tail}`;
|
|
699
|
+
const pattern = harness === "codex"
|
|
700
|
+
? /"thread_id":"([^"]+)"/u
|
|
701
|
+
: harness === "claude"
|
|
702
|
+
? /"session_id":"([^"]+)"/u
|
|
703
|
+
: /"continuationId":"([^"]+)"/u;
|
|
704
|
+
const match = pattern.exec(text);
|
|
705
|
+
return match ? match[1] : null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Count needle occurrences in one region, keeping the trailing bytes that
|
|
710
|
+
* could complete a needle in the next region so a straddling needle is
|
|
711
|
+
* counted exactly once.
|
|
712
|
+
*
|
|
713
|
+
* @param {Buffer} region
|
|
714
|
+
* @param {Buffer} carry
|
|
715
|
+
* @param {Buffer} needle
|
|
716
|
+
* @returns {{hits: number, carry: Buffer}}
|
|
717
|
+
*/
|
|
718
|
+
function countWithCarry(region, carry, needle) {
|
|
719
|
+
const stream = carry.length > 0 ? Buffer.concat([carry, region]) : region;
|
|
720
|
+
return { hits: countNeedle(stream, needle), carry: stream.subarray(Math.max(0, stream.length - (needle.length - 1))) };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* @param {Buffer} haystack
|
|
725
|
+
* @param {Buffer} needle
|
|
726
|
+
* @returns {number}
|
|
727
|
+
*/
|
|
728
|
+
function countNeedle(haystack, needle) {
|
|
729
|
+
let hits = 0;
|
|
730
|
+
for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + needle.length)) hits += 1;
|
|
731
|
+
return hits;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Decode a retained fragment without splitting a UTF-8 sequence.
|
|
736
|
+
*
|
|
737
|
+
* @param {Buffer} fragment
|
|
738
|
+
* @returns {string}
|
|
739
|
+
*/
|
|
740
|
+
function decodeFragment(fragment) {
|
|
741
|
+
let start = 0;
|
|
742
|
+
while (start < fragment.length && (fragment[start] & 0xc0) === 0x80) start += 1;
|
|
743
|
+
return fragment.toString("utf8", start);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* The last cache-read number in a fragment text, or null.
|
|
748
|
+
*
|
|
749
|
+
* @param {string} text
|
|
750
|
+
* @returns {number|null}
|
|
751
|
+
*/
|
|
752
|
+
function lastCacheRead(text) {
|
|
753
|
+
const matches = [...text.matchAll(CACHE_READ_PATTERN)];
|
|
754
|
+
return matches.length > 0 ? Number(matches.at(-1)?.[1]) : null;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Bound one tool result to at most `maxBytes` UTF-8 bytes, keeping the head
|
|
759
|
+
* and the tail around an omission marker. This is the reference head+tail
|
|
760
|
+
* form the toolPolicy contract names; a cut never splits a UTF-8 sequence.
|
|
761
|
+
*
|
|
762
|
+
* @param {string} value
|
|
763
|
+
* @param {number} [maxBytes]
|
|
764
|
+
* @returns {string}
|
|
765
|
+
*/
|
|
766
|
+
export function truncateToolOutput(value, maxBytes = TOOL_OUTPUT_LIMIT_BYTES) {
|
|
767
|
+
const bytes = Buffer.from(String(value ?? ""), "utf8");
|
|
768
|
+
if (bytes.length <= maxBytes) return bytes.toString("utf8");
|
|
769
|
+
if (maxBytes < 192) {
|
|
770
|
+
// Too small to carry a head+tail marker: keep only a UTF-8-safe prefix.
|
|
771
|
+
let end = Math.max(0, maxBytes - 3);
|
|
772
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
|
|
773
|
+
return end > 0 ? `${bytes.subarray(0, end).toString("utf8")}…` : "";
|
|
774
|
+
}
|
|
775
|
+
// Reserve headroom for the marker so the bounded result can never exceed
|
|
776
|
+
// the limit regardless of how many digits the omission count needs.
|
|
777
|
+
const markerBudget = 96;
|
|
778
|
+
const headBudget = Math.floor((maxBytes - markerBudget) / 2);
|
|
779
|
+
const tailBudget = maxBytes - markerBudget - headBudget;
|
|
780
|
+
let headEnd = headBudget;
|
|
781
|
+
while (headEnd > 0 && (bytes[headEnd] & 0xc0) === 0x80) headEnd -= 1;
|
|
782
|
+
let tailStart = bytes.length - tailBudget;
|
|
783
|
+
while (tailStart < bytes.length && (bytes[tailStart] & 0xc0) === 0x80) tailStart += 1;
|
|
784
|
+
const head = bytes.subarray(0, headEnd);
|
|
785
|
+
const tail = bytes.subarray(tailStart);
|
|
786
|
+
const marker = `\n…[${bytes.length - head.length - tail.length} bytes truncated; narrow with grep or tail]…\n`;
|
|
787
|
+
return Buffer.concat([head, Buffer.from(marker, "utf8"), tail]).toString("utf8");
|
|
788
|
+
}
|