@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 +12 -0
- package/dist/types/aggregator.d.ts +12 -7
- package/dist/types/types.d.ts +2 -2
- package/package.json +4 -4
- package/src/aggregator.ts +59 -35
- package/src/parser.ts +49 -42
- package/src/types.ts +2 -2
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
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
42
|
-
* while DB writes and offset bookkeeping stay on
|
|
43
|
-
* single SQLite handle stays uncontended.
|
|
44
|
-
* completed file (skipped files included so the
|
|
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;
|
package/dist/types/types.d.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
|
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.
|
|
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.
|
|
43
|
-
"@oh-my-pi/pi-catalog": "16.2.
|
|
44
|
-
"@oh-my-pi/pi-utils": "16.2.
|
|
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
|
-
*
|
|
153
|
-
*
|
|
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
|
-
*
|
|
187
|
-
* while DB writes and offset bookkeeping stay on
|
|
188
|
-
* single SQLite handle stays uncontended.
|
|
189
|
-
* completed file (skipped files included so the
|
|
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 {
|
|
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:
|
|
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
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
188
|
+
function visitSessionEntriesLenient(bytes: Uint8Array, visit: (entry: SessionEntry) => void): number {
|
|
194
189
|
let cursor = 0;
|
|
195
|
-
let
|
|
190
|
+
let read = 0;
|
|
196
191
|
|
|
197
192
|
while (cursor < bytes.length) {
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
if (nextNewline === -1) break;
|
|
206
|
-
cursor = nextNewline + 1;
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
208
|
+
return read;
|
|
209
|
+
}
|
|
209
210
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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:
|
|
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
|
|
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 };
|