@oh-my-pi/omp-stats 18.3.5 → 18.4.1

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.
Files changed (192) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +13 -11
  3. package/build.ts +5 -49
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +2 -2
  6. package/dist/client/index.js +105 -126
  7. package/dist/types/aggregator.d.ts +25 -22
  8. package/dist/types/client/api.d.ts +11 -7
  9. package/dist/types/client/app/LiveChip.d.ts +2 -0
  10. package/dist/types/client/app/Shell.d.ts +16 -0
  11. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  12. package/dist/types/client/app/nav.d.ts +18 -0
  13. package/dist/types/client/charts/BarList.d.ts +17 -0
  14. package/dist/types/client/charts/Chart.d.ts +49 -0
  15. package/dist/types/client/charts/Legend.d.ts +16 -0
  16. package/dist/types/client/charts/ShareBar.d.ts +11 -0
  17. package/dist/types/client/charts/Sparkline.d.ts +11 -0
  18. package/dist/types/client/charts/TimeChart.d.ts +9 -0
  19. package/dist/types/client/charts/index.d.ts +7 -0
  20. package/dist/types/client/charts/types.d.ts +25 -0
  21. package/dist/types/client/charts/useWidth.d.ts +3 -0
  22. package/dist/types/client/data/colors.d.ts +25 -0
  23. package/dist/types/client/data/formatters.d.ts +9 -1
  24. package/dist/types/client/data/live.d.ts +29 -0
  25. package/dist/types/client/data/query.d.ts +35 -0
  26. package/dist/types/client/data/range.d.ts +31 -0
  27. package/dist/types/client/data/series.d.ts +28 -0
  28. package/dist/types/client/data/useHashRoute.d.ts +18 -4
  29. package/dist/types/client/data/view-models.d.ts +111 -28
  30. package/dist/types/client/index.d.ts +0 -1
  31. package/dist/types/client/routes/CostsRoute.d.ts +2 -2
  32. package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
  33. package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
  34. package/dist/types/client/routes/GainRoute.d.ts +1 -2
  35. package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
  36. package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
  37. package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
  38. package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
  39. package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
  40. package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
  41. package/dist/types/client/routes/TracesRoute.d.ts +2 -2
  42. package/dist/types/client/routes/index.d.ts +1 -1
  43. package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
  44. package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
  45. package/dist/types/client/traces/trace-colors.d.ts +18 -12
  46. package/dist/types/client/ui/Badge.d.ts +19 -0
  47. package/dist/types/client/ui/Card.d.ts +25 -0
  48. package/dist/types/client/ui/Drawer.d.ts +25 -0
  49. package/dist/types/client/ui/JsonBlock.d.ts +2 -2
  50. package/dist/types/client/ui/Modal.d.ts +23 -0
  51. package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
  52. package/dist/types/client/ui/SearchInput.d.ts +8 -0
  53. package/dist/types/client/ui/Segmented.d.ts +15 -0
  54. package/dist/types/client/ui/Stat.d.ts +36 -0
  55. package/dist/types/client/ui/States.d.ts +41 -0
  56. package/dist/types/client/ui/Table.d.ts +48 -0
  57. package/dist/types/client/ui/index.d.ts +9 -10
  58. package/dist/types/db.d.ts +90 -106
  59. package/dist/types/frustration.d.ts +70 -0
  60. package/dist/types/index.d.ts +3 -1
  61. package/dist/types/live.d.ts +30 -0
  62. package/dist/types/rollup.d.ts +115 -0
  63. package/dist/types/server.d.ts +6 -1
  64. package/dist/types/shared-types.d.ts +116 -56
  65. package/dist/types/types.d.ts +4 -0
  66. package/dist/types/user-metrics.d.ts +8 -0
  67. package/package.json +7 -13
  68. package/src/aggregator.ts +276 -211
  69. package/src/client/App.tsx +38 -80
  70. package/src/client/api.ts +65 -7
  71. package/src/client/app/LiveChip.tsx +97 -0
  72. package/src/client/app/Shell.tsx +181 -0
  73. package/src/client/app/ThemeToggle.tsx +5 -2
  74. package/src/client/app/nav.ts +78 -0
  75. package/src/client/charts/BarList.tsx +48 -0
  76. package/src/client/charts/Chart.tsx +388 -0
  77. package/src/client/charts/Legend.tsx +61 -0
  78. package/src/client/charts/ShareBar.tsx +26 -0
  79. package/src/client/charts/Sparkline.tsx +38 -0
  80. package/src/client/charts/TimeChart.tsx +22 -0
  81. package/src/client/charts/index.ts +7 -0
  82. package/src/client/charts/types.ts +27 -0
  83. package/src/client/charts/useWidth.ts +19 -0
  84. package/src/client/data/colors.ts +45 -0
  85. package/src/client/data/formatters.ts +30 -9
  86. package/src/client/data/live.tsx +85 -0
  87. package/src/client/data/query.ts +166 -0
  88. package/src/client/data/range.ts +82 -0
  89. package/src/client/data/series.ts +85 -0
  90. package/src/client/data/useHashRoute.ts +41 -83
  91. package/src/client/data/view-models.ts +347 -140
  92. package/src/client/index.tsx +3 -6
  93. package/src/client/routes/CostsRoute.tsx +394 -222
  94. package/src/client/routes/ErrorsRoute.tsx +401 -98
  95. package/src/client/routes/FrustrationRoute.tsx +924 -0
  96. package/src/client/routes/GainRoute.tsx +209 -206
  97. package/src/client/routes/ModelsRoute.tsx +540 -431
  98. package/src/client/routes/OverviewRoute.tsx +283 -311
  99. package/src/client/routes/ProjectsRoute.tsx +298 -156
  100. package/src/client/routes/ProvidersRoute.tsx +948 -416
  101. package/src/client/routes/RequestsRoute.tsx +295 -107
  102. package/src/client/routes/ToolsRoute.tsx +495 -398
  103. package/src/client/routes/TracesRoute.tsx +136 -117
  104. package/src/client/routes/costs.css +24 -0
  105. package/src/client/routes/errors.css +60 -0
  106. package/src/client/routes/frustration.css +123 -0
  107. package/src/client/routes/index.ts +1 -1
  108. package/src/client/routes/models.css +43 -0
  109. package/src/client/routes/projects.css +6 -0
  110. package/src/client/routes/providers.css +28 -0
  111. package/src/client/routes/tools.css +14 -0
  112. package/src/client/styles.css +1396 -1299
  113. package/src/client/traces/AggregatesPanel.tsx +55 -74
  114. package/src/client/traces/Minimap.tsx +6 -15
  115. package/src/client/traces/SpanDrawer.tsx +128 -194
  116. package/src/client/traces/SummaryStrip.tsx +21 -24
  117. package/src/client/traces/TimelineCanvas.tsx +27 -44
  118. package/src/client/traces/TraceView.tsx +175 -130
  119. package/src/client/traces/TranscriptList.tsx +13 -41
  120. package/src/client/traces/trace-colors.ts +60 -53
  121. package/src/client/traces/traces.css +279 -0
  122. package/src/client/ui/Badge.tsx +29 -0
  123. package/src/client/ui/Card.tsx +61 -0
  124. package/src/client/ui/Drawer.tsx +93 -0
  125. package/src/client/ui/JsonBlock.tsx +26 -44
  126. package/src/client/ui/Modal.tsx +106 -0
  127. package/src/client/ui/RequestDrawer.tsx +163 -188
  128. package/src/client/ui/SearchInput.tsx +25 -0
  129. package/src/client/ui/Segmented.tsx +61 -0
  130. package/src/client/ui/Stat.tsx +104 -0
  131. package/src/client/ui/States.tsx +81 -0
  132. package/src/client/ui/Table.tsx +229 -0
  133. package/src/client/ui/index.ts +9 -10
  134. package/src/client/ui/modal.css +54 -0
  135. package/src/client/ui/request-drawer.css +40 -0
  136. package/src/db.ts +379 -983
  137. package/src/frustration.ts +473 -0
  138. package/src/index.ts +22 -22
  139. package/src/live.ts +259 -0
  140. package/src/parser.ts +55 -66
  141. package/src/rollup.ts +951 -0
  142. package/src/server.ts +143 -87
  143. package/src/shared-types.ts +120 -56
  144. package/src/trace.ts +14 -7
  145. package/src/types.ts +4 -0
  146. package/src/user-metrics.ts +13 -0
  147. package/dist/client/styles.css +0 -1976
  148. package/dist/types/client/app/AppLayout.d.ts +0 -16
  149. package/dist/types/client/app/NavRail.d.ts +0 -7
  150. package/dist/types/client/app/RangeControl.d.ts +0 -7
  151. package/dist/types/client/app/SyncButton.d.ts +0 -14
  152. package/dist/types/client/app/TopBar.d.ts +0 -15
  153. package/dist/types/client/app/routes.d.ts +0 -12
  154. package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
  155. package/dist/types/client/components/chart-shared.d.ts +0 -178
  156. package/dist/types/client/components/models-table-shared.d.ts +0 -175
  157. package/dist/types/client/components/range-meta.d.ts +0 -21
  158. package/dist/types/client/data/charts.d.ts +0 -1
  159. package/dist/types/client/data/useResource.d.ts +0 -13
  160. package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
  161. package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
  162. package/dist/types/client/ui/DataTable.d.ts +0 -17
  163. package/dist/types/client/ui/EmptyState.d.ts +0 -7
  164. package/dist/types/client/ui/ErrorState.d.ts +0 -6
  165. package/dist/types/client/ui/MetricCluster.d.ts +0 -5
  166. package/dist/types/client/ui/Panel.d.ts +0 -7
  167. package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
  168. package/dist/types/client/ui/Skeleton.d.ts +0 -8
  169. package/dist/types/client/ui/StatusPill.d.ts +0 -7
  170. package/src/client/app/AppLayout.tsx +0 -93
  171. package/src/client/app/NavRail.tsx +0 -44
  172. package/src/client/app/RangeControl.tsx +0 -39
  173. package/src/client/app/SyncButton.tsx +0 -75
  174. package/src/client/app/TopBar.tsx +0 -73
  175. package/src/client/app/routes.ts +0 -93
  176. package/src/client/components/AgentTokenShare.tsx +0 -68
  177. package/src/client/components/chart-shared.tsx +0 -273
  178. package/src/client/components/models-table-shared.tsx +0 -255
  179. package/src/client/components/range-meta.ts +0 -73
  180. package/src/client/data/charts.ts +0 -14
  181. package/src/client/data/useResource.ts +0 -154
  182. package/src/client/routes/BehaviorRoute.tsx +0 -623
  183. package/src/client/ui/AsyncBoundary.tsx +0 -54
  184. package/src/client/ui/DataTable.tsx +0 -122
  185. package/src/client/ui/EmptyState.tsx +0 -16
  186. package/src/client/ui/ErrorState.tsx +0 -25
  187. package/src/client/ui/MetricCluster.tsx +0 -92
  188. package/src/client/ui/Panel.tsx +0 -24
  189. package/src/client/ui/SegmentedControl.tsx +0 -36
  190. package/src/client/ui/Skeleton.tsx +0 -17
  191. package/src/client/ui/StatusPill.tsx +0 -15
  192. package/tailwind.config.js +0 -40
