@forwardimpact/libharness 0.1.22 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -201
  2. package/README.md +196 -80
  3. package/bin/fit-benchmark.js +44 -0
  4. package/bin/fit-harness.js +358 -0
  5. package/bin/fit-selfedit.js +165 -0
  6. package/bin/fit-trace.js +510 -0
  7. package/package.json +41 -11
  8. package/src/agent-runner.js +256 -0
  9. package/src/benchmark/apm-installer.js +207 -0
  10. package/src/benchmark/env-loader.js +158 -0
  11. package/src/benchmark/hook-env.js +40 -0
  12. package/src/benchmark/invariants.js +141 -0
  13. package/src/benchmark/judge.js +187 -0
  14. package/src/benchmark/npm-installer.js +87 -0
  15. package/src/benchmark/report.js +604 -0
  16. package/src/benchmark/result.js +127 -0
  17. package/src/benchmark/runner.js +688 -0
  18. package/src/benchmark/scheduler.js +78 -0
  19. package/src/benchmark/task-family.js +260 -0
  20. package/src/benchmark/workdir.js +344 -0
  21. package/src/commands/assert.js +153 -0
  22. package/src/commands/benchmark-definition.js +175 -0
  23. package/src/commands/benchmark-invariants.js +73 -0
  24. package/src/commands/benchmark-report.js +51 -0
  25. package/src/commands/benchmark-run.js +175 -0
  26. package/src/commands/by-discussion.js +94 -0
  27. package/src/commands/callback.js +119 -0
  28. package/src/commands/discuss.js +132 -0
  29. package/src/commands/facilitate.js +123 -0
  30. package/src/commands/output.js +36 -0
  31. package/src/commands/run.js +152 -0
  32. package/src/commands/supervise.js +136 -0
  33. package/src/commands/task-input.js +54 -0
  34. package/src/commands/tee.js +53 -0
  35. package/src/commands/trace.js +630 -0
  36. package/src/commands/work-tracker.js +35 -0
  37. package/src/cost.js +79 -0
  38. package/src/discuss-tools.js +173 -0
  39. package/src/discusser.js +394 -0
  40. package/src/events/github.js +161 -0
  41. package/src/facilitator.js +205 -0
  42. package/src/inbox-poller.js +81 -0
  43. package/src/index.js +72 -2
  44. package/src/judge.js +210 -0
  45. package/src/message-bus.js +118 -0
  46. package/src/orchestration-loop.js +330 -0
  47. package/src/orchestration-toolkit.js +441 -0
  48. package/src/orchestrator-helpers.js +23 -0
  49. package/src/profile-prompt.js +266 -0
  50. package/src/redaction.js +253 -0
  51. package/src/render/line-renderer.js +54 -0
  52. package/src/render/orchestrator-filter.js +19 -0
  53. package/src/render/palette.js +63 -0
  54. package/src/render/tool-hints.js +154 -0
  55. package/src/render/turn-renderer.js +96 -0
  56. package/src/reply-emitter.js +47 -0
  57. package/src/sequence-counter.js +21 -0
  58. package/src/signature-filter.js +27 -0
  59. package/src/supervisor.js +236 -0
  60. package/src/tee-writer.js +150 -0
  61. package/src/trace-collector.js +444 -0
  62. package/src/trace-github.js +473 -0
  63. package/src/trace-multi.js +101 -0
  64. package/src/trace-query.js +748 -0
  65. package/src/trace-render.js +211 -0
  66. package/src/trace-usage.js +249 -0
  67. package/src/fixture/assertions.js +0 -42
  68. package/src/fixture/cache.js +0 -50
  69. package/src/fixture/eval.js +0 -146
  70. package/src/fixture/index.js +0 -9
  71. package/src/fixture/pathway.js +0 -451
  72. package/src/fixture/services.js +0 -56
  73. package/src/mock/clients.js +0 -135
  74. package/src/mock/config.js +0 -45
  75. package/src/mock/data.js +0 -46
  76. package/src/mock/fs.js +0 -111
  77. package/src/mock/grpc.js +0 -94
  78. package/src/mock/http.js +0 -60
  79. package/src/mock/index.js +0 -36
  80. package/src/mock/infra.js +0 -219
  81. package/src/mock/logger.js +0 -42
  82. package/src/mock/observer.js +0 -74
  83. package/src/mock/resource-index.js +0 -95
  84. package/src/mock/service-callbacks.js +0 -39
  85. package/src/mock/services.js +0 -79
  86. package/src/mock/spy.js +0 -44
  87. package/src/mock/storage.js +0 -118
