@forwardimpact/libeval 0.1.64 → 0.1.65

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.
@@ -1,3 +1,13 @@
1
+ import {
2
+ ZERO_USAGE,
3
+ bucketUsageByTool,
4
+ carriedPerTurn,
5
+ computeDivergence,
6
+ isPreChangeDoc,
7
+ perMessageUsage,
8
+ reconcileBucketsToTotals,
9
+ } from "./trace-usage.js";
10
+
1
11
  /**
2
12
  * Query engine for structured trace documents produced by TraceCollector.
3
13
  *
@@ -367,149 +377,131 @@ export class TraceQuery {
367
377
  divergence: null,
368
378
  };
369
379
  }
370
- }
371
380
 
372
- /** Zero-valued token usage, used as the carried-document fallback. */
373
- const ZERO_USAGE = {
374
- inputTokens: 0,
375
- outputTokens: 0,
376
- cacheReadInputTokens: 0,
377
- cacheCreationInputTokens: 0,
378
- };
381
+ /**
382
+ * One record per `tool_use` block, each paired with its `tool_result`
383
+ * (joined by `toolUseId`) or `result: null` for orphaned calls.
384
+ * @returns {Array<{turnIndex: number, name: string, toolUseId: string, input: object, result: {content: *, isError: boolean}|null}>}
385
+ */
386
+ toolCalls() {
387
+ const blocks = collectToolUseBlocks(this.turns);
388
+ const results = new Map();
389
+ for (const turn of this.turns) {
390
+ if (turn.role === "tool_result" && turn.toolUseId) {
391
+ results.set(turn.toolUseId, {
392
+ content: turn.content ?? null,
393
+ isError: turn.isError ?? false,
394
+ });
395
+ }
396
+ }
397
+ return [...blocks.entries()].map(([toolUseId, b]) => ({
398
+ turnIndex: b.turnIndex,
399
+ name: b.name,
400
+ toolUseId,
401
+ input: b.input,
402
+ result: results.get(toolUseId) ?? null,
403
+ }));
404
+ }
379
405
 
