@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "14.9.8",
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": "14.9.8",
41
- "@oh-my-pi/pi-utils": "14.9.8",
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
- // `with { type: "file" }` resolves to the worker's absolute path at runtime
27
- // (dev) and survives bundling (the asset is copied alongside the build).
28
- // tsgo doesn't recognize Bun's file-URL import attribute and would raise
29
- // TS1192/TS5097 here; Bun honors it. Same suppression pattern lives in
30
- // `tab-supervisor.ts` and `context-manager.ts`.
31
- // @ts-expect-error -- Bun file-URL import (see comment above).
32
- import syncWorkerUrl from "./sync-worker.ts" with { type: "file" };
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 = new Worker(syncWorkerUrl, { type: "module" });
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
- if (event.data.ok) resolve(event.data.result);
98
- else reject(new Error(event.data.error));
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.toLocaleString(),
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) => `${(s.totalInputTokens + s.totalOutputTokens).toLocaleString()} total tokens`,
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-7 gap-4 mb-8">
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. Run once
724
- * per metric-definition bump; the meta sentinel records the version.
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 = 'user_messages_v5'").get() as
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("user_messages_v5", String(Date.now()));
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 = 'user_message_links_v1'").get() as
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("user_message_links_v1", String(Date.now()));
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))}`);
@@ -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 interface SyncWorkerRequest {
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 = { ok: true; result: ParseSessionResult } | { ok: false; error: string };
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 { sessionFile, fromOffset } = event.data;
28
+ const request = event.data;
24
29
  try {
25
- const result = await parseSessionFile(sessionFile, fromOffset);
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);