@oh-my-pi/omp-stats 18.3.5 → 18.4.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 (192) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +13 -11
  3. package/build.ts +5 -49
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +2 -2
  6. package/dist/client/index.js +105 -126
  7. package/dist/types/aggregator.d.ts +25 -22
  8. package/dist/types/client/api.d.ts +11 -7
  9. package/dist/types/client/app/LiveChip.d.ts +2 -0
  10. package/dist/types/client/app/Shell.d.ts +16 -0
  11. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  12. package/dist/types/client/app/nav.d.ts +18 -0
  13. package/dist/types/client/charts/BarList.d.ts +17 -0
  14. package/dist/types/client/charts/Chart.d.ts +49 -0
  15. package/dist/types/client/charts/Legend.d.ts +16 -0
  16. package/dist/types/client/charts/ShareBar.d.ts +11 -0
  17. package/dist/types/client/charts/Sparkline.d.ts +11 -0
  18. package/dist/types/client/charts/TimeChart.d.ts +9 -0
  19. package/dist/types/client/charts/index.d.ts +7 -0
  20. package/dist/types/client/charts/types.d.ts +25 -0
  21. package/dist/types/client/charts/useWidth.d.ts +3 -0
  22. package/dist/types/client/data/colors.d.ts +25 -0
  23. package/dist/types/client/data/formatters.d.ts +9 -1
  24. package/dist/types/client/data/live.d.ts +29 -0
  25. package/dist/types/client/data/query.d.ts +35 -0
  26. package/dist/types/client/data/range.d.ts +31 -0
  27. package/dist/types/client/data/series.d.ts +28 -0
  28. package/dist/types/client/data/useHashRoute.d.ts +18 -4
  29. package/dist/types/client/data/view-models.d.ts +111 -28
  30. package/dist/types/client/index.d.ts +0 -1
  31. package/dist/types/client/routes/CostsRoute.d.ts +2 -2
  32. package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
  33. package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
  34. package/dist/types/client/routes/GainRoute.d.ts +1 -2
  35. package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
  36. package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
  37. package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
  38. package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
  39. package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
  40. package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
  41. package/dist/types/client/routes/TracesRoute.d.ts +2 -2
  42. package/dist/types/client/routes/index.d.ts +1 -1
  43. package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
  44. package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
  45. package/dist/types/client/traces/trace-colors.d.ts +18 -12
  46. package/dist/types/client/ui/Badge.d.ts +19 -0
  47. package/dist/types/client/ui/Card.d.ts +25 -0
  48. package/dist/types/client/ui/Drawer.d.ts +25 -0
  49. package/dist/types/client/ui/JsonBlock.d.ts +2 -2
  50. package/dist/types/client/ui/Modal.d.ts +23 -0
  51. package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
  52. package/dist/types/client/ui/SearchInput.d.ts +8 -0
  53. package/dist/types/client/ui/Segmented.d.ts +15 -0
  54. package/dist/types/client/ui/Stat.d.ts +36 -0
  55. package/dist/types/client/ui/States.d.ts +41 -0
  56. package/dist/types/client/ui/Table.d.ts +48 -0
  57. package/dist/types/client/ui/index.d.ts +9 -10
  58. package/dist/types/db.d.ts +90 -106
  59. package/dist/types/frustration.d.ts +70 -0
  60. package/dist/types/index.d.ts +3 -1
  61. package/dist/types/live.d.ts +30 -0
  62. package/dist/types/rollup.d.ts +115 -0
  63. package/dist/types/server.d.ts +6 -1
  64. package/dist/types/shared-types.d.ts +116 -56
  65. package/dist/types/types.d.ts +4 -0
  66. package/dist/types/user-metrics.d.ts +8 -0
  67. package/package.json +7 -13
  68. package/src/aggregator.ts +276 -211
  69. package/src/client/App.tsx +38 -80
  70. package/src/client/api.ts +65 -7
  71. package/src/client/app/LiveChip.tsx +97 -0
  72. package/src/client/app/Shell.tsx +181 -0
  73. package/src/client/app/ThemeToggle.tsx +5 -2
  74. package/src/client/app/nav.ts +78 -0
  75. package/src/client/charts/BarList.tsx +48 -0
  76. package/src/client/charts/Chart.tsx +388 -0
  77. package/src/client/charts/Legend.tsx +61 -0
  78. package/src/client/charts/ShareBar.tsx +26 -0
  79. package/src/client/charts/Sparkline.tsx +38 -0
  80. package/src/client/charts/TimeChart.tsx +22 -0
  81. package/src/client/charts/index.ts +7 -0
  82. package/src/client/charts/types.ts +27 -0
  83. package/src/client/charts/useWidth.ts +19 -0
  84. package/src/client/data/colors.ts +45 -0
  85. package/src/client/data/formatters.ts +30 -9
  86. package/src/client/data/live.tsx +85 -0
  87. package/src/client/data/query.ts +166 -0
  88. package/src/client/data/range.ts +82 -0
  89. package/src/client/data/series.ts +85 -0
  90. package/src/client/data/useHashRoute.ts +41 -83
  91. package/src/client/data/view-models.ts +347 -140
  92. package/src/client/index.tsx +3 -6
  93. package/src/client/routes/CostsRoute.tsx +394 -222
  94. package/src/client/routes/ErrorsRoute.tsx +401 -98
  95. package/src/client/routes/FrustrationRoute.tsx +924 -0
  96. package/src/client/routes/GainRoute.tsx +209 -206
  97. package/src/client/routes/ModelsRoute.tsx +540 -431
  98. package/src/client/routes/OverviewRoute.tsx +283 -311
  99. package/src/client/routes/ProjectsRoute.tsx +298 -156
  100. package/src/client/routes/ProvidersRoute.tsx +948 -416
  101. package/src/client/routes/RequestsRoute.tsx +295 -107
  102. package/src/client/routes/ToolsRoute.tsx +495 -398
  103. package/src/client/routes/TracesRoute.tsx +136 -117
  104. package/src/client/routes/costs.css +24 -0
  105. package/src/client/routes/errors.css +60 -0
  106. package/src/client/routes/frustration.css +123 -0
  107. package/src/client/routes/index.ts +1 -1
  108. package/src/client/routes/models.css +43 -0
  109. package/src/client/routes/projects.css +6 -0
  110. package/src/client/routes/providers.css +28 -0
  111. package/src/client/routes/tools.css +14 -0
  112. package/src/client/styles.css +1396 -1299
  113. package/src/client/traces/AggregatesPanel.tsx +55 -74
  114. package/src/client/traces/Minimap.tsx +6 -15
  115. package/src/client/traces/SpanDrawer.tsx +128 -194
  116. package/src/client/traces/SummaryStrip.tsx +21 -24
  117. package/src/client/traces/TimelineCanvas.tsx +27 -44
  118. package/src/client/traces/TraceView.tsx +175 -130
  119. package/src/client/traces/TranscriptList.tsx +13 -41
  120. package/src/client/traces/trace-colors.ts +60 -53
  121. package/src/client/traces/traces.css +279 -0
  122. package/src/client/ui/Badge.tsx +29 -0
  123. package/src/client/ui/Card.tsx +61 -0
  124. package/src/client/ui/Drawer.tsx +93 -0
  125. package/src/client/ui/JsonBlock.tsx +26 -44
  126. package/src/client/ui/Modal.tsx +106 -0
  127. package/src/client/ui/RequestDrawer.tsx +163 -188
  128. package/src/client/ui/SearchInput.tsx +25 -0
  129. package/src/client/ui/Segmented.tsx +61 -0
  130. package/src/client/ui/Stat.tsx +104 -0
  131. package/src/client/ui/States.tsx +81 -0
  132. package/src/client/ui/Table.tsx +229 -0
  133. package/src/client/ui/index.ts +9 -10
  134. package/src/client/ui/modal.css +54 -0
  135. package/src/client/ui/request-drawer.css +40 -0
  136. package/src/db.ts +379 -983
  137. package/src/frustration.ts +473 -0
  138. package/src/index.ts +22 -22
  139. package/src/live.ts +259 -0
  140. package/src/parser.ts +55 -66
  141. package/src/rollup.ts +951 -0
  142. package/src/server.ts +123 -40
  143. package/src/shared-types.ts +120 -56
  144. package/src/trace.ts +14 -7
  145. package/src/types.ts +4 -0
  146. package/src/user-metrics.ts +13 -0
  147. package/dist/client/styles.css +0 -1976
  148. package/dist/types/client/app/AppLayout.d.ts +0 -16
  149. package/dist/types/client/app/NavRail.d.ts +0 -7
  150. package/dist/types/client/app/RangeControl.d.ts +0 -7
  151. package/dist/types/client/app/SyncButton.d.ts +0 -14
  152. package/dist/types/client/app/TopBar.d.ts +0 -15
  153. package/dist/types/client/app/routes.d.ts +0 -12
  154. package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
  155. package/dist/types/client/components/chart-shared.d.ts +0 -178
  156. package/dist/types/client/components/models-table-shared.d.ts +0 -175
  157. package/dist/types/client/components/range-meta.d.ts +0 -21
  158. package/dist/types/client/data/charts.d.ts +0 -1
  159. package/dist/types/client/data/useResource.d.ts +0 -13
  160. package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
  161. package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
  162. package/dist/types/client/ui/DataTable.d.ts +0 -17
  163. package/dist/types/client/ui/EmptyState.d.ts +0 -7
  164. package/dist/types/client/ui/ErrorState.d.ts +0 -6
  165. package/dist/types/client/ui/MetricCluster.d.ts +0 -5
  166. package/dist/types/client/ui/Panel.d.ts +0 -7
  167. package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
  168. package/dist/types/client/ui/Skeleton.d.ts +0 -8
  169. package/dist/types/client/ui/StatusPill.d.ts +0 -7
  170. package/src/client/app/AppLayout.tsx +0 -93
  171. package/src/client/app/NavRail.tsx +0 -44
  172. package/src/client/app/RangeControl.tsx +0 -39
  173. package/src/client/app/SyncButton.tsx +0 -75
  174. package/src/client/app/TopBar.tsx +0 -73
  175. package/src/client/app/routes.ts +0 -93
  176. package/src/client/components/AgentTokenShare.tsx +0 -68
  177. package/src/client/components/chart-shared.tsx +0 -273
  178. package/src/client/components/models-table-shared.tsx +0 -255
  179. package/src/client/components/range-meta.ts +0 -73
  180. package/src/client/data/charts.ts +0 -14
  181. package/src/client/data/useResource.ts +0 -154
  182. package/src/client/routes/BehaviorRoute.tsx +0 -623
  183. package/src/client/ui/AsyncBoundary.tsx +0 -54
  184. package/src/client/ui/DataTable.tsx +0 -122
  185. package/src/client/ui/EmptyState.tsx +0 -16
  186. package/src/client/ui/ErrorState.tsx +0 -25
  187. package/src/client/ui/MetricCluster.tsx +0 -92
  188. package/src/client/ui/Panel.tsx +0 -24
  189. package/src/client/ui/SegmentedControl.tsx +0 -36
  190. package/src/client/ui/Skeleton.tsx +0 -17
  191. package/src/client/ui/StatusPill.tsx +0 -15
  192. package/tailwind.config.js +0 -40
