paseo-bm-plugin 0.0.0-placeholder.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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/client/agent-tree.ts +308 -0
  4. package/client/answer-state.ts +62 -0
  5. package/client/bead-chips.tsx +147 -0
  6. package/client/beads-header-button.ts +108 -0
  7. package/client/beads-model.ts +581 -0
  8. package/client/beads-screen.tsx +516 -0
  9. package/client/beads-tab.tsx +58 -0
  10. package/client/chat-card.tsx +636 -0
  11. package/client/chat-cards.ts +1038 -0
  12. package/client/dashboard-actions.tsx +255 -0
  13. package/client/dashboard-model.ts +947 -0
  14. package/client/dashboard-view.ts +215 -0
  15. package/client/dashboard.tsx +318 -0
  16. package/client/launch-manager.ts +323 -0
  17. package/client/launcher.tsx +516 -0
  18. package/client/markdown-view.tsx +112 -0
  19. package/client/markdown.ts +145 -0
  20. package/client/settings.tsx +104 -0
  21. package/client/setup-model.ts +552 -0
  22. package/client/setup-screen.tsx +913 -0
  23. package/client/slot.ts +47 -0
  24. package/client/tree.tsx +204 -0
  25. package/client/ui.tsx +262 -0
  26. package/client/waiting-pills-model.ts +156 -0
  27. package/client/waiting-pills.tsx +201 -0
  28. package/index.client.tsx +232 -0
  29. package/index.server.ts +168 -0
  30. package/package.json +35 -0
  31. package/paseo-plugin.json +6 -0
  32. package/roles/manager.md +181 -0
  33. package/roles/reviewer.md +160 -0
  34. package/roles/worker.md +407 -0
  35. package/server/agent-labels.ts +194 -0
  36. package/server/agent-role.ts +102 -0
  37. package/server/answer-marks.ts +120 -0
  38. package/server/bead-actions.ts +88 -0
  39. package/server/bead-work.ts +80 -0
  40. package/server/beads-store.ts +342 -0
  41. package/server/bm-report.ts +433 -0
  42. package/server/chat-peers.ts +65 -0
  43. package/server/chat-rpc.ts +122 -0
  44. package/server/chat-waiting.ts +182 -0
  45. package/server/collector.ts +629 -0
  46. package/server/config-writer.ts +222 -0
  47. package/server/cost.ts +88 -0
  48. package/server/dashboard-rpc.ts +662 -0
  49. package/server/fallback-detect.ts +183 -0
  50. package/server/fallback-handover.ts +365 -0
  51. package/server/fallback-manager.ts +170 -0
  52. package/server/fallback-reviewer.ts +198 -0
  53. package/server/fallback-rpc.ts +306 -0
  54. package/server/fallback-settings.ts +322 -0
  55. package/server/fallback-state.ts +518 -0
  56. package/server/fallback-switch.ts +191 -0
  57. package/server/fallback-wait.ts +188 -0
  58. package/server/format-check.ts +352 -0
  59. package/server/install-home.ts +187 -0
  60. package/server/live-timeline.ts +129 -0
  61. package/server/manager-instructions.ts +9 -0
  62. package/server/manager.ts +647 -0
  63. package/server/model-costs.ts +238 -0
  64. package/server/notice-queue.ts +315 -0
  65. package/server/notices.ts +81 -0
  66. package/server/paseo-cli.ts +115 -0
  67. package/server/provider-id.ts +12 -0
  68. package/server/review-budget.ts +208 -0
  69. package/server/reviewer-instructions.ts +9 -0
  70. package/server/role-choices.ts +161 -0
  71. package/server/role-extras.ts +270 -0
  72. package/server/role-hook.ts +347 -0
  73. package/server/role-mode.ts +397 -0
  74. package/server/role-settings-rpc.ts +325 -0
  75. package/server/roles.ts +96 -0
  76. package/server/settings-notices.ts +112 -0
  77. package/server/setup-rpc.ts +70 -0
  78. package/server/setup-skills.ts +121 -0
  79. package/server/setup-tools.ts +162 -0
  80. package/server/shell.ts +68 -0
  81. package/server/stop-propagation.ts +365 -0
  82. package/server/tools-check.ts +118 -0
  83. package/server/trace-store.ts +1137 -0
  84. package/server/traces.ts +1356 -0
  85. package/server/worker-instructions.ts +9 -0
  86. package/server/workflow-steps.ts +422 -0
  87. package/shared/bead-ids.ts +25 -0
  88. package/shared/bm-fallback.ts +91 -0
  89. package/shared/bm-format.ts +424 -0
  90. package/shared/bm-questions.ts +213 -0
  91. package/shared/bm-report.ts +433 -0
  92. package/shared/contracts.ts +1371 -0
  93. package/shared/fallback-patterns.ts +201 -0
  94. package/shared/fallback.ts +46 -0
  95. package/shared/new-request.ts +20 -0
  96. package/shared/order.ts +22 -0
  97. package/shared/prices.ts +65 -0
  98. package/shared/settings.ts +57 -0
  99. package/shared/sole-worker.ts +20 -0
  100. package/shared/version.ts +6 -0
  101. package/tsconfig.json +16 -0