package/src/server.ts CHANGED
@@ -1,27 +1,33 @@
1
1
  import type { Dirent } from "node:fs";
2
2
  import * as fs from "node:fs/promises";
3
- import * as os from "node:os";
4
3
  import * as path from "node:path";
5
- import { isEnoent } from "@oh-my-pi/pi-utils";
4
+ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
6
5
  import { $, type Server } from "bun";
7
6
  import {
8
- getBehaviorDashboardStats,
9
7
  getCostDashboardStats,
10
8
  getDashboardStats,
11
9
  getFolderStats,
12
10
  getModelDashboardStats,
13
11
  getOverviewStats,
14
12
  getProviderDashboardStats,
13
+ getProviderWindowStats,
15
14
  getRecentErrors,
16
15
  getRecentRequests,
17
16
  getRequestDetails,
18
17
  getToolDashboardStats,
19
- getTotalMessageCount,
20
- syncAllSessions,
21
18
  } from "./aggregator";
22
19
  import { decodeEmbeddedClientArchive } from "./embedded-client";
23
20
  import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
21
+ import {
22
+ cancelFrustrationRun,
23
+ estimateFrustrationRun,
24
+ getFrustrationDashboardStats,
25
+ type StatsJudgeProvider,
26
+ setStatsJudgeProvider,
27
+ startFrustrationRun,
28
+ } from "./frustration";
24
29
  import { getGainDashboardStats } from "./gain-aggregator";
