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