@oh-my-pi/omp-stats 14.9.8 → 15.0.0
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 +3 -3
- package/src/aggregator.ts +74 -10
- package/src/client/components/StatsGrid.tsx +33 -4
- package/src/db.ts +34 -8
- package/src/index.ts +3 -0
- package/src/sync-worker.ts +16 -7
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/omp-stats",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "15.0.0",
|
|
5
5
|
"description": "Local observability dashboard for pi AI usage statistics",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-ai": "
|
|
41
|
-
"@oh-my-pi/pi-utils": "
|
|
40
|
+
"@oh-my-pi/pi-ai": "15.0.0",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.0.0",
|
|
42
42
|
"@tailwindcss/node": "^4.2.4",
|
|
43
43
|
"chart.js": "^4.5.1",
|
|
44
44
|
"date-fns": "^4.1.0",
|
package/src/aggregator.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import { isCompiledBinary } from "@oh-my-pi/pi-utils";
|
|
2
3
|
import {
|
|
3
4
|
getRecentErrors as dbGetRecentErrors,
|
|
4
5
|
getRecentRequests as dbGetRecentRequests,
|
|
@@ -23,13 +24,15 @@ import {
|
|
|
23
24
|
} from "./db";
|
|
24
25
|
import { getSessionEntry, listAllSessionFiles, type ParseSessionResult } from "./parser";
|
|
25
26
|
import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker";
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// `
|
|
31
|
-
//
|
|
32
|
-
|
|
27
|
+
// Worker entry. Bun's `--compile` bundler statically discovers the string
|
|
28
|
+
// literal in `new Worker("./packages/stats/src/sync-worker.ts", …)` below and
|
|
29
|
+
// emits the worker as an additional entrypoint (registered in
|
|
30
|
+
// `packages/coding-agent/scripts/build-binary.ts`). In dev runs we resolve
|
|
31
|
+
// the same source file through `import.meta.url`, so the literal only has to
|
|
32
|
+
// be valid relative to the `--root` directory (repo root). Importing the
|
|
33
|
+
// source as `with { type: "file" }` is NOT sufficient — that copies the file
|
|
34
|
+
// as a raw asset and does not bundle the worker's relative imports, so the
|
|
35
|
+
// worker would crash on first `import` (issue #1011, PR #1027).
|
|
33
36
|
import type { BehaviorDashboardStats, DashboardStats, MessageStats, RequestDetails } from "./types";
|
|
34
37
|
|
|
35
38
|
/**
|
|
@@ -85,8 +88,22 @@ interface WorkerHandle {
|
|
|
85
88
|
reject: ((err: Error) => void) | null;
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Create a fresh sync worker. In a `--compile` binary the literal-string
|
|
93
|
+
* specifier is what Bun's static analyzer needs (the file is also listed as
|
|
94
|
+
* an additional `--compile` entrypoint in
|
|
95
|
+
* `packages/coding-agent/scripts/build-binary.ts`). In dev runs we resolve
|
|
96
|
+
* the source URL via `import.meta.url` so the worker survives `cwd` changes
|
|
97
|
+
* by callers.
|
|
98
|
+
*/
|
|
99
|
+
function createSyncWorker(): Worker {
|
|
100
|
+
return isCompiledBinary()
|
|
101
|
+
? new Worker("./packages/stats/src/sync-worker.ts", { type: "module" })
|
|
102
|
+
: new Worker(new URL("./sync-worker.ts", import.meta.url).href, { type: "module" });
|
|
103
|
+
}
|
|
104
|
+
|
|
88
105
|
function spawnWorker(): WorkerHandle {
|
|
89
|
-
const worker =
|
|
106
|
+
const worker = createSyncWorker();
|
|
90
107
|
const handle: WorkerHandle = { worker, busy: false, resolve: null, reject: null };
|
|
91
108
|
worker.onmessage = (event: MessageEvent<SyncWorkerResponse>) => {
|
|
92
109
|
const { resolve, reject } = handle;
|
|
@@ -94,8 +111,16 @@ function spawnWorker(): WorkerHandle {
|
|
|
94
111
|
handle.reject = null;
|
|
95
112
|
handle.busy = false;
|
|
96
113
|
if (!resolve || !reject) return;
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
const data = event.data;
|
|
115
|
+
if (!data.ok) {
|
|
116
|
+
reject(new Error(data.error));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (data.kind === "pong") {
|
|
120
|
+
reject(new Error("sync worker: unexpected pong on parse channel"));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
resolve(data.result);
|
|
99
124
|
};
|
|
100
125
|
worker.onerror = (event: ErrorEvent) => {
|
|
101
126
|
const { reject } = handle;
|
|
@@ -119,6 +144,45 @@ function dispatch(handle: WorkerHandle, request: SyncWorkerRequest): Promise<Par
|
|
|
119
144
|
return promise;
|
|
120
145
|
}
|
|
121
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Smoke test: spawns one sync worker, pings it, asserts the pong response,
|
|
149
|
+
* then terminates. Used by `omp --smoke-test` so the install-method CI jobs
|
|
150
|
+
* catch the silent worker-load failure that hit compiled binaries in #1011
|
|
151
|
+
* and #1027 — neither `--version` nor `stats --summary` exercises the worker
|
|
152
|
+
* spawn path on a fresh install (no session files = early return), so a
|
|
153
|
+
* dedicated probe is the only reliable signal.
|
|
154
|
+
*
|
|
155
|
+
* Resolves with the worker's `import.meta.url` (caller-visible diagnostics);
|
|
156
|
+
* rejects on transport error, error response, or timeout.
|
|
157
|
+
*/
|
|
158
|
+
export async function smokeTestSyncWorker({ timeoutMs = 5_000 }: { timeoutMs?: number } = {}): Promise<void> {
|
|
159
|
+
const worker = createSyncWorker();
|
|
160
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
161
|
+
const timer = setTimeout(() => reject(new Error(`sync worker did not pong within ${timeoutMs}ms`)), timeoutMs);
|
|
162
|
+
worker.onmessage = (event: MessageEvent<SyncWorkerResponse>) => {
|
|
163
|
+
const data = event.data;
|
|
164
|
+
if (!data.ok) {
|
|
165
|
+
reject(new Error(data.error));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (data.kind !== "pong") {
|
|
169
|
+
reject(new Error(`sync worker: expected pong, got ${JSON.stringify(data)}`));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
resolve();
|
|
173
|
+
};
|
|
174
|
+
worker.onerror = (event: ErrorEvent) => {
|
|
175
|
+
reject(event.error instanceof Error ? event.error : new Error(event.message || "worker error"));
|
|
176
|
+
};
|
|
177
|
+
try {
|
|
178
|
+
worker.postMessage({ kind: "ping" } satisfies SyncWorkerRequest);
|
|
179
|
+
await promise;
|
|
180
|
+
} finally {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
worker.terminate();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
122
186
|
/**
|
|
123
187
|
* Sync all session files to the database.
|
|
124
188
|
*
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Activity, AlertCircle, BarChart3, Database, Server, Star, Zap } from "lucide-react";
|
|
1
|
+
import { Activity, AlertCircle, BarChart3, Database, Download, Server, Star, Upload, Zap } from "lucide-react";
|
|
2
2
|
import type { AggregatedStats } from "../types";
|
|
3
3
|
|
|
4
4
|
interface StatsGridProps {
|
|
@@ -14,6 +14,12 @@ function formatCompactNumber(value: number): string {
|
|
|
14
14
|
return compactNumberFormatter.format(value);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function formatExactNumber(value: number): string {
|
|
18
|
+
return value.toLocaleString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const totalPromptCompletionTokens = (stats: AggregatedStats) => stats.totalInputTokens + stats.totalOutputTokens;
|
|
22
|
+
|
|
17
23
|
const statConfig = [
|
|
18
24
|
{
|
|
19
25
|
key: "requests",
|
|
@@ -38,7 +44,7 @@ const statConfig = [
|
|
|
38
44
|
title: "Premium Reqs",
|
|
39
45
|
icon: Star,
|
|
40
46
|
color: "var(--accent-amber)",
|
|
41
|
-
getValue: (s: AggregatedStats) => s.totalPremiumRequests
|
|
47
|
+
getValue: (s: AggregatedStats) => formatExactNumber(s.totalPremiumRequests),
|
|
42
48
|
getDetail: (s: AggregatedStats) =>
|
|
43
49
|
s.totalRequests > 0 ? `${((s.totalPremiumRequests / s.totalRequests) * 100).toFixed(1)}% of requests` : "-",
|
|
44
50
|
},
|
|
@@ -50,6 +56,28 @@ const statConfig = [
|
|
|
50
56
|
getValue: (s: AggregatedStats) => `${(s.cacheRate * 100).toFixed(1)}%`,
|
|
51
57
|
getDetail: (s: AggregatedStats) => `${formatCompactNumber(s.totalCacheReadTokens)} cached tokens`,
|
|
52
58
|
},
|
|
59
|
+
{
|
|
60
|
+
key: "inputTokens",
|
|
61
|
+
title: "Input Tokens",
|
|
62
|
+
icon: Download,
|
|
63
|
+
color: "var(--accent-violet)",
|
|
64
|
+
getValue: (s: AggregatedStats) => formatExactNumber(s.totalInputTokens),
|
|
65
|
+
getDetail: (s: AggregatedStats) =>
|
|
66
|
+
totalPromptCompletionTokens(s) > 0
|
|
67
|
+
? `${((s.totalInputTokens / totalPromptCompletionTokens(s)) * 100).toFixed(1)}% of prompt+completion`
|
|
68
|
+
: "-",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
key: "outputTokens",
|
|
72
|
+
title: "Output Tokens",
|
|
73
|
+
icon: Upload,
|
|
74
|
+
color: "var(--accent-pink)",
|
|
75
|
+
getValue: (s: AggregatedStats) => formatExactNumber(s.totalOutputTokens),
|
|
76
|
+
getDetail: (s: AggregatedStats) =>
|
|
77
|
+
totalPromptCompletionTokens(s) > 0
|
|
78
|
+
? `${((s.totalOutputTokens / totalPromptCompletionTokens(s)) * 100).toFixed(1)}% of prompt+completion`
|
|
79
|
+
: "-",
|
|
80
|
+
},
|
|
53
81
|
{
|
|
54
82
|
key: "errors",
|
|
55
83
|
title: "Error Rate",
|
|
@@ -64,7 +92,8 @@ const statConfig = [
|
|
|
64
92
|
icon: BarChart3,
|
|
65
93
|
color: "var(--accent-green)",
|
|
66
94
|
getValue: (s: AggregatedStats) => s.avgTokensPerSecond?.toFixed(1) ?? "-",
|
|
67
|
-
getDetail: (s: AggregatedStats) =>
|
|
95
|
+
getDetail: (s: AggregatedStats) =>
|
|
96
|
+
`${formatCompactNumber(totalPromptCompletionTokens(s))} total prompt+completion`,
|
|
68
97
|
},
|
|
69
98
|
{
|
|
70
99
|
key: "ttft",
|
|
@@ -78,7 +107,7 @@ const statConfig = [
|
|
|
78
107
|
|
|
79
108
|
export function StatsGrid({ stats }: StatsGridProps) {
|
|
80
109
|
return (
|
|
81
|
-
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-
|
|
110
|
+
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-9 gap-4 mb-8">
|
|
82
111
|
{statConfig.map(stat => {
|
|
83
112
|
const Icon = stat.icon;
|
|
84
113
|
return (
|
package/src/db.ts
CHANGED
|
@@ -34,6 +34,13 @@ interface CostBackfillRow {
|
|
|
34
34
|
|
|
35
35
|
let db: Database | null = null;
|
|
36
36
|
|
|
37
|
+
const BACKFILL_COMPLETE = "complete";
|
|
38
|
+
const BACKFILL_PENDING = "pending";
|
|
39
|
+
const USER_MESSAGES_BACKFILL_KEY = "user_messages_v5";
|
|
40
|
+
const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
|
|
41
|
+
function shouldResetBackfill(value: string | undefined): boolean {
|
|
42
|
+
return value !== BACKFILL_COMPLETE && value !== BACKFILL_PENDING;
|
|
43
|
+
}
|
|
37
44
|
/**
|
|
38
45
|
* Initialize the database and create tables.
|
|
39
46
|
*/
|
|
@@ -720,8 +727,11 @@ export function getCostTimeSeries(days = 90, cutoff?: number | null): CostTimeSe
|
|
|
720
727
|
|
|
721
728
|
/**
|
|
722
729
|
* Reset `file_offsets` (and any existing `user_messages` rows) so the next
|
|
723
|
-
* sync re-parses every session and re-derives behavioral metrics.
|
|
724
|
-
* per metric-definition bump; the meta sentinel
|
|
730
|
+
* successful sync re-parses every session and re-derives behavioral metrics.
|
|
731
|
+
* Run once per metric-definition bump; the meta sentinel is only marked
|
|
732
|
+
* complete after `syncAllSessions` finishes. Older timestamp sentinel values
|
|
733
|
+
* are treated as pending so a failed compiled-binary sync cannot permanently
|
|
734
|
+
* suppress the backfill.
|
|
725
735
|
*
|
|
726
736
|
* - v1: initial introduction of `user_messages`.
|
|
727
737
|
* - v2: yelling-sentence metric replaces caps-word counts; existing rows are
|
|
@@ -738,16 +748,16 @@ export function getCostTimeSeries(days = 90, cutoff?: number | null): CostTimeSe
|
|
|
738
748
|
* Existing `messages` rows are unaffected - `INSERT OR IGNORE` keeps them.
|
|
739
749
|
*/
|
|
740
750
|
function backfillUserMessages(database: Database): void {
|
|
741
|
-
const row = database.prepare("SELECT value FROM meta WHERE key =
|
|
751
|
+
const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGES_BACKFILL_KEY) as
|
|
742
752
|
| { value: string }
|
|
743
753
|
| undefined;
|
|
744
|
-
if (row) return;
|
|
754
|
+
if (!shouldResetBackfill(row?.value)) return;
|
|
745
755
|
|
|
746
756
|
database.exec("DELETE FROM user_messages");
|
|
747
757
|
database.exec("DELETE FROM file_offsets");
|
|
748
758
|
database
|
|
749
759
|
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
750
|
-
.run(
|
|
760
|
+
.run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING);
|
|
751
761
|
}
|
|
752
762
|
|
|
753
763
|
/**
|
|
@@ -759,15 +769,31 @@ function backfillUserMessages(database: Database): void {
|
|
|
759
769
|
* sentinel row in `meta`.
|
|
760
770
|
*/
|
|
761
771
|
function repairUserMessageLinks(database: Database): void {
|
|
762
|
-
const row = database.prepare("SELECT value FROM meta WHERE key =
|
|
772
|
+
const row = database.prepare("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGE_LINKS_REPAIR_KEY) as
|
|
763
773
|
| { value: string }
|
|
764
774
|
| undefined;
|
|
765
|
-
if (row) return;
|
|
775
|
+
if (!shouldResetBackfill(row?.value)) return;
|
|
766
776
|
|
|
767
777
|
database.exec("DELETE FROM file_offsets");
|
|
768
778
|
database
|
|
769
779
|
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
770
|
-
.run(
|
|
780
|
+
.run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export function markUserMessagesBackfillComplete(): void {
|
|
784
|
+
if (!db) return;
|
|
785
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(
|
|
786
|
+
USER_MESSAGES_BACKFILL_KEY,
|
|
787
|
+
BACKFILL_COMPLETE,
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export function markUserMessageLinksRepairComplete(): void {
|
|
792
|
+
if (!db) return;
|
|
793
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(
|
|
794
|
+
USER_MESSAGE_LINKS_REPAIR_KEY,
|
|
795
|
+
BACKFILL_COMPLETE,
|
|
796
|
+
);
|
|
771
797
|
}
|
|
772
798
|
|
|
773
799
|
/**
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export {
|
|
|
11
11
|
getTotalMessageCount,
|
|
12
12
|
type SyncOptions,
|
|
13
13
|
type SyncProgress,
|
|
14
|
+
smokeTestSyncWorker,
|
|
14
15
|
syncAllSessions,
|
|
15
16
|
} from "./aggregator";
|
|
16
17
|
export { closeDb } from "./db";
|
|
@@ -52,6 +53,8 @@ async function printStats(): Promise<void> {
|
|
|
52
53
|
console.log(` Requests: ${formatNumber(overall.totalRequests)} (${formatNumber(overall.failedRequests)} errors)`);
|
|
53
54
|
console.log(` Error Rate: ${formatPercent(overall.errorRate)}`);
|
|
54
55
|
console.log(` Total Tokens: ${formatNumber(overall.totalInputTokens + overall.totalOutputTokens)}`);
|
|
56
|
+
console.log(` Input Tokens: ${formatNumber(overall.totalInputTokens)}`);
|
|
57
|
+
console.log(` Output Tokens: ${formatNumber(overall.totalOutputTokens)}`);
|
|
55
58
|
console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`);
|
|
56
59
|
console.log(` Total Cost: ${formatCost(overall.totalCost)}`);
|
|
57
60
|
console.log(` Premium Requests: ${formatNumber(normalizePremiumRequests(overall.totalPremiumRequests ?? 0))}`);
|
package/src/sync-worker.ts
CHANGED
|
@@ -4,25 +4,34 @@
|
|
|
4
4
|
* `parseSessionFile` (which is pure I/O + CPU, no DB), and post the
|
|
5
5
|
* structured-clone-safe result back. One in-flight request per worker so
|
|
6
6
|
* the main thread can fan jobs out 1:1 with the pool size.
|
|
7
|
+
*
|
|
8
|
+
* A `{ kind: "ping" }` request is also accepted and replies with
|
|
9
|
+
* `{ ok: true, kind: "pong" }` — used by `smokeTestSyncWorker` to prove the
|
|
10
|
+
* worker actually spawns and runs in compiled binaries (regression coverage
|
|
11
|
+
* for issue #1011 / PR #1027, where the worker silently failed to load).
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
import { type ParseSessionResult, parseSessionFile } from "./parser";
|
|
10
15
|
|
|
11
|
-
export
|
|
12
|
-
sessionFile: string;
|
|
13
|
-
fromOffset: number;
|
|
14
|
-
}
|
|
16
|
+
export type SyncWorkerRequest = { kind?: "parse"; sessionFile: string; fromOffset: number } | { kind: "ping" };
|
|
15
17
|
|
|
16
|
-
export type SyncWorkerResponse =
|
|
18
|
+
export type SyncWorkerResponse =
|
|
19
|
+
| { ok: true; kind?: "parse"; result: ParseSessionResult }
|
|
20
|
+
| { ok: true; kind: "pong" }
|
|
21
|
+
| { ok: false; error: string };
|
|
17
22
|
|
|
18
23
|
declare const self: Worker & {
|
|
19
24
|
onmessage: ((event: MessageEvent<SyncWorkerRequest>) => void) | null;
|
|
20
25
|
};
|
|
21
26
|
|
|
22
27
|
self.onmessage = async event => {
|
|
23
|
-
const
|
|
28
|
+
const request = event.data;
|
|
24
29
|
try {
|
|
25
|
-
|
|
30
|
+
if (request.kind === "ping") {
|
|
31
|
+
self.postMessage({ ok: true, kind: "pong" } satisfies SyncWorkerResponse);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const result = await parseSessionFile(request.sessionFile, request.fromOffset);
|
|
26
35
|
self.postMessage({ ok: true, result } satisfies SyncWorkerResponse);
|
|
27
36
|
} catch (err) {
|
|
28
37
|
const error = err instanceof Error ? (err.stack ?? err.message) : String(err);
|