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

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.
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,8 @@ import type {
11
14
  ModelStats,
12
15
  ModelTimeSeriesPoint,
13
16
  TimeSeriesPoint,
17
+ UserMessageLink,
18
+ UserMessageStats,
14
19
  } from "./types";
15
20
 
16
21
  type ModelCost = { input: number; output: number; cacheRead: number; cacheWrite: number };
@@ -74,12 +79,42 @@ export async function initDb(): Promise<Database> {
74
79
  CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);
75
80
  CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
76
81
  CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_file);
82
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp_model_provider ON messages(timestamp, model, provider);
83
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp_folder ON messages(timestamp, folder);
84
+ CREATE INDEX IF NOT EXISTS idx_messages_stop_reason_timestamp ON messages(stop_reason, timestamp);
77
85
 
78
86
  CREATE TABLE IF NOT EXISTS file_offsets (
79
87
  session_file TEXT PRIMARY KEY,
80
88
  offset INTEGER NOT NULL,
81
89
  last_modified INTEGER NOT NULL
82
90
  );
91
+
92
+ CREATE TABLE IF NOT EXISTS user_messages (
93
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ session_file TEXT NOT NULL,
95
+ entry_id TEXT NOT NULL,
96
+ folder TEXT NOT NULL,
97
+ timestamp INTEGER NOT NULL,
98
+ model TEXT,
99
+ provider TEXT,
100
+ chars INTEGER NOT NULL,
101
+ words INTEGER NOT NULL,
102
+ yelling INTEGER NOT NULL,
103
+ profanity INTEGER NOT NULL,
104
+ anguish INTEGER NOT NULL,
105
+ negation INTEGER NOT NULL DEFAULT 0,
106
+ repetition INTEGER NOT NULL DEFAULT 0,
107
+ blame INTEGER NOT NULL DEFAULT 0,
108
+ UNIQUE(session_file, entry_id)
109
+ );
110
+
111
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
112
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
113
+
114
+ CREATE TABLE IF NOT EXISTS meta (
115
+ key TEXT PRIMARY KEY,
116
+ value TEXT NOT NULL
117
+ );
83
118
  `);
84
119
 
85
120
  const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
@@ -87,6 +122,56 @@ export async function initDb(): Promise<Database> {
87
122
  db.exec("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
88
123
  }
89
124
  db.exec("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
125
+ // Each behavior-metric bump invalidates previously-ingested rows. We detect
126
+ // the stale schema by column name and drop the table; `IF NOT EXISTS` above
127
+ // already produced the new schema, but we want a clean wipe + re-ingest.
128
+ // `backfillUserMessages` then clears `file_offsets` so the next sync
129
+ // re-parses every session under the current metric definitions.
130
+ // v1 -> v2: yelling sentences replace `caps_words`.
131
+ // v2 -> v3: `drama_runs` folded into a single `anguish` signal that
132
+ // also captures elongated interjections, `dude`, and dot runs,
133
+ // gated on a stripped prose-line budget.
134
+ // v3 -> v4: added `negation`, `repetition`, `blame` frustration signals
135
+ // plus profanity dictionary expansion + word-boundary fix.
136
+ // v4 -> v5: column `yelling_sentences` renamed to `yelling` to match
137
+ // the other single-word signal columns.
138
+ const userMessageColumns = db.prepare("PRAGMA table_info(user_messages)").all() as {
139
+ name: string;
140
+ }[];
141
+ const hasStaleColumn =
142
+ userMessageColumns.length > 0 &&
143
+ (userMessageColumns.some(column => column.name === "caps_words") ||
144
+ userMessageColumns.some(column => column.name === "drama_runs") ||
145
+ userMessageColumns.some(column => column.name === "yelling_sentences"));
146
+ const hasV4Columns = userMessageColumns.some(column => column.name === "negation");
147
+ const hasOldUserMessages = userMessageColumns.length > 0;
148
+ if (hasStaleColumn || (hasOldUserMessages && !hasV4Columns)) {
149
+ db.exec("DROP TABLE user_messages");
150
+ db.exec(`
151
+ CREATE TABLE user_messages (
152
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
153
+ session_file TEXT NOT NULL,
154
+ entry_id TEXT NOT NULL,
155
+ folder TEXT NOT NULL,
156
+ timestamp INTEGER NOT NULL,
157
+ model TEXT,
158
+ provider TEXT,
159
+ chars INTEGER NOT NULL,
160
+ words INTEGER NOT NULL,
161
+ yelling INTEGER NOT NULL,
162
+ profanity INTEGER NOT NULL,
163
+ anguish INTEGER NOT NULL,
164
+ negation INTEGER NOT NULL DEFAULT 0,
165
+ repetition INTEGER NOT NULL DEFAULT 0,
166
+ blame INTEGER NOT NULL DEFAULT 0,
167
+ UNIQUE(session_file, entry_id)
168
+ );
169
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
170
+ CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
171
+ `);
172
+ }
173
+ backfillUserMessages(db);
174
+ repairUserMessageLinks(db);
90
175
  backfillMissingCatalogCosts(db);
91
176
  return db;
92
177
  }
@@ -312,9 +397,10 @@ function buildAggregatedStats(rows: any[]): AggregatedStats {
312
397
  /**
313
398
  * Get overall aggregated stats.
314
399
  */
315
- export function getOverallStats(): AggregatedStats {
400
+ export function getOverallStats(cutoff?: number): AggregatedStats {
316
401
  if (!db) return buildAggregatedStats([]);
317
402
 
403
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
318
404
  const stmt = db.prepare(`