package/src/db.ts CHANGED
@@ -10,30 +10,15 @@ import {
10
10
  import type { ModelCost } from "@oh-my-pi/pi-catalog/types";
11
11
  import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
12
12
  import { classifyAgentType, type ParseSessionResult, type SessionParserState } from "./parser";
13
+ import { ensureRollupSchema } from "./rollup";
13
14
  import type {
14
15
  AgentType,
15
- AgentTypeStats,
16
- AggregatedStats,
17
- BehaviorModelStats,
18
- BehaviorOverallStats,
19
- BehaviorTimeSeriesPoint,
20
- CostTimeSeriesPoint,
21
16
  DailyActivityPoint,
22
- FolderStats,
17
+ FrustrationCounts,
23
18
  MessageStats,
24
19
  MessageStatsInput,
25
- ModelPerformancePoint,
26
- ModelStats,
27
- ModelTimeSeriesPoint,
28
- ProviderAggregate,
29
- ProviderHourlyPoint,
30
- ProviderTimeSeriesPoint,
31
- TimeSeriesPoint,
32
20
  ToolCallStats,
33
- ToolModelStats,
34
21
  ToolResultLink,
35
- ToolTimeSeriesPoint,
36
- ToolUsageStats,
37
22
  UserMessageLink,
38
23
  UserMessageStats,
39
24
  } from "./types";
@@ -66,13 +51,11 @@ const ZERO_USAGE_COST: UsageCost = {
66
51
  * parser's timestamp sentinel, because their zero is a real price.
67
52
  * `prefix` qualifies the columns for queries that alias `messages`.
68
53
  */
69
- function unpricedRequestSql(prefix = ""): string {
54
+ export function unpricedRequestSql(prefix = ""): string {
70
55
  return `CASE WHEN ${prefix}total_tokens > 0 AND ${prefix}cost_total = 0
71
56
  AND (${prefix}provider = 'xai-oauth' OR ${prefix}cost_unpriced = 1) THEN 1 ELSE 0 END`;
72
57
  }
73
58
 
74
- const UNPRICED_REQUEST_SQL = unpricedRequestSql();
75
-
76
59
  interface CostBackfillRow {
77
60
  id: number;
78
61
  provider: string;
@@ -94,52 +77,16 @@ interface NoCacheInputCostBackfillRow {
94
77
  cache_write_tokens: number;
95
78
  }
96
79
 
97
- interface AggregatedStatsRow {
98
- total_requests: number;
99
- failed_requests: number | null;
100
- total_input_tokens: number | null;
101
- total_output_tokens: number | null;
102
- total_cache_read_tokens: number | null;
103
- total_cache_write_tokens: number | null;
104
- total_premium_requests: number | null;
105
- total_cost: number | null;
106
- unpriced_requests: number | null;
107
- total_cached_prompt_cost: number | null;
108
- total_no_cache_input_cost: number | null;
109
- avg_duration: number | null;
110
- avg_ttft: number | null;
111
- avg_tokens_per_second: number | null;
112
- first_timestamp: number | null;
113
- last_timestamp: number | null;
114
- }
115
-
116
- interface ModelStatsRow extends AggregatedStatsRow {
117
- model: string;
118
- provider: string;
119
- }
120
-
121
- interface FolderStatsRow extends AggregatedStatsRow {
122
- folder: string;
123
- }
80
+ let db: Database | null = null;
124
81
 
125
- interface CostTimeSeriesRow {
126
- bucket: number;
127
- model: string;
128
- provider: string;
129
- cost: number | null;
130
- unpriced_requests: number | null;
131
- cost_input: number | null;
132
- cost_output: number | null;
133
- cost_cache_read: number | null;
134
- cost_cache_write: number | null;
135
- requests: number;
82
+ /** The open stats database, or null before {@link initDb}. Used by the rollup query layer. */
83
+ export function currentDb(): Database | null {
84
+ return db;
136
85
  }
137
86
 
138
- let db: Database | null = null;
139
-
140
87
  const BACKFILL_COMPLETE = "complete";
141
88
  const BACKFILL_PENDING = "pending";
142
- const USER_MESSAGES_BACKFILL_KEY = "user_messages_v8";
89
+ const USER_MESSAGES_BACKFILL_KEY = "user_messages_v9";
143
90
  const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
144
91
  const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
145
92
  const AGENT_TYPE_BACKFILL_KEY = "agent_type_v1";
@@ -246,6 +193,8 @@ export async function initDb(): Promise<Database> {
246
193
  negation INTEGER NOT NULL DEFAULT 0,
247
194
  repetition INTEGER NOT NULL DEFAULT 0,
248
195
  blame INTEGER NOT NULL DEFAULT 0,
196
+ prose TEXT NOT NULL DEFAULT '',
197
+ prose_hash TEXT NOT NULL DEFAULT '',
249
198
  UNIQUE(session_file, entry_id)
250
199
  );
251
200
 
@@ -253,6 +202,17 @@ export async function initDb(): Promise<Database> {
253
202
  CREATE INDEX IF NOT EXISTS idx_user_messages_entry_timestamp ON user_messages(entry_id, timestamp);
254
203
  CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
255
204
 
205
+ -- Judge verdicts keyed by prose hash: identical messages share one verdict,
206
+ -- and verdicts outlive user_messages re-parses. No backfill wipes this table.
207
+ CREATE TABLE IF NOT EXISTS frustration_verdicts (
208
+ prose_hash TEXT PRIMARY KEY,
209
+ p_annoyed REAL NOT NULL,
210
+ p_angry REAL NOT NULL,
211
+ target TEXT NOT NULL,
212
+ judge TEXT NOT NULL,
213
+ judged_at INTEGER NOT NULL
214
+ );
215
+
256
216
  CREATE TABLE IF NOT EXISTS tool_calls (
257
217
  id INTEGER PRIMARY KEY AUTOINCREMENT,
258
218
  session_file TEXT NOT NULL,
@@ -367,12 +327,20 @@ export async function initDb(): Promise<Database> {
367
327
  negation INTEGER NOT NULL DEFAULT 0,
368
328
  repetition INTEGER NOT NULL DEFAULT 0,
369
329
  blame INTEGER NOT NULL DEFAULT 0,
330
+ prose TEXT NOT NULL DEFAULT '',
331
+ prose_hash TEXT NOT NULL DEFAULT '',
370
332
  UNIQUE(session_file, entry_id)
371
333
  );
372
334
  CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
373
335
  CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
374
336
  `);
337
+ } else if (!userMessageColumns.some(column => column.name === "prose")) {
338
+ // v9 judge prose: the backfill below clears the rows, so defaults are
339
+ // only placeholders until the re-parse repopulates them.
340
+ db.run("ALTER TABLE user_messages ADD COLUMN prose TEXT NOT NULL DEFAULT ''");
341
+ db.run("ALTER TABLE user_messages ADD COLUMN prose_hash TEXT NOT NULL DEFAULT ''");
375
342
  }
343
+ db.run("CREATE INDEX IF NOT EXISTS idx_user_messages_prose_hash ON user_messages(prose_hash)");
376
344
  backfillUserMessages(db);
377
345
  backfillToolCalls(db);
378
346
  backfillReingestCosts(db);
@@ -383,6 +351,7 @@ export async function initDb(): Promise<Database> {
383
351
  backfillMissingCatalogCosts(db);
384
352
  backfillNoCacheInputCosts(db);
385
353
  backfillForkDuplicates(db);
354
+ ensureRollupSchema(db);
386
355
  return db;
387
356
  }
388
357
 
@@ -598,23 +567,25 @@ function backfillNoCacheInputCosts(database: Database): void {
598
567
  applyBackfill();
599
568
  }
600
569
 
601
- /**
602
- * Get the stored offset for a session file.
603
- */
604
- export function getFileOffset(
605
- sessionFile: string,
606
- ): { offset: number; lastModified: number; parserState?: SessionParserState } | null {
607
- if (!db) return null;
570
+ /** Persisted transcript identity and parser cursor used to resume stats ingestion. */
571
+ export interface FileOffset {
572
+ offset: number;
573
+ lastModified: number;
574
+ parserState?: SessionParserState;
575
+ }
608
576
 
609
- const stmt = db.prepare("SELECT offset, last_modified, parser_state FROM file_offsets WHERE session_file = ?");
610
- const row = stmt.get(sessionFile) as
611
- | { offset: number; last_modified: number; parser_state: string | null }
612
- | undefined;
613
- if (!row) return null;
577
+ interface FileOffsetRow {
578
+ session_file: string;
579
+ offset: number;
580
+ last_modified: number;
581
+ parser_state: string | null;
582
+ }
583
+
584
+ function decodeFileOffset(row: FileOffsetRow): FileOffset {
614
585
  let parserState: SessionParserState | undefined;
615
586
  if (row.parser_state) {
616
587
  try {
617
- const state = JSON.parse(row.parser_state) as SessionParserState;
588
+ const state: SessionParserState = JSON.parse(row.parser_state);
618
589
  if (state?.version === 1 && state.offset === row.offset) parserState = state;
619
590
  } catch {
620
591
  /* A missing cursor is reconstructed from the transcript. */
@@ -623,6 +594,31 @@ export function getFileOffset(
623
594
  return { offset: row.offset, lastModified: row.last_modified, parserState };
624
595
  }
625
596
 
597
+ /** Read one persisted cursor for callers inspecting an individual transcript. */
598
+ export function getFileOffset(sessionFile: string): FileOffset | null {
599
+ if (!db) return null;
600
+ const row = db
601
+ .query<FileOffsetRow, [string]>(
602
+ "SELECT session_file, offset, last_modified, parser_state FROM file_offsets WHERE session_file = ?",
603
+ )
604
+ .get(sessionFile);
605
+ return row ? decodeFileOffset(row) : null;
606
+ }
607
+
608
+ /** Read a bounded sync work set's cursors with one SQLite snapshot instead of one lookup per file. */
609
+ export function getFileOffsets(sessionFiles: string[]): Map<string, FileOffset> {
610
+ const offsets = new Map<string, FileOffset>();
611
+ if (!db || sessionFiles.length === 0) return offsets;
612
+ const placeholders = sessionFiles.map(() => "?").join(",");
613
+ const rows = db
614
+ .query<FileOffsetRow, string[]>(
615
+ `SELECT session_file, offset, last_modified, parser_state FROM file_offsets WHERE session_file IN (${placeholders})`,
616
+ )
617
+ .all(...sessionFiles);
618
+ for (const row of rows) offsets.set(row.session_file, decodeFileOffset(row));
619
+ return offsets;
620
+ }
621
+
626
622
  /**
627
623
  * Update the stored offset for a session file.
628
624
  */
@@ -634,62 +630,168 @@ export function setFileOffset(
634
630
  ): void {
635
631
  if (!db) return;
636
632
 
637
- const stmt = db.prepare(`
633
+ const stmt = db.query(`
638
634
  INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified, parser_state)
639
635
  VALUES (?, ?, ?, ?)
640
636
  `);
641
637
  stmt.run(sessionFile, offset, lastModified, parserState ? JSON.stringify(parserState) : null);
642
638
  }
643
639
 
644
- export function applySessionParseResult(
645
- sessionFile: string,
646
- result: ParseSessionResult,
647
- rebuild = false,
648
- ): { processed: number; reconcile: boolean } {
649
- const parserState = result.parserState;
650
- if (!db || !parserState) return { processed: 0, reconcile: false };
640
+ /** Parsed transcript queued by the sync loop for an atomic database batch. */
641
+ export interface ParsedSession {
642
+ sessionFile: string;
643
+ result: ParseSessionResult;
644
+ rebuild: boolean;
645
+ /** Recover missing rows from a full-history scan without rewriting unchanged records. */
646
+ replay: boolean;
647
+ }
648
+
649
+ function* parsedRows<T>(
650
+ results: ParseSessionResult[],
651
+ select: (result: ParseSessionResult) => Iterable<T>,
652
+ ): Generator<T> {
653
+ for (const result of results) yield* select(result);
654
+ }
655
+
656
+ /** Commit distinct transcripts' rows, reconciliation state, and cursors together; failure rolls back the batch. */
657
+ export function applySessionParseResults(sessions: ParsedSession[]): {
658
+ processed: number;
659
+ files: number;
660
+ reconcile: boolean;
661
+ } {
662
+ if (!db) return { processed: 0, files: 0, reconcile: false };
651
663
  const database = db;
652
664
  return database.transaction(() => {
653
- let reconcile = result.reset ?? false;
654
- if (result.reset || rebuild) {
655
- const retainedMessages = new Set(result.stats.map(row => JSON.stringify([row.entryId, row.timestamp])));
656
- const retainedUsers = new Set(result.userStats.map(row => JSON.stringify([row.entryId, row.timestamp])));
657
- const retainedTools = new Set(
658
- result.toolCalls.map(row => JSON.stringify([row.entryId, row.timestamp, row.toolCallId])),
659
- );
660
- const messages = database
661
- .prepare("SELECT entry_id, timestamp FROM messages WHERE session_file = ?")
662
- .all(sessionFile) as {
663
- entry_id: string;
664
- timestamp: number;
665
- }[];
666
- const users = database
667
- .prepare("SELECT entry_id, timestamp FROM user_messages WHERE session_file = ?")
668
- .all(sessionFile) as { entry_id: string; timestamp: number }[];
669
- const tools = database
670
- .prepare("SELECT entry_id, timestamp, tool_call_id FROM tool_calls WHERE session_file = ?")
671
- .all(sessionFile) as { entry_id: string; timestamp: number; tool_call_id: string }[];
672
- // A removed owner may have surviving fork copies skipped earlier in this pass.
673
- reconcile ||=
674
- messages.some(row => !retainedMessages.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
675
- users.some(row => !retainedUsers.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
676
- tools.some(row => !retainedTools.has(JSON.stringify([row.entry_id, row.timestamp, row.tool_call_id])));
677
- database.prepare("DELETE FROM messages WHERE session_file = ?").run(sessionFile);
678
- database.prepare("DELETE FROM user_messages WHERE session_file = ?").run(sessionFile);
679
- database.prepare("DELETE FROM tool_calls WHERE session_file = ?").run(sessionFile);
665
+ let processed = 0;
666
+ let files = 0;
667
+ let reconcile = false;
668
+ const writes: ParseSessionResult[] = [];
669
+ for (const { sessionFile, result, rebuild, replay } of sessions) {
670
+ const parserState = result.parserState;
671
+ if (!parserState) continue;
672
+ let rows = result;
673
+ if (result.reset || rebuild || replay) {
674
+ const messages = database
675
+ .query<{ entry_id: string; timestamp: number }, [string]>(
676
+ "SELECT entry_id, timestamp FROM messages WHERE session_file = ?",
677
+ )
678
+ .all(sessionFile);
679
+ const users = database
680
+ .query<{ entry_id: string; timestamp: number; model: string | null }, [string]>(
681
+ "SELECT entry_id, timestamp, model FROM user_messages WHERE session_file = ?",
682
+ )
683
+ .all(sessionFile);
684
+ const tools = database
685
+ .query<
686
+ { entry_id: string; timestamp: number; tool_call_id: string; result_chars: number | null },
687
+ [string]
688
+ >("SELECT entry_id, timestamp, tool_call_id, result_chars FROM tool_calls WHERE session_file = ?")
689
+ .all(sessionFile);
690
+ const replace = result.reset || rebuild;
691
+ if (replace) {
692
+ // Only removed owners require another full replay, not an identity-only file replacement.
693
+ if (!reconcile && (messages.length > 0 || users.length > 0 || tools.length > 0)) {
694
+ const retainedMessages = new Set(
695
+ result.stats.map(row => JSON.stringify([row.entryId, row.timestamp])),
696
+ );
697
+ const retainedUsers = new Set(
698
+ result.userStats.map(row => JSON.stringify([row.entryId, row.timestamp])),
699
+ );
700
+ const retainedTools = new Set(
701
+ result.toolCalls.map(row => JSON.stringify([row.entryId, row.timestamp, row.toolCallId])),
702
+ );
703
+ reconcile =
704
+ messages.some(row => !retainedMessages.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
705
+ users.some(row => !retainedUsers.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
706
+ tools.some(
707
+ row => !retainedTools.has(JSON.stringify([row.entry_id, row.timestamp, row.tool_call_id])),
708
+ );
709
+ }
710
+ } else {
711
+ // A reconciliation replay only needs missing rows and unfinished links. Keep stable request
712
+ // IDs and avoid rewriting every table/index for transcripts whose contents did not change.
713
+ const messageById = new Map(messages.map(row => [row.entry_id, row.timestamp]));
714
+ const userById = new Map(users.map(row => [row.entry_id, row]));
715
+ const toolById = new Map(tools.map(row => [row.tool_call_id, row]));
716
+ const absentMessages = new Set(messageById.keys());
717
+ const absentUsers = new Set(userById.keys());
718
+ const absentTools = new Set(toolById.keys());
719
+ const stats = result.stats.filter(row => {
720
+ if (messageById.get(row.entryId) !== row.timestamp) return true;
721
+ absentMessages.delete(row.entryId);
722
+ return false;
723
+ });
724
+ const userStats = result.userStats.filter(row => {
725
+ if (userById.get(row.entryId)?.timestamp !== row.timestamp) return true;
726
+ absentUsers.delete(row.entryId);
727
+ return false;
728
+ });
729
+ const toolCalls = result.toolCalls.filter(row => {
730
+ const stored = toolById.get(row.toolCallId);
731
+ if (stored?.entry_id !== row.entryId || stored.timestamp !== row.timestamp) return true;
732
+ absentTools.delete(row.toolCallId);
733
+ return false;
734
+ });
735
+ rows = {
736
+ ...result,
737
+ stats,
738
+ userStats,
739
+ toolCalls,
740
+ // A reused ID needs fresh linkage after its old identity is removed.
741
+ userLinks: result.userLinks.filter(
742
+ row => absentUsers.has(row.entryId) || userById.get(row.entryId)?.model == null,
743
+ ),
744
+ toolResults: result.toolResults.filter(
745
+ row => absentTools.has(row.toolCallId) || toolById.get(row.toolCallId)?.result_chars == null,
746
+ ),
747
+ };
748
+ // Repair stale owners without invalidating IDs of records still present in the transcript.
749
+ if (absentMessages.size > 0 || absentUsers.size > 0 || absentTools.size > 0) {
750
+ reconcile = true;
751
+ for (const id of absentMessages) {
752
+ database
753
+ .query("DELETE FROM messages WHERE session_file = ? AND entry_id = ?")
754
+ .run(sessionFile, id);
755
+ }
756
+ for (const id of absentUsers) {
757
+ database
758
+ .query("DELETE FROM user_messages WHERE session_file = ? AND entry_id = ?")
759
+ .run(sessionFile, id);
760
+ }
761
+ for (const id of absentTools) {
762
+ database
763
+ .query("DELETE FROM tool_calls WHERE session_file = ? AND tool_call_id = ?")
764
+ .run(sessionFile, id);
765
+ }
766
+ }
767
+ }
768
+ if (replace) {
769
+ if (messages.length > 0) database.query("DELETE FROM messages WHERE session_file = ?").run(sessionFile);
770
+ if (users.length > 0)
771
+ database.query("DELETE FROM user_messages WHERE session_file = ?").run(sessionFile);
772
+ if (tools.length > 0) database.query("DELETE FROM tool_calls WHERE session_file = ?").run(sessionFile);
773
+ }
774
+ }
775
+ writes.push(rows);
776
+ const count = rows.stats.length + rows.userStats.length;
777
+ processed += count;
778
+ if (count > 0) files++;
779
+ }
780
+ // Remove replaced rows before choosing new fork owners, then write each table once per batch.
781
+ insertMessageStats(parsedRows(writes, result => result.stats));
782
+ insertUserMessageStats(parsedRows(writes, result => result.userStats));
783
+ updateUserMessageLinks(parsedRows(writes, result => result.userLinks));
784
+ insertToolCalls(parsedRows(writes, result => result.toolCalls));
785
+ updateToolResults(parsedRows(writes, result => result.toolResults));
786
+ for (const { sessionFile, result } of sessions) {
787
+ if (result.parserState) {
788
+ setFileOffset(sessionFile, result.newOffset, result.parserState.mtimeMs, result.parserState);
789
+ }
680
790
  }
681
791
  if (reconcile) {
682
- database
683
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_reconciliation', 'pending')")
684
- .run();
792
+ database.query("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_reconciliation', 'pending')").run();
685
793
  }
686
- if (result.stats.length > 0) insertMessageStats(result.stats);
687
- if (result.userStats.length > 0) insertUserMessageStats(result.userStats);
688
- if (result.userLinks.length > 0) updateUserMessageLinks(result.userLinks);
689
- if (result.toolCalls.length > 0) insertToolCalls(result.toolCalls);
690
- if (result.toolResults.length > 0) updateToolResults(result.toolResults);
691
- setFileOffset(sessionFile, result.newOffset, parserState.mtimeMs, parserState);
692
- return { processed: result.stats.length + result.userStats.length, reconcile };
794
+ return { processed, files, reconcile };
693
795
  })();
694
796
  }
695
797
 
@@ -717,10 +819,10 @@ export function completeSessionSync(reconcile: boolean): void {
717
819
  * stored cost (orchestration-aware) and keeps `premium_requests` monotonic, so
718
820
  * a forced re-parse repairs historical `premium_requests` and cost fix-ups.
719
821
  */
720
- export function insertMessageStats(stats: MessageStatsInput[]): number {
721
- if (!db || stats.length === 0) return 0;
822
+ export function insertMessageStats(stats: Iterable<MessageStatsInput>): number {
823
+ if (!db) return 0;
722
824
 
723
- const stmt = db.prepare(`
825
+ const stmt = db.query(`
724
826
  INSERT INTO messages (
725
827
  session_file, entry_id, folder, model, provider, api, timestamp,
726
828
  duration, ttft, stop_reason, error_message,
@@ -789,483 +891,6 @@ export function insertMessageStats(stats: MessageStatsInput[]): number {
789
891
  return inserted;
790
892
  }
791
893
 
792
- /**
793
- * Build aggregated stats from query results.
794
- */
795
- function buildAggregatedStats(rows: AggregatedStatsRow[]): AggregatedStats {
796
- if (rows.length === 0) {
797
- return {
798
- totalRequests: 0,
799
- successfulRequests: 0,
800
- failedRequests: 0,
801
- errorRate: 0,
802
- totalInputTokens: 0,
803
- totalOutputTokens: 0,
804
- totalCacheReadTokens: 0,
805
- totalCacheWriteTokens: 0,
806
- cacheRate: 0,
807
- cacheSavings: 0,
808
- totalCost: 0,
809
- unpricedRequests: 0,
810
- totalPremiumRequests: 0,
811
- avgDuration: null,
812
- avgTtft: null,
813
- avgTokensPerSecond: null,
814
- firstTimestamp: 0,
815
- lastTimestamp: 0,
816
- };
817
- }
818
-
819
- const row = rows[0];
820
- const totalRequests = row.total_requests || 0;
821
- const failedRequests = row.failed_requests || 0;
822
- const successfulRequests = totalRequests - failedRequests;
823
- const totalInputTokens = row.total_input_tokens || 0;
824
- const totalCacheReadTokens = row.total_cache_read_tokens || 0;
825
- const totalPremiumRequests = row.total_premium_requests || 0;
826
- const noCacheInputCost = row.total_no_cache_input_cost || 0;
827
- const cachedPromptCost = row.total_cached_prompt_cost || 0;
828
-
829
- return {
830
- totalRequests,
831
- successfulRequests,
832
- failedRequests,
833
- errorRate: totalRequests > 0 ? failedRequests / totalRequests : 0,
834
- totalInputTokens,
835
- totalOutputTokens: row.total_output_tokens || 0,
836
- totalCacheReadTokens,
837
- totalCacheWriteTokens: row.total_cache_write_tokens || 0,
838
- cacheRate:
839
- totalInputTokens + totalCacheReadTokens > 0
840
- ? totalCacheReadTokens / (totalInputTokens + totalCacheReadTokens)
841
- : 0,
842
- cacheSavings: noCacheInputCost > 0 ? (noCacheInputCost - cachedPromptCost) / noCacheInputCost : 0,
843
- totalCost: row.total_cost || 0,
844
- unpricedRequests: row.unpriced_requests || 0,
845
- totalPremiumRequests,
846
- avgDuration: row.avg_duration,
847
- avgTtft: row.avg_ttft,
848
- avgTokensPerSecond: row.avg_tokens_per_second,
849
- firstTimestamp: row.first_timestamp || 0,
850
- lastTimestamp: row.last_timestamp || 0,
851
- };
852
- }
853
-
854
- /**
855
- * Get overall aggregated stats.
856
- */
857
- export function getOverallStats(cutoff?: number): AggregatedStats {
858
- if (!db) return buildAggregatedStats([]);
859
-
860
- const hasCutoff = cutoff !== undefined && cutoff > 0;
861
- const stmt = db.prepare(`
862
- SELECT
863
- COUNT(*) as total_requests,
864
- SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
865
- SUM(input_tokens) as total_input_tokens,
866
- SUM(output_tokens) as total_output_tokens,
867
- SUM(cache_read_tokens) as total_cache_read_tokens,
868
- SUM(cache_write_tokens) as total_cache_write_tokens,
869
- SUM(premium_requests) as total_premium_requests,
870
- SUM(cost_total) as total_cost,
871
- SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
872
- SUM(CASE WHEN cost_no_cache_input > 0
873
- THEN cost_input + cost_cache_read + cost_cache_write
874
- ELSE 0 END) as total_cached_prompt_cost,
875
- SUM(cost_no_cache_input) as total_no_cache_input_cost,
876
- AVG(duration) as avg_duration,
877
- AVG(ttft) as avg_ttft,
878
- AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
879
- MIN(timestamp) as first_timestamp,
880
- MAX(timestamp) as last_timestamp
881
- FROM messages
882
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
883
- `);
884
-
885
- const rows = hasCutoff ? stmt.all(cutoff) : stmt.all();
886
- return buildAggregatedStats(rows as AggregatedStatsRow[]);
887
- }
888
- /**
889
- * Get stats grouped by model.
890
- */
891
- export function getStatsByModel(cutoff?: number): ModelStats[] {
892
- if (!db) return [];
893
-
894
- const hasCutoff = cutoff !== undefined && cutoff > 0;
895
- const stmt = db.prepare(`
896
- SELECT
897
- model,
898
- provider,
899
- COUNT(*) as total_requests,
900
- SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
901
- SUM(input_tokens) as total_input_tokens,
902
- SUM(output_tokens) as total_output_tokens,
903
- SUM(cache_read_tokens) as total_cache_read_tokens,
904
- SUM(cache_write_tokens) as total_cache_write_tokens,
905
- SUM(premium_requests) as total_premium_requests,
906
- SUM(cost_total) as total_cost,
907
- SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
908
- SUM(CASE WHEN cost_no_cache_input > 0
909
- THEN cost_input + cost_cache_read + cost_cache_write
910
- ELSE 0 END) as total_cached_prompt_cost,
911
- SUM(cost_no_cache_input) as total_no_cache_input_cost,
912
- AVG(duration) as avg_duration,
913
- AVG(ttft) as avg_ttft,
914
- AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
915
- MIN(timestamp) as first_timestamp,
916
- MAX(timestamp) as last_timestamp
917
- FROM messages
918
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
919
- GROUP BY model, provider
920
- ORDER BY total_requests DESC
921
- `);
922
-
923
- const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as ModelStatsRow[];
924
- return rows.map(row => ({
925
- model: row.model,
926
- provider: row.provider,
927
- ...buildAggregatedStats([row]),
928
- }));
929
- }
930
-
931
- /**
932
- * Get stats grouped by folder.
933
- */
934
- export function getStatsByFolder(cutoff?: number): FolderStats[] {
935
- if (!db) return [];
936
-
937
- const hasCutoff = cutoff !== undefined && cutoff > 0;
938
- const stmt = db.prepare(`
939
- SELECT
940
- folder,
941
- COUNT(*) as total_requests,
942
- SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
943
- SUM(input_tokens) as total_input_tokens,
944
- SUM(output_tokens) as total_output_tokens,
945
- SUM(cache_read_tokens) as total_cache_read_tokens,
946
- SUM(cache_write_tokens) as total_cache_write_tokens,
947
- SUM(premium_requests) as total_premium_requests,
948
- SUM(cost_total) as total_cost,
949
- SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
950
- SUM(CASE WHEN cost_no_cache_input > 0
951
- THEN cost_input + cost_cache_read + cost_cache_write
952
- ELSE 0 END) as total_cached_prompt_cost,
953
- SUM(cost_no_cache_input) as total_no_cache_input_cost,
954
- AVG(duration) as avg_duration,
955
- AVG(ttft) as avg_ttft,
956
- AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
957
- MIN(timestamp) as first_timestamp,
958
- MAX(timestamp) as last_timestamp
959
- FROM messages
960
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
961
- GROUP BY folder
962
- ORDER BY total_requests DESC
963
- `);
964
-
965
- const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as FolderStatsRow[];
966
- return rows.map(row => ({
967
- folder: row.folder,
968
- ...buildAggregatedStats([row]),
969
- }));
970
- }
971
-
972
- /**
973
- * Get token usage grouped by agent type (main agent, task subagents, advisor).
974
- * Token columns are explicit so the dashboard's share denominator matches the
975
- * counts it renders. Rows missing `agent_type` (defensive) fall back to "main".
976
- */
977
- export function getStatsByAgentType(cutoff?: number): AgentTypeStats[] {
978
- if (!db) return [];
979
-
980
- const hasCutoff = cutoff !== undefined && cutoff > 0;
981
- const stmt = db.prepare(`
982
- SELECT
983
- agent_type,
984
- COUNT(*) as total_requests,
985
- SUM(input_tokens) as total_input_tokens,
986
- SUM(output_tokens) as total_output_tokens,
987
- SUM(cache_read_tokens) as total_cache_read_tokens,
988
- SUM(cache_write_tokens) as total_cache_write_tokens,
989
- SUM(cost_total) as total_cost
990
- FROM messages
991
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
992
- GROUP BY agent_type
993
- `);
994
-
995
- const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
996
- return rows.map(row => ({
997
- agentType: (row.agent_type as AgentType) ?? "main",
998
- totalRequests: row.total_requests || 0,
999
- totalInputTokens: row.total_input_tokens || 0,
1000
- totalOutputTokens: row.total_output_tokens || 0,
1001
- totalCacheReadTokens: row.total_cache_read_tokens || 0,
1002
- totalCacheWriteTokens: row.total_cache_write_tokens || 0,
1003
- totalCost: row.total_cost || 0,
1004
- }));
1005
- }
1006
-
1007
- /**
1008
- * Get time series data.
1009
- */
1010
- export function getTimeSeries(hours = 24, cutoff?: number | null, bucketMs = 60 * 60 * 1000): TimeSeriesPoint[] {
1011
- if (!db) return [];
1012
-
1013
- const hasCutoff = cutoff !== null;
1014
- const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - hours * 60 * 60 * 1000) : 0;
1015
-
1016
- const stmt = db.prepare(`
1017
- SELECT
1018
- (timestamp / ?) * ? as bucket,
1019
- COUNT(*) as requests,
1020
- SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as errors,
1021
- SUM(total_tokens) as tokens,
1022
- SUM(cost_total) as cost
1023
- FROM messages
1024
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1025
- GROUP BY bucket
1026
- ORDER BY bucket ASC
1027
- `);
1028
-
1029
- const rows = hasCutoff
1030
- ? (stmt.all(bucketMs, bucketMs, seriesCutoff) as any[])
1031
- : (stmt.all(bucketMs, bucketMs) as any[]);
1032
- return rows.map(row => ({
1033
- timestamp: row.bucket,
1034
- requests: row.requests,
1035
- errors: row.errors,
1036
- tokens: row.tokens,
1037
- cost: row.cost,
1038
- }));
1039
- }
1040
-
1041
- /**
1042
- * Get daily performance time series data for the last N days.
1043
- */
1044
- /**
1045
- * Get daily model usage time series data for the last N days.
1046
- */
1047
- export function getModelTimeSeries(
1048
- days = 14,
1049
- cutoff?: number | null,
1050
- bucketMs = 24 * 60 * 60 * 1000,
1051
- ): ModelTimeSeriesPoint[] {
1052
- if (!db) return [];
1053
-
1054
- const hasCutoff = cutoff !== null;
1055
- const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
1056
-
1057
- const stmt = db.prepare(`
1058
- SELECT
1059
- (timestamp / ?) * ? as bucket,
1060
- model,
1061
- provider,
1062
- COUNT(*) as requests
1063
- FROM messages
1064
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1065
- GROUP BY bucket, model, provider
1066
- ORDER BY bucket ASC
1067
- `);
1068
-
1069
- const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
1070
- const rows = rowsRaw as Array<{ bucket: number; model: string; provider: string; requests: number }>;
1071
- return rows.map(row => ({
1072
- timestamp: row.bucket,
1073
- model: row.model,
1074
- provider: row.provider,
1075
- requests: row.requests,
1076
- }));
1077
- }
1078
-
1079
- /**
1080
- * Get request/token/cost totals grouped by provider.
1081
- */
1082
- export function getStatsByProvider(cutoff?: number | null): ProviderAggregate[] {
1083
- if (!db) return [];
1084
-
1085
- const hasCutoff = cutoff !== undefined && cutoff !== null && cutoff > 0;
1086
- const stmt = db.prepare(`
1087
- SELECT
1088
- provider,
1089
- COUNT(*) as total_requests,
1090
- SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
1091
- COUNT(DISTINCT model) as models,
1092
- SUM(input_tokens) as total_input_tokens,
1093
- SUM(output_tokens) as total_output_tokens,
1094
- SUM(cache_read_tokens) as total_cache_read_tokens,
1095
- SUM(cache_write_tokens) as total_cache_write_tokens,
1096
- SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
1097
- SUM(cost_total) as total_cost,
1098
- SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
1099
- SUM(premium_requests) as total_premium_requests,
1100
- AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
1101
- FROM messages
1102
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1103
- GROUP BY provider
1104
- ORDER BY total_tokens DESC
1105
- `);
1106
-
1107
- const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as Array<{
1108
- provider: string;
1109
- total_requests: number;
1110
- failed_requests: number;
1111
- models: number;
1112
- total_input_tokens: number | null;
1113
- total_output_tokens: number | null;
1114
- total_cache_read_tokens: number | null;
1115
- total_cache_write_tokens: number | null;
1116
- total_tokens: number | null;
1117
- total_cost: number | null;
1118
- unpriced_requests: number | null;
1119
- total_premium_requests: number | null;
1120
- avg_tokens_per_second: number | null;
1121
- }>;
1122
- return rows.map(row => ({
1123
- provider: row.provider,
1124
- totalRequests: row.total_requests,
1125
- failedRequests: row.failed_requests,
1126
- models: row.models,
1127
- totalInputTokens: row.total_input_tokens ?? 0,
1128
- totalOutputTokens: row.total_output_tokens ?? 0,
1129
- totalCacheReadTokens: row.total_cache_read_tokens ?? 0,
1130
- totalCacheWriteTokens: row.total_cache_write_tokens ?? 0,
1131
- totalTokens: row.total_tokens ?? 0,
1132
- totalCost: row.total_cost ?? 0,
1133
- unpricedRequests: row.unpriced_requests ?? 0,
1134
- totalPremiumRequests: row.total_premium_requests ?? 0,
1135
- avgTokensPerSecond: row.avg_tokens_per_second,
1136
- }));
1137
- }
1138
-
1139
- /**
1140
- * Get token burn grouped by provider and local hour of day (0-23).
1141
- * Hours use the server's timezone — the dashboard is a localhost tool, so
1142
- * server-local and viewer-local time coincide.
1143
- */
1144
- export function getProviderHourlyBurn(cutoff?: number | null): ProviderHourlyPoint[] {
1145
- if (!db) return [];
1146
-
1147
- const hasCutoff = cutoff !== undefined && cutoff !== null && cutoff > 0;
1148
- const stmt = db.prepare(`
1149
- SELECT
1150
- provider,
1151
- CAST(strftime('%H', timestamp / 1000, 'unixepoch', 'localtime') AS INTEGER) as hour,
1152
- SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
1153
- SUM(output_tokens) as output_tokens,
1154
- COUNT(*) as requests
1155
- FROM messages
1156
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1157
- GROUP BY provider, hour
1158
- ORDER BY provider, hour
1159
- `);
1160
-
1161
- const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as Array<{
1162
- provider: string;
1163
- hour: number;
1164
- total_tokens: number | null;
1165
- output_tokens: number | null;
1166
- requests: number;
1167
- }>;
1168
- return rows.map(row => ({
1169
- provider: row.provider,
1170
- hour: row.hour,
1171
- totalTokens: row.total_tokens ?? 0,
1172
- outputTokens: row.output_tokens ?? 0,
1173
- requests: row.requests,
1174
- }));
1175
- }
1176
-
1177
- /**
1178
- * Get token/cost time series grouped by provider (bucketed like the model series).
1179
- */
1180
- export function getProviderTimeSeries(
1181
- days = 14,
1182
- cutoff?: number | null,
1183
- bucketMs = 24 * 60 * 60 * 1000,
1184
- ): ProviderTimeSeriesPoint[] {
1185
- if (!db) return [];
1186
-
1187
- const hasCutoff = cutoff !== null;
1188
- const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
1189
-
1190
- const stmt = db.prepare(`
1191
- SELECT
1192
- (timestamp / ?) * ? as bucket,
1193
- provider,
1194
- SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
1195
- SUM(cost_total) as cost,
1196
- SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
1197
- COUNT(*) as requests
1198
- FROM messages
1199
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1200
- GROUP BY bucket, provider
1201
- ORDER BY bucket ASC
1202
- `);
1203
-
1204
- const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
1205
- const rows = rowsRaw as Array<{
1206
- bucket: number;
1207
- provider: string;
1208
- total_tokens: number | null;
1209
- cost: number | null;
1210
- unpriced_requests: number | null;
1211
- requests: number;
1212
- }>;
1213
- return rows.map(row => ({
1214
- timestamp: row.bucket,
1215
- provider: row.provider,
1216
- totalTokens: row.total_tokens ?? 0,
1217
- cost: row.cost ?? 0,
1218
- unpricedRequests: row.unpriced_requests ?? 0,
1219
- requests: row.requests,
1220
- }));
1221
- }
1222
-
1223
- /**
1224
- * Get daily model performance time series data for the last N days.
1225
- */
1226
- export function getModelPerformanceSeries(
1227
- days = 14,
1228
- cutoff?: number | null,
1229
- bucketMs = 24 * 60 * 60 * 1000,
1230
- ): ModelPerformancePoint[] {
1231
- if (!db) return [];
1232
-
1233
- const hasCutoff = cutoff !== null;
1234
- const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
1235
-
1236
- const stmt = db.prepare(`
1237
- SELECT
1238
- (timestamp / ?) * ? as bucket,
1239
- model,
1240
- provider,
1241
- COUNT(*) as requests,
1242
- AVG(ttft) as avg_ttft,
1243
- AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
1244
- FROM messages
1245
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1246
- GROUP BY bucket, model, provider
1247
- ORDER BY bucket ASC
1248
- `);
1249
-
1250
- const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
1251
- const rows = rowsRaw as Array<{
1252
- bucket: number;
1253
- model: string;
1254
- provider: string;
1255
- requests: number;
1256
- avg_ttft: number | null;
1257
- avg_tokens_per_second: number | null;
1258
- }>;
1259
- return rows.map(row => ({
1260
- timestamp: row.bucket,
1261
- model: row.model,
1262
- provider: row.provider,
1263
- requests: row.requests,
1264
- avgTtft: row.avg_ttft,
1265
- avgTokensPerSecond: row.avg_tokens_per_second,
1266
- }));
1267
- }
1268
-
1269
894
  /**
1270
895
  * Get total message count.
1271
896
  */
@@ -1350,92 +975,6 @@ export function getMessageById(id: number): MessageStats | null {
1350
975
  const row = stmt.get(id);
1351
976
  return row ? rowToMessageStats(row) : null;
1352
977
  }
1353
- /** Per-transcript-file rollup for the Traces session list. */
1354
- export interface SessionRollupRow {
1355
- sessionFile: string;
1356
- requests: number;
1357
- startedAt: number;
1358
- endedAt: number;
1359
- totalTokens: number;
1360
- costTotal: number;
1361
- unpricedRequests: number;
1362
- /** Comma-joined DISTINCT models. */
1363
- models: string;
1364
- }
1365
-
1366
- /** Aggregate every synced transcript file into one row (subagents unfolded). */
1367
- export function getSessionRollups(): SessionRollupRow[] {
1368
- if (!db) return [];
1369
- const stmt = db.prepare(`
1370
- SELECT session_file AS sessionFile,
1371
- COUNT(*) AS requests,
1372
- MIN(timestamp) AS startedAt,
1373
- MAX(timestamp + COALESCE(duration, 0)) AS endedAt,
1374
- SUM(total_tokens) AS totalTokens,
1375
- SUM(cost_total) AS costTotal,
1376
- SUM(${UNPRICED_REQUEST_SQL}) AS unpricedRequests,
1377
- GROUP_CONCAT(DISTINCT model) AS models
1378
- FROM messages
1379
- GROUP BY session_file
1380
- `);
1381
- return stmt.all() as SessionRollupRow[];
1382
- }
1383
-
1384
- /** Tool-call counts keyed by transcript file, for the Traces session list. */
1385
- export function getToolCallCountsBySession(): Map<string, number> {
1386
- const counts = new Map<string, number>();
1387
- if (!db) return counts;
1388
- const stmt = db.prepare(
1389
- "SELECT session_file AS sessionFile, COUNT(*) AS calls FROM tool_calls GROUP BY session_file",
1390
- );
1391
- for (const row of stmt.all() as Array<{ sessionFile: string; calls: number }>) {
1392
- counts.set(row.sessionFile, row.calls);
1393
- }
1394
- return counts;
1395
- }
1396
-
1397
- /**
1398
- * Get daily cost time series data for the last N days, broken down by model.
1399
- */
1400
- export function getCostTimeSeries(days = 90, cutoff?: number | null): CostTimeSeriesPoint[] {
1401
- if (!db) return [];
1402
-
1403
- const hasCutoff = cutoff !== null;
1404
- const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
1405
-
1406
- const stmt = db.prepare(`
1407
- SELECT
1408
- (timestamp / 86400000) * 86400000 as bucket,
1409
- model,
1410
- provider,
1411
- SUM(cost_total) as cost,
1412
- SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
1413
- SUM(cost_input) as cost_input,
1414
- SUM(cost_output) as cost_output,
1415
- SUM(cost_cache_read) as cost_cache_read,
1416
- SUM(cost_cache_write) as cost_cache_write,
1417
- COUNT(*) as requests
1418
- FROM messages
1419
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1420
- GROUP BY bucket, model, provider
1421
- ORDER BY bucket ASC
1422
- `);
1423
-
1424
- const rows = (hasCutoff ? stmt.all(seriesCutoff) : stmt.all()) as CostTimeSeriesRow[];
1425
- return rows.map(row => ({
1426
- timestamp: row.bucket,
1427
- model: row.model,
1428
- provider: row.provider,
1429
- cost: row.cost ?? 0,
1430
- unpricedRequests: row.unpriced_requests ?? 0,
1431
- costInput: row.cost_input ?? 0,
1432
- costOutput: row.cost_output ?? 0,
1433
- costCacheRead: row.cost_cache_read ?? 0,
1434
- costCacheWrite: row.cost_cache_write ?? 0,
1435
- requests: row.requests,
1436
- }));
1437
- }
1438
-
1439
978
  /**
1440
979
  * Per-local-day activity aggregates for the last `days` days, oldest first.
1441
980
  * Self-initializing (opens the stats DB on first use) so the coding-agent TUI
@@ -1507,6 +1046,9 @@ export async function getDailyActivity(days = 371): Promise<DailyActivityPoint[]
1507
1046
  * you` scores blame, `makes (no|zero) sense` scores negation. v7
1508
1047
  * shipped briefly without these, so any database that completed the v7
1509
1048
  * backfill needs one more re-derive.
1049
+ * - v9: stored judge prose (`prose` / `prose_hash`) for the frustration
1050
+ * judge, so every session re-parses once to populate it. Verdicts live in
1051
+ * `frustration_verdicts`, keyed by prose hash, which this reset never touches.
1510
1052
  *
1511
1053
  * Existing `messages` rows are unaffected - `INSERT OR IGNORE` keeps them.
1512
1054
  */
@@ -1728,16 +1270,16 @@ export function markSessionBackfillsComplete(): void {
1728
1270
  * copy user entries verbatim into the child JSONL, so the same
1729
1271
  * `(entry_id, timestamp)` must not land twice across different session files.
1730
1272
  */
1731
- export function insertUserMessageStats(stats: UserMessageStats[]): number {
1732
- if (!db || stats.length === 0) return 0;
1273
+ export function insertUserMessageStats(stats: Iterable<UserMessageStats>): number {
1274
+ if (!db) return 0;
1733
1275
 
1734
- const stmt = db.prepare(`
1276
+ const stmt = db.query(`
1735
1277
  INSERT OR IGNORE INTO user_messages (
1736
1278
  session_file, entry_id, folder, timestamp, model, provider,
1737
1279
  chars, words, yelling, profanity, anguish,
1738
- negation, repetition, blame
1280
+ negation, repetition, blame, prose, prose_hash
1739
1281
  )
1740
- SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
1282
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
1741
1283
  WHERE NOT EXISTS (
1742
1284
  SELECT 1 FROM user_messages
1743
1285
  WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
@@ -1762,6 +1304,8 @@ export function insertUserMessageStats(stats: UserMessageStats[]): number {
1762
1304
  s.negation,
1763
1305
  s.repetition,
1764
1306
  s.blame,
1307
+ s.prose,
1308
+ s.proseHash,
1765
1309
  // `WHERE NOT EXISTS` binds: skip when a different session_file
1766
1310
  // already holds this (entry_id, timestamp).
1767
1311
  s.entryId,
@@ -1777,17 +1321,16 @@ export function insertUserMessageStats(stats: UserMessageStats[]): number {
1777
1321
 
1778
1322
  /**
1779
1323
  * Backfill the responding `model`/`provider` on user-message rows that were
1780
- * persisted before their assistant reply was parsed (a side effect of
1781
- * incremental `fromOffset` syncing: the `userByEntryId` map in
1782
- * `parseSessionFile` only spans a single pass). Each row is updated at most
1783
- * once because the `model IS NULL` guard short-circuits subsequent passes.
1324
+ * persisted before their assistant reply was parsed by an incremental tail
1325
+ * read. Each row is updated at most once because the `model IS NULL` guard
1326
+ * short-circuits subsequent passes.
1784
1327
  *
1785
1328
  * Returns the number of rows actually updated.
1786
1329
  */
1787
- export function updateUserMessageLinks(links: UserMessageLink[]): number {
1788
- if (!db || links.length === 0) return 0;
1330
+ export function updateUserMessageLinks(links: Iterable<UserMessageLink>): number {
1331
+ if (!db) return 0;
1789
1332
 
1790
- const stmt = db.prepare(`
1333
+ const stmt = db.query(`
1791
1334
  UPDATE user_messages
1792
1335
  SET model = ?, provider = ?
1793
1336
  WHERE session_file = ? AND entry_id = ? AND model IS NULL
@@ -1804,181 +1347,162 @@ export function updateUserMessageLinks(links: UserMessageLink[]): number {
1804
1347
  return updated;
1805
1348
  }
1806
1349
 
1807
- const UNKNOWN_MODEL = "unknown";
1808
-
1809
- interface BehaviorSeriesRow {
1810
- bucket: number;
1350
+ /** Frustration tallies of one responding (model, provider) pair, before identity merging. */
1351
+ export interface FrustrationModelRow extends FrustrationCounts {
1811
1352
  model: string;
1812
- provider: string;
1353
+ provider: string | null;
1354
+ /** Earliest message timestamp (ms) in range. */
1355
+ firstSeen: number;
1356
+ }
1357
+
1358
+ /** One unique unjudged prose text: identical messages share a hash and one verdict. */
1359
+ export interface PendingProse {
1360
+ hash: string;
1361
+ prose: string;
1362
+ }
1363
+
1364
+ /** Cached judge verdict for one prose hash. */
1365
+ export interface FrustrationVerdict {
1366
+ proseHash: string;
1367
+ /** P(clearly annoyed) + P(angry) on the `annoyed` score question. */
1368
+ pAnnoyed: number;
1369
+ /** P(angry, hostile, or swearing). */
1370
+ pAngry: number;
1371
+ /** Most likely `target` choice: `assistant`, `other`, or `none`. */
1372
+ target: string;
1373
+ /** `provider/model` that produced the verdict. */
1374
+ judge: string;
1375
+ judgedAt: number;
1376
+ }
1377
+
1378
+ interface FrustrationCountsRow {
1813
1379
  messages: number;
1814
- yelling: number | null;
1815
- profanity: number | null;
1816
- anguish: number | null;
1817
- negation: number | null;
1818
- repetition: number | null;
1819
- blame: number | null;
1820
- chars: number | null;
1380
+ judged: number;
1381
+ annoyed: number;
1382
+ at_assistant: number;
1383
+ angry: number;
1384
+ }
1385
+
1386
+ interface FrustrationModelSqlRow extends FrustrationCountsRow {
1387
+ model: string;
1388
+ provider: string | null;
1389
+ first_seen: number;
1390
+ }
1391
+
1392
+ // Each message is classified once: by its cached verdict when `v` joined,
1393
+ // else by the regex signals stored at ingest. Keep in sync with the rules
1394
+ // documented on `FrustrationCounts` / `frustration.ts`.
1395
+ const JUDGED_SQL = "v.prose_hash IS NOT NULL";
1396
+ const JUDGED_ANNOYED_SQL = "v.p_annoyed >= 0.5";
1397
+ const JUDGED_AT_ASSISTANT_SQL = `${JUDGED_ANNOYED_SQL} AND v.target = 'assistant'`;
1398
+ const REGEX_AT_ASSISTANT_SQL = "u.negation + u.repetition + u.blame > 0";
1399
+ const FRUSTRATION_COUNTS_SQL = `
1400
+ COUNT(*) AS messages,
1401
+ COALESCE(SUM(${JUDGED_SQL}), 0) AS judged,
1402
+ COALESCE(SUM(CASE WHEN ${JUDGED_SQL} THEN ${JUDGED_ANNOYED_SQL}
1403
+ ELSE u.yelling + u.profanity + u.anguish + u.negation + u.repetition + u.blame > 0 END), 0) AS annoyed,
1404
+ COALESCE(SUM(CASE WHEN ${JUDGED_SQL} THEN ${JUDGED_AT_ASSISTANT_SQL}
1405
+ ELSE ${REGEX_AT_ASSISTANT_SQL} END), 0) AS at_assistant,
1406
+ COALESCE(SUM(CASE WHEN ${JUDGED_SQL} THEN ${JUDGED_AT_ASSISTANT_SQL} AND v.p_angry >= 0.5
1407
+ ELSE ${REGEX_AT_ASSISTANT_SQL} AND (u.profanity > 0 OR u.yelling > 0) END), 0) AS angry
1408
+ `;
1409
+
1410
+ function hasRangeCutoff(cutoff: number | null | undefined): cutoff is number {
1411
+ return cutoff !== null && cutoff !== undefined && cutoff > 0;
1412
+ }
1413
+
1414
+ function toFrustrationCounts(row: FrustrationCountsRow | undefined): FrustrationCounts {
1415
+ return {
1416
+ messages: row?.messages ?? 0,
1417
+ judged: row?.judged ?? 0,
1418
+ annoyed: row?.annoyed ?? 0,
1419
+ atAssistant: row?.at_assistant ?? 0,
1420
+ angry: row?.angry ?? 0,
1421
+ };
1422
+ }
1423
+
1424
+ /** Frustration tallies over every user message with prose in range, linked to a model or not. */
1425
+ export function getFrustrationOverall(cutoff?: number | null): FrustrationCounts {
1426
+ if (!db) return toFrustrationCounts(undefined);
1427
+ const hasCutoff = hasRangeCutoff(cutoff);
1428
+ const stmt = db.prepare(`
1429
+ SELECT ${FRUSTRATION_COUNTS_SQL}
1430
+ FROM user_messages u
1431
+ LEFT JOIN frustration_verdicts v ON v.prose_hash = u.prose_hash
1432
+ WHERE u.prose != ''${hasCutoff ? " AND u.timestamp >= ?" : ""}
1433
+ `);
1434
+ const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as FrustrationCountsRow | undefined;
1435
+ return toFrustrationCounts(row);
1821
1436
  }
1822
1437
 
1823
1438
  /**
1824
- * Daily behavioral time series, grouped by responding model+provider.
1439
+ * Frustration tallies per responding (model, provider) over the range. User
1440
+ * messages that never got a reply (null model) are only in the overall tally.
1825
1441
  */
1826
- export function getBehaviorTimeSeries(cutoff?: number | null): BehaviorTimeSeriesPoint[] {
1442
+ export function getFrustrationByModel(cutoff?: number | null): FrustrationModelRow[] {
1827
1443
  if (!db) return [];
1828
- const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
1444
+ const hasCutoff = hasRangeCutoff(cutoff);
1829
1445
  const stmt = db.prepare(`
1830
- SELECT
1831
- (timestamp / 86400000) * 86400000 as bucket,
1832
- COALESCE(model, ?) as model,
1833
- COALESCE(provider, ?) as provider,
1834
- COUNT(*) as messages,
1835
- SUM(yelling) as yelling,
1836
- SUM(profanity) as profanity,
1837
- SUM(anguish) as anguish,
1838
- SUM(negation) as negation,
1839
- SUM(repetition) as repetition,
1840
- SUM(blame) as blame,
1841
- SUM(chars) as chars
1842
- FROM user_messages
1843
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1844
- GROUP BY bucket, model, provider
1845
- ORDER BY bucket ASC
1446
+ SELECT u.model AS model, u.provider AS provider, MIN(u.timestamp) AS first_seen, ${FRUSTRATION_COUNTS_SQL}
1447
+ FROM user_messages u
1448
+ LEFT JOIN frustration_verdicts v ON v.prose_hash = u.prose_hash
1449
+ WHERE u.prose != '' AND u.model IS NOT NULL${hasCutoff ? " AND u.timestamp >= ?" : ""}
1450
+ GROUP BY u.model, u.provider
1846
1451
  `);
1847
- const rows = (
1848
- hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
1849
- ) as BehaviorSeriesRow[];
1452
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as FrustrationModelSqlRow[];
1850
1453
  return rows.map(row => ({
1851
- timestamp: row.bucket,
1852
1454
  model: row.model,
1853
1455
  provider: row.provider,
1854
- messages: row.messages,
1855
- yelling: row.yelling ?? 0,
1856
- profanity: row.profanity ?? 0,
1857
- anguish: row.anguish ?? 0,
1858
- negation: row.negation ?? 0,
1859
- repetition: row.repetition ?? 0,
1860
- blame: row.blame ?? 0,
1861
- chars: row.chars ?? 0,
1456
+ firstSeen: row.first_seen,
1457
+ ...toFrustrationCounts(row),
1862
1458
  }));
1863
1459
  }
1864
1460
 
1865
- interface BehaviorOverallRow {
1866
- total_messages: number;
1867
- total_yelling: number | null;
1868
- total_profanity: number | null;
1869
- total_anguish: number | null;
1870
- total_negation: number | null;
1871
- total_repetition: number | null;
1872
- total_blame: number | null;
1873
- total_chars: number | null;
1874
- first_timestamp: number | null;
1875
- last_timestamp: number | null;
1461
+ /** Unique unjudged prose in range, one row per prose hash. */
1462
+ function pendingProseSql(hasCutoff: boolean): string {
1463
+ return `
1464
+ SELECT u.prose_hash AS hash, MIN(u.prose) AS prose
1465
+ FROM user_messages u
1466
+ WHERE u.prose != ''${hasCutoff ? " AND u.timestamp >= ?" : ""}
1467
+ AND NOT EXISTS (SELECT 1 FROM frustration_verdicts v WHERE v.prose_hash = u.prose_hash)
1468
+ GROUP BY u.prose_hash
1469
+ `;
1876
1470
  }
1877
1471
 
1878
- /**
1879
- * Overall behavioral totals across the cutoff window.
1880
- */
1881
- export function getBehaviorOverall(cutoff?: number | null): BehaviorOverallStats {
1882
- const empty: BehaviorOverallStats = {
1883
- totalMessages: 0,
1884
- totalYelling: 0,
1885
- totalProfanity: 0,
1886
- totalAnguish: 0,
1887
- totalNegation: 0,
1888
- totalRepetition: 0,
1889
- totalBlame: 0,
1890
- totalChars: 0,
1891
- firstTimestamp: 0,
1892
- lastTimestamp: 0,
1893
- };
1894
- if (!db) return empty;
1895
- const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
1896
- const stmt = db.prepare(`
1897
- SELECT
1898
- COUNT(*) as total_messages,
1899
- SUM(yelling) as total_yelling,
1900
- SUM(profanity) as total_profanity,
1901
- SUM(anguish) as total_anguish,
1902
- SUM(negation) as total_negation,
1903
- SUM(repetition) as total_repetition,
1904
- SUM(blame) as total_blame,
1905
- SUM(chars) as total_chars,
1906
- MIN(timestamp) as first_timestamp,
1907
- MAX(timestamp) as last_timestamp
1908
- FROM user_messages
1909
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1910
- `);
1911
- const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as BehaviorOverallRow | undefined;
1912
- if (!row?.total_messages) return empty;
1913
- return {
1914
- totalMessages: row.total_messages,
1915
- totalYelling: row.total_yelling ?? 0,
1916
- totalProfanity: row.total_profanity ?? 0,
1917
- totalAnguish: row.total_anguish ?? 0,
1918
- totalNegation: row.total_negation ?? 0,
1919
- totalRepetition: row.total_repetition ?? 0,
1920
- totalBlame: row.total_blame ?? 0,
1921
- totalChars: row.total_chars ?? 0,
1922
- firstTimestamp: row.first_timestamp ?? 0,
1923
- lastTimestamp: row.last_timestamp ?? 0,
1924
- };
1472
+ /** Unique prose texts in range that have no cached verdict yet. */
1473
+ export function getPendingFrustrationProse(cutoff?: number | null): PendingProse[] {
1474
+ if (!db) return [];
1475
+ const hasCutoff = hasRangeCutoff(cutoff);
1476
+ const stmt = db.prepare(pendingProseSql(hasCutoff));
1477
+ return (hasCutoff ? stmt.all(cutoff) : stmt.all()) as PendingProse[];
1925
1478
  }
1926
1479
 
1927
- interface BehaviorByModelRow {
1928
- model: string;
1929
- provider: string;
1930
- total_messages: number;
1931
- total_yelling: number | null;
1932
- total_profanity: number | null;
1933
- total_anguish: number | null;
1934
- total_negation: number | null;
1935
- total_repetition: number | null;
1936
- total_blame: number | null;
1937
- total_chars: number | null;
1938
- last_timestamp: number | null;
1480
+ /** Count and total characters of {@link getPendingFrustrationProse} without loading the text. */
1481
+ export function getPendingFrustrationTotals(cutoff?: number | null): { messages: number; chars: number } {
1482
+ if (!db) return { messages: 0, chars: 0 };
1483
+ const hasCutoff = hasRangeCutoff(cutoff);
1484
+ const stmt = db.prepare(
1485
+ `SELECT COUNT(*) AS messages, COALESCE(SUM(LENGTH(prose)), 0) AS chars FROM (${pendingProseSql(hasCutoff)})`,
1486
+ );
1487
+ const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as { messages: number; chars: number } | undefined;
1488
+ return { messages: row?.messages ?? 0, chars: row?.chars ?? 0 };
1939
1489
  }
1940
1490
 
1941
1491
  /**
1942
- * Per-model behavioral totals over the cutoff window. "Unknown" represents
1943
- * user messages that never received an assistant reply.
1492
+ * Cache (or replace) verdicts, all in one transaction: a judge run lands
1493
+ * dozens per second, and a commit per verdict would contend with ingest for
1494
+ * the write lock every time.
1944
1495
  */
1945
- export function getBehaviorByModel(cutoff?: number | null): BehaviorModelStats[] {
1946
- if (!db) return [];
1947
- const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
1948
- const stmt = db.prepare(`
1949
- SELECT
1950
- COALESCE(model, ?) as model,
1951
- COALESCE(provider, ?) as provider,
1952
- COUNT(*) as total_messages,
1953
- SUM(yelling) as total_yelling,
1954
- SUM(profanity) as total_profanity,
1955
- SUM(anguish) as total_anguish,
1956
- SUM(negation) as total_negation,
1957
- SUM(repetition) as total_repetition,
1958
- SUM(blame) as total_blame,
1959
- SUM(chars) as total_chars,
1960
- MAX(timestamp) as last_timestamp
1961
- FROM user_messages
1962
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1963
- GROUP BY model, provider
1964
- ORDER BY total_messages DESC
1965
- `);
1966
- const rows = (
1967
- hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
1968
- ) as BehaviorByModelRow[];
1969
- return rows.map(row => ({
1970
- model: row.model,
1971
- provider: row.provider,
1972
- totalMessages: row.total_messages,
1973
- totalYelling: row.total_yelling ?? 0,
1974
- totalProfanity: row.total_profanity ?? 0,
1975
- totalAnguish: row.total_anguish ?? 0,
1976
- totalNegation: row.total_negation ?? 0,
1977
- totalRepetition: row.total_repetition ?? 0,
1978
- totalBlame: row.total_blame ?? 0,
1979
- totalChars: row.total_chars ?? 0,
1980
- lastTimestamp: row.last_timestamp ?? 0,
1981
- }));
1496
+ export function upsertFrustrationVerdicts(verdicts: readonly FrustrationVerdict[]): void {
1497
+ if (!db || verdicts.length === 0) return;
1498
+ const database = db;
1499
+ const stmt = database.query(
1500
+ `INSERT OR REPLACE INTO frustration_verdicts (prose_hash, p_annoyed, p_angry, target, judge, judged_at)
1501
+ VALUES (?, ?, ?, ?, ?, ?)`,
1502
+ );
1503
+ database.transaction(() => {
1504
+ for (const v of verdicts) stmt.run(v.proseHash, v.pAnnoyed, v.pAngry, v.target, v.judge, v.judgedAt);
1505
+ })();
1982
1506
  }
1983
1507
 
1984
1508
  /**
@@ -1990,10 +1514,10 @@ export function getBehaviorByModel(cutoff?: number | null): BehaviorModelStats[]
1990
1514
  * identity, not the call id alone — provider call ids are not a global
1991
1515
  * namespace across unrelated sessions.
1992
1516
  */
1993
- export function insertToolCalls(calls: ToolCallStats[]): number {
1994
- if (!db || calls.length === 0) return 0;
1517
+ export function insertToolCalls(calls: Iterable<ToolCallStats>): number {
1518
+ if (!db) return 0;
1995
1519
 
1996
- const stmt = db.prepare(`
1520
+ const stmt = db.query(`
1997
1521
  INSERT OR IGNORE INTO tool_calls (
1998
1522
  session_file, entry_id, tool_call_id, folder, tool_name,
1999
1523
  model, provider, timestamp, agent_type, calls_in_turn, args_chars
@@ -2041,10 +1565,10 @@ export function insertToolCalls(calls: ToolCallStats[]): number {
2041
1565
  * guard makes re-syncs idempotent; rows skipped by the fork guard simply
2042
1566
  * never match.
2043
1567
  */
2044
- export function updateToolResults(links: ToolResultLink[]): number {
2045
- if (!db || links.length === 0) return 0;
1568
+ export function updateToolResults(links: Iterable<ToolResultLink>): number {
1569
+ if (!db) return 0;
2046
1570
 
2047
- const stmt = db.prepare(`
1571
+ const stmt = db.query(`
2048
1572
  UPDATE tool_calls
2049
1573
  SET result_chars = ?, is_error = ?
2050
1574
  WHERE session_file = ? AND tool_call_id = ? AND result_chars IS NULL
@@ -2060,131 +1584,3 @@ export function updateToolResults(links: ToolResultLink[]): number {
2060
1584
  apply();
2061
1585
  return updated;
2062
1586
  }
2063
-
2064
- /**
2065
- * Shared SELECT list for tool aggregates. Real provider usage comes from the
2066
- * invoking assistant turn (`messages` join) divided by `calls_in_turn`, so
2067
- * per-tool token/cost shares stay additive across tools. The unpriced share
2068
- * reuses the request predicate against the joined message, whose stored cost
2069
- * it attributes.
2070
- */
2071
- const TOOL_AGGREGATE_COLUMNS = `
2072
- COUNT(*) as calls,
2073
- SUM(CASE WHEN t.is_error = 1 THEN 1 ELSE 0 END) as errors,
2074
- SUM(t.args_chars) as args_chars,
2075
- SUM(COALESCE(t.result_chars, 0)) as result_chars,
2076
- SUM(COALESCE(m.total_tokens, 0) * 1.0 / t.calls_in_turn) as total_tokens_share,
2077
- SUM(COALESCE(m.output_tokens, 0) * 1.0 / t.calls_in_turn) as output_tokens_share,
2078
- SUM(COALESCE(m.cost_total, 0) / t.calls_in_turn) as cost_share,
2079
- SUM(${unpricedRequestSql("m.")} * 1.0 / t.calls_in_turn) as unpriced_requests_share,
2080
- MAX(t.timestamp) as last_used
2081
- `;
2082
-
2083
- interface ToolAggregateRow {
2084
- tool_name: string;
2085
- model?: string;
2086
- provider?: string;
2087
- calls: number;
2088
- errors: number;
2089
- args_chars: number | null;
2090
- result_chars: number | null;
2091
- total_tokens_share: number | null;
2092
- output_tokens_share: number | null;
2093
- cost_share: number | null;
2094
- unpriced_requests_share: number | null;
2095
- last_used: number;
2096
- }
2097
-
2098
- function rowToToolUsage(row: ToolAggregateRow): ToolUsageStats {
2099
- return {
2100
- tool: row.tool_name,
2101
- calls: row.calls,
2102
- errors: row.errors,
2103
- argsChars: row.args_chars ?? 0,
2104
- resultChars: row.result_chars ?? 0,
2105
- totalTokensShare: row.total_tokens_share ?? 0,
2106
- outputTokensShare: row.output_tokens_share ?? 0,
2107
- costShare: row.cost_share ?? 0,
2108
- unpricedRequestsShare: row.unpriced_requests_share ?? 0,
2109
- lastUsed: row.last_used,
2110
- };
2111
- }
2112
-
2113
- /**
2114
- * Get tool usage aggregated by tool name.
2115
- */
2116
- export function getToolStats(cutoff?: number): ToolUsageStats[] {
2117
- if (!db) return [];
2118
-
2119
- const hasCutoff = cutoff !== undefined && cutoff > 0;
2120
- const stmt = db.prepare(`
2121
- SELECT t.tool_name, ${TOOL_AGGREGATE_COLUMNS}
2122
- FROM tool_calls t
2123
- LEFT JOIN messages m ON m.session_file = t.session_file AND m.entry_id = t.entry_id
2124
- ${hasCutoff ? "WHERE t.timestamp >= ?" : ""}
2125
- GROUP BY t.tool_name
2126
- ORDER BY calls DESC
2127
- `);
2128
-
2129
- const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as ToolAggregateRow[];
2130
- return rows.map(rowToToolUsage);
2131
- }
2132
-
2133
- /**
2134
- * Get tool usage aggregated by (tool, model, provider).
2135
- */
2136
- export function getToolStatsByModel(cutoff?: number): ToolModelStats[] {
2137
- if (!db) return [];
2138
-
2139
- const hasCutoff = cutoff !== undefined && cutoff > 0;
2140
- const stmt = db.prepare(`
2141
- SELECT t.tool_name, t.model, t.provider, ${TOOL_AGGREGATE_COLUMNS}
2142
- FROM tool_calls t
2143
- LEFT JOIN messages m ON m.session_file = t.session_file AND m.entry_id = t.entry_id
2144
- ${hasCutoff ? "WHERE t.timestamp >= ?" : ""}
2145
- GROUP BY t.tool_name, t.model, t.provider
2146
- ORDER BY calls DESC
2147
- `);
2148
-
2149
- const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as ToolAggregateRow[];
2150
- return rows.map(row => ({
2151
- ...rowToToolUsage(row),
2152
- model: row.model ?? "",
2153
- provider: row.provider ?? "",
2154
- }));
2155
- }
2156
-
2157
- /**
2158
- * Get tool-call time series (one point per bucket per tool).
2159
- */
2160
- export function getToolTimeSeries(
2161
- days = 14,
2162
- cutoff?: number | null,
2163
- bucketMs = 24 * 60 * 60 * 1000,
2164
- ): ToolTimeSeriesPoint[] {
2165
- if (!db) return [];
2166
-
2167
- const hasCutoff = cutoff !== null;
2168
- const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
2169
-
2170
- const stmt = db.prepare(`
2171
- SELECT
2172
- (timestamp / ?) * ? as bucket,
2173
- tool_name,
2174
- COUNT(*) as calls,
2175
- SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) as errors
2176
- FROM tool_calls
2177
- ${hasCutoff ? "WHERE timestamp >= ?" : ""}
2178
- GROUP BY bucket, tool_name
2179
- ORDER BY bucket ASC
2180
- `);
2181
-
2182
- const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
2183
- const rows = rowsRaw as Array<{ bucket: number; tool_name: string; calls: number; errors: number }>;
2184
- return rows.map(row => ({
2185
- timestamp: row.bucket,
2186
- tool: row.tool_name,
2187
- calls: row.calls,
2188
- errors: row.errors,
2189
- }));
2190
- }