@gajae-code/stats 0.1.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 (67) hide show
  1. package/README.md +82 -0
  2. package/build.ts +84 -0
  3. package/dist/client/index.css +1 -0
  4. package/dist/client/index.html +13 -0
  5. package/dist/client/index.js +257 -0
  6. package/dist/client/styles.css +1159 -0
  7. package/dist/types/aggregator.d.ts +65 -0
  8. package/dist/types/client/App.d.ts +1 -0
  9. package/dist/types/client/api.d.ts +10 -0
  10. package/dist/types/client/components/BehaviorChart.d.ts +6 -0
  11. package/dist/types/client/components/BehaviorModelsTable.d.ts +7 -0
  12. package/dist/types/client/components/BehaviorSummary.d.ts +7 -0
  13. package/dist/types/client/components/ChartsContainer.d.ts +7 -0
  14. package/dist/types/client/components/CostChart.d.ts +6 -0
  15. package/dist/types/client/components/CostSummary.d.ts +6 -0
  16. package/dist/types/client/components/Header.d.ts +12 -0
  17. package/dist/types/client/components/ModelsTable.d.ts +8 -0
  18. package/dist/types/client/components/RequestDetail.d.ts +6 -0
  19. package/dist/types/client/components/RequestList.d.ts +8 -0
  20. package/dist/types/client/components/StatsGrid.d.ts +6 -0
  21. package/dist/types/client/components/chart-shared.d.ts +187 -0
  22. package/dist/types/client/components/models-table-shared.d.ts +195 -0
  23. package/dist/types/client/components/range-meta.d.ts +21 -0
  24. package/dist/types/client/index.d.ts +1 -0
  25. package/dist/types/client/types.d.ts +62 -0
  26. package/dist/types/client/useSystemTheme.d.ts +2 -0
  27. package/dist/types/db.d.ts +93 -0
  28. package/dist/types/index.d.ts +5 -0
  29. package/dist/types/parser.d.ts +40 -0
  30. package/dist/types/server.d.ts +7 -0
  31. package/dist/types/shared-types.d.ts +192 -0
  32. package/dist/types/sync-worker.d.ts +31 -0
  33. package/dist/types/types.d.ts +120 -0
  34. package/dist/types/user-metrics.d.ts +72 -0
  35. package/package.json +91 -0
  36. package/src/aggregator.ts +454 -0
  37. package/src/client/App.tsx +221 -0
  38. package/src/client/api.ts +65 -0
  39. package/src/client/components/BehaviorChart.tsx +189 -0
  40. package/src/client/components/BehaviorModelsTable.tsx +342 -0
  41. package/src/client/components/BehaviorSummary.tsx +95 -0
  42. package/src/client/components/ChartsContainer.tsx +221 -0
  43. package/src/client/components/CostChart.tsx +171 -0
  44. package/src/client/components/CostSummary.tsx +53 -0
  45. package/src/client/components/Header.tsx +72 -0
  46. package/src/client/components/ModelsTable.tsx +265 -0
  47. package/src/client/components/RequestDetail.tsx +172 -0
  48. package/src/client/components/RequestList.tsx +73 -0
  49. package/src/client/components/StatsGrid.tsx +135 -0
  50. package/src/client/components/chart-shared.tsx +320 -0
  51. package/src/client/components/models-table-shared.tsx +275 -0
  52. package/src/client/components/range-meta.ts +72 -0
  53. package/src/client/css.d.ts +1 -0
  54. package/src/client/index.tsx +6 -0
  55. package/src/client/styles.css +306 -0
  56. package/src/client/types.ts +78 -0
  57. package/src/client/useSystemTheme.ts +31 -0
  58. package/src/db.ts +1100 -0
  59. package/src/embedded-client.generated.txt +7 -0
  60. package/src/index.ts +182 -0
  61. package/src/parser.ts +334 -0
  62. package/src/server.ts +325 -0
  63. package/src/shared-types.ts +204 -0
  64. package/src/sync-worker.ts +40 -0
  65. package/src/types.ts +125 -0
  66. package/src/user-metrics.ts +686 -0
  67. package/tailwind.config.js +40 -0
