@linxiraos/pi-stats 1.0.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 (126) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +82 -0
  3. package/build.ts +95 -0
  4. package/dist/client/index.css +1 -0
  5. package/dist/client/index.html +24 -0
  6. package/dist/client/index.js +257 -0
  7. package/dist/client/styles.css +1695 -0
  8. package/dist/types/aggregator.d.ts +98 -0
  9. package/dist/types/client/App.d.ts +1 -0
  10. package/dist/types/client/api.d.ts +22 -0
  11. package/dist/types/client/app/AppLayout.d.ts +16 -0
  12. package/dist/types/client/app/NavRail.d.ts +7 -0
  13. package/dist/types/client/app/RangeControl.d.ts +7 -0
  14. package/dist/types/client/app/SyncButton.d.ts +14 -0
  15. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  16. package/dist/types/client/app/TopBar.d.ts +15 -0
  17. package/dist/types/client/app/routes.d.ts +12 -0
  18. package/dist/types/client/components/AgentTokenShare.d.ts +5 -0
  19. package/dist/types/client/components/chart-shared.d.ts +173 -0
  20. package/dist/types/client/components/models-table-shared.d.ts +175 -0
  21. package/dist/types/client/components/range-meta.d.ts +21 -0
  22. package/dist/types/client/data/charts.d.ts +1 -0
  23. package/dist/types/client/data/formatters.d.ts +8 -0
  24. package/dist/types/client/data/useHashRoute.d.ts +8 -0
  25. package/dist/types/client/data/useResource.d.ts +13 -0
  26. package/dist/types/client/data/view-models.d.ts +74 -0
  27. package/dist/types/client/index.d.ts +2 -0
  28. package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
  29. package/dist/types/client/routes/CostsRoute.d.ts +7 -0
  30. package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
  31. package/dist/types/client/routes/GainRoute.d.ts +7 -0
  32. package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
  33. package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
  34. package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
  35. package/dist/types/client/routes/ProvidersRoute.d.ts +7 -0
  36. package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
  37. package/dist/types/client/routes/ToolsRoute.d.ts +7 -0
  38. package/dist/types/client/routes/index.d.ts +10 -0
  39. package/dist/types/client/types.d.ts +63 -0
  40. package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
  41. package/dist/types/client/ui/DataTable.d.ts +17 -0
  42. package/dist/types/client/ui/EmptyState.d.ts +7 -0
  43. package/dist/types/client/ui/ErrorState.d.ts +6 -0
  44. package/dist/types/client/ui/JsonBlock.d.ts +7 -0
  45. package/dist/types/client/ui/MetricCluster.d.ts +5 -0
  46. package/dist/types/client/ui/Panel.d.ts +7 -0
  47. package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
  48. package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
  49. package/dist/types/client/ui/Skeleton.d.ts +8 -0
  50. package/dist/types/client/ui/StatusPill.d.ts +7 -0
  51. package/dist/types/client/ui/index.d.ts +11 -0
  52. package/dist/types/client/useSystemTheme.d.ts +11 -0
  53. package/dist/types/db.d.ts +159 -0
  54. package/dist/types/embedded-client.d.ts +18 -0
  55. package/dist/types/gain-aggregator.d.ts +26 -0
  56. package/dist/types/index.d.ts +7 -0
  57. package/dist/types/parser.d.ts +53 -0
  58. package/dist/types/port-conflict.d.ts +14 -0
  59. package/dist/types/server.d.ts +12 -0
  60. package/dist/types/shared-types.d.ts +403 -0
  61. package/dist/types/sync-worker.d.ts +31 -0
  62. package/dist/types/types.d.ts +164 -0
  63. package/dist/types/usage-windows.d.ts +45 -0
  64. package/dist/types/user-metrics.d.ts +81 -0
  65. package/package.json +94 -0
  66. package/src/aggregator.ts +615 -0
  67. package/src/client/App.tsx +112 -0
  68. package/src/client/api.ts +118 -0
  69. package/src/client/app/AppLayout.tsx +93 -0
  70. package/src/client/app/NavRail.tsx +44 -0
  71. package/src/client/app/RangeControl.tsx +39 -0
  72. package/src/client/app/SyncButton.tsx +75 -0
  73. package/src/client/app/ThemeToggle.tsx +37 -0
  74. package/src/client/app/TopBar.tsx +73 -0
  75. package/src/client/app/routes.ts +86 -0
  76. package/src/client/components/AgentTokenShare.tsx +68 -0
  77. package/src/client/components/chart-shared.tsx +257 -0
  78. package/src/client/components/models-table-shared.tsx +255 -0
  79. package/src/client/components/range-meta.ts +73 -0
  80. package/src/client/css.d.ts +1 -0
  81. package/src/client/data/charts.ts +14 -0
  82. package/src/client/data/formatters.ts +45 -0
  83. package/src/client/data/useHashRoute.ts +88 -0
  84. package/src/client/data/useResource.ts +154 -0
  85. package/src/client/data/view-models.ts +259 -0
  86. package/src/client/index.tsx +10 -0
  87. package/src/client/routes/BehaviorRoute.tsx +623 -0
  88. package/src/client/routes/CostsRoute.tsx +234 -0
  89. package/src/client/routes/ErrorsRoute.tsx +118 -0
  90. package/src/client/routes/GainRoute.tsx +226 -0
  91. package/src/client/routes/ModelsRoute.tsx +430 -0
  92. package/src/client/routes/OverviewRoute.tsx +342 -0
  93. package/src/client/routes/ProjectsRoute.tsx +163 -0
  94. package/src/client/routes/ProvidersRoute.tsx +492 -0
  95. package/src/client/routes/RequestsRoute.tsx +123 -0
  96. package/src/client/routes/ToolsRoute.tsx +463 -0
  97. package/src/client/routes/index.ts +10 -0
  98. package/src/client/styles.css +1384 -0
  99. package/src/client/types.ts +80 -0
  100. package/src/client/ui/AsyncBoundary.tsx +54 -0
  101. package/src/client/ui/DataTable.tsx +122 -0
  102. package/src/client/ui/EmptyState.tsx +16 -0
  103. package/src/client/ui/ErrorState.tsx +25 -0
  104. package/src/client/ui/JsonBlock.tsx +75 -0
  105. package/src/client/ui/MetricCluster.tsx +78 -0
  106. package/src/client/ui/Panel.tsx +24 -0
  107. package/src/client/ui/RequestDrawer.tsx +208 -0
  108. package/src/client/ui/SegmentedControl.tsx +36 -0
  109. package/src/client/ui/Skeleton.tsx +17 -0
  110. package/src/client/ui/StatusPill.tsx +15 -0
  111. package/src/client/ui/index.ts +11 -0
  112. package/src/client/useSystemTheme.ts +87 -0
  113. package/src/db.ts +1702 -0
  114. package/src/embedded-client.generated.txt +0 -0
  115. package/src/embedded-client.ts +26 -0
  116. package/src/gain-aggregator.ts +311 -0
  117. package/src/index.ts +194 -0
  118. package/src/parser.ts +499 -0
  119. package/src/port-conflict.ts +263 -0
  120. package/src/server.ts +396 -0
  121. package/src/shared-types.ts +432 -0
  122. package/src/sync-worker.ts +40 -0
  123. package/src/types.ts +171 -0
  124. package/src/usage-windows.ts +304 -0
  125. package/src/user-metrics.ts +695 -0
  126. package/tailwind.config.js +40 -0
