@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
package/src/db.ts CHANGED
@@ -14,6 +14,15 @@ import { getConfigRootDir, getStatsDbPath, VERSION } from "@oh-my-pi/pi-utils";
14
14
  import { ChunkedInsert } from "./chunked-insert";
15
15
  import { classifyAgentType, type ParseSessionResult, type SessionParserState } from "./parser";
16
16
  import { ensureRollupSchema, getDailyActivityFromRollup } from "./rollup";
17
+ import {
18
+ mergeRanges,
19
+ type SpotlightCallResultLink,
20
+ type SpotlightCallRow,
21
+ type SpotlightNeedRow,
22
+ type SpotlightRequestRow,
23
+ type SpotlightSessionRow,
24
+ type SpotlightShapeRow,
25
+ } from "./spotlight-ingest";
17
26
  import type {
18
27
  AgentType,
19
28
  DailyActivityPoint,
@@ -89,7 +98,7 @@ export function currentDb(): Database | null {
89
98
 
90
99
  const BACKFILL_COMPLETE = "complete";
91
100
  const BACKFILL_PENDING = "pending";
92
- const USER_MESSAGES_BACKFILL_KEY = "user_messages_v9";
101
+ const USER_MESSAGES_BACKFILL_KEY = "user_messages_v10";
93
102
  const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
94
103
  // v2: the parser also records the served service tier per message, so a full
95
104
  // re-parse fills `service_tier` and re-derives ultrafast premium counts that the
@@ -120,6 +129,27 @@ const NO_CACHE_INPUT_BACKFILL_KEY = "cost_no_cache_input_v1";
120
129
  // value stored is the version that ran it), since only a catalog update can
121
130
  // price a model it could not price before.
122
131
  const CATALOG_COST_BACKFILL_KEY = "catalog_cost_backfill";
132
+ // The Spotlight tables are filled by the parser; transcripts ingested before
133
+ // they existed are re-parsed once. Only files modified since the experiment
134
+ // shipped can hold its entries, so older cursors stay put.
135
+ const SPOTLIGHT_BACKFILL_KEY = "spotlight_v1";
136
+ const SPOTLIGHT_SINCE_MS = Date.parse("2026-10-28T00:00:00Z");
137
+ // Experiment sessions parsed before `spotlight_calls.edits` existed are
138
+ // re-parsed once (only them: they are few) so recovery detection sees edits.
139
+ // (`v3`: a build that ran the `v2` pass may have had its sessions re-parsed
140
+ // again by an older build, which records no edits.)
141
+ const SPOTLIGHT_EDITS_BACKFILL_KEY = "spotlight_v3_edits";
142
+ // Experiment sessions parsed before results carried heat, kept runs, plain sizes
143
+ // and needed lines are re-parsed once (only them, never the whole history).
144
+ const SPOTLIGHT_NEEDS_BACKFILL_KEY = "spotlight_v4_needs";
145
+ // Experiment sessions parsed before calls carried argument tokens (the generic
146
+ // rerun rule for every tool beyond read and bash) are re-parsed once.
147
+ const SPOTLIGHT_ARG_TOKENS_BACKFILL_KEY = "spotlight_v5_arg_tokens";
148
+ // The experiment became a toggle: sessions are marked by `spotlight:session`
149
+ // (which older builds did not read) and legacy `spotlight:assignment` sessions
150
+ // count only with the arm that shipped. Every transcript that can hold
151
+ // Spotlight entries is re-parsed once by the new rules.
152
+ const SPOTLIGHT_TOGGLE_BACKFILL_KEY = "spotlight_v6_toggle";
123
153
  function shouldResetBackfill(value: string | undefined): boolean {
124
154
  return value !== BACKFILL_COMPLETE && value !== BACKFILL_PENDING;
125
155
  }
@@ -273,12 +303,128 @@ export async function initDb(): Promise<Database> {
273
303
  key TEXT PRIMARY KEY,
274
304
  value TEXT NOT NULL
275
305
  );
306
+
307
+ -- Spotlight experiment sessions: the first Spotlight marker of each transcript.
308
+ CREATE TABLE IF NOT EXISTS spotlight_sessions (
309
+ session_file TEXT PRIMARY KEY,
310
+ folder TEXT NOT NULL,
311
+ marked_at INTEGER NOT NULL,
312
+ cwd TEXT,
313
+ title TEXT
314
+ ) WITHOUT ROWID;
315
+
316
+ -- One evaluated tool result: its spotlight:shape entry and its result's details.spotlight, upserted.
317
+ CREATE TABLE IF NOT EXISTS spotlight_results (
318
+ session_file TEXT NOT NULL,
319
+ tool_call_id TEXT NOT NULL,
320
+ timestamp INTEGER NOT NULL,
321
+ tool TEXT NOT NULL,
322
+ in_chars INTEGER NOT NULL,
323
+ out_chars INTEGER NOT NULL,
324
+ in_tokens INTEGER NOT NULL,
325
+ out_tokens INTEGER NOT NULL,
326
+ steps TEXT NOT NULL,
327
+ has_details INTEGER NOT NULL,
328
+ holdout INTEGER NOT NULL DEFAULT 0,
329
+ original_url TEXT,
330
+ path TEXT,
331
+ start_line INTEGER,
332
+ lines INTEGER,
333
+ hidden TEXT,
334
+ hidden_lines INTEGER NOT NULL DEFAULT 0,
335
+ artifacts TEXT,
336
+ sp_calls INTEGER,
337
+ sp_failures INTEGER,
338
+ sp_ms REAL,
339
+ sp_cost REAL,
340
+ shape_ms REAL,
341
+ plain_chars INTEGER,
342
+ plain_tokens INTEGER,
343
+ capped_tokens INTEGER,
344
+ score_free INTEGER NOT NULL DEFAULT 0,
345
+ heat TEXT,
346
+ kept TEXT,
347
+ gated INTEGER NOT NULL DEFAULT 0,
348
+ PRIMARY KEY (session_file, tool_call_id)
349
+ ) WITHOUT ROWID;
350
+ CREATE INDEX IF NOT EXISTS idx_spotlight_results_call ON spotlight_results(tool_call_id);
351
+
352
+ -- Every tool call of an experiment session from its marker on, in order.
353
+ CREATE TABLE IF NOT EXISTS spotlight_calls (
354
+ session_file TEXT NOT NULL,
355
+ tool_call_id TEXT NOT NULL,
356
+ entry_id TEXT NOT NULL,
357
+ timestamp INTEGER NOT NULL,
358
+ seq INTEGER NOT NULL,
359
+ tool_name TEXT NOT NULL,
360
+ calls_in_turn INTEGER NOT NULL,
361
+ target TEXT NOT NULL,
362
+ result_chars INTEGER,
363
+ artifacts TEXT,
364
+ edits TEXT,
365
+ arg_tokens TEXT,
366
+ PRIMARY KEY (session_file, tool_call_id)
367
+ ) WITHOUT ROWID;
368
+ CREATE INDEX IF NOT EXISTS idx_spotlight_calls_entry ON spotlight_calls(entry_id, timestamp);
369
+
370
+ -- Main-loop requests of an experiment session (side requests journaled as model_usage excluded).
371
+ CREATE TABLE IF NOT EXISTS spotlight_requests (
372
+ session_file TEXT NOT NULL,
373
+ entry_id TEXT NOT NULL,
374
+ timestamp INTEGER NOT NULL,
375
+ PRIMARY KEY (session_file, entry_id)
376
+ ) WITHOUT ROWID;
377
+ CREATE INDEX IF NOT EXISTS idx_spotlight_requests_entry ON spotlight_requests(entry_id, timestamp);
378
+
379
+ -- Original lines (1-based positions) a shaped read's later calls needed; open while still collecting.
380
+ CREATE TABLE IF NOT EXISTS spotlight_needs (
381
+ session_file TEXT NOT NULL,
382
+ tool_call_id TEXT NOT NULL,
383
+ file TEXT NOT NULL,
384
+ open INTEGER NOT NULL,
385
+ reread TEXT NOT NULL,
386
+ edit TEXT NOT NULL,
387
+ PRIMARY KEY (session_file, tool_call_id)
388
+ ) WITHOUT ROWID;
276
389
  `);
277
390
 
278
391
  const offsetColumns = db.query("PRAGMA table_info(file_offsets)").all() as { name: string }[];
279
392
  if (!offsetColumns.some(column => column.name === "parser_state")) {
280
393
  db.run("ALTER TABLE file_offsets ADD COLUMN parser_state TEXT");
281
394
  }
395
+ dropSpotlightArms(db);
396
+ // No result was held out before this column existed, so the default is exact for old rows.
397
+ const spotlightResultColumns = db.query<{ name: string }, []>("PRAGMA table_info(spotlight_results)").all();
398
+ if (!spotlightResultColumns.some(column => column.name === "holdout")) {
399
+ db.run("ALTER TABLE spotlight_results ADD COLUMN holdout INTEGER NOT NULL DEFAULT 0");
400
+ }
401
+ // Filled for existing rows by the `spotlight_v4_needs` re-parse; until then plain sizes are estimated.
402
+ for (const [column, type] of [
403
+ ["plain_chars", "INTEGER"],
404
+ ["plain_tokens", "INTEGER"],
405
+ ["capped_tokens", "INTEGER"],
406
+ ["score_free", "INTEGER NOT NULL DEFAULT 0"],
407
+ ["heat", "TEXT"],
408
+ ["kept", "TEXT"],
409
+ // No read was gated before this existed: the default is exact.
410
+ ["gated", "INTEGER NOT NULL DEFAULT 0"],
411
+ ] as const) {
412
+ if (!spotlightResultColumns.some(existing => existing.name === column)) {
413
+ db.run(`ALTER TABLE spotlight_results ADD COLUMN ${column} ${type}`);
414
+ }
415
+ }
416
+ // pi's read mark went with its former edit gate.
417
+ if (spotlightResultColumns.some(column => column.name === "edit_likely")) {
418
+ db.run("ALTER TABLE spotlight_results DROP COLUMN edit_likely");
419
+ }
420
+ const spotlightCallColumns = db.query<{ name: string }, []>("PRAGMA table_info(spotlight_calls)").all();
421
+ if (!spotlightCallColumns.some(column => column.name === "edits")) {
422
+ db.run("ALTER TABLE spotlight_calls ADD COLUMN edits TEXT");
423
+ }
424
+ // Filled for existing rows by the `spotlight_v5_arg_tokens` re-parse; until then no generic rerun is detected.
425
+ if (!spotlightCallColumns.some(column => column.name === "arg_tokens")) {
426
+ db.run("ALTER TABLE spotlight_calls ADD COLUMN arg_tokens TEXT");
427
+ }
282
428
  const messageColumns = db.query("PRAGMA table_info(messages)").all() as { name: string }[];
283
429
  if (!messageColumns.some(column => column.name === "premium_requests")) {
284
430
  db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
@@ -389,6 +535,11 @@ export async function initDb(): Promise<Database> {
389
535
  backfillMissingCatalogCosts(db);
390
536
  backfillNoCacheInputCosts(db);
391
537
  backfillForkDuplicates(db);
538
+ backfillSpotlight(db, SPOTLIGHT_BACKFILL_KEY);
539
+ reparseSpotlightSessions(db, SPOTLIGHT_EDITS_BACKFILL_KEY);
540
+ reparseSpotlightSessions(db, SPOTLIGHT_NEEDS_BACKFILL_KEY);
541
+ reparseSpotlightSessions(db, SPOTLIGHT_ARG_TOKENS_BACKFILL_KEY);
542
+ backfillSpotlight(db, SPOTLIGHT_TOGGLE_BACKFILL_KEY);
392
543
  ensureRollupSchema(db);
393
544
  return db;
394
545
  }
@@ -625,6 +776,67 @@ function backfillNoCacheInputCosts(database: Database): void {
625
776
  applyBackfill();
626
777
  }
627
778
 
779
+ /**
780
+ * Re-parse, once per key, the transcripts that can hold Spotlight entries
781
+ * (modified since {@link SPOTLIGHT_SINCE_MS}) by dropping their cursors; every
782
+ * older cursor stays, so this never triggers a full-history re-parse. The next
783
+ * sync rebuilds those files from byte zero, replacing their Spotlight rows.
784
+ * Keys: {@link SPOTLIGHT_BACKFILL_KEY} (the tables are new),
785
+ * {@link SPOTLIGHT_TOGGLE_BACKFILL_KEY} (the experiment became a toggle).
786
+ */
787
+ function backfillSpotlight(database: Database, key: string): void {
788
+ const row = database.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?").get(key);
789
+ if (row?.value === BACKFILL_COMPLETE) return;
790
+ database.transaction(() => {
791
+ database.query("DELETE FROM file_offsets WHERE last_modified >= ?").run(SPOTLIGHT_SINCE_MS);
792
+ database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(key, BACKFILL_COMPLETE);
793
+ })();
794
+ }
795
+
796
+ /**
797
+ * Drop the arm columns of a database written while sessions drew arms, once
798
+ * (while `spotlight_sessions.arm` exists). Sessions of any arm but the one that shipped
799
+ * were never shaped with the shipped algorithm: their Spotlight rows go now,
800
+ * so the dashboard leaves them out before the {@link SPOTLIGHT_TOGGLE_BACKFILL_KEY}
801
+ * re-parse (which also clears their parser state) runs.
802
+ */
803
+ function dropSpotlightArms(database: Database): void {
804
+ const columns = database.query<{ name: string }, []>("PRAGMA table_info(spotlight_sessions)").all();
805
+ if (!columns.some(column => column.name === "arm")) return;
806
+ database.transaction(() => {
807
+ const legacy = "SELECT session_file FROM spotlight_sessions WHERE arm <> 'vylv5'";
808
+ // `spotlight_sessions` (first in the list) goes last: the other deletes select through it.
809
+ for (const table of SPOTLIGHT_TABLES.toReversed()) {
810
+ database.run(`DELETE FROM ${table} WHERE session_file IN (${legacy})`);
811
+ }
812
+ for (const column of ["arm", "find_ranker", "source"]) {
813
+ database.run(`ALTER TABLE spotlight_sessions DROP COLUMN ${column}`);
814
+ }
815
+ database.run("ALTER TABLE spotlight_sessions RENAME COLUMN assigned_at TO marked_at");
816
+ database.run("ALTER TABLE spotlight_results DROP COLUMN arm");
817
+ // The retired guard-rail comparison cached its history reference here.
818
+ database.run("DELETE FROM meta WHERE key LIKE 'spotlight_guard_history:%'");
819
+ })();
820
+ }
821
+
822
+ /**
823
+ * Re-parse, once per key, every experiment session (the transcripts
824
+ * `spotlight_sessions` names, never the whole history) by dropping their
825
+ * cursors; the next sync rebuilds them from byte zero, replacing their
826
+ * Spotlight rows. Keys: {@link SPOTLIGHT_EDITS_BACKFILL_KEY} (calls get
827
+ * `edits`), {@link SPOTLIGHT_NEEDS_BACKFILL_KEY} (results get heat, kept runs,
828
+ * plain sizes and the score-free flag, shaped reads needed lines),
829
+ * {@link SPOTLIGHT_ARG_TOKENS_BACKFILL_KEY} (calls get argument tokens).
830
+ */
831
+ function reparseSpotlightSessions(database: Database, key: string): void {
832
+ const row = database.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?").get(key);
833
+ if (row?.value === BACKFILL_COMPLETE) return;
834
+ database.transaction(() => {
835
+ database.run("DELETE FROM file_offsets WHERE session_file IN (SELECT session_file FROM spotlight_sessions)");
836
+ database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(key, BACKFILL_COMPLETE);
837
+ })();
838
+ }
839
+
628
840
  /** Persisted transcript identity and parser cursor used to resume stats ingestion. */
629
841
  export interface FileOffset {
630
842
  offset: number;
@@ -841,6 +1053,9 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
841
1053
  }
842
1054
  }
843
1055
  if (replace) {
1056
+ for (const table of SPOTLIGHT_TABLES) {
1057
+ changes += database.query(`DELETE FROM ${table} WHERE session_file = ?`).run(sessionFile).changes;
1058
+ }
844
1059
  if (messages.length > 0) {
845
1060
  changes += database.query("DELETE FROM messages WHERE session_file = ?").run(sessionFile).changes;
846
1061
  }
@@ -865,7 +1080,14 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
865
1080
  insertUserMessageStats(parsedRows(writes, result => result.userStats)) +
866
1081
  updateUserMessageLinks(parsedRows(writes, result => result.userLinks)) +
867
1082
  insertToolCalls(parsedRows(writes, result => result.toolCalls)) +
868
- updateToolResults(parsedRows(writes, result => result.toolResults));
1083
+ updateToolResults(parsedRows(writes, result => result.toolResults)) +
1084
+ insertSpotlightSessions(parsedRows(writes, result => result.spotlight.sessions)) +
1085
+ updateSpotlightTitles(parsedRows(writes, result => result.spotlight.titles)) +
1086
+ upsertSpotlightShapes(parsedRows(writes, result => result.spotlight.shapes)) +
1087
+ insertSpotlightCalls(parsedRows(writes, result => result.spotlight.calls)) +
1088
+ updateSpotlightCallResults(parsedRows(writes, result => result.spotlight.callResults)) +
1089
+ insertSpotlightRequests(parsedRows(writes, result => result.spotlight.requests)) +
1090
+ upsertSpotlightNeeds(parsedRows(writes, result => result.spotlight.needs));
869
1091
  for (const { sessionFile, result } of sessions) {
870
1092
  if (result.parserState) {
871
1093
  setFileOffset(sessionFile, result.newOffset, result.parserState.mtimeMs, result.parserState);
@@ -1160,6 +1382,8 @@ export async function getDailyActivity(days = 371): Promise<DailyActivityPoint[]
1160
1382
  * - v9: stored judge prose (`prose` / `prose_hash`) for the frustration
1161
1383
  * judge, so every session re-parses once to populate it. Verdicts live in
1162
1384
  * `frustration_verdicts`, keyed by prose hash, which this reset never touches.
1385
+ * - v10: excluded agent-attributed user-role messages, which diluted user
1386
+ * frustration rates and could be judged as user complaints.
1163
1387
  *
1164
1388
  * Existing `messages` rows are unaffected - `INSERT OR IGNORE` keeps them.
1165
1389
  */
@@ -1702,3 +1926,257 @@ export function updateToolResults(links: Iterable<ToolResultLink>): number {
1702
1926
  });
1703
1927
  return updated;
1704
1928
  }
1929
+
1930
+ /** Tables the parser fills for the Spotlight dashboard, all keyed by `session_file`. */
1931
+ const SPOTLIGHT_TABLES = [
1932
+ "spotlight_sessions",
1933
+ "spotlight_results",
1934
+ "spotlight_calls",
1935
+ "spotlight_requests",
1936
+ "spotlight_needs",
1937
+ ] as const;
1938
+
1939
+ /** Record each experiment session's first Spotlight marker; a later one (a resumed session) never replaces it. */
1940
+ export function insertSpotlightSessions(rows: Iterable<SpotlightSessionRow>): number {
1941
+ if (!db) return 0;
1942
+ const stmt = db.query(`
1943
+ INSERT OR IGNORE INTO spotlight_sessions (session_file, folder, marked_at, cwd, title)
1944
+ VALUES (?, ?, ?, ?, ?)
1945
+ `);
1946
+ let changes = 0;
1947
+ atomically(db, () => {
1948
+ for (const r of rows) {
1949
+ changes += stmt.run(r.sessionFile, r.folder, r.markedAt, r.cwd, r.title).changes;
1950
+ }
1951
+ });
1952
+ return changes;
1953
+ }
1954
+
1955
+ export function updateSpotlightTitles(rows: Iterable<{ sessionFile: string; title: string }>): number {
1956
+ if (!db) return 0;
1957
+ const stmt = db.query("UPDATE spotlight_sessions SET title = ? WHERE session_file = ?");
1958
+ let changes = 0;
1959
+ atomically(db, () => {
1960
+ for (const r of rows) changes += stmt.run(r.title, r.sessionFile).changes;
1961
+ });
1962
+ return changes;
1963
+ }
1964
+
1965
+ /**
1966
+ * Upsert evaluated results. The shape entry and the result message can land in
1967
+ * different tail parses: the shape entry brings Spotlight's usage and timing,
1968
+ * the result's details the hidden lines and named artifacts, and either may
1969
+ * come first. Fork copies of a call (same tool call id under another
1970
+ * transcript) are skipped, first writer wins.
1971
+ */
1972
+ export function upsertSpotlightShapes(rows: Iterable<SpotlightShapeRow>): number {
1973
+ if (!db) return 0;
1974
+ const fromDetails = (column: string) =>
1975
+ `${column} = CASE WHEN excluded.has_details = 1 THEN excluded.${column} ELSE spotlight_results.${column} END`;
1976
+ const coalesce = (column: string) => `${column} = COALESCE(excluded.${column}, spotlight_results.${column})`;
1977
+ const writer = new ChunkedInsert(db, {
1978
+ verb: "INSERT",
1979
+ table: "spotlight_results",
1980
+ columns: [
1981
+ "session_file",
1982
+ "tool_call_id",
1983
+ "timestamp",
1984
+ "tool",
1985
+ "in_chars",
1986
+ "out_chars",
1987
+ "in_tokens",
1988
+ "out_tokens",
1989
+ "steps",
1990
+ "has_details",
1991
+ "holdout",
1992
+ "original_url",
1993
+ "path",
1994
+ "start_line",
1995
+ "lines",
1996
+ "hidden",
1997
+ "hidden_lines",
1998
+ "artifacts",
1999
+ "sp_calls",
2000
+ "sp_failures",
2001
+ "sp_ms",
2002
+ "sp_cost",
2003
+ "shape_ms",
2004
+ "plain_chars",
2005
+ "plain_tokens",
2006
+ "capped_tokens",
2007
+ "score_free",
2008
+ "heat",
2009
+ "kept",
2010
+ "gated",
2011
+ ],
2012
+ identity: ["tool_call_id"],
2013
+ upsert: `ON CONFLICT(session_file, tool_call_id) DO UPDATE SET
2014
+ ${["tool", "in_chars", "out_chars", "in_tokens", "out_tokens", "steps", "hidden_lines"].map(fromDetails).join(",\n")},
2015
+ ${["original_url", "path", "start_line", "lines", "hidden", "artifacts", "sp_calls", "sp_failures", "sp_ms", "sp_cost", "shape_ms", "plain_chars", "plain_tokens", "capped_tokens", "heat", "kept"].map(coalesce).join(",\n")},
2016
+ has_details = MAX(spotlight_results.has_details, excluded.has_details),
2017
+ holdout = MAX(spotlight_results.holdout, excluded.holdout),
2018
+ score_free = MAX(spotlight_results.score_free, excluded.score_free),
2019
+ gated = MAX(spotlight_results.gated, excluded.gated)`,
2020
+ });
2021
+ return atomically(db, () => {
2022
+ for (const r of rows) {
2023
+ const o = r.original;
2024
+ writer.add(
2025
+ r.sessionFile,
2026
+ [r.toolCallId],
2027
+ [
2028
+ r.sessionFile,
2029
+ r.toolCallId,
2030
+ r.timestamp,
2031
+ r.tool,
2032
+ r.inChars,
2033
+ r.outChars,
2034
+ r.inTokens,
2035
+ r.outTokens,
2036
+ JSON.stringify(r.steps),
2037
+ r.hasDetails ? 1 : 0,
2038
+ r.holdout ? 1 : 0,
2039
+ o?.url ?? null,
2040
+ o?.path ?? null,
2041
+ o ? o.start : null,
2042
+ o ? o.lines : null,
2043
+ o ? JSON.stringify(o.hidden) : null,
2044
+ o?.hiddenLines ?? 0,
2045
+ r.artifacts ? JSON.stringify(r.artifacts) : null,
2046
+ r.spotlight?.calls ?? null,
2047
+ r.spotlight?.failures ?? null,
2048
+ r.spotlight?.ms ?? null,
2049
+ r.spotlight?.costUsd ?? null,
2050
+ r.shapeMs,
2051
+ r.plainChars,
2052
+ r.plainTokens,
2053
+ r.cappedTokens,
2054
+ r.scoreFree ? 1 : 0,
2055
+ o?.heat ?? null,
2056
+ o ? JSON.stringify(o.kept) : null,
2057
+ r.gated ? 1 : 0,
2058
+ ],
2059
+ );
2060
+ }
2061
+ return writer.finish();
2062
+ });
2063
+ }
2064
+
2065
+ /**
2066
+ * Merge needed lines of shaped reads: ranges add to the stored ones (a tail parse brings only its own calls'),
2067
+ * `open` takes the latest. A fork's copy of another transcript's read is skipped, first writer wins.
2068
+ */
2069
+ export function upsertSpotlightNeeds(rows: Iterable<SpotlightNeedRow>): number {
2070
+ if (!db) return 0;
2071
+ const database = db;
2072
+ const claimed = database.query<{ one: number }, [string, string]>(
2073
+ "SELECT 1 AS one FROM spotlight_needs WHERE tool_call_id = ? AND session_file <> ? LIMIT 1",
2074
+ );
2075
+ const stored = database.query<{ reread: string; edit: string }, [string, string]>(
2076
+ "SELECT reread, edit FROM spotlight_needs WHERE session_file = ? AND tool_call_id = ?",
2077
+ );
2078
+ const write = database.query(
2079
+ "INSERT OR REPLACE INTO spotlight_needs (session_file, tool_call_id, file, open, reread, edit) VALUES (?, ?, ?, ?, ?, ?)",
2080
+ );
2081
+ const ranges = (json: string | undefined): [number, number][] => {
2082
+ if (!json) return [];
2083
+ const value: unknown = JSON.parse(json);
2084
+ return Array.isArray(value)
2085
+ ? value.filter(
2086
+ (pair): pair is [number, number] =>
2087
+ Array.isArray(pair) && typeof pair[0] === "number" && typeof pair[1] === "number",
2088
+ )
2089
+ : [];
2090
+ };
2091
+ let changes = 0;
2092
+ atomically(database, () => {
2093
+ for (const r of rows) {
2094
+ if (claimed.get(r.toolCallId, r.sessionFile)) continue;
2095
+ const old = stored.get(r.sessionFile, r.toolCallId);
2096
+ changes += write.run(
2097
+ r.sessionFile,
2098
+ r.toolCallId,
2099
+ r.file,
2100
+ r.open ? 1 : 0,
2101
+ JSON.stringify(mergeRanges([...ranges(old?.reread), ...r.reread])),
2102
+ JSON.stringify(mergeRanges([...ranges(old?.edit), ...r.edit])),
2103
+ ).changes;
2104
+ }
2105
+ });
2106
+ return changes;
2107
+ }
2108
+
2109
+ /** Insert experiment-session tool calls; fork copies are skipped as for `tool_calls`. */
2110
+ export function insertSpotlightCalls(rows: Iterable<SpotlightCallRow>): number {
2111
+ if (!db) return 0;
2112
+ const writer = new ChunkedInsert(db, {
2113
+ verb: "INSERT OR IGNORE",
2114
+ table: "spotlight_calls",
2115
+ columns: [
2116
+ "session_file",
2117
+ "tool_call_id",
2118
+ "entry_id",
2119
+ "timestamp",
2120
+ "seq",
2121
+ "tool_name",
2122
+ "calls_in_turn",
2123
+ "target",
2124
+ "edits",
2125
+ "arg_tokens",
2126
+ ],
2127
+ identity: ["entry_id", "timestamp", "tool_call_id"],
2128
+ });
2129
+ return atomically(db, () => {
2130
+ for (const r of rows) {
2131
+ writer.add(
2132
+ r.sessionFile,
2133
+ [r.entryId, r.timestamp, r.toolCallId],
2134
+ [
2135
+ r.sessionFile,
2136
+ r.toolCallId,
2137
+ r.entryId,
2138
+ r.timestamp,
2139
+ r.seq,
2140
+ r.toolName,
2141
+ r.callsInTurn,
2142
+ r.target,
2143
+ r.edits ? JSON.stringify(r.edits) : null,
2144
+ r.argTokens ? JSON.stringify(r.argTokens) : null,
2145
+ ],
2146
+ );
2147
+ }
2148
+ return writer.finish();
2149
+ });
2150
+ }
2151
+
2152
+ /** Attach result size and named artifacts to recorded calls (results can land in a later tail parse). */
2153
+ export function updateSpotlightCallResults(links: Iterable<SpotlightCallResultLink>): number {
2154
+ if (!db) return 0;
2155
+ const stmt = db.query(`
2156
+ UPDATE spotlight_calls SET result_chars = ?, artifacts = ?
2157
+ WHERE session_file = ? AND tool_call_id = ? AND result_chars IS NULL
2158
+ `);
2159
+ let changes = 0;
2160
+ atomically(db, () => {
2161
+ for (const l of links) {
2162
+ changes += stmt.run(l.resultChars, JSON.stringify(l.artifacts), l.sessionFile, l.toolCallId).changes;
2163
+ }
2164
+ });
2165
+ return changes;
2166
+ }
2167
+
2168
+ /** Insert experiment-session main-loop requests; fork copies are skipped as for `messages`. */
2169
+ export function insertSpotlightRequests(rows: Iterable<SpotlightRequestRow>): number {
2170
+ if (!db) return 0;
2171
+ const writer = new ChunkedInsert(db, {
2172
+ verb: "INSERT OR IGNORE",
2173
+ table: "spotlight_requests",
2174
+ columns: ["session_file", "entry_id", "timestamp"],
2175
+ identity: ["entry_id", "timestamp"],
2176
+ });
2177
+ return atomically(db, () => {
2178
+ for (const r of rows)
2179
+ writer.add(r.sessionFile, [r.entryId, r.timestamp], [r.sessionFile, r.entryId, r.timestamp]);
2180
+ return writer.finish();
2181
+ });
2182
+ }
package/src/index.ts CHANGED
@@ -23,7 +23,16 @@ export { refreshRollups } from "./rollup";
23
23
  export type { StatsJudge, StatsJudgeProvider } from "./frustration";
24
24
  export { getGainDashboardStats } from "./gain-aggregator";
25
25
  export { formatStatsDashboardUrl, type StartServerOptions, setStatsCloseHandler, startServer } from "./server";
26
- export type { GainDashboardStats, GainSource, GainSourceTotals, GainTimeSeriesPoint } from "./shared-types";
26
+ export type {
27
+ GainDashboardStats,
28
+ GainSource,
29
+ GainSourceTotals,
30
+ GainTimeSeriesPoint,
31
+ SpotlightAgentScope,
32
+ SpotlightDashboardStats,
33
+ SpotlightSessionContext,
34
+ } from "./shared-types";
35
+ export { getSpotlightDashboardStats, getSpotlightSessionContext } from "./spotlight-aggregator";
27
36
  export type {
28
37
  AggregatedStats,
29
38
  DashboardStats,
package/src/live.ts CHANGED
@@ -25,6 +25,11 @@ const WATCH_DEBOUNCE_MS = 800;
25
25
  const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000;
26
26
  /** Minimum spacing of `version` bumps; each bump makes open pages refetch. */
27
27
  const VERSION_THROTTLE_MS = 1000;
28
+ /**
29
+ * The same while a full sync runs. Pages rebuild on the thread that ingests, so a catch-up over many transcripts would
30
+ * otherwise spend most of its time serving refetches; the held-back bump lands when the sync ends.
31
+ */
32
+ const SYNCING_VERSION_THROTTLE_MS = 15_000;
28
33
  /** Delay before retrying a failed sync (typically a lock held by another omp process). */
29
34
  const SYNC_RETRY_MS = 10_000;
30
35
  /** Minimum spacing of progress-only status events. */
@@ -169,6 +174,8 @@ export class StatsLive {
169
174
  }
170
175
  // Another process may have ingested too; its commits move SQLite's data_version.
171
176
  if (this.#externalWrites()) this.#changed();
177
+ // A full sync held its last bump back: show its data now rather than at the end of the throttle.
178
+ if (visible && this.#versionTimer) this.#advanceVersion();
172
179
  this.#emit();
173
180
  }
174
181
 
@@ -187,17 +194,22 @@ export class StatsLive {
187
194
  this.#bumpVersion();
188
195
  }
189
196
 
190
- /** Bump the data version (throttled), making open pages refetch. */
197
+ /** Bump the data version (throttled, more so during a full sync), making open pages refetch. */
191
198
  #bumpVersion(): void {
192
199
  if (this.#versionTimer) return;
193
- const wait = Math.max(0, this.#lastVersionAt + VERSION_THROTTLE_MS - Date.now());
194
- this.#versionTimer = setTimeout(() => {
195
- this.#versionTimer = null;
196
- this.#lastVersionAt = Date.now();
197
- this.#version++;
198
- this.#indexingHours = getRollupStatus().dirtyHours;
199
- this.#emit();
200
- }, wait);
200
+ const spacing = this.#sync.phase === "syncing" ? SYNCING_VERSION_THROTTLE_MS : VERSION_THROTTLE_MS;
201
+ const wait = Math.max(0, this.#lastVersionAt + spacing - Date.now());
202
+ this.#versionTimer = setTimeout(() => this.#advanceVersion(), wait);
203
+ }
204
+
205
+ /** Bump the data version now, cancelling a pending throttled bump. */
206
+ #advanceVersion(): void {
207
+ clearTimeout(this.#versionTimer ?? undefined);
208
+ this.#versionTimer = null;
209
+ this.#lastVersionAt = Date.now();
210
+ this.#version++;
211
+ this.#indexingHours = getRollupStatus().dirtyHours;
212
+ this.#emit();
201
213
  }
202
214
 
203
215
  async #refresh(): Promise<void> {