package/src/db.ts ADDED
@@ -0,0 +1,1100 @@
1
+ import { Database } from "bun:sqlite";
2
+ import * as fs from "node:fs/promises";
3
+ import { type GeneratedProvider, getBundledModel, type Usage } from "@gajae-code/ai";
4
+ import { getConfigRootDir, getStatsDbPath } from "@gajae-code/utils";
5
+ import type {
6
+ AggregatedStats,
7
+ BehaviorModelStats,
8
+ BehaviorOverallStats,
9
+ BehaviorTimeSeriesPoint,
10
+ CostTimeSeriesPoint,
11
+ FolderStats,
12
+ MessageStats,
13
+ ModelPerformancePoint,
14
+ ModelStats,
15
+ ModelTimeSeriesPoint,
16
+ TimeSeriesPoint,
17
+ UserMessageLink,
18
+ UserMessageStats,
19
+ } from "./types";
20
+
21
+ type ModelCost = { input: number; output: number; cacheRead: number; cacheWrite: number };
22
+ type UsageCost = Usage["cost"];
23
+ type CostTokens = Pick<Usage, "input" | "output" | "cacheRead" | "cacheWrite">;
24
+
25
+ interface CostBackfillRow {
26
+ id: number;
27
+ provider: string;
28
+ model: string;
29
+ input_tokens: number;
30
+ output_tokens: number;
31
+ cache_read_tokens: number;
32
+ cache_write_tokens: number;
33
+ }
34
+
35
+ let db: Database | null = null;
36
+
37
+ const BACKFILL_COMPLETE = "complete";
38
+ const BACKFILL_PENDING = "pending";
39
+ const USER_MESSAGES_BACKFILL_KEY = "user_messages_v5";
40
+ const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
41
+ const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
42
+ function shouldResetBackfill(value: string | undefined): boolean {
43
+ return value !== BACKFILL_COMPLETE && value !== BACKFILL_PENDING;
44
+ }
45
+ /**
46
+ * Initialize the database and create tables.
47
+ */
48
+ export async function initDb(): Promise<Database> {
49
+ if (db) return db;
50
+
51
+ // Ensure directory exists
52
+ await fs.mkdir(getConfigRootDir(), { recursive: true });
53
+
54
+ db = new Database(getStatsDbPath());
55
+ db.exec("PRAGMA journal_mode = WAL");
56
+
57
+ // Create tables
58
+ db.exec(`
59
+ CREATE TABLE IF NOT EXISTS messages (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ session_file TEXT NOT NULL,
62
+ entry_id TEXT NOT NULL,
63
+ folder TEXT NOT NULL,
64
+ model TEXT NOT NULL,
65
+ provider TEXT NOT NULL,
66
+ api TEXT NOT NULL,
67
+ timestamp INTEGER NOT NULL,
68
+ duration INTEGER,
69
+ ttft INTEGER,
70
+ stop_reason TEXT NOT NULL,
71
+ error_message TEXT,
72
+ input_tokens INTEGER NOT NULL,
73
+ output_tokens INTEGER NOT NULL,
74
+ cache_read_tokens INTEGER NOT NULL,
75
+ cache_write_tokens INTEGER NOT NULL,
76
+ total_tokens INTEGER NOT NULL,
77
+ premium_requests REAL NOT NULL,
78
+ cost_input REAL NOT NULL,
79
+ cost_output REAL NOT NULL,
80
+ cost_cache_read REAL NOT NULL,
81
+ cost_cache_write REAL NOT NULL,
82
+ cost_total REAL NOT NULL,
83
+ UNIQUE(session_file, entry_id)
84
+ );
85
+
86
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
87
+ CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);
88
+ CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
89
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_file);
90
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp_model_provider ON messages(timestamp, model, provider);
91
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp_folder ON messages(timestamp, folder);
92
+ CREATE INDEX IF NOT EXISTS idx_messages_stop_reason_timestamp ON messages(stop_reason, timestamp);
93
+
94
+ CREATE TABLE IF NOT EXISTS file_offsets (
95
+ session_file TEXT PRIMARY KEY,
96
+ offset INTEGER NOT NULL,
97
+ last_modified INTEGER NOT NULL
98
+ );
99
+
100
+ CREATE TABLE IF NOT EXISTS user_messages (
101
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
102
+ session_file TEXT NOT NULL,
103
+ entry_id TEXT NOT NULL,
104
+ folder TEXT NOT NULL,
105
+ timestamp INTEGER NOT NULL,
106
+ model TEXT,
107
+ provider TEXT,
108
+ chars INTEGER NOT NULL,
109
+ words INTEGER NOT NULL,
110
+ yelling INTEGER NOT NULL,
111
+ profanity INTEGER NOT NULL,
112
+ anguish INTEGER NOT NULL,
113
+ negation INTEGER NOT NULL DEFAULT 0,
114
+ repetition INTEGER NOT NULL DEFAULT 0,
115
+ blame INTEGER NOT NULL DEFAULT 0,
116
+ UNIQUE(session_file, entry_id)
117
+ );
118
+
119
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
120
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
121
+
122
+ CREATE TABLE IF NOT EXISTS meta (
123
+ key TEXT PRIMARY KEY,
124
+ value TEXT NOT NULL
125
+ );
126
+ `);
127
+
128
+ const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
129
+ if (!messageColumns.some(column => column.name === "premium_requests")) {
130
+ db.exec("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
131
+ }
132
+ db.exec("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
133
+ // Each behavior-metric bump invalidates previously-ingested rows. We detect
134
+ // the stale schema by column name and drop the table; `IF NOT EXISTS` above
135
+ // already produced the new schema, but we want a clean wipe + re-ingest.
136
+ // `backfillUserMessages` then clears `file_offsets` so the next sync
137
+ // re-parses every session under the current metric definitions.
138
+ // v1 -> v2: yelling sentences replace `caps_words`.
139
+ // v2 -> v3: `drama_runs` folded into a single `anguish` signal that
140
+ // also captures elongated interjections, `dude`, and dot runs,
141
+ // gated on a stripped prose-line budget.
142
+ // v3 -> v4: added `negation`, `repetition`, `blame` frustration signals
143
+ // plus profanity dictionary expansion + word-boundary fix.
144
+ // v4 -> v5: column `yelling_sentences` renamed to `yelling` to match
145
+ // the other single-word signal columns.
146
+ const userMessageColumns = db.prepare("PRAGMA table_info(user_messages)").all() as {
147
+ name: string;
148
+ }[];
149
+ const hasStaleColumn =
150
+ userMessageColumns.length > 0 &&
151
+ (userMessageColumns.some(column => column.name === "caps_words") ||
152
+ userMessageColumns.some(column => column.name === "drama_runs") ||
153
+ userMessageColumns.some(column => column.name === "yelling_sentences"));
154
+ const hasV4Columns = userMessageColumns.some(column => column.name === "negation");
155
+ const hasOldUserMessages = userMessageColumns.length > 0;
156
+ if (hasStaleColumn || (hasOldUserMessages && !hasV4Columns)) {
157
+ db.exec("DROP TABLE user_messages");
158
+ db.exec(`
159
+ CREATE TABLE user_messages (
160
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
161
+ session_file TEXT NOT NULL,
162
+ entry_id TEXT NOT NULL,
163
+ folder TEXT NOT NULL,
164
+ timestamp INTEGER NOT NULL,
165
+ model TEXT,
166
+ provider TEXT,
167
+ chars INTEGER NOT NULL,
168
+ words INTEGER NOT NULL,
169
+ yelling INTEGER NOT NULL,
170
+ profanity INTEGER NOT NULL,
171
+ anguish INTEGER NOT NULL,
172
+ negation INTEGER NOT NULL DEFAULT 0,
173
+ repetition INTEGER NOT NULL DEFAULT 0,
174
+ blame INTEGER NOT NULL DEFAULT 0,
175
+ UNIQUE(session_file, entry_id)
176
+ );
177
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
178
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
179
+ `);
180
+ }
181
+ backfillUserMessages(db);
182
+ repairUserMessageLinks(db);
183
+ backfillPriorityPremiumRequests(db);
184
+ backfillMissingCatalogCosts(db);
185
+ return db;
186
+ }
187
+
188
+ function hasBillableCost(cost: ModelCost): boolean {
189
+ return cost.input !== 0 || cost.output !== 0 || cost.cacheRead !== 0 || cost.cacheWrite !== 0;
190
+ }
191
+
192
+ function getBundledModelCost(provider: string, modelId: string): ModelCost | null {
193
+ const model = getBundledModel(provider as GeneratedProvider, modelId);
194
+ return model?.cost ?? null;
195
+ }
196
+
197
+ function getCatalogCost(provider: string, modelId: string): ModelCost | null {
198
+ const primaryCost = getBundledModelCost(provider, modelId);
199
+ if (primaryCost && hasBillableCost(primaryCost)) {
200
+ return primaryCost;
201
+ }
202
+
203
+ if (provider === "openai-codex") {
204
+ const openAICost = getBundledModelCost("openai", modelId);
205
+ if (openAICost && hasBillableCost(openAICost)) {
206
+ return openAICost;
207
+ }
208
+ }
209
+
210
+ return null;
211
+ }
212
+
213
+ function calculateCatalogCost(provider: string, modelId: string, tokens: CostTokens): UsageCost | null {
214
+ const cost = getCatalogCost(provider, modelId);
215
+ if (!cost) return null;
216
+
217
+ const input = (cost.input / 1_000_000) * tokens.input;
218
+ const output = (cost.output / 1_000_000) * tokens.output;
219
+ const cacheRead = (cost.cacheRead / 1_000_000) * tokens.cacheRead;
220
+ const cacheWrite = (cost.cacheWrite / 1_000_000) * tokens.cacheWrite;
221
+
222
+ return {
223
+ input,
224
+ output,
225
+ cacheRead,
226
+ cacheWrite,
227
+ total: input + output + cacheRead + cacheWrite,
228
+ };
229
+ }
230
+
231
+ function resolveStoredCost(stats: MessageStats): UsageCost {
232
+ if (stats.usage.cost.total !== 0) {
233
+ return stats.usage.cost;
234
+ }
235
+
236
+ return calculateCatalogCost(stats.provider, stats.model, stats.usage) ?? stats.usage.cost;
237
+ }
238
+
239
+ function backfillMissingCatalogCosts(database: Database): void {
240
+ const rows = database
241
+ .prepare(`
242
+ SELECT id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
243
+ FROM messages
244
+ WHERE cost_total = 0 AND total_tokens > 0
245
+ `)
246
+ .all() as CostBackfillRow[];
247
+
248
+ if (rows.length === 0) return;
249
+
250
+ const update = database.prepare(`
251
+ UPDATE messages
252
+ SET cost_input = ?, cost_output = ?, cost_cache_read = ?, cost_cache_write = ?, cost_total = ?
253
+ WHERE id = ?
254
+ `);
255
+
256
+ const applyBackfill = database.transaction(() => {
257
+ for (const row of rows) {
258
+ const cost = calculateCatalogCost(row.provider, row.model, {
259
+ input: row.input_tokens,
260
+ output: row.output_tokens,
261
+ cacheRead: row.cache_read_tokens,
262
+ cacheWrite: row.cache_write_tokens,
263
+ });
264
+
265
+ if (!cost || cost.total === 0) continue;
266
+
267
+ update.run(cost.input, cost.output, cost.cacheRead, cost.cacheWrite, cost.total, row.id);
268
+ }
269
+ });
270
+
271
+ applyBackfill();
272
+ }
273
+
274
+ /**
275
+ * Get the stored offset for a session file.
276
+ */
277
+ export function getFileOffset(sessionFile: string): { offset: number; lastModified: number } | null {
278
+ if (!db) return null;
279
+
280
+ const stmt = db.prepare("SELECT offset, last_modified FROM file_offsets WHERE session_file = ?");
281
+ const row = stmt.get(sessionFile) as { offset: number; last_modified: number } | undefined;
282
+
283
+ return row ? { offset: row.offset, lastModified: row.last_modified } : null;
284
+ }
285
+
286
+ /**
287
+ * Update the stored offset for a session file.
288
+ */
289
+ export function setFileOffset(sessionFile: string, offset: number, lastModified: number): void {
290
+ if (!db) return;
291
+
292
+ const stmt = db.prepare(`
293
+ INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified)
294
+ VALUES (?, ?, ?)
295
+ `);
296
+ stmt.run(sessionFile, offset, lastModified);
297
+ }
298
+
299
+ /**
300
+ * Insert message stats into the database.
301
+ */
302
+ export function insertMessageStats(stats: MessageStats[]): number {
303
+ if (!db || stats.length === 0) return 0;
304
+
305
+ // Use UPSERT so a re-sync can fix up `premium_requests` for rows persisted
306
+ // before priority service-tier traffic was counted as premium. The guard
307
+ // `WHERE messages.premium_requests < excluded.premium_requests` keeps every
308
+ // other column immutable and never demotes an existing count (e.g. when a
309
+ // later parse drops back to 0 for the same row).
310
+ const stmt = db.prepare(`
311
+ INSERT INTO messages (
312
+ session_file, entry_id, folder, model, provider, api, timestamp,
313
+ duration, ttft, stop_reason, error_message,
314
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
315
+ cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total
316
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
317
+ ON CONFLICT(session_file, entry_id) DO UPDATE SET
318
+ premium_requests = excluded.premium_requests
319
+ WHERE messages.premium_requests < excluded.premium_requests
320
+ `);
321
+
322
+ let inserted = 0;
323
+ const insert = db.transaction(() => {
324
+ for (const s of stats) {
325
+ const cost = resolveStoredCost(s);
326
+ const result = stmt.run(
327
+ s.sessionFile,
328
+ s.entryId,
329
+ s.folder,
330
+ s.model,
331
+ s.provider,
332
+ s.api,
333
+ s.timestamp,
334
+ s.duration,
335
+ s.ttft,
336
+ s.stopReason,
337
+ s.errorMessage,
338
+ s.usage.input,
339
+ s.usage.output,
340
+ s.usage.cacheRead,
341
+ s.usage.cacheWrite,
342
+ s.usage.totalTokens,
343
+ s.usage.premiumRequests ?? 0,
344
+ cost.input,
345
+ cost.output,
346
+ cost.cacheRead,
347
+ cost.cacheWrite,
348
+ cost.total,
349
+ );
350
+ if (result.changes > 0) inserted++;
351
+ }
352
+ });
353
+
354
+ insert();
355
+ return inserted;
356
+ }
357
+
358
+ /**
359
+ * Build aggregated stats from query results.
360
+ */
361
+ function buildAggregatedStats(rows: any[]): AggregatedStats {
362
+ if (rows.length === 0) {
363
+ return {
364
+ totalRequests: 0,
365
+ successfulRequests: 0,
366
+ failedRequests: 0,
367
+ errorRate: 0,
368
+ totalInputTokens: 0,
369
+ totalOutputTokens: 0,
370
+ totalCacheReadTokens: 0,
371
+ totalCacheWriteTokens: 0,
372
+ cacheRate: 0,
373
+ totalCost: 0,
374
+ totalPremiumRequests: 0,
375
+ avgDuration: null,
376
+ avgTtft: null,
377
+ avgTokensPerSecond: null,
378
+ firstTimestamp: 0,
379
+ lastTimestamp: 0,
380
+ };
381
+ }
382
+
383
+ const row = rows[0];
384
+ const totalRequests = row.total_requests || 0;
385
+ const failedRequests = row.failed_requests || 0;
386
+ const successfulRequests = totalRequests - failedRequests;
387
+ const totalInputTokens = row.total_input_tokens || 0;
388
+ const totalCacheReadTokens = row.total_cache_read_tokens || 0;
389
+ const totalPremiumRequests = row.total_premium_requests || 0;
390
+
391
+ return {
392
+ totalRequests,
393
+ successfulRequests,
394
+ failedRequests,
395
+ errorRate: totalRequests > 0 ? failedRequests / totalRequests : 0,
396
+ totalInputTokens,
397
+ totalOutputTokens: row.total_output_tokens || 0,
398
+ totalCacheReadTokens,
399
+ totalCacheWriteTokens: row.total_cache_write_tokens || 0,
400
+ cacheRate:
401
+ totalInputTokens + totalCacheReadTokens > 0
402
+ ? totalCacheReadTokens / (totalInputTokens + totalCacheReadTokens)
403
+ : 0,
404
+ totalCost: row.total_cost || 0,
405
+ totalPremiumRequests,
406
+ avgDuration: row.avg_duration,
407
+ avgTtft: row.avg_ttft,
408
+ avgTokensPerSecond: row.avg_tokens_per_second,
409
+ firstTimestamp: row.first_timestamp || 0,
410
+ lastTimestamp: row.last_timestamp || 0,
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Get overall aggregated stats.
416
+ */
417
+ export function getOverallStats(cutoff?: number): AggregatedStats {
418
+ if (!db) return buildAggregatedStats([]);
419
+
420
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
421
+ const stmt = db.prepare(`
422
+ SELECT
423
+ COUNT(*) as total_requests,
424
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
425
+ SUM(input_tokens) as total_input_tokens,
426
+ SUM(output_tokens) as total_output_tokens,
427
+ SUM(cache_read_tokens) as total_cache_read_tokens,
428
+ SUM(cache_write_tokens) as total_cache_write_tokens,
429
+ SUM(premium_requests) as total_premium_requests,
430
+ SUM(cost_total) as total_cost,
431
+ AVG(duration) as avg_duration,
432
+ AVG(ttft) as avg_ttft,
433
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
434
+ MIN(timestamp) as first_timestamp,
435
+ MAX(timestamp) as last_timestamp
436
+ FROM messages
437
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
438
+ `);
439
+
440
+ const rows = hasCutoff ? stmt.all(cutoff) : stmt.all();
441
+ return buildAggregatedStats(rows as any[]);
442
+ }
443
+ /**
444
+ * Get stats grouped by model.
445
+ */
446
+ export function getStatsByModel(cutoff?: number): ModelStats[] {
447
+ if (!db) return [];
448
+
449
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
450
+ const stmt = db.prepare(`
451
+ SELECT
452
+ model,
453
+ provider,
454
+ COUNT(*) as total_requests,
455
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
456
+ SUM(input_tokens) as total_input_tokens,
457
+ SUM(output_tokens) as total_output_tokens,
458
+ SUM(cache_read_tokens) as total_cache_read_tokens,
459
+ SUM(cache_write_tokens) as total_cache_write_tokens,
460
+ SUM(premium_requests) as total_premium_requests,
461
+ SUM(cost_total) as total_cost,
462
+ AVG(duration) as avg_duration,
463
+ AVG(ttft) as avg_ttft,
464
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
465
+ MIN(timestamp) as first_timestamp,
466
+ MAX(timestamp) as last_timestamp
467
+ FROM messages
468
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
469
+ GROUP BY model, provider
470
+ ORDER BY total_requests DESC
471
+ `);
472
+
473
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
474
+ return rows.map(row => ({
475
+ model: row.model,
476
+ provider: row.provider,
477
+ ...buildAggregatedStats([row]),
478
+ }));
479
+ }
480
+
481
+ /**
482
+ * Get stats grouped by folder.
483
+ */
484
+ export function getStatsByFolder(cutoff?: number): FolderStats[] {
485
+ if (!db) return [];
486
+
487
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
488
+ const stmt = db.prepare(`
489
+ SELECT
490
+ folder,
491
+ COUNT(*) as total_requests,
492
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
493
+ SUM(input_tokens) as total_input_tokens,
494
+ SUM(output_tokens) as total_output_tokens,
495
+ SUM(cache_read_tokens) as total_cache_read_tokens,
496
+ SUM(cache_write_tokens) as total_cache_write_tokens,
497
+ SUM(premium_requests) as total_premium_requests,
498
+ SUM(cost_total) as total_cost,
499
+ AVG(duration) as avg_duration,
500
+ AVG(ttft) as avg_ttft,
501
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
502
+ MIN(timestamp) as first_timestamp,
503
+ MAX(timestamp) as last_timestamp
504
+ FROM messages
505
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
506
+ GROUP BY folder
507
+ ORDER BY total_requests DESC
508
+ `);
509
+
510
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
511
+ return rows.map(row => ({
512
+ folder: row.folder,
513
+ ...buildAggregatedStats([row]),
514
+ }));
515
+ }
516
+
517
+ /**
518
+ * Get time series data.
519
+ */
520
+ export function getTimeSeries(hours = 24, cutoff?: number | null, bucketMs = 60 * 60 * 1000): TimeSeriesPoint[] {
521
+ if (!db) return [];
522
+
523
+ const hasCutoff = cutoff !== null;
524
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - hours * 60 * 60 * 1000) : 0;
525
+
526
+ const stmt = db.prepare(`
527
+ SELECT
528
+ (timestamp / ?) * ? as bucket,
529
+ COUNT(*) as requests,
530
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as errors,
531
+ SUM(total_tokens) as tokens,
532
+ SUM(cost_total) as cost
533
+ FROM messages
534
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
535
+ GROUP BY bucket
536
+ ORDER BY bucket ASC
537
+ `);
538
+
539
+ const rows = hasCutoff
540
+ ? (stmt.all(bucketMs, bucketMs, seriesCutoff) as any[])
541
+ : (stmt.all(bucketMs, bucketMs) as any[]);
542
+ return rows.map(row => ({
543
+ timestamp: row.bucket,
544
+ requests: row.requests,
545
+ errors: row.errors,
546
+ tokens: row.tokens,
547
+ cost: row.cost,
548
+ }));
549
+ }
550
+
551
+ /**
552
+ * Get daily performance time series data for the last N days.
553
+ */
554
+ /**
555
+ * Get daily model usage time series data for the last N days.
556
+ */
557
+ export function getModelTimeSeries(
558
+ days = 14,
559
+ cutoff?: number | null,
560
+ bucketMs = 24 * 60 * 60 * 1000,
561
+ ): ModelTimeSeriesPoint[] {
562
+ if (!db) return [];
563
+
564
+ const hasCutoff = cutoff !== null;
565
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
566
+
567
+ const stmt = db.prepare(`
568
+ SELECT
569
+ (timestamp / ?) * ? as bucket,
570
+ model,
571
+ provider,
572
+ COUNT(*) as requests
573
+ FROM messages
574
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
575
+ GROUP BY bucket, model, provider
576
+ ORDER BY bucket ASC
577
+ `);
578
+
579
+ const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
580
+ const rows = rowsRaw as Array<{ bucket: number; model: string; provider: string; requests: number }>;
581
+ return rows.map(row => ({
582
+ timestamp: row.bucket,
583
+ model: row.model,
584
+ provider: row.provider,
585
+ requests: row.requests,
586
+ }));
587
+ }
588
+
589
+ /**
590
+ * Get daily model performance time series data for the last N days.
591
+ */
592
+ export function getModelPerformanceSeries(
593
+ days = 14,
594
+ cutoff?: number | null,
595
+ bucketMs = 24 * 60 * 60 * 1000,
596
+ ): ModelPerformancePoint[] {
597
+ if (!db) return [];
598
+
599
+ const hasCutoff = cutoff !== null;
600
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
601
+
602
+ const stmt = db.prepare(`
603
+ SELECT
604
+ (timestamp / ?) * ? as bucket,
605
+ model,
606
+ provider,
607
+ COUNT(*) as requests,
608
+ AVG(ttft) as avg_ttft,
609
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
610
+ FROM messages
611
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
612
+ GROUP BY bucket, model, provider
613
+ ORDER BY bucket ASC
614
+ `);
615
+
616
+ const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
617
+ const rows = rowsRaw as Array<{
618
+ bucket: number;
619
+ model: string;
620
+ provider: string;
621
+ requests: number;
622
+ avg_ttft: number | null;
623
+ avg_tokens_per_second: number | null;
624
+ }>;
625
+ return rows.map(row => ({
626
+ timestamp: row.bucket,
627
+ model: row.model,
628
+ provider: row.provider,
629
+ requests: row.requests,
630
+ avgTtft: row.avg_ttft,
631
+ avgTokensPerSecond: row.avg_tokens_per_second,
632
+ }));
633
+ }
634
+
635
+ /**
636
+ * Get total message count.
637
+ */
638
+ export function getMessageCount(): number {
639
+ if (!db) return 0;
640
+ const stmt = db.prepare("SELECT COUNT(*) as count FROM messages");
641
+ const row = stmt.get() as { count: number };
642
+ return row.count;
643
+ }
644
+
645
+ /**
646
+ * Close the database connection.
647
+ */
648
+ export function closeDb(): void {
649
+ if (db) {
650
+ db.close();
651
+ db = null;
652
+ }
653
+ }
654
+
655
+ function rowToMessageStats(row: any): MessageStats {
656
+ return {
657
+ id: row.id,
658
+ sessionFile: row.session_file,
659
+ entryId: row.entry_id,
660
+ folder: row.folder,
661
+ model: row.model,
662
+ provider: row.provider,
663
+ api: row.api,
664
+ timestamp: row.timestamp,
665
+ duration: row.duration,
666
+ ttft: row.ttft,
667
+ stopReason: row.stop_reason as any,
668
+ errorMessage: row.error_message,
669
+ usage: {
670
+ input: row.input_tokens,
671
+ output: row.output_tokens,
672
+ cacheRead: row.cache_read_tokens,
673
+ cacheWrite: row.cache_write_tokens,
674
+ totalTokens: row.total_tokens,
675
+ premiumRequests: row.premium_requests ?? 0,
676
+ cost: {
677
+ input: row.cost_input,
678
+ output: row.cost_output,
679
+ cacheRead: row.cost_cache_read,
680
+ cacheWrite: row.cost_cache_write,
681
+ total: row.cost_total,
682
+ },
683
+ },
684
+ };
685
+ }
686
+
687
+ export function getRecentRequests(limit = 100): MessageStats[] {
688
+ if (!db) return [];
689
+ const stmt = db.prepare(`
690
+ SELECT * FROM messages
691
+ ORDER BY timestamp DESC
692
+ LIMIT ?
693
+ `);
694
+ return (stmt.all(limit) as any[]).map(rowToMessageStats);
695
+ }
696
+
697
+ export function getRecentErrors(limit = 100): MessageStats[] {
698
+ if (!db) return [];
699
+ const stmt = db.prepare(`
700
+ SELECT * FROM messages
701
+ WHERE stop_reason = 'error'
702
+ ORDER BY timestamp DESC
703
+ LIMIT ?
704
+ `);
705
+ return (stmt.all(limit) as any[]).map(rowToMessageStats);
706
+ }
707
+
708
+ export function getMessageById(id: number): MessageStats | null {
709
+ if (!db) return null;
710
+ const stmt = db.prepare("SELECT * FROM messages WHERE id = ?");
711
+ const row = stmt.get(id);
712
+ return row ? rowToMessageStats(row) : null;
713
+ }
714
+
715
+ /**
716
+ * Get daily cost time series data for the last N days, broken down by model.
717
+ */
718
+ export function getCostTimeSeries(days = 90, cutoff?: number | null): CostTimeSeriesPoint[] {
719
+ if (!db) return [];
720
+
721
+ const hasCutoff = cutoff !== null;
722
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
723
+
724
+ const stmt = db.prepare(`
725
+ SELECT
726
+ (timestamp / 86400000) * 86400000 as bucket,
727
+ model,
728
+ provider,
729
+ SUM(cost_total) as cost,
730
+ SUM(cost_input) as cost_input,
731
+ SUM(cost_output) as cost_output,
732
+ SUM(cost_cache_read) as cost_cache_read,
733
+ SUM(cost_cache_write) as cost_cache_write,
734
+ COUNT(*) as requests
735
+ FROM messages
736
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
737
+ GROUP BY bucket, model, provider
738
+ ORDER BY bucket ASC
739
+ `);
740
+
741
+ const rows = hasCutoff ? (stmt.all(seriesCutoff) as any[]) : (stmt.all() as any[]);
742
+ return rows.map(row => ({
743
+ timestamp: row.bucket,
744
+ model: row.model,
745
+ provider: row.provider,
746
+ cost: row.cost,
747
+ costInput: row.cost_input,
748
+ costOutput: row.cost_output,
749
+ costCacheRead: row.cost_cache_read,
750
+ costCacheWrite: row.cost_cache_write,
751
+ requests: row.requests,
752
+ }));
753
+ }
754
+
755
+ /**
756
+ * Reset `file_offsets` (and any existing `user_messages` rows) so the next
757
+ * successful sync re-parses every session and re-derives behavioral metrics.
758
+ * Run once per metric-definition bump; the meta sentinel is only marked
759
+ * complete after `syncAllSessions` finishes. Older timestamp sentinel values
760
+ * are treated as pending so a failed compiled-binary sync cannot permanently
761
+ * suppress the backfill.
762
+ *
763
+ * - v1: initial introduction of `user_messages`.
764
+ * - v2: yelling-sentence metric replaces caps-word counts; existing rows are
765
+ * computed under the old definition and must be discarded.
766
+ * - v3: drama runs collapsed into `anguish` (drama + elongated interjections
767
+ * + `dude` + dot runs), scored on a stripped prose body and gated on
768
+ * line count. Existing rows used the narrower definition.
769
+ * - v4: added `negation` / `repetition` / `blame` signals and fixed a
770
+ * latent word-boundary bug in the profanity / anguish regexes that had
771
+ * left those metrics matching nothing in real prose.
772
+ * - v5: renamed `yelling_sentences` column to `yelling` to match the other
773
+ * single-word signal columns (profanity, anguish, negation, ...).
774
+ *
775
+ * Existing `messages` rows are unaffected - `INSERT OR IGNORE` keeps them.
776
+ */
777
+ function backfillUserMessages(database: Database): void {
778
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGES_BACKFILL_KEY) as
779
+ | { value: string }
780
+ | undefined;
781
+ if (!shouldResetBackfill(row?.value)) return;
782
+
783
+ database.exec("DELETE FROM user_messages");
784
+ database.exec("DELETE FROM file_offsets");
785
+ database
786
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
787
+ .run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING);
788
+ }
789
+
790
+ /**
791
+ * One-shot wipe of `file_offsets` to force `parseSessionFile` to re-parse
792
+ * every session from byte zero. We don't touch `user_messages`; the parser
793
+ * now emits a `UserMessageLink` for every assistant->parent pair, and the
794
+ * guarded `updateUserMessageLinks` UPDATE fixes any row whose `model` was
795
+ * left NULL by the old in-pass-only linking logic. Idempotent: gated by a
796
+ * sentinel row in `meta`.
797
+ */
798
+ function repairUserMessageLinks(database: Database): void {
799
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGE_LINKS_REPAIR_KEY) as
800
+ | { value: string }
801
+ | undefined;
802
+ if (!shouldResetBackfill(row?.value)) return;
803
+
804
+ database.exec("DELETE FROM file_offsets");
805
+ database
806
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
807
+ .run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING);
808
+ }
809
+
810
+ /**
811
+ * One-shot wipe of `file_offsets` so the next sync re-parses every session
812
+ * and re-derives `premium_requests` from recorded `service_tier_change`
813
+ * entries. Earlier ingestions captured priority OpenAI traffic with
814
+ * `premium_requests = 0` because the AI layer only set the field for GitHub
815
+ * Copilot traffic. The parser now folds priority requests into the same
816
+ * counter; combined with the UPSERT in `insertMessageStats`, a single sync
817
+ * pass brings the messages table up to date without touching any other
818
+ * column. Idempotent: gated by a sentinel row in `meta`.
819
+ */
820
+ function backfillPriorityPremiumRequests(database: Database): void {
821
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY) as
822
+ | { value: string }
823
+ | undefined;
824
+ if (!shouldResetBackfill(row?.value)) return;
825
+
826
+ database.exec("DELETE FROM file_offsets");
827
+ database
828
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
829
+ .run(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY, BACKFILL_PENDING);
830
+ }
831
+
832
+ export function markPriorityPremiumRequestsBackfillComplete(): void {
833
+ if (!db) return;
834
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(
835
+ PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY,
836
+ BACKFILL_COMPLETE,
837
+ );
838
+ }
839
+
840
+ export function markUserMessagesBackfillComplete(): void {
841
+ if (!db) return;
842
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(
843
+ USER_MESSAGES_BACKFILL_KEY,
844
+ BACKFILL_COMPLETE,
845
+ );
846
+ }
847
+
848
+ export function markUserMessageLinksRepairComplete(): void {
849
+ if (!db) return;
850
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(
851
+ USER_MESSAGE_LINKS_REPAIR_KEY,
852
+ BACKFILL_COMPLETE,
853
+ );
854
+ }
855
+
856
+ /**
857
+ * Insert user-message stats. Idempotent via UNIQUE(session_file, entry_id).
858
+ */
859
+ export function insertUserMessageStats(stats: UserMessageStats[]): number {
860
+ if (!db || stats.length === 0) return 0;
861
+
862
+ const stmt = db.prepare(`
863
+ INSERT OR IGNORE INTO user_messages (
864
+ session_file, entry_id, folder, timestamp, model, provider,
865
+ chars, words, yelling, profanity, anguish,
866
+ negation, repetition, blame
867
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
868
+ `);
869
+
870
+ let inserted = 0;
871
+ const insert = db.transaction(() => {
872
+ for (const s of stats) {
873
+ const result = stmt.run(
874
+ s.sessionFile,
875
+ s.entryId,
876
+ s.folder,
877
+ s.timestamp,
878
+ s.model,
879
+ s.provider,
880
+ s.chars,
881
+ s.words,
882
+ s.yelling,
883
+ s.profanity,
884
+ s.anguish,
885
+ s.negation,
886
+ s.repetition,
887
+ s.blame,
888
+ );
889
+ if (result.changes > 0) inserted++;
890
+ }
891
+ });
892
+ insert();
893
+ return inserted;
894
+ }
895
+
896
+ /**
897
+ * Backfill the responding `model`/`provider` on user-message rows that were
898
+ * persisted before their assistant reply was parsed (a side effect of
899
+ * incremental `fromOffset` syncing: the `userByEntryId` map in
900
+ * `parseSessionFile` only spans a single pass). Each row is updated at most
901
+ * once because the `model IS NULL` guard short-circuits subsequent passes.
902
+ *
903
+ * Returns the number of rows actually updated.
904
+ */
905
+ export function updateUserMessageLinks(links: UserMessageLink[]): number {
906
+ if (!db || links.length === 0) return 0;
907
+
908
+ const stmt = db.prepare(`
909
+ UPDATE user_messages
910
+ SET model = ?, provider = ?
911
+ WHERE session_file = ? AND entry_id = ? AND model IS NULL
912
+ `);
913
+
914
+ let updated = 0;
915
+ const apply = db.transaction(() => {
916
+ for (const link of links) {
917
+ const result = stmt.run(link.model, link.provider, link.sessionFile, link.entryId);
918
+ if (result.changes > 0) updated++;
919
+ }
920
+ });
921
+ apply();
922
+ return updated;
923
+ }
924
+
925
+ const UNKNOWN_MODEL = "unknown";
926
+
927
+ interface BehaviorSeriesRow {
928
+ bucket: number;
929
+ model: string;
930
+ provider: string;
931
+ messages: number;
932
+ yelling: number | null;
933
+ profanity: number | null;
934
+ anguish: number | null;
935
+ negation: number | null;
936
+ repetition: number | null;
937
+ blame: number | null;
938
+ chars: number | null;
939
+ }
940
+
941
+ /**
942
+ * Daily behavioral time series, grouped by responding model+provider.
943
+ */
944
+ export function getBehaviorTimeSeries(cutoff?: number | null): BehaviorTimeSeriesPoint[] {
945
+ if (!db) return [];
946
+ const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
947
+ const stmt = db.prepare(`
948
+ SELECT
949
+ (timestamp / 86400000) * 86400000 as bucket,
950
+ COALESCE(model, ?) as model,
951
+ COALESCE(provider, ?) as provider,
952
+ COUNT(*) as messages,
953
+ SUM(yelling) as yelling,
954
+ SUM(profanity) as profanity,
955
+ SUM(anguish) as anguish,
956
+ SUM(negation) as negation,
957
+ SUM(repetition) as repetition,
958
+ SUM(blame) as blame,
959
+ SUM(chars) as chars
960
+ FROM user_messages
961
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
962
+ GROUP BY bucket, model, provider
963
+ ORDER BY bucket ASC
964
+ `);
965
+ const rows = (
966
+ hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
967
+ ) as BehaviorSeriesRow[];
968
+ return rows.map(row => ({
969
+ timestamp: row.bucket,
970
+ model: row.model,
971
+ provider: row.provider,
972
+ messages: row.messages,
973
+ yelling: row.yelling ?? 0,
974
+ profanity: row.profanity ?? 0,
975
+ anguish: row.anguish ?? 0,
976
+ negation: row.negation ?? 0,
977
+ repetition: row.repetition ?? 0,
978
+ blame: row.blame ?? 0,
979
+ chars: row.chars ?? 0,
980
+ }));
981
+ }
982
+
983
+ interface BehaviorOverallRow {
984
+ total_messages: number;
985
+ total_yelling: number | null;
986
+ total_profanity: number | null;
987
+ total_anguish: number | null;
988
+ total_negation: number | null;
989
+ total_repetition: number | null;
990
+ total_blame: number | null;
991
+ total_chars: number | null;
992
+ first_timestamp: number | null;
993
+ last_timestamp: number | null;
994
+ }
995
+
996
+ /**
997
+ * Overall behavioral totals across the cutoff window.
998
+ */
999
+ export function getBehaviorOverall(cutoff?: number | null): BehaviorOverallStats {
1000
+ const empty: BehaviorOverallStats = {
1001
+ totalMessages: 0,
1002
+ totalYelling: 0,
1003
+ totalProfanity: 0,
1004
+ totalAnguish: 0,
1005
+ totalNegation: 0,
1006
+ totalRepetition: 0,
1007
+ totalBlame: 0,
1008
+ totalChars: 0,
1009
+ firstTimestamp: 0,
1010
+ lastTimestamp: 0,
1011
+ };
1012
+ if (!db) return empty;
1013
+ const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
1014
+ const stmt = db.prepare(`
1015
+ SELECT
1016
+ COUNT(*) as total_messages,
1017
+ SUM(yelling) as total_yelling,
1018
+ SUM(profanity) as total_profanity,
1019
+ SUM(anguish) as total_anguish,
1020
+ SUM(negation) as total_negation,
1021
+ SUM(repetition) as total_repetition,
1022
+ SUM(blame) as total_blame,
1023
+ SUM(chars) as total_chars,
1024
+ MIN(timestamp) as first_timestamp,
1025
+ MAX(timestamp) as last_timestamp
1026
+ FROM user_messages
1027
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1028
+ `);
1029
+ const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as BehaviorOverallRow | undefined;
1030
+ if (!row?.total_messages) return empty;
1031
+ return {
1032
+ totalMessages: row.total_messages,
1033
+ totalYelling: row.total_yelling ?? 0,
1034
+ totalProfanity: row.total_profanity ?? 0,
1035
+ totalAnguish: row.total_anguish ?? 0,
1036
+ totalNegation: row.total_negation ?? 0,
1037
+ totalRepetition: row.total_repetition ?? 0,
1038
+ totalBlame: row.total_blame ?? 0,
1039
+ totalChars: row.total_chars ?? 0,
1040
+ firstTimestamp: row.first_timestamp ?? 0,
1041
+ lastTimestamp: row.last_timestamp ?? 0,
1042
+ };
1043
+ }
1044
+
1045
+ interface BehaviorByModelRow {
1046
+ model: string;
1047
+ provider: string;
1048
+ total_messages: number;
1049
+ total_yelling: number | null;
1050
+ total_profanity: number | null;
1051
+ total_anguish: number | null;
1052
+ total_negation: number | null;
1053
+ total_repetition: number | null;
1054
+ total_blame: number | null;
1055
+ total_chars: number | null;
1056
+ last_timestamp: number | null;
1057
+ }
1058
+
1059
+ /**
1060
+ * Per-model behavioral totals over the cutoff window. "Unknown" represents
1061
+ * user messages that never received an assistant reply.
1062
+ */
1063
+ export function getBehaviorByModel(cutoff?: number | null): BehaviorModelStats[] {
1064
+ if (!db) return [];
1065
+ const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
1066
+ const stmt = db.prepare(`
1067
+ SELECT
1068
+ COALESCE(model, ?) as model,
1069
+ COALESCE(provider, ?) as provider,
1070
+ COUNT(*) as total_messages,
1071
+ SUM(yelling) as total_yelling,
1072
+ SUM(profanity) as total_profanity,
1073
+ SUM(anguish) as total_anguish,
1074
+ SUM(negation) as total_negation,
1075
+ SUM(repetition) as total_repetition,
1076
+ SUM(blame) as total_blame,
1077
+ SUM(chars) as total_chars,
1078
+ MAX(timestamp) as last_timestamp
1079
+ FROM user_messages
1080
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
1081
+ GROUP BY model, provider
1082
+ ORDER BY total_messages DESC
1083
+ `);
1084
+ const rows = (
1085
+ hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
1086
+ ) as BehaviorByModelRow[];
1087
+ return rows.map(row => ({
1088
+ model: row.model,
1089
+ provider: row.provider,
1090
+ totalMessages: row.total_messages,
1091
+ totalYelling: row.total_yelling ?? 0,
1092
+ totalProfanity: row.total_profanity ?? 0,
1093
+ totalAnguish: row.total_anguish ?? 0,
1094
+ totalNegation: row.total_negation ?? 0,
1095
+ totalRepetition: row.total_repetition ?? 0,
1096
+ totalBlame: row.total_blame ?? 0,
1097
+ totalChars: row.total_chars ?? 0,
1098
+ lastTimestamp: row.last_timestamp ?? 0,
1099
+ }));
1100
+ }