jeopi-stats 16.2.13

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