@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,9 +1,20 @@
1
- import { join, dirname } from "node:path";
1
+ import { join, dirname, basename } from "node:path";
2
2
  import { isoTimestamp } from "@forwardimpact/libutil";
3
3
  import { createTraceCollector, sumTraceCost } from "@forwardimpact/libeval";
4
4
  import { createTraceQuery } from "../trace-query.js";
5
5
  import { createTraceGitHub } from "../trace-github.js";
6
6
  import { stripSignatures } from "../signature-filter.js";
7
+ import { runOver, aggregate, compareTwo } from "../trace-multi.js";
8
+ import {
9
+ renderToolCalls,
10
+ renderCommands,
11
+ renderPaths,
12
+ renderCompare,
13
+ renderStatsByTool,
14
+ renderStatsSummary,
15
+ renderSearch,
16
+ renderDefault,
17
+ } from "../trace-render.js";
7
18
 
8
19
  // Every handler receives a libcli `InvocationContext`:
9
20
  // ctx.options — parsed flag values (`cli.parse().values`)
@@ -12,6 +23,58 @@ import { stripSignatures } from "../signature-filter.js";
12
23
  // Handlers read/write the filesystem and stdout exclusively through
13
24
  // `ctx.deps.runtime` and return `{ ok: true }` on success.
14
25
 
26
+ /** Characters whose presence in a `--file` value marks it as a glob. */
27
+ const GLOB_CHARS = /[*?[\]{}]/;
28
+
29
+ /**
30
+ * Resolve the cross-trace `--file` option (`ctx.options.file`) into a sorted
31
+ * flat list of file paths. A literal path passes through; a value carrying
32
+ * glob metacharacters expands via `runtime.fsSync.globSync`. The literal-path
33
+ * fast path means the common single-file and shell-pre-expanded cases never
34
+ * touch `globSync`.
35
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
36
+ * @param {import("@forwardimpact/libcli").InvocationContext} ctx
37
+ * @returns {string[]}
38
+ */
39
+ function resolveFiles(runtime, ctx) {
40
+ const raw = ctx.options.file;
41
+ const values = raw === undefined ? [] : Array.isArray(raw) ? raw : [raw];
42
+ const out = [];
43
+ for (const value of values) {
44
+ if (GLOB_CHARS.test(value)) {
45
+ out.push(...runtime.fsSync.globSync(value));
46
+ } else {
47
+ out.push(value);
48
+ }
49
+ }
50
+ return out.sort();
51
+ }
52
+
53
+ /**
54
+ * Emit a query result for a cross-trace verb: under `--format json` write the
55
+ * JSON payload (single-object verbs unwrap when single-file so the envelope
56
+ * deep-equals today's output); otherwise render text to stdout. Source
57
+ * attribution is the renderer's job, gated by `multi`.
58
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
59
+ * @param {object|object[]} result
60
+ * @param {Function} renderer
61
+ * @param {import("@forwardimpact/libcli").InvocationContext} ctx
62
+ * @param {boolean} multi
63
+ * @param {boolean} [unwrap=false] - Single-object verb wrapped in a one-element array.
64
+ */
65
+ function emit(runtime, result, renderer, ctx, multi, unwrap = false) {
66
+ if (ctx.options.format === "json") {
67
+ const payload = unwrap && !multi ? result[0] : result;
68
+ writeJSON(runtime, payload, ctx.options);
69
+ return;
70
+ }
71
+ const text = renderer(result, {
72
+ multi,
73
+ signatures: !!ctx.options.signatures,
74
+ });
75
+ runtime.proc.stdout.write(text + "\n");
76
+ }
77
+
15
78
  // --- GitHub commands ---
16
79
 
17
80
  /**
@@ -94,49 +157,78 @@ export async function runDownloadCommand(ctx) {
94
157
 
95
158
  // --- Query commands ---
96
159
 
160
+ /**
161
+ * Build the injected loader the orchestrator uses (wires the runtime IO seam).
162
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
163
+ * @returns {(file: string) => import("../trace-query.js").TraceQuery}
164
+ */
165
+ function loader(runtime) {
166
+ return (file) => loadTrace(runtime, file);
167
+ }
168
+
169
+ /** No-files error envelope for a cross-trace verb. */
170
+ function noFiles(verb) {
171
+ return { ok: false, code: 1, error: `${verb}: no files (use --file)` };
172
+ }
173
+
97
174
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
98
175
  export async function runOverviewCommand(ctx) {
99
176
  const { runtime } = ctx.deps;
100
- writeJSON(runtime, loadTrace(runtime, ctx.args.file).overview(), ctx.options);
177
+ const files = resolveFiles(runtime, ctx);
178
+ if (files.length === 0) return noFiles("overview");
179
+ const result = runOver(files, (tq) => [tq.overview()], loader(runtime));
180
+ emit(runtime, result, renderDefault, ctx, files.length > 1, true);
101
181
  return { ok: true };
102
182
  }