30
+ import { statsLive } from "./live";
25
31
  import {
26
32
  prepareStatsPort,
27
33
  recoverStatsPort,
@@ -30,6 +36,7 @@ import {
30
36
  STATS_DASHBOARD_HOSTNAME_HEADER,
31
37
  STATS_DASHBOARD_SECURITY_VERSION,
32
38
  } from "./port-conflict";
39
+ import type { LiveStatus } from "./shared-types";
33
40
  import {
34
41
  buildSessionTrace,
35
42
  getTraceEntry,
@@ -55,35 +62,10 @@ const IS_BUN_COMPILED =
55
62
  const IS_PREBUILT = IS_BUN_COMPILED || Boolean(process.env.PI_BUNDLED || Bun.env.PI_BUNDLED);
56
63
  const USE_EMBEDDED_CLIENT = EMBEDDED_CLIENT_ARCHIVE !== null || IS_PREBUILT;
57
64
 
58
- const EMBEDDED_CLIENT_DIR_ROOT = path.join(os.tmpdir(), "omp-stats-client");
59
- let embeddedClientDirPromise: Promise<string> | null = null;
60
-
61
- function sanitizeArchivePath(archivePath: string): string | null {
62
- const normalized = archivePath.replaceAll("\\", "/").replace(/^\.\//, "");
63
- if (!normalized || normalized === ".") return null;
64
- if (normalized.includes("..") || path.isAbsolute(normalized)) return null;
65
- return normalized;
66
- }
65
+ let embeddedClientFilesPromise: Promise<Map<string, Blob>> | null = null;
67
66
 
68
- async function extractEmbeddedClientArchive(archiveBytes: Buffer, outputDir: string): Promise<void> {
69
- const archive = new Bun.Archive(archiveBytes);
70
- const files = await archive.files();
71
- const extractRoot = path.resolve(outputDir);
72
-
73
- for (const [archivePath, file] of files) {
74
- const sanitizedPath = sanitizeArchivePath(archivePath);
75
- if (!sanitizedPath) continue;
76
- const destinationPath = path.resolve(extractRoot, sanitizedPath);
77
- if (!destinationPath.startsWith(extractRoot + path.sep)) {
78
- throw new Error(`Archive entry escapes extraction directory: ${archivePath}`);
79
- }
80
- await Bun.write(destinationPath, file);
81
- }
82
- }
83
-
84
- async function getEmbeddedClientDir(): Promise<string> {
85
- if (!USE_EMBEDDED_CLIENT) return STATIC_DIR;
86
- if (embeddedClientDirPromise) return embeddedClientDirPromise;
67
+ async function getEmbeddedClientFiles(): Promise<Map<string, Blob>> {
68
+ if (embeddedClientFilesPromise) return embeddedClientFilesPromise;
87
69
 
88
70
  if (!EMBEDDED_CLIENT_ARCHIVE) {
89
71
  throw new Error(
@@ -91,22 +73,16 @@ async function getEmbeddedClientDir(): Promise<string> {
91
73
  );
92
74
  }
93
75
 
94
- embeddedClientDirPromise = (async () => {
95
- const bundleHash = Bun.hash(EMBEDDED_CLIENT_ARCHIVE).toString(16);
96
- const outputDir = path.join(EMBEDDED_CLIENT_DIR_ROOT, bundleHash);
97
- const markerPath = path.join(outputDir, "index.html");
98
- try {
99
- const marker = await fs.stat(markerPath);
100
- if (marker.isFile()) return outputDir;
101
- } catch {}
102
-
103
- await fs.rm(outputDir, { recursive: true, force: true });
104
- await fs.mkdir(outputDir, { recursive: true });
105
- await extractEmbeddedClientArchive(EMBEDDED_CLIENT_ARCHIVE, outputDir);
106
- return outputDir;
107
- })();
76
+ // Keep bundled assets in memory so OS temporary-file cleanup cannot break a live dashboard.
77
+ embeddedClientFilesPromise = new Bun.Archive(EMBEDDED_CLIENT_ARCHIVE).files().then(files => {
78
+ for (const [name, file] of files) {
79
+ // Archive entries are untyped blobs; infer MIME types just as disk-backed Bun files do.
80
+ files.set(name, new File([file], name, { type: Bun.file(name).type }));
81
+ }
82
+ return files;
83
+ });
108
84
 
109
- return embeddedClientDirPromise;
85
+ return embeddedClientFilesPromise;
110
86
  }
111
87
 
112
88
  async function getLatestMtime(dir: string): Promise<number> {
@@ -145,15 +121,8 @@ async function getLatestMtime(dir: string): Promise<number> {
145
121
  const ensureClientBuild = async () => {
146
122
  if (USE_EMBEDDED_CLIENT) return;
147
123
  const indexPath = path.join(STATIC_DIR, "index.html");
148
- const cssPath = path.join(STATIC_DIR, "styles.css");
149
- const clientSourceMtime = await getLatestMtime(CLIENT_DIR);
150
- const tailwindConfigPath = path.join(import.meta.dir, "..", "tailwind.config.js");
151
- let tailwindConfigMtime = 0;
152
- try {
153
- const tailwindConfigStats = await fs.stat(tailwindConfigPath);
154
- tailwindConfigMtime = tailwindConfigStats.mtimeMs;
155
- } catch {}
156
- const sourceMtime = Math.max(clientSourceMtime, tailwindConfigMtime);
124
+ const cssPath = path.join(STATIC_DIR, "index.css");
125
+ const sourceMtime = await getLatestMtime(CLIENT_DIR);
157
126
  let shouldBuild = true;
158
127
  try {
159
128
  const [indexStats, cssStats] = await Promise.all([fs.stat(indexPath), fs.stat(cssPath)]);
@@ -173,7 +142,7 @@ const ensureClientBuild = async () => {
173
142
 
174
143
  await fs.rm(STATIC_DIR, { recursive: true, force: true });
175
144
 
176
- console.log("Building stats client...");
145
+ logger.debug("Building stats client");
177
146
  const packageRoot = path.join(import.meta.dir, "..");
178
147
  const buildResult = await $`bun run build.ts`.cwd(packageRoot).quiet().nothrow();
179
148
  if (buildResult.exitCode !== 0) {
@@ -181,24 +150,15 @@ const ensureClientBuild = async () => {
181
150
  const details = output ? `\n${output}` : "";
182
151
  throw new Error(`Failed to build stats client (exit ${buildResult.exitCode})${details}`);
183
152
  }
184
-
185
- const indexHtml = `<!DOCTYPE html>
186
- <html lang="en">
187
- <head>
188
- <meta charset="UTF-8">
189
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
190
- <title>AI Usage Statistics</title>
191
- <link rel="stylesheet" href="styles.css">
192
- </head>
193
- <body>
194
- <div id="root"></div>
195
- <script src="index.js" type="module"></script>
196
- </body>
197
- </html>`;
198
-
199
- await Bun.write(path.join(STATIC_DIR, "index.html"), indexHtml);
200
153
  };
201
154
 
155
+ /**
156
+ * Required on every spending/mutating POST. A custom header forces a CORS
157
+ * preflight, which this server never approves, so a hostile page cannot
158
+ * trigger a paid judge run through a cross-site form or `fetch`.
159
+ */
160
+ const STATS_ACTION_HEADER = "X-Omp-Stats-Action";
161
+
202
162
  /**
203
163
  * Handle API requests.
204
164
  */
@@ -206,9 +166,13 @@ export async function handleApi(req: Request): Promise<Response> {
206
166
  const url = new URL(req.url);
207
167
  const path = url.pathname;
208
168
 
209
- // Stats reads are DB-only; explicit /api/sync does the expensive session scan.
169
+ // Stats reads are DB-only; ingest runs in the background (see `live.ts`).
210
170
  const range = url.searchParams.get("range");
211
171
 
172
+ if (path === "/api/status") {
173
+ return Response.json(statsLive().status());
174
+ }
175
+
212
176
  if (path === "/api/stats") {
213
177
  const stats = await getDashboardStats(range);
214
178
  return Response.json(stats);
@@ -229,16 +193,36 @@ export async function handleApi(req: Request): Promise<Response> {
229
193
  return Response.json(stats);
230
194
  }
231
195
 
232
- if (path === "/api/stats/behavior") {
233
- const stats = await getBehaviorDashboardStats(range);
196
+ if (path === "/api/stats/frustration") {
197
+ const stats = await getFrustrationDashboardStats(range);
234
198
  return Response.json(stats);
235
199
  }
236
200
 
201
+ if (path === "/api/frustration/estimate") {
202
+ const estimate = await estimateFrustrationRun(range);
203
+ return Response.json(estimate);
204
+ }
205
+
206
+ if (path === "/api/frustration/judge" || path === "/api/frustration/cancel") {
207
+ if (req.method !== "POST") return Response.json({ error: "POST required" }, { status: 405 });
208
+ if (req.headers.get(STATS_ACTION_HEADER) !== "1") {
209
+ return Response.json({ error: `${STATS_ACTION_HEADER}: 1 header required` }, { status: 403 });
210
+ }
211
+ if (path === "/api/frustration/cancel") return Response.json(cancelFrustrationRun());
212
+ const result = await startFrustrationRun(range);
213
+ if (!result.started) return Response.json({ error: result.error }, { status: result.status });
214
+ return Response.json(result.job, { status: 202 });
215
+ }
216
+
237
217
  if (path === "/api/stats/tools") {
238
218
  const stats = await getToolDashboardStats(range);
239
219
  return Response.json(stats);
240
220
  }
241
221
 
222
+ if (path === "/api/stats/provider-windows") {
223
+ return Response.json(await getProviderWindowStats(range, url.searchParams.get("provider")));
224
+ }
225
+
242
226
  if (path === "/api/stats/providers") {
243
227
  const stats = await getProviderDashboardStats(range);
244
228
  return Response.json(stats);
@@ -280,9 +264,9 @@ export async function handleApi(req: Request): Promise<Response> {
280
264
  }
281
265
 
282
266
  if (path === "/api/sync") {
283
- const result = await syncAllSessions();
284
- const count = await getTotalMessageCount();
285
- return Response.json({ ...result, totalMessages: count });
267
+ if (req.method !== "POST") return Response.json({ error: "POST required" }, { status: 405 });
268
+ statsLive().requestSync();
269
+ return Response.json(statsLive().status(), { status: 202 });
286
270
  }
287
271
 
288
272
  if (path === "/api/stats/gain") {
@@ -348,9 +332,14 @@ export async function handleApi(req: Request): Promise<Response> {
348
332
  * Handle static file requests.
349
333
  */
350
334
  async function handleStatic(requestPath: string): Promise<Response> {
351
- const staticDir = await getEmbeddedClientDir();
335
+ if (USE_EMBEDDED_CLIENT) {
336
+ const files = await getEmbeddedClientFiles();
337
+ const file = files.get(requestPath.slice(1)) ?? files.get("index.html");
338
+ return file ? new Response(file) : new Response("Not Found", { status: 404 });
339
+ }
340
+
352
341
  const filePath = requestPath === "/" ? "/index.html" : requestPath;
353
- const fullPath = path.join(staticDir, filePath);
342
+ const fullPath = path.join(STATIC_DIR, filePath);
354
343
 
355
344
  const file = Bun.file(fullPath);
356
345
  if (await file.exists()) {
@@ -358,7 +347,7 @@ async function handleStatic(requestPath: string): Promise<Response> {
358
347
  }
359
348
 
360
349
  // SPA fallback
361
- const index = Bun.file(path.join(staticDir, "index.html"));
350
+ const index = Bun.file(path.join(STATIC_DIR, "index.html"));
362
351
  if (await index.exists()) {
363
352
  return new Response(index);
364
353
  }
@@ -376,7 +365,7 @@ function createDashboardServer(port: number, hostname: string): Server<undefined
376
365
  const server = Bun.serve({
377
366
  port,
378
367
  hostname,
379
- async fetch(req) {
368
+ async fetch(req, server) {
380
369
  const url = new URL(req.url);
381
370
  const path = url.pathname;
382
371
 
@@ -391,6 +380,12 @@ function createDashboardServer(port: number, hostname: string): Server<undefined
391
380
  return new Response(null, { headers: dashboardHeaders });
392
381
  }
393
382
 
383
+ if (path === "/api/events") {
384
+ // Long-lived stream: exempt from the idle timeout.
385
+ server.timeout(req, 0);
386
+ return liveEventStream(dashboardHeaders);
387
+ }
388
+
394
389
  try {
395
390
  let response: Response;
396
391
 
@@ -411,7 +406,7 @@ function createDashboardServer(port: number, hostname: string): Server<undefined
411
406
  headers,
412
407
  });
413
408
  } catch (error) {
414
- console.error("Server error:", error);
409
+ logger.error("Stats dashboard request failed", { path, error: String(error) });
415
410
  return Response.json(
416
411
  { error: error instanceof Error ? error.message : "Unknown error" },
417
412
  { status: 500, headers: dashboardHeaders },
@@ -422,6 +417,49 @@ function createDashboardServer(port: number, hostname: string): Server<undefined
422
417
  return server;
423
418
  }
424
419
 
420
+ /** Keep-alive comment cadence so proxies and the browser keep the stream open. */
421
+ const EVENT_HEARTBEAT_MS = 15_000;
422
+
423
+ /**
424
+ * Server-sent events of {@link LiveStatus}: the current status immediately,
425
+ * then every change (sync progress, data version bumps, indexing backlog).
426
+ */
427
+ function liveEventStream(headers: Record<string, string>): Response {
428
+ const live = statsLive();
429
+ // Ingest starts when the first page connects, not when the server binds.
430
+ live.start();
431
+ const encoder = new TextEncoder();
432
+ let cleanup = () => {};
433
+ const stream = new ReadableStream<Uint8Array>({
434
+ start(controller) {
435
+ const send = (status: LiveStatus) => {
436
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(status)}\n\n`));
437
+ };
438
+ send(live.status());
439
+ const unsubscribe = live.subscribe(send);
440
+ const heartbeat = setInterval(
441
+ () => controller.enqueue(encoder.encode(": keep-alive\n\n")),
442
+ EVENT_HEARTBEAT_MS,
443
+ );
444
+ cleanup = () => {
445
+ unsubscribe();
446
+ clearInterval(heartbeat);
447
+ };
448
+ },
449
+ cancel() {
450
+ cleanup();
451
+ },
452
+ });
453
+ return new Response(stream, {
454
+ headers: {
455
+ ...headers,
456
+ "Content-Type": "text/event-stream",
457
+ "Cache-Control": "no-cache",
458
+ Connection: "keep-alive",
459
+ },
460
+ });
461
+ }
462
+
425
463
  /**
426
464
  * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
427
465
  */
@@ -436,8 +474,20 @@ export interface StatsServerHandle {
436
474
  // the live handle: probing our own port can time out under load and would
437
475
  // then dead-end in the reclaim path's self-PID guard.
438
476
  const activeServers = new Map<string, StatsServerHandle>();
477
+ /** Dashboards bound by this process (any port); background ingest stops when the last one does. */
478
+ let liveServers = 0;
479
+
480
+ export interface StartServerOptions {
481
+ /** Host judge for the Frustration dashboard's judge runs; replaces any previously registered one. */
482
+ judge?: StatsJudgeProvider;
483
+ }
439
484
 
440
- export async function startServer(port = 3847, hostname = STATS_DASHBOARD_HOSTNAME): Promise<StatsServerHandle> {
485
+ export async function startServer(
486
+ port = 3847,
487
+ hostname = STATS_DASHBOARD_HOSTNAME,
488
+ options: StartServerOptions = {},
489
+ ): Promise<StatsServerHandle> {
490
+ if (options.judge) setStatsJudgeProvider(options.judge);
441
491
  const activeKey = `${hostname}:${port}`;
442
492
  if (port !== 0) {
443
493
  const active = activeServers.get(activeKey);
@@ -449,12 +499,18 @@ export async function startServer(port = 3847, hostname = STATS_DASHBOARD_HOSTNA
449
499
  return { hostname, port, stop: () => {} };
450
500
  }
451
501
  const register = (server: Server<undefined>): StatsServerHandle => {
502
+ liveServers++;
503
+ let stopped = false;
452
504
  const handle: StatsServerHandle = {
453
505
  hostname,
454
506
  port: server.port ?? port,
455
507
  stop: () => {
456
508
  activeServers.delete(activeKey);
457
- server.stop();
509
+ server.stop(true);
510
+ if (stopped) return;
511
+ stopped = true;
512
+ // The last dashboard in this process takes background ingest down with it.
513
+ if (--liveServers === 0) statsLive().stop();
458
514
  },
459
515
  };
460
516
  if (port !== 0) activeServers.set(activeKey, handle);
@@ -194,68 +194,95 @@ export interface AgentTypeStats {
194
194
  }
195
195
 
196
196
  /**
197
- * Behavior time-series point (daily bucket, per responding model).
197
+ * Frustration tallies over a set of user messages. Each message counts once,
198
+ * classified by its cached judge verdict when one exists, else by the regex
199
+ * behavior signals stored at ingest (see `frustration.ts` for both rules).
198
200
  */
199
- export interface BehaviorTimeSeriesPoint {
200
- /** Bucket timestamp (start of day) */
201
- timestamp: number;
202
- /** Responding model ("unknown" if user msg never got a reply) */
203
- model: string;
204
- /** Responding provider */
205
- provider: string;
206
- /** Number of user messages in bucket */
201
+ export interface FrustrationCounts {
202
+ /** User messages with non-empty prose. */
207
203
  messages: number;
208
- /** Total yelling sentences in bucket */
209
- yelling: number;
210
- /** Total profanity hits in bucket */
211
- profanity: number;
212
- /** Total anguish signal in bucket */
213
- anguish: number;
214
- /** Total corrective-negation hits in bucket */
215
- negation: number;
216
- /** Total user-repeating-themselves hits in bucket */
217
- repetition: number;
218
- /** Total second-person blame hits in bucket */
219
- blame: number;
220
- /** Total characters in bucket */
221
- chars: number;
222
- }
223
-
224
- export interface BehaviorOverallStats {
225
- totalMessages: number;
226
- totalYelling: number;
227
- totalProfanity: number;
228
- totalAnguish: number;
229
- totalNegation: number;
230
- totalRepetition: number;
231
- totalBlame: number;
232
- totalChars: number;
233
- firstTimestamp: number;
234
- lastTimestamp: number;
204
+ /** Messages whose prose has a cached judge verdict; the rest use regex fallback. */
205
+ judged: number;
206
+ /** Annoyed at anything (the assistant, tooling, other people, ...). */
207
+ annoyed: number;
208
+ /** Annoyed and aimed at the assistant. Subset of `annoyed`. */
209
+ atAssistant: number;
210
+ /** Aimed at the assistant and hostile/angry. Subset of `atAssistant`. */
211
+ angry: number;
235
212
  }
236
213
 
237
214
  /**
238
- * Per-model behavioral aggregate over the active range.
215
+ * Frustration tallies for one model version. Provider/spelling variants of the
216
+ * same model (`claude-opus-4.6`, `anthropic/claude-opus-4-6`) merge under one
217
+ * catalog identity.
239
218
  */
240
- export interface BehaviorModelStats {
241
- model: string;
242
- provider: string;
243
- totalMessages: number;
244
- totalYelling: number;
245
- totalProfanity: number;
246
- totalAnguish: number;
247
- totalNegation: number;
248
- totalRepetition: number;
249
- totalBlame: number;
250
- totalChars: number;
251
- lastTimestamp: number;
219
+ export interface FrustrationModelStats extends FrustrationCounts {
220
+ /** Stable key: `class/family/revision` when classified, else the raw model id. */
221
+ key: string;
222
+ /** Display label, e.g. `opus 4.5`; the raw model id when unclassified. */
223
+ label: string;
224
+ /** Catalog identity class, e.g. `anthropic`; `unknown` when unclassified. */
225
+ modelClass: string;
226
+ /** Catalog family within the class, e.g. `opus`. */
227
+ family: string | null;
228
+ /** Canonical `major.minor.patch`, e.g. `4.5.0`. */
229
+ revision: string | null;
230
+ /** Raw model ids merged into this row. */
231
+ models: string[];
232
+ /** Earliest message timestamp (ms) in range. */
233
+ firstSeen: number;
252
234
  }
253
235
 
254
- export interface BehaviorDashboardStats {
255
- overall: BehaviorOverallStats;
256
- byModel: BehaviorModelStats[];
257
- behaviorSeries: BehaviorTimeSeriesPoint[];
258
- }
236
+ /** Lifecycle of the dashboard-host judge run that classifies unjudged messages. */
237
+ export type FrustrationJobState = "idle" | "running" | "done" | "cancelled" | "failed";
238
+
239
+ /** Progress of the (single, process-wide) frustration judge run. */
240
+ export interface FrustrationJobStatus {
241
+ state: FrustrationJobState;
242
+ /** Unique prose texts queued for judgment. */
243
+ total: number;
244
+ /** Texts judged successfully. */
245
+ done: number;
246
+ /** Texts that exhausted their retries. */
247
+ failed: number;
248
+ /** Accumulated USD cost of every judge attempt, retries included. */
249
+ cost: number;
250
+ /** Judge label (`provider/model`) of the run, when known. */
251
+ judge: string | null;
252
+ /** Why the run failed; null otherwise. */
253
+ error: string | null;
254
+ startedAt: number | null;
255
+ finishedAt: number | null;
256
+ /** Judge requests the run currently keeps in flight (adapts to the judge's rate limits). */
257
+ concurrency: number;
258
+ }
259
+
260
+ /** Payload of `GET /api/stats/frustration`. */
261
+ export interface FrustrationDashboardStats {
262
+ overall: FrustrationCounts;
263
+ /** Every model with messages in range, ordered by class, then revision, then family. */
264
+ byModel: FrustrationModelStats[];
265
+ /** Whether this dashboard host can run the judge (standalone `omp-stats` cannot). */
266
+ judgeAvailable: boolean;
267
+ job: FrustrationJobStatus;
268
+ }
269
+
270
+ /** Payload of `GET /api/frustration/estimate`: the pre-run cost quote shown before judging. */
271
+ export type FrustrationEstimate =
272
+ | {
273
+ available: true;
274
+ /** Unique unjudged prose texts in range (identical messages share one verdict). */
275
+ messages: number;
276
+ /** Total prose characters sent. */
277
+ chars: number;
278
+ /** Estimated billed input tokens. */
279
+ inputTokens: number;
280
+ /** Estimated USD cost. */
281
+ cost: number;
282
+ /** Judge label (`provider/model`) the run will route to first. */
283
+ judge: string;
284
+ }
285
+ | { available: false; reason: string };
259
286
 
260
287
  /** Token savings from a single source type. */
261
288
  export interface GainSourceTotals {
@@ -455,13 +482,50 @@ export interface ProviderWindowInsight {
455
482
  exhaustedEvents: number;
456
483
  }
457
484
 
458
- /** Complete providers dashboard payload. */
485
+ /** Ingest state of the dashboard host's background session sync. */
486
+ export interface LiveSyncStatus {
487
+ phase: "idle" | "syncing" | "error";
488
+ /** Files completed in the running sync (0 when not yet known). */
489
+ current: number;
490
+ /** Files in the running sync's work set (0 while listing → indeterminate). */
491
+ total: number;
492
+ /** Rows inserted by the running (or last) sync. */
493
+ processed: number;
494
+ /** Wall-clock time the last sync finished, if any. */
495
+ lastSyncedAt: number | null;
496
+ /** Failure message of the last sync when `phase` is `"error"`. */
497
+ error: string | null;
498
+ }
499
+
500
+ /**
501
+ * Payload of every `GET /api/events` server-sent event (and `GET /api/status`).
502
+ * `version` increases whenever stored stats may have changed, so clients
503
+ * revalidate their queries when it moves.
504
+ */
505
+ export interface LiveStatus {
506
+ version: number;
507
+ sync: LiveSyncStatus;
508
+ /** Hours whose rollups are still being (re)built; ranges covering them may be incomplete. */
509
+ indexingHours: number;
510
+ }
511
+
512
+ /** Providers dashboard payload: local request stats only (fast, DB-backed). */
459
513
  export interface ProviderDashboardStats {
460
514
  providers: ProviderAggregate[];
461
515
  hourly: ProviderHourlyPoint[];
462
516
  series: ProviderTimeSeriesPoint[];
463
- usageSeries: UsageWindowSeries[];
517
+ }
518
+
519
+ /**
520
+ * Subscription-window payload (`GET /api/stats/provider-windows`). Snapshots
521
+ * may come from the auth broker over the network, so it is fetched separately
522
+ * from {@link ProviderDashboardStats}. `usageSeries` only carries the
523
+ * requested provider's series (empty without a provider) — all of them can be
524
+ * thousands of series.
525
+ */
526
+ export interface ProviderWindowStats {
464
527
  windowInsights: ProviderWindowInsight[];
528
+ usageSeries: UsageWindowSeries[];
465
529
  }
466
530
  /**
467
531
  * One row of the Traces session list: a root session with every child
package/src/trace.ts CHANGED
@@ -13,8 +13,9 @@ import * as fs from "node:fs/promises";
13
13
  import * as path from "node:path";
14
14
  import { getBundledModel, type GeneratedProvider } from "@oh-my-pi/pi-catalog/models";
15
15
  import { getSessionsDir, isEnoent } from "@oh-my-pi/pi-utils";
16
- import { getSessionRollups, getToolCallCountsBySession, isScheduledCatalogModel } from "./db";
16
+ import { initDb, isScheduledCatalogModel } from "./db";
17
17
  import { extractFolderFromPath, parseAllSessionEntries, resolveUsageTotal } from "./parser";
18
+ import { getSessionRollups } from "./rollup";
18
19
  import type {
19
20
  SessionEntry,
20
21
  SessionSummary,
@@ -1069,17 +1070,22 @@ function basenameTimestamp(base: string): number | undefined {
1069
1070
  // poll is pure syscall churn. TTL is deliberately short so a just-created
1070
1071
  // session appears within seconds.
1071
1072
  let diskRootsMemo:
1072
- | { atMs: number; limit: number; roots: Array<{ file: string; mtimeMs: number; startedAt: number }> }
1073
+ | {
1074
+ atMs: number;
1075
+ sessionsDir: string;
1076
+ limit: number;
1077
+ roots: Array<{ file: string; mtimeMs: number; startedAt: number }>;
1078
+ }
1073
1079
  | undefined;
1074
1080
  const DISK_ROOTS_TTL_MS = 5_000;
1075
1081
 
1076
1082
  async function scanDiskRoots(limit: number): Promise<Array<{ file: string; mtimeMs: number; startedAt: number }>> {
1077
1083
  const now = Date.now();
1084
+ const sessionsDir = getSessionsDir();
1078
1085
  const memo = diskRootsMemo;
1079
- if (memo && memo.limit >= limit && now - memo.atMs < DISK_ROOTS_TTL_MS) {
1086
+ if (memo && memo.sessionsDir === sessionsDir && memo.limit >= limit && now - memo.atMs < DISK_ROOTS_TTL_MS) {
1080
1087
  return memo.roots.slice(0, limit);
1081
1088
  }
1082
- const sessionsDir = getSessionsDir();
1083
1089
  let projects: string[] = [];
1084
1090
  try {
1085
1091
  projects = await fs.readdir(sessionsDir);
@@ -1111,7 +1117,7 @@ async function scanDiskRoots(limit: number): Promise<Array<{ file: string; mtime
1111
1117
  }),
1112
1118
  );
1113
1119
  roots.sort((a, b) => b.mtimeMs - a.mtimeMs);
1114
- if (roots.length <= 1000) diskRootsMemo = { atMs: Date.now(), limit, roots };
1120
+ if (roots.length <= 1000) diskRootsMemo = { atMs: Date.now(), sessionsDir, limit, roots };
1115
1121
  return roots.slice(0, limit);
1116
1122
  }
1117
1123
 
@@ -1120,8 +1126,9 @@ async function scanDiskRoots(limit: number): Promise<Array<{ file: string; mtime
1120
1126
  * transcript (subagents, advisors) into its root row.
1121
1127
  */
1122
1128
  export async function listSessionSummaries(limit = 100, q?: string): Promise<SessionSummary[]> {
1129
+ // The Traces page may be the first thing a dashboard serves.
1130
+ await initDb();
1123
1131
  const sessionsDir = getSessionsDir();
1124
- const toolCounts = getToolCallCountsBySession();
1125
1132
  const byRoot = new Map<string, SummaryFold>();
1126
1133
 
1127
1134
  for (const row of getSessionRollups()) {
@@ -1152,7 +1159,7 @@ export async function listSessionSummaries(limit = 100, q?: string): Promise<Ses
1152
1159
  byRoot.set(rootFile, fold);
1153
1160
  }
1154
1161
  fold.requests += row.requests;
1155
- fold.toolCalls += toolCounts.get(row.sessionFile) ?? 0;
1162
+ fold.toolCalls += row.toolCalls;
1156
1163
  if (isChild) fold.subagents++;
1157
1164
  if (row.startedAt < fold.startedAt) fold.startedAt = row.startedAt;
1158
1165
  if (row.endedAt > fold.endedAt) fold.endedAt = row.endedAt;
package/src/types.ts CHANGED
@@ -175,6 +175,10 @@ export interface UserMessageStats {
175
175
  repetition: number;
176
176
  /** Second-person reproach ("you didnt", "why did you", "stop X-ing") */
177
177
  blame: number;
178
+ /** Judge-facing prose (see `judgeProse`); empty when the message was all markup. */
179
+ prose: string;
180
+ /** Hex `Bun.hash` of `prose`, keying the shared verdict cache; empty when `prose` is. */
181
+ proseHash: string;
178
182
  }
179
183
 
180
184
  /**