380
- /**
381
- * Per-stream-event breakdown for a pre-change document, labeled as carried
382
- * old documents lack message identity, so rows stay keyed by turn index.
383
- * @param {object[]} turns
384
- * @returns {object[]}
385
- */
386
- function carriedPerTurn(turns) {
387
- const perTurn = [];
388
- for (const turn of turns) {
389
- if (turn.role !== "assistant" || !turn.usage) continue;
390
- perTurn.push({
391
- index: turn.index,
392
- inputTokens: turn.usage.inputTokens ?? 0,
393
- outputTokens: turn.usage.outputTokens ?? 0,
394
- cacheReadInputTokens: turn.usage.cacheReadInputTokens ?? 0,
395
- cacheCreationInputTokens: turn.usage.cacheCreationInputTokens ?? 0,
396
- population: "carried-document-per-turn",
397
- });
406
+ /**
407
+ * One record per `Bash` `tool_use` block, carrying its command text.
408
+ * @param {string} [re] - Optional regex source tested against `input.command`.
409
+ * @returns {Array<{turnIndex: number, toolUseId: string, command: string}>}
410
+ */
411
+ commands(re) {
412
+ const filter = re === undefined ? null : new RegExp(re);
413
+ const out = [];
414
+ for (const [toolUseId, b] of collectToolUseBlocks(this.turns, "Bash")) {
415
+ const command = b.input?.command ?? "";
416
+ if (filter && !filter.test(command)) continue;
417
+ out.push({ turnIndex: b.turnIndex, toolUseId, command });
418
+ }
419
+ return out;
398
420
  }
399
- return perTurn;
400
- }
401
421
 
402
- /**
403
- * Whether a structured-document version predates per-message accounting
404
- * (1.2.0). A trace with no version (collected by this build from NDJSON) is
405
- * not pre-change. Compares numeric version parts so 1.10.0 reads as post-change.
406
- * @param {string|undefined|null} version
407
- * @returns {boolean}
408
- */
409
- function isPreChangeDoc(version) {
410
- if (typeof version !== "string") return false;
411
- const [major = 0, minor = 0] = version
412
- .split(".")
413
- .map((part) => parseInt(part, 10) || 0);
414
- if (major !== 1) return major < 1;
415
- // Per-message accounting arrived in 1.2.0; any 1.2.x is post-change.
416
- return minor < 2;
417
- }
422
+ /**
423
+ * Distinct `file_path` arguments across `Read`/`Edit`/`Write` tool calls,
424
+ * frequency-sorted (count desc, path asc tiebreak).
425
+ * @param {string} [prefix] - Optional `startsWith` filter.
426
+ * @returns {Array<{path: string, count: number}>}
427
+ */
428
+ paths(prefix) {
429
+ return [...collectFilePaths(this.turns).entries()]
430
+ .filter(([path]) => prefix === undefined || path.startsWith(prefix))
431
+ .map(([path, count]) => ({ path, count }))
432
+ .sort((a, b) => b.count - a.count || a.path.localeCompare(b.path));
433
+ }
418
434
 
419
- /**
420
- * Account assistant usage once per API message. Turns are grouped by
421
- * `messageId` (a null id is its own singleton message); per message the
422
- * field-wise max across its snapshots is taken — order-insensitive, equal to
423
- * the single value when a message's duplicate snapshots are byte-identical
424
- * (zero residual against result-event sums), and a floor for output (the
425
- * largest streaming snapshot, never an overstatement).
426
- * @param {object[]} turns
427
- * @returns {{perMessage: object[], totals: object}}
428
- */
429
- function perMessageUsage(turns) {
430
- const byMessage = new Map();
431
- let singletonSeq = 0;
435
+ /**
436
+ * Side-by-side comparison of this trace against another peer `TraceQuery`.
437
+ * Identity (case name, participant) comes from the caller the trace
438
+ * carries no filename.
439
+ * @param {TraceQuery} other
440
+ * @param {{aIdentity: {caseName: string, participant: string|null}, bIdentity: {caseName: string, participant: string|null}}} identities
441
+ * @returns {{a: object, b: object, toolDelta: Array, pathDelta: Array}}
442
+ */
443
+ compare(other, { aIdentity, bIdentity } = {}) {
444
+ const a = sideSummary(this, aIdentity);
445
+ const b = sideSummary(other, bIdentity);
446
+
447
+ const toolNames = [
448
+ ...new Set([...a.toolFreq.keys(), ...b.toolFreq.keys()]),
449
+ ];
450
+ const toolDelta = toolNames
451
+ .map((tool) => {
452
+ const av = a.toolFreq.get(tool) ?? 0;
453
+ const bv = b.toolFreq.get(tool) ?? 0;
454
+ return { tool, a: av, b: bv, diff: bv - av };
455
+ })
456
+ .sort(
457
+ (x, y) =>
458
+ Math.abs(y.diff) - Math.abs(x.diff) || x.tool.localeCompare(y.tool),
459
+ );
432
460
 
433
- for (const turn of turns) {
434
- if (turn.role !== "assistant" || !turn.usage) continue;
435
- const key = turn.messageId ?? `__null__${singletonSeq++}`;
436
- accumulateMessage(byMessage, key, turn);
437
- }
461
+ const pathNames = [
462
+ ...new Set([...a.pathFreq.keys(), ...b.pathFreq.keys()]),
463
+ ];
464
+ const pathDelta = pathNames
465
+ .map((path) => {
466
+ const av = a.pathFreq.get(path) ?? 0;
467
+ const bv = b.pathFreq.get(path) ?? 0;
468
+ return { path, a: av, b: bv, diff: bv - av };
469
+ })
470
+ .sort(
471
+ (x, y) =>
472
+ Math.abs(y.diff) - Math.abs(x.diff) || x.path.localeCompare(y.path),
473
+ );
438
474
 
439
- const totals = {
440
- inputTokens: 0,
441
- outputTokens: 0,
442
- cacheReadInputTokens: 0,
443
- cacheCreationInputTokens: 0,
444
- };
445
- const perMessage = [];
446
- for (const row of byMessage.values()) {
447
- totals.inputTokens += row.inputTokens;
448
- totals.outputTokens += row.outputTokens;
449
- totals.cacheReadInputTokens += row.cacheReadInputTokens;
450
- totals.cacheCreationInputTokens += row.cacheCreationInputTokens;
451
- perMessage.push({
452
- ...row,
453
- outputIsStreamingSnapshot: true,
454
- population: "api-message",
455
- });
475
+ return { a: a.surface, b: b.surface, toolDelta, pathDelta };
456
476
  }
457
- return { perMessage, totals };
458
- }
459
477
 
460
- /**
461
- * Fold one assistant turn's usage into its message bucket by field-wise max.
462
- * @param {Map<string, object>} byMessage
463
- * @param {string} key
464
- * @param {object} turn
465
- */
466
- function accumulateMessage(byMessage, key, turn) {
467
- const u = turn.usage;
468
- const prev = byMessage.get(key);
469
- if (!prev) {
470
- byMessage.set(key, {
471
- messageId: turn.messageId ?? null,
472
- inputTokens: u.inputTokens ?? 0,
473
- outputTokens: u.outputTokens ?? 0,
474
- cacheReadInputTokens: u.cacheReadInputTokens ?? 0,
475
- cacheCreationInputTokens: u.cacheCreationInputTokens ?? 0,
476
- });
477
- return;
478
+ /**
479
+ * Per-tool token attribution: each `tool_use` block gets an equal share of
480
+ * its host turn's usage; assistant turns with no `tool_use` block contribute
481
+ * full usage to the `(no-tool)` bucket. Per-bucket sums are scaled onto
482
+ * `stats().totals` the authoritative population (result-event sums when the
483
+ * trace carries them, the per-message fallback otherwise) — so the buckets
484
+ * answer "of the reported total, what share did each tool drive" rather than
485
+ * a separate per-turn re-count that drifts from the headline figure. The
486
+ * largest bucket absorbs the rounding residual on each axis, so the input,
487
+ * output, and `costShare` columns each sum to the corresponding `totals`
488
+ * value (and `1.0`) exactly (criterion-6 invariant).
489
+ * @returns {{perTool: Array<{tool: string, turns: number, inputTokens: number, outputTokens: number, costShare: number}>, totals: object}}
490
+ */
491
+ statsByTool() {
492
+ const { buckets, bucketTurns } = bucketUsageByTool(this.turns);
493
+ const totals = this.stats().totals;
494
+ const perTool = reconcileBucketsToTotals(buckets, bucketTurns, totals);
495
+ return { perTool, totals };
478
496
  }
479
- prev.inputTokens = Math.max(prev.inputTokens, u.inputTokens ?? 0);
480
- prev.outputTokens = Math.max(prev.outputTokens, u.outputTokens ?? 0);
481
- prev.cacheReadInputTokens = Math.max(
482
- prev.cacheReadInputTokens,
483
- u.cacheReadInputTokens ?? 0,
484
- );
485
- prev.cacheCreationInputTokens = Math.max(
486
- prev.cacheCreationInputTokens,
487
- u.cacheCreationInputTokens ?? 0,
488
- );
489
- }
490
497
 
491
- /**
492
- * Compare per-message sums against the result-event sums on the fields the
493
- * spec guarantees parity for (input, cacheRead, cacheCreation — never output,
494
- * which always diverges by mechanism 2). Returns the first divergent field as
495
- * `{field, perMessageSum, resultEventSum}`, or null when all agree.
496
- * @param {object} perMessageTotals
497
- * @param {object} resultEventUsage
498
- * @returns {object|null}
499
- */
500
- function computeDivergence(perMessageTotals, resultEventUsage) {
501
- for (const field of [
502
- "inputTokens",
503
- "cacheReadInputTokens",
504
- "cacheCreationInputTokens",
505
- ]) {
506
- const perMessageSum = perMessageTotals[field] ?? 0;
507
- const resultEventSum = resultEventUsage[field] ?? 0;
508
- if (perMessageSum !== resultEventSum) {
509
- return { field, perMessageSum, resultEventSum };
510
- }
498
+ /**
499
+ * Totals-only view `stats().totals` with no per-turn array.
500
+ * @returns {{totals: object}}
501
+ */
502
+ statsSummary() {
503
+ return { totals: this.stats().totals };
511
504
  }
512
- return null;
513
505
  }
514
506
 
515
507
  /**
@@ -544,6 +536,31 @@ function matchesToolName(turn, toolName) {
544
536
  );
545
537
  }
546
538
 
539
+ /**
540
+ * Collect every assistant `tool_use` block keyed by `toolUseId`, optionally
541
+ * filtered by tool name. The shared join-key source feeding `toolCalls()`,
542
+ * `commands()`, and `collectToolUseIds()`. Insertion order follows turn order.
543
+ * @param {object[]} turns
544
+ * @param {string} [name] - Optional tool-name filter.
545
+ * @returns {Map<string, {turnIndex: number, name: string, input: object}>}
546
+ */
547
+ function collectToolUseBlocks(turns, name) {
548
+ const blocks = new Map();
549
+ for (const turn of turns) {
550
+ if (turn.role !== "assistant") continue;
551
+ for (const b of turn.content) {
552
+ if (b.type !== "tool_use" || !b.toolUseId) continue;
553
+ if (name !== undefined && b.name !== name) continue;
554
+ blocks.set(b.toolUseId, {
555
+ turnIndex: turn.index,
556
+ name: b.name,
557
+ input: b.input,
558
+ });
559
+ }
560
+ }
561
+ return blocks;
562
+ }
563
+
547
564
  /**
548
565
  * Collect all toolUseIds for a given tool name from assistant turns.
549
566
  * @param {object[]} turns
@@ -551,16 +568,68 @@ function matchesToolName(turn, toolName) {
551
568
  * @returns {Set<string>}
552
569
  */
553
570
  function collectToolUseIds(turns, name) {
554
- const ids = new Set();
571
+ return new Set(collectToolUseBlocks(turns, name).keys());
572
+ }
573
+
574
+ /** Tool names in `Read`/`Edit`/`Write` that carry a `file_path` argument. */
575
+ const PATH_TOOLS = new Set(["Read", "Edit", "Write"]);
576
+
577
+ /**
578
+ * Frequency map of distinct `file_path` arguments across `Read`/`Edit`/`Write`
579
+ * tool calls, in first-seen insertion order.
580
+ * @param {object[]} turns
581
+ * @returns {Map<string, number>}
582
+ */
583
+ function collectFilePaths(turns) {
584
+ const counts = new Map();
555
585
  for (const turn of turns) {
556
586
  if (turn.role !== "assistant") continue;
557
- for (const b of turn.content) {
558
- if (b.type === "tool_use" && b.name === name && b.toolUseId) {
559
- ids.add(b.toolUseId);
560
- }
587
+ for (const block of turn.content) {
588
+ if (block.type !== "tool_use" || !PATH_TOOLS.has(block.name)) continue;
589
+ const p = block.input?.file_path;
590
+ if (typeof p !== "string") continue;
591
+ counts.set(p, (counts.get(p) ?? 0) + 1);
561
592
  }
562
593
  }
563
- return ids;
594
+ return counts;
595
+ }
596
+
597
+ /**
598
+ * Build the per-side comparison surface plus the tool/path frequency maps
599
+ * the delta computation consumes. Empty traces emit a `(empty)` marker.
600
+ * @param {TraceQuery} query
601
+ * @param {{caseName: string, participant: string|null}} [identity]
602
+ * @returns {{surface: object, toolFreq: Map<string, number>, pathFreq: Map<string, number>}}
603
+ */
604
+ function sideSummary(
605
+ query,
606
+ identity = { caseName: "(unknown)", participant: null },
607
+ ) {
608
+ const toolFreq = new Map(query.toolFrequency().map((t) => [t.tool, t.count]));
609
+ const pathFreq = collectFilePaths(query.turns);
610
+
611
+ const isEmpty = query.turns.length === 0;
612
+ const metadata = {
613
+ caseName: identity.caseName,
614
+ participant: identity.participant ?? null,
615
+ };
616
+ if (isEmpty) metadata.marker = "(empty)";
617
+
618
+ const tools = [...toolFreq.keys()].sort();
619
+ const paths = [...pathFreq.keys()].sort();
620
+
621
+ return {
622
+ surface: {
623
+ metadata,
624
+ turnCount: query.turns.length,
625
+ tools,
626
+ paths,
627
+ pathCount: paths.length,
628
+ cost: query.stats().totals.totalCostUsd,
629
+ },
630
+ toolFreq,
631
+ pathFreq,
632
+ };
564
633
  }
565
634
 
566
635
  /**
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Text renderers for `fit-trace` query output.
3
+ *
4
+ * One named export per renderable verb. Each renderer accepts the query result
5
+ * plus `{multi, signatures}` and returns a string. `multi` controls
6
+ * source-attribution prefixing (`grep -H` convention); record-per-line
7
+ * renderers prepend `<basename>:`, block renderers emit `# <basename>` headers.
8
+ *
9
+ * Internal module — imported by `commands/trace.js` and tests by relative
10
+ * path, never re-exported from `src/index.js`.
11
+ */
12
+
13
+ /** Collapse newlines/tabs in a value to a single-line, grep-friendly string. */
14
+ function oneLine(value) {
15
+ const str = typeof value === "string" ? value : JSON.stringify(value ?? null);
16
+ return str.replace(/[\r\n\t]+/g, " ").trim();
17
+ }
18
+
19
+ /** Group records by their `source` field (multi-file path), preserving order. */
20
+ function groupBySource(records) {
21
+ const groups = new Map();
22
+ for (const record of records) {
23
+ const key = record.source ?? "";
24
+ if (!groups.has(key)) groups.set(key, []);
25
+ groups.get(key).push(record);
26
+ }
27
+ return groups;
28
+ }
29
+
30
+ /**
31
+ * Render record-per-line output, prefixing each line with `<source>:` when
32
+ * multi-file. `lineOf` maps one record to its text line.
33
+ * @param {object[]} records
34
+ * @param {(record: object) => string} lineOf
35
+ * @param {{multi: boolean}} opts
36
+ * @returns {string}
37
+ */
38
+ function renderLines(records, lineOf, { multi }) {
39
+ return records
40
+ .map((r) => (multi && r.source ? `${r.source}:${lineOf(r)}` : lineOf(r)))
41
+ .join("\n");
42
+ }
43
+
44
+ /**
45
+ * Render a block per source. `blockOf` maps one record to a multi-line string;
46
+ * multi-file output separates groups with `# <source>` headers.
47
+ * @param {object[]} records
48
+ * @param {(record: object) => string} blockOf
49
+ * @param {{multi: boolean}} opts
50
+ * @returns {string}
51
+ */
52
+ function renderBlocks(records, blockOf, { multi }) {
53
+ if (!multi) return records.map(blockOf).join("\n");
54
+ const out = [];
55
+ for (const [source, group] of groupBySource(records)) {
56
+ out.push(`# ${source}`);
57
+ out.push(...group.map(blockOf));
58
+ }
59
+ return out.join("\n");
60
+ }
61
+
62
+ /** `[turnIdx] <Tool> <toolUseId>` / ` in:` / ` out:` per block. */
63
+ export function renderToolCalls(records, opts = {}) {
64
+ return renderBlocks(
65
+ records,
66
+ (r) => {
67
+ const head = `[${r.turnIndex}] ${r.name} ${r.toolUseId}`;
68
+ const input = ` in: ${oneLine(r.input)}`;
69
+ const out = ` out: ${
70
+ r.result ? oneLine(r.result.content) : "(no result)"
71
+ }`;
72
+ return [head, input, out].join("\n");
73
+ },
74
+ opts,
75
+ );
76
+ }
77
+
78
+ /** `[turnIdx] <command>` per line, newlines escaped. */
79
+ export function renderCommands(records, opts = {}) {
80
+ return renderLines(
81
+ records,
82
+ (r) => `[${r.turnIndex}] ${oneLine(r.command)}`,
83
+ opts,
84
+ );
85
+ }
86
+
87
+ /** `<count>\t<path>` frequency-sorted. */
88
+ export function renderPaths(records, opts = {}) {
89
+ return renderLines(records, (r) => `${r.count}\t${r.path}`, opts);
90
+ }
91
+
92
+ /** Metadata header, per-row metrics, then Tool and Path delta tables. */
93
+ export function renderCompare(result) {
94
+ const { a, b, toolDelta, pathDelta } = result;
95
+ const part = (p) => (p == null ? "(none)" : p);
96
+ const lines = [];
97
+ lines.push(
98
+ `A: ${a.metadata.caseName} / ${part(a.metadata.participant)}${
99
+ a.metadata.marker ? ` ${a.metadata.marker}` : ""
100
+ }`,
101
+ );
102
+ lines.push(
103
+ `B: ${b.metadata.caseName} / ${part(b.metadata.participant)}${
104
+ b.metadata.marker ? ` ${b.metadata.marker}` : ""
105
+ }`,
106
+ );
107
+ lines.push("");
108
+ lines.push(`turns | ${a.turnCount} | ${b.turnCount}`);
109
+ lines.push(`tools | ${a.tools.length} | ${b.tools.length}`);
110
+ lines.push(`paths | ${a.pathCount} | ${b.pathCount}`);
111
+ lines.push(`cost | ${a.cost} | ${b.cost}`);
112
+ lines.push("");
113
+ lines.push("Tool | A | B | Δ");
114
+ for (const d of toolDelta) {
115
+ lines.push(`${d.tool} | ${d.a} | ${d.b} | ${d.diff}`);
116
+ }
117
+ lines.push("");
118
+ lines.push("Path | A | B | Δ");
119
+ for (const d of pathDelta) {
120
+ lines.push(`${d.path} | ${d.a} | ${d.b} | ${d.diff}`);
121
+ }
122
+ return lines.join("\n");
123
+ }
124
+
125
+ /** `Tool | Turns | In | Out | Share` sorted Share desc. */
126
+ export function renderStatsByTool(result) {
127
+ const lines = ["Tool | Turns | In | Out | Share"];
128
+ for (const b of result.perTool) {
129
+ lines.push(
130
+ `${b.tool} | ${b.turns} | ${Math.round(b.inputTokens)} | ${Math.round(
131
+ b.outputTokens,
132
+ )} | ${b.costShare.toFixed(4)}`,
133
+ );
134
+ }
135
+ return lines.join("\n");
136
+ }
137
+
138
+ /** Totals block only. */
139
+ export function renderStatsSummary(result) {
140
+ const t = result.totals;
141
+ return [
142
+ `inputTokens: ${t.inputTokens}`,
143
+ `outputTokens: ${t.outputTokens}`,
144
+ `cacheReadInputTokens: ${t.cacheReadInputTokens}`,
145
+ `cacheCreationInputTokens: ${t.cacheCreationInputTokens}`,
146
+ `totalCostUsd: ${t.totalCostUsd}`,
147
+ `durationMs: ${t.durationMs}`,
148
+ ].join("\n");
149
+ }
150
+
151
+ /** `[turnIdx] <prefix>: <excerpt>` per match. */
152
+ export function renderSearch(records, opts = {}) {
153
+ const lines = [];
154
+ for (const hit of records) {
155
+ const idx = hit.turn?.index;
156
+ const prefix = multiPrefix(hit, opts);
157
+ for (const match of hit.matches ?? []) {
158
+ lines.push(`${prefix}[${idx}] ${oneLine(match)}`);
159
+ }
160
+ }
161
+ return lines.join("\n");
162
+ }
163
+
164
+ /** Source prefix for a multi-file record (search/default), or "". */
165
+ function multiPrefix(record, { multi }) {
166
+ return multi && record.source ? `${record.source}:` : "";
167
+ }
168
+
169
+ /**
170
+ * Default renderer for every other renderable verb: one record per block,
171
+ * fields rendered as `key: value` lines (no JSON braces or quotes, so the
172
+ * default output is grep/awk-friendly and does not parse as JSON). Nested
173
+ * values are collapsed to a single grep-friendly line. Multi-file output
174
+ * separates source groups with `# <source>` headers (`renderBlocks`
175
+ * convention).
176
+ * @param {object[]|object} result
177
+ * @param {{multi: boolean}} opts
178
+ * @returns {string}
179
+ */
180
+ export function renderDefault(result, opts = {}) {
181
+ const records = Array.isArray(result) ? result : [result];
182
+ return renderBlocks(records, (r) => recordBlock(stripSource(r)), opts);
183
+ }
184
+
185
+ /**
186
+ * Render one record as `key: value` lines. Scalars render verbatim; objects
187
+ * and arrays collapse to a single line via `oneLine`. A non-object record
188
+ * (string/number) renders as its own single line.
189
+ * @param {*} record
190
+ * @returns {string}
191
+ */
192
+ function recordBlock(record) {
193
+ if (record == null || typeof record !== "object" || Array.isArray(record)) {
194
+ return oneLine(record);
195
+ }
196
+ return Object.entries(record)
197
+ .map(([key, value]) => {
198
+ const scalar = value == null || typeof value !== "object";
199
+ return `${key}: ${scalar ? String(value) : oneLine(value)}`;
200
+ })
201
+ .join("\n");
202
+ }
203
+
204
+ /** Drop the orchestrator-injected `source` field before textifying. */
205
+ function stripSource(record) {
206
+ if (record == null || typeof record !== "object" || Array.isArray(record)) {
207
+ return record;
208
+ }
209
+ const { source, ...rest } = record;
210
+ return rest;
211
+ }