319
405
  SELECT
320
406
  COUNT(*) as total_requests,
@@ -331,18 +417,19 @@ export function getOverallStats(): AggregatedStats {
331
417
  MIN(timestamp) as first_timestamp,
332
418
  MAX(timestamp) as last_timestamp
333
419
  FROM messages
420
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
334
421
  `);
335
422
 
336
- const rows = stmt.all();
337
- return buildAggregatedStats(rows);
423
+ const rows = hasCutoff ? stmt.all(cutoff) : stmt.all();
424
+ return buildAggregatedStats(rows as any[]);
338
425
  }
339
-
340
426
  /**
341
427
  * Get stats grouped by model.
342
428
  */
343
- export function getStatsByModel(): ModelStats[] {
429
+ export function getStatsByModel(cutoff?: number): ModelStats[] {
344
430
  if (!db) return [];
345
431
 
432
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
346
433
  const stmt = db.prepare(`
347
434
  SELECT
348
435
  model,
@@ -361,11 +448,12 @@ export function getStatsByModel(): ModelStats[] {
361
448
  MIN(timestamp) as first_timestamp,
362
449
  MAX(timestamp) as last_timestamp
363
450
  FROM messages
451
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
364
452
  GROUP BY model, provider
365
453
  ORDER BY total_requests DESC
366
454
  `);
367
455
 
368
- const rows = stmt.all() as any[];
456
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
369
457
  return rows.map(row => ({
370
458
  model: row.model,
371
459
  provider: row.provider,
@@ -376,9 +464,10 @@ export function getStatsByModel(): ModelStats[] {
376
464
  /**
377
465
  * Get stats grouped by folder.
378
466
  */
379
- export function getStatsByFolder(): FolderStats[] {
467
+ export function getStatsByFolder(cutoff?: number): FolderStats[] {
380
468
  if (!db) return [];
381
469
 
470
+ const hasCutoff = cutoff !== undefined && cutoff > 0;
382
471
  const stmt = db.prepare(`
383
472
  SELECT
384
473
  folder,
@@ -396,11 +485,12 @@ export function getStatsByFolder(): FolderStats[] {
396
485
  MIN(timestamp) as first_timestamp,
397
486
  MAX(timestamp) as last_timestamp
398
487
  FROM messages
488
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
399
489
  GROUP BY folder
400
490
  ORDER BY total_requests DESC
401
491
  `);
402
492
 
403
- const rows = stmt.all() as any[];
493
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
404
494
  return rows.map(row => ({
405
495
  folder: row.folder,
406
496
  ...buildAggregatedStats([row]),
@@ -408,27 +498,30 @@ export function getStatsByFolder(): FolderStats[] {
408
498
  }
409
499
 
410
500
  /**
411
- * Get hourly time series data.
501
+ * Get time series data.
412
502
  */
413
- export function getTimeSeries(hours = 24): TimeSeriesPoint[] {
503
+ export function getTimeSeries(hours = 24, cutoff?: number | null, bucketMs = 60 * 60 * 1000): TimeSeriesPoint[] {
414
504
  if (!db) return [];
415
505
 
416
- const cutoff = Date.now() - hours * 60 * 60 * 1000;
506
+ const hasCutoff = cutoff !== null;
507
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - hours * 60 * 60 * 1000) : 0;
417
508
 
418
509
  const stmt = db.prepare(`
419
510
  SELECT
420
- (timestamp / 3600000) * 3600000 as bucket,
511
+ (timestamp / ?) * ? as bucket,
421
512
  COUNT(*) as requests,
422
513
  SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as errors,
423
514
  SUM(total_tokens) as tokens,
424
515
  SUM(cost_total) as cost
425
516
  FROM messages
426
- WHERE timestamp >= ?
517
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
427
518
  GROUP BY bucket
428
519
  ORDER BY bucket ASC
429
520
  `);
430
521
 
431
- const rows = stmt.all(cutoff) as any[];
522
+ const rows = hasCutoff
523
+ ? (stmt.all(bucketMs, bucketMs, seriesCutoff) as any[])
524
+ : (stmt.all(bucketMs, bucketMs) as any[]);
432
525
  return rows.map(row => ({
433
526
  timestamp: row.bucket,
434
527
  requests: row.requests,
@@ -444,10 +537,11 @@ export function getTimeSeries(hours = 24): TimeSeriesPoint[] {
444
537
  /**
445
538
  * Get daily model usage time series data for the last N days.
446
539
  */
447
- export function getModelTimeSeries(days = 14): ModelTimeSeriesPoint[] {
540
+ export function getModelTimeSeries(days = 14, cutoff?: number | null): ModelTimeSeriesPoint[] {
448
541
  if (!db) return [];
449
542
 
450
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
543
+ const hasCutoff = cutoff !== null;
544
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
451
545
 
452
546
  const stmt = db.prepare(`
453
547
  SELECT
@@ -456,12 +550,12 @@ export function getModelTimeSeries(days = 14): ModelTimeSeriesPoint[] {
456
550
  provider,
457
551
  COUNT(*) as requests
458
552
  FROM messages
459
- WHERE timestamp >= ?
553
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
460
554
  GROUP BY bucket, model, provider
461
555
  ORDER BY bucket ASC
462
556
  `);
463
557
 
464
- const rows = stmt.all(cutoff) as any[];
558
+ const rows = hasCutoff ? (stmt.all(seriesCutoff) as any[]) : (stmt.all() as any[]);
465
559
  return rows.map(row => ({
466
560
  timestamp: row.bucket,
467
561
  model: row.model,
@@ -473,10 +567,11 @@ export function getModelTimeSeries(days = 14): ModelTimeSeriesPoint[] {
473
567
  /**
474
568
  * Get daily model performance time series data for the last N days.
475
569
  */
476
- export function getModelPerformanceSeries(days = 14): ModelPerformancePoint[] {
570
+ export function getModelPerformanceSeries(days = 14, cutoff?: number | null): ModelPerformancePoint[] {
477
571
  if (!db) return [];
478
572
 
479
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
573
+ const hasCutoff = cutoff !== null;
574
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
480
575
 
481
576
  const stmt = db.prepare(`
482
577
  SELECT
@@ -487,12 +582,12 @@ export function getModelPerformanceSeries(days = 14): ModelPerformancePoint[] {
487
582
  AVG(ttft) as avg_ttft,
488
583
  AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
489
584
  FROM messages
490
- WHERE timestamp >= ?
585
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
491
586
  GROUP BY bucket, model, provider
492
587
  ORDER BY bucket ASC
493
588
  `);
494
589
 
495
- const rows = stmt.all(cutoff) as any[];
590
+ const rows = hasCutoff ? (stmt.all(seriesCutoff) as any[]) : (stmt.all() as any[]);
496
591
  return rows.map(row => ({
497
592
  timestamp: row.bucket,
498
593
  model: row.model,
@@ -586,10 +681,11 @@ export function getMessageById(id: number): MessageStats | null {
586
681
  /**
587
682
  * Get daily cost time series data for the last N days, broken down by model.
588
683
  */
589
- export function getCostTimeSeries(days = 90): CostTimeSeriesPoint[] {
684
+ export function getCostTimeSeries(days = 90, cutoff?: number | null): CostTimeSeriesPoint[] {
590
685
  if (!db) return [];
591
686
 
592
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
687
+ const hasCutoff = cutoff !== null;
688
+ const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
593
689
 
594
690
  const stmt = db.prepare(`
595
691
  SELECT
@@ -603,12 +699,12 @@ export function getCostTimeSeries(days = 90): CostTimeSeriesPoint[] {
603
699
  SUM(cost_cache_write) as cost_cache_write,
604
700
  COUNT(*) as requests
605
701
  FROM messages
606
- WHERE timestamp >= ?
702
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
607
703
  GROUP BY bucket, model, provider
608
704
  ORDER BY bucket ASC
609
705
  `);
610
706
 
611
- const rows = stmt.all(cutoff) as any[];
707
+ const rows = hasCutoff ? (stmt.all(seriesCutoff) as any[]) : (stmt.all() as any[]);
612
708
  return rows.map(row => ({
613
709
  timestamp: row.bucket,
614
710
  model: row.model,
@@ -621,3 +717,301 @@ export function getCostTimeSeries(days = 90): CostTimeSeriesPoint[] {
621
717
  requests: row.requests,
622
718
  }));
623
719
  }
720
+
721
+ /**
722
+ * Reset `file_offsets` (and any existing `user_messages` rows) so the next
723
+ * sync re-parses every session and re-derives behavioral metrics. Run once
724
+ * per metric-definition bump; the meta sentinel records the version.
725
+ *
726
+ * - v1: initial introduction of `user_messages`.
727
+ * - v2: yelling-sentence metric replaces caps-word counts; existing rows are
728
+ * computed under the old definition and must be discarded.
729
+ * - v3: drama runs collapsed into `anguish` (drama + elongated interjections
730
+ * + `dude` + dot runs), scored on a stripped prose body and gated on
731
+ * line count. Existing rows used the narrower definition.
732
+ * - v4: added `negation` / `repetition` / `blame` signals and fixed a
733
+ * latent word-boundary bug in the profanity / anguish regexes that had
734
+ * left those metrics matching nothing in real prose.
735
+ * - v5: renamed `yelling_sentences` column to `yelling` to match the other
736
+ * single-word signal columns (profanity, anguish, negation, ...).
737
+ *
738
+ * Existing `messages` rows are unaffected - `INSERT OR IGNORE` keeps them.
739
+ */
740
+ function backfillUserMessages(database: Database): void {
741
+ const row = database.prepare("SELECT value FROM meta WHERE key = 'user_messages_v5'").get() as
742
+ | { value: string }
743
+ | undefined;
744
+ if (row) return;
745
+
746
+ database.exec("DELETE FROM user_messages");
747
+ database.exec("DELETE FROM file_offsets");
748
+ database
749
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
750
+ .run("user_messages_v5", String(Date.now()));
751
+ }
752
+
753
+ /**
754
+ * One-shot wipe of `file_offsets` to force `parseSessionFile` to re-parse
755
+ * every session from byte zero. We don't touch `user_messages`; the parser
756
+ * now emits a `UserMessageLink` for every assistant->parent pair, and the
757
+ * guarded `updateUserMessageLinks` UPDATE fixes any row whose `model` was
758
+ * left NULL by the old in-pass-only linking logic. Idempotent: gated by a
759
+ * sentinel row in `meta`.
760
+ */
761
+ function repairUserMessageLinks(database: Database): void {
762
+ const row = database.prepare("SELECT value FROM meta WHERE key = 'user_message_links_v1'").get() as
763
+ | { value: string }
764
+ | undefined;
765
+ if (row) return;
766
+
767
+ database.exec("DELETE FROM file_offsets");
768
+ database
769
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
770
+ .run("user_message_links_v1", String(Date.now()));
771
+ }
772
+
773
+ /**
774
+ * Insert user-message stats. Idempotent via UNIQUE(session_file, entry_id).
775
+ */
776
+ export function insertUserMessageStats(stats: UserMessageStats[]): number {
777
+ if (!db || stats.length === 0) return 0;
778
+
779
+ const stmt = db.prepare(`
780
+ INSERT OR IGNORE INTO user_messages (
781
+ session_file, entry_id, folder, timestamp, model, provider,
782
+ chars, words, yelling, profanity, anguish,
783
+ negation, repetition, blame
784
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
785
+ `);
786
+
787
+ let inserted = 0;
788
+ const insert = db.transaction(() => {
789
+ for (const s of stats) {
790
+ const result = stmt.run(
791
+ s.sessionFile,
792
+ s.entryId,
793
+ s.folder,
794
+ s.timestamp,
795
+ s.model,
796
+ s.provider,
797
+ s.chars,
798
+ s.words,
799
+ s.yelling,
800
+ s.profanity,
801
+ s.anguish,
802
+ s.negation,
803
+ s.repetition,
804
+ s.blame,
805
+ );
806
+ if (result.changes > 0) inserted++;
807
+ }
808
+ });
809
+ insert();
810
+ return inserted;
811
+ }
812
+
813
+ /**
814
+ * Backfill the responding `model`/`provider` on user-message rows that were
815
+ * persisted before their assistant reply was parsed (a side effect of
816
+ * incremental `fromOffset` syncing: the `userByEntryId` map in
817
+ * `parseSessionFile` only spans a single pass). Each row is updated at most
818
+ * once because the `model IS NULL` guard short-circuits subsequent passes.
819
+ *
820
+ * Returns the number of rows actually updated.
821
+ */
822
+ export function updateUserMessageLinks(links: UserMessageLink[]): number {
823
+ if (!db || links.length === 0) return 0;
824
+
825
+ const stmt = db.prepare(`
826
+ UPDATE user_messages
827
+ SET model = ?, provider = ?
828
+ WHERE session_file = ? AND entry_id = ? AND model IS NULL
829
+ `);
830
+
831
+ let updated = 0;
832
+ const apply = db.transaction(() => {
833
+ for (const link of links) {
834
+ const result = stmt.run(link.model, link.provider, link.sessionFile, link.entryId);
835
+ if (result.changes > 0) updated++;
836
+ }
837
+ });
838
+ apply();
839
+ return updated;
840
+ }
841
+
842
+ const UNKNOWN_MODEL = "unknown";
843
+
844
+ interface BehaviorSeriesRow {
845
+ bucket: number;
846
+ model: string;
847
+ provider: string;
848
+ messages: number;
849
+ yelling: number | null;
850
+ profanity: number | null;
851
+ anguish: number | null;
852
+ negation: number | null;
853
+ repetition: number | null;
854
+ blame: number | null;
855
+ chars: number | null;
856
+ }
857
+
858
+ /**
859
+ * Daily behavioral time series, grouped by responding model+provider.
860
+ */
861
+ export function getBehaviorTimeSeries(cutoff?: number | null): BehaviorTimeSeriesPoint[] {
862
+ if (!db) return [];
863
+ const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
864
+ const stmt = db.prepare(`
865
+ SELECT
866
+ (timestamp / 86400000) * 86400000 as bucket,
867
+ COALESCE(model, ?) as model,
868
+ COALESCE(provider, ?) as provider,
869
+ COUNT(*) as messages,
870
+ SUM(yelling) as yelling,
871
+ SUM(profanity) as profanity,
872
+ SUM(anguish) as anguish,
873
+ SUM(negation) as negation,
874
+ SUM(repetition) as repetition,
875
+ SUM(blame) as blame,
876
+ SUM(chars) as chars
877
+ FROM user_messages
878
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
879
+ GROUP BY bucket, model, provider
880
+ ORDER BY bucket ASC
881
+ `);
882
+ const rows = (
883
+ hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
884
+ ) as BehaviorSeriesRow[];
885
+ return rows.map(row => ({
886
+ timestamp: row.bucket,
887
+ model: row.model,
888
+ provider: row.provider,
889
+ messages: row.messages,
890
+ yelling: row.yelling ?? 0,
891
+ profanity: row.profanity ?? 0,
892
+ anguish: row.anguish ?? 0,
893
+ negation: row.negation ?? 0,
894
+ repetition: row.repetition ?? 0,
895
+ blame: row.blame ?? 0,
896
+ chars: row.chars ?? 0,
897
+ }));
898
+ }
899
+
900
+ interface BehaviorOverallRow {
901
+ total_messages: number;
902
+ total_yelling: number | null;
903
+ total_profanity: number | null;
904
+ total_anguish: number | null;
905
+ total_negation: number | null;
906
+ total_repetition: number | null;
907
+ total_blame: number | null;
908
+ total_chars: number | null;
909
+ first_timestamp: number | null;
910
+ last_timestamp: number | null;
911
+ }
912
+
913
+ /**
914
+ * Overall behavioral totals across the cutoff window.
915
+ */
916
+ export function getBehaviorOverall(cutoff?: number | null): BehaviorOverallStats {
917
+ const empty: BehaviorOverallStats = {
918
+ totalMessages: 0,
919
+ totalYelling: 0,
920
+ totalProfanity: 0,
921
+ totalAnguish: 0,
922
+ totalNegation: 0,
923
+ totalRepetition: 0,
924
+ totalBlame: 0,
925
+ totalChars: 0,
926
+ firstTimestamp: 0,
927
+ lastTimestamp: 0,
928
+ };
929
+ if (!db) return empty;
930
+ const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
931
+ const stmt = db.prepare(`
932
+ SELECT
933
+ COUNT(*) as total_messages,
934
+ SUM(yelling) as total_yelling,
935
+ SUM(profanity) as total_profanity,
936
+ SUM(anguish) as total_anguish,
937
+ SUM(negation) as total_negation,
938
+ SUM(repetition) as total_repetition,
939
+ SUM(blame) as total_blame,
940
+ SUM(chars) as total_chars,
941
+ MIN(timestamp) as first_timestamp,
942
+ MAX(timestamp) as last_timestamp
943
+ FROM user_messages
944
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
945
+ `);
946
+ const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as BehaviorOverallRow | undefined;
947
+ if (!row?.total_messages) return empty;
948
+ return {
949
+ totalMessages: row.total_messages,
950
+ totalYelling: row.total_yelling ?? 0,
951
+ totalProfanity: row.total_profanity ?? 0,
952
+ totalAnguish: row.total_anguish ?? 0,
953
+ totalNegation: row.total_negation ?? 0,
954
+ totalRepetition: row.total_repetition ?? 0,
955
+ totalBlame: row.total_blame ?? 0,
956
+ totalChars: row.total_chars ?? 0,
957
+ firstTimestamp: row.first_timestamp ?? 0,
958
+ lastTimestamp: row.last_timestamp ?? 0,
959
+ };
960
+ }
961
+
962
+ interface BehaviorByModelRow {
963
+ model: string;
964
+ provider: string;
965
+ total_messages: number;
966
+ total_yelling: number | null;
967
+ total_profanity: number | null;
968
+ total_anguish: number | null;
969
+ total_negation: number | null;
970
+ total_repetition: number | null;
971
+ total_blame: number | null;
972
+ total_chars: number | null;
973
+ last_timestamp: number | null;
974
+ }
975
+
976
+ /**
977
+ * Per-model behavioral totals over the cutoff window. "Unknown" represents
978
+ * user messages that never received an assistant reply.
979
+ */
980
+ export function getBehaviorByModel(cutoff?: number | null): BehaviorModelStats[] {
981
+ if (!db) return [];
982
+ const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
983
+ const stmt = db.prepare(`
984
+ SELECT
985
+ COALESCE(model, ?) as model,
986
+ COALESCE(provider, ?) as provider,
987
+ COUNT(*) as total_messages,
988
+ SUM(yelling) as total_yelling,
989
+ SUM(profanity) as total_profanity,
990
+ SUM(anguish) as total_anguish,
991
+ SUM(negation) as total_negation,
992
+ SUM(repetition) as total_repetition,
993
+ SUM(blame) as total_blame,
994
+ SUM(chars) as total_chars,
995
+ MAX(timestamp) as last_timestamp
996
+ FROM user_messages
997
+ ${hasCutoff ? "WHERE timestamp >= ?" : ""}
998
+ GROUP BY model, provider
999
+ ORDER BY total_messages DESC
1000
+ `);
1001
+ const rows = (
1002
+ hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
1003
+ ) as BehaviorByModelRow[];
1004
+ return rows.map(row => ({
1005
+ model: row.model,
1006
+ provider: row.provider,
1007
+ totalMessages: row.total_messages,
1008
+ totalYelling: row.total_yelling ?? 0,
1009
+ totalProfanity: row.total_profanity ?? 0,
1010
+ totalAnguish: row.total_anguish ?? 0,
1011
+ totalNegation: row.total_negation ?? 0,
1012
+ totalRepetition: row.total_repetition ?? 0,
1013
+ totalBlame: row.total_blame ?? 0,
1014
+ totalChars: row.total_chars ?? 0,
1015
+ lastTimestamp: row.last_timestamp ?? 0,
1016
+ }));
1017
+ }
package/src/index.ts CHANGED
@@ -6,7 +6,13 @@ import { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggr
6
6
  import { closeDb } from "./db";
7
7
  import { startServer } from "./server";
8
8
 
9
- export { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
9
+ export {
10
+ getDashboardStats,
11
+ getTotalMessageCount,
12
+ type SyncOptions,
13
+ type SyncProgress,
14
+ syncAllSessions,
15
+ } from "./aggregator";
10
16
  export { closeDb } from "./db";
11
17
  export { startServer } from "./server";
12
18
  export type {
@@ -112,8 +118,28 @@ Examples:
112
118
 
113
119
  try {
114
120
  // Sync first
115
- console.log("Syncing session files...");
116
- const { processed, files } = await syncAllSessions();
121
+ const tty = process.stderr.isTTY === true;
122
+ process.stderr.write("Syncing session files...\n");
123
+ let lastWidth = 0;
124
+ let lastRender = 0;
125
+ const { processed, files } = await syncAllSessions({
126
+ onProgress: event => {
127
+ if (!tty) return;
128
+ const now = Date.now();
129
+ if (event.current < event.total && now - lastRender < 33) return;
130
+ lastRender = now;
131
+ const marker = "/sessions/";
132
+ const idx = event.sessionFile.indexOf(marker);
133
+ const short = idx >= 0 ? event.sessionFile.slice(idx + marker.length) : event.sessionFile;
134
+ const pct = ((event.current / event.total) * 100).toFixed(0).padStart(3, " ");
135
+ const line = `[${event.current}/${event.total}] ${pct}% ${short}`;
136
+ const columns = process.stderr.columns ?? 120;
137
+ const clipped = line.length > columns - 1 ? `${line.slice(0, columns - 2)}\u2026` : line;
138
+ process.stderr.write(`\r${clipped.padEnd(lastWidth)}`);
139
+ lastWidth = clipped.length;
140
+ },
141
+ });
142
+ if (tty && lastWidth > 0) process.stderr.write(`\r${" ".repeat(lastWidth)}\r`);
117
143
  const total = await getTotalMessageCount();
118
144
  console.log(`Synced ${processed} new entries from ${files} files (${total} total)\n`);
119
145