@oh-my-pi/omp-stats 18.3.5 → 18.4.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 (192) hide show
  1. package/CHANGELOG.md +22 -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 +143 -87
  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/rollup.ts ADDED
@@ -0,0 +1,951 @@
1
+ /**
2
+ * Hourly rollups of `messages` and `tool_calls`, and every range query the
3
+ * dashboard runs over them.
4
+ *
5
+ * The raw tables hold millions of rows; grouping them per request made every
6
+ * range switch cost seconds. Instead, `message_rollup` / `tool_rollup` keep one
7
+ * row per (hour, dimensions) with additive sums, so a 90-day query reads a few
8
+ * thousand rows.
9
+ *
10
+ * Freshness is trigger-driven: any INSERT/UPDATE/DELETE on the raw tables — from
11
+ * this process, a sync worker, or another omp process sharing the database —
12
+ * records the touched hour in `rollup_dirty`. {@link refreshRollups} recomputes
13
+ * dirty hours newest-first in short immediate transactions.
14
+ *
15
+ * Reads never wait for a refresh and are exact: each query unions
16
+ * - clean rollup hours inside the range,
17
+ * - raw rows for the partial hour at the range start, and
18
+ * - raw rows for dirty hours (while few are dirty),
19
+ * so live ingest shows up before its hour is re-rolled. While the initial build
20
+ * (or a mass re-ingest) leaves many hours dirty, reads use the rollup rows as
21
+ * they stand (stale-but-present; not-yet-built hours are missing) and
22
+ * {@link getRollupStatus} reports the backlog so the UI can show progress.
23
+ *
24
+ * The 1h range needs 5-minute buckets, so it aggregates raw rows directly
25
+ * (an hour of data is small and indexed by timestamp).
26
+ */
27
+
28
+ import type { Database } from "bun:sqlite";
29
+ import { currentDb, unpricedRequestSql } from "./db";
30
+ import type {
31
+ AgentType,
32
+ AgentTypeStats,
33
+ AggregatedStats,
34
+ CostTimeSeriesPoint,
35
+ FolderStats,
36
+ ModelPerformancePoint,
37
+ ModelStats,
38
+ ModelTimeSeriesPoint,
39
+ ProviderAggregate,
40
+ ProviderHourlyPoint,
41
+ ProviderTimeSeriesPoint,
42
+ TimeSeriesPoint,
43
+ ToolModelStats,
44
+ ToolTimeSeriesPoint,
45
+ ToolUsageStats,
46
+ } from "./types";
47
+
48
+ const HOUR_MS = 60 * 60 * 1000;
49
+ const DAY_MS = 24 * HOUR_MS;
50
+
51
+ /**
52
+ * Bump when the rollup schema or fact definitions change: the tables are then
53
+ * dropped and every hour is rebuilt from the raw tables.
54
+ */
55
+ const ROLLUP_VERSION = "2";
56
+ const ROLLUP_VERSION_KEY = "rollup_version";
57
+ /** Bump when the dirty-marking triggers change; they are then recreated in place. */
58
+ const TRIGGER_VERSION = "3";
59
+ const TRIGGER_VERSION_KEY = "rollup_triggers_version";
60
+
61
+ /** Above this many dirty hours, reads use stale rollup rows instead of scanning dirty hours raw. */
62
+ const EXACT_DIRTY_LIMIT = 96;
63
+ /** Target wall time of one refresh transaction; batch size adapts to hit it. */
64
+ const REFRESH_TARGET_MS = 40;
65
+ const REFRESH_MAX_BATCH = 64;
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Fact definitions: one SELECT list per table, shared by the rollup writer and
69
+ // the raw read paths so both produce identical columns.
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const MESSAGE_DIMENSIONS = "model, provider, folder, agent_type";
73
+
74
+ /** Additive per-bucket facts over `messages` (columns qualified by `p`). */
75
+ function messageFacts(bucketExpr: string, p: string): string {
76
+ return `
77
+ ${bucketExpr} AS bucket,
78
+ ${p}model AS model, ${p}provider AS provider, ${p}folder AS folder, ${p}agent_type AS agent_type,
79
+ COUNT(*) AS requests,
80
+ SUM(CASE WHEN ${p}stop_reason = 'error' THEN 1 ELSE 0 END) AS failed,
81
+ SUM(${p}input_tokens) AS input_tokens,
82
+ SUM(${p}output_tokens) AS output_tokens,
83
+ SUM(${p}cache_read_tokens) AS cache_read_tokens,
84
+ SUM(${p}cache_write_tokens) AS cache_write_tokens,
85
+ SUM(${p}total_tokens) AS total_tokens,
86
+ TOTAL(${p}premium_requests) AS premium_requests,
87
+ TOTAL(${p}cost_total) AS cost_total,
88
+ TOTAL(${p}cost_input) AS cost_input,
89
+ TOTAL(${p}cost_output) AS cost_output,
90
+ TOTAL(${p}cost_cache_read) AS cost_cache_read,
91
+ TOTAL(${p}cost_cache_write) AS cost_cache_write,
92
+ SUM(${unpricedRequestSql(p)}) AS unpriced,
93
+ TOTAL(CASE WHEN ${p}cost_no_cache_input > 0
94
+ THEN ${p}cost_input + ${p}cost_cache_read + ${p}cost_cache_write ELSE 0 END) AS cached_prompt_cost,
95
+ TOTAL(${p}cost_no_cache_input) AS no_cache_input_cost,
96
+ TOTAL(${p}duration) AS duration_sum,
97
+ COUNT(${p}duration) AS duration_n,
98
+ TOTAL(${p}ttft) AS ttft_sum,
99
+ COUNT(${p}ttft) AS ttft_n,
100
+ TOTAL(CASE WHEN ${p}duration > 0 THEN ${p}output_tokens * 1000.0 / ${p}duration END) AS tps_sum,
101
+ COUNT(CASE WHEN ${p}duration > 0 THEN 1 END) AS tps_n,
102
+ MIN(${p}timestamp) AS first_ts,
103
+ MAX(${p}timestamp) AS last_ts`;
104
+ }
105
+
106
+ const MESSAGE_ROLLUP_COLUMNS = `bucket, ${MESSAGE_DIMENSIONS}, requests, failed, input_tokens, output_tokens,
107
+ cache_read_tokens, cache_write_tokens, total_tokens, premium_requests, cost_total, cost_input, cost_output,
108
+ cost_cache_read, cost_cache_write, unpriced, cached_prompt_cost, no_cache_input_cost, duration_sum, duration_n,
109
+ ttft_sum, ttft_n, tps_sum, tps_n, first_ts, last_ts`;
110
+
111
+ /**
112
+ * Additive per-bucket facts over `tool_calls t LEFT JOIN messages m`. Provider
113
+ * usage comes from the invoking assistant turn divided by `calls_in_turn`, so
114
+ * per-tool token/cost shares stay additive across tools.
115
+ */
116
+ function toolFacts(bucketExpr: string): string {
117
+ return `
118
+ ${bucketExpr} AS bucket,
119
+ t.tool_name AS tool_name, t.model AS model, t.provider AS provider,
120
+ COUNT(*) AS calls,
121
+ SUM(CASE WHEN t.is_error = 1 THEN 1 ELSE 0 END) AS errors,
122
+ SUM(t.args_chars) AS args_chars,
123
+ SUM(COALESCE(t.result_chars, 0)) AS result_chars,
124
+ TOTAL(COALESCE(m.total_tokens, 0) * 1.0 / t.calls_in_turn) AS total_tokens_share,
125
+ TOTAL(COALESCE(m.output_tokens, 0) * 1.0 / t.calls_in_turn) AS output_tokens_share,
126
+ TOTAL(COALESCE(m.cost_total, 0) / t.calls_in_turn) AS cost_share,
127
+ TOTAL(${unpricedRequestSql("m.")} * 1.0 / t.calls_in_turn) AS unpriced_share,
128
+ MAX(t.timestamp) AS last_used`;
129
+ }
130
+
131
+ const TOOL_ROLLUP_COLUMNS = `bucket, tool_name, model, provider, calls, errors, args_chars, result_chars,
132
+ total_tokens_share, output_tokens_share, cost_share, unpriced_share, last_used`;
133
+
134
+ const TOOL_JOIN = "LEFT JOIN messages m ON m.session_file = t.session_file AND m.entry_id = t.entry_id";
135
+
136
+ function hourOf(column: string): string {
137
+ return `((${column}) / ${HOUR_MS}) * ${HOUR_MS}`;
138
+ }
139
+
140
+ /**
141
+ * Trigger statements marking a row's hour and transcript dirty (`row` is
142
+ * `NEW` or `OLD`), without any conflict clause.
143
+ */
144
+ function markDirty(row: "NEW" | "OLD"): string {
145
+ return `INSERT INTO rollup_dirty (bucket)
146
+ SELECT h FROM (SELECT ${hourOf(`${row}.timestamp`)} AS h) WHERE h NOT IN (SELECT bucket FROM rollup_dirty);
147
+ INSERT INTO session_dirty (session_file)
148
+ SELECT ${row}.session_file WHERE ${row}.session_file NOT IN (SELECT session_file FROM session_dirty);`;
149
+ }
150
+
151
+ /** Per-transcript facts over `messages` (qualified by `p`); tool calls counted by subquery. */
152
+ function sessionFacts(p: string): string {
153
+ return `
154
+ ${p}session_file AS session_file,
155
+ COUNT(*) AS requests,
156
+ MIN(${p}timestamp) AS started_at,
157
+ MAX(${p}timestamp + COALESCE(${p}duration, 0)) AS ended_at,
158
+ SUM(${p}total_tokens) AS total_tokens,
159
+ TOTAL(${p}cost_total) AS cost_total,
160
+ SUM(${unpricedRequestSql(p)}) AS unpriced,
161
+ GROUP_CONCAT(DISTINCT ${p}model) AS models,
162
+ (SELECT COUNT(*) FROM tool_calls tc WHERE tc.session_file = ${p}session_file) AS tool_calls`;
163
+ }
164
+
165
+ const SESSION_ROLLUP_COLUMNS =
166
+ "session_file, requests, started_at, ended_at, total_tokens, cost_total, unpriced, models, tool_calls";
167
+ /** Dirty transcripts rolled per dirty hour in one refresh batch. */
168
+ const SESSIONS_PER_HOUR = 16;
169
+ /** Above this many dirty transcripts, session reads use stale rows instead of scanning raw. */
170
+ const EXACT_DIRTY_SESSIONS = 512;
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Schema
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Create (or rebuild after a version bump) the rollup tables and the triggers
178
+ * that mark hours dirty. Called from `initDb` after the raw tables exist.
179
+ */
180
+ export function ensureRollupSchema(database: Database): void {
181
+ const readMeta = (key: string) =>
182
+ (database.prepare("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined)?.value;
183
+ const fresh = readMeta(ROLLUP_VERSION_KEY) !== ROLLUP_VERSION;
184
+ // Steady state touches no schema: DDL on every open would contend with
185
+ // other omp processes writing the same database.
186
+ if (!fresh && readMeta(TRIGGER_VERSION_KEY) === TRIGGER_VERSION) return;
187
+ database
188
+ .transaction(() => {
189
+ if (fresh) {
190
+ database.run("DROP TABLE IF EXISTS message_rollup");
191
+ database.run("DROP TABLE IF EXISTS tool_rollup");
192
+ database.run("DROP TABLE IF EXISTS rollup_dirty");
193
+ database.run("DROP TABLE IF EXISTS session_rollup");
194
+ database.run("DROP TABLE IF EXISTS session_dirty");
195
+ }
196
+ database.run(`
197
+ CREATE TABLE IF NOT EXISTS message_rollup (
198
+ bucket INTEGER NOT NULL,
199
+ model TEXT NOT NULL,
200
+ provider TEXT NOT NULL,
201
+ folder TEXT NOT NULL,
202
+ agent_type TEXT NOT NULL,
203
+ requests INTEGER NOT NULL,
204
+ failed INTEGER NOT NULL,
205
+ input_tokens INTEGER NOT NULL,
206
+ output_tokens INTEGER NOT NULL,
207
+ cache_read_tokens INTEGER NOT NULL,
208
+ cache_write_tokens INTEGER NOT NULL,
209
+ total_tokens INTEGER NOT NULL,
210
+ premium_requests REAL NOT NULL,
211
+ cost_total REAL NOT NULL,
212
+ cost_input REAL NOT NULL,
213
+ cost_output REAL NOT NULL,
214
+ cost_cache_read REAL NOT NULL,
215
+ cost_cache_write REAL NOT NULL,
216
+ unpriced INTEGER NOT NULL,
217
+ cached_prompt_cost REAL NOT NULL,
218
+ no_cache_input_cost REAL NOT NULL,
219
+ duration_sum REAL NOT NULL,
220
+ duration_n INTEGER NOT NULL,
221
+ ttft_sum REAL NOT NULL,
222
+ ttft_n INTEGER NOT NULL,
223
+ tps_sum REAL NOT NULL,
224
+ tps_n INTEGER NOT NULL,
225
+ first_ts INTEGER NOT NULL,
226
+ last_ts INTEGER NOT NULL
227
+ );
228
+ CREATE INDEX IF NOT EXISTS idx_message_rollup_bucket ON message_rollup(bucket);
229
+ CREATE TABLE IF NOT EXISTS tool_rollup (
230
+ bucket INTEGER NOT NULL,
231
+ tool_name TEXT NOT NULL,
232
+ model TEXT NOT NULL,
233
+ provider TEXT NOT NULL,
234
+ calls INTEGER NOT NULL,
235
+ errors INTEGER NOT NULL,
236
+ args_chars INTEGER NOT NULL,
237
+ result_chars INTEGER NOT NULL,
238
+ total_tokens_share REAL NOT NULL,
239
+ output_tokens_share REAL NOT NULL,
240
+ cost_share REAL NOT NULL,
241
+ unpriced_share REAL NOT NULL,
242
+ last_used INTEGER NOT NULL
243
+ );
244
+ CREATE INDEX IF NOT EXISTS idx_tool_rollup_bucket ON tool_rollup(bucket);
245
+ CREATE TABLE IF NOT EXISTS rollup_dirty (bucket INTEGER PRIMARY KEY) WITHOUT ROWID;
246
+ CREATE TABLE IF NOT EXISTS session_rollup (
247
+ session_file TEXT PRIMARY KEY,
248
+ requests INTEGER NOT NULL,
249
+ started_at INTEGER NOT NULL,
250
+ ended_at INTEGER NOT NULL,
251
+ total_tokens INTEGER NOT NULL,
252
+ cost_total REAL NOT NULL,
253
+ unpriced INTEGER NOT NULL,
254
+ models TEXT,
255
+ tool_calls INTEGER NOT NULL
256
+ ) WITHOUT ROWID;
257
+ CREATE TABLE IF NOT EXISTS session_dirty (session_file TEXT PRIMARY KEY) WITHOUT ROWID;
258
+ `);
259
+ // A tool share reads its assistant message, so message changes dirty the
260
+ // hour for both rollups (tool calls share their message's timestamp).
261
+ // Triggers must not use `OR IGNORE`: a trigger statement inherits the
262
+ // outer statement's conflict policy, and the UPSERTs in
263
+ // `insertMessageStats` would turn a duplicate dirty mark into a failed ingest.
264
+ for (const table of ["messages", "tool_calls"]) {
265
+ const triggers = {
266
+ insert: `AFTER INSERT ON ${table} BEGIN ${markDirty("NEW")} END`,
267
+ delete: `AFTER DELETE ON ${table} BEGIN ${markDirty("OLD")} END`,
268
+ update: `AFTER UPDATE ON ${table} BEGIN ${markDirty("OLD")} ${markDirty("NEW")} END`,
269
+ };
270
+ for (const [event, body] of Object.entries(triggers)) {
271
+ database.run(`DROP TRIGGER IF EXISTS rollup_dirty_${table}_${event}`);
272
+ database.run(`CREATE TRIGGER rollup_dirty_${table}_${event} ${body}`);
273
+ }
274
+ }
275
+ database
276
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
277
+ .run(TRIGGER_VERSION_KEY, TRIGGER_VERSION);
278
+ if (fresh) {
279
+ database.run(`
280
+ INSERT OR IGNORE INTO rollup_dirty (bucket)
281
+ SELECT DISTINCT ${hourOf("timestamp")} FROM messages
282
+ UNION SELECT DISTINCT ${hourOf("timestamp")} FROM tool_calls
283
+ `);
284
+ database.run(`
285
+ INSERT OR IGNORE INTO session_dirty (session_file)
286
+ SELECT DISTINCT session_file FROM messages UNION SELECT DISTINCT session_file FROM tool_calls
287
+ `);
288
+ database
289
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
290
+ .run(ROLLUP_VERSION_KEY, ROLLUP_VERSION);
291
+ }
292
+ })
293
+ .immediate();
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Refresh
298
+ // ---------------------------------------------------------------------------
299
+
300
+ /** Backlog of hours and transcripts whose rollups are stale. */
301
+ export interface RollupStatus {
302
+ dirtyHours: number;
303
+ dirtySessions: number;
304
+ }
305
+
306
+ export function getRollupStatus(): RollupStatus {
307
+ const database = currentDb();
308
+ if (!database) return { dirtyHours: 0, dirtySessions: 0 };
309
+ return database
310
+ .prepare(
311
+ "SELECT (SELECT COUNT(*) FROM rollup_dirty) AS dirtyHours, (SELECT COUNT(*) FROM session_dirty) AS dirtySessions",
312
+ )
313
+ .get() as RollupStatus;
314
+ }
315
+
316
+ /**
317
+ * Recompute up to `limit` dirty hours, newest first, in one immediate
318
+ * transaction (so no writer can dirty an hour between its read and its
319
+ * dirty-mark removal). Returns the number of hours refreshed.
320
+ */
321
+ export function refreshRollupBatch(limit: number): number {
322
+ const database = currentDb();
323
+ if (!database) return 0;
324
+ const run = database.transaction(() => {
325
+ const buckets = database.prepare("SELECT bucket FROM rollup_dirty ORDER BY bucket DESC LIMIT ?").all(limit) as {
326
+ bucket: number;
327
+ }[];
328
+ const clearMessages = database.prepare("DELETE FROM message_rollup WHERE bucket = ?");
329
+ const clearTools = database.prepare("DELETE FROM tool_rollup WHERE bucket = ?");
330
+ const fillMessages = database.prepare(`
331
+ INSERT INTO message_rollup (${MESSAGE_ROLLUP_COLUMNS})
332
+ SELECT ${messageFacts("?1", "")}
333
+ FROM messages WHERE timestamp >= ?1 AND timestamp < ?1 + ${HOUR_MS}
334
+ GROUP BY ${MESSAGE_DIMENSIONS}
335
+ `);
336
+ const fillTools = database.prepare(`
337
+ INSERT INTO tool_rollup (${TOOL_ROLLUP_COLUMNS})
338
+ SELECT ${toolFacts("?1")}
339
+ FROM tool_calls t ${TOOL_JOIN}
340
+ WHERE t.timestamp >= ?1 AND t.timestamp < ?1 + ${HOUR_MS}
341
+ GROUP BY t.tool_name, t.model, t.provider
342
+ `);
343
+ const clean = database.prepare("DELETE FROM rollup_dirty WHERE bucket = ?");
344
+ for (const { bucket } of buckets) {
345
+ clearMessages.run(bucket);
346
+ clearTools.run(bucket);
347
+ fillMessages.run(bucket);
348
+ fillTools.run(bucket);
349
+ clean.run(bucket);
350
+ }
351
+
352
+ // Transcripts are cheap (indexed by session_file); roll many per hour-batch.
353
+ const sessions = database
354
+ .prepare("SELECT session_file FROM session_dirty LIMIT ?")
355
+ .all(limit * SESSIONS_PER_HOUR) as { session_file: string }[];
356
+ const clearSession = database.prepare("DELETE FROM session_rollup WHERE session_file = ?");
357
+ const fillSession = database.prepare(`
358
+ INSERT INTO session_rollup (${SESSION_ROLLUP_COLUMNS})
359
+ SELECT ${sessionFacts("m.")}
360
+ FROM messages m WHERE m.session_file = ?1
361
+ GROUP BY m.session_file
362
+ `);
363
+ const cleanSession = database.prepare("DELETE FROM session_dirty WHERE session_file = ?");
364
+ for (const { session_file } of sessions) {
365
+ clearSession.run(session_file);
366
+ fillSession.run(session_file);
367
+ cleanSession.run(session_file);
368
+ }
369
+ return buckets.length + sessions.length;
370
+ });
371
+ return run.immediate();
372
+ }
373
+
374
+ export interface RefreshOptions {
375
+ /** Called after each committed batch with the remaining backlog. */
376
+ onProgress?: (remaining: number) => void;
377
+ /** Stop early (between batches). */
378
+ signal?: AbortSignal;
379
+ }
380
+
381
+ /**
382
+ * Drain the dirty-hour backlog in small transactions, yielding to the event
383
+ * loop between batches so a host process (TUI, dashboard server) stays
384
+ * responsive during the initial build. Batch size adapts so each transaction
385
+ * takes about {@link REFRESH_TARGET_MS}; busy hours shrink it to one.
386
+ */
387
+ export async function refreshRollups(opts?: RefreshOptions): Promise<void> {
388
+ let batch = 4;
389
+ while (!opts?.signal?.aborted) {
390
+ const started = performance.now();
391
+ const done = refreshRollupBatch(batch);
392
+ if (done === 0) return;
393
+ const elapsed = performance.now() - started;
394
+ if (elapsed < REFRESH_TARGET_MS / 2) batch = Math.min(REFRESH_MAX_BATCH, batch * 2);
395
+ else if (elapsed > REFRESH_TARGET_MS * 2) batch = Math.max(1, Math.floor(batch / 2));
396
+ opts?.onProgress?.(getRollupStatus().dirtyHours);
397
+ await Bun.sleep(0);
398
+ }
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // Range sources
403
+ // ---------------------------------------------------------------------------
404
+
405
+ /** A range query's time window. `cutoff` null = all time. */
406
+ export interface RangeWindow {
407
+ cutoff: number | null;
408
+ /** Bucket size for time series (5 minutes, an hour, or a day). */
409
+ bucketMs: number;
410
+ }
411
+
412
+ interface Source {
413
+ sql: string;
414
+ params: number[];
415
+ }
416
+
417
+ function dirtyIsSmall(database: Database): boolean {
418
+ const row = database
419
+ .prepare(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM rollup_dirty LIMIT ${EXACT_DIRTY_LIMIT + 1})`)
420
+ .get() as {
421
+ n: number;
422
+ };
423
+ return row.n <= EXACT_DIRTY_LIMIT;
424
+ }
425
+
426
+ /**
427
+ * Message facts covering `[cutoff, now]`, at hour granularity (5-minute raw
428
+ * buckets when `fine`). See the module docs for the union's parts.
429
+ */
430
+ function messageSource(database: Database, cutoff: number | null, fine: boolean): Source {
431
+ const groupRaw = `GROUP BY 1, ${MESSAGE_DIMENSIONS}`;
432
+ if (fine) {
433
+ return {
434
+ sql: `SELECT ${messageFacts("(timestamp / 300000) * 300000", "")} FROM messages WHERE timestamp >= ? ${groupRaw}`,
435
+ params: [cutoff ?? 0],
436
+ };
437
+ }
438
+ const hi = cutoff === null ? null : Math.ceil(cutoff / HOUR_MS) * HOUR_MS;
439
+ const exact = dirtyIsSmall(database);
440
+ const parts: string[] = [];
441
+ const params: number[] = [];
442
+ parts.push(
443
+ `SELECT ${MESSAGE_ROLLUP_COLUMNS} FROM message_rollup
444
+ WHERE ${exact ? "bucket NOT IN (SELECT bucket FROM rollup_dirty)" : "1"}${hi !== null ? " AND bucket >= ?" : ""}`,
445
+ );
446
+ if (hi !== null) params.push(hi);
447
+ if (cutoff !== null && hi !== null && cutoff < hi) {
448
+ parts.push(
449
+ `SELECT ${messageFacts(hourOf("timestamp"), "")} FROM messages
450
+ WHERE timestamp >= ? AND timestamp < ? ${groupRaw}`,
451
+ );
452
+ params.push(cutoff, hi);
453
+ }
454
+ if (exact) {
455
+ parts.push(
456
+ `SELECT ${messageFacts("d.bucket", "m.")}
457
+ FROM rollup_dirty d JOIN messages m ON m.timestamp >= d.bucket AND m.timestamp < d.bucket + ${HOUR_MS}
458
+ ${hi !== null ? "WHERE d.bucket >= ?" : ""}
459
+ GROUP BY d.bucket, m.model, m.provider, m.folder, m.agent_type`,
460
+ );
461
+ if (hi !== null) params.push(hi);
462
+ }
463
+ return { sql: parts.join(" UNION ALL "), params };
464
+ }
465
+
466
+ /** Tool facts covering `[cutoff, now]`; same union shape as {@link messageSource}. */
467
+ function toolSource(database: Database, cutoff: number | null, fine: boolean): Source {
468
+ const groupRaw = "GROUP BY 1, t.tool_name, t.model, t.provider";
469
+ if (fine) {
470
+ return {
471
+ sql: `SELECT ${toolFacts("(t.timestamp / 300000) * 300000")} FROM tool_calls t ${TOOL_JOIN}
472
+ WHERE t.timestamp >= ? ${groupRaw}`,
473
+ params: [cutoff ?? 0],
474
+ };
475
+ }
476
+ const hi = cutoff === null ? null : Math.ceil(cutoff / HOUR_MS) * HOUR_MS;
477
+ const exact = dirtyIsSmall(database);
478
+ const parts: string[] = [];
479
+ const params: number[] = [];
480
+ parts.push(
481
+ `SELECT ${TOOL_ROLLUP_COLUMNS} FROM tool_rollup
482
+ WHERE ${exact ? "bucket NOT IN (SELECT bucket FROM rollup_dirty)" : "1"}${hi !== null ? " AND bucket >= ?" : ""}`,
483
+ );
484
+ if (hi !== null) params.push(hi);
485
+ if (cutoff !== null && hi !== null && cutoff < hi) {
486
+ parts.push(
487
+ `SELECT ${toolFacts(hourOf("t.timestamp"))} FROM tool_calls t ${TOOL_JOIN}
488
+ WHERE t.timestamp >= ? AND t.timestamp < ? ${groupRaw}`,
489
+ );
490
+ params.push(cutoff, hi);
491
+ }
492
+ if (exact) {
493
+ parts.push(
494
+ `SELECT ${toolFacts("d.bucket")}
495
+ FROM rollup_dirty d JOIN tool_calls t ON t.timestamp >= d.bucket AND t.timestamp < d.bucket + ${HOUR_MS}
496
+ ${TOOL_JOIN}
497
+ ${hi !== null ? "WHERE d.bucket >= ?" : ""}
498
+ GROUP BY d.bucket, t.tool_name, t.model, t.provider`,
499
+ );
500
+ if (hi !== null) params.push(hi);
501
+ }
502
+ return { sql: parts.join(" UNION ALL "), params };
503
+ }
504
+
505
+ /** Run `select … FROM (<facts>) f <tail>` over the message facts for a window. */
506
+ function queryMessages<T>(window: Pick<RangeWindow, "cutoff"> & { bucketMs?: number }, select: string, tail = ""): T[] {
507
+ const database = currentDb();
508
+ if (!database) return [];
509
+ const fine = window.bucketMs !== undefined && window.bucketMs < HOUR_MS;
510
+ const source = messageSource(database, normalizeCutoff(window.cutoff), fine);
511
+ return database.prepare(`SELECT ${select} FROM (${source.sql}) f ${tail}`).all(...source.params) as T[];
512
+ }
513
+
514
+ function queryTools<T>(window: Pick<RangeWindow, "cutoff"> & { bucketMs?: number }, select: string, tail = ""): T[] {
515
+ const database = currentDb();
516
+ if (!database) return [];
517
+ const fine = window.bucketMs !== undefined && window.bucketMs < HOUR_MS;
518
+ const source = toolSource(database, normalizeCutoff(window.cutoff), fine);
519
+ return database.prepare(`SELECT ${select} FROM (${source.sql}) f ${tail}`).all(...source.params) as T[];
520
+ }
521
+
522
+ function normalizeCutoff(cutoff: number | null | undefined): number | null {
523
+ return cutoff === undefined || cutoff === null || cutoff <= 0 ? null : cutoff;
524
+ }
525
+
526
+ function seriesBucket(bucketMs: number): string {
527
+ return `(f.bucket / ${bucketMs}) * ${bucketMs}`;
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // Aggregates
532
+ // ---------------------------------------------------------------------------
533
+
534
+ const AGGREGATE_COLUMNS = `
535
+ SUM(f.requests) AS requests,
536
+ SUM(f.failed) AS failed,
537
+ SUM(f.input_tokens) AS input_tokens,
538
+ SUM(f.output_tokens) AS output_tokens,
539
+ SUM(f.cache_read_tokens) AS cache_read_tokens,
540
+ SUM(f.cache_write_tokens) AS cache_write_tokens,
541
+ TOTAL(f.premium_requests) AS premium_requests,
542
+ TOTAL(f.cost_total) AS cost_total,
543
+ SUM(f.unpriced) AS unpriced,
544
+ TOTAL(f.cached_prompt_cost) AS cached_prompt_cost,
545
+ TOTAL(f.no_cache_input_cost) AS no_cache_input_cost,
546
+ TOTAL(f.duration_sum) / NULLIF(SUM(f.duration_n), 0) AS avg_duration,
547
+ TOTAL(f.ttft_sum) / NULLIF(SUM(f.ttft_n), 0) AS avg_ttft,
548
+ TOTAL(f.tps_sum) / NULLIF(SUM(f.tps_n), 0) AS avg_tps,
549
+ MIN(f.first_ts) AS first_ts,
550
+ MAX(f.last_ts) AS last_ts`;
551
+
552
+ interface AggregateRow {
553
+ requests: number | null;
554
+ failed: number | null;
555
+ input_tokens: number | null;
556
+ output_tokens: number | null;
557
+ cache_read_tokens: number | null;
558
+ cache_write_tokens: number | null;
559
+ premium_requests: number | null;
560
+ cost_total: number | null;
561
+ unpriced: number | null;
562
+ cached_prompt_cost: number | null;
563
+ no_cache_input_cost: number | null;
564
+ avg_duration: number | null;
565
+ avg_ttft: number | null;
566
+ avg_tps: number | null;
567
+ first_ts: number | null;
568
+ last_ts: number | null;
569
+ }
570
+
571
+ function toAggregatedStats(row: AggregateRow | undefined): AggregatedStats {
572
+ const totalRequests = row?.requests ?? 0;
573
+ const failedRequests = row?.failed ?? 0;
574
+ const totalInputTokens = row?.input_tokens ?? 0;
575
+ const totalCacheReadTokens = row?.cache_read_tokens ?? 0;
576
+ const noCacheInputCost = row?.no_cache_input_cost ?? 0;
577
+ const cachedPromptCost = row?.cached_prompt_cost ?? 0;
578
+ return {
579
+ totalRequests,
580
+ successfulRequests: totalRequests - failedRequests,
581
+ failedRequests,
582
+ errorRate: totalRequests > 0 ? failedRequests / totalRequests : 0,
583
+ totalInputTokens,
584
+ totalOutputTokens: row?.output_tokens ?? 0,
585
+ totalCacheReadTokens,
586
+ totalCacheWriteTokens: row?.cache_write_tokens ?? 0,
587
+ cacheRate:
588
+ totalInputTokens + totalCacheReadTokens > 0
589
+ ? totalCacheReadTokens / (totalInputTokens + totalCacheReadTokens)
590
+ : 0,
591
+ cacheSavings: noCacheInputCost > 0 ? (noCacheInputCost - cachedPromptCost) / noCacheInputCost : 0,
592
+ totalCost: row?.cost_total ?? 0,
593
+ unpricedRequests: row?.unpriced ?? 0,
594
+ totalPremiumRequests: row?.premium_requests ?? 0,
595
+ avgDuration: row?.avg_duration ?? null,
596
+ avgTtft: row?.avg_ttft ?? null,
597
+ avgTokensPerSecond: row?.avg_tps ?? null,
598
+ firstTimestamp: row?.first_ts ?? 0,
599
+ lastTimestamp: row?.last_ts ?? 0,
600
+ };
601
+ }
602
+
603
+ /** Overall request/token/cost aggregate since `cutoff` (all time when null). */
604
+ export function getOverallStats(cutoff: number | null = null): AggregatedStats {
605
+ return toAggregatedStats(queryMessages<AggregateRow>({ cutoff }, AGGREGATE_COLUMNS)[0]);
606
+ }
607
+
608
+ /** Aggregates per (model, provider), busiest first. */
609
+ export function getStatsByModel(cutoff: number | null = null): ModelStats[] {
610
+ return queryMessages<AggregateRow & { model: string; provider: string }>(
611
+ { cutoff },
612
+ `f.model AS model, f.provider AS provider, ${AGGREGATE_COLUMNS}`,
613
+ "GROUP BY f.model, f.provider ORDER BY requests DESC",
614
+ ).map(row => ({ model: row.model, provider: row.provider, ...toAggregatedStats(row) }));
615
+ }
616
+
617
+ /** Aggregates per project folder, busiest first; at most `limit` folders. */
618
+ export function getStatsByFolder(cutoff: number | null = null, limit = Number.MAX_SAFE_INTEGER): FolderStats[] {
619
+ return queryMessages<AggregateRow & { folder: string }>(
620
+ { cutoff },
621
+ `f.folder AS folder, ${AGGREGATE_COLUMNS}`,
622
+ `GROUP BY f.folder ORDER BY requests DESC LIMIT ${Math.max(0, Math.floor(limit))}`,
623
+ ).map(row => ({ folder: row.folder, ...toAggregatedStats(row) }));
624
+ }
625
+
626
+ /** Token usage per agent type (main, subagent, advisor). */
627
+ export function getStatsByAgentType(cutoff: number | null = null): AgentTypeStats[] {
628
+ return queryMessages<{
629
+ agent_type: string | null;
630
+ requests: number;
631
+ input_tokens: number | null;
632
+ output_tokens: number | null;
633
+ cache_read_tokens: number | null;
634
+ cache_write_tokens: number | null;
635
+ cost_total: number | null;
636
+ }>(
637
+ { cutoff },
638
+ `f.agent_type AS agent_type, SUM(f.requests) AS requests, SUM(f.input_tokens) AS input_tokens,
639
+ SUM(f.output_tokens) AS output_tokens, SUM(f.cache_read_tokens) AS cache_read_tokens,
640
+ SUM(f.cache_write_tokens) AS cache_write_tokens, TOTAL(f.cost_total) AS cost_total`,
641
+ "GROUP BY f.agent_type",
642
+ ).map(row => ({
643
+ agentType: (row.agent_type as AgentType | null) ?? "main",
644
+ totalRequests: row.requests,
645
+ totalInputTokens: row.input_tokens ?? 0,
646
+ totalOutputTokens: row.output_tokens ?? 0,
647
+ totalCacheReadTokens: row.cache_read_tokens ?? 0,
648
+ totalCacheWriteTokens: row.cache_write_tokens ?? 0,
649
+ totalCost: row.cost_total ?? 0,
650
+ }));
651
+ }
652
+
653
+ /** Requests, errors, tokens and cost per bucket. */
654
+ export function getTimeSeries({ cutoff, bucketMs }: RangeWindow): TimeSeriesPoint[] {
655
+ return queryMessages<{ bucket: number; requests: number; errors: number; tokens: number; cost: number }>(
656
+ { cutoff, bucketMs },
657
+ `${seriesBucket(bucketMs)} AS bucket, SUM(f.requests) AS requests, SUM(f.failed) AS errors,
658
+ SUM(f.total_tokens) AS tokens, TOTAL(f.cost_total) AS cost`,
659
+ "GROUP BY 1 ORDER BY 1",
660
+ ).map(row => ({
661
+ timestamp: row.bucket,
662
+ requests: row.requests,
663
+ errors: row.errors,
664
+ tokens: row.tokens,
665
+ cost: row.cost,
666
+ }));
667
+ }
668
+
669
+ /** Requests per bucket per (model, provider). */
670
+ export function getModelTimeSeries({ cutoff, bucketMs }: RangeWindow): ModelTimeSeriesPoint[] {
671
+ return queryMessages<{ bucket: number; model: string; provider: string; requests: number }>(
672
+ { cutoff, bucketMs },
673
+ `${seriesBucket(bucketMs)} AS bucket, f.model AS model, f.provider AS provider, SUM(f.requests) AS requests`,
674
+ "GROUP BY 1, f.model, f.provider ORDER BY 1",
675
+ ).map(row => ({ timestamp: row.bucket, model: row.model, provider: row.provider, requests: row.requests }));
676
+ }
677
+
678
+ /** Average TTFT and output tokens/s per bucket per (model, provider). */
679
+ export function getModelPerformanceSeries({ cutoff, bucketMs }: RangeWindow): ModelPerformancePoint[] {
680
+ return queryMessages<{
681
+ bucket: number;
682
+ model: string;
683
+ provider: string;
684
+ requests: number;
685
+ avg_ttft: number | null;
686
+ avg_tps: number | null;
687
+ }>(
688
+ { cutoff, bucketMs },
689
+ `${seriesBucket(bucketMs)} AS bucket, f.model AS model, f.provider AS provider, SUM(f.requests) AS requests,
690
+ TOTAL(f.ttft_sum) / NULLIF(SUM(f.ttft_n), 0) AS avg_ttft,
691
+ TOTAL(f.tps_sum) / NULLIF(SUM(f.tps_n), 0) AS avg_tps`,
692
+ "GROUP BY 1, f.model, f.provider ORDER BY 1",
693
+ ).map(row => ({
694
+ timestamp: row.bucket,
695
+ model: row.model,
696
+ provider: row.provider,
697
+ requests: row.requests,
698
+ avgTtft: row.avg_ttft,
699
+ avgTokensPerSecond: row.avg_tps,
700
+ }));
701
+ }
702
+
703
+ /** Daily cost per (model, provider) with the per-component split. */
704
+ export function getCostTimeSeries(cutoff: number | null = null): CostTimeSeriesPoint[] {
705
+ return queryMessages<{
706
+ bucket: number;
707
+ model: string;
708
+ provider: string;
709
+ cost: number;
710
+ unpriced: number;
711
+ cost_input: number;
712
+ cost_output: number;
713
+ cost_cache_read: number;
714
+ cost_cache_write: number;
715
+ requests: number;
716
+ }>(
717
+ { cutoff },
718
+ `${seriesBucket(DAY_MS)} AS bucket, f.model AS model, f.provider AS provider, TOTAL(f.cost_total) AS cost,
719
+ SUM(f.unpriced) AS unpriced, TOTAL(f.cost_input) AS cost_input, TOTAL(f.cost_output) AS cost_output,
720
+ TOTAL(f.cost_cache_read) AS cost_cache_read, TOTAL(f.cost_cache_write) AS cost_cache_write,
721
+ SUM(f.requests) AS requests`,
722
+ "GROUP BY 1, f.model, f.provider ORDER BY 1",
723
+ ).map(row => ({
724
+ timestamp: row.bucket,
725
+ model: row.model,
726
+ provider: row.provider,
727
+ cost: row.cost,
728
+ unpricedRequests: row.unpriced,
729
+ costInput: row.cost_input,
730
+ costOutput: row.cost_output,
731
+ costCacheRead: row.cost_cache_read,
732
+ costCacheWrite: row.cost_cache_write,
733
+ requests: row.requests,
734
+ }));
735
+ }
736
+
737
+ /** Request/token/cost totals per provider, biggest token burn first. */
738
+ export function getStatsByProvider(cutoff: number | null = null): ProviderAggregate[] {
739
+ return queryMessages<{
740
+ provider: string;
741
+ requests: number;
742
+ failed: number;
743
+ models: number;
744
+ input_tokens: number;
745
+ output_tokens: number;
746
+ cache_read_tokens: number;
747
+ cache_write_tokens: number;
748
+ cost_total: number;
749
+ unpriced: number;
750
+ premium_requests: number;
751
+ avg_tps: number | null;
752
+ }>(
753
+ { cutoff },
754
+ `f.provider AS provider, SUM(f.requests) AS requests, SUM(f.failed) AS failed,
755
+ COUNT(DISTINCT f.model) AS models, SUM(f.input_tokens) AS input_tokens, SUM(f.output_tokens) AS output_tokens,
756
+ SUM(f.cache_read_tokens) AS cache_read_tokens, SUM(f.cache_write_tokens) AS cache_write_tokens,
757
+ TOTAL(f.cost_total) AS cost_total, SUM(f.unpriced) AS unpriced, TOTAL(f.premium_requests) AS premium_requests,
758
+ TOTAL(f.tps_sum) / NULLIF(SUM(f.tps_n), 0) AS avg_tps`,
759
+ "GROUP BY f.provider",
760
+ )
761
+ .map(row => ({
762
+ provider: row.provider,
763
+ totalRequests: row.requests,
764
+ failedRequests: row.failed,
765
+ models: row.models,
766
+ totalInputTokens: row.input_tokens,
767
+ totalOutputTokens: row.output_tokens,
768
+ totalCacheReadTokens: row.cache_read_tokens,
769
+ totalCacheWriteTokens: row.cache_write_tokens,
770
+ totalTokens: row.input_tokens + row.output_tokens + row.cache_read_tokens + row.cache_write_tokens,
771
+ totalCost: row.cost_total,
772
+ unpricedRequests: row.unpriced,
773
+ totalPremiumRequests: row.premium_requests,
774
+ avgTokensPerSecond: row.avg_tps,
775
+ }))
776
+ .sort((a, b) => b.totalTokens - a.totalTokens);
777
+ }
778
+
779
+ /**
780
+ * Token burn per provider per local hour of day (0-23). Hours use the
781
+ * server's timezone — a localhost tool, so server and viewer coincide. Rolled
782
+ * hours are attributed by their start, so half-hour UTC offsets shift burn by
783
+ * up to 30 minutes.
784
+ */
785
+ export function getProviderHourlyBurn(cutoff: number | null = null): ProviderHourlyPoint[] {
786
+ return queryMessages<{
787
+ provider: string;
788
+ hour: number;
789
+ total_tokens: number;
790
+ output_tokens: number;
791
+ requests: number;
792
+ }>(
793
+ { cutoff },
794
+ `f.provider AS provider, CAST(strftime('%H', f.bucket / 1000, 'unixepoch', 'localtime') AS INTEGER) AS hour,
795
+ SUM(f.input_tokens + f.output_tokens + f.cache_read_tokens + f.cache_write_tokens) AS total_tokens,
796
+ SUM(f.output_tokens) AS output_tokens, SUM(f.requests) AS requests`,
797
+ "GROUP BY f.provider, hour ORDER BY f.provider, hour",
798
+ ).map(row => ({
799
+ provider: row.provider,
800
+ hour: row.hour,
801
+ totalTokens: row.total_tokens,
802
+ outputTokens: row.output_tokens,
803
+ requests: row.requests,
804
+ }));
805
+ }
806
+
807
+ /** Tokens/cost per bucket per provider. */
808
+ export function getProviderTimeSeries({ cutoff, bucketMs }: RangeWindow): ProviderTimeSeriesPoint[] {
809
+ return queryMessages<{
810
+ bucket: number;
811
+ provider: string;
812
+ total_tokens: number;
813
+ cost: number;
814
+ unpriced: number;
815
+ requests: number;
816
+ }>(
817
+ { cutoff, bucketMs },
818
+ `${seriesBucket(bucketMs)} AS bucket, f.provider AS provider,
819
+ SUM(f.input_tokens + f.output_tokens + f.cache_read_tokens + f.cache_write_tokens) AS total_tokens,
820
+ TOTAL(f.cost_total) AS cost, SUM(f.unpriced) AS unpriced, SUM(f.requests) AS requests`,
821
+ "GROUP BY 1, f.provider ORDER BY 1",
822
+ ).map(row => ({
823
+ timestamp: row.bucket,
824
+ provider: row.provider,
825
+ totalTokens: row.total_tokens,
826
+ cost: row.cost,
827
+ unpricedRequests: row.unpriced,
828
+ requests: row.requests,
829
+ }));
830
+ }
831
+
832
+ // ---------------------------------------------------------------------------
833
+ // Sessions
834
+ // ---------------------------------------------------------------------------
835
+
836
+ /** Per-transcript-file rollup for the Traces session list. */
837
+ export interface SessionRollupRow {
838
+ sessionFile: string;
839
+ requests: number;
840
+ startedAt: number;
841
+ endedAt: number;
842
+ totalTokens: number;
843
+ costTotal: number;
844
+ unpricedRequests: number;
845
+ /** Comma-joined DISTINCT models. */
846
+ models: string | null;
847
+ toolCalls: number;
848
+ }
849
+
850
+ /**
851
+ * One row per synced transcript file (subagents unfolded). Same exactness
852
+ * rules as the range queries: dirty transcripts are aggregated raw while few.
853
+ */
854
+ export function getSessionRollups(): SessionRollupRow[] {
855
+ const database = currentDb();
856
+ if (!database) return [];
857
+ const { n } = database
858
+ .prepare(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM session_dirty LIMIT ${EXACT_DIRTY_SESSIONS + 1})`)
859
+ .get() as { n: number };
860
+ const exact = n <= EXACT_DIRTY_SESSIONS;
861
+ const parts = [
862
+ `SELECT ${SESSION_ROLLUP_COLUMNS} FROM session_rollup
863
+ ${exact ? "WHERE session_file NOT IN (SELECT session_file FROM session_dirty)" : ""}`,
864
+ ];
865
+ if (exact) {
866
+ parts.push(
867
+ `SELECT ${sessionFacts("m.")} FROM session_dirty d JOIN messages m ON m.session_file = d.session_file
868
+ GROUP BY m.session_file`,
869
+ );
870
+ }
871
+ return database
872
+ .prepare(
873
+ `SELECT session_file AS sessionFile, requests, started_at AS startedAt, ended_at AS endedAt,
874
+ total_tokens AS totalTokens, cost_total AS costTotal, unpriced AS unpricedRequests, models,
875
+ tool_calls AS toolCalls
876
+ FROM (${parts.join(" UNION ALL ")})`,
877
+ )
878
+ .all() as SessionRollupRow[];
879
+ }
880
+
881
+ // ---------------------------------------------------------------------------
882
+ // Tools
883
+ // ---------------------------------------------------------------------------
884
+
885
+ const TOOL_AGGREGATE_COLUMNS = `
886
+ SUM(f.calls) AS calls,
887
+ SUM(f.errors) AS errors,
888
+ SUM(f.args_chars) AS args_chars,
889
+ SUM(f.result_chars) AS result_chars,
890
+ TOTAL(f.total_tokens_share) AS total_tokens_share,
891
+ TOTAL(f.output_tokens_share) AS output_tokens_share,
892
+ TOTAL(f.cost_share) AS cost_share,
893
+ TOTAL(f.unpriced_share) AS unpriced_share,
894
+ MAX(f.last_used) AS last_used`;
895
+
896
+ interface ToolAggregateRow {
897
+ tool_name: string;
898
+ model?: string;
899
+ provider?: string;
900
+ calls: number;
901
+ errors: number;
902
+ args_chars: number | null;
903
+ result_chars: number | null;
904
+ total_tokens_share: number;
905
+ output_tokens_share: number;
906
+ cost_share: number;
907
+ unpriced_share: number;
908
+ last_used: number;
909
+ }
910
+
911
+ function toToolUsage(row: ToolAggregateRow): ToolUsageStats {
912
+ return {
913
+ tool: row.tool_name,
914
+ calls: row.calls,
915
+ errors: row.errors,
916
+ argsChars: row.args_chars ?? 0,
917
+ resultChars: row.result_chars ?? 0,
918
+ totalTokensShare: row.total_tokens_share,
919
+ outputTokensShare: row.output_tokens_share,
920
+ costShare: row.cost_share,
921
+ unpricedRequestsShare: row.unpriced_share,
922
+ lastUsed: row.last_used,
923
+ };
924
+ }
925
+
926
+ /** Tool usage per tool name, most-called first. */
927
+ export function getToolStats(cutoff: number | null = null): ToolUsageStats[] {
928
+ return queryTools<ToolAggregateRow>(
929
+ { cutoff },
930
+ `f.tool_name AS tool_name, ${TOOL_AGGREGATE_COLUMNS}`,
931
+ "GROUP BY f.tool_name ORDER BY calls DESC, f.tool_name",
932
+ ).map(toToolUsage);
933
+ }
934
+
935
+ /** Tool usage per (tool, model, provider), most-called first. */
936
+ export function getToolStatsByModel(cutoff: number | null = null): ToolModelStats[] {
937
+ return queryTools<ToolAggregateRow>(
938
+ { cutoff },
939
+ `f.tool_name AS tool_name, f.model AS model, f.provider AS provider, ${TOOL_AGGREGATE_COLUMNS}`,
940
+ "GROUP BY f.tool_name, f.model, f.provider ORDER BY calls DESC, f.tool_name, f.model, f.provider",
941
+ ).map(row => ({ ...toToolUsage(row), model: row.model ?? "", provider: row.provider ?? "" }));
942
+ }
943
+
944
+ /** Calls and errors per bucket per tool. */
945
+ export function getToolTimeSeries({ cutoff, bucketMs }: RangeWindow): ToolTimeSeriesPoint[] {
946
+ return queryTools<{ bucket: number; tool_name: string; calls: number; errors: number }>(
947
+ { cutoff, bucketMs },
948
+ `${seriesBucket(bucketMs)} AS bucket, f.tool_name AS tool_name, SUM(f.calls) AS calls, SUM(f.errors) AS errors`,
949
+ "GROUP BY 1, f.tool_name ORDER BY 1",
950
+ ).map(row => ({ timestamp: row.bucket, tool: row.tool_name, calls: row.calls, errors: row.errors }));
951
+ }