@oh-my-pi/omp-stats 18.16.0 → 18.17.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 (77) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/client/index.css +1 -1
  3. package/dist/client/index.js +102 -99
  4. package/dist/types/client/api.d.ts +4 -1
  5. package/dist/types/client/app/nav.d.ts +1 -1
  6. package/dist/types/client/charts/Chart.d.ts +16 -8
  7. package/dist/types/client/charts/DotWhisker.d.ts +50 -0
  8. package/dist/types/client/charts/Sparkline.d.ts +1 -1
  9. package/dist/types/client/charts/Waterfall.d.ts +26 -0
  10. package/dist/types/client/charts/index.d.ts +2 -0
  11. package/dist/types/client/charts/types.d.ts +8 -2
  12. package/dist/types/client/routes/SpotlightRoute.d.ts +7 -0
  13. package/dist/types/client/routes/index.d.ts +1 -0
  14. package/dist/types/client/spotlight/Compounding.d.ts +8 -0
  15. package/dist/types/client/spotlight/Coverage.d.ts +6 -0
  16. package/dist/types/client/spotlight/Economics.d.ts +10 -0
  17. package/dist/types/client/spotlight/FetchBack.d.ts +9 -0
  18. package/dist/types/client/spotlight/Hero.d.ts +12 -0
  19. package/dist/types/client/spotlight/Overhead.d.ts +6 -0
  20. package/dist/types/client/spotlight/Ranking.d.ts +6 -0
  21. package/dist/types/client/spotlight/ResultsFeed.d.ts +6 -0
  22. package/dist/types/client/spotlight/Savings.d.ts +10 -0
  23. package/dist/types/client/spotlight/ShareTrend.d.ts +8 -0
  24. package/dist/types/client/spotlight/Steps.d.ts +6 -0
  25. package/dist/types/client/spotlight/TimeCost.d.ts +6 -0
  26. package/dist/types/client/spotlight/chips.d.ts +16 -0
  27. package/dist/types/client/spotlight/meta.d.ts +64 -0
  28. package/dist/types/db.d.ts +26 -0
  29. package/dist/types/index.d.ts +2 -1
  30. package/dist/types/parser.d.ts +4 -0
  31. package/dist/types/rollup.d.ts +22 -1
  32. package/dist/types/shared-types.d.ts +419 -0
  33. package/dist/types/spotlight-aggregator.d.ts +189 -0
  34. package/dist/types/spotlight-bootstrap.d.ts +32 -0
  35. package/dist/types/spotlight-ingest.d.ts +258 -0
  36. package/dist/types/spotlight-measures.d.ts +51 -0
  37. package/package.json +5 -5
  38. package/src/aggregator.ts +3 -1
  39. package/src/client/App.tsx +3 -0
  40. package/src/client/api.ts +21 -0
  41. package/src/client/app/nav.ts +4 -1
  42. package/src/client/charts/Chart.tsx +105 -22
  43. package/src/client/charts/DotWhisker.tsx +149 -0
  44. package/src/client/charts/Sparkline.tsx +3 -2
  45. package/src/client/charts/Waterfall.tsx +86 -0
  46. package/src/client/charts/index.ts +2 -0
  47. package/src/client/charts/types.ts +9 -2
  48. package/src/client/react-css.d.ts +1 -1
  49. package/src/client/routes/SpotlightRoute.tsx +138 -0
  50. package/src/client/routes/index.ts +1 -0
  51. package/src/client/spotlight/Compounding.tsx +222 -0
  52. package/src/client/spotlight/Coverage.tsx +96 -0
  53. package/src/client/spotlight/Economics.tsx +167 -0
  54. package/src/client/spotlight/FetchBack.tsx +222 -0
  55. package/src/client/spotlight/Hero.tsx +177 -0
  56. package/src/client/spotlight/Overhead.tsx +135 -0
  57. package/src/client/spotlight/Ranking.tsx +248 -0
  58. package/src/client/spotlight/ResultsFeed.tsx +354 -0
  59. package/src/client/spotlight/Savings.tsx +355 -0
  60. package/src/client/spotlight/ShareTrend.tsx +103 -0
  61. package/src/client/spotlight/Steps.tsx +225 -0
  62. package/src/client/spotlight/TimeCost.tsx +64 -0
  63. package/src/client/spotlight/chips.tsx +63 -0
  64. package/src/client/spotlight/meta.ts +206 -0
  65. package/src/client/spotlight/spotlight.css +1387 -0
  66. package/src/client/styles.css +289 -1
  67. package/src/db.ts +480 -2
  68. package/src/index.ts +10 -1
  69. package/src/live.ts +21 -9
  70. package/src/parser.ts +44 -8
  71. package/src/rollup.ts +203 -10
  72. package/src/server.ts +42 -0
  73. package/src/shared-types.ts +453 -0
  74. package/src/spotlight-aggregator.ts +1738 -0
  75. package/src/spotlight-bootstrap.ts +110 -0
  76. package/src/spotlight-ingest.ts +981 -0
  77. package/src/spotlight-measures.ts +667 -0