@@ -0,0 +1,150 @@
1
+ /**
2
+ * TeeWriter — a Writable stream that writes raw NDJSON to a file while
3
+ * simultaneously streaming human-readable text to a separate stream (e.g.
4
+ * process.stdout).
5
+ *
6
+ * All modes emit the same { source, seq, event } envelope. The `mode`
7
+ * parameter controls display formatting: multi-participant modes show
8
+ * source labels on content lines.
9
+ *
10
+ * Human text rendering is delegated to the pure modules under `./render/`
11
+ * so the live stream and the offline `TraceCollector.toText()` replay share
12
+ * one formatting path. The NDJSON going to `fileStream` is
13
+ * untouched — only what reaches `textStream` changes.
14
+ *
15
+ * Follows OO+DI: constructor injection, factory function, tests bypass factory.
16
+ */
17
+
18
+ import { Writable } from "node:stream";
19
+ import { TraceCollector } from "./trace-collector.js";
20
+ import { renderTurnLines } from "./render/turn-renderer.js";
21
+ import { isSuppressedOrchestratorEvent } from "./render/orchestrator-filter.js";
22
+
23
+ /** Writable stream that saves raw NDJSON to a file while streaming human-readable text to a display stream. */
24
+ export class TeeWriter extends Writable {
25
+ /**
26
+ * @param {object} deps
27
+ * @param {import("stream").Writable} deps.fileStream - Stream to write raw NDJSON to
28
+ * @param {import("stream").Writable} deps.textStream - Stream to write human-readable text to
29
+ * @param {"raw"|"supervised"} [deps.mode] - Display mode: "raw" (no source labels) or "supervised" (source labels) (default: "raw")
30
+ * @param {function} [deps.now] - Injected ISO-timestamp source threaded into
31
+ * the internal `TraceCollector` (`() => isoTimestamp(runtime.clock.now())`).
32
+ */
33
+ constructor({ fileStream, textStream, mode, now }) {
34
+ super();
35
+ if (!fileStream) throw new Error("fileStream is required");
36
+ if (!textStream) throw new Error("textStream is required");
37
+ this.fileStream = fileStream;
38
+ this.textStream = textStream;
39
+ this.mode = mode ?? "raw";
40
+ this.collector = new TraceCollector({ now });
41
+ this.turnsEmitted = 0;
42
+ }
43
+
44
+ /**
45
+ * @param {Buffer|string} chunk
46
+ * @param {string} encoding
47
+ * @param {function} callback
48
+ */
49
+ _write(chunk, encoding, callback) {
50
+ const str = (this.partial ?? "") + chunk.toString();
51
+ const lines = str.split("\n");
52
+ this.partial = lines.pop() ?? "";
53
+
54
+ for (const line of lines) {
55
+ if (!line.trim()) continue;
56
+ this.fileStream.write(line + "\n");
57
+ this.processLine(line);
58
+ }
59
+ callback();
60
+ }
61
+
62
+ /**
63
+ * @param {function} callback
64
+ */
65
+ _final(callback) {
66
+ if (this.partial && this.partial.trim()) {
67
+ this.fileStream.write(this.partial + "\n");
68
+ this.processLine(this.partial);
69
+ }
70
+
71
+ // Emit the trailing `--- Result: ... ---` footer — the one summary line
72
+ // humans want. This is the same tail TraceCollector.toText()
73
+ // appends, so the live stream and the offline replay stay in sync.
74
+ // The superseded `--- Evaluation ... ---` footer is gone in every mode.
75
+ if (this.collector.result) {
76
+ const text = this.collector.toText();
77
+ const idx = text.lastIndexOf("\n---");
78
+ if (idx !== -1) {
79
+ // Slice past the leading `\n` — the previously-streamed body
80
+ // already ended with its own newline, so re-emitting `\n---` here
81
+ // would produce a blank line before the footer and desync from
82
+ // the offline replay.
83
+ this.textStream.write(text.slice(idx + 1) + "\n");
84
+ }
85
+ }
86
+
87
+ callback();
88
+ }
89
+
90
+ /**
91
+ * Process a single NDJSON line — unified envelope handling for all modes.
92
+ * @param {string} line
93
+ */
94
+ processLine(line) {
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(line);
98
+ } catch {
99
+ return;
100
+ }
101
+
102
+ // Universal envelope: { source, seq, event }
103
+ if (parsed.event) {
104
+ // Always forward to the collector so it can capture orchestrator
105
+ // metadata (e.g. the summary verdict for the result footer); the
106
+ // collector adds no turn for suppressed events, so flushTurns stays
107
+ // a no-op when we skip it below.
108
+ this.collector.addLine(line);
109
+
110
+ // Orchestrator lifecycle events are suppressed from the text stream
111
+ // entirely — humans only want agent-visible content. They still
112
+ // reached fileStream above.
113
+ if (
114
+ parsed.source === "orchestrator" &&
115
+ isSuppressedOrchestratorEvent(parsed.event)
116
+ ) {
117
+ return;
118
+ }
119
+ this.flushTurns();
120
+ return;
121
+ }
122
+
123
+ // Bare event (unwrapped run mode line or direct feed)
124
+ this.collector.addLine(line);
125
+ this.flushTurns();
126
+ }
127
+
128
+ /**
129
+ * Emit text for any new turns accumulated by the collector.
130
+ */
131
+ flushTurns() {
132
+ const turns = this.collector.turns;
133
+ const withPrefix = this.mode !== "raw";
134
+ while (this.turnsEmitted < turns.length) {
135
+ const turn = turns[this.turnsEmitted++];
136
+ for (const line of renderTurnLines(turn, withPrefix)) {
137
+ this.textStream.write(line);
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Factory function — wires a TeeWriter with the given streams.
145
+ * @param {object} deps - Same as TeeWriter constructor
146
+ * @returns {TeeWriter}
147
+ */
148
+ export function createTeeWriter(deps) {
149
+ return new TeeWriter(deps);
150
+ }
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Collects Claude Code stream-json NDJSON events into structured traces.
3
+ *
4
+ * Accepts one NDJSON line at a time via addLine(), then produces either a
5
+ * structured JSON trace (toJSON) or human-readable text (toText).
6
+ *
7
+ * Human text rendering is delegated to the pure modules under `./render/`
8
+ * so the live `TeeWriter` stream and the offline `toText()` replay share
9
+ * one formatting path.
10
+ */
11
+
12
+ import { isoTimestamp } from "@forwardimpact/libutil";
13
+
14
+ import { renderTurnLines } from "./render/turn-renderer.js";
15
+ import { isSuppressedOrchestratorEvent } from "./render/orchestrator-filter.js";
16
+
17
+ /** Accumulate Claude Code NDJSON stream events into structured traces for analysis or text replay. */
18
+ export class TraceCollector {
19
+ /**
20
+ * @param {object} [deps]
21
+ * @param {function} [deps.now] - Returns an ISO timestamp string. Injected
22
+ * so the collector never reads the wall clock directly; construct it as
23
+ * `() => isoTimestamp(runtime.clock.now())`. When omitted (pure
24
+ * structural/replay use where every event already carries a `timestamp`),
25
+ * the fallback formats the epoch — a deterministic sentinel, not a clock
26
+ * read.
27
+ */
28
+ constructor(deps = {}) {
29
+ /** @type {function} */
30
+ this.now = deps.now ?? (() => isoTimestamp(0));
31
+ /** @type {object|null} */
32
+ this.metadata = null;
33
+ /** @type {Array<object>} */
34
+ this.turns = [];
35
+ /** @type {object|null} */
36
+ this.result = null;
37
+ /** @type {{verdict?: string, summary?: string, turns?: number}|null} */
38
+ this.orchestratorSummary = null;
39
+ /** @type {number} */
40
+ this.turnIndex = 0;
41
+ /** @type {object|null} */
42
+ this.initEvent = null;
43
+ }
44
+
45
+ /**
46
+ * Parse one NDJSON line and accumulate state.
47
+ * Malformed lines are silently skipped.
48
+ * @param {string} line - A single JSON line from stream-json output
49
+ */
50
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: NDJSON envelope unwrap + orchestrator/system/assistant/user dispatch
51
+ addLine(line) {
52
+ const trimmed = line.trim();
53
+ if (!trimmed) return;
54
+
55
+ let event;
56
+ try {
57
+ event = JSON.parse(trimmed);
58
+ } catch {
59
+ return;
60
+ }
61
+
62
+ // Unwrap combined supervised trace format {source, seq, event}. The
63
+ // Supervisor / Facilitator emits this wrapper; when replayed through
64
+ // addLine the inner event is the one we care about. Carry the envelope
65
+ // `source` onto each new turn so the renderer can color it correctly.
66
+ let source = null;
67
+ if (event.event && !event.type && typeof event.source === "string") {
68
+ source = event.source;
69
+ event = event.event;
70
+ }
71
+
72
+ // Orchestrator lifecycle events carry no content and are suppressed
73
+ // from turns entirely — the NDJSON artifact keeps them separately.
74
+ if (source === "orchestrator" && isSuppressedOrchestratorEvent(event)) {
75
+ // The summary event carries the supervisor/facilitator verdict —
76
+ // capture it before dropping the event, so the result footer can
77
+ // surface verdict="failure" instead of the SDK's per-runner status.
78
+ if (event.type === "summary") {
79
+ this.orchestratorSummary = {
80
+ ...(event.verdict && { verdict: event.verdict }),
81
+ ...(typeof event.summary === "string" && { summary: event.summary }),
82
+ ...(typeof event.turns === "number" && { turns: event.turns }),
83
+ };
84
+ }
85
+ if (event.type === "meta" && typeof event.discussion_id === "string") {
86
+ this.discussionId = event.discussion_id;
87
+ }
88
+ return;
89
+ }
90
+
91
+ switch (event.type) {
92
+ case "system":
93
+ this.handleSystem(event, source);
94
+ break;
95
+ case "assistant":
96
+ this.handleAssistant(event, source);
97
+ break;
98
+ case "user":
99
+ this.handleUser(event, source);
100
+ break;
101
+ case "result":
102
+ this.handleResult(event);
103
+ break;
104
+ default:
105
+ break;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * @param {object} event
111
+ * @param {string|null} source
112
+ */
113
+ handleSystem(event, source) {
114
+ const { type: _type, ...payload } = event;
115
+
116
+ if (event.subtype === "init") {
117
+ this.metadata = {
118
+ timestamp: event.timestamp ?? this.now(),
119
+ sessionId: event.session_id ?? null,
120
+ model: event.model ?? null,
121
+ claudeCodeVersion: event.claude_code_version ?? null,
122
+ tools: event.tools ?? [],
123
+ permissionMode: event.permissionMode ?? null,
124
+ };
125
+ this.initEvent = payload;
126
+ }
127
+
128
+ this.turns.push({
129
+ index: this.turnIndex++,
130
+ role: "system",
131
+ source,
132
+ subtype: event.subtype ?? null,
133
+ data: payload,
134
+ });
135
+ }
136
+
137
+ /**
138
+ * @param {object} event
139
+ * @param {string|null} source
140
+ */
141
+ handleAssistant(event, source) {
142
+ const message = event.message;
143
+ if (!message) return;
144
+
145
+ const content = (message.content ?? []).map((block) => {
146
+ if (block.type === "text") {
147
+ return { type: "text", text: block.text };
148
+ }
149
+ if (block.type === "tool_use") {
150
+ return {
151
+ type: "tool_use",
152
+ toolUseId: block.id ?? null,
153
+ name: block.name,
154
+ input: block.input,
155
+ };
156
+ }
157
+ return block;
158
+ });
159
+
160
+ const usage = message.usage
161
+ ? {
162
+ inputTokens: message.usage.input_tokens ?? 0,
163
+ outputTokens: message.usage.output_tokens ?? 0,
164
+ cacheReadInputTokens: message.usage.cache_read_input_tokens ?? 0,
165
+ cacheCreationInputTokens:
166
+ message.usage.cache_creation_input_tokens ?? 0,
167
+ }
168
+ : null;
169
+
170
+ this.turns.push({
171
+ index: this.turnIndex++,
172
+ role: "assistant",
173
+ source,
174
+ messageId: message.id ?? null,
175
+ content,
176
+ usage,
177
+ });
178
+ }
179
+
180
+ /**
181
+ * @param {object} event
182
+ * @param {string|null} source
183
+ */
184
+ handleUser(event, source) {
185
+ const message = event.message;
186
+ if (!message) return;
187
+
188
+ const contentItems = message.content;
189
+ if (!Array.isArray(contentItems)) return;
190
+
191
+ const textBlocks = contentItems
192
+ .filter((item) => item.type === "text")
193
+ .map((item) => ({ type: "text", text: item.text }));
194
+
195
+ if (textBlocks.length > 0) {
196
+ this.turns.push({
197
+ index: this.turnIndex++,
198
+ role: "user",
199
+ source,
200
+ content: textBlocks,
201
+ });
202
+ }
203
+
204
+ for (const item of contentItems) {
205
+ if (item.type === "tool_result") {
206
+ this.turns.push({
207
+ index: this.turnIndex++,
208
+ role: "tool_result",
209
+ source,
210
+ toolUseId: item.tool_use_id ?? null,
211
+ content:
212
+ typeof item.content === "string"
213
+ ? item.content
214
+ : JSON.stringify(item.content),
215
+ isError: item.is_error ?? false,
216
+ });
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Accumulate a result event into the running summary. Facilitated and
223
+ * supervised sessions emit one result event per runner invocation, so a
224
+ * single trace can carry several — cost, duration, turn, and token
225
+ * figures sum across all of them. `result` reflects the latest event;
226
+ * `isError` is true once any event errored.
227
+ * @param {object} event
228
+ */
229
+ handleResult(event) {
230
+ const prev = this.result ?? EMPTY_RESULT;
231
+
232
+ this.result = {
233
+ result: event.subtype ?? "unknown",
234
+ isError: prev.isError || (event.is_error ?? false),
235
+ totalCostUsd: prev.totalCostUsd + (event.total_cost_usd ?? 0),
236
+ durationMs: prev.durationMs + (event.duration_ms ?? 0),
237
+ numTurns: prev.numTurns + (event.num_turns ?? 0),
238
+ tokenUsage: sumTokenUsage(prev.tokenUsage, normalizeUsage(event.usage)),
239
+ modelUsage: mergeModelUsage(prev.modelUsage, event.modelUsage),
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Return a structured trace object for offline analysis.
245
+ * @returns {object} Structured trace document
246
+ */
247
+ toJSON() {
248
+ return {
249
+ version: "1.2.0",
250
+ metadata: this.metadata ?? {
251
+ timestamp: this.now(),
252
+ sessionId: null,
253
+ model: null,
254
+ claudeCodeVersion: null,
255
+ tools: [],
256
+ permissionMode: null,
257
+ },
258
+ initEvent: this.initEvent ?? null,
259
+ turns: this.turns,
260
+ summary: this.result ?? {
261
+ result: "unknown",
262
+ isError: false,
263
+ totalCostUsd: 0,
264
+ durationMs: 0,
265
+ numTurns: 0,
266
+ tokenUsage: null,
267
+ modelUsage: null,
268
+ },
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Render the accumulated turns as human-readable text — the same path the
274
+ * live `TeeWriter` stream uses, so `fit-harness output --format=text` over a
275
+ * captured trace reproduces what the live workflow log showed.
276
+ *
277
+ * Source prefixes are emitted whenever at least one turn has a non-null
278
+ * source (supervised / facilitated traces). A pure `run` trace has no
279
+ * envelope, all turn sources are null, and the renderer drops the prefix.
280
+ *
281
+ * @returns {string} Formatted text output including ANSI escapes
282
+ */
283
+ toText() {
284
+ const withPrefix = this.turns.some((t) => t.source);
285
+ const out = [];
286
+
287
+ for (const turn of this.turns) {
288
+ out.push(...renderTurnLines(turn, withPrefix));
289
+ }
290
+
291
+ const tail = this.#formatResultTail();
292
+
293
+ // Each rendered line already ends with `\n`; concatenate, drop the
294
+ // trailing newline, then append the tail so the output shape stays
295
+ // compatible with existing consumers (no double-blank line before
296
+ // the result footer when there are turns, no leading blank when there
297
+ // are not).
298
+ const body = out.join("").replace(/\n$/, "");
299
+ return body + tail;
300
+ }
301
+
302
+ /**
303
+ * Format the trailing result summary line. When an orchestrator
304
+ * summary is present (supervised / facilitated mode), the headline word is
305
+ * the supervisor's verdict ("success" / "failure") rather than the SDK's
306
+ * per-runner subtype, so the footer aligns with the CI exit code. Turn,
307
+ * cost, and duration figures are the accumulated totals across every
308
+ * result event in the trace, not the last event's.
309
+ * @returns {string}
310
+ */
311
+ #formatResultTail() {
312
+ if (!this.result) return "";
313
+ const duration = formatDuration(this.result.durationMs);
314
+ const cost = Number(this.result.totalCostUsd).toFixed(4);
315
+ const headline = this.orchestratorSummary?.verdict ?? this.result.result;
316
+ return (
317
+ "\n" +
318
+ `--- Result: ${headline} | Turns: ${this.result.numTurns} | Cost: $${cost} | Duration: ${duration} ---`
319
+ );
320
+ }
321
+ }
322
+
323
+ /** Identity element for result-event accumulation in handleResult. */
324
+ const EMPTY_RESULT = {
325
+ isError: false,
326
+ totalCostUsd: 0,
327
+ durationMs: 0,
328
+ numTurns: 0,
329
+ tokenUsage: null,
330
+ modelUsage: null,
331
+ };
332
+
333
+ /**
334
+ * Normalize an SDK snake_case usage block to camelCase token fields.
335
+ * @param {object|null|undefined} usage
336
+ * @returns {object|null}
337
+ */
338
+ function normalizeUsage(usage) {
339
+ if (!usage) return null;
340
+ return {
341
+ inputTokens: usage.input_tokens ?? 0,
342
+ outputTokens: usage.output_tokens ?? 0,
343
+ cacheReadInputTokens: usage.cache_read_input_tokens ?? 0,
344
+ cacheCreationInputTokens: usage.cache_creation_input_tokens ?? 0,
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Sum two token-usage records field-by-field. Either side may be null
350
+ * (a result event without usage); the sum is null only when both are.
351
+ * @param {object|null} a
352
+ * @param {object|null} b
353
+ * @returns {object|null}
354
+ */
355
+ function sumTokenUsage(a, b) {
356
+ if (!a) return b;
357
+ if (!b) return a;
358
+ return {
359
+ inputTokens: a.inputTokens + b.inputTokens,
360
+ outputTokens: a.outputTokens + b.outputTokens,
361
+ cacheReadInputTokens: a.cacheReadInputTokens + b.cacheReadInputTokens,
362
+ cacheCreationInputTokens:
363
+ a.cacheCreationInputTokens + b.cacheCreationInputTokens,
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Per-model fields that sum additively across result events — token counts,
369
+ * per-model cost, and request counters. Every other per-model field (e.g. a
370
+ * context-window size) is carried first-seen, never summed.
371
+ */
372
+ const ADDITIVE_MODEL_FIELDS = [
373
+ "inputTokens",
374
+ "outputTokens",
375
+ "cacheReadInputTokens",
376
+ "cacheCreationInputTokens",
377
+ "costUSD",
378
+ "webSearchRequests",
379
+ ];
380
+
381
+ /**
382
+ * Merge two per-model usage maps across result events. Additive fields
383
+ * (token counts, cost, request counters) sum; non-additive fields are carried
384
+ * from the first event that set them (prev wins). Either side may be null.
385
+ * @param {object|null} prevMU
386
+ * @param {object|null} nextMU
387
+ * @returns {object|null}
388
+ */
389
+ function mergeModelUsage(prevMU, nextMU) {
390
+ if (!prevMU) return nextMU ?? null;
391
+ if (!nextMU) return prevMU;
392
+
393
+ const merged = {};
394
+ for (const model of new Set([
395
+ ...Object.keys(prevMU),
396
+ ...Object.keys(nextMU),
397
+ ])) {
398
+ merged[model] = mergeOneModel(prevMU[model] ?? {}, nextMU[model] ?? {});
399
+ }
400
+ return merged;
401
+ }
402
+
403
+ /**
404
+ * Merge one model's usage: additive fields sum, others carry first-seen (a).
405
+ * @param {object} a - First-seen (prev) per-model usage.
406
+ * @param {object} b - Next per-model usage.
407
+ * @returns {object}
408
+ */
409
+ function mergeOneModel(a, b) {
410
+ const entry = { ...a, ...b };
411
+ for (const field of ADDITIVE_MODEL_FIELDS) {
412
+ if (field in a || field in b) {
413
+ entry[field] = (a[field] ?? 0) + (b[field] ?? 0);
414
+ }
415
+ }
416
+ for (const field of Object.keys(a)) {
417
+ if (!ADDITIVE_MODEL_FIELDS.includes(field)) entry[field] = a[field];
418
+ }
419
+ return entry;
420
+ }
421
+
422
+ /**
423
+ * Format milliseconds into a human-readable duration.
424
+ * @param {number} ms - Duration in milliseconds
425
+ * @returns {string} Formatted duration
426
+ */
427
+ function formatDuration(ms) {
428
+ if (ms < 1000) return `${ms}ms`;
429
+ const seconds = Math.round(ms / 1000);
430
+ if (seconds < 60) return `${seconds}s`;
431
+ const minutes = Math.floor(seconds / 60);
432
+ const remainingSeconds = seconds % 60;
433
+ return `${minutes}m ${remainingSeconds}s`;
434
+ }
435
+
436
+ /**
437
+ * Factory function for TraceCollector.
438
+ * @param {object} [deps]
439
+ * @param {function} [deps.now] - Returns ISO timestamp string
440
+ * @returns {TraceCollector}
441
+ */
442
+ export function createTraceCollector(deps) {
443
+ return new TraceCollector(deps);
444
+ }