@oh-my-pi/omp-stats 14.9.3 → 14.9.5

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.
@@ -1,17 +1,28 @@
1
1
  import { Activity, RefreshCw } from "lucide-react";
2
+ import type { TimeRange } from "../types";
2
3
 
3
- type Tab = "overview" | "requests" | "errors" | "models" | "costs";
4
+ type Tab = "overview" | "requests" | "errors" | "models" | "costs" | "behavior";
5
+
6
+ const tabs: Tab[] = ["overview", "requests", "errors", "models", "costs", "behavior"];
7
+ const timeRanges: { label: string; value: TimeRange }[] = [
8
+ { label: "1h", value: "1h" },
9
+ { label: "24h", value: "24h" },
10
+ { label: "7d", value: "7d" },
11
+ { label: "30d", value: "30d" },
12
+ { label: "90d", value: "90d" },
13
+ { label: "All", value: "all" },
14
+ ];
4
15
 
5
16
  interface HeaderProps {
6
17
  activeTab: Tab;
7
18
  onTabChange: (tab: Tab) => void;
8
19
  onSync: () => void;
9
20
  syncing: boolean;
21
+ timeRange: TimeRange;
22
+ onTimeRangeChange: (timeRange: TimeRange) => void;
10
23
  }
11
24
 
12
- const tabs: Tab[] = ["overview", "requests", "errors", "models", "costs"];
13
-
14
- export function Header({ activeTab, onTabChange, onSync, syncing }: HeaderProps) {
25
+ export function Header({ activeTab, onTabChange, onSync, syncing, timeRange, onTimeRangeChange }: HeaderProps) {
15
26
  return (
16
27
  <header className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 mb-8 border-b border-[var(--border-subtle)]">
17
28
  <div className="flex items-center gap-3">
@@ -37,6 +48,19 @@ export function Header({ activeTab, onTabChange, onSync, syncing }: HeaderProps)
37
48
  </button>
38
49
  ))}
39
50
  </div>
51
+ <div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-md)] p-1 border border-[var(--border-subtle)]">
52
+ {timeRanges.map(range => (
53
+ <button
54
+ key={range.value}
55
+ type="button"
56
+ onClick={() => onTimeRangeChange(range.value)}
57
+ className={`tab-btn ${timeRange === range.value ? "active" : ""}`}
58
+ title={range.value === "all" ? "All time" : `Last ${range.label}`}
59
+ >
60
+ {range.label}
61
+ </button>
62
+ ))}
63
+ </div>
40
64
 
41
65
  <button type="button" onClick={onSync} disabled={syncing} className="btn btn-primary">
42
66
  <RefreshCw size={16} className={syncing ? "spin" : ""} />
@@ -5,6 +5,15 @@ interface StatsGridProps {
5
5
  stats: AggregatedStats;
6
6
  }
7
7
 
