@oh-my-pi/omp-stats 18.7.0 → 18.8.1

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 (48) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/client/index.css +1 -1
  3. package/dist/client/index.js +96 -96
  4. package/dist/types/aggregator.d.ts +3 -0
  5. package/dist/types/client/api.d.ts +5 -1
  6. package/dist/types/client/data/live.d.ts +16 -11
  7. package/dist/types/client/data/query-store.d.ts +39 -0
  8. package/dist/types/client/data/query.d.ts +10 -5
  9. package/dist/types/client/traces/Minimap.d.ts +2 -0
  10. package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
  11. package/dist/types/client/traces/TranscriptList.d.ts +3 -1
  12. package/dist/types/db.d.ts +14 -2
  13. package/dist/types/embedded-client.d.ts +5 -0
  14. package/dist/types/live.d.ts +5 -3
  15. package/dist/types/rollup.d.ts +9 -1
  16. package/dist/types/trace.d.ts +18 -6
  17. package/package.json +4 -4
  18. package/src/aggregator.ts +26 -9
  19. package/src/client/api.ts +40 -5
  20. package/src/client/app/LiveChip.tsx +2 -2
  21. package/src/client/app/Shell.tsx +2 -2
  22. package/src/client/data/live.tsx +85 -45
  23. package/src/client/data/query-store.ts +108 -0
  24. package/src/client/data/query.ts +47 -56
  25. package/src/client/routes/CostsRoute.tsx +4 -2
  26. package/src/client/routes/ErrorsRoute.tsx +3 -1
  27. package/src/client/routes/FrustrationRoute.tsx +1 -1
  28. package/src/client/routes/GainRoute.tsx +3 -1
  29. package/src/client/routes/ModelsRoute.tsx +9 -2
  30. package/src/client/routes/OverviewRoute.tsx +2 -2
  31. package/src/client/routes/ProjectsRoute.tsx +1 -1
  32. package/src/client/routes/ProvidersRoute.tsx +5 -3
  33. package/src/client/routes/RequestsRoute.tsx +1 -1
  34. package/src/client/routes/ToolsRoute.tsx +1 -1
  35. package/src/client/routes/TracesRoute.tsx +5 -4
  36. package/src/client/traces/Minimap.tsx +63 -26
  37. package/src/client/traces/TimelineCanvas.tsx +224 -105
  38. package/src/client/traces/TraceView.tsx +29 -10
  39. package/src/client/traces/TranscriptList.tsx +17 -9
  40. package/src/client/traces/traces.css +26 -0
  41. package/src/client/ui/RequestDrawer.tsx +3 -1
  42. package/src/db.ts +106 -64
  43. package/src/embedded-client.ts +9 -0
  44. package/src/live.ts +36 -11
  45. package/src/parser.ts +1 -8
  46. package/src/rollup.ts +41 -20
  47. package/src/server.ts +38 -24
  48. package/src/trace.ts +54 -40
@@ -4,7 +4,7 @@
4
4
  * selection. Hand-rolled fixed-row virtualization (32px rows, spacer divs).
5
5
  */
6
6
 
7
- import { useEffect, useMemo, useRef, useState } from "react";
7
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
8
8
  import { formatDurationMs } from "../data/formatters";
9
9
  import { EmptyState } from "../ui";
10
10
  import type { TraceMarker, TraceSpan, TraceTrack } from "../types";
@@ -25,6 +25,8 @@ export interface TranscriptListProps {
25
25
  selection: string | null;
26
26
  onSelect: (spanId: string) => void;
27
27
  search: string;
28
+ /** Spans matching `search` (from the trace view); `null` when not searching. */
29
+ matchIds: ReadonlySet<string> | null;
28
30
  traceStart: number;
29
31
  }
30
32
 
@@ -49,20 +51,26 @@ export function buildTranscriptRows(tracks: TraceTrack[]): TranscriptRow[] {
49
51
  return rows;
50
52
  }
51
53
 
