@llm4ts/runner 3.4.0 → 4.0.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 (84) hide show
  1. package/dist/Connectors.d.ts +0 -6
  2. package/dist/Connectors.d.ts.map +1 -1
  3. package/dist/Connectors.js +0 -9
  4. package/dist/Connectors.js.map +1 -1
  5. package/dist/EventPresentation.d.ts.map +1 -1
  6. package/dist/EventPresentation.js +0 -1
  7. package/dist/EventPresentation.js.map +1 -1
  8. package/dist/FlowRunner.d.ts +10 -3
  9. package/dist/FlowRunner.d.ts.map +1 -1
  10. package/dist/FlowRunner.js +20 -2
  11. package/dist/FlowRunner.js.map +1 -1
  12. package/dist/NodeGeminiCliExecutor.d.ts.map +1 -1
  13. package/dist/NodeGeminiCliExecutor.js +1 -0
  14. package/dist/NodeGeminiCliExecutor.js.map +1 -1
  15. package/dist/NodeGeminiModelBridge.d.ts.map +1 -1
  16. package/dist/NodeGeminiModelBridge.js +1 -0
  17. package/dist/NodeGeminiModelBridge.js.map +1 -1
  18. package/dist/NodeHttpClient.d.ts.map +1 -1
  19. package/dist/NodeHttpClient.js +1 -0
  20. package/dist/NodeHttpClient.js.map +1 -1
  21. package/dist/Otel.d.ts.map +1 -1
  22. package/dist/Otel.js +1 -0
  23. package/dist/Otel.js.map +1 -1
  24. package/dist/Package.d.ts.map +1 -1
  25. package/dist/Package.js +1 -0
  26. package/dist/Package.js.map +1 -1
  27. package/dist/Terminal.d.ts +1 -2
  28. package/dist/Terminal.d.ts.map +1 -1
  29. package/dist/Terminal.js +1 -4
  30. package/dist/Terminal.js.map +1 -1
  31. package/dist/WindowsCommand.d.ts.map +1 -1
  32. package/dist/WindowsCommand.js +1 -0
  33. package/dist/WindowsCommand.js.map +1 -1
  34. package/dist/index.d.ts +1 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -2
  37. package/dist/index.js.map +1 -1
  38. package/package.json +8 -21
  39. package/src/Connectors.ts +0 -10
  40. package/src/EventPresentation.ts +0 -1
  41. package/src/FlowRunner.ts +26 -6
  42. package/src/NodeGeminiCliExecutor.ts +1 -0
  43. package/src/NodeGeminiModelBridge.ts +1 -0
  44. package/src/NodeHttpClient.ts +1 -0
  45. package/src/Otel.ts +1 -0
  46. package/src/Package.ts +1 -0
  47. package/src/Terminal.ts +1 -5
  48. package/src/WindowsCommand.ts +1 -0
  49. package/src/index.ts +0 -2
  50. package/dist/Costs.d.ts +0 -52
  51. package/dist/Costs.d.ts.map +0 -1
  52. package/dist/Costs.js +0 -95
  53. package/dist/Costs.js.map +0 -1
  54. package/dist/McpStdio.d.ts +0 -7
  55. package/dist/McpStdio.d.ts.map +0 -1
  56. package/dist/McpStdio.js +0 -19
  57. package/dist/McpStdio.js.map +0 -1
  58. package/dist/Profile.d.ts +0 -313
  59. package/dist/Profile.d.ts.map +0 -1
  60. package/dist/Profile.js +0 -937
  61. package/dist/Profile.js.map +0 -1
  62. package/dist/ReviewFingerprint.d.ts +0 -2
  63. package/dist/ReviewFingerprint.d.ts.map +0 -1
  64. package/dist/ReviewFingerprint.js +0 -13
  65. package/dist/ReviewFingerprint.js.map +0 -1
  66. package/dist/RunReaders.d.ts +0 -70
  67. package/dist/RunReaders.d.ts.map +0 -1
  68. package/dist/RunReaders.js +0 -236
  69. package/dist/RunReaders.js.map +0 -1
  70. package/dist/StoryReaders.d.ts +0 -16
  71. package/dist/StoryReaders.d.ts.map +0 -1
  72. package/dist/StoryReaders.js +0 -107
  73. package/dist/StoryReaders.js.map +0 -1
  74. package/dist/Watch.d.ts +0 -68
  75. package/dist/Watch.d.ts.map +0 -1
  76. package/dist/Watch.js +0 -261
  77. package/dist/Watch.js.map +0 -1
  78. package/src/Costs.ts +0 -148
  79. package/src/McpStdio.ts +0 -41
  80. package/src/Profile.ts +0 -1196
  81. package/src/ReviewFingerprint.ts +0 -13
  82. package/src/RunReaders.ts +0 -370
  83. package/src/StoryReaders.ts +0 -157
  84. package/src/Watch.ts +0 -446
