@oh-my-pi/omp-stats 16.2.5 → 16.2.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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.2.7] - 2026-06-30
6
+
7
+ ### Fixed
8
+
9
+ - Improved premium request calculation accuracy by correctly accounting for specific model families.
10
+
11
+ ## [16.2.6] - 2026-06-29
12
+
13
+ ### Fixed
14
+
15
+ - Fixed application crashes and Bun aborts on macOS and when parsing large stats session files, including during `omp --smoke-test` runs, by utilizing a more resilient serial parser and lenient line scanner.
16
+
5
17
  ## [16.2.3] - 2026-06-28
6
18
 
7
19
  ### Added
@@ -29,8 +29,13 @@ export interface SyncOptions {
29
29
  * spawn path on a fresh install (no session files = early return), so a
30
30
  * dedicated probe is the only reliable signal.
31
31
  *
32
- * Resolves with the worker's `import.meta.url` (caller-visible diagnostics);
33
- * rejects on transport error, error response, or timeout.
32
+ * No-op on darwin: `syncAllSessions` keeps macOS on the serial parser path
33
+ * (see {@link defaultWorkerCount}) so the worker spawn surface is unreachable
34
+ * from the CLI, and probing it under the hardened runtime in
35
+ * `scripts/ci-macos-sign.sh` would re-enter the Bun-worker abort surface that
36
+ * motivated the darwin serial default in the first place.
37
+ *
38
+ * Rejects on transport error, error response, or timeout.
34
39
  */
35
40
  export declare function smokeTestSyncWorker({ timeoutMs }?: {
36
41
  timeoutMs?: number;
@@ -38,11 +43,11 @@ export declare function smokeTestSyncWorker({ timeoutMs }?: {
38
43
  /**
39
44
  * Sync all session files to the database.
40
45
  *
41
- * Parsing fans out across a worker pool (one in-flight job per worker)
42
- * while DB writes and offset bookkeeping stay on the calling thread so the
43
- * single SQLite handle stays uncontended. `onProgress` fires once per
44
- * completed file (skipped files included so the bar walks at a steady
45
- * rate).
46
+ * `workers: 1` parses inline. Larger pools fan parsing out across workers
47
+ * (one in-flight job per worker) while DB writes and offset bookkeeping stay on
48
+ * the calling thread so the single SQLite handle stays uncontended.
49
+ * `onProgress` fires once per completed file (skipped files included so the
50
+ * bar walks at a steady rate).
46
51
  */
47
52
  export declare function syncAllSessions(opts?: SyncOptions): Promise<{
48
53
  processed: number;
@@ -1,4 +1,4 @@
1
- import type { AssistantMessage, ServiceTier, StopReason, Usage } from "@oh-my-pi/pi-ai";
1
+ import type { AssistantMessage, ServiceTier, ServiceTierByFamily, StopReason, Usage } from "@oh-my-pi/pi-ai";
2
2
  import type { AgentType } from "./shared-types";
3
3
  export * from "./shared-types";
4
4
  /**
@@ -68,7 +68,7 @@ export interface SessionServiceTierChangeEntry {
68
68
  id: string;
69
69
  parentId?: string | null;
70
70
  timestamp: string;
71
- serviceTier: ServiceTier | null;
71
+ serviceTier: ServiceTierByFamily | ServiceTier | null;
72
72
  }
73
73
  export type SessionEntry = SessionHeader | SessionMessageEntry | SessionServiceTierChangeEntry | {
74
74
  type: string;
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.2.5",
4
+ "version": "16.2.7",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -39,9 +39,9 @@
39
39
  "fmt": "biome format --write ."
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "16.2.5",
43
- "@oh-my-pi/pi-catalog": "16.2.5",
44
- "@oh-my-pi/pi-utils": "16.2.5",
42
+ "@oh-my-pi/pi-ai": "16.2.7",
43
+ "@oh-my-pi/pi-catalog": "16.2.7",
44
+ "@oh-my-pi/pi-utils": "16.2.7",
45
45
  "@tailwindcss/node": "^4.3.0",
46
46
  "chart.js": "^4.5.1",
47
47
  "date-fns": "^4.4.0",
package/src/aggregator.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  setFileOffset,
24
24
  updateUserMessageLinks,
25
25
  } from "./db";
26
- import { getSessionEntry, listAllSessionFiles, type ParseSessionResult } from "./parser";
26
+ import { getSessionEntry, listAllSessionFiles, type ParseSessionResult, parseSessionFile } from "./parser";
27
27
  import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker";
28
28
  // Coding-agent binary/bundle workers route through the CLI entrypoint with a
29
29
  // hidden argv mode, so the compiled binary and npm bundle only need one
@@ -68,6 +68,9 @@ export interface SyncOptions {
68
68
  }
69
69
 
70
70
  function defaultWorkerCount(): number {
71
+ // Bun 1.3.x can abort the macOS process when stats sync workers re-enter
72
+ // the compiled `omp` binary. Keep macOS on the documented serial path.
73
+ if (process.platform === "darwin") return 1;
71
74
  // `navigator.hardwareConcurrency` is the portable answer in Bun; fall
72
75
  // back to a small fixed pool if it's somehow unavailable.
73
76
  const hw = typeof navigator !== "undefined" ? (navigator.hardwareConcurrency ?? 0) : 0;
@@ -149,10 +152,16 @@ function dispatch(handle: WorkerHandle, request: SyncWorkerRequest): Promise<Par
149
152
  * spawn path on a fresh install (no session files = early return), so a
150
153
  * dedicated probe is the only reliable signal.
151
154
  *
152
- * Resolves with the worker's `import.meta.url` (caller-visible diagnostics);
153
- * rejects on transport error, error response, or timeout.
155
+ * No-op on darwin: `syncAllSessions` keeps macOS on the serial parser path
156
+ * (see {@link defaultWorkerCount}) so the worker spawn surface is unreachable
157
+ * from the CLI, and probing it under the hardened runtime in
158
+ * `scripts/ci-macos-sign.sh` would re-enter the Bun-worker abort surface that
159
+ * motivated the darwin serial default in the first place.
160
+ *
161
+ * Rejects on transport error, error response, or timeout.
154
162
  */
155
163
  export async function smokeTestSyncWorker({ timeoutMs = 5_000 }: { timeoutMs?: number } = {}): Promise<void> {
164
+ if (process.platform === "darwin") return;
156
165
  const worker = createSyncWorker();
157
166
  const { promise, resolve, reject } = Promise.withResolvers<void>();
158
167
  const timer = setTimeout(() => reject(new Error(`sync worker did not pong within ${timeoutMs}ms`)), timeoutMs);
@@ -183,11 +192,11 @@ export async function smokeTestSyncWorker({ timeoutMs = 5_000 }: { timeoutMs?: n
183
192
  /**
184
193
  * Sync all session files to the database.
185
194
  *
186
- * Parsing fans out across a worker pool (one in-flight job per worker)
187
- * while DB writes and offset bookkeeping stay on the calling thread so the
188
- * single SQLite handle stays uncontended. `onProgress` fires once per
189
- * completed file (skipped files included so the bar walks at a steady
190
- * rate).
195
+ * `workers: 1` parses inline. Larger pools fan parsing out across workers
196
+ * (one in-flight job per worker) while DB writes and offset bookkeeping stay on
197
+ * the calling thread so the single SQLite handle stays uncontended.
198
+ * `onProgress` fires once per completed file (skipped files included so the
199
+ * bar walks at a steady rate).
191
200
  */
192
201
  export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed: number; files: number }> {
193
202
  await initDb();
@@ -200,10 +209,6 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed:
200
209
  let completed = 0;
201
210
  let cursor = 0;
202
211
 
203
- const poolSize = Math.max(1, Math.min(files.length, opts?.workers ?? defaultWorkerCount()));
204
- const handles: WorkerHandle[] = [];
205
- for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
206
-
207
212
  const report = (sessionFile: string) => {
208
213
  completed++;
209
214
  opts?.onProgress?.({
@@ -214,34 +219,53 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed:
214
219
  });
215
220
  };
216
221
 
222
+ const processFile = async (
223
+ sessionFile: string,
224
+ parse: (sessionFile: string, fromOffset: number) => Promise<ParseSessionResult>,
225
+ ): Promise<void> => {
226
+ let fileStats: fs.Stats;
227
+ try {
228
+ fileStats = await fs.promises.stat(sessionFile);
229
+ } catch {
230
+ report(sessionFile);
231
+ return;
232
+ }
233
+ const lastModified = fileStats.mtimeMs;
234
+ const stored = getFileOffset(sessionFile);
235
+ if (stored && stored.lastModified >= lastModified) {
236
+ report(sessionFile);
237
+ return;
238
+ }
239
+
240
+ const fromOffset = stored?.offset ?? 0;
241
+ const result = await parse(sessionFile, fromOffset);
242
+ const inserted = applyParseResult(sessionFile, lastModified, result);
243
+ if (inserted > 0) {
244
+ totalProcessed += inserted;
245
+ filesProcessed++;
246
+ }
247
+ report(sessionFile);
248
+ };
249
+
250
+ const requestedWorkers = Math.max(1, Math.floor(opts?.workers ?? defaultWorkerCount()));
251
+ if (requestedWorkers === 1) {
252
+ for (const sessionFile of files) {
253
+ await processFile(sessionFile, parseSessionFile);
254
+ }
255
+ return { processed: totalProcessed, files: filesProcessed };
256
+ }
257
+
258
+ const poolSize = Math.min(files.length, requestedWorkers);
259
+
260
+ const handles: WorkerHandle[] = [];
261
+ for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
262
+
217
263
  async function drain(handle: WorkerHandle): Promise<void> {
218
264
  while (true) {
219
265
  const idx = cursor++;
220
266
  if (idx >= files.length) return;
221
267
  const sessionFile = files[idx];
222
-
223
- let fileStats: fs.Stats;
224
- try {
225
- fileStats = await fs.promises.stat(sessionFile);
226
- } catch {
227
- report(sessionFile);
228
- continue;
229
- }
230
- const lastModified = fileStats.mtimeMs;
231
- const stored = getFileOffset(sessionFile);
232
- if (stored && stored.lastModified >= lastModified) {
233
- report(sessionFile);
234
- continue;
235
- }
236
-
237
- const fromOffset = stored?.offset ?? 0;
238
- const result = await dispatch(handle, { sessionFile, fromOffset });
239
- const inserted = applyParseResult(sessionFile, lastModified, result);
240
- if (inserted > 0) {
241
- totalProcessed += inserted;
242
- filesProcessed++;
243
- }
244
- report(sessionFile);
268
+ await processFile(sessionFile, (file, fromOffset) => dispatch(handle, { sessionFile: file, fromOffset }));
245
269
  }
246
270
  }
247
271
 
package/src/parser.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { type AssistantMessage, getPriorityPremiumRequests, type ServiceTier } from "@oh-my-pi/pi-ai";
3
+ import {
4
+ type AssistantMessage,
5
+ coerceServiceTierByFamily,
6
+ getPriorityPremiumRequests,
7
+ resolveModelServiceTier,
8
+ type ServiceTierByFamily,
9
+ } from "@oh-my-pi/pi-ai";
4
10
  import { getSessionsDir, isEnoent } from "@oh-my-pi/pi-utils";
5
11
  import type {
6
12
  AgentType,
@@ -130,7 +136,7 @@ function extractStats(
130
136
  sessionFile: string,
131
137
  folder: string,
132
138
  entry: SessionMessageEntry,
133
- currentServiceTier: ServiceTier | undefined,
139
+ currentServiceTier: ServiceTierByFamily | undefined,
134
140
  agentType: AgentType,
135
141
  ): MessageStats | null {
136
142
  const msg = entry.message as AssistantMessage;
@@ -143,7 +149,9 @@ function extractStats(
143
149
  // non-zero value already in `usage.premiumRequests` (Copilot multipliers or
144
150
  // the new AI code path) and only synthesise when the field is missing/zero.
145
151
  const recorded = msg.usage.premiumRequests ?? 0;
146
- const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(currentServiceTier, msg.provider);
152
+ const model = { provider: msg.provider, api: msg.api, id: msg.model };
153
+ const tier = resolveModelServiceTier(currentServiceTier, model);
154
+ const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(tier, model);
147
155
  const usage = derived === recorded ? msg.usage : { ...msg.usage, premiumRequests: derived };
148
156
 
149
157
  return {
@@ -164,54 +172,53 @@ function extractStats(
164
172
  }
165
173
 
166
174
  const LF = 0x0a;
175
+ const CR = 0x0d;
176
+ const jsonLineDecoder = new TextDecoder();
167
177
 
168
- function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[]; read: number } {
169
- const entries: SessionEntry[] = [];
170
- let cursor = 0;
171
-
172
- while (cursor < bytes.length) {
173
- const { values, error, read, done } = Bun.JSONL.parseChunk(bytes, cursor, bytes.length);
174
- for (const value of values as SessionEntry[]) {
175
- entries.push(value);
176
- }
177
-
178
- if (error) {
179
- const nextNewline = bytes.indexOf(LF, Math.max(read, cursor));
180
- if (nextNewline === -1) break;
181
- cursor = nextNewline + 1;
182
- continue;
183
- }
184
-
185
- if (read <= cursor) break;
186
- cursor = read;
187
- if (done) break;
178
+ function parseJsonLine(bytes: Uint8Array, start: number, end: number): SessionEntry | null {
179
+ while (end > start && bytes[end - 1] === CR) end--;
180
+ if (end <= start) return null;
181
+ try {
182
+ return JSON.parse(jsonLineDecoder.decode(bytes.subarray(start, end))) as SessionEntry;
183
+ } catch {
184
+ return null;
188
185
  }
189
-
190
- return { entries, read: cursor };
191
186
  }
192
187
 
193
- function scanLastServiceTier(bytes: Uint8Array): ServiceTier | undefined {
188
+ function visitSessionEntriesLenient(bytes: Uint8Array, visit: (entry: SessionEntry) => void): number {
194
189
  let cursor = 0;
195
- let currentServiceTier: ServiceTier | undefined;
190
+ let read = 0;
196
191
 
197
192
  while (cursor < bytes.length) {
198
- const { values, error, read, done } = Bun.JSONL.parseChunk(bytes, cursor, bytes.length);
199
- for (const value of values as SessionEntry[]) {
200
- if (isServiceTierChange(value)) currentServiceTier = value.serviceTier ?? undefined;
193
+ const newline = bytes.indexOf(LF, cursor);
194
+ const hasNewline = newline !== -1;
195
+ const lineEnd = hasNewline ? newline : bytes.length;
196
+ const entry = parseJsonLine(bytes, cursor, lineEnd);
197
+ if (entry) {
198
+ visit(entry);
199
+ read = hasNewline ? newline + 1 : lineEnd;
200
+ } else if (hasNewline) {
201
+ read = newline + 1;
202
+ } else {
203
+ break;
201
204
  }
205
+ cursor = hasNewline ? newline + 1 : lineEnd;
206
+ }
202
207
 
203
- if (error) {
204
- const nextNewline = bytes.indexOf(LF, Math.max(read, cursor));
205
- if (nextNewline === -1) break;
206
- cursor = nextNewline + 1;
207
- continue;
208
- }
208
+ return read;
209
+ }
209
210
 
210
- if (read <= cursor) break;
211
- cursor = read;
212
- if (done) break;
213
- }
211
+ function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[]; read: number } {
212
+ const entries: SessionEntry[] = [];
213
+ const read = visitSessionEntriesLenient(bytes, entry => entries.push(entry));
214
+ return { entries, read };
215
+ }
214
216
 
217
+ function scanLastServiceTier(bytes: Uint8Array): ServiceTierByFamily | undefined {
218
+ let currentServiceTier: ServiceTierByFamily | undefined;
219
+ visitSessionEntriesLenient(bytes, entry => {
220
+ if (isServiceTierChange(entry)) currentServiceTier = coerceServiceTierByFamily(entry.serviceTier);
221
+ });
215
222
  return currentServiceTier;
216
223
  }
217
224
  /**
@@ -254,13 +261,13 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
254
261
  const start = Math.max(0, Math.min(fromOffset, bytes.length));
255
262
  const unprocessed = bytes.subarray(start);
256
263
  const { entries, read } = parseSessionEntriesLenient(unprocessed);
257
- let currentServiceTier: ServiceTier | undefined;
264
+ let currentServiceTier: ServiceTierByFamily | undefined;
258
265
  if (start > 0) {
259
266
  currentServiceTier = scanLastServiceTier(bytes.subarray(0, start));
260
267
  }
261
268
  for (const entry of entries) {
262
269
  if (isServiceTierChange(entry)) {
263
- currentServiceTier = entry.serviceTier ?? undefined;
270
+ currentServiceTier = coerceServiceTierByFamily(entry.serviceTier);
264
271
  continue;
265
272
  }
266
273
  if (isUserMessage(entry)) {
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AssistantMessage, ServiceTier, StopReason, Usage } from "@oh-my-pi/pi-ai";
1
+ import type { AssistantMessage, ServiceTier, ServiceTierByFamily, StopReason, Usage } from "@oh-my-pi/pi-ai";
2
2
  import type { AgentType } from "./shared-types";
3
3
 
4
4
  export * from "./shared-types";
@@ -72,7 +72,7 @@ export interface SessionServiceTierChangeEntry {
72
72
  id: string;
73
73
  parentId?: string | null;
74
74
  timestamp: string;
75
- serviceTier: ServiceTier | null;
75
+ serviceTier: ServiceTierByFamily | ServiceTier | null;
76
76
  }
77
77
 
78
78
  export type SessionEntry = SessionHeader | SessionMessageEntry | SessionServiceTierChangeEntry | { type: string };