package/src/db.ts ADDED
@@ -0,0 +1,1702 @@
1
+ import { Database } from "bun:sqlite";
2
+ import * as fs from "node:fs/promises";
3
+ import type { Usage } from "@linxiraos/pi-ai";
4
+ import type { GeneratedProvider } from "@linxiraos/pi-catalog/models";
5
+ import { getBundledModel } from "@linxiraos/pi-catalog/models";
6
+ import { getConfigRootDir, getStatsDbPath } from "@linxiraos/pi-utils";
7
+ import { classifyAgentType } from "./parser";
8
+ import type {
9
+ AgentType,
10
+ AgentTypeStats,
11
+ AggregatedStats,
12
+ BehaviorModelStats,
13
+ BehaviorOverallStats,
14
+ BehaviorTimeSeriesPoint,
15
+ CostTimeSeriesPoint,
16
+ FolderStats,
17
+ MessageStats,
18
+ ModelPerformancePoint,
19
+ ModelStats,
20
+ ModelTimeSeriesPoint,
21
+ ProviderAggregate,
22
+ ProviderHourlyPoint,
23
+ ProviderTimeSeriesPoint,
24
+ TimeSeriesPoint,
25
+ ToolCallStats,
26
+ ToolModelStats,
27
+ ToolResultLink,
28
+ ToolTimeSeriesPoint,
29
+ ToolUsageStats,
30
+ UserMessageLink,
31
+ UserMessageStats,
32
+ } from "./types";
33
+
34
+ type ModelCost = { input: number; output: number; cacheRead: number; cacheWrite: number };
35
+ type UsageCost = Usage["cost"];
36
+ type CostTokens = Pick<Usage, "input" | "output" | "cacheRead" | "cacheWrite">;
37
+
38
+ const ZERO_USAGE_COST: UsageCost = {
39
+ input: 0,
40
+ output: 0,
41
+ cacheRead: 0,
42
+ cacheWrite: 0,
43
+ total: 0,
44
+ };
45
+
46
+ interface CostBackfillRow {
47
+ id: number;
48
+ provider: string;
49
+ model: string;
50
+ input_tokens: number;
51
+ output_tokens: number;
52
+ cache_read_tokens: number;
53
+ cache_write_tokens: number;
54
+ }
55
+
56
+ let db: Database | null = null;
57
+
58
+ const BACKFILL_COMPLETE = "complete";
59
+ const BACKFILL_PENDING = "pending";
60
+ const USER_MESSAGES_BACKFILL_KEY = "user_messages_v8";
61
+ const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
62
+ const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
63
+ const AGENT_TYPE_BACKFILL_KEY = "agent_type_v1";
64
+ const FORK_DEDUPE_KEY = "fork_dedupe_v1";
65
+ const TOOL_CALLS_BACKFILL_KEY = "tool_calls_v1";
66
+ function shouldResetBackfill(value: string | undefined): boolean {
67
+ return value !== BACKFILL_COMPLETE && value !== BACKFILL_PENDING;
68
+ }
69
+ /**
70
+ * Initialize the database and create tables.
71
+ */
72
+ export async function initDb(): Promise<Database> {
73
+ if (db) return db;
74
+
75
+ // Ensure directory exists
76
+ await fs.mkdir(getConfigRootDir(), { recursive: true });
77
+
78
+ db = new Database(getStatsDbPath());
79
+ // Install the busy handler BEFORE any lock-taking statement. See
80
+ // https://github.com/can1357/oh-my-pi/issues/2421.
81
+ db.run("PRAGMA busy_timeout = 5000");
82
+ db.run("PRAGMA journal_mode = WAL");
83
+
84
+ // Whether `messages` predates this init — drives the one-time agent_type
85
+ // backfill below, so it must be sampled before CREATE TABLE adds the table.
86
+ const messagesTableExisted =
87
+ db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages'").get() !== undefined;
88
+
89
+ // Create tables
90
+ db.run(`
91
+ CREATE TABLE IF NOT EXISTS messages (
92
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ session_file TEXT NOT NULL,
94
+ entry_id TEXT NOT NULL,
95
+ folder TEXT NOT NULL,
96
+ model TEXT NOT NULL,
97
+ provider TEXT NOT NULL,
98
+ api TEXT NOT NULL,
99
+ timestamp INTEGER NOT NULL,
100
+ duration INTEGER,
101
+ ttft INTEGER,
102
+ stop_reason TEXT NOT NULL,
103
+ error_message TEXT,
104
+ input_tokens INTEGER NOT NULL,
105
+ output_tokens INTEGER NOT NULL,
106
+ cache_read_tokens INTEGER NOT NULL,
107
+ cache_write_tokens INTEGER NOT NULL,
108
+ total_tokens INTEGER NOT NULL,
109
+ premium_requests REAL NOT NULL,
110
+ cost_input REAL NOT NULL,
111
+ cost_output REAL NOT NULL,
112
+ cost_cache_read REAL NOT NULL,
113
+ cost_cache_write REAL NOT NULL,
114
+ cost_total REAL NOT NULL,
115
+ agent_type TEXT NOT NULL DEFAULT 'main',
116
+ UNIQUE(session_file, entry_id)
117
+ );
118
+
119
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
120
+ CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);
121
+ CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
122
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_file);
123
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp_model_provider ON messages(timestamp, model, provider);
124
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp_folder ON messages(timestamp, folder);
125
+ CREATE INDEX IF NOT EXISTS idx_messages_stop_reason_timestamp ON messages(stop_reason, timestamp);
126
+
127
+ CREATE TABLE IF NOT EXISTS file_offsets (
128
+ session_file TEXT PRIMARY KEY,
129
+ offset INTEGER NOT NULL,
130
+ last_modified INTEGER NOT NULL
131
+ );
132
+
133
+ CREATE TABLE IF NOT EXISTS user_messages (
134
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
135
+ session_file TEXT NOT NULL,
136
+ entry_id TEXT NOT NULL,
137
+ folder TEXT NOT NULL,
138
+ timestamp INTEGER NOT NULL,
139
+ model TEXT,
140
+ provider TEXT,
141
+ chars INTEGER NOT NULL,
142
+ words INTEGER NOT NULL,
143
+ yelling INTEGER NOT NULL,
144
+ profanity INTEGER NOT NULL,
145
+ anguish INTEGER NOT NULL,
146
+ negation INTEGER NOT NULL DEFAULT 0,
147
+ repetition INTEGER NOT NULL DEFAULT 0,
148
+ blame INTEGER NOT NULL DEFAULT 0,
149
+ UNIQUE(session_file, entry_id)
150
+ );
151
+
152
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
153
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
154
+
155
+ CREATE TABLE IF NOT EXISTS tool_calls (
156
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
157
+ session_file TEXT NOT NULL,
158
+ entry_id TEXT NOT NULL,
159
+ tool_call_id TEXT NOT NULL,
160
+ folder TEXT NOT NULL,
161
+ tool_name TEXT NOT NULL,
162
+ model TEXT NOT NULL,
163
+ provider TEXT NOT NULL,
164
+ timestamp INTEGER NOT NULL,
165
+ agent_type TEXT NOT NULL DEFAULT 'main',
166
+ calls_in_turn INTEGER NOT NULL DEFAULT 1,
167
+ args_chars INTEGER NOT NULL DEFAULT 0,
168
+ result_chars INTEGER,
169
+ is_error INTEGER,
170
+ UNIQUE(session_file, tool_call_id)
171
+ );
172
+
173
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_timestamp ON tool_calls(timestamp);
174
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_tool_timestamp ON tool_calls(tool_name, timestamp);
175
+
176
+ CREATE TABLE IF NOT EXISTS meta (
177
+ key TEXT PRIMARY KEY,
178
+ value TEXT NOT NULL
179
+ );
180
+ `);
181
+
182
+ const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
183
+ if (!messageColumns.some(column => column.name === "premium_requests")) {
184
+ db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
185
+ }
186
+ db.run("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
187
+ // Token-usage-by-agent: each message is classified main / subagent / advisor
188
+ // from its transcript path. A brand-new table gets the column from CREATE
189
+ // TABLE and the parser labels rows at insert time; a pre-existing table gets
190
+ // the column here (defaulting every prior row to 'main') and enrolls the
191
+ // one-time path-based reclassification, gated by a meta sentinel.
192
+ const hasAgentTypeColumn = messageColumns.some(column => column.name === "agent_type");
193
+ if (!hasAgentTypeColumn) {
194
+ db.run("ALTER TABLE messages ADD COLUMN agent_type TEXT NOT NULL DEFAULT 'main'");
195
+ }
196
+ // For any pre-existing table, enroll the backfill PENDING unless a prior run
197
+ // already settled the sentinel — `OR IGNORE` leaves an existing
198
+ // COMPLETE/PENDING value intact, so an ALTER that committed before its
199
+ // sentinel write (process killed in between) still reclassifies on the next
200
+ // init instead of silently leaving every row as the 'main' default. A
201
+ // brand-new empty table has nothing to reclassify, so it settles COMPLETE.
202
+ db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
203
+ AGENT_TYPE_BACKFILL_KEY,
204
+ messagesTableExisted ? BACKFILL_PENDING : BACKFILL_COMPLETE,
205
+ );
206
+ db.run("CREATE INDEX IF NOT EXISTS idx_messages_timestamp_agent_type ON messages(timestamp, agent_type)");
207
+ // Each behavior-metric bump invalidates previously-ingested rows. We detect
208
+ // the stale schema by column name and drop the table; `IF NOT EXISTS` above
209
+ // already produced the new schema, but we want a clean wipe + re-ingest.
210
+ // `backfillUserMessages` then clears `file_offsets` so the next sync
211
+ // re-parses every session under the current metric definitions.
212
+ // v1 -> v2: yelling sentences replace `caps_words`.
213
+ // v2 -> v3: `drama_runs` folded into a single `anguish` signal that
214
+ // also captures elongated interjections, `dude`, and dot runs,
215
+ // gated on a stripped prose-line budget.
216
+ // v3 -> v4: added `negation`, `repetition`, `blame` frustration signals
217
+ // plus profanity dictionary expansion + word-boundary fix.
218
+ // v4 -> v5: column `yelling_sentences` renamed to `yelling` to match
219
+ // the other single-word signal columns.
220
+ // v5 -> v6: dropped `git` from the profanity word list.
221
+ // v6 -> v7: dropped dot runs from `anguish`, technical-collision and
222
+ // opinion words from the profanity list; gated yelling on
223
+ // multi-word caps and bare `no` on interjection use.
224
+ // v7 -> v8: `no-op` compounds no longer count as negation; recovered
225
+ // measured false negatives: `:(` emoticons -> anguish,
226
+ // `why (would|did) you` -> blame, `makes no sense` -> negation.
227
+ const userMessageColumns = db.prepare("PRAGMA table_info(user_messages)").all() as {
228
+ name: string;
229
+ }[];
230
+ const hasStaleColumn =
231
+ userMessageColumns.length > 0 &&
232
+ (userMessageColumns.some(column => column.name === "caps_words") ||
233
+ userMessageColumns.some(column => column.name === "drama_runs") ||
234
+ userMessageColumns.some(column => column.name === "yelling_sentences"));
235
+ const hasV4Columns = userMessageColumns.some(column => column.name === "negation");
236
+ const hasOldUserMessages = userMessageColumns.length > 0;
237
+ if (hasStaleColumn || (hasOldUserMessages && !hasV4Columns)) {
238
+ db.run("DROP TABLE user_messages");
239
+ db.run(`
240
+ CREATE TABLE user_messages (
241
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
242
+ session_file TEXT NOT NULL,
243
+ entry_id TEXT NOT NULL,
244
+ folder TEXT NOT NULL,
245
+ timestamp INTEGER NOT NULL,
246
+ model TEXT,
247
+ provider TEXT,
248
+ chars INTEGER NOT NULL,
249
+ words INTEGER NOT NULL,
250
+ yelling INTEGER NOT NULL,
251
+ profanity INTEGER NOT NULL,
252
+ anguish INTEGER NOT NULL,
253
+ negation INTEGER NOT NULL DEFAULT 0,
254
+ repetition INTEGER NOT NULL DEFAULT 0,
255
+ blame INTEGER NOT NULL DEFAULT 0,
256
+ UNIQUE(session_file, entry_id)
257
+ );
258
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
259
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
260
+ `);
261
+ }
262
+ backfillUserMessages(db);
263
+ backfillToolCalls(db);
264
+ repairUserMessageLinks(db);
265
+ backfillPriorityPremiumRequests(db);
266
+ backfillAgentType(db);
267
+ backfillMissingCatalogCosts(db);
268
+ backfillForkDuplicates(db);
269
+ return db;
270
+ }
271
+
272
+ function hasBillableCost(cost: ModelCost): boolean {
273
+ return cost.input !== 0 || cost.output !== 0 || cost.cacheRead !== 0 || cost.cacheWrite !== 0;
274
+ }
275
+
276
+ function getBundledModelCost(provider: string, modelId: string): ModelCost | null {
277
+ const model = getBundledModel(provider as GeneratedProvider, modelId);
278
+ return model?.cost ?? null;
279
+ }
280
+
281
+ function getCatalogCost(provider: string, modelId: string): ModelCost | null {
282
+ const primaryCost = getBundledModelCost(provider, modelId);
283
+ if (primaryCost && hasBillableCost(primaryCost)) {
284
+ return primaryCost;
285
+ }
286
+
287
+ if (provider === "openai-codex") {
288
+ const openAICost = getBundledModelCost("openai", modelId);
289
+ if (openAICost && hasBillableCost(openAICost)) {
290
+ return openAICost;
291
+ }
292
+ }
293
+
294
+ return null;
295
+ }
296
+
297
+ function calculateCatalogCost(provider: string, modelId: string, tokens: CostTokens): UsageCost | null {
298
+ const cost = getCatalogCost(provider, modelId);
299
+ if (!cost) return null;
300
+
301
+ const input = (cost.input / 1_000_000) * tokens.input;
302
+ const output = (cost.output / 1_000_000) * tokens.output;
303
+ const cacheRead = (cost.cacheRead / 1_000_000) * tokens.cacheRead;
304
+ const cacheWrite = (cost.cacheWrite / 1_000_000) * tokens.cacheWrite;
305
+
306
+ return {
307
+ input,
308
+ output,
309
+ cacheRead,
310
+ cacheWrite,
311
+ total: input + output + cacheRead + cacheWrite,
312
+ };
313
+ }
314
+
315
+ function resolveStoredCost(stats: MessageStats): UsageCost {
316
+ // `usage.cost` was optional in older session files. Although current
317
+ // MessageStats requires it, parsed JSONL can still carry that legacy shape.
318
+ const storedCost: UsageCost | undefined = stats.usage.cost;
319
+ if (storedCost && storedCost.total !== 0) {
320
+ return storedCost;
321
+ }
322
+
323
+ return calculateCatalogCost(stats.provider, stats.model, stats.usage) ?? storedCost ?? ZERO_USAGE_COST;
324
+ }
325
+
326
+ function backfillMissingCatalogCosts(database: Database): void {
327
+ const rows = database
328
+ .prepare(`
329
+ SELECT id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
330
+ FROM messages
331
+ WHERE cost_total = 0 AND total_tokens > 0
332
+ `)
333
+ .all() as CostBackfillRow[];
334
+
335
+ if (rows.length === 0) return;
336
+
337
+ const update = database.prepare(`
338
+ UPDATE messages
339
+ SET cost_input = ?, cost_output = ?, cost_cache_read = ?, cost_cache_write = ?, cost_total = ?
340
+ WHERE id = ?
341
+ `);
342
+
343
+ const applyBackfill = database.transaction(() => {
344
+ for (const row of rows) {
345
+ const cost = calculateCatalogCost(row.provider, row.model, {
346
+ input: row.input_tokens,
347
+ output: row.output_tokens,
348
+ cacheRead: row.cache_read_tokens,
349
+ cacheWrite: row.cache_write_tokens,
350
+ });
351
+
352
+ if (!cost || cost.total === 0) continue;
353
+
354
+ update.run(cost.input, cost.output, cost.cacheRead, cost.cacheWrite, cost.total, row.id);
355
+ }
356
+ });
357
+
358
+ applyBackfill();
359
+ }
360
+
361
+ /**
362
+ * Get the stored offset for a session file.
363
+ */
364
+ export function getFileOffset(sessionFile: string): { offset: number; lastModified: number } | null {
365
+ if (!db) return null;
366
+
367
+ const stmt = db.prepare("SELECT offset, last_modified FROM file_offsets WHERE session_file = ?");
368
+ const row = stmt.get(sessionFile) as { offset: number; last_modified: number } | undefined;
369
+
370
+ return row ? { offset: row.offset, lastModified: row.last_modified } : null;
371
+ }
372
+
373
+ /**
374
+ * Update the stored offset for a session file.
375
+ */
376
+ export function setFileOffset(sessionFile: string, offset: number, lastModified: number): void {
377
+ if (!db) return;
378
+
379
+ const stmt = db.prepare(`
380
+ INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified)
381
+ VALUES (?, ?, ?)
382
+ `);
383
+ stmt.run(sessionFile, offset, lastModified);
384
+ }
385
+
386
+ /**
387
+ * Insert message stats into the database.
388
+ *
389
+ * Forked / branched sessions (see `SessionManager.fork()` and
390
+ * `createBranchedSession()` in `@linxiraos/zeta`) deep-copy a parent
391
+ * session's entries into a new JSONL — same `entry_id`, `timestamp`, `model`,
392
+ * `provider`, token counts, and `responseId`. The `UNIQUE(session_file,
393
+ * entry_id)` constraint alone keys each row by file, so without the guard
394
+ * below the same provider request would land twice and inflate every
395
+ * aggregate. The `WHERE NOT EXISTS` clause skips inserts whose
396
+ * `(entry_id, timestamp)` already exists under a different `session_file` —
397
+ * first-write-wins across the lineage. Same-file re-syncs still hit the
398
+ * `ON CONFLICT(session_file, entry_id)` upsert below so historical
399
+ * `premium_requests` fix-ups continue to work.
400
+ */
401
+ export function insertMessageStats(stats: MessageStats[]): number {
402
+ if (!db || stats.length === 0) return 0;
403
+
404
+ const stmt = db.prepare(`
405
+ INSERT INTO messages (
406
+ session_file, entry_id, folder, model, provider, api, timestamp,
407
+ duration, ttft, stop_reason, error_message,
408
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
409
+ cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total, agent_type
410
+ )
411
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
412
+ WHERE NOT EXISTS (
413
+ SELECT 1 FROM messages
414
+ WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
415
+ )
416
+ ON CONFLICT(session_file, entry_id) DO UPDATE SET
417
+ premium_requests = excluded.premium_requests
418
+ WHERE messages.premium_requests < excluded.premium_requests
419
+ `);
420
+
421
+ let inserted = 0;
422
+ const insert = db.transaction(() => {
423
+ for (const s of stats) {
424
+ const cost = resolveStoredCost(s);
425
+ const result = stmt.run(
426
+ s.sessionFile,
427
+ s.entryId,
428
+ s.folder,
429
+ s.model,
430
+ s.provider,
431
+ s.api,
432
+ s.timestamp,
433
+ s.duration,
434
+ s.ttft,
435
+ s.stopReason,
436
+ s.errorMessage,
437
+ s.usage.input,
438
+ s.usage.output,
439
+ s.usage.cacheRead,
440
+ s.usage.cacheWrite,
441
+ s.usage.totalTokens,
442
+ s.usage.premiumRequests ?? 0,
443
+ cost.input,
444
+ cost.output,
445
+ cost.cacheRead,
446
+ cost.cacheWrite,
447
+ cost.total,
448
+ s.agentType,
449
+ // `WHERE NOT EXISTS` binds: skip when a different session_file
450
+ // already holds this (entry_id, timestamp).
451
+ s.entryId,
452
+ s.timestamp,
453
+ s.sessionFile,
454
+ );
455
+ if (result.changes > 0) inserted++;
456
+ }
457
+ });
458
+
459
+ insert();
460
+ return inserted;
461
+ }
462
+
463
+ /**
464
+ * Build aggregated stats from query results.
465
+ */
466
+ function buildAggregatedStats(rows: any[]): AggregatedStats {
467
+ if (rows.length === 0) {
468
+ return {
469
+ totalRequests: 0,
470
+ successfulRequests: 0,
471
+ failedRequests: 0,
472
+ errorRate: 0,
473
+ totalInputTokens: 0,
474
+ totalOutputTokens: 0,
475
+ totalCacheReadTokens: 0,
476
+ totalCacheWriteTokens: 0,
477
+ cacheRate: 0,
478
+ totalCost: 0,
479
+ totalPremiumRequests: 0,
480
+ avgDuration: null,
481
+ avgTtft: null,
482
+ avgTokensPerSecond: null,
483
+ firstTimestamp: 0,
484
+ lastTimestamp: 0,
485
+ };
486
+ }
487
+
488
+ const row = rows[0];
489
+ const totalRequests = row.total_requests || 0;
490
+ const failedRequests = row.failed_requests || 0;
491
+ const successfulRequests = totalRequests - failedRequests;
492
+ const totalInputTokens = row.total_input_tokens || 0;
493
+ const totalCacheReadTokens = row.total_cache_read_tokens || 0;
494
+ const totalPremiumRequests = row.total_premium_requests || 0;
495
+
496
+ return {
497
+ totalRequests,
498
+ successfulRequests,
499
+ failedRequests,
500
+ errorRate: totalRequests > 0 ? failedRequests / totalRequests : 0,
501
+ totalInputTokens,
502
+ totalOutputTokens: row.total_output_tokens || 0,
503
+ totalCacheReadTokens,
504
+ totalCacheWriteTokens: row.total_cache_write_tokens || 0,
505
+ cacheRate:
506
+ totalInputTokens + totalCacheReadTokens > 0
507
+ ? totalCacheReadTokens / (totalInputTokens + totalCacheReadTokens)
508
+ : 0,
509
+ totalCost: row.total_cost || 0,
510
+ totalPremiumRequests,
511
+ avgDuration: row.avg_duration,
512
+ avgTtft: row.avg_ttft,
513
+ avgTokensPerSecond: row.avg_tokens_per_second,
514
+ firstTimestamp: row.first_timestamp || 0,
515
+ lastTimestamp: row.last_timestamp || 0,
516
+ };
517
+ }
518
+
519
+ /**
520
+ * Get overall aggregated stats.
521
+ */
522
+ export function getOverallStats(cutoff?: number): AggregatedStats {
523
+ if (!db) return buildAggregatedStats([]);
524
+
525
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
526
+ const stmt = db.prepare(`
527
+ SELECT
528
+ COUNT(*) as total_requests,
529
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
530
+ SUM(input_tokens) as total_input_tokens,
531
+ SUM(output_tokens) as total_output_tokens,
532
+ SUM(cache_read_tokens) as total_cache_read_tokens,
533
+ SUM(cache_write_tokens) as total_cache_write_tokens,
534
+ SUM(premium_requests) as total_premium_requests,
535
+ SUM(cost_total) as total_cost,
536
+ AVG(duration) as avg_duration,
537
+ AVG(ttft) as avg_ttft,
538
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
539
+ MIN(timestamp) as first_timestamp,
540
+ MAX(timestamp) as last_timestamp
541
+ FROM messages
542
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
543
+ `);
544
+
545
+ const rows = hasCutoff ? stmt.all(cutoff) : stmt.all();
546
+ return buildAggregatedStats(rows as any[]);
547
+ }
548
+ /**
549
+ * Get stats grouped by model.
550
+ */
551
+ export function getStatsByModel(cutoff?: number): ModelStats[] {
552
+ if (!db) return [];
553
+
554
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
555
+ const stmt = db.prepare(`
556
+ SELECT
557
+ model,
558
+ provider,
559
+ COUNT(*) as total_requests,
560
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
561
+ SUM(input_tokens) as total_input_tokens,
562
+ SUM(output_tokens) as total_output_tokens,
563
+ SUM(cache_read_tokens) as total_cache_read_tokens,
564
+ SUM(cache_write_tokens) as total_cache_write_tokens,
565
+ SUM(premium_requests) as total_premium_requests,
566
+ SUM(cost_total) as total_cost,
567
+ AVG(duration) as avg_duration,
568
+ AVG(ttft) as avg_ttft,
569
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
570
+ MIN(timestamp) as first_timestamp,
571
+ MAX(timestamp) as last_timestamp
572
+ FROM messages
573
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
574
+ GROUP BY model, provider
575
+ ORDER BY total_requests DESC
576
+ `);
577
+
578
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
579
+ return rows.map(row => ({
580
+ model: row.model,
581
+ provider: row.provider,
582
+ ...buildAggregatedStats([row]),
583
+ }));
584
+ }
585
+
586
+ /**
587
+ * Get stats grouped by folder.
588
+ */
589
+ export function getStatsByFolder(cutoff?: number): FolderStats[] {
590
+ if (!db) return [];
591
+
592
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
593
+ const stmt = db.prepare(`
594
+ SELECT
595
+ folder,
596
+ COUNT(*) as total_requests,
597
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
598
+ SUM(input_tokens) as total_input_tokens,
599
+ SUM(output_tokens) as total_output_tokens,
600
+ SUM(cache_read_tokens) as total_cache_read_tokens,
601
+ SUM(cache_write_tokens) as total_cache_write_tokens,
602
+ SUM(premium_requests) as total_premium_requests,
603
+ SUM(cost_total) as total_cost,
604
+ AVG(duration) as avg_duration,
605
+ AVG(ttft) as avg_ttft,
606
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
607
+ MIN(timestamp) as first_timestamp,
608
+ MAX(timestamp) as last_timestamp
609
+ FROM messages
610
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
611
+ GROUP BY folder
612
+ ORDER BY total_requests DESC
613
+ `);
614
+
615
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
616
+ return rows.map(row => ({
617
+ folder: row.folder,
618
+ ...buildAggregatedStats([row]),
619
+ }));
620
+ }
621
+
622
+ /**
623
+ * Get token usage grouped by agent type (main agent, task subagents, advisor).
624
+ * Token columns are explicit so the dashboard's share denominator matches the
625
+ * counts it renders. Rows missing `agent_type` (defensive) fall back to "main".
626
+ */
627
+ export function getStatsByAgentType(cutoff?: number): AgentTypeStats[] {
628
+ if (!db) return [];
629
+
630
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
631
+ const stmt = db.prepare(`
632
+ SELECT
633
+ agent_type,
634
+ COUNT(*) as total_requests,
635
+ SUM(input_tokens) as total_input_tokens,
636
+ SUM(output_tokens) as total_output_tokens,
637
+ SUM(cache_read_tokens) as total_cache_read_tokens,
638
+ SUM(cache_write_tokens) as total_cache_write_tokens,
639
+ SUM(cost_total) as total_cost
640
+ FROM messages
641
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
642
+ GROUP BY agent_type
643
+ `);
644
+
645
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
646
+ return rows.map(row => ({
647
+ agentType: (row.agent_type as AgentType) ?? "main",
648
+ totalRequests: row.total_requests || 0,
649
+ totalInputTokens: row.total_input_tokens || 0,
650
+ totalOutputTokens: row.total_output_tokens || 0,
651
+ totalCacheReadTokens: row.total_cache_read_tokens || 0,
652
+ totalCacheWriteTokens: row.total_cache_write_tokens || 0,
653
+ totalCost: row.total_cost || 0,
654
+ }));
655
+ }
656
+
657
+ /**
658
+ * Get time series data.
659
+ */
660
+ export function getTimeSeries(hours = 24, cutoff?: number | null, bucketMs = 60 * 60 * 1000): TimeSeriesPoint[] {
661
+ if (!db) return [];
662
+
663
+ const hasCutoff = cutoff !== null;
664
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - hours * 60 * 60 * 1000) : 0;
665
+
666
+ const stmt = db.prepare(`
667
+ SELECT
668
+ (timestamp / ?) * ? as bucket,
669
+ COUNT(*) as requests,
670
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as errors,
671
+ SUM(total_tokens) as tokens,
672
+ SUM(cost_total) as cost
673
+ FROM messages
674
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
675
+ GROUP BY bucket
676
+ ORDER BY bucket ASC
677
+ `);
678
+
679
+ const rows = hasCutoff
680
+ ? (stmt.all(bucketMs, bucketMs, seriesCutoff) as any[])
681
+ : (stmt.all(bucketMs, bucketMs) as any[]);
682
+ return rows.map(row => ({
683
+ timestamp: row.bucket,
684
+ requests: row.requests,
685
+ errors: row.errors,
686
+ tokens: row.tokens,
687
+ cost: row.cost,
688
+ }));
689
+ }
690
+
691
+ /**
692
+ * Get daily performance time series data for the last N days.
693
+ */
694
+ /**
695
+ * Get daily model usage time series data for the last N days.
696
+ */
697
+ export function getModelTimeSeries(
698
+ days = 14,
699
+ cutoff?: number | null,
700
+ bucketMs = 24 * 60 * 60 * 1000,
701
+ ): ModelTimeSeriesPoint[] {
702
+ if (!db) return [];
703
+
704
+ const hasCutoff = cutoff !== null;
705
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
706
+
707
+ const stmt = db.prepare(`
708
+ SELECT
709
+ (timestamp / ?) * ? as bucket,
710
+ model,
711
+ provider,
712
+ COUNT(*) as requests
713
+ FROM messages
714
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
715
+ GROUP BY bucket, model, provider
716
+ ORDER BY bucket ASC
717
+ `);
718
+
719
+ const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
720
+ const rows = rowsRaw as Array<{ bucket: number; model: string; provider: string; requests: number }>;
721
+ return rows.map(row => ({
722
+ timestamp: row.bucket,
723
+ model: row.model,
724
+ provider: row.provider,
725
+ requests: row.requests,
726
+ }));
727
+ }
728
+
729
+ /**
730
+ * Get request/token/cost totals grouped by provider.
731
+ */
732
+ export function getStatsByProvider(cutoff?: number | null): ProviderAggregate[] {
733
+ if (!db) return [];
734
+
735
+ const hasCutoff = cutoff !== undefined && cutoff !== null && cutoff > 0;
736
+ const stmt = db.prepare(`
737
+ SELECT
738
+ provider,
739
+ COUNT(*) as total_requests,
740
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
741
+ COUNT(DISTINCT model) as models,
742
+ SUM(input_tokens) as total_input_tokens,
743
+ SUM(output_tokens) as total_output_tokens,
744
+ SUM(cache_read_tokens) as total_cache_read_tokens,
745
+ SUM(cache_write_tokens) as total_cache_write_tokens,
746
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
747
+ SUM(cost_total) as total_cost,
748
+ SUM(premium_requests) as total_premium_requests,
749
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
750
+ FROM messages
751
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
752
+ GROUP BY provider
753
+ ORDER BY total_tokens DESC
754
+ `);
755
+
756
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as Array<{
757
+ provider: string;
758
+ total_requests: number;
759
+ failed_requests: number;
760
+ models: number;
761
+ total_input_tokens: number | null;
762
+ total_output_tokens: number | null;
763
+ total_cache_read_tokens: number | null;
764
+ total_cache_write_tokens: number | null;
765
+ total_tokens: number | null;
766
+ total_cost: number | null;
767
+ total_premium_requests: number | null;
768
+ avg_tokens_per_second: number | null;
769
+ }>;
770
+ return rows.map(row => ({
771
+ provider: row.provider,
772
+ totalRequests: row.total_requests,
773
+ failedRequests: row.failed_requests,
774
+ models: row.models,
775
+ totalInputTokens: row.total_input_tokens ?? 0,
776
+ totalOutputTokens: row.total_output_tokens ?? 0,
777
+ totalCacheReadTokens: row.total_cache_read_tokens ?? 0,
778
+ totalCacheWriteTokens: row.total_cache_write_tokens ?? 0,
779
+ totalTokens: row.total_tokens ?? 0,
780
+ totalCost: row.total_cost ?? 0,
781
+ totalPremiumRequests: row.total_premium_requests ?? 0,
782
+ avgTokensPerSecond: row.avg_tokens_per_second,
783
+ }));
784
+ }
785
+
786
+ /**
787
+ * Get token burn grouped by provider and local hour of day (0-23).
788
+ * Hours use the server's timezone — the dashboard is a localhost tool, so
789
+ * server-local and viewer-local time coincide.
790
+ */
791
+ export function getProviderHourlyBurn(cutoff?: number | null): ProviderHourlyPoint[] {
792
+ if (!db) return [];
793
+
794
+ const hasCutoff = cutoff !== undefined && cutoff !== null && cutoff > 0;
795
+ const stmt = db.prepare(`
796
+ SELECT
797
+ provider,
798
+ CAST(strftime('%H', timestamp / 1000, 'unixepoch', 'localtime') AS INTEGER) as hour,
799
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
800
+ SUM(output_tokens) as output_tokens,
801
+ COUNT(*) as requests
802
+ FROM messages
803
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
804
+ GROUP BY provider, hour
805
+ ORDER BY provider, hour
806
+ `);
807
+
808
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as Array<{
809
+ provider: string;
810
+ hour: number;
811
+ total_tokens: number | null;
812
+ output_tokens: number | null;
813
+ requests: number;
814
+ }>;
815
+ return rows.map(row => ({
816
+ provider: row.provider,
817
+ hour: row.hour,
818
+ totalTokens: row.total_tokens ?? 0,
819
+ outputTokens: row.output_tokens ?? 0,
820
+ requests: row.requests,
821
+ }));
822
+ }
823
+
824
+ /**
825
+ * Get token/cost time series grouped by provider (bucketed like the model series).
826
+ */
827
+ export function getProviderTimeSeries(
828
+ days = 14,
829
+ cutoff?: number | null,
830
+ bucketMs = 24 * 60 * 60 * 1000,
831
+ ): ProviderTimeSeriesPoint[] {
832
+ if (!db) return [];
833
+
834
+ const hasCutoff = cutoff !== null;
835
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
836
+
837
+ const stmt = db.prepare(`
838
+ SELECT
839
+ (timestamp / ?) * ? as bucket,
840
+ provider,
841
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
842
+ SUM(cost_total) as cost,
843
+ COUNT(*) as requests
844
+ FROM messages
845
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
846
+ GROUP BY bucket, provider
847
+ ORDER BY bucket ASC
848
+ `);
849
+
850
+ const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
851
+ const rows = rowsRaw as Array<{
852
+ bucket: number;
853
+ provider: string;
854
+ total_tokens: number | null;
855
+ cost: number | null;
856
+ requests: number;
857
+ }>;
858
+ return rows.map(row => ({
859
+ timestamp: row.bucket,
860
+ provider: row.provider,
861
+ totalTokens: row.total_tokens ?? 0,
862
+ cost: row.cost ?? 0,
863
+ requests: row.requests,
864
+ }));
865
+ }
866
+
867
+ /**
868
+ * Get daily model performance time series data for the last N days.
869
+ */
870
+ export function getModelPerformanceSeries(
871
+ days = 14,
872
+ cutoff?: number | null,
873
+ bucketMs = 24 * 60 * 60 * 1000,
874
+ ): ModelPerformancePoint[] {
875
+ if (!db) return [];
876
+
877
+ const hasCutoff = cutoff !== null;
878
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
879
+
880
+ const stmt = db.prepare(`
881
+ SELECT
882
+ (timestamp / ?) * ? as bucket,
883
+ model,
884
+ provider,
885
+ COUNT(*) as requests,
886
+ AVG(ttft) as avg_ttft,
887
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
888
+ FROM messages
889
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
890
+ GROUP BY bucket, model, provider
891
+ ORDER BY bucket ASC
892
+ `);
893
+
894
+ const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
895
+ const rows = rowsRaw as Array<{
896
+ bucket: number;
897
+ model: string;
898
+ provider: string;
899
+ requests: number;
900
+ avg_ttft: number | null;
901
+ avg_tokens_per_second: number | null;
902
+ }>;
903
+ return rows.map(row => ({
904
+ timestamp: row.bucket,
905
+ model: row.model,
906
+ provider: row.provider,
907
+ requests: row.requests,
908
+ avgTtft: row.avg_ttft,
909
+ avgTokensPerSecond: row.avg_tokens_per_second,
910
+ }));
911
+ }
912
+
913
+ /**
914
+ * Get total message count.
915
+ */
916
+ export function getMessageCount(): number {
917
+ if (!db) return 0;
918
+ const stmt = db.prepare("SELECT COUNT(*) as count FROM messages");
919
+ const row = stmt.get() as { count: number };
920
+ return row.count;
921
+ }
922
+
923
+ /**
924
+ * Close the database connection.
925
+ */
926
+ export function closeDb(): void {
927
+ if (db) {
928
+ db.close();
929
+ db = null;
930
+ }
931
+ }
932
+
933
+ function rowToMessageStats(row: any): MessageStats {
934
+ return {
935
+ id: row.id,
936
+ sessionFile: row.session_file,
937
+ entryId: row.entry_id,
938
+ folder: row.folder,
939
+ model: row.model,
940
+ provider: row.provider,
941
+ api: row.api,
942
+ timestamp: row.timestamp,
943
+ duration: row.duration,
944
+ ttft: row.ttft,
945
+ stopReason: row.stop_reason as any,
946
+ errorMessage: row.error_message,
947
+ usage: {
948
+ input: row.input_tokens,
949
+ output: row.output_tokens,
950
+ cacheRead: row.cache_read_tokens,
951
+ cacheWrite: row.cache_write_tokens,
952
+ totalTokens: row.total_tokens,
953
+ premiumRequests: row.premium_requests ?? 0,
954
+ cost: {
955
+ input: row.cost_input,
956
+ output: row.cost_output,
957
+ cacheRead: row.cost_cache_read,
958
+ cacheWrite: row.cost_cache_write,
959
+ total: row.cost_total,
960
+ },
961
+ },
962
+ agentType: (row.agent_type as AgentType) ?? "main",
963
+ };
964
+ }
965
+
966
+ export function getRecentRequests(limit = 100): MessageStats[] {
967
+ if (!db) return [];
968
+ const stmt = db.prepare(`
969
+ SELECT * FROM messages
970
+ ORDER BY timestamp DESC
971
+ LIMIT ?
972
+ `);
973
+ return (stmt.all(limit) as any[]).map(rowToMessageStats);
974
+ }
975
+
976
+ export function getRecentErrors(limit = 100, cutoff?: number | null): MessageStats[] {
977
+ if (!db) return [];
978
+ const hasCutoff = cutoff !== undefined && cutoff !== null;
979
+ const stmt = db.prepare(`
980
+ SELECT * FROM messages
981
+ WHERE stop_reason = 'error'
982
+ ${hasCutoff ? "AND timestamp >= ?" : ""}
983
+ ORDER BY timestamp DESC
984
+ LIMIT ?
985
+ `);
986
+ const rows = hasCutoff ? stmt.all(cutoff, limit) : stmt.all(limit);
987
+ return rows.map(rowToMessageStats);
988
+ }
989
+
990
+ export function getMessageById(id: number): MessageStats | null {
991
+ if (!db) return null;
992
+ const stmt = db.prepare("SELECT * FROM messages WHERE id = ?");
993
+ const row = stmt.get(id);
994
+ return row ? rowToMessageStats(row) : null;
995
+ }
996
+
997
+ /**
998
+ * Get daily cost time series data for the last N days, broken down by model.
999
+ */
1000
+ export function getCostTimeSeries(days = 90, cutoff?: number | null): CostTimeSeriesPoint[] {
1001
+ if (!db) return [];
1002
+
1003
+ const hasCutoff = cutoff !== null;
1004
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
1005
+
1006
+ const stmt = db.prepare(`
1007
+ SELECT
1008
+ (timestamp / 86400000) * 86400000 as bucket,
1009
+ model,
1010
+ provider,
1011
+ SUM(cost_total) as cost,
1012
+ SUM(cost_input) as cost_input,
1013
+ SUM(cost_output) as cost_output,
1014
+ SUM(cost_cache_read) as cost_cache_read,
1015
+ SUM(cost_cache_write) as cost_cache_write,
1016
+ COUNT(*) as requests
1017
+ FROM messages
1018
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1019
+ GROUP BY bucket, model, provider
1020
+ ORDER BY bucket ASC
1021
+ `);
1022
+
1023
+ const rows = hasCutoff ? (stmt.all(seriesCutoff) as any[]) : (stmt.all() as any[]);
1024
+ return rows.map(row => ({
1025
+ timestamp: row.bucket,
1026
+ model: row.model,
1027
+ provider: row.provider,
1028
+ cost: row.cost,
1029
+ costInput: row.cost_input,
1030
+ costOutput: row.cost_output,
1031
+ costCacheRead: row.cost_cache_read,
1032
+ costCacheWrite: row.cost_cache_write,
1033
+ requests: row.requests,
1034
+ }));
1035
+ }
1036
+
1037
+ /**
1038
+ * Reset `file_offsets` (and any existing `user_messages` rows) so the next
1039
+ * successful sync re-parses every session and re-derives behavioral metrics.
1040
+ * Run once per metric-definition bump; the meta sentinel is only marked
1041
+ * complete after `syncAllSessions` finishes. Older timestamp sentinel values
1042
+ * are treated as pending so a failed compiled-binary sync cannot permanently
1043
+ * suppress the backfill.
1044
+ *
1045
+ * - v1: initial introduction of `user_messages`.
1046
+ * - v2: yelling-sentence metric replaces caps-word counts; existing rows are
1047
+ * computed under the old definition and must be discarded.
1048
+ * - v3: drama runs collapsed into `anguish` (drama + elongated interjections
1049
+ * + `dude` + dot runs), scored on a stripped prose body and gated on
1050
+ * line count. Existing rows used the narrower definition.
1051
+ * - v4: added `negation` / `repetition` / `blame` signals and fixed a
1052
+ * latent word-boundary bug in the profanity / anguish regexes that had
1053
+ * left those metrics matching nothing in real prose.
1054
+ * - v5: renamed `yelling_sentences` column to `yelling` to match the other
1055
+ * single-word signal columns (profanity, anguish, negation, ...).
1056
+ * - v6: dropped `git` from the profanity word list - it collided with the
1057
+ * version-control tool name, so existing rows over-counted profanity.
1058
+ * - v7: false-positive trim measured against the real corpus: dot runs
1059
+ * (`..`/`...`) no longer count as anguish; profanity list dropped
1060
+ * technical-collision words (`dummy`, `blast`, `knob`, `trash`, `crud`,
1061
+ * `garbage`, ...), opinion/dislike words (`useless`, `awful`, `hate`,
1062
+ * `meh`, ...) and moved `ugh`/`argh`/`grr` interjections to anguish;
1063
+ * yelling now requires multi-word caps (filenames like `AGENTS.md` no
1064
+ * longer fragment into all-caps sentences); bare leading `no` only
1065
+ * counts as negation when used as an interjection, not a determiner.
1066
+ * - v8: `no-op`-style compounds no longer count as corrective negation
1067
+ * (hyphen after bare `no` only counts as a separator when it isn't
1068
+ * gluing a compound word), and three measured false-negative clusters
1069
+ * were recovered: sad emoticons (`:(`) score anguish, `why (would|did)
1070
+ * you` scores blame, `makes (no|zero) sense` scores negation. v7
1071
+ * shipped briefly without these, so any database that completed the v7
1072
+ * backfill needs one more re-derive.
1073
+ *
1074
+ * Existing `messages` rows are unaffected - `INSERT OR IGNORE` keeps them.
1075
+ */
1076
+ function backfillUserMessages(database: Database): void {
1077
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGES_BACKFILL_KEY) as
1078
+ | { value: string }
1079
+ | undefined;
1080
+ if (!shouldResetBackfill(row?.value)) return;
1081
+
1082
+ database.run("DELETE FROM user_messages");
1083
+ database.run("DELETE FROM file_offsets");
1084
+ database
1085
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1086
+ .run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING);
1087
+ }
1088
+
1089
+ /**
1090
+ * One-shot wipe of `tool_calls` + `file_offsets` when the `tool_calls` table
1091
+ * is introduced (or its schema version bumps), so the next sync re-parses
1092
+ * every session and ingests historical tool calls. `messages` and
1093
+ * `user_messages` re-inserts are idempotent, so the offset reset is safe.
1094
+ * Same sentinel protocol as {@link backfillUserMessages}: the PENDING value
1095
+ * written here prevents re-wiping on subsequent inits.
1096
+ */
1097
+ function backfillToolCalls(database: Database): void {
1098
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(TOOL_CALLS_BACKFILL_KEY) as
1099
+ | { value: string }
1100
+ | undefined;
1101
+ if (!shouldResetBackfill(row?.value)) return;
1102
+
1103
+ database.run("DELETE FROM tool_calls");
1104
+ database.run("DELETE FROM file_offsets");
1105
+ database
1106
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1107
+ .run(TOOL_CALLS_BACKFILL_KEY, BACKFILL_PENDING);
1108
+ }
1109
+
1110
+ /**
1111
+ * Reclassify pre-existing `messages` rows by agent type once, after the
1112
+ * `agent_type` column is added to an older database (every prior row defaulted
1113
+ * to 'main' on the ALTER). Classification is purely path-based — derived from
1114
+ * the stored `session_file` — so no session re-parse is needed. Idempotent and
1115
+ * crash-safe: enrolled (PENDING) only at migration time in {@link initDb} and
1116
+ * marked COMPLETE inside the same transaction that applies the updates, so an
1117
+ * interrupted run rolls back and retries on the next init.
1118
+ */
1119
+ function backfillAgentType(database: Database): void {
1120
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(AGENT_TYPE_BACKFILL_KEY) as
1121
+ | { value: string }
1122
+ | undefined;
1123
+ if (row?.value !== BACKFILL_PENDING) return;
1124
+
1125
+ const sessionFiles = database.prepare("SELECT DISTINCT session_file FROM messages").all() as {
1126
+ session_file: string;
1127
+ }[];
1128
+ const update = database.prepare("UPDATE messages SET agent_type = ? WHERE session_file = ?");
1129
+ const markComplete = database.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
1130
+ const apply = database.transaction(() => {
1131
+ for (const { session_file } of sessionFiles) {
1132
+ const agentType = classifyAgentType(session_file);
1133
+ // Rows already default to 'main'; only the nested transcripts move.
1134
+ if (agentType !== "main") update.run(agentType, session_file);
1135
+ }
1136
+ markComplete.run(AGENT_TYPE_BACKFILL_KEY, BACKFILL_COMPLETE);
1137
+ });
1138
+ apply();
1139
+ }
1140
+
1141
+ /**
1142
+ * One-shot collapse of forked-session duplicates that landed under the old
1143
+ * `UNIQUE(session_file, entry_id)`-only invariant. `SessionManager.fork()`
1144
+ * and `createBranchedSession()` deep-copy a parent's entries into the new
1145
+ * JSONL — same `entry_id`, `timestamp`, `model`, `responseId`, token counts,
1146
+ * cost — and the previous insert path counted both files toward request /
1147
+ * token / cost totals. The migration keeps the lowest-`id` row per
1148
+ * `(entry_id, timestamp)` group (almost always the parent — sessions are
1149
+ * filename-timestamped and sync processes them in name order, so the
1150
+ * originating file lands first) and drops every other copy. Same fix on
1151
+ * `user_messages` since forks copy user entries too. Idempotent and
1152
+ * crash-safe: enrolled at module-load via the `meta` sentinel, marked
1153
+ * COMPLETE inside the same transaction so an aborted run rolls back and
1154
+ * retries on the next init.
1155
+ */
1156
+ function backfillForkDuplicates(database: Database): void {
1157
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(FORK_DEDUPE_KEY) as
1158
+ | { value: string }
1159
+ | undefined;
1160
+ if (row?.value === BACKFILL_COMPLETE) return;
1161
+
1162
+ const markComplete = database.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
1163
+ const apply = database.transaction(() => {
1164
+ database.run(`
1165
+ DELETE FROM messages
1166
+ WHERE id NOT IN (
1167
+ SELECT MIN(id) FROM messages GROUP BY entry_id, timestamp
1168
+ )
1169
+ `);
1170
+ database.run(`
1171
+ DELETE FROM user_messages
1172
+ WHERE id NOT IN (
1173
+ SELECT MIN(id) FROM user_messages GROUP BY entry_id, timestamp
1174
+ )
1175
+ `);
1176
+ markComplete.run(FORK_DEDUPE_KEY, BACKFILL_COMPLETE);
1177
+ });
1178
+ apply();
1179
+ }
1180
+
1181
+ /**
1182
+ * One-shot wipe of `file_offsets` to force `parseSessionFile` to re-parse
1183
+ * every session from byte zero. We don't touch `user_messages`; the parser
1184
+ * now emits a `UserMessageLink` for every assistant->parent pair, and the
1185
+ * guarded `updateUserMessageLinks` UPDATE fixes any row whose `model` was
1186
+ * left NULL by the old in-pass-only linking logic. Idempotent: gated by a
1187
+ * sentinel row in `meta`.
1188
+ */
1189
+ function repairUserMessageLinks(database: Database): void {
1190
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGE_LINKS_REPAIR_KEY) as
1191
+ | { value: string }
1192
+ | undefined;
1193
+ if (!shouldResetBackfill(row?.value)) return;
1194
+
1195
+ database.run("DELETE FROM file_offsets");
1196
+ database
1197
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1198
+ .run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING);
1199
+ }
1200
+
1201
+ /**
1202
+ * One-shot wipe of `file_offsets` so the next sync re-parses every session
1203
+ * and re-derives `premium_requests` from recorded `service_tier_change`
1204
+ * entries. Earlier ingestions captured priority OpenAI traffic with
1205
+ * `premium_requests = 0` because the AI layer only set the field for GitHub
1206
+ * Copilot traffic. The parser now folds priority requests into the same
1207
+ * counter; combined with the UPSERT in `insertMessageStats`, a single sync
1208
+ * pass brings the messages table up to date without touching any other
1209
+ * column. Idempotent: gated by a sentinel row in `meta`.
1210
+ */
1211
+ function backfillPriorityPremiumRequests(database: Database): void {
1212
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY) as
1213
+ | { value: string }
1214
+ | undefined;
1215
+ if (!shouldResetBackfill(row?.value)) return;
1216
+
1217
+ database.run("DELETE FROM file_offsets");
1218
+ database
1219
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1220
+ .run(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY, BACKFILL_PENDING);
1221
+ }
1222
+
1223
+ /**
1224
+ * Settle every full-session backfill after a successful sync pass.
1225
+ */
1226
+ export function markSessionBackfillsComplete(): void {
1227
+ if (!db) return;
1228
+ const markComplete = db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
1229
+ const apply = db.transaction(() => {
1230
+ for (const key of [
1231
+ USER_MESSAGES_BACKFILL_KEY,
1232
+ TOOL_CALLS_BACKFILL_KEY,
1233
+ USER_MESSAGE_LINKS_REPAIR_KEY,
1234
+ PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY,
1235
+ ]) {
1236
+ markComplete.run(key, BACKFILL_COMPLETE);
1237
+ }
1238
+ });
1239
+ apply();
1240
+ }
1241
+
1242
+ /**
1243
+ * Insert user-message stats. Idempotent via UNIQUE(session_file, entry_id).
1244
+ * The `WHERE NOT EXISTS` clause matches {@link insertMessageStats}: forks
1245
+ * copy user entries verbatim into the child JSONL, so the same
1246
+ * `(entry_id, timestamp)` must not land twice across different session files.
1247
+ */
1248
+ export function insertUserMessageStats(stats: UserMessageStats[]): number {
1249
+ if (!db || stats.length === 0) return 0;
1250
+
1251
+ const stmt = db.prepare(`
1252
+ INSERT OR IGNORE INTO user_messages (
1253
+ session_file, entry_id, folder, timestamp, model, provider,
1254
+ chars, words, yelling, profanity, anguish,
1255
+ negation, repetition, blame
1256
+ )
1257
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
1258
+ WHERE NOT EXISTS (
1259
+ SELECT 1 FROM user_messages
1260
+ WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
1261
+ )
1262
+ `);
1263
+
1264
+ let inserted = 0;
1265
+ const insert = db.transaction(() => {
1266
+ for (const s of stats) {
1267
+ const result = stmt.run(
1268
+ s.sessionFile,
1269
+ s.entryId,
1270
+ s.folder,
1271
+ s.timestamp,
1272
+ s.model,
1273
+ s.provider,
1274
+ s.chars,
1275
+ s.words,
1276
+ s.yelling,
1277
+ s.profanity,
1278
+ s.anguish,
1279
+ s.negation,
1280
+ s.repetition,
1281
+ s.blame,
1282
+ // `WHERE NOT EXISTS` binds: skip when a different session_file
1283
+ // already holds this (entry_id, timestamp).
1284
+ s.entryId,
1285
+ s.timestamp,
1286
+ s.sessionFile,
1287
+ );
1288
+ if (result.changes > 0) inserted++;
1289
+ }
1290
+ });
1291
+ insert();
1292
+ return inserted;
1293
+ }
1294
+
1295
+ /**
1296
+ * Backfill the responding `model`/`provider` on user-message rows that were
1297
+ * persisted before their assistant reply was parsed (a side effect of
1298
+ * incremental `fromOffset` syncing: the `userByEntryId` map in
1299
+ * `parseSessionFile` only spans a single pass). Each row is updated at most
1300
+ * once because the `model IS NULL` guard short-circuits subsequent passes.
1301
+ *
1302
+ * Returns the number of rows actually updated.
1303
+ */
1304
+ export function updateUserMessageLinks(links: UserMessageLink[]): number {
1305
+ if (!db || links.length === 0) return 0;
1306
+
1307
+ const stmt = db.prepare(`
1308
+ UPDATE user_messages
1309
+ SET model = ?, provider = ?
1310
+ WHERE session_file = ? AND entry_id = ? AND model IS NULL
1311
+ `);
1312
+
1313
+ let updated = 0;
1314
+ const apply = db.transaction(() => {
1315
+ for (const link of links) {
1316
+ const result = stmt.run(link.model, link.provider, link.sessionFile, link.entryId);
1317
+ if (result.changes > 0) updated++;
1318
+ }
1319
+ });
1320
+ apply();
1321
+ return updated;
1322
+ }
1323
+
1324
+ const UNKNOWN_MODEL = "unknown";
1325
+
1326
+ interface BehaviorSeriesRow {
1327
+ bucket: number;
1328
+ model: string;
1329
+ provider: string;
1330
+ messages: number;
1331
+ yelling: number | null;
1332
+ profanity: number | null;
1333
+ anguish: number | null;
1334
+ negation: number | null;
1335
+ repetition: number | null;
1336
+ blame: number | null;
1337
+ chars: number | null;
1338
+ }
1339
+
1340
+ /**
1341
+ * Daily behavioral time series, grouped by responding model+provider.
1342
+ */
1343
+ export function getBehaviorTimeSeries(cutoff?: number | null): BehaviorTimeSeriesPoint[] {
1344
+ if (!db) return [];
1345
+ const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
1346
+ const stmt = db.prepare(`
1347
+ SELECT
1348
+ (timestamp / 86400000) * 86400000 as bucket,
1349
+ COALESCE(model, ?) as model,
1350
+ COALESCE(provider, ?) as provider,
1351
+ COUNT(*) as messages,
1352
+ SUM(yelling) as yelling,
1353
+ SUM(profanity) as profanity,
1354
+ SUM(anguish) as anguish,
1355
+ SUM(negation) as negation,
1356
+ SUM(repetition) as repetition,
1357
+ SUM(blame) as blame,
1358
+ SUM(chars) as chars
1359
+ FROM user_messages
1360
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1361
+ GROUP BY bucket, model, provider
1362
+ ORDER BY bucket ASC
1363
+ `);
1364
+ const rows = (
1365
+ hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
1366
+ ) as BehaviorSeriesRow[];
1367
+ return rows.map(row => ({
1368
+ timestamp: row.bucket,
1369
+ model: row.model,
1370
+ provider: row.provider,
1371
+ messages: row.messages,
1372
+ yelling: row.yelling ?? 0,
1373
+ profanity: row.profanity ?? 0,
1374
+ anguish: row.anguish ?? 0,
1375
+ negation: row.negation ?? 0,
1376
+ repetition: row.repetition ?? 0,
1377
+ blame: row.blame ?? 0,
1378
+ chars: row.chars ?? 0,
1379
+ }));
1380
+ }
1381
+
1382
+ interface BehaviorOverallRow {
1383
+ total_messages: number;
1384
+ total_yelling: number | null;
1385
+ total_profanity: number | null;
1386
+ total_anguish: number | null;
1387
+ total_negation: number | null;
1388
+ total_repetition: number | null;
1389
+ total_blame: number | null;
1390
+ total_chars: number | null;
1391
+ first_timestamp: number | null;
1392
+ last_timestamp: number | null;
1393
+ }
1394
+
1395
+ /**
1396
+ * Overall behavioral totals across the cutoff window.
1397
+ */
1398
+ export function getBehaviorOverall(cutoff?: number | null): BehaviorOverallStats {
1399
+ const empty: BehaviorOverallStats = {
1400
+ totalMessages: 0,
1401
+ totalYelling: 0,
1402
+ totalProfanity: 0,
1403
+ totalAnguish: 0,
1404
+ totalNegation: 0,
1405
+ totalRepetition: 0,
1406
+ totalBlame: 0,
1407
+ totalChars: 0,
1408
+ firstTimestamp: 0,
1409
+ lastTimestamp: 0,
1410
+ };
1411
+ if (!db) return empty;
1412
+ const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
1413
+ const stmt = db.prepare(`
1414
+ SELECT
1415
+ COUNT(*) as total_messages,
1416
+ SUM(yelling) as total_yelling,
1417
+ SUM(profanity) as total_profanity,
1418
+ SUM(anguish) as total_anguish,
1419
+ SUM(negation) as total_negation,
1420
+ SUM(repetition) as total_repetition,
1421
+ SUM(blame) as total_blame,
1422
+ SUM(chars) as total_chars,
1423
+ MIN(timestamp) as first_timestamp,
1424
+ MAX(timestamp) as last_timestamp
1425
+ FROM user_messages
1426
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1427
+ `);
1428
+ const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as BehaviorOverallRow | undefined;
1429
+ if (!row?.total_messages) return empty;
1430
+ return {
1431
+ totalMessages: row.total_messages,
1432
+ totalYelling: row.total_yelling ?? 0,
1433
+ totalProfanity: row.total_profanity ?? 0,
1434
+ totalAnguish: row.total_anguish ?? 0,
1435
+ totalNegation: row.total_negation ?? 0,
1436
+ totalRepetition: row.total_repetition ?? 0,
1437
+ totalBlame: row.total_blame ?? 0,
1438
+ totalChars: row.total_chars ?? 0,
1439
+ firstTimestamp: row.first_timestamp ?? 0,
1440
+ lastTimestamp: row.last_timestamp ?? 0,
1441
+ };
1442
+ }
1443
+
1444
+ interface BehaviorByModelRow {
1445
+ model: string;
1446
+ provider: string;
1447
+ total_messages: number;
1448
+ total_yelling: number | null;
1449
+ total_profanity: number | null;
1450
+ total_anguish: number | null;
1451
+ total_negation: number | null;
1452
+ total_repetition: number | null;
1453
+ total_blame: number | null;
1454
+ total_chars: number | null;
1455
+ last_timestamp: number | null;
1456
+ }
1457
+
1458
+ /**
1459
+ * Per-model behavioral totals over the cutoff window. "Unknown" represents
1460
+ * user messages that never received an assistant reply.
1461
+ */
1462
+ export function getBehaviorByModel(cutoff?: number | null): BehaviorModelStats[] {
1463
+ if (!db) return [];
1464
+ const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
1465
+ const stmt = db.prepare(`
1466
+ SELECT
1467
+ COALESCE(model, ?) as model,
1468
+ COALESCE(provider, ?) as provider,
1469
+ COUNT(*) as total_messages,
1470
+ SUM(yelling) as total_yelling,
1471
+ SUM(profanity) as total_profanity,
1472
+ SUM(anguish) as total_anguish,
1473
+ SUM(negation) as total_negation,
1474
+ SUM(repetition) as total_repetition,
1475
+ SUM(blame) as total_blame,
1476
+ SUM(chars) as total_chars,
1477
+ MAX(timestamp) as last_timestamp
1478
+ FROM user_messages
1479
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1480
+ GROUP BY model, provider
1481
+ ORDER BY total_messages DESC
1482
+ `);
1483
+ const rows = (
1484
+ hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
1485
+ ) as BehaviorByModelRow[];
1486
+ return rows.map(row => ({
1487
+ model: row.model,
1488
+ provider: row.provider,
1489
+ totalMessages: row.total_messages,
1490
+ totalYelling: row.total_yelling ?? 0,
1491
+ totalProfanity: row.total_profanity ?? 0,
1492
+ totalAnguish: row.total_anguish ?? 0,
1493
+ totalNegation: row.total_negation ?? 0,
1494
+ totalRepetition: row.total_repetition ?? 0,
1495
+ totalBlame: row.total_blame ?? 0,
1496
+ totalChars: row.total_chars ?? 0,
1497
+ lastTimestamp: row.last_timestamp ?? 0,
1498
+ }));
1499
+ }
1500
+
1501
+ /**
1502
+ * Insert tool-call rows. Idempotent via UNIQUE(session_file, tool_call_id);
1503
+ * the `WHERE NOT EXISTS` guard mirrors {@link insertMessageStats}: forked
1504
+ * sessions deep-copy assistant entries (same `entry_id`, `timestamp`, and
1505
+ * tool-call ids under a new file), so first-write-wins across the lineage
1506
+ * keeps aggregates from double counting. Keyed on the assistant entry
1507
+ * identity, not the call id alone — provider call ids are not a global
1508
+ * namespace across unrelated sessions.
1509
+ */
1510
+ export function insertToolCalls(calls: ToolCallStats[]): number {
1511
+ if (!db || calls.length === 0) return 0;
1512
+
1513
+ const stmt = db.prepare(`
1514
+ INSERT OR IGNORE INTO tool_calls (
1515
+ session_file, entry_id, tool_call_id, folder, tool_name,
1516
+ model, provider, timestamp, agent_type, calls_in_turn, args_chars
1517
+ )
1518
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
1519
+ WHERE NOT EXISTS (
1520
+ SELECT 1 FROM tool_calls
1521
+ WHERE entry_id = ? AND timestamp = ? AND tool_call_id = ? AND session_file <> ?
1522
+ )
1523
+ `);
1524
+
1525
+ let inserted = 0;
1526
+ const insert = db.transaction(() => {
1527
+ for (const c of calls) {
1528
+ const result = stmt.run(
1529
+ c.sessionFile,
1530
+ c.entryId,
1531
+ c.toolCallId,
1532
+ c.folder,
1533
+ c.toolName,
1534
+ c.model,
1535
+ c.provider,
1536
+ c.timestamp,
1537
+ c.agentType,
1538
+ c.callsInTurn,
1539
+ c.argsChars,
1540
+ // `WHERE NOT EXISTS` binds: skip when a different session_file
1541
+ // already holds this (entry_id, timestamp, tool_call_id).
1542
+ c.entryId,
1543
+ c.timestamp,
1544
+ c.toolCallId,
1545
+ c.sessionFile,
1546
+ );
1547
+ if (result.changes > 0) inserted++;
1548
+ }
1549
+ });
1550
+ insert();
1551
+ return inserted;
1552
+ }
1553
+
1554
+ /**
1555
+ * Attach result size / error flag to persisted tool-call rows. Results can
1556
+ * land in a later incremental sync pass than the call that produced them, so
1557
+ * this is an UPDATE keyed by (session_file, tool_call_id). The `IS NULL`
1558
+ * guard makes re-syncs idempotent; rows skipped by the fork guard simply
1559
+ * never match.
1560
+ */
1561
+ export function updateToolResults(links: ToolResultLink[]): number {
1562
+ if (!db || links.length === 0) return 0;
1563
+
1564
+ const stmt = db.prepare(`
1565
+ UPDATE tool_calls
1566
+ SET result_chars = ?, is_error = ?
1567
+ WHERE session_file = ? AND tool_call_id = ? AND result_chars IS NULL
1568
+ `);
1569
+
1570
+ let updated = 0;
1571
+ const apply = db.transaction(() => {
1572
+ for (const link of links) {
1573
+ const result = stmt.run(link.resultChars, link.isError ? 1 : 0, link.sessionFile, link.toolCallId);
1574
+ updated += result.changes;
1575
+ }
1576
+ });
1577
+ apply();
1578
+ return updated;
1579
+ }
1580
+
1581
+ /**
1582
+ * Shared SELECT list for tool aggregates. Real provider usage comes from the
1583
+ * invoking assistant turn (`messages` join) divided by `calls_in_turn`, so
1584
+ * per-tool token/cost shares stay additive across tools.
1585
+ */
1586
+ const TOOL_AGGREGATE_COLUMNS = `
1587
+ COUNT(*) as calls,
1588
+ SUM(CASE WHEN t.is_error = 1 THEN 1 ELSE 0 END) as errors,
1589
+ SUM(t.args_chars) as args_chars,
1590
+ SUM(COALESCE(t.result_chars, 0)) as result_chars,
1591
+ SUM(COALESCE(m.total_tokens, 0) * 1.0 / t.calls_in_turn) as total_tokens_share,
1592
+ SUM(COALESCE(m.output_tokens, 0) * 1.0 / t.calls_in_turn) as output_tokens_share,
1593
+ SUM(COALESCE(m.cost_total, 0) / t.calls_in_turn) as cost_share,
1594
+ MAX(t.timestamp) as last_used
1595
+ `;
1596
+
1597
+ interface ToolAggregateRow {
1598
+ tool_name: string;
1599
+ model?: string;
1600
+ provider?: string;
1601
+ calls: number;
1602
+ errors: number;
1603
+ args_chars: number | null;
1604
+ result_chars: number | null;
1605
+ total_tokens_share: number | null;
1606
+ output_tokens_share: number | null;
1607
+ cost_share: number | null;
1608
+ last_used: number;
1609
+ }
1610
+
1611
+ function rowToToolUsage(row: ToolAggregateRow): ToolUsageStats {
1612
+ return {
1613
+ tool: row.tool_name,
1614
+ calls: row.calls,
1615
+ errors: row.errors,
1616
+ argsChars: row.args_chars ?? 0,
1617
+ resultChars: row.result_chars ?? 0,
1618
+ totalTokensShare: row.total_tokens_share ?? 0,
1619
+ outputTokensShare: row.output_tokens_share ?? 0,
1620
+ costShare: row.cost_share ?? 0,
1621
+ lastUsed: row.last_used,
1622
+ };
1623
+ }
1624
+
1625
+ /**
1626
+ * Get tool usage aggregated by tool name.
1627
+ */
1628
+ export function getToolStats(cutoff?: number): ToolUsageStats[] {
1629
+ if (!db) return [];
1630
+
1631
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
1632
+ const stmt = db.prepare(`
1633
+ SELECT t.tool_name, ${TOOL_AGGREGATE_COLUMNS}
1634
+ FROM tool_calls t
1635
+ LEFT JOIN messages m ON m.session_file = t.session_file AND m.entry_id = t.entry_id
1636
+ ${hasCutoff ? "WHERE t.timestamp >= ?" : ""}
1637
+ GROUP BY t.tool_name
1638
+ ORDER BY calls DESC
1639
+ `);
1640
+
1641
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as ToolAggregateRow[];
1642
+ return rows.map(rowToToolUsage);
1643
+ }
1644
+
1645
+ /**
1646
+ * Get tool usage aggregated by (tool, model, provider).
1647
+ */
1648
+ export function getToolStatsByModel(cutoff?: number): ToolModelStats[] {
1649
+ if (!db) return [];
1650
+
1651
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
1652
+ const stmt = db.prepare(`
1653
+ SELECT t.tool_name, t.model, t.provider, ${TOOL_AGGREGATE_COLUMNS}
1654
+ FROM tool_calls t
1655
+ LEFT JOIN messages m ON m.session_file = t.session_file AND m.entry_id = t.entry_id
1656
+ ${hasCutoff ? "WHERE t.timestamp >= ?" : ""}
1657
+ GROUP BY t.tool_name, t.model, t.provider
1658
+ ORDER BY calls DESC
1659
+ `);
1660
+
1661
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as ToolAggregateRow[];
1662
+ return rows.map(row => ({
1663
+ ...rowToToolUsage(row),
1664
+ model: row.model ?? "",
1665
+ provider: row.provider ?? "",
1666
+ }));
1667
+ }
1668
+
1669
+ /**
1670
+ * Get tool-call time series (one point per bucket per tool).
1671
+ */
1672
+ export function getToolTimeSeries(
1673
+ days = 14,
1674
+ cutoff?: number | null,
1675
+ bucketMs = 24 * 60 * 60 * 1000,
1676
+ ): ToolTimeSeriesPoint[] {
1677
+ if (!db) return [];
1678
+
1679
+ const hasCutoff = cutoff !== null;
1680
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
1681
+
1682
+ const stmt = db.prepare(`
1683
+ SELECT
1684
+ (timestamp / ?) * ? as bucket,
1685
+ tool_name,
1686
+ COUNT(*) as calls,
1687
+ SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) as errors
1688
+ FROM tool_calls
1689
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1690
+ GROUP BY bucket, tool_name
1691
+ ORDER BY bucket ASC
1692
+ `);
1693
+
1694
+ const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
1695
+ const rows = rowsRaw as Array<{ bucket: number; tool_name: string; calls: number; errors: number }>;
1696
+ return rows.map(row => ({
1697
+ timestamp: row.bucket,
1698
+ tool: row.tool_name,
1699
+ calls: row.calls,
1700
+ errors: row.errors,
1701
+ }));
1702
+ }