package/dist/Profile.js DELETED
@@ -1,937 +0,0 @@
1
- import * as Effect from "effect/Effect";
2
- import * as Schema from "effect/Schema";
3
- import { LaneKind, laneClosed, laneOf, laneOpened } from "@llm4ts/flow/FlowEvents";
4
- import { readTrace } from "@llm4ts/flow/Replay";
5
- import { duration, treeInputsOfTrace } from "./AgentTree.js";
6
- export { duration };
7
- import { presentationOf } from "./EventPresentation.js";
8
- import { formatCount } from "./Terminal.js";
9
- import { resolveTraceTarget, WatchTargetMissing } from "./Watch.js";
10
- /**
11
- * `llm4ts profile`: where a run's time went. A pure fold over the
12
- * same inputs as the agent tree: per story, its wall time split into model,
13
- * the coder's tools, gates, merge and waiting, the rest left as unaccounted;
14
- * per role and executor the model calls, per command the gates; and the
15
- * biggest sinks ranked in plain words.
16
- *
17
- * Content-free by design, so a report can leave a customer's server: story
18
- * ids, roles, executor ids, tool names, gate commands as configured, counts
19
- * and durations — never a prompt, a reply, an argument or an output.
20
- *
21
- * A trace from before the `Timed` event (2.22) is estimated: a model call is
22
- * the gap before its token report in the same lane, and the report says so.
23
- */
24
- const Ms = Schema.Number;
25
- export const ProfileTime = Schema.Struct({
26
- model: Ms,
27
- tools: Ms,
28
- gates: Ms,
29
- merge: Ms,
30
- waiting: Ms,
31
- unaccounted: Ms
32
- });
33
- export const TurnStats = Schema.Struct({
34
- count: Schema.Int,
35
- avgMs: Ms,
36
- maxMs: Ms,
37
- firstPrompt: Schema.optionalKey(Schema.Int),
38
- lastPrompt: Schema.optionalKey(Schema.Int),
39
- maxPrompt: Schema.optionalKey(Schema.Int),
40
- /** The last prompt is more than three times the first. */
41
- promptGrowth: Schema.Boolean,
42
- /** Tool calls per finished turn: each is a model round trip. */
43
- avgSteps: Schema.Number,
44
- /** The model's own time per round trip: a turn's time less its tools, over its steps + 1. */
45
- avgStepMs: Ms
46
- });
47
- /** A stretch of a story's time no `Timed` event covers, named by the events around it. */
48
- export const Gap = Schema.Struct({
49
- ms: Ms,
50
- after: Schema.String,
51
- before: Schema.String
52
- });
53
- /**
54
- * The coder's tool calls by kind of work (build, install and git count as
55
- * other). `script` is the ad-hoc Python/Node one-liners, heredocs and scratch
56
- * files it improvised; profiles saved before it existed decode it as 0.
57
- */
58
- export const ToolCalls = Schema.Struct({
59
- explore: Schema.Int,
60
- edit: Schema.Int,
61
- test: Schema.Int,
62
- script: Schema.Int.pipe(Schema.withDecodingDefaultKey(Effect.succeed(0))),
63
- other: Schema.Int
64
- });
65
- export const StoryProfile = Schema.Struct({
66
- id: Schema.String,
67
- /** What the lane stands for when it is not a story: a roundtable attendee, a taskforce specialist. */
68
- kind: Schema.optionalKey(LaneKind),
69
- executor: Schema.optionalKey(Schema.String),
70
- status: Schema.Literals(["done", "failed", "running"]),
71
- wallMs: Ms,
72
- /** From the plan being ready to this story starting: dependencies and free coder slots. */
73
- queuedMs: Schema.optionalKey(Ms),
74
- time: ProfileTime,
75
- turns: TurnStats,
76
- /** Its largest untimed stretches, 30 seconds or more: where "unaccounted" sits. */
77
- gaps: Schema.Array(Gap),
78
- /**
79
- * A coder turn the trace shows working (tool calls after its last finished
80
- * call) but not ended: a run still going. Counted as model time so far.
81
- */
82
- openTurnMs: Ms,
83
- openTurnTools: Schema.Int,
84
- toolCalls: ToolCalls,
85
- /** Task stages the coder ran on this lane: the tasks it gave itself, plus any revision. */
86
- tasks: Schema.Int,
87
- /** From the coder's first task (after setup and planning) to its first edit; absent when it never edited. */
88
- firstEditMs: Schema.optionalKey(Ms),
89
- /** Explore calls (ls, find, grep, read) made before that first edit. */
90
- exploreBeforeEdit: Schema.Int,
91
- /** Commands the coder's Findings claimed to have run that no tool call carried (ADR 0027). */
92
- unverifiedClaims: Schema.Int,
93
- /** Tasks whose Findings said `confidence: low`. */
94
- lowConfidenceTasks: Schema.Int,
95
- /** The largest diff its reviewers read in one round, in lines (ADR 0044). */
96
- reviewDiffLines: Schema.optionalKey(Schema.Int)
97
- });
98
- export const ModelRow = Schema.Struct({
99
- role: Schema.String,
100
- executor: Schema.optionalKey(Schema.String),
101
- calls: Schema.Int,
102
- ms: Ms,
103
- avgMs: Ms,
104
- avgFirstMs: Schema.optionalKey(Ms),
105
- /** As the backend reported them. */
106
- apiMs: Schema.optionalKey(Ms),
107
- toolMs: Schema.optionalKey(Ms)
108
- });
109
- export const GateRow = Schema.Struct({
110
- command: Schema.String,
111
- runs: Schema.Int,
112
- ms: Ms,
113
- avgMs: Ms,
114
- failed: Schema.Int
115
- });
116
- export const CountRow = Schema.Struct({ label: Schema.String, count: Schema.Int, ms: Ms });
117
- export const Finding = Schema.Struct({ text: Schema.String, ms: Schema.optionalKey(Ms) });
118
- export const ProfileReport = Schema.Struct({
119
- /** No `Timed` events: model time is estimated from gaps, the rest is unknown. */
120
- estimated: Schema.Boolean,
121
- wallMs: Ms,
122
- stories: Schema.Array(StoryProfile),
123
- /** Stories merged on an earlier run: this run only skipped them. */
124
- skipped: Schema.Array(Schema.String),
125
- totals: ProfileTime,
126
- models: Schema.Array(ModelRow),
127
- gates: Schema.Array(GateRow),
128
- tools: Schema.Array(CountRow),
129
- /** Coder tool time by kind of work: explore, edit, test, script, build, install, git, other. */
130
- toolCategories: Schema.Array(CountRow),
131
- waits: Schema.Array(CountRow),
132
- /** Timed on the run's events, in no story: the run's own planning, or a seat no story owns. */
133
- outside: Schema.Array(CountRow),
134
- findings: Schema.Array(Finding)
135
- });
136
- /** Events a coder turn emits while it runs: not where a model call starts. */
137
- const withinTurn = new Set([
138
- "ToolUse",
139
- "UsageProgress",
140
- "CapabilityUsed",
141
- "AssistantMessage"
142
- ]);
143
- const sum = (values) => values.reduce((total, value) => total + value, 0);
144
- const average = (values) => values.length === 0 ? undefined : sum(values) / values.length;
145
- const groupBy = (items, key) => {
146
- const groups = new Map();
147
- for (const item of items) {
148
- const group = groups.get(key(item)) ?? [];
149
- group.push(item);
150
- groups.set(key(item), group);
151
- }
152
- return groups;
153
- };
154
- /** An event as the gaps name it: its kind, never its content. */
155
- const describe = (event) => {
156
- const { kind } = presentationOf(event);
157
- return typeof kind === "function" ? kind(event) : kind;
158
- };
159
- const bareLaneStage = /^(\S+) (\S+)$/u;
160
- /**
161
- * A stage that is the lane's own and not one of its coder's tasks: its steps
162
- * (`story home: setup`) and a bare `<kind> <id>` that names another lane.
163
- */
164
- const ownStage = (lane, stage) => bareLaneStage.exec(stage)?.[1] === lane.kind || stage.startsWith(`${lane.kind} ${lane.id}:`);
165
- const gapThresholdMs = 30_000;
166
- /** The parts of each step between two lane events that no timed interval covers, largest first. */
167
- const gapsOf = (lane) => {
168
- const covered = [
169
- ...lane.timed.map((item) => [item.at - item.event.ms, item.at]),
170
- // A turn still open is model time, not a gap.
171
- ...(lane.openSince === undefined || lane.end !== undefined
172
- ? []
173
- : [[lane.openSince, Number.POSITIVE_INFINITY]])
174
- ].sort((left, right) => left[0] - right[0]);
175
- const uncovered = (from, to) => {
176
- let free = 0;
177
- let cursor = from;
178
- for (const [start, end] of covered) {
179
- if (end <= cursor || start >= to) {
180
- continue;
181
- }
182
- free += Math.max(0, start - cursor);
183
- cursor = Math.max(cursor, end);
184
- }
185
- return free + Math.max(0, to - cursor);
186
- };
187
- const moments = [...lane.moments].sort((left, right) => left.at - right.at);
188
- return moments
189
- .slice(1)
190
- .flatMap((next, index) => {
191
- const previous = moments[index];
192
- if (previous === undefined) {
193
- return [];
194
- }
195
- const ms = uncovered(previous.at, next.at);
196
- return ms >= gapThresholdMs ? [{ ms, after: previous.what, before: next.what }] : [];
197
- })
198
- .sort((left, right) => right.ms - left.ms)
199
- .slice(0, 3);
200
- };
201
- /** The roster role a seat label stands for, to find its lease. */
202
- const leaseRole = (label) => (label === "reasoning" ? "reviewer" : label);
203
- export const profileOf = (inputs) => {
204
- const lanes = new Map();
205
- const leases = [];
206
- const allTimed = [];
207
- let first;
208
- let lastAt = 0;
209
- let planReady;
210
- for (const input of inputs) {
211
- first = first ?? input.at;
212
- lastAt = Math.max(lastAt, input.at);
213
- if (input._tag !== "Event") {
214
- continue;
215
- }
216
- const { event, at } = input;
217
- const tagged = laneOf(event);
218
- const lane = tagged === undefined ? undefined : lanes.get(tagged.lane);
219
- switch (event._tag) {
220
- case "StageStarted": {
221
- const opened = laneOpened(event);
222
- if (opened !== undefined) {
223
- lanes.set(opened.id, {
224
- id: opened.id,
225
- kind: opened.kind,
226
- start: at,
227
- end: undefined,
228
- status: "running",
229
- executor: event.executor,
230
- timed: [],
231
- prompts: [],
232
- estimatedCalls: [],
233
- last: at,
234
- skipped: false,
235
- moments: [{ at, what: describe(event) }],
236
- openSince: undefined,
237
- openTools: 0,
238
- tasks: 0,
239
- firstTask: undefined,
240
- toolCalls: { explore: 0, edit: 0, test: 0, script: 0, other: 0 },
241
- firstEdit: undefined,
242
- exploreBeforeEdit: 0,
243
- unverifiedClaims: 0,
244
- lowConfidenceTasks: 0,
245
- reviewDiffLines: undefined
246
- });
247
- continue;
248
- }
249
- if (lane !== undefined && !ownStage(lane, event.stage)) {
250
- lane.tasks += 1;
251
- lane.firstTask = lane.firstTask ?? at;
252
- }
253
- break;
254
- }
255
- case "StageCompleted":
256
- case "StageFailed":
257
- if (event.stage === "story plan" && event.lane === undefined) {
258
- planReady = at;
259
- }
260
- if (lane !== undefined && laneClosed(event)?.kind === lane.kind) {
261
- lane.end = at;
262
- lane.status = event._tag === "StageFailed" ? "failed" : "done";
263
- }
264
- break;
265
- case "Timed": {
266
- allTimed.push({ at, event });
267
- lane?.timed.push({ at, event });
268
- if (lane !== undefined && event.kind === "tool" && event.category !== undefined) {
269
- const kind = event.category === "explore" ||
270
- event.category === "edit" ||
271
- event.category === "test" ||
272
- event.category === "script"
273
- ? event.category
274
- : "other";
275
- lane.toolCalls[kind] += 1;
276
- if (kind === "edit" && lane.firstEdit === undefined) {
277
- lane.firstEdit = at - (lane.firstTask ?? lane.start);
278
- lane.exploreBeforeEdit = lane.toolCalls.explore;
279
- }
280
- }
281
- if (lane !== undefined && event.kind === "model" && event.label === "coder") {
282
- lane.openSince = undefined;
283
- lane.openTools = 0;
284
- }
285
- break;
286
- }
287
- case "ToolUse":
288
- if (lane !== undefined) {
289
- // A tool call opens a turn until a coder call ends: it began at
290
- // the last turn boundary.
291
- lane.openSince = lane.openSince ?? lane.last;
292
- lane.openTools += 1;
293
- }
294
- break;
295
- case "TokensUsed":
296
- if (lane !== undefined) {
297
- if (event.agent === "coder") {
298
- lane.prompts.push(event.usage.prompt);
299
- }
300
- lane.estimatedCalls.push(at - lane.last);
301
- }
302
- break;
303
- case "ExecutorLeased":
304
- leases.push({ at, executor: event.executor, role: event.role, label: event.label });
305
- break;
306
- case "Info":
307
- if (lane !== undefined && event.message.startsWith(`story ${lane.id}: already merged`)) {
308
- lane.skipped = true;
309
- }
310
- break;
311
- case "ReviewFindings":
312
- if (lane !== undefined && event.diffLines !== undefined) {
313
- lane.reviewDiffLines = Math.max(lane.reviewDiffLines ?? 0, event.diffLines);
314
- }
315
- break;
316
- case "EvidenceChecked":
317
- if (lane !== undefined) {
318
- lane.unverifiedClaims += event.unverified;
319
- if (event.confidence === "low") {
320
- lane.lowConfidenceTasks += 1;
321
- }
322
- }
323
- break;
324
- default:
325
- break;
326
- }
327
- if (lane !== undefined) {
328
- lane.moments.push({ at, what: describe(event) });
329
- lane.executor = lane.executor ?? tagged?.executor;
330
- if (!withinTurn.has(event._tag)) {
331
- lane.last = at;
332
- }
333
- }
334
- }
335
- const estimated = allTimed.length === 0;
336
- const stories = [...lanes.values()]
337
- .filter((lane) => !lane.skipped)
338
- .map((lane) => storyProfile(lane, lastAt, planReady, estimated));
339
- const skipped = [...lanes.values()].filter((lane) => lane.skipped).map((lane) => lane.id);
340
- const executorOf = (item) => {
341
- if (item.event.executor !== undefined) {
342
- return item.event.executor;
343
- }
344
- const role = leaseRole(item.event.label);
345
- const lease = [...leases]
346
- .reverse()
347
- .find((candidate) => candidate.at <= item.at && candidate.role === role && candidate.label === item.event.lane);
348
- return lease?.executor;
349
- };
350
- const modelCalls = allTimed.filter((item) => item.event.kind === "model");
351
- const models = [
352
- ...groupBy(modelCalls, (item) => `${item.event.label}\u0000${executorOf(item) ?? ""}`).values()
353
- ].map((calls) => {
354
- const sample = calls[0];
355
- const executor = sample === undefined ? undefined : executorOf(sample);
356
- const ms = sum(calls.map((item) => item.event.ms));
357
- const firsts = calls.flatMap((item) => item.event.firstMs === undefined ? [] : [item.event.firstMs]);
358
- const apis = calls.flatMap((item) => (item.event.apiMs === undefined ? [] : [item.event.apiMs]));
359
- const toolsReported = calls.flatMap((item) => item.event.toolMs === undefined ? [] : [item.event.toolMs]);
360
- const avgFirstMs = average(firsts);
361
- return {
362
- role: sample?.event.label ?? "",
363
- ...(executor === undefined ? {} : { executor }),
364
- calls: calls.length,
365
- ms,
366
- avgMs: ms / calls.length,
367
- ...(avgFirstMs === undefined ? {} : { avgFirstMs }),
368
- ...(apis.length === 0 ? {} : { apiMs: sum(apis) }),
369
- ...(toolsReported.length === 0 ? {} : { toolMs: sum(toolsReported) })
370
- };
371
- });
372
- const gates = [
373
- ...groupBy(allTimed.filter((item) => item.event.kind === "gate"), (item) => item.event.label).entries()
374
- ].map(([command, runs]) => {
375
- const ms = sum(runs.map((item) => item.event.ms));
376
- return {
377
- command,
378
- runs: runs.length,
379
- ms,
380
- avgMs: ms / runs.length,
381
- failed: runs.filter((item) => (item.event.exitCode ?? 0) !== 0 || item.event.failed === true)
382
- .length
383
- };
384
- });
385
- const counted = (kind) => [
386
- ...groupBy(allTimed.filter((item) => item.event.kind === kind), (item) => item.event.label).entries()
387
- ].map(([label, items]) => ({
388
- label,
389
- count: items.length,
390
- ms: sum(items.map((item) => item.event.ms))
391
- }));
392
- const tools = counted("tool");
393
- const toolCategories = [
394
- ...groupBy(allTimed.filter((item) => item.event.kind === "tool" && item.event.category !== undefined), (item) => item.event.category ?? "").entries()
395
- ]
396
- .map(([label, items]) => ({
397
- label,
398
- count: items.length,
399
- ms: sum(items.map((item) => item.event.ms))
400
- }))
401
- .sort((left, right) => right.ms - left.ms);
402
- const waits = counted("wait");
403
- const steps = stepStats(estimated ? [] : [...lanes.values()].flatMap(turnSteps));
404
- const outside = [
405
- ...groupBy(allTimed.filter((item) => item.event.lane === undefined), (item) => item.event.label).entries()
406
- ].map(([label, items]) => ({
407
- label,
408
- count: items.length,
409
- ms: sum(items.map((item) => item.event.ms))
410
- }));
411
- const merges = [...counted("merge"), ...counted("git")];
412
- const totals = stories.reduce((total, story) => ({
413
- model: total.model + story.time.model,
414
- tools: total.tools + story.time.tools,
415
- gates: total.gates + story.time.gates,
416
- merge: total.merge + story.time.merge,
417
- waiting: total.waiting + story.time.waiting,
418
- unaccounted: total.unaccounted + story.time.unaccounted
419
- }), { model: 0, tools: 0, gates: 0, merge: 0, waiting: 0, unaccounted: 0 });
420
- const storyTime = sum(stories.map((story) => story.wallMs));
421
- return {
422
- estimated,
423
- wallMs: first === undefined ? 0 : lastAt - first,
424
- stories,
425
- skipped,
426
- totals,
427
- models,
428
- gates,
429
- tools,
430
- toolCategories,
431
- waits,
432
- outside,
433
- findings: findingsOf({
434
- steps,
435
- toolCategories,
436
- stories,
437
- models,
438
- gates,
439
- tools,
440
- waits,
441
- merges,
442
- storyTime,
443
- estimated
444
- })
445
- };
446
- };
447
- const stepStats = (turns) => ({
448
- avgSteps: average(turns.map((turn) => turn.steps)) ?? 0,
449
- avgStepMs: average(turns.map((turn) => turn.stepMs)) ?? 0
450
- });
451
- /** Each finished coder turn's steps (its tool calls) and model time per step. */
452
- const turnSteps = (lane) => {
453
- const tools = lane.timed.filter((item) => item.event.kind === "tool");
454
- return lane.timed
455
- .filter((item) => item.event.kind === "model" && item.event.label === "coder")
456
- .map((turn) => {
457
- const start = turn.at - turn.event.ms;
458
- const inside = tools.filter((tool) => tool.at > start && tool.at <= turn.at);
459
- const toolMs = sum(inside.map((tool) => tool.event.ms));
460
- return {
461
- steps: inside.length,
462
- stepMs: Math.max(0, turn.event.ms - toolMs) / (inside.length + 1)
463
- };
464
- });
465
- };
466
- const storyProfile = (lane, lastAt, planReady, estimated) => {
467
- const wallMs = (lane.end ?? lastAt) - lane.start;
468
- const of = (kind) => sum(lane.timed.filter((item) => item.event.kind === kind).map((item) => item.event.ms));
469
- const coderCalls = lane.timed
470
- .filter((item) => item.event.kind === "model" && item.event.label === "coder")
471
- .map((item) => item.event.ms);
472
- const tools = of("tool");
473
- // Only a story still running has a turn in progress; in a finished one an
474
- // unclosed turn is a timing that never came, left to the gaps.
475
- const open = !estimated && lane.end === undefined && lane.openSince !== undefined;
476
- const openTurnMs = open && lane.openSince !== undefined ? lastAt - lane.openSince : 0;
477
- const model = estimated ? sum(lane.estimatedCalls) : Math.max(0, of("model") + openTurnMs - tools);
478
- const gates = of("gate");
479
- const merge = of("merge") + of("git");
480
- const waiting = of("wait");
481
- const turnTimes = estimated ? lane.estimatedCalls : coderCalls;
482
- const firstPrompt = lane.prompts[0];
483
- const lastPrompt = lane.prompts.at(-1);
484
- const maxPrompt = lane.prompts.length === 0 ? undefined : Math.max(...lane.prompts);
485
- return {
486
- gaps: estimated ? [] : gapsOf(lane),
487
- openTurnMs,
488
- openTurnTools: open ? lane.openTools : 0,
489
- toolCalls: { ...lane.toolCalls },
490
- tasks: lane.tasks,
491
- ...(lane.firstEdit === undefined ? {} : { firstEditMs: lane.firstEdit }),
492
- exploreBeforeEdit: lane.exploreBeforeEdit,
493
- unverifiedClaims: lane.unverifiedClaims,
494
- lowConfidenceTasks: lane.lowConfidenceTasks,
495
- ...(lane.reviewDiffLines === undefined ? {} : { reviewDiffLines: lane.reviewDiffLines }),
496
- id: lane.id,
497
- ...(lane.kind === "story" ? {} : { kind: lane.kind }),
498
- ...(lane.executor === undefined ? {} : { executor: lane.executor }),
499
- status: lane.status,
500
- wallMs,
501
- ...(planReady === undefined ? {} : { queuedMs: Math.max(0, lane.start - planReady) }),
502
- time: {
503
- model,
504
- tools,
505
- gates,
506
- merge,
507
- waiting,
508
- unaccounted: Math.max(0, wallMs - model - tools - gates - merge - waiting)
509
- },
510
- turns: {
511
- count: turnTimes.length,
512
- avgMs: average(turnTimes) ?? 0,
513
- maxMs: turnTimes.length === 0 ? 0 : Math.max(...turnTimes),
514
- ...(firstPrompt === undefined ? {} : { firstPrompt }),
515
- ...(lastPrompt === undefined ? {} : { lastPrompt }),
516
- ...(maxPrompt === undefined ? {} : { maxPrompt }),
517
- ...stepStats(estimated ? [] : turnSteps(lane)),
518
- promptGrowth: firstPrompt !== undefined &&
519
- lastPrompt !== undefined &&
520
- firstPrompt > 0 &&
521
- lastPrompt > 3 * firstPrompt
522
- }
523
- };
524
- };
525
- // ── words ───────────────────────────────────────────────────────────────────
526
- const stepsText = (steps) => `${Number.isInteger(steps) ? steps : steps.toFixed(1)} step${steps === 1 ? "" : "s"}`;
527
- const share = (ms, of) => (of <= 0 ? "" : `${Math.round((ms / of) * 100)}%`);
528
- const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
529
- /** What a report calls its lanes: their kind when they share one, stories when it has none. */
530
- const laneWord = (stories) => {
531
- const kinds = new Set(stories.map((story) => story.kind ?? "story"));
532
- const [kind = "story"] = kinds;
533
- return kinds.size > 1
534
- ? { one: "lane", many: "lanes" }
535
- : kind === "story"
536
- ? { one: "story", many: "stories" }
537
- : { one: kind, many: `${kind}s` };
538
- };
539
- const capitalized = (word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`;
540
- const findingsOf = (facts) => {
541
- const word = laneWord(facts.stories);
542
- const ofStories = (ms) => `${duration(ms)} (${share(ms, facts.storyTime)} of ${word.one} time)`;
543
- const byRole = [...groupBy(facts.models, (row) => row.role).entries()].map(([role, rows]) => {
544
- const calls = sum(rows.map((row) => row.calls));
545
- const ms = sum(rows.map((row) => row.ms));
546
- const firsts = rows.flatMap((row) => (row.avgFirstMs === undefined ? [] : [row.avgFirstMs]));
547
- const firstMs = average(firsts);
548
- const first = firstMs === undefined ? "" : `, first output after ${duration(firstMs)}`;
549
- return {
550
- ms,
551
- text: role === "coder"
552
- ? `coder turns: ${calls}, ${ofStories(ms)}, avg ${duration(ms / calls)}${first}${facts.steps.avgSteps > 0
553
- ? `, ${stepsText(facts.steps.avgSteps)} per turn, ${duration(facts.steps.avgStepMs)} of model per step`
554
- : ""}`
555
- : `${role} calls: ${calls}, ${ofStories(ms)}, avg ${duration(ms / calls)}${first}`
556
- };
557
- });
558
- const candidates = [
559
- ...byRole,
560
- ...facts.gates.map((row) => ({
561
- ms: row.ms,
562
- text: `gate \`${row.command}\`: ${plural(row.runs, "run")}, ${ofStories(row.ms)}${row.failed === 0 ? "" : `, ${row.failed} failed`}`
563
- })),
564
- // By kind of work when the trace has it (2.23), else by tool name.
565
- ...(facts.toolCategories.length > 0
566
- ? facts.toolCategories.map((row) => ({
567
- ms: row.ms,
568
- text: `coder tools, ${row.label}: ${plural(row.count, "call")}, ${ofStories(row.ms)}`
569
- }))
570
- : facts.tools.map((row) => ({
571
- ms: row.ms,
572
- text: `coder tool \`${row.label}\`: ${plural(row.count, "call")}, ${ofStories(row.ms)}`
573
- }))),
574
- ...facts.waits.map((row) => ({
575
- ms: row.ms,
576
- text: `waiting for the ${row.label}: ${row.count}×, ${ofStories(row.ms)}`
577
- })),
578
- ...facts.merges.map((row) => ({
579
- ms: row.ms,
580
- text: `${row.label}: ${row.count}×, ${ofStories(row.ms)}`
581
- }))
582
- ];
583
- for (const story of facts.stories) {
584
- if (story.openTurnMs >= 1_000) {
585
- candidates.push({
586
- ms: story.openTurnMs,
587
- text: `${story.id}: a coder turn still running for ${duration(story.openTurnMs)} (${plural(story.openTurnTools, "tool call")} so far)`
588
- });
589
- }
590
- }
591
- for (const story of facts.stories) {
592
- const wandered = story.firstEditMs !== undefined &&
593
- story.wallMs >= 60_000 &&
594
- (story.exploreBeforeEdit >= 15 ||
595
- (story.exploreBeforeEdit >= 5 && story.firstEditMs >= story.wallMs / 4));
596
- if (wandered && story.firstEditMs !== undefined) {
597
- candidates.push({
598
- ms: story.firstEditMs,
599
- text: `${story.id}: ${plural(story.exploreBeforeEdit, "explore call")} and ${duration(story.firstEditMs)} before the coder's first edit — it found its way instead of being told where to go`
600
- });
601
- }
602
- }
603
- const queued = sum(facts.stories.map((story) => story.queuedMs ?? 0));
604
- if (queued >= 1_000) {
605
- candidates.push({
606
- ms: queued,
607
- text: `stories queued before starting (dependencies, free coder slots): ${duration(queued)} in all`
608
- });
609
- }
610
- const estimated = facts.estimated
611
- ? facts.stories.map((story) => ({
612
- ms: story.time.model,
613
- text: `${story.id}: model calls (estimated) ${duration(story.time.model)} of ${duration(story.wallMs)}`
614
- }))
615
- : [];
616
- const ranked = [...candidates, ...estimated]
617
- .filter((candidate) => candidate.ms >= 1_000)
618
- .sort((left, right) => right.ms - left.ms)
619
- .slice(0, 3);
620
- const fabricated = facts.stories.flatMap((story) => story.unverifiedClaims > 0
621
- ? [
622
- {
623
- text: `${story.id}: the coder claimed ${plural(story.unverifiedClaims, "verification command")} that never ran — its account of its own testing is not evidence`
624
- }
625
- ]
626
- : []);
627
- const improvised = facts.stories.flatMap((story) => story.toolCalls.script >= 3
628
- ? [
629
- {
630
- text: `${story.id}: ${story.toolCalls.script} ad-hoc scripts — a pack tool preset (llm4ts tool) would make this repeatable`
631
- }
632
- ]
633
- : []);
634
- const growth = facts.stories.flatMap((story) => story.turns.promptGrowth &&
635
- story.turns.firstPrompt !== undefined &&
636
- story.turns.lastPrompt !== undefined
637
- ? [
638
- {
639
- text: `${story.id}: coder prompts grew ${(story.turns.lastPrompt / story.turns.firstPrompt).toFixed(1)}× (${formatCount(story.turns.firstPrompt)} → ${formatCount(story.turns.lastPrompt)} tokens): every turn resends the history`
640
- }
641
- ]
642
- : []);
643
- return [...ranked, ...fabricated, ...improvised, ...growth];
644
- };
645
- const table = (header, rows) => {
646
- const widths = header.map((title, column) => Math.max(title.length, ...rows.map((row) => (row[column] ?? "").length)));
647
- const line = (cells) => ` ${cells.map((cell, column) => (column === 0 ? cell.padEnd(widths[column] ?? 0) : cell.padStart(widths[column] ?? 0))).join(" ")}`.trimEnd();
648
- return [line(header), ...rows.map(line)];
649
- };
650
- export const renderProfile = (report) => {
651
- const storyTime = sum(report.stories.map((story) => story.wallMs));
652
- const word = laneWord(report.stories);
653
- const categories = [
654
- [report.estimated ? "model + tools + gates (estimated)" : "model", report.totals.model],
655
- ["coder tools", report.totals.tools],
656
- ["gates", report.totals.gates],
657
- ["git + merge", report.totals.merge],
658
- ["waiting", report.totals.waiting],
659
- ["unaccounted", report.totals.unaccounted]
660
- ];
661
- return [
662
- `llm4ts profile · ${duration(report.wallMs)} wall · ${report.stories.length} ${report.stories.length === 1 ? word.one : word.many}${report.estimated
663
- ? " · estimated: this trace has no timings (before 2.22), model time is read from gaps"
664
- : ""}`,
665
- "",
666
- "Where the time goes",
667
- ...(report.findings.length === 0
668
- ? [" nothing timed yet"]
669
- : report.findings.map((finding, index) => ` ${index + 1}. ${finding.text}`)),
670
- "",
671
- `${capitalized(word.one)} time`,
672
- ...table(["category", "time", "share"], categories.map(([name, ms]) => [name, duration(ms), share(ms, storyTime)])),
673
- "",
674
- capitalized(word.many),
675
- ...table([
676
- word.one,
677
- "status",
678
- "wall",
679
- "queued",
680
- "model",
681
- "tools",
682
- "gates",
683
- "git+merge",
684
- "wait",
685
- "other",
686
- "turns",
687
- "avg turn",
688
- "steps/turn",
689
- "model/step",
690
- "prompt ×"
691
- ], report.stories.map((story) => [
692
- story.id,
693
- story.status,
694
- duration(story.wallMs),
695
- story.queuedMs === undefined ? "" : duration(story.queuedMs),
696
- duration(story.time.model),
697
- duration(story.time.tools),
698
- duration(story.time.gates),
699
- duration(story.time.merge),
700
- duration(story.time.waiting),
701
- duration(story.time.unaccounted),
702
- String(story.turns.count),
703
- duration(story.turns.avgMs),
704
- story.turns.avgSteps === 0 ? "" : story.turns.avgSteps.toFixed(1),
705
- story.turns.avgSteps === 0 ? "" : duration(story.turns.avgStepMs),
706
- story.turns.firstPrompt !== undefined &&
707
- story.turns.lastPrompt !== undefined &&
708
- story.turns.firstPrompt > 0
709
- ? `${(story.turns.lastPrompt / story.turns.firstPrompt).toFixed(1)}×`
710
- : ""
711
- ])),
712
- ...(report.stories.every((story) => story.toolCalls.explore +
713
- story.toolCalls.edit +
714
- story.toolCalls.test +
715
- story.toolCalls.script +
716
- story.toolCalls.other ===
717
- 0)
718
- ? []
719
- : [
720
- "",
721
- `Coder work per ${word.one}`,
722
- ...table([
723
- word.one,
724
- "tasks",
725
- "explore",
726
- "edit",
727
- "test",
728
- "script",
729
- "other",
730
- "before 1st edit",
731
- "1st edit at"
732
- ], report.stories.map((story) => [
733
- story.id,
734
- String(story.tasks),
735
- String(story.toolCalls.explore),
736
- String(story.toolCalls.edit),
737
- String(story.toolCalls.test),
738
- String(story.toolCalls.script),
739
- String(story.toolCalls.other),
740
- story.firstEditMs === undefined ? "" : `${story.exploreBeforeEdit} explore`,
741
- story.firstEditMs === undefined ? "never" : duration(story.firstEditMs)
742
- ]))
743
- ]),
744
- ...(report.stories.every((story) => story.reviewDiffLines === undefined)
745
- ? []
746
- : [
747
- "",
748
- `Largest review diff per ${word.one}`,
749
- ...report.stories.flatMap((story) => story.reviewDiffLines === undefined
750
- ? []
751
- : [` ${story.id}: review diff ${story.reviewDiffLines} lines`])
752
- ]),
753
- ...(report.skipped.length === 0
754
- ? []
755
- : [` skipped (merged on an earlier run): ${report.skipped.join(", ")}`]),
756
- ...(report.stories.every((story) => story.gaps.length === 0)
757
- ? []
758
- : [
759
- "",
760
- "Unaccounted, largest gaps (no timing covers them)",
761
- ...report.stories.flatMap((story) => story.gaps.map((gap) => ` ${story.id}: ${duration(gap.ms)} between ${gap.after} and ${gap.before}`))
762
- ]),
763
- ...(report.outside.length === 0
764
- ? []
765
- : [
766
- "",
767
- `Timed outside any ${word.one} (the run's own planning, or a seat no ${word.one} owns)`,
768
- ...table(["label", "count", "total"], report.outside.map((row) => [row.label, String(row.count), duration(row.ms)]))
769
- ]),
770
- ...(report.models.length === 0
771
- ? []
772
- : [
773
- "",
774
- "Model calls",
775
- ...table([
776
- "role",
777
- "executor",
778
- "calls",
779
- "total",
780
- "avg",
781
- "first output",
782
- "api (reported)",
783
- "tools (reported)"
784
- ], report.models.map((row) => [
785
- row.role,
786
- row.executor ?? "",
787
- String(row.calls),
788
- duration(row.ms),
789
- duration(row.avgMs),
790
- row.avgFirstMs === undefined ? "" : duration(row.avgFirstMs),
791
- row.apiMs === undefined ? "" : duration(row.apiMs),
792
- row.toolMs === undefined ? "" : duration(row.toolMs)
793
- ]))
794
- ]),
795
- ...(report.gates.length === 0
796
- ? []
797
- : [
798
- "",
799
- "Gates",
800
- ...table(["command", "runs", "total", "avg", "failed"], report.gates.map((row) => [
801
- row.command,
802
- String(row.runs),
803
- duration(row.ms),
804
- duration(row.avgMs),
805
- String(row.failed)
806
- ]))
807
- ]),
808
- ...(report.toolCategories.length === 0
809
- ? []
810
- : [
811
- "",
812
- "Coder tools by kind",
813
- ...table(["kind", "calls", "total", "avg"], report.toolCategories.map((row) => [
814
- row.label,
815
- String(row.count),
816
- duration(row.ms),
817
- duration(row.ms / row.count)
818
- ]))
819
- ])
820
- ].join("\n");
821
- };
822
- // ── delta ───────────────────────────────────────────────────────────────────
823
- /** One line of a before/after comparison (ADR 0029): a count or a duration. */
824
- export const DeltaRow = Schema.Struct({
825
- label: Schema.String,
826
- before: Schema.Number,
827
- after: Schema.Number,
828
- unit: Schema.Literals(["count", "ms"]),
829
- /** Which direction is better, for the arrow; `none` for neutral facts. */
830
- better: Schema.Literals(["lower", "higher", "none"])
831
- });
832
- export const ProfileDelta = Schema.Struct({
833
- rows: Schema.Array(DeltaRow)
834
- });
835
- const roleCalls = (report, role) => sum(report.models.filter((row) => row.role === role).map((row) => row.calls));
836
- /**
837
- * The harness-evals comparison (`docs/harness-evals/README.md`), in its
838
- * order: how much the coders explored before editing, how often review and
839
- * judgment were called, what the gates said, how honest the claims were,
840
- * then where the time went. Tokens and cost are `llm4ts costs`' business.
841
- */
842
- export const profileDelta = (before, after) => {
843
- const count = (label, pick, better = "lower") => ({
844
- label,
845
- before: pick(before),
846
- after: pick(after),
847
- unit: "count",
848
- better
849
- });
850
- const time = (label, pick) => ({
851
- label,
852
- before: pick(before),
853
- after: pick(after),
854
- unit: "ms",
855
- better: "lower"
856
- });
857
- return {
858
- rows: [
859
- count("stories done", (r) => r.stories.filter((s) => s.status === "done").length, "higher"),
860
- count("stories failed", (r) => r.stories.filter((s) => s.status === "failed").length),
861
- count("explore calls before the first edit", (r) => sum(r.stories.map((s) => s.exploreBeforeEdit))),
862
- count("coder turns", (r) => sum(r.stories.map((s) => s.turns.count))),
863
- count("coder edits", (r) => sum(r.stories.map((s) => s.toolCalls.edit)), "none"),
864
- count("coder test runs", (r) => sum(r.stories.map((s) => s.toolCalls.test)), "none"),
865
- count("coder scripts", (r) => sum(r.stories.map((s) => s.toolCalls.script))),
866
- count("reviewer calls", (r) => roleCalls(r, "reviewer")),
867
- count("judge calls", (r) => roleCalls(r, "judge")),
868
- count("gate runs", (r) => sum(r.gates.map((g) => g.runs))),
869
- count("gate failures", (r) => sum(r.gates.map((g) => g.failed))),
870
- count("fabricated verification claims", (r) => sum(r.stories.map((s) => s.unverifiedClaims))),
871
- count("low-confidence tasks", (r) => sum(r.stories.map((s) => s.lowConfidenceTasks))),
872
- count("stories whose prompt grew over 3×", (r) => r.stories.filter((s) => s.turns.promptGrowth).length),
873
- time("wall", (r) => r.wallMs),
874
- time("model time", (r) => r.totals.model),
875
- time("coder tool time", (r) => r.totals.tools),
876
- time("gate time", (r) => r.totals.gates),
877
- time("waiting", (r) => r.totals.waiting)
878
- ]
879
- };
880
- };
881
- const deltaCell = (row) => {
882
- const diff = row.after - row.before;
883
- if (diff === 0) {
884
- return "=";
885
- }
886
- const shown = row.unit === "ms" ? duration(Math.abs(diff)) : String(Math.abs(diff));
887
- const sign = diff > 0 ? "+" : "-";
888
- const verdict = row.better === "none" ? "" : diff < 0 === (row.better === "lower") ? " better" : " worse";
889
- const percent = row.before === 0 ? "" : ` (${Math.round((diff / row.before) * 100)}%)`;
890
- return `${sign}${shown}${percent}${verdict}`;
891
- };
892
- export const renderProfileDelta = (delta, labels) => [
893
- `llm4ts profile · ${labels.before} → ${labels.after}`,
894
- "",
895
- ...table(["measure", "before", "after", "change"], delta.rows.map((row) => [
896
- row.label,
897
- row.unit === "ms" ? duration(row.before) : String(row.before),
898
- row.unit === "ms" ? duration(row.after) : String(row.after),
899
- deltaCell(row)
900
- ])),
901
- "",
902
- "* tokens and cost per role: llm4ts costs --repo <repo>"
903
- ].join("\n");
904
- const encodeReport = Schema.encodeSync(Schema.fromJsonString(ProfileReport));
905
- const decodeReport = Schema.decodeUnknownOption(Schema.fromJsonString(ProfileReport));
906
- const encodeDelta = Schema.encodeSync(Schema.fromJsonString(ProfileDelta));
907
- const reportAt = Effect.fn("@llm4ts/runner/Profile.reportAt")(function* (files, path) {
908
- if (path.endsWith(".json")) {
909
- const text = yield* files.read(path);
910
- const decoded = text === undefined ? undefined : decodeReport(text);
911
- if (decoded === undefined || decoded._tag === "None") {
912
- return yield* WatchTargetMissing.make({
913
- message: `${path} is not a profile report (write one with llm4ts profile --json)`
914
- });
915
- }
916
- return decoded.value;
917
- }
918
- return profileOf(treeInputsOfTrace(yield* readTrace(files, path)));
919
- });
920
- /**
921
- * The report for a trace, an epic's latest run, or the newest trace: text,
922
- * or JSON for comparing runs; with `against`, the delta from that earlier
923
- * run to this one.
924
- */
925
- export const makeProfileProgram = Effect.fn("@llm4ts/runner/Profile.make")(function* (options, sources) {
926
- const target = yield* resolveTraceTarget(options, sources);
927
- const report = profileOf(treeInputsOfTrace(yield* readTrace(sources.files, target.tracePath)));
928
- if (options.against !== undefined) {
929
- const before = yield* reportAt(sources.files, options.against);
930
- const delta = profileDelta(before, report);
931
- return options.json === true
932
- ? encodeDelta(delta)
933
- : renderProfileDelta(delta, { before: options.against, after: target.tracePath });
934
- }
935
- return options.json === true ? encodeReport(report) : renderProfile(report);
936
- });
937
- //# sourceMappingURL=Profile.js.map