103
183
 
104
184
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
105
185
  export async function runCountCommand(ctx) {
106
186
  const { runtime } = ctx.deps;
107
- runtime.proc.stdout.write(
108
- String(loadTrace(runtime, ctx.args.file).count()) + "\n",
187
+ const files = resolveFiles(runtime, ctx);
188
+ if (files.length === 0) return noFiles("count");
189
+ const multi = files.length > 1;
190
+ const result = runOver(
191
+ files,
192
+ (tq) => [{ count: tq.count() }],
193
+ loader(runtime),
109
194
  );
195
+ for (const r of result) {
196
+ const prefix = multi && r.source ? `${r.source}:` : "";
197
+ runtime.proc.stdout.write(`${prefix}${r.count}\n`);
198
+ }
110
199
  return { ok: true };
111
200
  }
112
201
 
113
202
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
114
203
  export async function runBatchCommand(ctx) {
115
204
  const { runtime } = ctx.deps;
116
- writeJSON(
117
- runtime,
118
- loadTrace(runtime, ctx.args.file).batch(
119
- parseInt(ctx.args.from, 10),
120
- parseInt(ctx.args.to, 10),
121
- ),
122
- ctx.options,
205
+ const result = loadTrace(runtime, ctx.args.file).batch(
206
+ parseInt(ctx.args.from, 10),
207
+ parseInt(ctx.args.to, 10),
123
208
  );
209
+ emit(runtime, result, renderDefault, ctx, false);
124
210
  return { ok: true };
125
211
  }
126
212
 
127
213
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
128
214
  export async function runHeadCommand(ctx) {
129
215
  const { runtime } = ctx.deps;
130
- const n = ctx.args.n ? parseInt(ctx.args.n, 10) : 10;
131
- writeJSON(runtime, loadTrace(runtime, ctx.args.file).head(n), ctx.options);
216
+ const files = resolveFiles(runtime, ctx);
217
+ if (files.length === 0) return noFiles("head");
218
+ const n = ctx.options.lines ? parseInt(ctx.options.lines, 10) : 10;
219
+ const result = runOver(files, (tq) => tq.head(n), loader(runtime));
220
+ emit(runtime, result, renderDefault, ctx, files.length > 1);
132
221
  return { ok: true };
133
222
  }
134
223
 
135
224
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
136
225
  export async function runTailCommand(ctx) {
137
226
  const { runtime } = ctx.deps;
138
- const n = ctx.args.n ? parseInt(ctx.args.n, 10) : 10;
139
- writeJSON(runtime, loadTrace(runtime, ctx.args.file).tail(n), ctx.options);
227
+ const files = resolveFiles(runtime, ctx);
228
+ if (files.length === 0) return noFiles("tail");
229
+ const n = ctx.options.lines ? parseInt(ctx.options.lines, 10) : 10;
230
+ const result = runOver(files, (tq) => tq.tail(n), loader(runtime));
231
+ emit(runtime, result, renderDefault, ctx, files.length > 1);
140
232
  return { ok: true };
141
233
  }
142
234
 
@@ -146,72 +238,120 @@ export async function runSearchCommand(ctx) {
146
238
  const limit = ctx.options.limit ? parseInt(ctx.options.limit, 10) : 50;
147
239
  const context = ctx.options.context ? parseInt(ctx.options.context, 10) : 0;
148
240
  const full = ctx.options.full ?? false;
149
- writeJSON(
150
- runtime,
151
- loadTrace(runtime, ctx.args.file).search(ctx.args.pattern, {
152
- limit,
153
- context,
154
- full,
155
- }),
156
- ctx.options,
157
- );
241
+ const result = loadTrace(runtime, ctx.args.file).search(ctx.args.pattern, {
242
+ limit,
243
+ context,
244
+ full,
245
+ });
246
+ emit(runtime, result, renderSearch, ctx, false);
158
247
  return { ok: true };
159
248
  }
160
249
 
161
250
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
162
251
  export async function runToolsCommand(ctx) {
163
252
  const { runtime } = ctx.deps;
164
- writeJSON(
165
- runtime,
166
- loadTrace(runtime, ctx.args.file).toolFrequency(),
167
- ctx.options,
253
+ const files = resolveFiles(runtime, ctx);
254
+ if (files.length === 0) return noFiles("tools");
255
+ const result = aggregate(
256
+ files,
257
+ (tq) => tq.toolFrequency(),
258
+ (r) => r.tool,
259
+ loader(runtime),
168
260
  );
261
+ emit(runtime, result, renderDefault, ctx, files.length > 1);
169
262
  return { ok: true };
170
263
  }
171
264
 
172
265
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
173
266
  export async function runToolCommand(ctx) {
174
267
  const { runtime } = ctx.deps;
175
- writeJSON(
176
- runtime,
177
- loadTrace(runtime, ctx.args.file).tool(ctx.args.name),
178
- ctx.options,
179
- );
268
+ const result = loadTrace(runtime, ctx.args.file).tool(ctx.args.name);
269
+ emit(runtime, result, renderDefault, ctx, false);
180
270
  return { ok: true };
181
271
  }
182
272
 
183
273
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
184
274
  export async function runErrorsCommand(ctx) {
185
275
  const { runtime } = ctx.deps;
186
- writeJSON(runtime, loadTrace(runtime, ctx.args.file).errors(), ctx.options);
276
+ const files = resolveFiles(runtime, ctx);
277
+ if (files.length === 0) return noFiles("errors");
278
+ const result = runOver(files, (tq) => tq.errors(), loader(runtime));
279
+ emit(runtime, result, renderDefault, ctx, files.length > 1);
187
280
  return { ok: true };
188
281
  }
189
282
 
190
283
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
191
284
  export async function runReasoningCommand(ctx) {
192
285
  const { runtime } = ctx.deps;
286
+ const files = resolveFiles(runtime, ctx);
287
+ if (files.length === 0) return noFiles("reasoning");
193
288
  const from = ctx.options.from ? parseInt(ctx.options.from, 10) : undefined;
194
289
  const to = ctx.options.to ? parseInt(ctx.options.to, 10) : undefined;
195
- writeJSON(
196
- runtime,
197
- loadTrace(runtime, ctx.args.file).reasoning({ from, to }),
198
- ctx.options,
290
+ const result = runOver(
291
+ files,
292
+ (tq) => tq.reasoning({ from, to }),
293
+ loader(runtime),
199
294
  );
295
+ emit(runtime, result, renderDefault, ctx, files.length > 1);
200
296
  return { ok: true };
201
297
  }
202
298
 
203
299
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
204
300
  export async function runTimelineCommand(ctx) {
205
301
  const { runtime } = ctx.deps;
206
- const lines = loadTrace(runtime, ctx.args.file).timeline();
207
- runtime.proc.stdout.write(lines.join("\n") + "\n");
302
+ const files = resolveFiles(runtime, ctx);
303
+ if (files.length === 0) return noFiles("timeline");
304
+ const multi = files.length > 1;
305
+ for (const file of files) {
306
+ if (multi) runtime.proc.stdout.write(`# ${basename(file)}\n`);
307
+ runtime.proc.stdout.write(
308
+ loadTrace(runtime, file).timeline().join("\n") + "\n",
309
+ );
310
+ }
208
311
  return { ok: true };
209
312
  }
210
313
 
314
+ /** Select the per-file `stats` query for the active flag combination. */
315
+ function statsQuery(ctx) {
316
+ if (ctx.options.summary) return (tq) => tq.statsSummary();
317
+ if (ctx.options["by-tool"]) return (tq) => tq.statsByTool();
318
+ return (tq) => tq.stats();
319
+ }
320
+
321
+ /** Select the `stats` text renderer for the active flag combination. */
322
+ function statsRenderer(ctx) {
323
+ if (ctx.options.summary) return renderStatsSummary;
324
+ if (ctx.options["by-tool"]) return renderStatsByTool;
325
+ return (result) => renderDefault(result);
326
+ }
327
+
211
328
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
212
329
  export async function runStatsCommand(ctx) {
213
330
  const { runtime } = ctx.deps;
214
- writeJSON(runtime, loadTrace(runtime, ctx.args.file).stats(), ctx.options);
331
+ const files = resolveFiles(runtime, ctx);
332
+ if (files.length === 0) return noFiles("stats");
333
+ const multi = files.length > 1;
334
+ const query = statsQuery(ctx);
335
+ // stats results are per-file objects; one block per file (no cross-file sum),
336
+ // tagged with source only when multi-file.
337
+ const results = files.map((file) => ({
338
+ result: query(loadTrace(runtime, file)),
339
+ source: multi ? basename(file) : undefined,
340
+ }));
341
+
342
+ if (ctx.options.format === "json") {
343
+ const payloads = results.map((r) =>
344
+ multi ? { ...r.result, source: r.source } : r.result,
345
+ );
346
+ writeJSON(runtime, multi ? payloads : payloads[0], ctx.options);
347
+ return { ok: true };
348
+ }
349
+
350
+ const render = statsRenderer(ctx);
351
+ const blocks = results.map((r) =>
352
+ multi ? `# ${r.source}\n${render(r.result)}` : render(r.result),
353
+ );
354
+ runtime.proc.stdout.write(blocks.join("\n") + "\n");
215
355
  return { ok: true };
216
356
  }
217
357
 
@@ -263,33 +403,87 @@ function renderCostMarkdown(cost) {
263
403
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
264
404
  export async function runInitCommand(ctx) {
265
405
  const { runtime } = ctx.deps;
266
- writeJSON(runtime, loadTrace(runtime, ctx.args.file).init(), ctx.options);
406
+ const files = resolveFiles(runtime, ctx);
407
+ if (files.length === 0) return noFiles("init");
408
+ const result = runOver(files, (tq) => [tq.init()], loader(runtime));
409
+ emit(runtime, result, renderDefault, ctx, files.length > 1, true);
267
410
  return { ok: true };
268
411
  }
269
412
 
270
413
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
271
414
  export async function runTurnCommand(ctx) {
272
415
  const { runtime } = ctx.deps;
273
- writeJSON(
274
- runtime,
275
- loadTrace(runtime, ctx.args.file).turn(parseInt(ctx.args.index, 10)),
276
- ctx.options,
416
+ const result = loadTrace(runtime, ctx.args.file).turn(
417
+ parseInt(ctx.args.index, 10),
277
418
  );
419
+ emit(runtime, result, renderDefault, ctx, false);
278
420
  return { ok: true };
279
421
  }
280
422
 
281
423
  /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
282
424
  export async function runFilterCommand(ctx) {
283
425
  const { runtime } = ctx.deps;
426
+ const files = resolveFiles(runtime, ctx);
427
+ if (files.length === 0) return noFiles("filter");
284
428
  const opts = {};
285
429
  if (ctx.options.role) opts.role = ctx.options.role;
286
430
  if (ctx.options.tool) opts.toolName = ctx.options.tool;
287
431
  if (ctx.options.error) opts.isError = true;
288
- writeJSON(
289
- runtime,
290
- loadTrace(runtime, ctx.args.file).filter(opts),
291
- ctx.options,
432
+ const result = runOver(files, (tq) => tq.filter(opts), loader(runtime));
433
+ emit(runtime, result, renderDefault, ctx, files.length > 1);
434
+ return { ok: true };
435
+ }
436
+
437
+ // --- Aggregator verbs (tool-calls, commands, paths, compare) ---
438
+
439
+ /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
440
+ export async function runToolCallsCommand(ctx) {
441
+ const { runtime } = ctx.deps;
442
+ const files = resolveFiles(runtime, ctx);
443
+ if (files.length === 0) return noFiles("tool-calls");
444
+ const result = runOver(files, (tq) => tq.toolCalls(), loader(runtime));
445
+ emit(runtime, result, renderToolCalls, ctx, files.length > 1);
446
+ return { ok: true };
447
+ }
448
+
449
+ /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
450
+ export async function runCommandsCommand(ctx) {
451
+ const { runtime } = ctx.deps;
452
+ const files = resolveFiles(runtime, ctx);
453
+ if (files.length === 0) return noFiles("commands");
454
+ const result = runOver(
455
+ files,
456
+ (tq) => tq.commands(ctx.options.match),
457
+ loader(runtime),
458
+ );
459
+ emit(runtime, result, renderCommands, ctx, files.length > 1);
460
+ return { ok: true };
461
+ }
462
+
463
+ /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
464
+ export async function runPathsCommand(ctx) {
465
+ const { runtime } = ctx.deps;
466
+ const files = resolveFiles(runtime, ctx);
467
+ if (files.length === 0) return noFiles("paths");
468
+ const result = aggregate(
469
+ files,
470
+ (tq) => tq.paths(ctx.options.prefix),
471
+ (r) => r.path,
472
+ loader(runtime),
473
+ );
474
+ emit(runtime, result, renderPaths, ctx, files.length > 1);
475
+ return { ok: true };
476
+ }
477
+
478
+ /** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
479
+ export async function runCompareCommand(ctx) {
480
+ const { runtime } = ctx.deps;
481
+ const result = compareTwo(
482
+ ctx.args["file-a"],
483
+ ctx.args["file-b"],
484
+ loader(runtime),
292
485
  );
486
+ emit(runtime, result, renderCompare, ctx, false);
293
487
  return { ok: true };
294
488
  }
295
489
 
@@ -401,7 +595,7 @@ function computeTraceCost(content) {
401
595
  * @param {string} file
402
596
  * @returns {import("../trace-query.js").TraceQuery}
403
597
  */
404
- function loadTrace(runtime, file) {
598
+ export function loadTrace(runtime, file) {
405
599
  const content = runtime.fsSync.readFileSync(file, "utf8");
406
600
 
407
601
  try {
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Multi-file orchestrator for cross-trace `fit-trace` verbs.
3
+ *
4
+ * Two functions centralise the load-tag-concat (`runOver`) and
5
+ * aggregate-and-sort (`aggregate`) policies so every cross-trace verb shares
6
+ * one source-attribution rule. `compareTwo` derives per-side identity from
7
+ * each input's basename and threads it into `TraceQuery.compare()`.
8
+ *
9
+ * `load` is injected (the exported `loadTrace` from `commands/trace.js`) so
10
+ * this module stays IO-policy-free and unit-testable with a stub.
11
+ */
12
+ import { basename } from "node:path";
13
+
14
+ /**
15
+ * Load each file → `TraceQuery`, run `query(tq)`, tag each emitted record with
16
+ * `source: <basename>` only when more than one file is supplied. Records are
17
+ * concatenated in file-then-record order.
18
+ * @param {string[]} files
19
+ * @param {(tq: object) => object[]} query
20
+ * @param {(file: string) => object} load
21
+ * @returns {object[]}
22
+ */
23
+ export function runOver(files, query, load) {
24
+ const multi = files.length > 1;
25
+ const out = [];
26
+ for (const file of files) {
27
+ const source = basename(file);
28
+ const records = query(load(file));
29
+ for (const record of records) {
30
+ out.push(multi ? { ...record, source } : record);
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /**
37
+ * Merge per-file record arrays by `key(record)`, summing each record's
38
+ * existing `count` field (not occurrence count), and frequency-sort by
39
+ * `count desc`. Merged records carry `sources: string[]` only when more than
40
+ * one file is supplied.
41
+ * @param {string[]} files
42
+ * @param {(tq: object) => Array<{count: number}>} query
43
+ * @param {(record: object) => string} key
44
+ * @param {(file: string) => object} load
45
+ * @returns {object[]}
46
+ */
47
+ export function aggregate(files, query, key, load) {
48
+ const multi = files.length > 1;
49
+ const merged = new Map();
50
+ for (const file of files) {
51
+ const source = basename(file);
52
+ for (const record of query(load(file))) {
53
+ const k = key(record);
54
+ if (!merged.has(k)) {
55
+ merged.set(k, { record: { ...record }, sources: new Set() });
56
+ } else {
57
+ merged.get(k).record.count += record.count;
58
+ }
59
+ merged.get(k).sources.add(source);
60
+ }
61
+ }
62
+ return [...merged.values()]
63
+ .map(({ record, sources }) =>
64
+ multi ? { ...record, sources: [...sources].sort() } : record,
65
+ )
66
+ .sort((a, b) => b.count - a.count);
67
+ }
68
+
69
+ /**
70
+ * Load two files, derive each side's `{caseName, participant}` from its
71
+ * basename via the `split` convention, and thread them into
72
+ * `a.compare(b, {aIdentity, bIdentity})`.
73
+ * @param {string} a
74
+ * @param {string} b
75
+ * @param {(file: string) => object} load
76
+ * @returns {object}
77
+ */
78
+ export function compareTwo(a, b, load) {
79
+ const qa = load(a);
80
+ const qb = load(b);
81
+ return qa.compare(qb, {
82
+ aIdentity: parseIdentity(a),
83
+ bIdentity: parseIdentity(b),
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Parse `trace--<case>--<participant>.<role>.ndjson` into `{caseName,
89
+ * participant}`. On no match, `caseName` is the basename minus its final
90
+ * `.ndjson` extension only and `participant` is null.
91
+ * @param {string} file
92
+ * @returns {{caseName: string, participant: string|null}}
93
+ */
94
+ export function parseIdentity(file) {
95
+ const name = basename(file);
96
+ const match = name.match(/^trace--(.+?)--(.+?)\.[^.]+\.ndjson$/);
97
+ if (match) {
98
+ return { caseName: match[1], participant: match[2] };
99
+ }
100
+ return { caseName: name.replace(/\.ndjson$/, ""), participant: null };
101
+ }