@@ -0,0 +1,981 @@
1
+ /**
2
+ * Spotlight experiment facts the parser extracts from a session journal, for the Spotlight dashboard
3
+ * (`spotlight-aggregator.ts`).
4
+ *
5
+ * A session is an experiment session from its first Spotlight marker on (see {@link experimentMarker}); from then the
6
+ * parser records every evaluated result, every tool call (target, order, result size, the artifacts its result names)
7
+ * and every main-loop request, so recoveries of hidden output can be detected later. Evaluated results come from two
8
+ * entries that may land in different tail parses: the `spotlight:shape` journal entry (Spotlight's bill, timing) and
9
+ * the result message's `details.spotlight` (what the model saw, the hidden lines). Both upsert the same row.
10
+ *
11
+ * Each shaped read (applied, held out or score-free) also collects the original lines the agent needed later (see
12
+ * {@link SpotlightCollector}): its open reads ride in the parser state, so a later tail parse keeps collecting, and the
13
+ * lines land in `spotlight_needs`.
14
+ */
15
+
16
+ import * as fs from "node:fs/promises";
17
+ import * as path from "node:path";
18
+ import { getSessionsDir, isRecord } from "@oh-my-pi/pi-utils";
19
+
20
+ /**
21
+ * Whether a custom entry marks its session as a Spotlight experiment session: pi's `spotlight:session` entry, which
22
+ * every session with the experiment on writes. Journals from before the experiment became a toggle carry
23
+ * `spotlight:assignment` instead, naming the arm the session drew; only the arm that shipped shaped with the shipped
24
+ * algorithm, so sessions of any other arm (`native`, `plain`, …) are not experiment sessions.
25
+ */
26
+ export function experimentMarker(customType: unknown, data: Record<string, unknown>): boolean {
27
+ if (customType === "spotlight:session") return true;
28
+ return customType === "spotlight:assignment" && data.arm === "vylv5";
29
+ }
30
+
31
+ /** Longest stored call target. */
32
+ export const SPOTLIGHT_TARGET_CHARS = 500;
33
+
34
+ /** A shaped read still collecting the lines the agent needs later (see {@link SpotlightCollector}). */
35
+ export interface SpotlightOpenRead {
36
+ toolCallId: string;
37
+ /** The read file, resolved against the session's working directory. */
38
+ file: string;
39
+ /** First file line of the original and its line count. */
40
+ start: number;
41
+ lines: number;
42
+ /** `original.url`: the session artifact holding the original text. */
43
+ url: string | null;
44
+ }
45
+
46
+ /** Spotlight state a tail parse needs from earlier bytes of the same transcript. */
47
+ export interface SpotlightParserState {
48
+ /** A Spotlight marker was seen: results, calls and requests are recorded from here on. */
49
+ experiment: boolean;
50
+ /** Tool calls recorded so far (the next call's order). */
51
+ seq: number;
52
+ /** The session header's working directory, for resolving relative read paths. */
53
+ cwd: string | null;
54
+ /** Latest session title. */
55
+ title: string | null;
56
+ /** Shaped reads still collecting needed lines; absent in cursors persisted before them. */
57
+ open?: SpotlightOpenRead[];
58
+ }
59
+
60
+ export function emptySpotlightState(): SpotlightParserState {
61
+ return { experiment: false, seq: 0, cwd: null, title: null, open: [] };
62
+ }
63
+
64
+ /** `spotlight_sessions` row: the session's first Spotlight marker. */
65
+ export interface SpotlightSessionRow {
66
+ sessionFile: string;
67
+ folder: string;
68
+ markedAt: number;
69
+ cwd: string | null;
70
+ title: string | null;
71
+ }
72
+
73
+ /** `spotlight_results` row (upserted): one evaluated result, from its shape entry, its result details, or both. */
74
+ export interface SpotlightShapeRow {
75
+ sessionFile: string;
76
+ toolCallId: string;
77
+ timestamp: number;
78
+ tool: string;
79
+ inChars: number;
80
+ outChars: number;
81
+ inTokens: number;
82
+ outTokens: number;
83
+ /** What pi would have shown unshaped (`plain`) for the call; null on traces written before pi recorded it. */
84
+ plainChars: number | null;
85
+ plainTokens: number | null;
86
+ /**
87
+ * A cut wider than `plain` that pi did not apply (steps end with `plain-cap`): the would-be cut's tokens; the model
88
+ * got `plain`'s text (`outTokens`). Null otherwise.
89
+ */
90
+ cappedTokens: number | null;
91
+ /** The score-free coin skipped Spotlight: the no-score fallback rules shaped the result. */
92
+ scoreFree: boolean;
93
+ steps: string[];
94
+ /** The result message carried `details.spotlight`. */
95
+ hasDetails: boolean;
96
+ /**
97
+ * Held out at random: Spotlight would have shortened it, but the model saw it unshaped; the token counts and
98
+ * `original` describe the would-be cut.
99
+ */
100
+ holdout: boolean;
101
+ /**
102
+ * A read pi's former edit gate (removed) kept whole, in transcripts journaled while it ran: the model saw
103
+ * `plain`'s page; like a held-out result, the token counts and `original` describe the would-be cut.
104
+ */
105
+ gated: boolean;
106
+ /** `details.spotlight.original` (present only when shaping changed the text). */
107
+ original: SpotlightOriginalFacts | null;
108
+ /** Artifact ids the result names (its text's `artifact://N` plus `original.url`); null before the result lands. */
109
+ artifacts: number[] | null;
110
+ /** Spotlight's own usage, from the shape entry; null before it lands. */
111
+ spotlight: { calls: number; failures: number; ms: number; costUsd: number } | null;
112
+ /** Wall time shaping took, from the shape entry. */
113
+ shapeMs: number | null;
114
+ }
115
+
116
+ export interface SpotlightOriginalFacts {
117
+ url: string | null;
118
+ path: string | null;
119
+ /** First original line of the window (1-based file line for reads, else 1). */
120
+ start: number;
121
+ lines: number;
122
+ /** Hidden absolute line ranges, inclusive: the window minus the kept ranges. */
123
+ hidden: [number, number][];
124
+ hiddenLines: number;
125
+ /** Spotlight's per-line heat (`0`–`9`, `-` unscored) over the first lines; null when the trace has none. */
126
+ heat: string | null;
127
+ /** Kept runs, 1-based positions in the original (of a held-out result: what the cut would have kept). */
128
+ kept: [number, number][];
129
+ }
130
+
131
+ /**
132
+ * `spotlight_needs` row (merged on upsert): original lines (1-based positions in the original) a shaped read's later
133
+ * calls needed. Ranges of later parses are added to the stored ones; `open` is the latest.
134
+ */
135
+ export interface SpotlightNeedRow {
136
+ sessionFile: string;
137
+ toolCallId: string;
138
+ file: string;
139
+ /** Still collecting: no edit, whole-file read or later shaped read of the file yet. */
140
+ open: boolean;
141
+ /** Lines later `read` selections covered. */
142
+ reread: [number, number][];
143
+ /** Lines later `edit` Find blocks located in the original. */
144
+ edit: [number, number][];
145
+ }
146
+
147
+ /** `spotlight_calls` row: one tool call of an experiment session. */
148
+ export interface SpotlightCallRow {
149
+ sessionFile: string;
150
+ toolCallId: string;
151
+ entryId: string;
152
+ timestamp: number;
153
+ seq: number;
154
+ toolName: string;
155
+ callsInTurn: number;
156
+ target: string;
157
+ /** Files an `edit`/`write`/`ast_edit` call changes, as written (relative paths unresolved); null for other calls. */
158
+ edits: string[] | null;
159
+ /** {@link argumentTokens} of the call, for the generic rerun rule; null for `read` and `bash` (own rules). */
160
+ argTokens: string[] | null;
161
+ }
162
+
163
+ /** Result facts of a recorded call, applied as an update (the result can land in a later tail parse). */
164
+ export interface SpotlightCallResultLink {
165
+ sessionFile: string;
166
+ toolCallId: string;
167
+ resultChars: number;
168
+ artifacts: number[];
169
+ }
170
+
171
+ /** `spotlight_requests` row: a main-loop assistant message of an experiment session (side requests excluded). */
172
+ export interface SpotlightRequestRow {
173
+ sessionFile: string;
174
+ entryId: string;
175
+ timestamp: number;
176
+ }
177
+
178
+ /** Everything one parse extracted for the Spotlight tables. */
179
+ export interface SpotlightParseRows {
180
+ sessions: SpotlightSessionRow[];
181
+ /** Title changes of experiment sessions seen after their marker. */
182
+ titles: { sessionFile: string; title: string }[];
183
+ shapes: SpotlightShapeRow[];
184
+ calls: SpotlightCallRow[];
185
+ callResults: SpotlightCallResultLink[];
186
+ requests: SpotlightRequestRow[];
187
+ needs: SpotlightNeedRow[];
188
+ }
189
+
190
+ export function emptySpotlightRows(): SpotlightParseRows {
191
+ return { sessions: [], titles: [], shapes: [], calls: [], callResults: [], requests: [], needs: [] };
192
+ }
193
+
194
+ export function spotlightRowCount(rows: SpotlightParseRows): number {
195
+ return (
196
+ rows.sessions.length +
197
+ rows.titles.length +
198
+ rows.shapes.length +
199
+ rows.calls.length +
200
+ rows.callResults.length +
201
+ rows.requests.length +
202
+ rows.needs.length
203
+ );
204
+ }
205
+
206
+ function finite(value: unknown): number {
207
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
208
+ }
209
+
210
+ const ARTIFACT_RE = /artifact:\/\/(\d+)/g;
211
+
212
+ /** Distinct `artifact://N` ids a text names, ascending. */
213
+ export function artifactIds(text: string): number[] {
214
+ if (!text.includes("artifact://")) return [];
215
+ const ids = new Set<number>();
216
+ for (const match of text.matchAll(ARTIFACT_RE)) ids.add(Number(match[1]));
217
+ return [...ids].sort((a, b) => a - b);
218
+ }
219
+
220
+ /**
221
+ * What a call asked for, as recovery detection compares it: a read's path with its selector, a bash command, a
222
+ * search pattern with its path, eval code. Clipped to {@link SPOTLIGHT_TARGET_CHARS}.
223
+ */
224
+ export function callTarget(toolName: string, args: unknown): string {
225
+ const a: Record<string, unknown> = isRecord(args) ? args : {};
226
+ const str = (key: string): string => {
227
+ const value = a[key];
228
+ return typeof value === "string" ? value : "";
229
+ };
230
+ let target: string;
231
+ if (toolName === "read") target = str("path");
232
+ else if (toolName === "bash") target = str("command");
233
+ else if (toolName === "eval") target = str("code");
234
+ else if (typeof a.pattern === "string") target = [str("pattern"), str("path")].filter(Boolean).join(" ");
235
+ else if (typeof a.query === "string") target = [str("query"), str("path")].filter(Boolean).join(" ");
236
+ else if (typeof a.path === "string") target = str("path");
237
+ else {
238
+ try {
239
+ target = JSON.stringify(args ?? {});
240
+ } catch {
241
+ target = "";
242
+ }
243
+ }
244
+ return target.slice(0, SPOTLIGHT_TARGET_CHARS);
245
+ }
246
+
247
+ /** Top-level arguments that say why, not what: the intent note every omp tool takes. */
248
+ const NOTE_ARGUMENTS = new Set(["i"]);
249
+ /** Argument keys whose string values are paths (or URLs), kept as one token even with spaces. */
250
+ const PATH_ARGUMENT = /(?:^|_)(?:path|file|dir|cwd|url)s?$/i;
251
+ /** Most tokens kept per call (the first distinct ones), and the longest token. */
252
+ const ARGUMENT_TOKEN_LIMIT = 256;
253
+ const ARGUMENT_TOKEN_CHARS = 200;
254
+
255
+ /**
256
+ * A call's arguments as a token set for the generic rerun rule (two calls of the same tool rerun when their sets'
257
+ * Jaccard similarity is high): every JSON leaf as `key=value`, the key being the nearest object key; strings split
258
+ * on whitespace except under path keys (`path`, `paths`, `file`, `cwd`, `url`, …), which stay whole; nulls dropped;
259
+ * the top-level intent note `i` left out. Distinct tokens in first-seen order, at most
260
+ * {@link ARGUMENT_TOKEN_LIMIT}, each clipped to {@link ARGUMENT_TOKEN_CHARS} chars.
261
+ */
262
+ export function argumentTokens(args: unknown): string[] {
263
+ const tokens = new Set<string>();
264
+ const add = (key: string, value: string) => {
265
+ if (tokens.size < ARGUMENT_TOKEN_LIMIT) tokens.add(`${key}=${value}`.slice(0, ARGUMENT_TOKEN_CHARS));
266
+ };
267
+ const visit = (value: unknown, key: string, top: boolean): void => {
268
+ if (tokens.size >= ARGUMENT_TOKEN_LIMIT || value === null || value === undefined) return;
269
+ if (Array.isArray(value)) {
270
+ for (const item of value) visit(item, key, false);
271
+ } else if (isRecord(value)) {
272
+ for (const child in value) {
273
+ if (!(top && NOTE_ARGUMENTS.has(child))) visit(value[child], child, false);
274
+ }
275
+ } else if (typeof value === "string") {
276
+ if (PATH_ARGUMENT.test(key)) {
277
+ const whole = value.trim();
278
+ if (whole) add(key, whole);
279
+ } else {
280
+ for (const part of value.split(/\s+/)) if (part) add(key, part);
281
+ }
282
+ } else if (typeof value === "number" || typeof value === "boolean") {
283
+ add(key, String(value));
284
+ }
285
+ };
286
+ visit(args, "", true);
287
+ return [...tokens];
288
+ }
289
+
290
+ const URL_SCHEME = /^[a-z][\w+.-]*:\/\//i;
291
+ /** Patch headers naming a changed file: `*** Edit File: PATH` (optionally ` all`), apply-patch headers too. */
292
+ const EDIT_HEADER = /^\*\*\* (?:Edit|Update|Add|Delete) File: (.+?)(?: all)?\s*$|^\*\*\* Move to: (.+?)\s*$/gm;
293
+
294
+ function unquotePath(value: string): string {
295
+ const trimmed = value.trim();
296
+ if (!trimmed.startsWith('"')) return trimmed;
297
+ try {
298
+ const parsed: unknown = JSON.parse(trimmed);
299
+ return typeof parsed === "string" ? parsed : trimmed;
300
+ } catch {
301
+ return trimmed;
302
+ }
303
+ }
304
+
305
+ function filePaths(values: readonly unknown[]): string[] {
306
+ const paths = new Set<string>();
307
+ for (const value of values) {
308
+ if (typeof value !== "string") continue;
309
+ const file = unquotePath(value);
310
+ if (file && !URL_SCHEME.test(file)) paths.add(file);
311
+ }
312
+ return [...paths];
313
+ }
314
+
315
+ /**
316
+ * Files a call changes: an `edit`'s patch headers (or its `path`), a `write`'s `path`, an `ast_edit`'s `paths`
317
+ * (globs kept as written), also when `ast_edit` runs through `write xd://ast_edit`. Null when the call is no file
318
+ * edit (other tools, or a write to an internal URL such as `proc://…/kill`).
319
+ */
320
+ export function editedPaths(toolName: string, args: unknown): string[] | null {
321
+ if (!isRecord(args)) return null;
322
+ let paths: string[];
323
+ if (toolName === "edit") {
324
+ const headers: unknown[] = [];
325
+ if (typeof args.input === "string") {
326
+ for (const match of args.input.matchAll(EDIT_HEADER)) headers.push(match[1] ?? match[2]);
327
+ }
328
+ paths = filePaths(headers.length > 0 ? headers : [args.path]);
329
+ } else if (toolName === "ast_edit") {
330
+ paths = filePaths(Array.isArray(args.paths) ? args.paths : [args.path]);
331
+ } else if (toolName === "write") {
332
+ if (args.path === "xd://ast_edit" && typeof args.content === "string") {
333
+ try {
334
+ return editedPaths("ast_edit", JSON.parse(args.content));
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+ paths = filePaths([args.path]);
340
+ } else {
341
+ return null;
342
+ }
343
+ return paths.length > 0 ? paths : null;
344
+ }
345
+
346
+ /** A read target split into its path and selected lines. */
347
+ export interface ReadSelection {
348
+ path: string;
349
+ /** Selected inclusive line ranges; empty with `tail` null means the whole file. */
350
+ ranges: [number, number][];
351
+ /** `:-N`: the last N lines. */
352
+ tail: number | null;
353
+ }
354
+
355
+ const SELECTOR_CHUNK = /:(raw|conflicts|img|-?\d+(?:[-+]\d*)?(?:,-?\d+(?:[-+]\d*)?)*)$/i;
356
+
357
+ /** Split a read path into the path and its selector (`file.ts:10-40`, `:50+20`, `:-30`, `:5-9,20`, `:raw`). */
358
+ export function parseReadSelection(target: string): ReadSelection {
359
+ let rest = target.trim();
360
+ const ranges: [number, number][] = [];
361
+ let tail: number | null = null;
362
+ for (let match = SELECTOR_CHUNK.exec(rest); match && match.index > 0; match = SELECTOR_CHUNK.exec(rest)) {
363
+ const chunk = match[1];
364
+ rest = rest.slice(0, match.index);
365
+ if (/^(?:raw|conflicts|img)$/i.test(chunk)) continue;
366
+ for (const part of chunk.split(",")) {
367
+ const tailMatch = /^-(\d+)$/.exec(part);
368
+ if (tailMatch) {
369
+ tail = Number(tailMatch[1]);
370
+ continue;
371
+ }
372
+ const range = /^(\d+)(?:([-+])(\d*))?$/.exec(part);
373
+ if (!range) continue;
374
+ const start = Number(range[1]);
375
+ const bound = range[3] ? Number(range[3]) : null;
376
+ if (range[2] === "+" && bound !== null) ranges.push([start, start + Math.max(bound, 1) - 1]);
377
+ else if (range[2] === "-" && bound !== null) ranges.push([start, bound]);
378
+ else ranges.push([start, Number.POSITIVE_INFINITY]);
379
+ }
380
+ }
381
+ return { path: rest, ranges, tail };
382
+ }
383
+
384
+ /** A read selection's lines; null for the whole file. */
385
+ export function selectedLines(selection: ReadSelection): [number, number][] | null {
386
+ return selection.ranges.length === 0 && selection.tail === null ? null : selection.ranges;
387
+ }
388
+
389
+ /** A path as written, resolved against the session's working directory (URLs and absolute paths kept). */
390
+ export function resolveReadPath(target: string, cwd: string | null): string {
391
+ if (URL_SCHEME.test(target) || !cwd || path.isAbsolute(target)) return target;
392
+ return path.resolve(cwd, target);
393
+ }
394
+
395
+ /** One `*** Find` block of an `edit` input and the file its section edits (null: the call's `path`). */
396
+ export interface EditFindBlock {
397
+ file: string | null;
398
+ /** `*** Edit File: PATH all`: every occurrence is replaced. */
399
+ all: boolean;
400
+ lines: string[];
401
+ }
402
+
403
+ const EDIT_FILE_HEADER = /^\*\*\* (?:Edit|Update) File: (.+?)( all)?\s*$/;
404
+ const PATCH_HEADER = /^\*\*\* [A-Z]/;
405
+
406
+ /** The `*** Find` blocks of an `edit` call's input, with the file each one's section edits. */
407
+ export function editFindBlocks(input: string): EditFindBlock[] {
408
+ const blocks: EditFindBlock[] = [];
409
+ let file: string | null = null;
410
+ let all = false;
411
+ let block: EditFindBlock | null = null;
412
+ for (const line of input.split("\n")) {
413
+ if (PATCH_HEADER.test(line)) {
414
+ block = null;
415
+ const header = EDIT_FILE_HEADER.exec(line);
416
+ if (header) {
417
+ file = unquotePath(header[1]);
418
+ all = header[2] !== undefined;
419
+ } else if (line.trimEnd() === "*** Find") {
420
+ block = { file, all, lines: [] };
421
+ blocks.push(block);
422
+ }
423
+ continue;
424
+ }
425
+ block?.lines.push(line);
426
+ }
427
+ return blocks;
428
+ }
429
+
430
+ const GAP = "…";
431
+
432
+ /** Whether original line `line` matches Find line `find`: equal up to trailing space, or its `…`-split pieces in order. */
433
+ function findLineMatches(line: string, find: string): boolean {
434
+ if (!find.includes(GAP)) return line.trimEnd() === find.trimEnd();
435
+ let at = 0;
436
+ for (const piece of find.split(GAP)) {
437
+ const literal = piece.trim();
438
+ if (!literal) continue;
439
+ const found = line.indexOf(literal, at);
440
+ if (found === -1) return false;
441
+ at = found + literal.length;
442
+ }
443
+ return true;
444
+ }
445
+
446
+ /**
447
+ * Where a Find block's text lies in `original` (lines): every match as 1-based inclusive `[first, last]` positions.
448
+ * A Find line ending in `…` (or a lone `…` line) may skip original lines before the next Find line matches; blank
449
+ * lines around the block are ignored.
450
+ */
451
+ export function locateFind(original: readonly string[], find: readonly string[]): [number, number][] {
452
+ let lo = 0;
453
+ let hi = find.length;
454
+ while (lo < hi && find[lo].trim() === "") lo++;
455
+ while (hi > lo && find[hi - 1].trim() === "") hi--;
456
+ const block = find.slice(lo, hi);
457
+ while (block.length > 0 && block[0].trim() === GAP) block.shift();
458
+ if (block.length === 0) return [];
459
+ const spans: [number, number][] = [];
460
+ for (let first = 0; first < original.length; first++) {
461
+ if (!findLineMatches(original[first], block[0])) continue;
462
+ let at = first;
463
+ let gap = block[0].trimEnd().endsWith(GAP);
464
+ let matched = true;
465
+ for (let k = 1; k < block.length && matched; k++) {
466
+ const find = block[k];
467
+ if (find.trim() === GAP) {
468
+ gap = true;
469
+ continue;
470
+ }
471
+ if (gap) {
472
+ let next = at + 1;
473
+ while (next < original.length && !findLineMatches(original[next], find)) next++;
474
+ matched = next < original.length;
475
+ at = next;
476
+ } else {
477
+ matched = at + 1 < original.length && findLineMatches(original[at + 1], find);
478
+ at++;
479
+ }
480
+ gap = find.trimEnd().endsWith(GAP);
481
+ }
482
+ if (!matched) continue;
483
+ spans.push([first + 1, at + 1]);
484
+ first = at;
485
+ }
486
+ return spans;
487
+ }
488
+
489
+ /** Union of inclusive ranges, sorted and merged (touching ranges join). */
490
+ export function mergeRanges(ranges: readonly (readonly [number, number])[]): [number, number][] {
491
+ const sorted = ranges.map(([a, b]): [number, number] => [a, b]).sort((x, y) => x[0] - y[0] || x[1] - y[1]);
492
+ const out: [number, number][] = [];
493
+ for (const [a, b] of sorted) {
494
+ const last = out.at(-1);
495
+ if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b);
496
+ else out.push([a, b]);
497
+ }
498
+ return out;
499
+ }
500
+
501
+ /** Hidden absolute ranges of a window `[start, start + lines - 1]` whose kept ranges are 1-based within it. */
502
+ export function hiddenRanges(start: number, lines: number, kept: readonly [number, number][]): [number, number][] {
503
+ const sorted = [...kept].sort((x, y) => x[0] - y[0]);
504
+ const hidden: [number, number][] = [];
505
+ let next = 1;
506
+ for (const [a, b] of sorted) {
507
+ if (a > next) hidden.push([start + next - 1, start + Math.min(a - 1, lines) - 1]);
508
+ next = Math.max(next, b + 1);
509
+ if (next > lines) break;
510
+ }
511
+ if (next <= lines) hidden.push([start + next - 1, start + lines - 1]);
512
+ return hidden.filter(([a, b]) => b >= a);
513
+ }
514
+
515
+ function parseKept(value: unknown): [number, number][] {
516
+ if (!Array.isArray(value)) return [];
517
+ const out: [number, number][] = [];
518
+ for (const pair of value) {
519
+ if (Array.isArray(pair) && typeof pair[0] === "number" && typeof pair[1] === "number")
520
+ out.push([pair[0], pair[1]]);
521
+ }
522
+ return out;
523
+ }
524
+
525
+ function parseOriginal(value: unknown): SpotlightOriginalFacts | null {
526
+ if (!isRecord(value)) return null;
527
+ const lines = finite(value.lines);
528
+ const start = finite(value.start) || 1;
529
+ const kept = parseKept(value.kept);
530
+ const hidden = hiddenRanges(start, lines, kept);
531
+ return {
532
+ url: typeof value.url === "string" ? value.url : null,
533
+ path: typeof value.path === "string" ? value.path : null,
534
+ start,
535
+ lines,
536
+ hidden,
537
+ hiddenLines: hidden.reduce((sum, [a, b]) => sum + b - a + 1, 0),
538
+ heat: typeof value.heat === "string" ? value.heat : null,
539
+ kept,
540
+ };
541
+ }
542
+
543
+ /** A count the trace may lack (written before pi recorded it): null then. */
544
+ function optionalCount(value: unknown): number | null {
545
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
546
+ }
547
+
548
+ function parseSteps(value: unknown): string[] {
549
+ return Array.isArray(value) ? value.filter((step): step is string => typeof step === "string") : [];
550
+ }
551
+
552
+ /** The row a `spotlight:shape` entry's data describes, or null when it names no call. */
553
+ export function shapeFromEntry(
554
+ sessionFile: string,
555
+ data: Record<string, unknown>,
556
+ timestamp: number,
557
+ ): SpotlightShapeRow | null {
558
+ if (typeof data.toolCallId !== "string" || typeof data.tool !== "string") return null;
559
+ const usage: Record<string, unknown> = isRecord(data.spotlight) ? data.spotlight : {};
560
+ return {
561
+ sessionFile,
562
+ toolCallId: data.toolCallId,
563
+ timestamp,
564
+ tool: data.tool,
565
+ inChars: finite(data.inChars),
566
+ outChars: finite(data.outChars),
567
+ inTokens: finite(data.inTokens),
568
+ outTokens: finite(data.outTokens),
569
+ plainChars: optionalCount(data.plainChars),
570
+ plainTokens: optionalCount(data.plainTokens),
571
+ cappedTokens: optionalCount(data.cappedTokens),
572
+ scoreFree: data.scoreFree === true,
573
+ steps: parseSteps(data.steps),
574
+ hasDetails: false,
575
+ holdout: data.holdout === true,
576
+ gated: data.gated === true,
577
+ original: null,
578
+ artifacts: null,
579
+ spotlight: {
580
+ calls: finite(usage.calls),
581
+ failures: finite(usage.failures),
582
+ ms: finite(usage.ms),
583
+ costUsd: finite(usage.costUsd),
584
+ },
585
+ shapeMs: finite(data.ms),
586
+ };
587
+ }
588
+
589
+ /** The row a result message's `details.spotlight` describes, or null when it carries none. */
590
+ export function shapeFromDetails(
591
+ sessionFile: string,
592
+ toolCallId: string,
593
+ details: unknown,
594
+ text: string,
595
+ timestamp: number,
596
+ ): SpotlightShapeRow | null {
597
+ if (!isRecord(details) || !isRecord(details.spotlight)) return null;
598
+ const s = details.spotlight;
599
+ if (typeof s.tool !== "string") return null;
600
+ const original = parseOriginal(s.original);
601
+ const holdout = s.holdout === true;
602
+ const gated = s.gated === true;
603
+ const artifacts = new Set(artifactIds(text));
604
+ // A held-out or gated result's original artifact was never shown to the model: it cannot fetch from it.
605
+ if (!holdout && !gated) for (const id of artifactIds(original?.url ?? "")) artifacts.add(id);
606
+ return {
607
+ sessionFile,
608
+ toolCallId,
609
+ timestamp,
610
+ tool: s.tool,
611
+ inChars: finite(s.inChars),
612
+ outChars: finite(s.outChars),
613
+ inTokens: finite(s.inTokens),
614
+ outTokens: finite(s.outTokens),
615
+ plainChars: optionalCount(s.plainChars),
616
+ plainTokens: optionalCount(s.plainTokens),
617
+ cappedTokens: optionalCount(s.cappedTokens),
618
+ scoreFree: s.scoreFree === true,
619
+ steps: parseSteps(s.steps),
620
+ hasDetails: true,
621
+ holdout,
622
+ gated,
623
+ original,
624
+ artifacts: [...artifacts].sort((a, b) => a - b),
625
+ spotlight: null,
626
+ shapeMs: null,
627
+ };
628
+ }
629
+
630
+ /**
631
+ * Directories a session's artifacts can live in: its own (`<session>/`), else, for a subagent transcript
632
+ * (`<parent>/<name>.jsonl`), its parent's and further ancestors' up to the project folder.
633
+ */
634
+ function artifactDirs(sessionFile: string): string[] {
635
+ const dirs = [sessionFile.replace(/\.jsonl$/, "")];
636
+ const sessionsDir = getSessionsDir();
637
+ for (let dir = path.dirname(sessionFile); ; dir = path.dirname(dir)) {
638
+ const rel = path.relative(sessionsDir, dir);
639
+ // The project folder (`<sessions>/<folder>`) holds transcripts, not artifacts.
640
+ if (rel.startsWith("..") || path.isAbsolute(rel) || rel.split(path.sep).length < 2) break;
641
+ dirs.push(dir);
642
+ }
643
+ return dirs;
644
+ }
645
+
646
+ /** The lines of artifact `url` (`artifact://N`, file `N.*`) of a session; null when it cannot be found or read. */
647
+ async function readArtifactLines(
648
+ sessionFile: string,
649
+ url: string,
650
+ listings: Map<string, Promise<string[]>>,
651
+ ): Promise<string[] | null> {
652
+ const id = /^artifact:\/\/(\d+)$/.exec(url)?.[1];
653
+ if (id === undefined) return null;
654
+ for (const dir of artifactDirs(sessionFile)) {
655
+ let listing = listings.get(dir);
656
+ if (!listing) {
657
+ listing = fs.readdir(dir).catch(() => []);
658
+ listings.set(dir, listing);
659
+ }
660
+ const name = (await listing).find(entry => entry.startsWith(`${id}.`));
661
+ if (!name) continue;
662
+ try {
663
+ return (await Bun.file(path.join(dir, name)).text()).split("\n");
664
+ } catch {
665
+ return null;
666
+ }
667
+ }
668
+ return null;
669
+ }
670
+
671
+ /** Message time in ms: the message's own timestamp, else the entry's ISO time, else 0 (as the parser stores it). */
672
+ function entryTime(messageTimestamp: unknown, isoTimestamp: unknown): number {
673
+ if (typeof messageTimestamp === "number" && Number.isFinite(messageTimestamp) && messageTimestamp > 0) {
674
+ return messageTimestamp;
675
+ }
676
+ const parsed = typeof isoTimestamp === "string" ? Date.parse(isoTimestamp) : Number.NaN;
677
+ return Number.isFinite(parsed) ? parsed : 0;
678
+ }
679
+
680
+ function joinedText(content: unknown): string {
681
+ if (!Array.isArray(content)) return "";
682
+ let text = "";
683
+ for (const block of content) {
684
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") text += block.text;
685
+ }
686
+ return text;
687
+ }
688
+
689
+ /**
690
+ * Reduces one transcript's entries to {@link SpotlightParseRows}, carrying {@link SpotlightParserState} across tail
691
+ * parses. With `record` false it only advances the state (a prefix scan that rebuilds the state of a cursor without
692
+ * one).
693
+ *
694
+ * Needed lines: once a shaped read's result lands (applied, held out or score-free: any read with
695
+ * `details.spotlight.original`), later calls of the session, all from later requests, add to its needs: a `read` of the
696
+ * same file with a line selection adds the selected lines; an `edit` of it adds the lines its `*** Find` blocks
697
+ * locate in the original (read from the session's artifact directory in {@link finish}). Collecting stops at the
698
+ * file's first edit (after recording its lines; any edit tool), a later whole-file read, or a later shaped read of
699
+ * the file (after recording its selection), which starts collecting for itself.
700
+ */
701
+ export class SpotlightCollector {
702
+ readonly rows: SpotlightParseRows = emptySpotlightRows();
703
+ readonly state: SpotlightParserState;
704
+ readonly #sessionFile: string;
705
+ readonly #folder: string;
706
+ readonly #record: boolean;
707
+ /** Rows of this parse by call id, so a shape entry and its result details merge before the upsert. */
708
+ readonly #shapes = new Map<string, SpotlightShapeRow>();
709
+ /** Need rows of this parse by call id (the changes this parse makes; the upsert merges them). */
710
+ readonly #needs = new Map<string, SpotlightNeedRow>();
711
+ /** Find blocks to locate in an original once its text is read ({@link finish}). */
712
+ readonly #locates: { read: SpotlightOpenRead; blocks: EditFindBlock[] }[] = [];
713
+
714
+ constructor(sessionFile: string, folder: string, state: SpotlightParserState, record = true) {
715
+ this.#sessionFile = sessionFile;
716
+ this.#folder = folder;
717
+ this.state = { ...state, open: (state.open ?? []).map(read => ({ ...read })) };
718
+ this.#record = record;
719
+ }
720
+
721
+ /**
722
+ * Locate the parse's edit Find blocks in their reads' originals (artifact files) and add the lines to the needs;
723
+ * an original that cannot be read adds none.
724
+ */
725
+ async finish(): Promise<void> {
726
+ const texts = new Map<string, Promise<string[] | null>>();
727
+ const listings = new Map<string, Promise<string[]>>();
728
+ for (const { read, blocks } of this.#locates) {
729
+ const url = read.url ?? "";
730
+ let text = texts.get(url);
731
+ if (!text) {
732
+ text = readArtifactLines(this.#sessionFile, url, listings);
733
+ texts.set(url, text);
734
+ }
735
+ const original = await text;
736
+ if (!original) continue;
737
+ const row = this.#need(read);
738
+ for (const block of blocks) {
739
+ const spans = locateFind(original, block.lines);
740
+ // Without `all` the edit needs exactly one match; more is a Find this text cannot place.
741
+ const located = block.all ? spans : spans.length === 1 ? spans : [];
742
+ for (const [a, b] of located) {
743
+ if (a <= read.lines) row.edit.push([a, Math.min(b, read.lines)]);
744
+ }
745
+ }
746
+ row.edit = mergeRanges(row.edit);
747
+ }
748
+ this.#locates.length = 0;
749
+ }
750
+
751
+ #need(read: SpotlightOpenRead): SpotlightNeedRow {
752
+ let row = this.#needs.get(read.toolCallId);
753
+ if (!row) {
754
+ row = {
755
+ sessionFile: this.#sessionFile,
756
+ toolCallId: read.toolCallId,
757
+ file: read.file,
758
+ open: true,
759
+ reread: [],
760
+ edit: [],
761
+ };
762
+ this.#needs.set(read.toolCallId, row);
763
+ this.rows.needs.push(row);
764
+ }
765
+ return row;
766
+ }
767
+
768
+ #close(read: SpotlightOpenRead): void {
769
+ const open = this.state.open ?? [];
770
+ this.state.open = open.filter(other => other !== read);
771
+ if (this.#record) this.#need(read).open = false;
772
+ }
773
+
774
+ /** Start collecting for a shaped read whose result just landed; it stops an earlier one of the same file. */
775
+ #openRead(row: SpotlightShapeRow): void {
776
+ const original = row.original;
777
+ if (row.tool !== "read" || !original?.path || original.lines <= 0) return;
778
+ if ((this.state.open ?? []).some(read => read.toolCallId === row.toolCallId)) return;
779
+ const file = resolveReadPath(original.path, this.state.cwd);
780
+ for (const read of this.state.open ?? []) if (read.file === file) this.#close(read);
781
+ const read: SpotlightOpenRead = {
782
+ toolCallId: row.toolCallId,
783
+ file,
784
+ start: original.start,
785
+ lines: original.lines,
786
+ url: original.url,
787
+ };
788
+ this.state.open = [...(this.state.open ?? []), read];
789
+ if (this.#record) this.#need(read);
790
+ }
791
+
792
+ /** What a later call needs of the open reads, and which ones it stops. */
793
+ #noteCall(toolName: string, args: unknown): void {
794
+ const open = this.state.open ?? [];
795
+ if (open.length === 0 || !isRecord(args)) return;
796
+ if (toolName === "read" && typeof args.path === "string") {
797
+ const selection = parseReadSelection(args.path);
798
+ const file = resolveReadPath(selection.path, this.state.cwd);
799
+ for (const read of open) {
800
+ if (read.file !== file) continue;
801
+ const ranges = selectedLines(selection);
802
+ if (ranges === null) {
803
+ this.#close(read);
804
+ continue;
805
+ }
806
+ if (!this.#record) continue;
807
+ const end = read.start + read.lines - 1;
808
+ const positions: [number, number][] = [];
809
+ for (const [a, b] of ranges) {
810
+ const from = Math.max(a, read.start);
811
+ const to = Math.min(b, end);
812
+ if (from <= to) positions.push([from - read.start + 1, to - read.start + 1]);
813
+ }
814
+ // The last N lines are known only when the original reaches the file's end: a whole-file read's does.
815
+ if (selection.tail !== null && read.start === 1)
816
+ positions.push([Math.max(1, read.lines - selection.tail + 1), read.lines]);
817
+ const row = this.#need(read);
818
+ row.reread = mergeRanges([...row.reread, ...positions]);
819
+ }
820
+ return;
821
+ }
822
+ const edited = editedPaths(toolName, args);
823
+ if (!edited) return;
824
+ const exact = new Set<string>();
825
+ const globs: Bun.Glob[] = [];
826
+ for (const file of edited) {
827
+ const resolved = resolveReadPath(file, this.state.cwd);
828
+ if (/[*?[{]/.test(file)) globs.push(new Bun.Glob(resolved));
829
+ else exact.add(resolved);
830
+ }
831
+ const blocks = toolName === "edit" && typeof args.input === "string" ? editFindBlocks(args.input) : [];
832
+ const fallback = typeof args.path === "string" ? args.path : null;
833
+ for (const read of open) {
834
+ if (!exact.has(read.file) && !globs.some(glob => glob.match(read.file))) continue;
835
+ if (this.#record) {
836
+ const own = blocks.filter(block => {
837
+ const file = block.file ?? fallback;
838
+ return file !== null && resolveReadPath(file, this.state.cwd) === read.file;
839
+ });
840
+ if (own.length > 0) this.#locates.push({ read, blocks: own });
841
+ }
842
+ this.#close(read);
843
+ }
844
+ }
845
+
846
+ visit(entry: unknown): void {
847
+ if (!isRecord(entry)) return;
848
+ const state = this.state;
849
+ switch (entry.type) {
850
+ case "session":
851
+ if (typeof entry.cwd === "string") state.cwd = entry.cwd;
852
+ if (typeof entry.title === "string") state.title = entry.title;
853
+ return;
854
+ case "title":
855
+ case "title_change":
856
+ if (typeof entry.title !== "string") return;
857
+ state.title = entry.title;
858
+ if (state.experiment && this.#record)
859
+ this.rows.titles.push({ sessionFile: this.#sessionFile, title: entry.title });
860
+ return;
861
+ case "custom":
862
+ this.#custom(entry);
863
+ return;
864
+ case "message":
865
+ if (isRecord(entry.message)) this.#message(entry, entry.message);
866
+ return;
867
+ }
868
+ }
869
+
870
+ #custom(entry: Record<string, unknown>): void {
871
+ if (!isRecord(entry.data)) return;
872
+ const timestamp = entryTime(undefined, entry.timestamp);
873
+ if (experimentMarker(entry.customType, entry.data)) {
874
+ // A later marker (the session moved files, or its settings changed) keeps the first one's start.
875
+ if (this.state.experiment) return;
876
+ this.state.experiment = true;
877
+ if (!this.#record) return;
878
+ this.rows.sessions.push({
879
+ sessionFile: this.#sessionFile,
880
+ folder: this.#folder,
881
+ markedAt: timestamp,
882
+ cwd: this.state.cwd,
883
+ title: this.state.title,
884
+ });
885
+ } else if (entry.customType === "spotlight:shape" && this.state.experiment && this.#record) {
886
+ const row = shapeFromEntry(this.#sessionFile, entry.data, timestamp);
887
+ if (row) this.#addShape(row);
888
+ }
889
+ }
890
+
891
+ #message(entry: Record<string, unknown>, message: Record<string, unknown>): void {
892
+ const state = this.state;
893
+ if (message.role === "toolResult") {
894
+ if (typeof message.toolCallId !== "string") return;
895
+ const details = message.details;
896
+ // Traces count only from the session's Spotlight marker on.
897
+ const traced = state.experiment && isRecord(details) && isRecord(details.spotlight);
898
+ if (!this.#record) {
899
+ // A prefix scan only rebuilds the open reads.
900
+ if (traced) {
901
+ const row = shapeFromDetails(this.#sessionFile, message.toolCallId, details, "", 0);
902
+ if (row) this.#openRead(row);
903
+ }
904
+ return;
905
+ }
906
+ if (!state.experiment) return;
907
+ const text = joinedText(message.content);
908
+ const timestamp = entryTime(message.timestamp, entry.timestamp);
909
+ if (traced) {
910
+ const row = shapeFromDetails(this.#sessionFile, message.toolCallId, details, text, timestamp);
911
+ if (row) {
912
+ this.#addShape(row);
913
+ this.#openRead(row);
914
+ }
915
+ }
916
+ this.rows.callResults.push({
917
+ sessionFile: this.#sessionFile,
918
+ toolCallId: message.toolCallId,
919
+ resultChars: text.length,
920
+ artifacts: artifactIds(text),
921
+ });
922
+ return;
923
+ }
924
+ if (message.role !== "assistant" || !state.experiment || typeof entry.id !== "string") return;
925
+ const timestamp = entryTime(message.timestamp, entry.timestamp);
926
+ if (this.#record) this.rows.requests.push({ sessionFile: this.#sessionFile, entryId: entry.id, timestamp });
927
+ if (!Array.isArray(message.content)) return;
928
+ const blocks = message.content.filter(
929
+ (block): block is Record<string, unknown> =>
930
+ isRecord(block) &&
931
+ block.type === "toolCall" &&
932
+ typeof block.id === "string" &&
933
+ typeof block.name === "string",
934
+ );
935
+ for (const block of blocks) {
936
+ const seq = state.seq++;
937
+ const toolName = String(block.name).trim();
938
+ this.#noteCall(toolName, block.arguments);
939
+ if (!this.#record) continue;
940
+ this.rows.calls.push({
941
+ sessionFile: this.#sessionFile,
942
+ toolCallId: String(block.id),
943
+ entryId: entry.id,
944
+ timestamp,
945
+ seq,
946
+ toolName,
947
+ callsInTurn: blocks.length,
948
+ target: callTarget(toolName, block.arguments),
949
+ edits: editedPaths(toolName, block.arguments),
950
+ argTokens: toolName === "read" || toolName === "bash" ? null : argumentTokens(block.arguments),
951
+ });
952
+ }
953
+ }
954
+
955
+ #addShape(row: SpotlightShapeRow): void {
956
+ const seen = this.#shapes.get(row.toolCallId);
957
+ if (!seen) {
958
+ this.#shapes.set(row.toolCallId, row);
959
+ this.rows.shapes.push(row);
960
+ return;
961
+ }
962
+ if (row.hasDetails) {
963
+ seen.hasDetails = true;
964
+ seen.original = row.original;
965
+ seen.artifacts = row.artifacts;
966
+ seen.inChars = row.inChars;
967
+ seen.outChars = row.outChars;
968
+ seen.inTokens = row.inTokens;
969
+ seen.outTokens = row.outTokens;
970
+ seen.steps = row.steps;
971
+ }
972
+ seen.plainChars ??= row.plainChars;
973
+ seen.plainTokens ??= row.plainTokens;
974
+ seen.cappedTokens ??= row.cappedTokens;
975
+ seen.scoreFree ||= row.scoreFree;
976
+ seen.holdout ||= row.holdout;
977
+ seen.gated ||= row.gated;
978
+ seen.spotlight ??= row.spotlight;
979
+ seen.shapeMs ??= row.shapeMs;
980
+ }
981
+ }