@@ -0,0 +1,1356 @@
1
+ /**
2
+ * Trace reconstruction (WP-206.1, Dashboard Design §6, §7.3).
3
+ *
4
+ * Turns the store's per-turn records plus the current agent list into one row
5
+ * per *user request*. Two rules are absolute, and both exist because a
6
+ * confident wrong answer is worse than an honest gap:
7
+ *
8
+ * 1. **Never attach an agent to the nearest trace on a hunch.** When nothing
9
+ * links it, it goes to the unknown group and the row says `linking:
10
+ * "unknown"` (REQ-041d).
11
+ * 2. **One Manager user request is one trace.** A Worker's `BM-REPORT`, which
12
+ * arrives in the Manager's timeline as a `user_message` (confirmed against
13
+ * the shipped daemon in WP-205.2), never opens a trace of its own.
14
+ *
15
+ * This module is pure: records in, rows out. No filesystem, no SDK, no clock.
16
+ */
17
+ import { looksLikeReport, requestIdFromText } from "./bm-report";
18
+ import { isPluginNotice } from "./notices";
19
+ import { brActions } from "./shell";
20
+ import { byAt, uniqueBy } from "../shared/order";
21
+ import type {
22
+ AgentTiming,
23
+ Confidence,
24
+ Evidence,
25
+ GuardrailReport,
26
+ ParsedReport,
27
+ ParsedReview,
28
+ RuntimeRow,
29
+ SubAgentTrace,
30
+ Tier,
31
+ TraceBead,
32
+ TraceDetail,
33
+ TraceMessage,
34
+ TraceRecord,
35
+ TraceState,
36
+ TraceSummary,
37
+ TurnTiming,
38
+ WorkflowStepResult,
39
+ Usage,
40
+ WorkspaceState,
41
+ } from "../shared/contracts";
42
+
43
+ /** Agent facts reconstruction needs, from `agents.list` or the store. */
44
+ export interface AgentFacts {
45
+ id: string;
46
+ role: "manager" | "worker" | "reviewer" | "unknown";
47
+ status: string;
48
+ parentAgentId: string | null;
49
+ createdAt: string | null;
50
+ /** `bm.requestId` label when the agent carries one (REQ-051). */
51
+ requestIdLabel: string | null;
52
+ /** `bm.batchId` label when the agent carries one. */
53
+ batchIdLabel: string | null;
54
+ archived: boolean;
55
+ /** The agent's title in Paseo, when known. */
56
+ title?: string | null;
57
+ /**
58
+ * False when the role came from the provider only: the agent carries no valid
59
+ * `bm.role` label (delta 20260918g §4.1). Absent means labelled.
60
+ */
61
+ labelled?: boolean;
62
+ /** `bm.replacedBy` label: the agent that took over after a fallback switch (delta 20260921 §4.4.7). */
63
+ replacedBy?: string | null;
64
+ }
65
+
66
+ /** One reconstructed request, before timing and bead enrichment (WP-206.1.2). */
67
+ export interface ReconstructedTrace {
68
+ traceId: string;
69
+ requestId: string | null;
70
+ requestedAt: string;
71
+ /** Null when collection started after the request did (no user turn captured). */
72
+ requestText: string | null;
73
+ managerAgentId: string | null;
74
+ workerIds: string[];
75
+ reviewerIds: string[];
76
+ /** Turn records that belong to this trace, oldest first. */
77
+ records: TraceRecord[];
78
+ reports: ParsedReport[];
79
+ reviews: ParsedReview[];
80
+ reviewCalls: number | null;
81
+ guardrailReported: GuardrailReport | null;
82
+ tier: Tier | null;
83
+ state: TraceState;
84
+ linking: Confidence;
85
+ agentsMissing: string[];
86
+ notices: string[];
87
+ /** The request's turns, oldest first. Always at least one (delta 20260917e §4.3). */
88
+ segments: TraceSegment[];
89
+ }
90
+
91
+ /**
92
+ * One turn of the conversation inside a request: what the user asked, and
93
+ * everything that happened until they asked again.
94
+ *
95
+ * The owner asked for a follow-up to show as its own flow instead of being
96
+ * folded into one hard-to-trace row (delta 20260917e §4.3). A segment opens at
97
+ * a Manager turn whose first inbound message is the USER's — `origin: "user"`,
98
+ * which the collector sets only when the timeline item carried a
99
+ * `clientMessageId`.
100
+ *
101
+ * That field, not the wording, is what makes this safe. A Worker's status
102
+ * update reaches its Manager as a `user_message` too and is indistinguishable
103
+ * by text — WP-214 defect 9, which is why unnamed turns are folded at all. It
104
+ * carries `origin: "agent"`, so it never opens a segment. Records written
105
+ * before `origin` existed have none, and AGENTS.md forbids reading that absence
106
+ * as `user`: such a request stays a single segment.
107
+ */
108
+ export interface TraceSegment {
109
+ /** 1-based, in time order. */
110
+ index: number;
111
+ startedAt: string;
112
+ /** The user's message that opened this segment; null when the opening turn was never captured. */
113
+ text: string | null;
114
+ /** Records of this trace inside the segment's window, oldest first. */
115
+ records: TraceRecord[];
116
+ /** Reports that arrived while this segment was open. */
117
+ reports: ParsedReport[];
118
+ }
119
+
120
+ /**
121
+ * Splits a finished trace into its turns.
122
+ *
123
+ * Boundaries are the Manager turns the USER opened. Everything before the first
124
+ * such turn still belongs to segment 1, so no record is ever dropped: a request
125
+ * whose opening turn was not captured, or one written before the `origin` field
126
+ * existed, comes back as exactly one segment covering everything.
127
+ */
128
+ function segmentsOf(trace: ReconstructedTrace): TraceSegment[] {
129
+ const opens: Array<{ at: string; text: string }> = [];
130
+ for (const record of trace.records) {
131
+ if (record.role !== "manager") continue;
132
+ const message = firstUserMessage(record);
133
+ // `origin` is absent on records written before the collector recorded it.
134
+ // Absence is NOT "user" (AGENTS.md), so such a record opens nothing.
135
+ if (message?.origin !== "user") continue;
136
+ opens.push({ at: message.at, text: message.text });
137
+ }
138
+ opens.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
139
+
140
+ const first = trace.records[0]?.at ?? trace.requestedAt;
141
+ // The opening segment starts with the trace itself unless the user's first
142
+ // message is what started it.
143
+ const heads =
144
+ opens.length > 0 && opens[0]!.at <= first
145
+ ? opens
146
+ : [{ at: first, text: null as string | null }, ...opens];
147
+
148
+ const segments: TraceSegment[] = heads.map((head, index) => ({
149
+ index: index + 1,
150
+ startedAt: head.at,
151
+ text: head.text,
152
+ records: [],
153
+ reports: [],
154
+ }));
155
+
156
+ const segmentAt = (at: string): TraceSegment => {
157
+ let chosen = segments[0]!;
158
+ for (const segment of segments) {
159
+ if (segment.startedAt <= at) chosen = segment;
160
+ else break;
161
+ }
162
+ return chosen;
163
+ };
164
+
165
+ for (const record of trace.records) segmentAt(record.at).records.push(record);
166
+ for (const report of trace.reports) segmentAt(report.at).reports.push(report);
167
+ return segments;
168
+ }
169
+
170
+ /** First inbound message that is neither a role report nor one of the plugin's own notices. */
171
+ function firstUserMessage(record: TraceRecord): TraceMessage | null {
172
+ for (const message of record.sent) {
173
+ if (!looksLikeReport(message.text) && !isPluginNotice(message.text)) return message;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ function firstUserText(record: TraceRecord): string | null {
179
+ return firstUserMessage(record)?.text ?? null;
180
+ }
181
+
182
+ /**
183
+ * Trace key of a Manager turn: stable across reads so the UI can link to it.
184
+ * Prefers the request id, which survives a re-read even if the Manager's turn
185
+ * ids change.
186
+ */
187
+ export function traceIdFor(record: TraceRecord): string {
188
+ return record.requestId !== null ? `req:${record.requestId}` : `${record.agentId}:${record.turnId ?? record.at}`;
189
+ }
190
+
191
+ interface Bucket {
192
+ trace: ReconstructedTrace;
193
+ /** Window used for time-based inference: request time to last activity. */
194
+ from: string;
195
+ to: string;
196
+ }
197
+
198
+ /**
199
+ * Request id of a Manager turn, when the record itself does not carry one.
200
+ *
201
+ * Found in the acceptance run (WP-214): the Manager *generates* the id during
202
+ * the turn that opens the request, so it is not in the incoming message. It
203
+ * shows up in the Manager's own reply ("- `requestId`: `req-…`"), so that is
204
+ * read too. It is deliberately NOT read from the `date` command the Manager
205
+ * runs to build the id: that command carries a format string
206
+ * (`date -u +req-%Y%m%dT%H%M%SZ`), not the id.
207
+ */
208
+ export function managerRequestId(record: TraceRecord): string | null {
209
+ if (record.requestId !== null) return record.requestId;
210
+ for (const message of [...record.received, ...record.sent]) {
211
+ const found = requestIdFromText(message.text);
212
+ if (found !== null) return found;
213
+ }
214
+ return null;
215
+ }
216
+
217
+ /** Said once, retracted in one place: see the fold below. */
218
+ const MISSING_REQUEST_TEXT =
219
+ "The request text was not recorded: collection started after this request began.";
220
+
221
+ /**
222
+ * A report belongs to the request it names, and to no other.
223
+ *
224
+ * WP-214 acceptance: one malformed record (defect 10) carried F-1's request id
225
+ * while holding F-2's reports, and F-1's state, tier and guardrail were then
226
+ * read off F-2's `finished` report. A report that names a different request is
227
+ * never evidence about this one, whatever record it arrived in.
228
+ */
229
+ function reportsBelongingTo(requestId: string | null, reports: readonly ParsedReport[]): ParsedReport[] {
230
+ return reports.filter((report) => report.requestId === null || report.requestId === requestId);
231
+ }
232
+
233
+ /**
234
+ * Opens one bucket per request the Manager handled.
235
+ *
236
+ * A request is keyed by its id, so N Manager turns of one request are one row.
237
+ * A Manager turn whose only inbound message is a `BM-REPORT` never opens a
238
+ * request of its own.
239
+ */
240
+ function openBuckets(records: readonly TraceRecord[]): { buckets: Bucket[]; byRequestId: Map<string, Bucket> } {
241
+ const managerRecords = records.filter((record) => record.role === "manager");
242
+ // Computed once per turn: the passes below read them repeatedly, and D-1's
243
+ // threshold is stated at up to 500 traces.
244
+ const idOf = managerRecords.map((record) => managerRequestId(record));
245
+ const textOf = managerRecords.map((record) => firstUserText(record));
246
+ const buckets: Bucket[] = [];
247
+ const byRequestId = new Map<string, Bucket>();
248
+
249
+ const newBucket = (record: TraceRecord, requestId: string | null, text: string | null): void => {
250
+ const at = record.sent[0]?.at ?? record.at;
251
+ const bucket: Bucket = {
252
+ trace: {
253
+ traceId: requestId === null ? traceIdFor(record) : `req:${requestId}`,
254
+ requestId,
255
+ requestedAt: at,
256
+ requestText: text,
257
+ managerAgentId: record.agentId,
258
+ workerIds: [],
259
+ reviewerIds: [],
260
+ records: [record],
261
+ reports: reportsBelongingTo(requestId, record.reports),
262
+ reviews: [...record.reviews],
263
+ reviewCalls: null,
264
+ guardrailReported: null,
265
+ tier: null,
266
+ state: "unknown",
267
+ linking: requestId !== null ? "exact" : "unknown",
268
+ agentsMissing: [],
269
+ notices: [],
270
+ segments: [],
271
+ },
272
+ from: at,
273
+ to: record.at,
274
+ };
275
+ buckets.push(bucket);
276
+ if (requestId !== null) byRequestId.set(requestId, bucket);
277
+ };
278
+
279
+ /** Folds one more Manager turn into a request already open. */
280
+ const fold = (bucket: Bucket, record: TraceRecord, text: string | null): void => {
281
+ const at = record.sent[0]?.at ?? record.at;
282
+ // The earliest user message of a request is the request; later ones are
283
+ // follow-ups, and a Worker's status update is neither.
284
+ if (text !== null && (bucket.trace.requestText === null || at < bucket.trace.requestedAt)) {
285
+ bucket.trace.requestText = text;
286
+ bucket.trace.requestedAt = at;
287
+ }
288
+ if (at < bucket.from) bucket.from = at;
289
+ if (bucket.to < record.at) bucket.to = record.at;
290
+ bucket.trace.records.push(record);
291
+ bucket.trace.reports.push(...reportsBelongingTo(bucket.trace.requestId, record.reports));
292
+ bucket.trace.reviews.push(...record.reviews);
293
+ };
294
+
295
+ // Pass 1: every request the Manager actually named gets exactly one bucket.
296
+ managerRecords.forEach((record, index) => {
297
+ const requestId = idOf[index]!;
298
+ if (requestId === null) return;
299
+ const existing = byRequestId.get(requestId);
300
+ if (existing === undefined) newBucket(record, requestId, textOf[index]!);
301
+ else fold(existing, record, textOf[index]!);
302
+ });
303
+
304
+ // Pass 2: a Manager turn that names no request belongs to the request the
305
+ // SAME Manager names NEXT.
306
+ //
307
+ // WP-214 acceptance, defect 9: a Worker's status update to its Manager
308
+ // arrives as a `user_message`, exactly like a user request, and opened a row
309
+ // of its own with no worker, no beads and eleven unknown steps. Nothing
310
+ // textual separates the two, but the Manager's own next word does. A user's
311
+ // follow-up instruction lands on its request for the same reason.
312
+ //
313
+ // That reason only holds inside ONE Manager's conversation, so every step
314
+ // below runs over one Manager's own turns. Workspace-wide, a turn of an
315
+ // archived Manager reached the next Manager's request across eighteen hours
316
+ // and carried its text, its time and its tokens into that row (delta
317
+ // 20260917d defect A, observed on the owner's workspace).
318
+ const turnsOfManager = new Map<string, number[]>();
319
+ managerRecords.forEach((record, index) => {
320
+ const own = turnsOfManager.get(record.agentId);
321
+ if (own === undefined) turnsOfManager.set(record.agentId, [index]);
322
+ else own.push(index);
323
+ });
324
+
325
+ for (const own of turnsOfManager.values()) {
326
+ // Positions are into `own`; `own[position]` indexes `managerRecords`.
327
+ const nextNamed: (string | null)[] = new Array(own.length).fill(null);
328
+ for (let position = own.length - 2; position >= 0; position -= 1) {
329
+ nextNamed[position] = idOf[own[position + 1]!] ?? nextNamed[position + 1] ?? null;
330
+ }
331
+ const firstPosition = new Map<string, number>();
332
+ own.forEach((index, position) => {
333
+ const id = idOf[index]!;
334
+ if (id !== null && !firstPosition.has(id)) firstPosition.set(id, position);
335
+ });
336
+ // A message can close one request and open another ("(a) keep it. New
337
+ // request: CSV export"). If a request OPENS before the next unnamed user
338
+ // turn, the message started that request; without this, WP-214 F-4 was
339
+ // timed from a later answer, four minutes late.
340
+ const openedAfter = (position: number): string | null => {
341
+ for (let later = position + 1; later < own.length; later += 1) {
342
+ const index = own[later]!;
343
+ const id = idOf[index]!;
344
+ if (id === null) {
345
+ if (textOf[index] !== null) return null;
346
+ } else if (firstPosition.get(id) === later) {
347
+ return id;
348
+ }
349
+ }
350
+ return null;
351
+ };
352
+
353
+ own.forEach((index, position) => {
354
+ if (idOf[index] !== null) return;
355
+ const record = managerRecords[index]!;
356
+ const text = textOf[index]!;
357
+ const nextRequestId = (text === null ? null : openedAfter(position)) ?? nextNamed[position] ?? null;
358
+ const bucket = nextRequestId === null ? undefined : byRequestId.get(nextRequestId);
359
+ if (bucket !== undefined) fold(bucket, record, text);
360
+ // Nothing follows from this Manager: a request still in flight gets a
361
+ // provisional row; a turn without user text has nothing to show.
362
+ else if (text !== null) newBucket(record, null, text);
363
+ });
364
+ }
365
+
366
+ for (const bucket of buckets) {
367
+ if (bucket.trace.requestText === null) bucket.trace.notices.push(MISSING_REQUEST_TEXT);
368
+ }
369
+ buckets.sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
370
+ return { buckets, byRequestId };
371
+ }
372
+
373
+ const BARE_REQUEST_ID = /\breq-\d{8}T\d{6}Z\b/g;
374
+
375
+ /**
376
+ * Request id of an agent, in the order design §6 step 3 fixes: label → its own
377
+ * records and reports → its prompts → the bare id its messages name most.
378
+ * Returns the confidence with it so the caller can tell a fact from an
379
+ * inference.
380
+ */
381
+ export function requestIdOfAgent(
382
+ agent: AgentFacts,
383
+ records: readonly TraceRecord[],
384
+ ): { requestId: string | null; confidence: Confidence } {
385
+ if (agent.requestIdLabel !== null) return { requestId: agent.requestIdLabel, confidence: "exact" };
386
+ const own = records.filter((record) => record.agentId === agent.id);
387
+ for (const record of own) {
388
+ if (record.requestId !== null) return { requestId: record.requestId, confidence: "exact" };
389
+ for (const report of record.reports) {
390
+ if (report.requestId !== null) return { requestId: report.requestId, confidence: "exact" };
391
+ }
392
+ }
393
+ for (const record of own) {
394
+ for (const message of record.sent) {
395
+ const fromPrompt = requestIdFromText(message.text);
396
+ if (fromPrompt !== null) return { requestId: fromPrompt, confidence: "exact" };
397
+ }
398
+ }
399
+ // The id written bare, e.g. "TIẾP TỤC LÀM VIỆC — `req-20260916T081749Z`".
400
+ // Managers from paseo-bm 0.1.0 wrote it that way and set no label, and a
401
+ // Worker's messages also cite other requests for cross-reference. On the
402
+ // owner's workspace one Worker's messages named its request 5 times and
403
+ // three others once each. The id named more often than all others together
404
+ // is the agent's request; anything less decisive says nothing.
405
+ const counts = new Map<string, number>();
406
+ for (const record of own) {
407
+ for (const message of record.sent) {
408
+ for (const id of new Set(message.text.match(BARE_REQUEST_ID) ?? [])) counts.set(id, (counts.get(id) ?? 0) + 1);
409
+ }
410
+ }
411
+ const total = [...counts.values()].reduce((sum, count) => sum + count, 0);
412
+ const [top] = [...counts.entries()].sort((a, b) => b[1] - a[1]);
413
+ if (top !== undefined && top[1] > total - top[1]) return { requestId: top[0], confidence: "inferred" };
414
+ return { requestId: null, confidence: "unknown" };
415
+ }
416
+
417
+ /**
418
+ * Buckets an agent by time: its creation must fall inside exactly one request
419
+ * window, or after exactly one request's start.
420
+ *
421
+ * Two matching windows return null rather than a guess. With per-turn records
422
+ * that is hard to reach — a Manager handles one turn at a time, so request
423
+ * windows do not overlap — but the branch stays as the defence that keeps the
424
+ * "never attach on a hunch" rule true if the record shape ever changes.
425
+ *
426
+ * An agent created **before every request** also returns null: there is nothing
427
+ * it could plausibly belong to.
428
+ */
429
+ function bucketByTime(buckets: Bucket[], createdAt: string | null): Bucket | null {
430
+ if (createdAt === null) return null;
431
+ const candidates = buckets.filter((bucket) => createdAt >= bucket.from && createdAt <= bucket.to);
432
+ if (candidates.length === 1) return candidates[0] ?? null;
433
+ if (candidates.length > 1) return null;
434
+ // Nothing contains it: fall back to the most recent request that started
435
+ // before it, but only when that request is unambiguous (exactly one).
436
+ const before = buckets.filter((bucket) => createdAt >= bucket.from);
437
+ return before.length === 1 ? (before[0] ?? null) : null;
438
+ }
439
+
440
+ /**
441
+ * Number of review requests a Reviewer received: its inbound non-`BM-REVIEW` messages.
442
+ *
443
+ * `replacementIds` are Reviewers that took over from one stopped on its
444
+ * provider plan (delta 20260921 §4.5.1). The Worker sends each of them,
445
+ * unchanged, the review message the old one got: the same review call, so the
446
+ * FIRST message each would count is skipped and every later one counts. A
447
+ * replacement the Worker did not label `bm.replaces` is not in the set and its
448
+ * resend IS counted — the safe failure: `BM-BUDGET`, and the Manager asks the user.
449
+ */
450
+ export function reviewCallsOf(
451
+ reviewerIds: readonly string[],
452
+ records: readonly TraceRecord[],
453
+ replacementIds: Iterable<string> = [],
454
+ ): number | null {
455
+ // Replacements whose resend has not been met yet.
456
+ const resendPending = new Set(replacementIds);
457
+ let calls = 0;
458
+ let seen = false;
459
+ for (const record of records) {
460
+ if (!reviewerIds.includes(record.agentId)) continue;
461
+ seen = true;
462
+ for (const message of record.sent) {
463
+ // Neither a Reviewer's own BM-REVIEW nor the plugin's stop notice
464
+ // (stop-propagation.ts) is a review request.
465
+ if (/^\s*>?\s*(?:[-*]\s*)?bm-review\b/im.test(message.text)) continue;
466
+ if (isPluginNotice(message.text)) continue;
467
+ if (resendPending.delete(record.agentId)) continue;
468
+ calls += 1;
469
+ }
470
+ }
471
+ // No Reviewer turn was recorded (collection started later, or the records
472
+ // were deleted): the number of calls is unknown, not zero. The owner's
473
+ // workspace showed "6 reviewers · 0 review calls" for exactly this reason.
474
+ return seen ? calls : null;
475
+ }
476
+
477
+ /**
478
+ * True when a `finished` report's `blockers` says something is still open.
479
+ *
480
+ * The report format writes `none` for an empty field, and Workers add notes and
481
+ * `Suggestion (not done): …` items after it (worker.md asks for suggestions in
482
+ * the finished report). WP-214 acceptance: two delivered requests read as
483
+ * "Stopped" because their blockers were "none — user chose (a) …" and
484
+ * "none. Suggestion (not done): …".
485
+ */
486
+ export function blocksCompletion(blockers: string | null): boolean {
487
+ if (blockers === null) return false;
488
+ const text = blockers.trim();
489
+ if (text === "" || /^none\b/i.test(text)) return false;
490
+ const withoutSuggestions = text.replace(/Suggestion \(not done\):[^]*?(?=Suggestion \(not done\):|$)/gi, "").trim();
491
+ return withoutSuggestions !== "";
492
+ }
493
+
494
+ /**
495
+ * State of a trace, in the precedence order of design §7.3. Order matters: a
496
+ * running agent outranks a `blocked` report, because "still working" is what
497
+ * the user needs to know first.
498
+ */
499
+ export function stateOf(
500
+ trace: Pick<ReconstructedTrace, "workerIds" | "reviewerIds" | "reports">,
501
+ agents: ReadonlyMap<string, AgentFacts>,
502
+ records: readonly TraceRecord[],
503
+ ): TraceState {
504
+ const own = [...trace.workerIds, ...trace.reviewerIds]
505
+ .map((id) => agents.get(id))
506
+ .filter((agent): agent is AgentFacts => agent !== undefined);
507
+
508
+ if (own.some((agent) => agent.status === "running")) return "running";
509
+
510
+ const lastReport = trace.reports.at(-1);
511
+ if (lastReport?.phase === "blocked") return "waiting_user";
512
+
513
+ const failedTurn = records.some((record) => record.outcome === "failed");
514
+ if (own.some((agent) => agent.status === "error") || failedTurn) return "failed";
515
+
516
+ // `worker.md` sends `finished` both when the work is done AND when the Worker
517
+ // was stopped, so the phase alone does not mean success. The Worker says which
518
+ // it was in `blockers`; a stopped run names what it did not deliver.
519
+ // (Found in the WP-214 acceptance run, where a stopped request read as
520
+ // "Completed".)
521
+ //
522
+ // Only the LAST report decides. An earlier `canceled` turn is history, not a
523
+ // verdict: F-1 of that same run was interrupted four times and then finished
524
+ // properly, and reading "any canceled turn" as a stop reported the delivered
525
+ // request as "Stopped" — the mirror image of the first defect. A Worker
526
+ // killed so hard it never reported at all does not reach this branch and
527
+ // falls through to the `stopped` default below.
528
+ if (lastReport?.phase === "finished") {
529
+ return blocksCompletion(lastReport.blockers) ? "stopped" : "completed";
530
+ }
531
+
532
+ if (trace.workerIds.length > 0) return "stopped";
533
+ return "unknown";
534
+ }
535
+
536
+ export interface ReconstructOptions {
537
+ records: readonly TraceRecord[];
538
+ agents: readonly AgentFacts[];
539
+ /**
540
+ * Reviewers that replaced a stopped one (delta 20260921 §4.5.1): their first
541
+ * message is not a new review call (`reviewCallsOf`). None by default.
542
+ */
543
+ replacementIds?: Iterable<string>;
544
+ }
545
+
546
+ /**
547
+ * Rebuilds every trace of one workspace.
548
+ *
549
+ * The unknown group is returned as its own trace with `linking: "unknown"` so
550
+ * nothing is silently dropped: an agent whose request cannot be determined is
551
+ * still visible, just not attributed.
552
+ */
553
+ export function reconstructTraces(options: ReconstructOptions): ReconstructedTrace[] {
554
+ const { records, agents } = options;
555
+ const replacementIds = new Set(options.replacementIds ?? []);
556
+ const ordered = [...records].sort(byAt);
557
+ const agentById = new Map(agents.map((agent) => [agent.id, agent]));
558
+ const { buckets, byRequestId: byRequest } = openBuckets(ordered);
559
+
560
+ const unknown: ReconstructedTrace = {
561
+ traceId: "unknown",
562
+ requestId: null,
563
+ requestedAt: ordered[0]?.at ?? "",
564
+ requestText: null,
565
+ managerAgentId: null,
566
+ workerIds: [],
567
+ reviewerIds: [],
568
+ records: [],
569
+ reports: [],
570
+ reviews: [],
571
+ reviewCalls: null,
572
+ guardrailReported: null,
573
+ tier: null,
574
+ state: "unknown",
575
+ linking: "unknown",
576
+ agentsMissing: [],
577
+ notices: ["These agents could not be linked to a request."],
578
+ segments: [],
579
+ };
580
+
581
+ // Reports that arrived in the Manager's timeline belong to the trace they name.
582
+ for (const record of ordered) {
583
+ if (record.role !== "manager") continue;
584
+ for (const report of record.reports) {
585
+ const bucket = report.requestId === null ? undefined : byRequest.get(report.requestId);
586
+ if (bucket === undefined) continue;
587
+ bucket.trace.reports.push(report);
588
+ if (bucket.to < record.at) bucket.to = record.at;
589
+ }
590
+ }
591
+
592
+ const assignWorker = (agent: AgentFacts): Bucket | null => {
593
+ const { requestId } = requestIdOfAgent(agent, ordered);
594
+ if (requestId !== null) {
595
+ const bucket = byRequest.get(requestId);
596
+ if (bucket !== undefined) return bucket;
597
+ // The agent names its request, but no Manager turn recorded that id. When
598
+ // its creation falls inside exactly one request window, that window is the
599
+ // request it names: adopt the id rather than calling the link a guess.
600
+ const containing = buckets.filter(
601
+ (candidate) =>
602
+ candidate.trace.requestId === null &&
603
+ agent.createdAt !== null &&
604
+ agent.createdAt >= candidate.from &&
605
+ agent.createdAt <= candidate.to,
606
+ );
607
+ if (containing.length === 1) {
608
+ const adopted = containing[0]!;
609
+ adopted.trace.requestId = requestId;
610
+ adopted.trace.traceId = `req:${requestId}`;
611
+ adopted.trace.linking = "exact";
612
+ byRequest.set(requestId, adopted);
613
+ return adopted;
614
+ }
615
+ // The agent says which request it belongs to, and that request is not
616
+ // here (its trace was deleted, or never recorded). Attaching it to
617
+ // another request by time would contradict its own label — WP-214 D-9:
618
+ // after deleting one trace, its Worker was linked to a different Large
619
+ // request because its creation fell inside that request's window.
620
+ return null;
621
+ }
622
+ const inferred = bucketByTime(buckets, agent.createdAt);
623
+ if (inferred !== null) {
624
+ // The row's linking describes how its agents were attached, so a time
625
+ // link downgrades an exact row and upgrades an unknown one.
626
+ inferred.trace.linking = "inferred";
627
+ inferred.trace.notices.push(`Agent ${agent.id} was linked by time, not by request id.`);
628
+ return inferred;
629
+ }
630
+ return null;
631
+ };
632
+
633
+ for (const agent of agents) {
634
+ if (agent.role !== "worker") continue;
635
+ const bucket = assignWorker(agent);
636
+ if (bucket === null) {
637
+ unknown.workerIds.push(agent.id);
638
+ continue;
639
+ }
640
+ bucket.trace.workerIds.push(agent.id);
641
+ if (agent.archived) {
642
+ bucket.trace.notices.push(`Worker ${agent.id} has been archived.`);
643
+ }
644
+ }
645
+
646
+ for (const agent of agents) {
647
+ if (agent.role !== "reviewer") continue;
648
+ // The Reviewer's own label names its request exactly; the parent is only a
649
+ // fallback. On the owner's workspace six Reviewers labelled with one
650
+ // request followed their parent Worker into a different, time-linked one.
651
+ if (agent.requestIdLabel !== null) {
652
+ const labelled = byRequest.get(agent.requestIdLabel);
653
+ if (labelled !== undefined) {
654
+ labelled.trace.reviewerIds.push(agent.id);
655
+ continue;
656
+ }
657
+ }
658
+ const owner =
659
+ agent.parentAgentId === null
660
+ ? undefined
661
+ : buckets.find((bucket) => bucket.trace.workerIds.includes(agent.parentAgentId!));
662
+ if (owner !== undefined) {
663
+ owner.trace.reviewerIds.push(agent.id);
664
+ continue;
665
+ }
666
+ // No parent in this workspace: try what the agent itself says, then give up honestly.
667
+ const { requestId } = requestIdOfAgent(agent, ordered);
668
+ const byLabel = requestId === null ? undefined : byRequest.get(requestId);
669
+ if (byLabel !== undefined) {
670
+ byLabel.trace.reviewerIds.push(agent.id);
671
+ byLabel.trace.notices.push(`Reviewer ${agent.id} was linked by request id; its Worker is not here.`);
672
+ continue;
673
+ }
674
+ unknown.reviewerIds.push(agent.id);
675
+ }
676
+
677
+ for (const bucket of buckets) {
678
+ const trace = bucket.trace;
679
+
680
+ // Records of the trace's own Workers and Reviewers; its Manager turns are
681
+ // already held.
682
+ const ownAgentIds = new Set([trace.managerAgentId, ...trace.workerIds, ...trace.reviewerIds]);
683
+ const held = new Set(trace.records);
684
+ const take = (record: TraceRecord): void => {
685
+ held.add(record);
686
+ trace.records.push(record);
687
+ trace.reports.push(...reportsBelongingTo(trace.requestId, record.reports));
688
+ trace.reviews.push(...record.reviews);
689
+ };
690
+ for (const record of ordered) {
691
+ if (record.role !== "manager" && !held.has(record) && ownAgentIds.has(record.agentId)) take(record);
692
+ }
693
+
694
+ // A record names its own request, so it can be placed even when its agent
695
+ // cannot. The user may delete a Worker — `agents.list` then has nothing to
696
+ // match on — and without this the row loses the work those records
697
+ // describe (D-8's "archive, then delete the Worker" leg).
698
+ const recordedOnly = new Set<string>();
699
+ for (const record of ordered) {
700
+ if (trace.requestId === null || record.requestId !== trace.requestId || held.has(record)) continue;
701
+ if (agentById.has(record.agentId) || ownAgentIds.has(record.agentId)) continue;
702
+ take(record);
703
+ recordedOnly.add(record.agentId);
704
+ }
705
+
706
+ // "Latest" has to mean latest in time: reports arrive from several passes,
707
+ // so array order is not chronology (WP-214 F-1 once read its state off
708
+ // whichever report was appended last). The same report also arrives twice —
709
+ // from a Worker's turn and from the Manager turn quoting it — as two parsed
710
+ // objects, so it is de-duplicated on the design's key, not on identity.
711
+ trace.reports = uniqueBy(
712
+ [...trace.reports].sort(byAt),
713
+ (report) => `${report.agentId}|${report.phase ?? ""}|${report.requestId ?? ""}|${report.at}`,
714
+ );
715
+ trace.reviews = uniqueBy(
716
+ [...trace.reviews].sort(byAt),
717
+ (review) => `${review.agentId}|${review.batchId ?? ""}|${review.verdict ?? ""}|${review.at}`,
718
+ );
719
+ trace.records.sort(byAt);
720
+ trace.segments = segmentsOf(trace);
721
+
722
+ const lastGuardrail = [...trace.reports].reverse().find((report) => report.guardrail !== null);
723
+ trace.guardrailReported = lastGuardrail?.guardrail ?? null;
724
+ trace.tier = [...trace.reports].reverse().find((report) => report.tier !== null)?.tier ?? null;
725
+ trace.reviewCalls = reviewCallsOf(trace.reviewerIds, trace.records, replacementIds);
726
+
727
+ const missing = [
728
+ ...new Set([
729
+ ...[...trace.workerIds, ...trace.reviewerIds].filter((id) => !agentById.has(id)),
730
+ ...recordedOnly,
731
+ ]),
732
+ ];
733
+ trace.agentsMissing = missing;
734
+ if (missing.length > 0) {
735
+ trace.notices.push(
736
+ `${missing.length} agent(s) of this request are no longer on this machine; showing what was recorded.`,
737
+ );
738
+ }
739
+
740
+ trace.state = stateOf(trace, agentById, trace.records);
741
+ trace.notices = [...new Set(trace.notices)];
742
+ }
743
+
744
+ const result = buckets.map((bucket) => bucket.trace);
745
+ if (unknown.workerIds.length > 0 || unknown.reviewerIds.length > 0) {
746
+ unknown.state = stateOf(unknown, agentById, []);
747
+ result.push(unknown);
748
+ }
749
+ // Newest first (REQ-041c).
750
+ return result.sort((a, b) => (a.requestedAt > b.requestedAt ? -1 : a.requestedAt < b.requestedAt ? 1 : 0));
751
+ }
752
+
753
+ // ---------------------------------------------------------------------------
754
+ // Timing, bead detail, summaries and pages (WP-206.1.2; Design §4.2, §4.3, §7).
755
+ // ---------------------------------------------------------------------------
756
+
757
+ /** Sentence printed next to every duration, so no number is read as machine time. */
758
+ export const TIMING_BASIS =
759
+ "Wall-clock time, measured from the request to the last recorded activity. It includes any time spent waiting for you to answer.";
760
+
761
+ function msBetween(from: string | null, to: string | null): number | null {
762
+ if (from === null || to === null) return null;
763
+ const start = Date.parse(from);
764
+ const end = Date.parse(to);
765
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
766
+ return end - start;
767
+ }
768
+
769
+ /** Turn timings of the Manager records of a trace (design §7.1). */
770
+ export function managerTurns(trace: ReconstructedTrace): TurnTiming[] {
771
+ return trace.records
772
+ .filter((record) => record.role === "manager")
773
+ .map((record) => ({
774
+ turnId: record.turnId,
775
+ startedAt: record.startedAt ?? record.sent[0]?.at ?? null,
776
+ endedAt: record.endedAt,
777
+ ms: msBetween(record.startedAt ?? record.sent[0]?.at ?? null, record.endedAt),
778
+ }));
779
+ }
780
+
781
+ /**
782
+ * Lifetime of one agent inside a trace.
783
+ *
784
+ * A Worker ends at its `finished` report, a Reviewer at its last `BM-REVIEW`;
785
+ * without either, the last recorded activity is used. A running agent has no
786
+ * end, so `ms` stays null rather than being measured against "now" — the UI
787
+ * shows elapsed time instead (REQ-043d).
788
+ */
789
+ export function agentTiming(
790
+ agentId: string,
791
+ role: "worker" | "reviewer",
792
+ trace: ReconstructedTrace,
793
+ agents: ReadonlyMap<string, AgentFacts>,
794
+ ): AgentTiming {
795
+ const own = trace.records.filter((record) => record.agentId === agentId);
796
+ const startedAt = agents.get(agentId)?.createdAt ?? own[0]?.startedAt ?? own[0]?.at ?? null;
797
+ const lastActivityAt = own.at(-1)?.at ?? null;
798
+
799
+ const finishedAt =
800
+ role === "worker"
801
+ ? (trace.reports.filter((report) => report.agentId === agentId && report.phase === "finished").at(-1)?.at ??
802
+ null)
803
+ : (trace.reviews.filter((review) => review.agentId === agentId).at(-1)?.at ?? null);
804
+
805
+ const status = agents.get(agentId)?.status ?? "closed";
806
+ const endedAt = status === "running" ? null : (finishedAt ?? lastActivityAt);
807
+
808
+ return {
809
+ agentId,
810
+ role,
811
+ startedAt,
812
+ lastActivityAt,
813
+ ms: msBetween(startedAt, endedAt),
814
+ state: status === "running" ? "running" : status === "error" ? "failed" : "completed",
815
+ };
816
+ }
817
+
818
+ /** Total duration of a request, or null while anything is still running. */
819
+ export function totalMsOf(trace: ReconstructedTrace): number | null {
820
+ if (trace.state === "running") return null;
821
+ const finished = trace.reports.filter((report) => report.phase === "finished").at(-1)?.at ?? null;
822
+ const lastRecord = trace.records.at(-1)?.at ?? null;
823
+ const end = finished !== null && lastRecord !== null ? (finished > lastRecord ? finished : lastRecord) : (finished ?? lastRecord);
824
+ return msBetween(trace.requestedAt, end);
825
+ }
826
+
827
+ /** Bead ids a trace touched, split by action, with the confidence of each source. */
828
+ export interface BeadActions {
829
+ created: { ids: string[]; confidence: Confidence };
830
+ updated: { ids: string[]; confidence: Confidence };
831
+ closed: { ids: string[]; confidence: Confidence };
832
+ ready: { ids: string[]; confidence: Confidence };
833
+ evidenceById: Map<string, Evidence[]>;
834
+ }
835
+
836
+ /**
837
+ * Collects bead actions from reports first, then from `br` commands seen in the
838
+ * timeline.
839
+ *
840
+ * A report is `exact`; a command is `inferred`. When there is no report and no
841
+ * command, the confidence is `unknown` — which is what stops the UI from
842
+ * claiming "this request created no beads" (REQ-044c). Only a `finished` report
843
+ * that says `beadsCreated: none` justifies that claim, and that shows up here
844
+ * as an `exact` empty list.
845
+ */
846
+ export function beadActionsOf(trace: ReconstructedTrace): BeadActions {
847
+ const evidenceById = new Map<string, Evidence[]>();
848
+ const add = (bucket: Set<string>, ids: readonly string[], evidence: Evidence | null) => {
849
+ for (const id of ids) {
850
+ bucket.add(id);
851
+ if (evidence === null) continue;
852
+ evidenceById.set(id, [...(evidenceById.get(id) ?? []), evidence]);
853
+ }
854
+ };
855
+
856
+ const created = new Set<string>();
857
+ const updated = new Set<string>();
858
+ const closed = new Set<string>();
859
+ const ready = new Set<string>();
860
+
861
+ const reportEvidence = (report: ParsedReport): Evidence => ({
862
+ kind: "report",
863
+ detail: `BM-REPORT phase=${report.phase ?? "unknown"}`,
864
+ agentId: report.agentId,
865
+ at: report.at,
866
+ });
867
+ // Stable sort: reports with the same timestamp keep their order.
868
+ const byTime = [...trace.reports].sort(byAt);
869
+ for (const report of byTime) {
870
+ const evidence = reportEvidence(report);
871
+ add(created, report.beadsCreated, evidence);
872
+ add(updated, report.beadsUpdated, evidence);
873
+ add(closed, report.beadsClosed, evidence);
874
+ }
875
+ // `ready` is a state, not an action (delta 20260917 §5.2): only the latest
876
+ // report says what is ready now. The 2026-09-16 run showed 3 ready beads
877
+ // from `beads-done` although `finished` said `beadsReady: none`.
878
+ const latest = byTime.at(-1);
879
+ if (latest !== undefined) add(ready, latest.beadsReady, reportEvidence(latest));
880
+
881
+ let sawCommand = false;
882
+ for (const record of trace.records) {
883
+ for (const evidence of record.evidence) {
884
+ if (evidence.kind !== "shell") continue;
885
+ for (const { verb, ids } of brActions(evidence.detail)) {
886
+ if (ids.length === 0 && verb !== "create") continue;
887
+ sawCommand = true;
888
+ add(verb === "create" ? created : verb === "update" ? updated : closed, ids, evidence);
889
+ }
890
+ }
891
+ }
892
+
893
+ // A list the parser could not fully read is a lower bound (delta 20260917
894
+ // §5.1): the count is then `inferred`, not `exact`.
895
+ const confidenceFor = (fromReports: boolean, incomplete: boolean): Confidence =>
896
+ fromReports ? (incomplete ? "inferred" : "exact") : sawCommand ? "inferred" : "unknown";
897
+ const incompleteIn = (reports: readonly ParsedReport[], field: string): boolean =>
898
+ reports.some((report) => (report.incompleteFields ?? []).includes(field));
899
+
900
+ const reportedAny = trace.reports.length > 0;
901
+ return {
902
+ created: { ids: [...created], confidence: confidenceFor(reportedAny, incompleteIn(byTime, "beadsCreated")) },
903
+ updated: { ids: [...updated], confidence: confidenceFor(reportedAny, incompleteIn(byTime, "beadsUpdated")) },
904
+ closed: { ids: [...closed], confidence: confidenceFor(reportedAny, incompleteIn(byTime, "beadsClosed")) },
905
+ ready: {
906
+ ids: [...ready],
907
+ confidence: confidenceFor(reportedAny, latest !== undefined && incompleteIn([latest], "beadsReady")),
908
+ },
909
+ evidenceById,
910
+ };
911
+ }
912
+
913
+ /**
914
+ * The model a turn actually ran on (delta 20260918 §4.2): the running model the
915
+ * collector recorded in `runtime`, else the configured one in `usage`. Records
916
+ * written before that delta only have the second. Every grouping by model uses
917
+ * this one definition, so cost, the per-model lines and the overview agree.
918
+ */
919
+ export function effectiveModel(record: Pick<TraceRecord, "runtime" | "usage">): string | null {
920
+ return record.runtime?.model ?? record.usage?.model ?? null;
921
+ }
922
+
923
+ /**
924
+ * Sums the tokens a trace used. Cost is left unpriced here (`unavailable`):
925
+ * WP-209 owns the price table and applies it, so this module never has to know
926
+ * about money.
927
+ */
928
+ export function summariseUsage(trace: ReconstructedTrace): Usage {
929
+ let inputTokens = 0;
930
+ let cachedInputTokens = 0;
931
+ let outputTokens = 0;
932
+ let model: string | null = null;
933
+ for (const record of trace.records) {
934
+ if (record.usage === null) continue;
935
+ inputTokens += record.usage.inputTokens;
936
+ cachedInputTokens += record.usage.cachedInputTokens;
937
+ outputTokens += record.usage.outputTokens;
938
+ // The model the turn ran on, so a part priced below is priced as what ran.
939
+ model = effectiveModel(record) ?? model;
940
+ }
941
+ return {
942
+ inputTokens,
943
+ cachedInputTokens,
944
+ outputTokens,
945
+ costUsd: null,
946
+ costBasis: "unavailable",
947
+ model,
948
+ pricesUpdatedAt: null,
949
+ };
950
+ }
951
+
952
+ const tokensOf = (usage: Usage) => usage.inputTokens + usage.cachedInputTokens + usage.outputTokens;
953
+
954
+ /**
955
+ * A trace's records grouped by the model they ran on (`effectiveModel`, delta
956
+ * 20260918 §4.3), in order of first appearance, each summed and still unpriced.
957
+ * Parts without tokens are dropped. `key` is the model without a provider
958
+ * prefix — `bm-worker/claude-opus-5` and `claude-opus-5` are one model, and the
959
+ * price table accepts both — or `""` when no model is known.
960
+ */
961
+ function modelParts(trace: ReconstructedTrace): Array<{ key: string; usage: Usage }> {
962
+ const byModel = new Map<string, TraceRecord[]>();
963
+ for (const record of trace.records) {
964
+ if (record.usage === null) continue;
965
+ const model = effectiveModel(record) ?? "";
966
+ const key = model.slice(model.lastIndexOf("/") + 1);
967
+ byModel.set(key, [...(byModel.get(key) ?? []), record]);
968
+ }
969
+ return [...byModel.entries()]
970
+ .map(([key, records]) => ({ key, usage: summariseUsage({ ...trace, records }) }))
971
+ .filter((part) => tokensOf(part.usage) > 0);
972
+ }
973
+
974
+ /**
975
+ * Tokens and cost of a trace per model it ran on (delta 20260918 §4.3,
976
+ * REQ-058d): the same parts `usageOfTrace` prices, so the lines add up to its
977
+ * total. A model without a price keeps `costUsd: null` and shows tokens only.
978
+ */
979
+ export function usageByModelOf(
980
+ trace: ReconstructedTrace,
981
+ price?: (usage: Usage) => Usage,
982
+ ): Array<{ model: string | null; usage: Usage }> {
983
+ return modelParts(trace).map((part) => {
984
+ const model = part.key === "" ? null : part.key;
985
+ const usage = { ...part.usage, model };
986
+ return { model, usage: price === undefined ? usage : price(usage) };
987
+ });
988
+ }
989
+
990
+ /**
991
+ * Tokens and cost per role and model (delta 20260918 §4.3, REQ-058e): the
992
+ * per-model parts of each role's records, so a role's lines add up to what its
993
+ * agents used and a model is keyed the same way as everywhere else.
994
+ */
995
+ export function usageByModelRoleOf(
996
+ trace: ReconstructedTrace,
997
+ price?: (usage: Usage) => Usage,
998
+ ): Array<{ role: TraceRecord["role"]; model: string | null; usage: Usage }> {
999
+ const roles = [...new Set(trace.records.map((record) => record.role))];
1000
+ return roles.flatMap((role) =>
1001
+ usageByModelOf({ ...trace, records: trace.records.filter((record) => record.role === role) }, price).map((entry) => ({
1002
+ role,
1003
+ ...entry,
1004
+ })),
1005
+ );
1006
+ }
1007
+
1008
+ /**
1009
+ * What one agent ran on, turn by turn, folded into one row per distinct
1010
+ * combination in order of first appearance (delta 20260918 §4.3). A turn with
1011
+ * `runtime` gives a recorded row; one without keeps the model its `usage` knew,
1012
+ * and says thinking and mode were not recorded.
1013
+ */
1014
+ export function runtimeRowsOf(trace: ReconstructedTrace, agentId: string): RuntimeRow[] {
1015
+ const rows = new Map<string, RuntimeRow>();
1016
+ for (const record of trace.records) {
1017
+ if (record.agentId !== agentId) continue;
1018
+ const runtime = record.runtime ?? null;
1019
+ const row: Omit<RuntimeRow, "turns"> =
1020
+ runtime === null
1021
+ ? { model: record.usage?.model ?? null, thinkingOptionId: null, modeId: null, recorded: false }
1022
+ : { model: effectiveModel(record), thinkingOptionId: runtime.thinkingOptionId, modeId: runtime.modeId, recorded: true };
1023
+ const key = JSON.stringify([row.model, row.thinkingOptionId, row.modeId, row.recorded]);
1024
+ const current = rows.get(key);
1025
+ rows.set(key, current === undefined ? { ...row, turns: 1 } : { ...current, turns: current.turns + 1 });
1026
+ }
1027
+ return [...rows.values()];
1028
+ }
1029
+
1030
+ /**
1031
+ * A trace's total usage, priced per model (delta 20260917 §5.4).
1032
+ *
1033
+ * The total used to price the summed tokens of every agent with one model —
1034
+ * the model of the last record — so on 2026-09-16 the Codex Reviewers' tokens
1035
+ * were charged as claude-opus-5 and the total ($17.71) did not match the
1036
+ * agents ($0.68 + $14.55). Now each model is priced on its own, the money is
1037
+ * the sum of the priced parts, and tokens from a model without a price are
1038
+ * named in a notice instead of being priced wrongly or hiding the rest.
1039
+ */
1040
+ export function usageOfTrace(
1041
+ trace: ReconstructedTrace,
1042
+ price?: (usage: Usage) => Usage,
1043
+ ): { usage: Usage; notice: string | null } {
1044
+ const parts = modelParts(trace);
1045
+ const total = summariseUsage(trace);
1046
+ // No tokens at all: nothing to split, so keep the plain result.
1047
+ if (parts.length === 0) return { usage: price === undefined ? total : price(total), notice: null };
1048
+ const model = parts.length === 1 && parts[0]!.key !== "" ? parts[0]!.key : null;
1049
+ if (price === undefined) return { usage: { ...total, model }, notice: null };
1050
+
1051
+ let costUsd: number | null = null;
1052
+ let pricesUpdatedAt: string | null = null;
1053
+ const unpricedModels: string[] = [];
1054
+ let unpricedTokens = 0;
1055
+ for (const part of parts) {
1056
+ const priced = price(part.usage);
1057
+ if (priced.costUsd === null) {
1058
+ unpricedModels.push(part.key === "" ? "unknown model" : part.key);
1059
+ unpricedTokens += tokensOf(part.usage);
1060
+ continue;
1061
+ }
1062
+ costUsd = (costUsd ?? 0) + priced.costUsd;
1063
+ pricesUpdatedAt = pricesUpdatedAt ?? priced.pricesUpdatedAt;
1064
+ }
1065
+ return {
1066
+ usage: {
1067
+ ...total,
1068
+ model,
1069
+ costUsd: costUsd === null ? null : Math.round(costUsd * 10_000) / 10_000,
1070
+ costBasis: costUsd === null ? "unavailable" : "estimated",
1071
+ pricesUpdatedAt,
1072
+ },
1073
+ notice:
1074
+ unpricedModels.length === 0
1075
+ ? null
1076
+ : `Cost excludes ${unpricedTokens} tokens from models without a price: ${unpricedModels.join(", ")}.`,
1077
+ };
1078
+ }
1079
+
1080
+ /** Sub-agent traces of a trace: provider-internal agents, counted per parent (REQ-042e). */
1081
+ export function subAgentTracesOf(trace: ReconstructedTrace): SubAgentTrace[] {
1082
+ const byAgent = new Map<string, { subAgentType: string | null; description: string | null; count: number }>();
1083
+ for (const record of trace.records) {
1084
+ for (const evidence of record.evidence) {
1085
+ if (evidence.kind !== "agent") continue;
1086
+ const current = byAgent.get(record.agentId) ?? { subAgentType: null, description: null, count: 0 };
1087
+ const [type, description] = evidence.detail.split(" — ");
1088
+ byAgent.set(record.agentId, {
1089
+ subAgentType: current.subAgentType ?? (type ?? null),
1090
+ description: current.description ?? (description ?? null),
1091
+ count: current.count + 1,
1092
+ });
1093
+ }
1094
+ }
1095
+ return [...byAgent.entries()].map(([agentId, value]) => ({ agentId, ...value }));
1096
+ }
1097
+
1098
+ /**
1099
+ * Tokens of one agent inside a trace, priced per model when a price table is
1100
+ * given (an agent can switch models between turns).
1101
+ */
1102
+ function usageOfAgent(trace: ReconstructedTrace, agentId: string, price?: (usage: Usage) => Usage): Usage {
1103
+ return usageOfTrace({ ...trace, records: trace.records.filter((record) => record.agentId === agentId) }, price).usage;
1104
+ }
1105
+
1106
+ export interface SummariseDeps {
1107
+ agents: ReadonlyMap<string, AgentFacts>;
1108
+ workspaceState: WorkspaceState;
1109
+ reassignedFrom: string | null;
1110
+ /**
1111
+ * Applies the price table to a token sum (WP-209). Injected so this module
1112
+ * never has to know about money; without it the row reports tokens only.
1113
+ */
1114
+ priceUsage?: (usage: Usage) => Usage;
1115
+ }
1116
+
1117
+ /**
1118
+ * What the user typed directly to an agent of this request, oldest first.
1119
+ *
1120
+ * Only messages the collector marked `origin: "user"`; a record written before
1121
+ * that field existed says nothing either way and contributes nothing.
1122
+ */
1123
+ export function userMessagesOf(trace: ReconstructedTrace): TraceMessage[] {
1124
+ const out: TraceMessage[] = [];
1125
+ for (const record of trace.records) {
1126
+ for (const message of record.sent) {
1127
+ if (message.origin === "user") out.push({ ...message, agentId: record.agentId });
1128
+ }
1129
+ }
1130
+ return out.sort(byAt);
1131
+ }
1132
+
1133
+ /** Skills each agent loaded, oldest first, one entry per agent and skill. */
1134
+ export function skillsOf(trace: ReconstructedTrace): Array<{ agentId: string; skill: string; at: string | null }> {
1135
+ const all = trace.records.flatMap((record) =>
1136
+ record.evidence
1137
+ .filter((entry) => entry.kind === "skill")
1138
+ .map((entry) => ({ agentId: entry.agentId ?? record.agentId, skill: entry.detail, at: entry.at })),
1139
+ );
1140
+ return uniqueBy(all.sort(byAt), (entry) => `${entry.agentId}|${entry.skill}`);
1141
+ }
1142
+
1143
+ /** One row for `traces.list` (design §4.2). */
1144
+ export function summarise(trace: ReconstructedTrace, deps: SummariseDeps): TraceSummary {
1145
+ const beads = beadActionsOf(trace);
1146
+ const total = usageOfTrace(trace, deps.priceUsage);
1147
+ return {
1148
+ traceId: trace.traceId,
1149
+ requestId: trace.requestId,
1150
+ requestedAt: trace.requestedAt,
1151
+ excerpt: trace.requestText === null ? null : (trace.requestText.split("\n")[0]?.slice(0, 200) ?? ""),
1152
+ turn: null,
1153
+ state: trace.state,
1154
+ workerIds: trace.workerIds,
1155
+ reviewerIds: trace.reviewerIds,
1156
+ reviewCalls: trace.reviewCalls,
1157
+ guardrailReported: trace.guardrailReported,
1158
+ durationMs: totalMsOf(trace),
1159
+ usage: total.usage,
1160
+ messageCount: trace.records.reduce((count, record) => count + record.sent.length + record.received.length, 0),
1161
+ userMessageCount: userMessagesOf(trace).length,
1162
+ workerUsage: trace.workerIds.map((agentId) => ({
1163
+ agentId,
1164
+ title: deps.agents.get(agentId)?.title ?? null,
1165
+ usage: usageOfAgent(trace, agentId, deps.priceUsage),
1166
+ })),
1167
+ usageByModelRole: usageByModelRoleOf(trace, deps.priceUsage),
1168
+ beadCounts: {
1169
+ created: { count: beads.created.ids.length, confidence: beads.created.confidence },
1170
+ updated: { count: beads.updated.ids.length, confidence: beads.updated.confidence },
1171
+ closed: { count: beads.closed.ids.length, confidence: beads.closed.confidence },
1172
+ ready: { count: beads.ready.ids.length, confidence: beads.ready.confidence },
1173
+ },
1174
+ tier: trace.tier,
1175
+ linking: trace.linking,
1176
+ agentsMissing: trace.agentsMissing,
1177
+ workspaceState: deps.workspaceState,
1178
+ reassignedFrom: deps.reassignedFrom,
1179
+ notices: total.notice === null ? trace.notices : [...trace.notices, total.notice],
1180
+ };
1181
+ }
1182
+
1183
+ /**
1184
+ * One row per turn the user opened, instead of one row per request.
1185
+ *
1186
+ * The owner asked for each follow-up to be its own flow rather than being
1187
+ * folded into a row that is hard to trace (delta 20260917e §4.3, decision Q23:
1188
+ * the Dashboard splits, the `requestId` does not).
1189
+ *
1190
+ * What is split and what is not matters more than the split itself:
1191
+ * - **split**, because it belongs to one turn — when it was asked, what was
1192
+ * asked, the tokens, the duration, the messages, the beads its reports name;
1193
+ * - **kept whole**, because it belongs to the request — the review calls above
1194
+ * all. Counting those per turn would hand a user who asks three follow-ups
1195
+ * three times the reviews their tier allows, which is the very thing the
1196
+ * budget exists to stop. Also whole: the state, tier, linking, and the agents,
1197
+ * which are the request's, not a turn's.
1198
+ *
1199
+ * A request nobody followed up returns exactly one row with `turn: null`, so
1200
+ * nothing changes for it.
1201
+ */
1202
+ export function summariseSegments(trace: ReconstructedTrace, deps: SummariseDeps): TraceSummary[] {
1203
+ const whole = summarise(trace, deps);
1204
+ if (trace.segments.length <= 1) return [whole];
1205
+ const total = trace.segments.length;
1206
+ return trace.segments.map((segment) => {
1207
+ const part = summarise(
1208
+ {
1209
+ ...trace,
1210
+ records: segment.records,
1211
+ reports: segment.reports,
1212
+ requestedAt: segment.startedAt,
1213
+ requestText: segment.text,
1214
+ },
1215
+ deps,
1216
+ );
1217
+ return {
1218
+ ...part,
1219
+ turn: { index: segment.index, total },
1220
+ // Notices are the request's, and `summarise` derives one of them from the
1221
+ // records it was given — which here are a single turn's. Everything else
1222
+ // request-level (review calls, state, tier, linking, the agents) is
1223
+ // carried through untouched by `summarise`, so it needs no override; the
1224
+ // suite pins that invariant so a future change cannot start splitting it.
1225
+ notices: whole.notices,
1226
+ };
1227
+ });
1228
+ }
1229
+
1230
+ export interface DetailDeps extends SummariseDeps {
1231
+ /** Current title and status of the beads a trace names (WP-208 lookup). */
1232
+ lookupBeads: (ids: readonly string[]) => {
1233
+ found: Array<{ id: string; title: string | null; status: string; updatedAt?: string }>;
1234
+ missing: string[];
1235
+ };
1236
+ /**
1237
+ * Feature-workflow table (WP-207). Injected rather than imported so this
1238
+ * module stays free of the inference rules, and so a caller that does not
1239
+ * want the table can leave it out.
1240
+ */
1241
+ workflowSteps?: (trace: ReconstructedTrace, beadStatus: (id: string) => string | null) => WorkflowStepResult[];
1242
+ }
1243
+
1244
+ /** Full detail for `traces.get` (design §4.3). */
1245
+ export function detail(trace: ReconstructedTrace, deps: DetailDeps): TraceDetail {
1246
+ const summary = summarise(trace, deps);
1247
+ const actions = beadActionsOf(trace);
1248
+
1249
+ const allIds = [
1250
+ ...new Set([...actions.created.ids, ...actions.updated.ids, ...actions.closed.ids, ...actions.ready.ids]),
1251
+ ];
1252
+ const { found, missing } = deps.lookupBeads(allIds);
1253
+ const currentById = new Map(found.map((bead) => [bead.id, bead]));
1254
+
1255
+ const beadRows: TraceBead[] = [];
1256
+ // A reported update is checked against the store: a bead whose `updated_at`
1257
+ // is older than the request was not touched by it. WP-214 F-3: the Worker
1258
+ // listed `repo-cv6` under beadsUpdated while the store still showed its
1259
+ // creation time, and the screen repeated the claim as exact.
1260
+ const contradicted: string[] = [];
1261
+ const pushRows = (ids: readonly string[], action: TraceBead["action"], confidence: Confidence) => {
1262
+ for (const id of ids) {
1263
+ const current = currentById.get(id);
1264
+ const untouched =
1265
+ action === "updated" &&
1266
+ current?.updatedAt !== undefined &&
1267
+ current.updatedAt !== "" &&
1268
+ current.updatedAt < trace.requestedAt;
1269
+ if (untouched) contradicted.push(id);
1270
+ beadRows.push({
1271
+ id,
1272
+ title: current?.title ?? null,
1273
+ statusNow: current?.status ?? null,
1274
+ action,
1275
+ confidence: untouched ? "unknown" : confidence,
1276
+ evidence: actions.evidenceById.get(id) ?? [],
1277
+ });
1278
+ }
1279
+ };
1280
+ pushRows(actions.created.ids, "created", actions.created.confidence);
1281
+ pushRows(actions.updated.ids, "updated", actions.updated.confidence);
1282
+ pushRows(actions.closed.ids, "closed", actions.closed.confidence);
1283
+ pushRows(actions.ready.ids, "ready", actions.ready.confidence);
1284
+
1285
+ const notices = [...summary.notices];
1286
+ if (missing.length > 0) {
1287
+ notices.push(`${missing.length} reported bead(s) are not in the workspace's bead store.`);
1288
+ }
1289
+ if (contradicted.length > 0) {
1290
+ notices.push(
1291
+ `Reported as updated, but unchanged in the bead store since this request began: ${contradicted.join(", ")}.`,
1292
+ );
1293
+ }
1294
+
1295
+ const workerPrompts = trace.records
1296
+ .filter((record) => record.role === "worker")
1297
+ .flatMap((record) => record.sent.slice(0, 1));
1298
+ const reviewRequests = trace.records
1299
+ .filter((record) => record.role === "reviewer")
1300
+ .flatMap((record) =>
1301
+ record.sent.map((message) => ({ ...message, batchId: /batch[\s:-]*([A-Za-z0-9._-]+)/i.exec(message.text)?.[1] ?? null })),
1302
+ );
1303
+ const managerReplies = trace.records
1304
+ .filter((record) => record.role === "manager")
1305
+ .flatMap((record) => record.received);
1306
+
1307
+ return {
1308
+ ...summary,
1309
+ notices,
1310
+ sent: {
1311
+ // The message reconstruction chose as the request. The first Manager
1312
+ // record's first message can be a Worker's BM-REPORT instead.
1313
+ userRequest:
1314
+ trace.records
1315
+ .filter((record) => record.role === "manager")
1316
+ .flatMap((record) => record.sent)
1317
+ .find((message) => message.text === trace.requestText) ?? null,
1318
+ workerInitialPrompts: workerPrompts,
1319
+ reviewRequests,
1320
+ },
1321
+ received: {
1322
+ reports: trace.reports,
1323
+ reviews: trace.reviews,
1324
+ managerReplies,
1325
+ },
1326
+ timing: {
1327
+ totalMs: summary.durationMs,
1328
+ managerTurns: managerTurns(trace),
1329
+ workers: trace.workerIds.map((id) => agentTiming(id, "worker", trace, deps.agents)),
1330
+ reviewers: trace.reviewerIds.map((id) => agentTiming(id, "reviewer", trace, deps.agents)),
1331
+ basis: TIMING_BASIS,
1332
+ },
1333
+ usageByAgent: [...new Set(trace.records.map((record) => record.agentId))].map((agentId) => ({
1334
+ agentId,
1335
+ role: deps.agents.get(agentId)?.role ?? trace.records.find((record) => record.agentId === agentId)?.role ?? "unknown",
1336
+ usage: usageOfAgent(trace, agentId, deps.priceUsage),
1337
+ runtime: runtimeRowsOf(trace, agentId),
1338
+ })),
1339
+ usageByModel: usageByModelOf(trace, deps.priceUsage),
1340
+ beads: beadRows,
1341
+ workflowSteps:
1342
+ deps.workflowSteps?.(trace, (id) => currentById.get(id)?.status ?? null) ?? [],
1343
+ subAgentTraces: subAgentTracesOf(trace),
1344
+ userMessages: userMessagesOf(trace),
1345
+ skills: skillsOf(trace),
1346
+ };
1347
+ }
1348
+
1349
+ /** One page of rows, oldest cursor semantics: an opaque index into the sorted list. */
1350
+ export function paginate<T>(rows: readonly T[], limit: number, cursor?: string): { page: T[]; nextCursor: string | null; truncated: boolean } {
1351
+ const start = cursor === undefined ? 0 : Math.max(0, Number.parseInt(cursor, 10) || 0);
1352
+ const page = rows.slice(start, start + limit);
1353
+ const nextIndex = start + page.length;
1354
+ const hasMore = nextIndex < rows.length;
1355
+ return { page: [...page], nextCursor: hasMore ? String(nextIndex) : null, truncated: hasMore };
1356
+ }