8
+ const compactNumberFormatter = new Intl.NumberFormat(undefined, {
9
+ notation: "compact",
10
+ maximumFractionDigits: 1,
11
+ });
12
+
13
+ function formatCompactNumber(value: number): string {
14
+ return compactNumberFormatter.format(value);
15
+ }
16
+
8
17
  const statConfig = [
9
18
  {
10
19
  key: "requests",
@@ -39,7 +48,7 @@ const statConfig = [
39
48
  icon: Database,
40
49
  color: "var(--accent-cyan)",
41
50
  getValue: (s: AggregatedStats) => `${(s.cacheRate * 100).toFixed(1)}%`,
42
- getDetail: (s: AggregatedStats) => `${(s.totalCacheReadTokens / 1000).toFixed(1)}k cached tokens`,
51
+ getDetail: (s: AggregatedStats) => `${formatCompactNumber(s.totalCacheReadTokens)} cached tokens`,
43
52
  },
44
53
  {
45
54
  key: "errors",
@@ -59,6 +59,7 @@ export interface AggregatedStats {
59
59
  lastTimestamp: number;
60
60
  }
61
61
 
62
+ export type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
62
63
  export interface ModelStats extends AggregatedStats {
63
64
  model: string;
64
65
  provider: string;
@@ -113,3 +114,56 @@ export interface DashboardStats {
113
114
  modelPerformanceSeries: ModelPerformancePoint[];
114
115
  costSeries: CostTimeSeriesPoint[];
115
116
  }
117
+
118
+ export interface OverviewStats {
119
+ overall: AggregatedStats;
120
+ timeSeries: TimeSeriesPoint[];
121
+ }
122
+
123
+ export interface ModelDashboardStats {
124
+ byModel: ModelStats[];
125
+ modelSeries: ModelTimeSeriesPoint[];
126
+ modelPerformanceSeries: ModelPerformancePoint[];
127
+ }
128
+
129
+ export interface CostDashboardStats {
130
+ costSeries: CostTimeSeriesPoint[];
131
+ }
132
+
133
+ export interface BehaviorTimeSeriesPoint {
134
+ timestamp: number;
135
+ model: string;
136
+ provider: string;
137
+ messages: number;
138
+ yellingSentences: number;
139
+ profanity: number;
140
+ dramaRuns: number;
141
+ chars: number;
142
+ }
143
+
144
+ export interface BehaviorOverallStats {
145
+ totalMessages: number;
146
+ totalYellingSentences: number;
147
+ totalProfanity: number;
148
+ totalDramaRuns: number;
149
+ totalChars: number;
150
+ firstTimestamp: number;
151
+ lastTimestamp: number;
152
+ }
153
+
154
+ export interface BehaviorModelStats {
155
+ model: string;
156
+ provider: string;
157
+ totalMessages: number;
158
+ totalYellingSentences: number;
159
+ totalProfanity: number;
160
+ totalDramaRuns: number;
161
+ totalChars: number;
162
+ lastTimestamp: number;
163
+ }
164
+
165
+ export interface BehaviorDashboardStats {
166
+ overall: BehaviorOverallStats;
167
+ byModel: BehaviorModelStats[];
168
+ behaviorSeries: BehaviorTimeSeriesPoint[];
169
+ }
package/src/db.ts CHANGED
@@ -4,6 +4,9 @@ import { type GeneratedProvider, getBundledModel, type Usage } from "@oh-my-pi/p
4
4
  import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
5
5
  import type {
6
6
  AggregatedStats,
7
+ BehaviorModelStats,
8
+ BehaviorOverallStats,
9
+ BehaviorTimeSeriesPoint,
7
10
  CostTimeSeriesPoint,
8
11
  FolderStats,
9
12
  MessageStats,
@@ -11,6 +14,7 @@ import type {
11
14
  ModelStats,
12
15
  ModelTimeSeriesPoint,
13
16
  TimeSeriesPoint,
17
+ UserMessageStats,
14
18
  } from "./types";
15
19
 
16
20
  type ModelCost = { input: number; output: number; cacheRead: number; cacheWrite: number };
@@ -74,12 +78,39 @@ export async function initDb(): Promise<Database> {
74
78
  CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);
75
79
  CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
76
80
  CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_file);
81
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp_model_provider ON messages(timestamp, model, provider);
82
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp_folder ON messages(timestamp, folder);
83
+ CREATE INDEX IF NOT EXISTS idx_messages_stop_reason_timestamp ON messages(stop_reason, timestamp);
77
84
 
78
85
  CREATE TABLE IF NOT EXISTS file_offsets (
79
86
  session_file TEXT PRIMARY KEY,
80
87
  offset INTEGER NOT NULL,
81
88
  last_modified INTEGER NOT NULL
82
89
  );
90
+
91
+ CREATE TABLE IF NOT EXISTS user_messages (
92
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ session_file TEXT NOT NULL,
94
+ entry_id TEXT NOT NULL,
95
+ folder TEXT NOT NULL,
96
+ timestamp INTEGER NOT NULL,
97
+ model TEXT,
98
+ provider TEXT,
99
+ chars INTEGER NOT NULL,
100
+ words INTEGER NOT NULL,
101
+ yelling_sentences INTEGER NOT NULL,
102
+ profanity INTEGER NOT NULL,
103
+ drama_runs INTEGER NOT NULL,
104
+ UNIQUE(session_file, entry_id)
105
+ );
106
+
107
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
108
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
109
+
110
+ CREATE TABLE IF NOT EXISTS meta (
111
+ key TEXT PRIMARY KEY,
112
+ value TEXT NOT NULL
113
+ );
83
114
  `);
84
115
 
85
116
  const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
@@ -87,6 +118,38 @@ export async function initDb(): Promise<Database> {
87
118
  db.exec("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
88
119
  }
89
120
  db.exec("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
121
+ // Bumping the metric definition (yelling sentences vs caps words) invalidates
122
+ // previously-ingested rows. If the legacy column is present we drop the table
123
+ // outright; the `IF NOT EXISTS` create above already gave us the new schema
124
+ // in parallel, but we want a clean wipe + re-ingest. The accompanying
125
+ // `backfillUserMessages` bump (v2) clears `file_offsets` so the next sync
126
+ // re-parses every session.
127
+ const userMessageColumns = db.prepare("PRAGMA table_info(user_messages)").all() as {
128
+ name: string;
129
+ }[];
130
+ if (userMessageColumns.some(column => column.name === "caps_words")) {
131
+ db.exec("DROP TABLE user_messages");
132
+ db.exec(`
133
+ CREATE TABLE user_messages (
134
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
135
+ session_file TEXT NOT NULL,
136
+ entry_id TEXT NOT NULL,
137
+ folder TEXT NOT NULL,
138
+ timestamp INTEGER NOT NULL,
139
+ model TEXT,
140
+ provider TEXT,
141
+ chars INTEGER NOT NULL,
142
+ words INTEGER NOT NULL,
143
+ yelling_sentences INTEGER NOT NULL,
144
+ profanity INTEGER NOT NULL,
145
+ drama_runs INTEGER NOT NULL,
146
+ UNIQUE(session_file, entry_id)
147
+ );
148
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
149
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
150
+ `);
151
+ }
152
+ backfillUserMessages(db);
90
153
  backfillMissingCatalogCosts(db);
91
154
  return db;
92
155
  }
@@ -312,9 +375,10 @@ function buildAggregatedStats(rows: any[]): AggregatedStats {
312
375
  /**
313
376
  * Get overall aggregated stats.
314
377
  */
315
- export function getOverallStats(): AggregatedStats {
378
+ export function getOverallStats(cutoff?: number): AggregatedStats {
316
379
  if (!db) return buildAggregatedStats([]);
317
380
 
381
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
318
382
  const stmt = db.prepare(`
319
383
  SELECT
320
384
  COUNT(*) as total_requests,
@@ -331,18 +395,19 @@ export function getOverallStats(): AggregatedStats {
331
395
  MIN(timestamp) as first_timestamp,
332
396
  MAX(timestamp) as last_timestamp
333
397
  FROM messages
398
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
334
399
  `);
335
400
 
336
- const rows = stmt.all();
337
- return buildAggregatedStats(rows);
401
+ const rows = hasCutoff ? stmt.all(cutoff) : stmt.all();
402
+ return buildAggregatedStats(rows as any[]);
338
403
  }
339
-
340
404
  /**
341
405
  * Get stats grouped by model.
342
406
  */
343
- export function getStatsByModel(): ModelStats[] {
407
+ export function getStatsByModel(cutoff?: number): ModelStats[] {
344
408
  if (!db) return [];
345
409
 
410
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
346
411
  const stmt = db.prepare(`
347
412
  SELECT
348
413
  model,
@@ -361,11 +426,12 @@ export function getStatsByModel(): ModelStats[] {
361
426
  MIN(timestamp) as first_timestamp,
362
427
  MAX(timestamp) as last_timestamp
363
428
  FROM messages
429
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
364
430
  GROUP BY model, provider
365
431
  ORDER BY total_requests DESC
366
432
  `);
367
433
 
368
- const rows = stmt.all() as any[];
434
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
369
435
  return rows.map(row => ({
370
436
  model: row.model,
371
437
  provider: row.provider,
@@ -376,9 +442,10 @@ export function getStatsByModel(): ModelStats[] {
376
442
  /**
377
443
  * Get stats grouped by folder.
378
444
  */
379
- export function getStatsByFolder(): FolderStats[] {
445
+ export function getStatsByFolder(cutoff?: number): FolderStats[] {
380
446
  if (!db) return [];
381
447
 
448
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
382
449
  const stmt = db.prepare(`
383
450
  SELECT
384
451
  folder,
@@ -396,11 +463,12 @@ export function getStatsByFolder(): FolderStats[] {
396
463
  MIN(timestamp) as first_timestamp,
397
464
  MAX(timestamp) as last_timestamp
398
465
  FROM messages
466
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
399
467
  GROUP BY folder
400
468
  ORDER BY total_requests DESC
401
469
  `);
402
470
 
403
- const rows = stmt.all() as any[];
471
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
404
472
  return rows.map(row => ({
405
473
  folder: row.folder,
406
474
  ...buildAggregatedStats([row]),
@@ -408,27 +476,30 @@ export function getStatsByFolder(): FolderStats[] {
408
476
  }
409
477
 
410
478
  /**
411
- * Get hourly time series data.
479
+ * Get time series data.
412
480
  */
413
- export function getTimeSeries(hours = 24): TimeSeriesPoint[] {
481
+ export function getTimeSeries(hours = 24, cutoff?: number | null, bucketMs = 60 * 60 * 1000): TimeSeriesPoint[] {
414
482
  if (!db) return [];
415
483
 
416
- const cutoff = Date.now() - hours * 60 * 60 * 1000;
484
+ const hasCutoff = cutoff !== null;
485
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - hours * 60 * 60 * 1000) : 0;
417
486
 
418
487
  const stmt = db.prepare(`
419
488
  SELECT
420
- (timestamp / 3600000) * 3600000 as bucket,
489
+ (timestamp / ?) * ? as bucket,
421
490
  COUNT(*) as requests,
422
491
  SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as errors,
423
492
  SUM(total_tokens) as tokens,
424
493
  SUM(cost_total) as cost
425
494
  FROM messages
426
- WHERE timestamp >= ?
495
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
427
496
  GROUP BY bucket
428
497
  ORDER BY bucket ASC
429
498
  `);
430
499
 
431
- const rows = stmt.all(cutoff) as any[];
500
+ const rows = hasCutoff
501
+ ? (stmt.all(bucketMs, bucketMs, seriesCutoff) as any[])
502
+ : (stmt.all(bucketMs, bucketMs) as any[]);
432
503
  return rows.map(row => ({
433
504
  timestamp: row.bucket,
434
505
  requests: row.requests,
@@ -444,10 +515,11 @@ export function getTimeSeries(hours = 24): TimeSeriesPoint[] {
444
515
  /**
445
516
  * Get daily model usage time series data for the last N days.
446
517
  */
447
- export function getModelTimeSeries(days = 14): ModelTimeSeriesPoint[] {
518
+ export function getModelTimeSeries(days = 14, cutoff?: number | null): ModelTimeSeriesPoint[] {
448
519
  if (!db) return [];
449
520
 
450
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
521
+ const hasCutoff = cutoff !== null;
522
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
451
523
 
452
524
  const stmt = db.prepare(`
453
525
  SELECT
@@ -456,12 +528,12 @@ export function getModelTimeSeries(days = 14): ModelTimeSeriesPoint[] {
456
528
  provider,
457
529
  COUNT(*) as requests
458
530
  FROM messages
459
- WHERE timestamp >= ?
531
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
460
532
  GROUP BY bucket, model, provider
461
533
  ORDER BY bucket ASC
462
534
  `);
463
535
 
464
- const rows = stmt.all(cutoff) as any[];
536
+ const rows = hasCutoff ? (stmt.all(seriesCutoff) as any[]) : (stmt.all() as any[]);
465
537
  return rows.map(row => ({
466
538
  timestamp: row.bucket,
467
539
  model: row.model,
@@ -473,10 +545,11 @@ export function getModelTimeSeries(days = 14): ModelTimeSeriesPoint[] {
473
545
  /**
474
546
  * Get daily model performance time series data for the last N days.
475
547
  */
476
- export function getModelPerformanceSeries(days = 14): ModelPerformancePoint[] {
548
+ export function getModelPerformanceSeries(days = 14, cutoff?: number | null): ModelPerformancePoint[] {
477
549
  if (!db) return [];
478
550
 
479
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
551
+ const hasCutoff = cutoff !== null;
552
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
480
553
 
481
554
  const stmt = db.prepare(`
482
555
  SELECT
@@ -487,12 +560,12 @@ export function getModelPerformanceSeries(days = 14): ModelPerformancePoint[] {
487
560
  AVG(ttft) as avg_ttft,
488
561
  AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
489
562
  FROM messages
490
- WHERE timestamp >= ?
563
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
491
564
  GROUP BY bucket, model, provider
492
565
  ORDER BY bucket ASC
493
566
  `);
494
567
 
495
- const rows = stmt.all(cutoff) as any[];
568
+ const rows = hasCutoff ? (stmt.all(seriesCutoff) as any[]) : (stmt.all() as any[]);
496
569
  return rows.map(row => ({
497
570
  timestamp: row.bucket,
498
571
  model: row.model,
@@ -586,10 +659,11 @@ export function getMessageById(id: number): MessageStats | null {
586
659
  /**
587
660
  * Get daily cost time series data for the last N days, broken down by model.
588
661
  */
589
- export function getCostTimeSeries(days = 90): CostTimeSeriesPoint[] {
662
+ export function getCostTimeSeries(days = 90, cutoff?: number | null): CostTimeSeriesPoint[] {
590
663
  if (!db) return [];
591
664
 
592
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
665
+ const hasCutoff = cutoff !== null;
666
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
593
667
 
594
668
  const stmt = db.prepare(`
595
669
  SELECT
@@ -603,12 +677,12 @@ export function getCostTimeSeries(days = 90): CostTimeSeriesPoint[] {
603
677
  SUM(cost_cache_write) as cost_cache_write,
604
678
  COUNT(*) as requests
605
679
  FROM messages
606
- WHERE timestamp >= ?
680
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
607
681
  GROUP BY bucket, model, provider
608
682
  ORDER BY bucket ASC
609
683
  `);
610
684
 
611
- const rows = stmt.all(cutoff) as any[];
685
+ const rows = hasCutoff ? (stmt.all(seriesCutoff) as any[]) : (stmt.all() as any[]);
612
686
  return rows.map(row => ({
613
687
  timestamp: row.bucket,
614
688
  model: row.model,
@@ -621,3 +695,210 @@ export function getCostTimeSeries(days = 90): CostTimeSeriesPoint[] {
621
695
  requests: row.requests,
622
696
  }));
623
697
  }
698
+
699
+ /**
700
+ * Reset `file_offsets` (and any existing `user_messages` rows) so the next
701
+ * sync re-parses every session and re-derives behavioral metrics. Run once
702
+ * per metric-definition bump; the meta sentinel records the version.
703
+ *
704
+ * - v1: initial introduction of `user_messages`.
705
+ * - v2: yelling-sentence metric replaces caps-word counts; existing rows are
706
+ * computed under the old definition and must be discarded.
707
+ *
708
+ * Existing `messages` rows are unaffected — `INSERT OR IGNORE` keeps them.
709
+ */
710
+ function backfillUserMessages(database: Database): void {
711
+ const row = database.prepare("SELECT value FROM meta WHERE key = 'user_messages_v2'").get() as
712
+ | { value: string }
713
+ | undefined;
714
+ if (row) return;
715
+
716
+ database.exec("DELETE FROM user_messages");
717
+ database.exec("DELETE FROM file_offsets");
718
+ database
719
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
720
+ .run("user_messages_v2", String(Date.now()));
721
+ }
722
+
723
+ /**
724
+ * Insert user-message stats. Idempotent via UNIQUE(session_file, entry_id).
725
+ */
726
+ export function insertUserMessageStats(stats: UserMessageStats[]): number {
727
+ if (!db || stats.length === 0) return 0;
728
+
729
+ const stmt = db.prepare(`
730
+ INSERT OR IGNORE INTO user_messages (
731
+ session_file, entry_id, folder, timestamp, model, provider,
732
+ chars, words, yelling_sentences, profanity, drama_runs
733
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
734
+ `);
735
+
736
+ let inserted = 0;
737
+ const insert = db.transaction(() => {
738
+ for (const s of stats) {
739
+ const result = stmt.run(
740
+ s.sessionFile,
741
+ s.entryId,
742
+ s.folder,
743
+ s.timestamp,
744
+ s.model,
745
+ s.provider,
746
+ s.chars,
747
+ s.words,
748
+ s.yellingSentences,
749
+ s.profanity,
750
+ s.dramaRuns,
751
+ );
752
+ if (result.changes > 0) inserted++;
753
+ }
754
+ });
755
+ insert();
756
+ return inserted;
757
+ }
758
+
759
+ const UNKNOWN_MODEL = "unknown";
760
+
761
+ interface BehaviorSeriesRow {
762
+ bucket: number;
763
+ model: string;
764
+ provider: string;
765
+ messages: number;
766
+ yelling_sentences: number | null;
767
+ profanity: number | null;
768
+ drama_runs: number | null;
769
+ chars: number | null;
770
+ }
771
+
772
+ /**
773
+ * Daily behavioral time series, grouped by responding model+provider.
774
+ */
775
+ export function getBehaviorTimeSeries(cutoff?: number | null): BehaviorTimeSeriesPoint[] {
776
+ if (!db) return [];
777
+ const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
778
+ const stmt = db.prepare(`
779
+ SELECT
780
+ (timestamp / 86400000) * 86400000 as bucket,
781
+ COALESCE(model, ?) as model,
782
+ COALESCE(provider, ?) as provider,
783
+ COUNT(*) as messages,
784
+ SUM(yelling_sentences) as yelling_sentences,
785
+ SUM(profanity) as profanity,
786
+ SUM(drama_runs) as drama_runs,
787
+ SUM(chars) as chars
788
+ FROM user_messages
789
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
790
+ GROUP BY bucket, model, provider
791
+ ORDER BY bucket ASC
792
+ `);
793
+ const rows = (
794
+ hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
795
+ ) as BehaviorSeriesRow[];
796
+ return rows.map(row => ({
797
+ timestamp: row.bucket,
798
+ model: row.model,
799
+ provider: row.provider,
800
+ messages: row.messages,
801
+ yellingSentences: row.yelling_sentences ?? 0,
802
+ profanity: row.profanity ?? 0,
803
+ dramaRuns: row.drama_runs ?? 0,
804
+ chars: row.chars ?? 0,
805
+ }));
806
+ }
807
+
808
+ interface BehaviorOverallRow {
809
+ total_messages: number;
810
+ total_yelling_sentences: number | null;
811
+ total_profanity: number | null;
812
+ total_drama_runs: number | null;
813
+ total_chars: number | null;
814
+ first_timestamp: number | null;
815
+ last_timestamp: number | null;
816
+ }
817
+
818
+ /**
819
+ * Overall behavioral totals across the cutoff window.
820
+ */
821
+ export function getBehaviorOverall(cutoff?: number | null): BehaviorOverallStats {
822
+ const empty: BehaviorOverallStats = {
823
+ totalMessages: 0,
824
+ totalYellingSentences: 0,
825
+ totalProfanity: 0,
826
+ totalDramaRuns: 0,
827
+ totalChars: 0,
828
+ firstTimestamp: 0,
829
+ lastTimestamp: 0,
830
+ };
831
+ if (!db) return empty;
832
+ const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
833
+ const stmt = db.prepare(`
834
+ SELECT
835
+ COUNT(*) as total_messages,
836
+ SUM(yelling_sentences) as total_yelling_sentences,
837
+ SUM(profanity) as total_profanity,
838
+ SUM(drama_runs) as total_drama_runs,
839
+ SUM(chars) as total_chars,
840
+ MIN(timestamp) as first_timestamp,
841
+ MAX(timestamp) as last_timestamp
842
+ FROM user_messages
843
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
844
+ `);
845
+ const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as BehaviorOverallRow | undefined;
846
+ if (!row?.total_messages) return empty;
847
+ return {
848
+ totalMessages: row.total_messages,
849
+ totalYellingSentences: row.total_yelling_sentences ?? 0,
850
+ totalProfanity: row.total_profanity ?? 0,
851
+ totalDramaRuns: row.total_drama_runs ?? 0,
852
+ totalChars: row.total_chars ?? 0,
853
+ firstTimestamp: row.first_timestamp ?? 0,
854
+ lastTimestamp: row.last_timestamp ?? 0,
855
+ };
856
+ }
857
+
858
+ interface BehaviorByModelRow {
859
+ model: string;
860
+ provider: string;
861
+ total_messages: number;
862
+ total_yelling_sentences: number | null;
863
+ total_profanity: number | null;
864
+ total_drama_runs: number | null;
865
+ total_chars: number | null;
866
+ last_timestamp: number | null;
867
+ }
868
+
869
+ /**
870
+ * Per-model behavioral totals over the cutoff window. "Unknown" represents
871
+ * user messages that never received an assistant reply.
872
+ */
873
+ export function getBehaviorByModel(cutoff?: number | null): BehaviorModelStats[] {
874
+ if (!db) return [];
875
+ const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
876
+ const stmt = db.prepare(`
877
+ SELECT
878
+ COALESCE(model, ?) as model,
879
+ COALESCE(provider, ?) as provider,
880
+ COUNT(*) as total_messages,
881
+ SUM(yelling_sentences) as total_yelling_sentences,
882
+ SUM(profanity) as total_profanity,
883
+ SUM(drama_runs) as total_drama_runs,
884
+ SUM(chars) as total_chars,
885
+ MAX(timestamp) as last_timestamp
886
+ FROM user_messages
887
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
888
+ GROUP BY model, provider
889
+ ORDER BY total_messages DESC
890
+ `);
891
+ const rows = (
892
+ hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
893
+ ) as BehaviorByModelRow[];
894
+ return rows.map(row => ({
895
+ model: row.model,
896
+ provider: row.provider,
897
+ totalMessages: row.total_messages,
898
+ totalYellingSentences: row.total_yelling_sentences ?? 0,
899
+ totalProfanity: row.total_profanity ?? 0,
900
+ totalDramaRuns: row.total_drama_runs ?? 0,
901
+ totalChars: row.total_chars ?? 0,
902
+ lastTimestamp: row.last_timestamp ?? 0,
903
+ }));
904
+ }