@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/parser.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  type AssistantMessage,
6
6
  coerceServiceTierByFamily,
7
7
  getPremiumServiceTierRequests,
8
+ type MessageAttribution,
8
9
  parseServiceTier,
9
10
  resolveModelServiceTier,
10
11
  type ServiceTierByFamily,
@@ -26,6 +27,13 @@ import type {
26
27
  UserMessageLink,
27
28
  UserMessageStats,
28
29
  } from "./types";
30
+ import {
31
+ emptySpotlightRows,
32
+ emptySpotlightState,
33
+ SpotlightCollector,
34
+ type SpotlightParseRows,
35
+ type SpotlightParserState,
36
+ } from "./spotlight-ingest";
29
37
  import { computeUserMessageMetrics, judgeProse } from "./user-metrics";
30
38
 
31
39
  /** Basename of an advisor agent's transcript inside a session artifacts dir. */
@@ -128,11 +136,16 @@ function extractUserText(content: unknown): string {
128
136
  }
129
137
 
130
138
  /**
131
- * Build user-message stats from an entry. Returns null for empty/synthetic content.
139
+ * Build user-message stats from an entry. Returns null for empty, synthetic, or agent-attributed content.
132
140
  */
133
141
  function extractUserStats(sessionFile: string, folder: string, entry: SessionMessageEntry): UserMessageStats | null {
134
- const msg = entry.message as { role: "user"; content?: unknown; synthetic?: boolean };
135
- if (msg.role !== "user" || msg.synthetic) return null;
142
+ const msg = entry.message as {
143
+ role: "user";
144
+ content?: unknown;
145
+ synthetic?: boolean;
146
+ attribution?: MessageAttribution;
147
+ };
148
+ if (msg.role !== "user" || msg.synthetic || msg.attribution === "agent") return null;
136
149
  const text = extractUserText(msg.content);
137
150
  if (!text.trim()) return null;
138
151
  const metrics = computeUserMessageMetrics(text);
@@ -490,10 +503,12 @@ export function parseAllSessionEntries(bytes: Uint8Array): SessionEntry[] {
490
503
  return entries;
491
504
  }
492
505
 
493
- function scanLastServiceTier(bytes: Uint8Array): ServiceTierByFamily | undefined {
506
+ /** Rebuild the cursor state of `bytes` (a transcript prefix): the last service tier and the Spotlight state. */
507
+ function scanPrefixState(bytes: Uint8Array, spotlight: SpotlightCollector): ServiceTierByFamily | undefined {
494
508
  let currentServiceTier: ServiceTierByFamily | undefined;
495
509
  visitSessionEntriesLenient(bytes, entry => {
496
510
  if (isServiceTierChange(entry)) currentServiceTier = coerceServiceTierByFamily(entry.serviceTier);
511
+ else spotlight.visit(entry);
497
512
  });
498
513
  return currentServiceTier;
499
514
  }
@@ -507,6 +522,8 @@ export interface SessionParserState {
507
522
  mtimeMs: number;
508
523
  checkpoint: string;
509
524
  serviceTier: ServiceTierByFamily | null;
525
+ /** Absent in cursors persisted before the Spotlight tables; such a tail parse starts from an empty state. */
526
+ spotlight?: SpotlightParserState;
510
527
  }
511
528
 
512
529
  export interface ParseSessionResult {
@@ -515,6 +532,7 @@ export interface ParseSessionResult {
515
532
  userLinks: UserMessageLink[];
516
533
  toolCalls: ToolCallStats[];
517
534
  toolResults: ToolResultLink[];
535
+ spotlight: SpotlightParseRows;
518
536
  newOffset: number;
519
537
  parserState?: SessionParserState;
520
538
  reset?: boolean;
@@ -562,9 +580,11 @@ export async function parseSessionFile(
562
580
  const toolResults: ToolResultLink[] = [];
563
581
  // First call per id: the row `INSERT OR IGNORE` keeps, so it can be written with its result.
564
582
  const callsById = new Map<string, ToolCallStats>();
583
+ let spotlight = new SpotlightCollector(sessionPath, folder, emptySpotlightState());
565
584
 
566
585
  // Reduce each entry immediately so full replays do not retain tool-output/message bodies.
567
586
  const visit = (entry: SessionEntry): void => {
587
+ spotlight.visit(entry);
568
588
  if (isServiceTierChange(entry)) {
569
589
  currentServiceTier = coerceServiceTierByFamily(entry.serviceTier);
570
590
  return;
@@ -633,9 +653,14 @@ export async function parseSessionFile(
633
653
  start = reset || replay ? 0 : Math.max(0, fromOffset);
634
654
  const readStart = resume ? start : 0;
635
655
  bytes = await file.slice(readStart, info.size).bytes();
636
- currentServiceTier = resume
637
- ? (state?.serviceTier ?? undefined)
638
- : scanLastServiceTier(bytes.subarray(0, start));
656
+ if (resume) {
657
+ currentServiceTier = state?.serviceTier ?? undefined;
658
+ spotlight = new SpotlightCollector(sessionPath, folder, state?.spotlight ?? emptySpotlightState());
659
+ } else {
660
+ const prefix = new SpotlightCollector(sessionPath, folder, emptySpotlightState(), false);
661
+ currentServiceTier = scanPrefixState(bytes.subarray(0, start), prefix);
662
+ spotlight = new SpotlightCollector(sessionPath, folder, prefix.state);
663
+ }
639
664
  read = visitSessionEntriesLenient(bytes.subarray(start - readStart), visit);
640
665
  const newOffset = start + read;
641
666
  const checkpointStart = Math.max(0, newOffset - CHECKPOINT_BYTES);
@@ -649,10 +674,19 @@ export async function parseSessionFile(
649
674
  }
650
675
  } catch (err) {
651
676
  if (isEnoent(err))
652
- return { stats: [], userStats: [], userLinks: [], toolCalls: [], toolResults: [], newOffset: fromOffset };
677
+ return {
678
+ stats: [],
679
+ userStats: [],
680
+ userLinks: [],
681
+ toolCalls: [],
682
+ toolResults: [],
683
+ spotlight: emptySpotlightRows(),
684
+ newOffset: fromOffset,
685
+ };
653
686
  throw err;
654
687
  }
655
688
 
689
+ await spotlight.finish();
656
690
  const newOffset = start + read;
657
691
  return {
658
692
  stats,
@@ -660,6 +694,7 @@ export async function parseSessionFile(
660
694
  userLinks,
661
695
  toolCalls,
662
696
  toolResults,
697
+ spotlight: spotlight.rows,
663
698
  newOffset,
664
699
  reset,
665
700
  parserState: {
@@ -672,6 +707,7 @@ export async function parseSessionFile(
672
707
  mtimeMs: info.mtimeMs,
673
708
  checkpoint,
674
709
  serviceTier: currentServiceTier ?? null,
710
+ spotlight: spotlight.state,
675
711
  },
676
712
  };
677
713
  }
package/src/rollup.ts CHANGED
@@ -56,8 +56,17 @@ const DAY_MS = 24 * HOUR_MS;
56
56
  const ROLLUP_VERSION = "2";
57
57
  const ROLLUP_VERSION_KEY = "rollup_version";
58
58
  /** Bump when the dirty-marking triggers change; they are then recreated in place. */
59
- const TRIGGER_VERSION = "3";
59
+ const TRIGGER_VERSION = "4";
60
60
  const TRIGGER_VERSION_KEY = "rollup_triggers_version";
61
+ /**
62
+ * `tool_agent_rollup` (tool-result characters per hour and agent type) is built
63
+ * on its own dirty set, so adding it never rebuilds the other rollups. Bump to
64
+ * rebuild just this table.
65
+ */
66
+ const TOOL_AGENT_VERSION = "1";
67
+ const TOOL_AGENT_VERSION_KEY = "tool_agent_rollup_version";
68
+ /** Before this, timestamps are sentinels (0) or garbage: their hours are seeded one by one, not as a range. */
69
+ const SANE_TIMESTAMP_MS = Date.UTC(2000, 0, 1);
61
70
 
62
71
  /** Above this many dirty hours, reads use stale rollup rows instead of scanning dirty hours raw. */
63
72
  const EXACT_DIRTY_LIMIT = 96;
@@ -142,11 +151,22 @@ function hourOf(column: string): string {
142
151
  * Trigger statements marking a row's hour and transcript dirty (`row` is
143
152
  * `NEW` or `OLD`), without any conflict clause.
144
153
  */
145
- function markDirty(row: "NEW" | "OLD"): string {
154
+ function markDirty(row: "NEW" | "OLD", table: "messages" | "tool_calls"): string {
155
+ const toolAgent =
156
+ table === "tool_calls"
157
+ ? `INSERT INTO tool_agent_dirty (bucket)
158
+ SELECT h FROM (SELECT ${hourOf(`${row}.timestamp`)} AS h) WHERE h NOT IN (SELECT bucket FROM tool_agent_dirty);`
159
+ : "";
146
160
  return `INSERT INTO rollup_dirty (bucket)
147
161
  SELECT h FROM (SELECT ${hourOf(`${row}.timestamp`)} AS h) WHERE h NOT IN (SELECT bucket FROM rollup_dirty);
148
162
  INSERT INTO session_dirty (session_file)
149
- SELECT ${row}.session_file WHERE ${row}.session_file NOT IN (SELECT session_file FROM session_dirty);`;
163
+ SELECT ${row}.session_file WHERE ${row}.session_file NOT IN (SELECT session_file FROM session_dirty);
164
+ ${toolAgent}`;
165
+ }
166
+
167
+ /** Additive per-bucket tool-result facts per agent type (columns of `tool_calls`). */
168
+ function toolAgentFacts(bucketExpr: string): string {
169
+ return `${bucketExpr} AS bucket, agent_type, SUM(COALESCE(result_chars, 0)) AS result_chars`;
150
170
  }
151
171
 
152
172
  /** Per-transcript facts over `messages` (qualified by `p`); tool calls counted by subquery. */
@@ -182,9 +202,10 @@ export function ensureRollupSchema(database: Database): void {
182
202
  const readMeta = (key: string) =>
183
203
  (database.query("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined)?.value;
184
204
  const fresh = readMeta(ROLLUP_VERSION_KEY) !== ROLLUP_VERSION;
205
+ const toolAgentFresh = fresh || readMeta(TOOL_AGENT_VERSION_KEY) !== TOOL_AGENT_VERSION;
185
206
  // Steady state touches no schema: DDL on every open would contend with
186
207
  // other omp processes writing the same database.
187
- if (!fresh && readMeta(TRIGGER_VERSION_KEY) === TRIGGER_VERSION) return;
208
+ if (!fresh && !toolAgentFresh && readMeta(TRIGGER_VERSION_KEY) === TRIGGER_VERSION) return;
188
209
  database
189
210
  .transaction(() => {
190
211
  if (fresh) {
@@ -194,6 +215,10 @@ export function ensureRollupSchema(database: Database): void {
194
215
  database.run("DROP TABLE IF EXISTS session_rollup");
195
216
  database.run("DROP TABLE IF EXISTS session_dirty");
196
217
  }
218
+ if (toolAgentFresh) {
219
+ database.run("DROP TABLE IF EXISTS tool_agent_rollup");
220
+ database.run("DROP TABLE IF EXISTS tool_agent_dirty");
221
+ }
197
222
  database.run(`
198
223
  CREATE TABLE IF NOT EXISTS message_rollup (
199
224
  bucket INTEGER NOT NULL,
@@ -256,17 +281,24 @@ export function ensureRollupSchema(database: Database): void {
256
281
  tool_calls INTEGER NOT NULL
257
282
  ) WITHOUT ROWID;
258
283
  CREATE TABLE IF NOT EXISTS session_dirty (session_file TEXT PRIMARY KEY) WITHOUT ROWID;
284
+ CREATE TABLE IF NOT EXISTS tool_agent_rollup (
285
+ bucket INTEGER NOT NULL,
286
+ agent_type TEXT NOT NULL,
287
+ result_chars INTEGER NOT NULL
288
+ );
289
+ CREATE INDEX IF NOT EXISTS idx_tool_agent_rollup_bucket ON tool_agent_rollup(bucket);
290
+ CREATE TABLE IF NOT EXISTS tool_agent_dirty (bucket INTEGER PRIMARY KEY) WITHOUT ROWID;
259
291
  `);
260
292
  // A tool share reads its assistant message, so message changes dirty the
261
293
  // hour for both rollups (tool calls share their message's timestamp).
262
294
  // Triggers must not use `OR IGNORE`: a trigger statement inherits the
263
295
  // outer statement's conflict policy, and the UPSERTs in
264
296
  // `insertMessageStats` would turn a duplicate dirty mark into a failed ingest.
265
- for (const table of ["messages", "tool_calls"]) {
297
+ for (const table of ["messages", "tool_calls"] as const) {
266
298
  const triggers = {
267
- insert: `AFTER INSERT ON ${table} BEGIN ${markDirty("NEW")} END`,
268
- delete: `AFTER DELETE ON ${table} BEGIN ${markDirty("OLD")} END`,
269
- update: `AFTER UPDATE ON ${table} BEGIN ${markDirty("OLD")} ${markDirty("NEW")} END`,
299
+ insert: `AFTER INSERT ON ${table} BEGIN ${markDirty("NEW", table)} END`,
300
+ delete: `AFTER DELETE ON ${table} BEGIN ${markDirty("OLD", table)} END`,
301
+ update: `AFTER UPDATE ON ${table} BEGIN ${markDirty("OLD", table)} ${markDirty("NEW", table)} END`,
270
302
  };
271
303
  for (const [event, body] of Object.entries(triggers)) {
272
304
  database.run(`DROP TRIGGER IF EXISTS rollup_dirty_${table}_${event}`);
@@ -290,6 +322,26 @@ export function ensureRollupSchema(database: Database): void {
290
322
  .query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
291
323
  .run(ROLLUP_VERSION_KEY, ROLLUP_VERSION);
292
324
  }
325
+ if (toolAgentFresh) {
326
+ // Every hour between the first and last call, seeded from two index lookups
327
+ // instead of a scan; hours without calls refresh to nothing.
328
+ database.run(`
329
+ WITH RECURSIVE h(bucket) AS (
330
+ SELECT ${hourOf("MIN(timestamp)")} FROM tool_calls WHERE timestamp >= ${SANE_TIMESTAMP_MS}
331
+ UNION ALL
332
+ SELECT bucket + ${HOUR_MS} FROM h
333
+ WHERE bucket + ${HOUR_MS} <= (SELECT MAX(timestamp) FROM tool_calls)
334
+ )
335
+ INSERT OR IGNORE INTO tool_agent_dirty (bucket) SELECT bucket FROM h WHERE bucket IS NOT NULL
336
+ `);
337
+ database.run(`
338
+ INSERT OR IGNORE INTO tool_agent_dirty (bucket)
339
+ SELECT DISTINCT ${hourOf("timestamp")} FROM tool_calls WHERE timestamp < ${SANE_TIMESTAMP_MS}
340
+ `);
341
+ database
342
+ .query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
343
+ .run(TOOL_AGENT_VERSION_KEY, TOOL_AGENT_VERSION);
344
+ }
293
345
  })
294
346
  .immediate();
295
347
  }
@@ -309,7 +361,8 @@ export function getRollupStatus(): RollupStatus {
309
361
  if (!database) return { dirtyHours: 0, dirtySessions: 0 };
310
362
  return database
311
363
  .query(
312
- "SELECT (SELECT COUNT(*) FROM rollup_dirty) AS dirtyHours, (SELECT COUNT(*) FROM session_dirty) AS dirtySessions",
364
+ `SELECT MAX((SELECT COUNT(*) FROM rollup_dirty), (SELECT COUNT(*) FROM tool_agent_dirty)) AS dirtyHours,
365
+ (SELECT COUNT(*) FROM session_dirty) AS dirtySessions`,
313
366
  )
314
367
  .get() as RollupStatus;
315
368
  }
@@ -350,6 +403,23 @@ export function refreshRollupBatch(limit: number): number {
350
403
  clean.run(bucket);
351
404
  }
352
405
 
406
+ const agentBuckets = database
407
+ .query("SELECT bucket FROM tool_agent_dirty ORDER BY bucket DESC LIMIT ?")
408
+ .all(limit) as { bucket: number }[];
409
+ const clearAgent = database.query("DELETE FROM tool_agent_rollup WHERE bucket = ?");
410
+ const fillAgent = database.query(`
411
+ INSERT INTO tool_agent_rollup (bucket, agent_type, result_chars)
412
+ SELECT ${toolAgentFacts("?1")}
413
+ FROM tool_calls WHERE timestamp >= ?1 AND timestamp < ?1 + ${HOUR_MS}
414
+ GROUP BY agent_type
415
+ `);
416
+ const cleanAgent = database.query("DELETE FROM tool_agent_dirty WHERE bucket = ?");
417
+ for (const { bucket } of agentBuckets) {
418
+ clearAgent.run(bucket);
419
+ fillAgent.run(bucket);
420
+ cleanAgent.run(bucket);
421
+ }
422
+
353
423
  // Transcripts are cheap (indexed by session_file); roll many per hour-batch.
354
424
  const sessions = database
355
425
  .query("SELECT session_file FROM session_dirty LIMIT ?")
@@ -367,7 +437,7 @@ export function refreshRollupBatch(limit: number): number {
367
437
  fillSession.run(session_file);
368
438
  cleanSession.run(session_file);
369
439
  }
370
- return buckets.length + sessions.length;
440
+ return buckets.length + agentBuckets.length + sessions.length;
371
441
  });
372
442
  return run.immediate();
373
443
  }
@@ -970,3 +1040,126 @@ export function getToolTimeSeries({ cutoff, bucketMs }: RangeWindow): ToolTimeSe
970
1040
  "GROUP BY 1, f.tool_name ORDER BY 1",
971
1041
  ).map(row => ({ timestamp: row.bucket, tool: row.tool_name, calls: row.calls, errors: row.errors }));
972
1042
  }
1043
+
1044
+ // ---------------------------------------------------------------------------
1045
+ // Context facts (Spotlight tool-output share)
1046
+ // ---------------------------------------------------------------------------
1047
+
1048
+ /** Per-bucket context facts of every transcript of one agent type: tool-result characters and output tokens. */
1049
+ export interface ContextFactPoint {
1050
+ bucket: number;
1051
+ agentType: AgentType;
1052
+ resultChars: number;
1053
+ outputTokens: number;
1054
+ }
1055
+
1056
+ /** Tool-result characters per hour and agent type over `[cutoff, now]`; same union shape as {@link toolSource}. */
1057
+ function toolAgentSource(database: Database, cutoff: number | null, fine: boolean): Source {
1058
+ if (fine) {
1059
+ return {
1060
+ sql: `SELECT ${toolAgentFacts("(timestamp / 300000) * 300000")} FROM tool_calls WHERE timestamp >= ? GROUP BY 1, agent_type`,
1061
+ params: [cutoff ?? 0],
1062
+ };
1063
+ }
1064
+ const hi = cutoff === null ? null : Math.ceil(cutoff / HOUR_MS) * HOUR_MS;
1065
+ const { n } = database
1066
+ .query(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM tool_agent_dirty LIMIT ${EXACT_DIRTY_LIMIT + 1})`)
1067
+ .get() as { n: number };
1068
+ const exact = n <= EXACT_DIRTY_LIMIT;
1069
+ const parts: string[] = [];
1070
+ const params: number[] = [];
1071
+ parts.push(
1072
+ `SELECT bucket, agent_type, result_chars FROM tool_agent_rollup
1073
+ WHERE ${exact ? "bucket NOT IN (SELECT bucket FROM tool_agent_dirty)" : "1"}${hi !== null ? " AND bucket >= ?" : ""}`,
1074
+ );
1075
+ if (hi !== null) params.push(hi);
1076
+ if (cutoff !== null && hi !== null && cutoff < hi) {
1077
+ parts.push(
1078
+ `SELECT ${toolAgentFacts(hourOf("timestamp"))} FROM tool_calls
1079
+ WHERE timestamp >= ? AND timestamp < ? GROUP BY 1, agent_type`,
1080
+ );
1081
+ params.push(cutoff, hi);
1082
+ }
1083
+ if (exact) {
1084
+ parts.push(
1085
+ `SELECT d.bucket AS bucket, t.agent_type AS agent_type, SUM(COALESCE(t.result_chars, 0)) AS result_chars
1086
+ FROM tool_agent_dirty d JOIN tool_calls t ON t.timestamp >= d.bucket AND t.timestamp < d.bucket + ${HOUR_MS}
1087
+ ${hi !== null ? "WHERE d.bucket >= ?" : ""}
1088
+ GROUP BY d.bucket, t.agent_type`,
1089
+ );
1090
+ if (hi !== null) params.push(hi);
1091
+ }
1092
+ return { sql: parts.join(" UNION ALL "), params };
1093
+ }
1094
+
1095
+ /** {@link ContextFactPoint}s over a window, from the rollups (exact while few hours are dirty). */
1096
+ export function getContextFactSeries(database: Database, { cutoff, bucketMs }: RangeWindow): ContextFactPoint[] {
1097
+ const fine = bucketMs < HOUR_MS;
1098
+ const normalized = normalizeCutoff(cutoff);
1099
+ const tools = toolAgentSource(database, normalized, fine);
1100
+ const messages = messageSource(database, normalized, fine);
1101
+ const points = new Map<string, ContextFactPoint>();
1102
+ const point = (bucket: number, agentType: AgentType) => {
1103
+ const key = `${bucket}:${agentType}`;
1104
+ let p = points.get(key);
1105
+ if (!p) {
1106
+ p = { bucket, agentType, resultChars: 0, outputTokens: 0 };
1107
+ points.set(key, p);
1108
+ }
1109
+ return p;
1110
+ };
1111
+ type Row = { bucket: number; agent_type: AgentType; n: number | null };
1112
+ const toolRows = database
1113
+ .query(
1114
+ `SELECT ${seriesBucket(bucketMs)} AS bucket, f.agent_type AS agent_type, SUM(f.result_chars) AS n
1115
+ FROM (${tools.sql}) f GROUP BY 1, 2`,
1116
+ )
1117
+ .all(...tools.params) as Row[];
1118
+ for (const row of toolRows) point(row.bucket, row.agent_type).resultChars += row.n ?? 0;
1119
+ const messageRows = database
1120
+ .query(
1121
+ `SELECT ${seriesBucket(bucketMs)} AS bucket, f.agent_type AS agent_type, SUM(f.output_tokens) AS n
1122
+ FROM (${messages.sql}) f GROUP BY 1, 2`,
1123
+ )
1124
+ .all(...messages.params) as Row[];
1125
+ for (const row of messageRows) point(row.bucket, row.agent_type).outputTokens += row.n ?? 0;
1126
+ return [...points.values()].sort((a, b) => a.bucket - b.bucket);
1127
+ }
1128
+
1129
+ /** A transcript's request span and its first request's prompt (input + cache read + cache write). */
1130
+ export interface SessionSpan {
1131
+ sessionFile: string;
1132
+ startedAt: number;
1133
+ endedAt: number;
1134
+ firstPrompt: number;
1135
+ }
1136
+
1137
+ /**
1138
+ * Spans of the transcripts with a request at or after `endedAfter` (all when null), from `session_rollup` with
1139
+ * the same exactness rule as {@link getSessionRollups}.
1140
+ */
1141
+ export function getSessionSpans(database: Database, endedAfter: number | null): SessionSpan[] {
1142
+ const { n } = database
1143
+ .query(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM session_dirty LIMIT ${EXACT_DIRTY_SESSIONS + 1})`)
1144
+ .get() as { n: number };
1145
+ const exact = n <= EXACT_DIRTY_SESSIONS;
1146
+ const parts = [
1147
+ `SELECT session_file, started_at, ended_at FROM session_rollup
1148
+ WHERE ended_at >= ?1${exact ? " AND session_file NOT IN (SELECT session_file FROM session_dirty)" : ""}`,
1149
+ ];
1150
+ if (exact) {
1151
+ parts.push(
1152
+ `SELECT m.session_file, MIN(m.timestamp), MAX(m.timestamp + COALESCE(m.duration, 0))
1153
+ FROM session_dirty d JOIN messages m ON m.session_file = d.session_file
1154
+ GROUP BY m.session_file HAVING MAX(m.timestamp + COALESCE(m.duration, 0)) >= ?1`,
1155
+ );
1156
+ }
1157
+ return database
1158
+ .query(
1159
+ `SELECT s.session_file AS sessionFile, s.started_at AS startedAt, s.ended_at AS endedAt,
1160
+ COALESCE((SELECT m.input_tokens + m.cache_read_tokens + m.cache_write_tokens FROM messages m
1161
+ WHERE m.timestamp = s.started_at AND m.session_file = s.session_file LIMIT 1), 0) AS firstPrompt
1162
+ FROM (${parts.join(" UNION ALL ")}) s`,
1163
+ )
1164
+ .all(endedAfter ?? 0) as SessionSpan[];
1165
+ }
package/src/server.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  STATS_DASHBOARD_SECURITY_VERSION,
40
40
  } from "./port-conflict";
41
41
  import { getStatsByModel, getTimeSeries } from "./rollup";
42
+ import { getSpotlightDashboardStats, getSpotlightSessionContext } from "./spotlight-aggregator";
42
43
  import {
43
44
  buildSessionTrace,
44
45
  getTraceEntry,
@@ -166,6 +167,33 @@ const STATS_ACTION_HEADER = "X-Omp-Stats-Action";
166
167
  /** What the page's Close calls; unset, the page offers none. */
167
168
  let closeHandler: (() => void) | undefined;
168
169
 
170
+ /** Longest a Spotlight build serves later requests of its data version: its time range moves with the clock. */
171
+ const SPOTLIGHT_VIEW_TTL_MS = 60_000;
172
+
173
+ /** Spotlight payloads (JSON) of one live data version by `range\0agent`, with when each build started. */
174
+ let spotlightViews: { version: number; views: Map<string, { at: number; json: Promise<string> }> } | null = null;
175
+
176
+ /**
177
+ * The Spotlight dashboard as JSON, built once per data version and view. Every open page refetches on each version
178
+ * bump and a build takes seconds on the thread that also ingests, so pages showing the same view share one build.
179
+ */
180
+ function spotlightView(range: string | null, agent: string | null): Promise<string> {
181
+ const version = statsLive().status().version;
182
+ if (spotlightViews?.version !== version) spotlightViews = { version, views: new Map() };
183
+ const views = spotlightViews.views;
184
+ const key = `${range ?? ""}\0${agent ?? ""}`;
185
+ const cached = views.get(key);
186
+ if (cached && Date.now() - cached.at < SPOTLIGHT_VIEW_TTL_MS) return cached.json;
187
+ const json = getSpotlightDashboardStats(range, agent).then(stats => JSON.stringify(stats));
188
+ const entry = { at: Date.now(), json };
189
+ views.set(key, entry);
190
+ // A failed build is not reused.
191
+ json.catch(() => {
192
+ if (views.get(key) === entry) views.delete(key);
193
+ });
194
+ return json;
195
+ }
196
+
169
197
  /**
170
198
  * Let the page close the dashboard: while `close` is set, it shows a Close
171
199
  * button (and takes Esc and Ctrl+C) that calls it. `omp stats` sets it while
@@ -303,6 +331,20 @@ export async function handleApi(req: Request): Promise<Response> {
303
331
  const stats = await getGainDashboardStats(range, project);
304
332
  return Response.json(stats);
305
333
  }
334
+ if (path === "/api/stats/spotlight") {
335
+ return new Response(await spotlightView(range, url.searchParams.get("agent")), {
336
+ headers: { "content-type": "application/json;charset=utf-8" },
337
+ });
338
+ }
339
+
340
+ if (path === "/api/stats/spotlight/session") {
341
+ const file = url.searchParams.get("file");
342
+ if (!file) return Response.json({ error: "file required" }, { status: 400 });
343
+ const context = await getSpotlightSessionContext(file);
344
+ if (!context) return Response.json({ error: "not a Spotlight experiment session" }, { status: 404 });
345
+ return Response.json(context);
346
+ }
347
+
306
348
  if (path === "/api/sessions") {
307
349
  const limitParam = Number(url.searchParams.get("limit") ?? "100");
308
350
  const limit = Number.isFinite(limitParam) && limitParam > 0 ? Math.floor(limitParam) : 100;