@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/package.json +6 -6
- package/src/aggregator.ts +279 -42
- package/src/client/App.tsx +125 -30
- package/src/client/api.ts +35 -3
- package/src/client/components/BehaviorChart.tsx +374 -0
- package/src/client/components/BehaviorModelsTable.tsx +465 -0
- package/src/client/components/BehaviorSummary.tsx +95 -0
- package/src/client/components/CostChart.tsx +5 -38
- package/src/client/components/CostSummary.tsx +8 -47
- package/src/client/components/Header.tsx +28 -4
- package/src/client/components/StatsGrid.tsx +10 -1
- package/src/client/types.ts +63 -0
- package/src/db.ts +420 -26
- package/src/index.ts +29 -3
- package/src/parser.ts +95 -7
- package/src/server.ts +30 -6
- package/src/sync-worker.ts +31 -0
- package/src/types.ts +113 -0
- package/src/user-metrics.ts +686 -0
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
|
|
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
|
|
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 /
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|