52
- export function TranscriptList({ tracks, selection, onSelect, search, traceStart }: TranscriptListProps) {
54
+ export const TranscriptList = memo(function TranscriptList({
55
+ tracks,
56
+ selection,
57
+ onSelect,
58
+ search,
59
+ matchIds,
60
+ traceStart,
61
+ }: TranscriptListProps) {
53
62
  const colors = useTraceTheme();
54
63
  const containerRef = useRef<HTMLDivElement>(null);
55
64
  const [scrollTop, setScrollTop] = useState(0);
56
65
 
57
66
  const allRows = useMemo(() => buildTranscriptRows(tracks), [tracks]);
58
67
  const rows = useMemo(() => {
68
+ if (!matchIds) return allRows;
59
69
  const needle = search.trim().toLowerCase();
60
- if (!needle) return allRows;
61
- return allRows.filter(row => {
62
- const text = row.span ? `${row.span.label} ${row.span.detail ?? ""}` : (row.marker?.label ?? "");
63
- return text.toLowerCase().includes(needle);
64
- });
65
- }, [allRows, search]);
70
+ return allRows.filter(row =>
71
+ row.span ? matchIds.has(row.span.id) : (row.marker?.label ?? "").toLowerCase().includes(needle),
72
+ );
73
+ }, [allRows, search, matchIds]);
66
74
 
67
75
  // Scroll the selected row into view when selection changes externally.
68
76
  useEffect(() => {
@@ -135,4 +143,4 @@ export function TranscriptList({ tracks, selection, onSelect, search, traceStart
135
143
  {rows.length === 0 && <EmptyState title="No matching events" />}
136
144
  </div>
137
145
  );
138
- }
146
+ });
@@ -111,6 +111,32 @@
111
111
  cursor: grabbing;
112
112
  }
113
113
 
