@oh-my-pi/omp-stats 16.1.15 → 16.1.17

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.17] - 2026-06-24
6
+
7
+ ### Fixed
8
+
9
+ - Stats sync counted the same provider request multiple times when a forked or branched session file copied the parent's entries verbatim. Inserts now skip rows whose `(entry_id, timestamp)` already exists under a different `session_file`, and a one-shot migration on the next `omp stats` run collapses any pre-existing duplicates ([#3370](https://github.com/can1357/oh-my-pi/issues/3370)).
10
+
5
11
  ## [16.1.15] - 2026-06-22
6
12
 
7
13
  ### Added
@@ -17,6 +17,18 @@ export declare function getFileOffset(sessionFile: string): {
17
17
  export declare function setFileOffset(sessionFile: string, offset: number, lastModified: number): void;
18
18
  /**
19
19
  * Insert message stats into the database.
20
+ *
21
+ * Forked / branched sessions (see `SessionManager.fork()` and
22
+ * `createBranchedSession()` in `@oh-my-pi/pi-coding-agent`) deep-copy a parent
23
+ * session's entries into a new JSONL — same `entry_id`, `timestamp`, `model`,
24
+ * `provider`, token counts, and `responseId`. The `UNIQUE(session_file,
25
+ * entry_id)` constraint alone keys each row by file, so without the guard
26
+ * below the same provider request would land twice and inflate every
27
+ * aggregate. The `WHERE NOT EXISTS` clause skips inserts whose
28
+ * `(entry_id, timestamp)` already exists under a different `session_file` —
29
+ * first-write-wins across the lineage. Same-file re-syncs still hit the
30
+ * `ON CONFLICT(session_file, entry_id)` upsert below so historical
31
+ * `premium_requests` fix-ups continue to work.
20
32
  */
21
33
  export declare function insertMessageStats(stats: MessageStats[]): number;
22
34
  /**
@@ -72,6 +84,9 @@ export declare function markUserMessagesBackfillComplete(): void;
72
84
  export declare function markUserMessageLinksRepairComplete(): void;
73
85
  /**
74
86
  * Insert user-message stats. Idempotent via UNIQUE(session_file, entry_id).
87
+ * The `WHERE NOT EXISTS` clause matches {@link insertMessageStats}: forks
88
+ * copy user entries verbatim into the child JSONL, so the same
89
+ * `(entry_id, timestamp)` must not land twice across different session files.
75
90
  */
76
91
  export declare function insertUserMessageStats(stats: UserMessageStats[]): number;
77
92
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "16.1.15",
4
+ "version": "16.1.17",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,9 +37,9 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-ai": "16.1.15",
41
- "@oh-my-pi/pi-catalog": "16.1.15",
42
- "@oh-my-pi/pi-utils": "16.1.15",
40
+ "@oh-my-pi/pi-ai": "16.1.17",
41
+ "@oh-my-pi/pi-catalog": "16.1.17",
42
+ "@oh-my-pi/pi-utils": "16.1.17",
43
43
  "@tailwindcss/node": "^4.3.0",
44
44
  "chart.js": "^4.5.1",
45
45
  "date-fns": "^4.4.0",
package/src/db.ts CHANGED
@@ -45,6 +45,7 @@ const USER_MESSAGES_BACKFILL_KEY = "user_messages_v6";
45
45
  const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
46
46
  const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
47
47
  const AGENT_TYPE_BACKFILL_KEY = "agent_type_v1";
48
+ const FORK_DEDUPE_KEY = "fork_dedupe_v1";
48
49
  function shouldResetBackfill(value: string | undefined): boolean {
49
50
  return value !== BACKFILL_COMPLETE && value !== BACKFILL_PENDING;
50
51
  }
@@ -218,6 +219,7 @@ export async function initDb(): Promise<Database> {
218
219
  backfillPriorityPremiumRequests(db);
219
220
  backfillAgentType(db);
220
221
  backfillMissingCatalogCosts(db);
222
+ backfillForkDuplicates(db);
221
223
  return db;
222
224
  }
223
225
 
@@ -334,22 +336,34 @@ export function setFileOffset(sessionFile: string, offset: number, lastModified:
334
336
 
335
337
  /**
336
338
  * Insert message stats into the database.
339
+ *
340
+ * Forked / branched sessions (see `SessionManager.fork()` and
341
+ * `createBranchedSession()` in `@oh-my-pi/pi-coding-agent`) deep-copy a parent
342
+ * session's entries into a new JSONL — same `entry_id`, `timestamp`, `model`,
343
+ * `provider`, token counts, and `responseId`. The `UNIQUE(session_file,
344
+ * entry_id)` constraint alone keys each row by file, so without the guard
345
+ * below the same provider request would land twice and inflate every
346
+ * aggregate. The `WHERE NOT EXISTS` clause skips inserts whose
347
+ * `(entry_id, timestamp)` already exists under a different `session_file` —
348
+ * first-write-wins across the lineage. Same-file re-syncs still hit the
349
+ * `ON CONFLICT(session_file, entry_id)` upsert below so historical
350
+ * `premium_requests` fix-ups continue to work.
337
351
  */
338
352
  export function insertMessageStats(stats: MessageStats[]): number {
339
353
  if (!db || stats.length === 0) return 0;
340
354
 
341
- // Use UPSERT so a re-sync can fix up `premium_requests` for rows persisted
342
- // before priority service-tier traffic was counted as premium. The guard
343
- // `WHERE messages.premium_requests < excluded.premium_requests` keeps every
344
- // other column immutable and never demotes an existing count (e.g. when a
345
- // later parse drops back to 0 for the same row).
346
355
  const stmt = db.prepare(`
347
356
  INSERT INTO messages (
348
357
  session_file, entry_id, folder, model, provider, api, timestamp,
349
358
  duration, ttft, stop_reason, error_message,
350
359
  input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
351
360
  cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total, agent_type
352
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
361
+ )
362
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
363
+ WHERE NOT EXISTS (
364
+ SELECT 1 FROM messages
365
+ WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
366
+ )
353
367
  ON CONFLICT(session_file, entry_id) DO UPDATE SET
354
368
  premium_requests = excluded.premium_requests
355
369
  WHERE messages.premium_requests < excluded.premium_requests
@@ -383,6 +397,11 @@ export function insertMessageStats(stats: MessageStats[]): number {
383
397
  cost.cacheWrite,
384
398
  cost.total,
385
399
  s.agentType,
400
+ // `WHERE NOT EXISTS` binds: skip when a different session_file
401
+ // already holds this (entry_id, timestamp).
402
+ s.entryId,
403
+ s.timestamp,
404
+ s.sessionFile,
386
405
  );
387
406
  if (result.changes > 0) inserted++;
388
407
  }
@@ -893,6 +912,46 @@ function backfillAgentType(database: Database): void {
893
912
  apply();
894
913
  }
895
914
 
915
+ /**
916
+ * One-shot collapse of forked-session duplicates that landed under the old
917
+ * `UNIQUE(session_file, entry_id)`-only invariant. `SessionManager.fork()`
918
+ * and `createBranchedSession()` deep-copy a parent's entries into the new
919
+ * JSONL — same `entry_id`, `timestamp`, `model`, `responseId`, token counts,
920
+ * cost — and the previous insert path counted both files toward request /
921
+ * token / cost totals. The migration keeps the lowest-`id` row per
922
+ * `(entry_id, timestamp)` group (almost always the parent — sessions are
923
+ * filename-timestamped and sync processes them in name order, so the
924
+ * originating file lands first) and drops every other copy. Same fix on
925
+ * `user_messages` since forks copy user entries too. Idempotent and
926
+ * crash-safe: enrolled at module-load via the `meta` sentinel, marked
927
+ * COMPLETE inside the same transaction so an aborted run rolls back and
928
+ * retries on the next init.
929
+ */
930
+ function backfillForkDuplicates(database: Database): void {
931
+ const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(FORK_DEDUPE_KEY) as
932
+ | { value: string }
933
+ | undefined;
934
+ if (row?.value === BACKFILL_COMPLETE) return;
935
+
936
+ const markComplete = database.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
937
+ const apply = database.transaction(() => {
938
+ database.run(`
939
+ DELETE FROM messages
940
+ WHERE id NOT IN (
941
+ SELECT MIN(id) FROM messages GROUP BY entry_id, timestamp
942
+ )
943
+ `);
944
+ database.run(`
945
+ DELETE FROM user_messages
946
+ WHERE id NOT IN (
947
+ SELECT MIN(id) FROM user_messages GROUP BY entry_id, timestamp
948
+ )
949
+ `);
950
+ markComplete.run(FORK_DEDUPE_KEY, BACKFILL_COMPLETE);
951
+ });
952
+ apply();
953
+ }
954
+
896
955
  /**
897
956
  * One-shot wipe of `file_offsets` to force `parseSessionFile` to re-parse
898
957
  * every session from byte zero. We don't touch `user_messages`; the parser
@@ -961,6 +1020,9 @@ export function markUserMessageLinksRepairComplete(): void {
961
1020
 
962
1021
  /**
963
1022
  * Insert user-message stats. Idempotent via UNIQUE(session_file, entry_id).
1023
+ * The `WHERE NOT EXISTS` clause matches {@link insertMessageStats}: forks
1024
+ * copy user entries verbatim into the child JSONL, so the same
1025
+ * `(entry_id, timestamp)` must not land twice across different session files.
964
1026
  */
965
1027
  export function insertUserMessageStats(stats: UserMessageStats[]): number {
966
1028
  if (!db || stats.length === 0) return 0;
@@ -970,7 +1032,12 @@ export function insertUserMessageStats(stats: UserMessageStats[]): number {
970
1032
  session_file, entry_id, folder, timestamp, model, provider,
971
1033
  chars, words, yelling, profanity, anguish,
972
1034
  negation, repetition, blame
973
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1035
+ )
1036
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
1037
+ WHERE NOT EXISTS (
1038
+ SELECT 1 FROM user_messages
1039
+ WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
1040
+ )
974
1041
  `);
975
1042
 
976
1043
  let inserted = 0;
@@ -991,6 +1058,11 @@ export function insertUserMessageStats(stats: UserMessageStats[]): number {
991
1058
  s.negation,
992
1059
  s.repetition,
993
1060
  s.blame,
1061
+ // `WHERE NOT EXISTS` binds: skip when a different session_file
1062
+ // already holds this (entry_id, timestamp).
1063
+ s.entryId,
1064
+ s.timestamp,
1065
+ s.sessionFile,
994
1066
  );
995
1067
  if (result.changes > 0) inserted++;
996
1068
  }