114
+ /* Scene canvas with the hover/selection boxes stacked on top. */
115
+ .traces-canvas-stack {
116
+ position: relative;
117
+ flex-shrink: 0;
118
+ overflow: hidden;
119
+ }
120
+
121
+ .traces-canvas-stack > canvas {
122
+ display: block;
123
+ }
124
+
125
+ .traces-canvas-hover,
126
+ .traces-canvas-selection {
127
+ position: absolute;
128
+ pointer-events: none;
129
+ box-sizing: border-box;
130
+ }
131
+
132
+ .traces-canvas-hover {
133
+ background: rgba(255, 255, 255, 0.16);
134
+ }
135
+
136
+ .traces-canvas-selection {
137
+ border: 1.5px solid;
138
+ }
139
+
114
140
  .traces-chevron {
115
141
  display: inline-flex;
116
142
  background: none;
@@ -34,7 +34,9 @@ export interface RequestDrawerProps {
34
34
 
35
35
  /** Full detail sheet for one model request: timing, tokens, cost, ids, error, and the raw session entry. */
36
36
  export function RequestDrawer({ id, onClose }: RequestDrawerProps) {
37
- const query = useQuery(["request", id], () => getRequestDetails(id ?? 0), { enabled: id !== null });
37
+ const query = useQuery(["request", id], ({ signal }) => getRequestDetails(id ?? 0, signal), {
38
+ enabled: id !== null,
39
+ });
38
40
  // Never show the previous request's payload under a new id.
39
41
  const details = query.stale ? null : query.data;
40
42
  const status = details ? REQUEST_STATUS[requestStatus(details)] : null;
package/src/db.ts CHANGED
@@ -8,9 +8,9 @@ import {
8
8
  getBundledModel,
9
9
  } from "@oh-my-pi/pi-catalog/models";
10
10
  import type { ModelCost } from "@oh-my-pi/pi-catalog/types";
11
- import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
11
+ import { getConfigRootDir, getStatsDbPath, VERSION } from "@oh-my-pi/pi-utils";
12
12
  import { classifyAgentType, type ParseSessionResult, type SessionParserState } from "./parser";
13
- import { ensureRollupSchema } from "./rollup";
13
+ import { ensureRollupSchema, getDailyActivityFromRollup } from "./rollup";
14
14
  import type {
15
15
  AgentType,
16
16
  DailyActivityPoint,
@@ -109,6 +109,13 @@ const COST_REINGEST_BACKFILL_KEY = "messages_cost_reingest_v1";
109
109
  // above is already spent for them — without this one, every historical row
110
110
  // keeps `cost_unpriced = 0` and unknown scheduled spend reports as free.
111
111
  const COST_UNPRICED_BACKFILL_KEY = "messages_cost_unpriced_v1";
112
+ // Rows predating the `cost_no_cache_input` column are NULL; ingest always
113
+ // writes a value, so one pass settles the column for good.
114
+ const NO_CACHE_INPUT_BACKFILL_KEY = "cost_no_cache_input_v1";
115
+ // Zero-cost rows are repriced from the bundled catalog once per release (the
116
+ // value stored is the version that ran it), since only a catalog update can
117
+ // price a model it could not price before.
118
+ const CATALOG_COST_BACKFILL_KEY = "catalog_cost_backfill";
112
119
  function shouldResetBackfill(value: string | undefined): boolean {
113
120
  return value !== BACKFILL_COMPLETE && value !== BACKFILL_PENDING;
114
121
  }
@@ -130,7 +137,7 @@ export async function initDb(): Promise<Database> {
130
137
  // Whether `messages` predates this init — drives the one-time agent_type
131
138
  // backfill below, so it must be sampled before CREATE TABLE adds the table.
132
139
  const messagesTableExisted =
133
- db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages'").get() !== undefined;
140
+ db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages'").get() !== undefined;
134
141
 
135
142
  // Create tables
136
143
  db.run(`
@@ -245,11 +252,11 @@ export async function initDb(): Promise<Database> {
245
252
  );
246
253
  `);
247
254
 
248
- const offsetColumns = db.prepare("PRAGMA table_info(file_offsets)").all() as { name: string }[];
255
+ const offsetColumns = db.query("PRAGMA table_info(file_offsets)").all() as { name: string }[];
249
256
  if (!offsetColumns.some(column => column.name === "parser_state")) {
250
257
  db.run("ALTER TABLE file_offsets ADD COLUMN parser_state TEXT");
251
258
  }
252
- const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
259
+ const messageColumns = db.query("PRAGMA table_info(messages)").all() as { name: string }[];
253
260
  if (!messageColumns.some(column => column.name === "premium_requests")) {
254
261
  db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
255
262
  }
@@ -266,7 +273,6 @@ export async function initDb(): Promise<Database> {
266
273
  if (!messageColumns.some(column => column.name === "cost_unpriced")) {
267
274
  db.run("ALTER TABLE messages ADD COLUMN cost_unpriced INTEGER NOT NULL DEFAULT 0");
268
275
  }
269
- db.run("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
270
276
  // Token-usage-by-agent: each message is classified main / subagent / advisor
271
277
  // from its transcript path. A brand-new table gets the column from CREATE
272
278
  // TABLE and the parser labels rows at insert time; a pre-existing table gets
@@ -282,7 +288,7 @@ export async function initDb(): Promise<Database> {
282
288
  // sentinel write (process killed in between) still reclassifies on the next
283
289
  // init instead of silently leaving every row as the 'main' default. A
284
290
  // brand-new empty table has nothing to reclassify, so it settles COMPLETE.
285
- db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
291
+ db.query("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
286
292
  AGENT_TYPE_BACKFILL_KEY,
287
293
  messagesTableExisted ? BACKFILL_PENDING : BACKFILL_COMPLETE,
288
294
  );
@@ -307,7 +313,7 @@ export async function initDb(): Promise<Database> {
307
313
  // v7 -> v8: `no-op` compounds no longer count as negation; recovered
308
314
  // measured false negatives: `:(` emoticons -> anguish,
309
315
  // `why (would|did) you` -> blame, `makes no sense` -> negation.
310
- const userMessageColumns = db.prepare("PRAGMA table_info(user_messages)").all() as {
316
+ const userMessageColumns = db.query("PRAGMA table_info(user_messages)").all() as {
311
317
  name: string;
312
318
  }[];
313
319
  const hasStaleColumn =
@@ -500,17 +506,20 @@ function calculateNoCacheInputCost(
500
506
  }
501
507
 
502
508
  function backfillMissingCatalogCosts(database: Database): void {
509
+ const ran = database.query("SELECT value FROM meta WHERE key = ?").get(CATALOG_COST_BACKFILL_KEY) as
510
+ | { value: string }
511
+ | undefined;
512
+ if (ran?.value === VERSION) return;
513
+ const markRan = database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
503
514
  const rows = database
504
- .prepare(`
515
+ .query(`
505
516
  SELECT id, provider, model, timestamp, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
506
517
  FROM messages
507
518
  WHERE cost_total = 0 AND total_tokens > 0
508
519
  `)
509
520
  .all() as CostBackfillRow[];
510
521
 
511
- if (rows.length === 0) return;
512
-
513
- const update = database.prepare(`
522
+ const update = database.query(`
514
523
  UPDATE messages
515
524
  SET cost_input = ?, cost_output = ?, cost_cache_read = ?, cost_cache_write = ?, cost_total = ?
516
525
  WHERE id = ?
@@ -541,22 +550,27 @@ function backfillMissingCatalogCosts(database: Database): void {
541
550
 
542
551
  update.run(cost.input, cost.output, cost.cacheRead, cost.cacheWrite, cost.total, row.id);
543
552
  }
553
+ markRan.run(CATALOG_COST_BACKFILL_KEY, VERSION);
544
554
  });
545
555
 
546
556
  applyBackfill();
547
557
  }
548
558
 
549
559
  function backfillNoCacheInputCosts(database: Database): void {
560
+ const row = database.query("SELECT value FROM meta WHERE key = ?").get(NO_CACHE_INPUT_BACKFILL_KEY) as
561
+ | { value: string }
562
+ | undefined;
563
+ if (row?.value === BACKFILL_COMPLETE) return;
564
+ const markComplete = database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
550
565
  const rows = database
551
- .prepare(`
566
+ .query(`
552
567
  SELECT id, provider, model, timestamp, input_tokens, cache_read_tokens, cache_write_tokens
553
568
  FROM messages
554
569
  WHERE cost_no_cache_input IS NULL
555
570
  `)
556
571
  .all() as NoCacheInputCostBackfillRow[];
557
- if (rows.length === 0) return;
558
572
 
559
- const update = database.prepare("UPDATE messages SET cost_no_cache_input = ? WHERE id = ?");
573
+ const update = database.query("UPDATE messages SET cost_no_cache_input = ? WHERE id = ?");
560
574
  const applyBackfill = database.transaction(() => {
561
575
  for (const row of rows) {
562
576
  const cost = calculateNoCacheInputCost(
@@ -572,6 +586,7 @@ function backfillNoCacheInputCosts(database: Database): void {
572
586
  );
573
587
  update.run(cost ?? 0, row.id);
574
588
  }
589
+ markComplete.run(NO_CACHE_INPUT_BACKFILL_KEY, BACKFILL_COMPLETE);
575
590
  });
576
591
  applyBackfill();
577
592
  }
@@ -662,18 +677,24 @@ function* parsedRows<T>(
662
677
  for (const result of results) yield* select(result);
663
678
  }
664
679
 
665
- /** Commit distinct transcripts' rows, reconciliation state, and cursors together; failure rolls back the batch. */
680
+ /**
681
+ * Commit distinct transcripts' rows, reconciliation state, and cursors together; failure rolls back the batch.
682
+ * `changes` counts stored rows inserted, updated or deleted (cursor bookkeeping excluded), so callers can
683
+ * tell a batch that changed nothing.
684
+ */
666
685
  export function applySessionParseResults(sessions: ParsedSession[]): {
667
686
  processed: number;
668
687
  files: number;
669
688
  reconcile: boolean;
689
+ changes: number;
670
690
  } {
671
- if (!db) return { processed: 0, files: 0, reconcile: false };
691
+ if (!db) return { processed: 0, files: 0, reconcile: false, changes: 0 };
672
692
  const database = db;
673
693
  return database.transaction(() => {
674
694
  let processed = 0;
675
695
  let files = 0;
676
696
  let reconcile = false;
697
+ let changes = 0;
677
698
  const writes: ParseSessionResult[] = [];
678
699
  for (const { sessionFile, result, rebuild, replay } of sessions) {
679
700
  const parserState = result.parserState;
@@ -758,27 +779,34 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
758
779
  if (absentMessages.size > 0 || absentUsers.size > 0 || absentTools.size > 0) {
759
780
  reconcile = true;
760
781
  for (const id of absentMessages) {
761
- database
782
+ changes += database
762
783
  .query("DELETE FROM messages WHERE session_file = ? AND entry_id = ?")
763
- .run(sessionFile, id);
784
+ .run(sessionFile, id).changes;
764
785
  }
765
786
  for (const id of absentUsers) {
766
- database
787
+ changes += database
767
788
  .query("DELETE FROM user_messages WHERE session_file = ? AND entry_id = ?")
768
- .run(sessionFile, id);
789
+ .run(sessionFile, id).changes;
769
790
  }
770
791
  for (const id of absentTools) {
771
- database
792
+ changes += database
772
793
  .query("DELETE FROM tool_calls WHERE session_file = ? AND tool_call_id = ?")
773
- .run(sessionFile, id);
794
+ .run(sessionFile, id).changes;
774
795
  }
775
796
  }
776
797
  }
777
798
  if (replace) {
778
- if (messages.length > 0) database.query("DELETE FROM messages WHERE session_file = ?").run(sessionFile);
779
- if (users.length > 0)
780
- database.query("DELETE FROM user_messages WHERE session_file = ?").run(sessionFile);
781
- if (tools.length > 0) database.query("DELETE FROM tool_calls WHERE session_file = ?").run(sessionFile);
799
+ if (messages.length > 0) {
800
+ changes += database.query("DELETE FROM messages WHERE session_file = ?").run(sessionFile).changes;
801
+ }
802
+ if (users.length > 0) {
803
+ changes += database
804
+ .query("DELETE FROM user_messages WHERE session_file = ?")
805
+ .run(sessionFile).changes;
806
+ }
807
+ if (tools.length > 0) {
808
+ changes += database.query("DELETE FROM tool_calls WHERE session_file = ?").run(sessionFile).changes;
809
+ }
782
810
  }
783
811
  }
784
812
  writes.push(rows);
@@ -787,11 +815,12 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
787
815
  if (count > 0) files++;
788
816
  }
789
817
  // Remove replaced rows before choosing new fork owners, then write each table once per batch.
790
- insertMessageStats(parsedRows(writes, result => result.stats));
791
- insertUserMessageStats(parsedRows(writes, result => result.userStats));
792
- updateUserMessageLinks(parsedRows(writes, result => result.userLinks));
793
- insertToolCalls(parsedRows(writes, result => result.toolCalls));
794
- updateToolResults(parsedRows(writes, result => result.toolResults));
818
+ changes +=
819
+ insertMessageStats(parsedRows(writes, result => result.stats)) +
820
+ insertUserMessageStats(parsedRows(writes, result => result.userStats)) +
821
+ updateUserMessageLinks(parsedRows(writes, result => result.userLinks)) +
822
+ insertToolCalls(parsedRows(writes, result => result.toolCalls)) +
823
+ updateToolResults(parsedRows(writes, result => result.toolResults));
795
824
  for (const { sessionFile, result } of sessions) {
796
825
  if (result.parserState) {
797
826
  setFileOffset(sessionFile, result.newOffset, result.parserState.mtimeMs, result.parserState);
@@ -800,16 +829,26 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
800
829
  if (reconcile) {
801
830
  database.query("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_reconciliation', 'pending')").run();
802
831
  }
803
- return { processed, files, reconcile };
832
+ return { processed, files, reconcile, changes };
804
833
  })();
805
834
  }
806
835
 
836
+ /**
837
+ * SQLite `PRAGMA data_version` of the open connection, or null before
838
+ * {@link initDb}. It moves only when another connection (another omp process)
839
+ * commits, so a caller can detect foreign writes without scanning.
840
+ */
841
+ export function getDataVersion(): number | null {
842
+ if (!db) return null;
843
+ return db.query<{ data_version: number }, []>("PRAGMA data_version").get()?.data_version ?? null;
844
+ }
845
+
807
846
  export function prepareSessionSync(): boolean {
808
- return Boolean(db?.prepare("SELECT 1 FROM meta WHERE key = 'session_reconciliation'").get());
847
+ return Boolean(db?.query("SELECT 1 FROM meta WHERE key = 'session_reconciliation'").get());
809
848
  }
810
849
 
811
850
  export function completeSessionSync(reconcile: boolean): void {
812
- if (!reconcile) db?.prepare("DELETE FROM meta WHERE key = 'session_reconciliation'").run();
851
+ if (!reconcile) db?.query("DELETE FROM meta WHERE key = 'session_reconciliation'").run();
813
852
  }
814
853
 
815
854
  /**
@@ -907,7 +946,7 @@ export function insertMessageStats(stats: Iterable<MessageStatsInput>): number {
907
946
  */
908
947
  export function getMessageCount(): number {
909
948
  if (!db) return 0;
910
- const stmt = db.prepare("SELECT COUNT(*) as count FROM messages");
949
+ const stmt = db.query("SELECT COUNT(*) as count FROM messages");
911
950
  const row = stmt.get() as { count: number };
912
951
  return row.count;
913
952
  }
@@ -959,7 +998,7 @@ function rowToMessageStats(row: any): MessageStats {
959
998
 
960
999
  export function getRecentRequests(limit = 100): MessageStats[] {
961
1000
  if (!db) return [];
962
- const stmt = db.prepare(`
1001
+ const stmt = db.query(`
963
1002
  SELECT * FROM messages
964
1003
  ORDER BY timestamp DESC
965
1004
  LIMIT ?
@@ -970,7 +1009,7 @@ export function getRecentRequests(limit = 100): MessageStats[] {
970
1009
  export function getRecentErrors(limit = 100, cutoff?: number | null): MessageStats[] {
971
1010
  if (!db) return [];
972
1011
  const hasCutoff = cutoff !== undefined && cutoff !== null;
973
- const stmt = db.prepare(`
1012
+ const stmt = db.query(`
974
1013
  SELECT * FROM messages
975
1014
  WHERE stop_reason = 'error'
976
1015
  ${hasCutoff ? "AND timestamp >= ?" : ""}
@@ -983,7 +1022,7 @@ export function getRecentErrors(limit = 100, cutoff?: number | null): MessageSta
983
1022
 
984
1023
  export function getMessageById(id: number): MessageStats | null {
985
1024
  if (!db) return null;
986
- const stmt = db.prepare("SELECT * FROM messages WHERE id = ?");
1025
+ const stmt = db.query("SELECT * FROM messages WHERE id = ?");
987
1026
  const row = stmt.get(id);
988
1027
  return row ? rowToMessageStats(row) : null;
989
1028
  }
@@ -992,12 +1031,15 @@ export function getMessageById(id: number): MessageStats | null {
992
1031
  * Self-initializing (opens the stats DB on first use) so the coding-agent TUI
993
1032
  * can query without the dashboard server's init flow. Days use the machine's
994
1033
  * timezone — this is a localhost tool, same rationale as
995
- * {@link getProviderHourlyBurn}.
1034
+ * {@link getProviderHourlyBurn}. Reads the hourly rollup, falling back to the
1035
+ * raw rows while its rebuild backlog is too large for exact reads.
996
1036
  */
997
1037
  export async function getDailyActivity(days = 371): Promise<DailyActivityPoint[]> {
998
1038
  const database = await initDb();
999
1039
  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
1000
- const stmt = database.prepare(`
1040
+ const rolled = getDailyActivityFromRollup(cutoff);
1041
+ if (rolled) return rolled;
1042
+ const stmt = database.query(`
1001
1043
  SELECT
1002
1044
  date(timestamp / 1000, 'unixepoch', 'localtime') as day,
1003
1045
  SUM(cost_total) as cost,
@@ -1065,7 +1107,7 @@ export async function getDailyActivity(days = 371): Promise<DailyActivityPoint[]
1065
1107
  * Existing `messages` rows are unaffected - `INSERT OR IGNORE` keeps them.
1066
1108
  */
1067
1109
  function backfillUserMessages(database: Database): void {
1068
- const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGES_BACKFILL_KEY) as
1110
+ const row = database.query("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGES_BACKFILL_KEY) as
1069
1111
  | { value: string }
1070
1112
  | undefined;
1071
1113
  if (!shouldResetBackfill(row?.value)) return;
@@ -1073,7 +1115,7 @@ function backfillUserMessages(database: Database): void {
1073
1115
  database.run("DELETE FROM user_messages");
1074
1116
  database.run("DELETE FROM file_offsets");
1075
1117
  database
1076
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1118
+ .query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1077
1119
  .run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING);
1078
1120
  }
1079
1121
 
@@ -1086,7 +1128,7 @@ function backfillUserMessages(database: Database): void {
1086
1128
  * written here prevents re-wiping on subsequent inits.
1087
1129
  */
1088
1130
  function backfillToolCalls(database: Database): void {
1089
- const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(TOOL_CALLS_BACKFILL_KEY) as
1131
+ const row = database.query("SELECT value FROM meta WHERE key = ?").get(TOOL_CALLS_BACKFILL_KEY) as
1090
1132
  | { value: string }
1091
1133
  | undefined;
1092
1134
  if (!shouldResetBackfill(row?.value)) return;
@@ -1094,7 +1136,7 @@ function backfillToolCalls(database: Database): void {
1094
1136
  database.run("DELETE FROM tool_calls");
1095
1137
  database.run("DELETE FROM file_offsets");
1096
1138
  database
1097
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1139
+ .query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1098
1140
  .run(TOOL_CALLS_BACKFILL_KEY, BACKFILL_PENDING);
1099
1141
  }
1100
1142
 
@@ -1110,14 +1152,14 @@ function backfillToolCalls(database: Database): void {
1110
1152
  * offset reset is safe. Same sentinel protocol as {@link backfillToolCalls}.
1111
1153
  */
1112
1154
  function backfillReingestCosts(database: Database): void {
1113
- const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(COST_REINGEST_BACKFILL_KEY) as
1155
+ const row = database.query("SELECT value FROM meta WHERE key = ?").get(COST_REINGEST_BACKFILL_KEY) as
1114
1156
  | { value: string }
1115
1157
  | undefined;
1116
1158
  if (!shouldResetBackfill(row?.value)) return;
1117
1159
 
1118
1160
  database.run("DELETE FROM file_offsets");
1119
1161
  database
1120
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1162
+ .query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1121
1163
  .run(COST_REINGEST_BACKFILL_KEY, BACKFILL_PENDING);
1122
1164
  }
1123
1165
 
@@ -1131,14 +1173,14 @@ function backfillReingestCosts(database: Database): void {
1131
1173
  * absence as absence. Same sentinel protocol as {@link backfillReingestCosts}.
1132
1174
  */
1133
1175
  function backfillUnpricedCosts(database: Database): void {
1134
- const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(COST_UNPRICED_BACKFILL_KEY) as
1176
+ const row = database.query("SELECT value FROM meta WHERE key = ?").get(COST_UNPRICED_BACKFILL_KEY) as
1135
1177
  | { value: string }
1136
1178
  | undefined;
1137
1179
  if (!shouldResetBackfill(row?.value)) return;
1138
1180
 
1139
1181
  database.run("DELETE FROM file_offsets");
1140
1182
  database
1141
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1183
+ .query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1142
1184
  .run(COST_UNPRICED_BACKFILL_KEY, BACKFILL_PENDING);
1143
1185
  }
1144
1186
 
@@ -1152,16 +1194,16 @@ function backfillUnpricedCosts(database: Database): void {
1152
1194
  * interrupted run rolls back and retries on the next init.
1153
1195
  */
1154
1196
  function backfillAgentType(database: Database): void {
1155
- const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(AGENT_TYPE_BACKFILL_KEY) as
1197
+ const row = database.query("SELECT value FROM meta WHERE key = ?").get(AGENT_TYPE_BACKFILL_KEY) as
1156
1198
  | { value: string }
1157
1199
  | undefined;
1158
1200
  if (row?.value !== BACKFILL_PENDING) return;
1159
1201
 
1160
- const sessionFiles = database.prepare("SELECT DISTINCT session_file FROM messages").all() as {
1202
+ const sessionFiles = database.query("SELECT DISTINCT session_file FROM messages").all() as {
1161
1203
  session_file: string;
1162
1204
  }[];
1163
- const update = database.prepare("UPDATE messages SET agent_type = ? WHERE session_file = ?");
1164
- const markComplete = database.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
1205
+ const update = database.query("UPDATE messages SET agent_type = ? WHERE session_file = ?");
1206
+ const markComplete = database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
1165
1207
  const apply = database.transaction(() => {
1166
1208
  for (const { session_file } of sessionFiles) {
1167
1209
  const agentType = classifyAgentType(session_file);
@@ -1189,12 +1231,12 @@ function backfillAgentType(database: Database): void {
1189
1231
  * retries on the next init.
1190
1232
  */
1191
1233
  function backfillForkDuplicates(database: Database): void {
1192
- const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(FORK_DEDUPE_KEY) as
1234
+ const row = database.query("SELECT value FROM meta WHERE key = ?").get(FORK_DEDUPE_KEY) as
1193
1235
  | { value: string }
1194
1236
  | undefined;
1195
1237
  if (row?.value === BACKFILL_COMPLETE) return;
1196
1238
 
1197
- const markComplete = database.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
1239
+ const markComplete = database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
1198
1240
  const apply = database.transaction(() => {
1199
1241
  database.run(`
1200
1242
  DELETE FROM messages
@@ -1222,14 +1264,14 @@ function backfillForkDuplicates(database: Database): void {
1222
1264
  * sentinel row in `meta`.
1223
1265
  */
1224
1266
  function repairUserMessageLinks(database: Database): void {
1225
- const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGE_LINKS_REPAIR_KEY) as
1267
+ const row = database.query("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGE_LINKS_REPAIR_KEY) as
1226
1268
  | { value: string }
1227
1269
  | undefined;
1228
1270
  if (!shouldResetBackfill(row?.value)) return;
1229
1271
 
1230
1272
  database.run("DELETE FROM file_offsets");
1231
1273
  database
1232
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1274
+ .query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1233
1275
  .run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING);
1234
1276
  }
1235
1277
 
@@ -1244,14 +1286,14 @@ function repairUserMessageLinks(database: Database): void {
1244
1286
  * column. Idempotent: gated by a sentinel row in `meta`.
1245
1287
  */
1246
1288
  function backfillPriorityPremiumRequests(database: Database): void {
1247
- const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY) as
1289
+ const row = database.query("SELECT value FROM meta WHERE key = ?").get(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY) as
1248
1290
  | { value: string }
1249
1291
  | undefined;
1250
1292
  if (!shouldResetBackfill(row?.value)) return;
1251
1293
 
1252
1294
  database.run("DELETE FROM file_offsets");
1253
1295
  database
1254
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1296
+ .query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1255
1297
  .run(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY, BACKFILL_PENDING);
1256
1298
  }
1257
1299
 
@@ -1260,7 +1302,7 @@ function backfillPriorityPremiumRequests(database: Database): void {
1260
1302
  */
1261
1303
  export function markSessionBackfillsComplete(): void {
1262
1304
  if (!db) return;
1263
- const markComplete = db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
1305
+ const markComplete = db.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
1264
1306
  const apply = db.transaction(() => {
1265
1307
  for (const key of [
1266
1308
  USER_MESSAGES_BACKFILL_KEY,
@@ -1437,7 +1479,7 @@ function toFrustrationCounts(row: FrustrationCountsRow | undefined): Frustration
1437
1479
  export function getFrustrationOverall(cutoff?: number | null): FrustrationCounts {
1438
1480
  if (!db) return toFrustrationCounts(undefined);
1439
1481
  const hasCutoff = hasRangeCutoff(cutoff);
1440
- const stmt = db.prepare(`
1482
+ const stmt = db.query(`
1441
1483
  SELECT ${FRUSTRATION_COUNTS_SQL}
1442
1484
  FROM user_messages u
1443
1485
  LEFT JOIN frustration_verdicts v ON v.prose_hash = u.prose_hash
@@ -1454,7 +1496,7 @@ export function getFrustrationOverall(cutoff?: number | null): FrustrationCounts
1454
1496
  export function getFrustrationByModel(cutoff?: number | null): FrustrationModelRow[] {
1455
1497
  if (!db) return [];
1456
1498
  const hasCutoff = hasRangeCutoff(cutoff);
1457
- const stmt = db.prepare(`
1499
+ const stmt = db.query(`
1458
1500
  SELECT u.model AS model, u.provider AS provider, MIN(u.timestamp) AS first_seen, ${FRUSTRATION_COUNTS_SQL}
1459
1501
  FROM user_messages u
1460
1502
  LEFT JOIN frustration_verdicts v ON v.prose_hash = u.prose_hash
@@ -1485,7 +1527,7 @@ function pendingProseSql(hasCutoff: boolean): string {
1485
1527
  export function getPendingFrustrationProse(cutoff?: number | null): PendingProse[] {
1486
1528
  if (!db) return [];
1487
1529
  const hasCutoff = hasRangeCutoff(cutoff);
1488
- const stmt = db.prepare(pendingProseSql(hasCutoff));
1530
+ const stmt = db.query(pendingProseSql(hasCutoff));
1489
1531
  return (hasCutoff ? stmt.all(cutoff) : stmt.all()) as PendingProse[];
1490
1532
  }
1491
1533
 
@@ -1493,7 +1535,7 @@ export function getPendingFrustrationProse(cutoff?: number | null): PendingProse
1493
1535
  export function getPendingFrustrationTotals(cutoff?: number | null): { messages: number; chars: number } {
1494
1536
  if (!db) return { messages: 0, chars: 0 };
1495
1537
  const hasCutoff = hasRangeCutoff(cutoff);
1496
- const stmt = db.prepare(
1538
+ const stmt = db.query(
1497
1539
  `SELECT COUNT(*) AS messages, COALESCE(SUM(LENGTH(prose)), 0) AS chars FROM (${pendingProseSql(hasCutoff)})`,
1498
1540
  );
1499
1541
  const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as { messages: number; chars: number } | undefined;
@@ -8,6 +8,14 @@
8
8
  * keeps building the dashboard from source.
9
9
  */
10
10
 
11
+ /**
12
+ * Cheap check whether the generated text carries a gzip archive: sniffs the
13
+ * base64 of the gzip magic (`1f 8b 08`) without decoding megabytes.
14
+ */
15
+ export function hasEmbeddedClientArchive(txt: string): boolean {
16
+ return /^\s*H4s/.test(txt);
17
+ }
18
+
11
19
  /**
12
20
  * Decode the generated archive text.
13
21
  *
@@ -17,6 +25,7 @@
17
25
  * rather than decoded into garbage bytes.
18
26
  */
19
27
  export function decodeEmbeddedClientArchive(txt: string): Buffer | null {
28
+ if (!hasEmbeddedClientArchive(txt)) return null;
20
29
  const normalized = txt.replaceAll(/\s+/g, "");
21
30
  if (!normalized) return null;
22
31
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) return null;