@oh-my-pi/omp-stats 18.3.5 → 18.4.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.
Files changed (192) hide show
  1. package/CHANGELOG.md +16 -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 +123 -40
  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
@@ -2,26 +2,33 @@ import type { Dirent } from "node:fs";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { isEnoent } from "@oh-my-pi/pi-utils";
5
+ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
6
6
  import { $, type Server } from "bun";
7
7
  import {
8
- getBehaviorDashboardStats,
9
8
  getCostDashboardStats,
10
9
  getDashboardStats,
11
10
  getFolderStats,
12
11
  getModelDashboardStats,
13
12
  getOverviewStats,
14
13
  getProviderDashboardStats,
14
+ getProviderWindowStats,
15
15
  getRecentErrors,
16
16
  getRecentRequests,
17
17
  getRequestDetails,
18
18
  getToolDashboardStats,
19
- getTotalMessageCount,
20
- syncAllSessions,
21
19
  } from "./aggregator";
22
20
  import { decodeEmbeddedClientArchive } from "./embedded-client";
23
21
  import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
22
+ import {
23
+ cancelFrustrationRun,
24
+ estimateFrustrationRun,
25
+ getFrustrationDashboardStats,
26
+ type StatsJudgeProvider,
27
+ setStatsJudgeProvider,
28
+ startFrustrationRun,
29
+ } from "./frustration";
24
30
  import { getGainDashboardStats } from "./gain-aggregator";
31
+ import { statsLive } from "./live";
25
32
  import {
26
33
  prepareStatsPort,
27
34
  recoverStatsPort,
@@ -30,6 +37,7 @@ import {
30
37
  STATS_DASHBOARD_HOSTNAME_HEADER,
31
38
  STATS_DASHBOARD_SECURITY_VERSION,
32
39
  } from "./port-conflict";
40
+ import type { LiveStatus } from "./shared-types";
33
41
  import {
34
42
  buildSessionTrace,
35
43
  getTraceEntry,
@@ -145,15 +153,8 @@ async function getLatestMtime(dir: string): Promise<number> {
145
153
  const ensureClientBuild = async () => {
146
154
  if (USE_EMBEDDED_CLIENT) return;
147
155
  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);
156
+ const cssPath = path.join(STATIC_DIR, "index.css");
157
+ const sourceMtime = await getLatestMtime(CLIENT_DIR);
157
158
  let shouldBuild = true;
158
159
  try {
159
160
  const [indexStats, cssStats] = await Promise.all([fs.stat(indexPath), fs.stat(cssPath)]);
@@ -173,7 +174,7 @@ const ensureClientBuild = async () => {
173
174
 
174
175
  await fs.rm(STATIC_DIR, { recursive: true, force: true });
175
176
 
176
- console.log("Building stats client...");
177
+ logger.debug("Building stats client");
177
178
  const packageRoot = path.join(import.meta.dir, "..");
178
179
  const buildResult = await $`bun run build.ts`.cwd(packageRoot).quiet().nothrow();
179
180
  if (buildResult.exitCode !== 0) {
@@ -181,24 +182,15 @@ const ensureClientBuild = async () => {
181
182
  const details = output ? `\n${output}` : "";
182
183
  throw new Error(`Failed to build stats client (exit ${buildResult.exitCode})${details}`);
183
184
  }
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
185
  };
201
186
 
187
+ /**
188
+ * Required on every spending/mutating POST. A custom header forces a CORS
189
+ * preflight, which this server never approves, so a hostile page cannot
190
+ * trigger a paid judge run through a cross-site form or `fetch`.
191
+ */
192
+ const STATS_ACTION_HEADER = "X-Omp-Stats-Action";
193
+
202
194
  /**
203
195
  * Handle API requests.
204
196
  */
@@ -206,9 +198,13 @@ export async function handleApi(req: Request): Promise<Response> {
206
198
  const url = new URL(req.url);
207
199
  const path = url.pathname;
208
200
 
209
- // Stats reads are DB-only; explicit /api/sync does the expensive session scan.
201
+ // Stats reads are DB-only; ingest runs in the background (see `live.ts`).
210
202
  const range = url.searchParams.get("range");
211
203
 
204
+ if (path === "/api/status") {
205
+ return Response.json(statsLive().status());
206
+ }
207
+
212
208
  if (path === "/api/stats") {
213
209
  const stats = await getDashboardStats(range);
214
210
  return Response.json(stats);
@@ -229,16 +225,36 @@ export async function handleApi(req: Request): Promise<Response> {
229
225
  return Response.json(stats);
230
226
  }
231
227
 
232
- if (path === "/api/stats/behavior") {
233
- const stats = await getBehaviorDashboardStats(range);
228
+ if (path === "/api/stats/frustration") {
229
+ const stats = await getFrustrationDashboardStats(range);
234
230
  return Response.json(stats);
235
231
  }
236
232
 
233
+ if (path === "/api/frustration/estimate") {
234
+ const estimate = await estimateFrustrationRun(range);
235
+ return Response.json(estimate);
236
+ }
237
+
238
+ if (path === "/api/frustration/judge" || path === "/api/frustration/cancel") {
239
+ if (req.method !== "POST") return Response.json({ error: "POST required" }, { status: 405 });
240
+ if (req.headers.get(STATS_ACTION_HEADER) !== "1") {
241
+ return Response.json({ error: `${STATS_ACTION_HEADER}: 1 header required` }, { status: 403 });
242
+ }
243
+ if (path === "/api/frustration/cancel") return Response.json(cancelFrustrationRun());
244
+ const result = await startFrustrationRun(range);
245
+ if (!result.started) return Response.json({ error: result.error }, { status: result.status });
246
+ return Response.json(result.job, { status: 202 });
247
+ }
248
+
237
249
  if (path === "/api/stats/tools") {
238
250
  const stats = await getToolDashboardStats(range);
239
251
  return Response.json(stats);
240
252
  }
241
253
 
254
+ if (path === "/api/stats/provider-windows") {
255
+ return Response.json(await getProviderWindowStats(range, url.searchParams.get("provider")));
256
+ }
257
+
242
258
  if (path === "/api/stats/providers") {
243
259
  const stats = await getProviderDashboardStats(range);
244
260
  return Response.json(stats);
@@ -280,9 +296,9 @@ export async function handleApi(req: Request): Promise<Response> {
280
296
  }
281
297
 
282
298
  if (path === "/api/sync") {
283
- const result = await syncAllSessions();
284
- const count = await getTotalMessageCount();
285
- return Response.json({ ...result, totalMessages: count });
299
+ if (req.method !== "POST") return Response.json({ error: "POST required" }, { status: 405 });
300
+ statsLive().requestSync();
301
+ return Response.json(statsLive().status(), { status: 202 });
286
302
  }
287
303
 
288
304
  if (path === "/api/stats/gain") {
@@ -376,7 +392,7 @@ function createDashboardServer(port: number, hostname: string): Server<undefined
376
392
  const server = Bun.serve({
377
393
  port,
378
394
  hostname,
379
- async fetch(req) {
395
+ async fetch(req, server) {
380
396
  const url = new URL(req.url);
381
397
  const path = url.pathname;
382
398
 
@@ -391,6 +407,12 @@ function createDashboardServer(port: number, hostname: string): Server<undefined
391
407
  return new Response(null, { headers: dashboardHeaders });
392
408
  }
393
409
 
410
+ if (path === "/api/events") {
411
+ // Long-lived stream: exempt from the idle timeout.
412
+ server.timeout(req, 0);
413
+ return liveEventStream(dashboardHeaders);
414
+ }
415
+
394
416
  try {
395
417
  let response: Response;
396
418
 
@@ -411,7 +433,7 @@ function createDashboardServer(port: number, hostname: string): Server<undefined
411
433
  headers,
412
434
  });
413
435
  } catch (error) {
414
- console.error("Server error:", error);
436
+ logger.error("Stats dashboard request failed", { path, error: String(error) });
415
437
  return Response.json(
416
438
  { error: error instanceof Error ? error.message : "Unknown error" },
417
439
  { status: 500, headers: dashboardHeaders },
@@ -422,6 +444,49 @@ function createDashboardServer(port: number, hostname: string): Server<undefined
422
444
  return server;
423
445
  }
424
446
 
447
+ /** Keep-alive comment cadence so proxies and the browser keep the stream open. */
448
+ const EVENT_HEARTBEAT_MS = 15_000;
449
+
450
+ /**
451
+ * Server-sent events of {@link LiveStatus}: the current status immediately,
452
+ * then every change (sync progress, data version bumps, indexing backlog).
453
+ */
454
+ function liveEventStream(headers: Record<string, string>): Response {
455
+ const live = statsLive();
456
+ // Ingest starts when the first page connects, not when the server binds.
457
+ live.start();
458
+ const encoder = new TextEncoder();
459
+ let cleanup = () => {};
460
+ const stream = new ReadableStream<Uint8Array>({
461
+ start(controller) {
462
+ const send = (status: LiveStatus) => {
463
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(status)}\n\n`));
464
+ };
465
+ send(live.status());
466
+ const unsubscribe = live.subscribe(send);
467
+ const heartbeat = setInterval(
468
+ () => controller.enqueue(encoder.encode(": keep-alive\n\n")),
469
+ EVENT_HEARTBEAT_MS,
470
+ );
471
+ cleanup = () => {
472
+ unsubscribe();
473
+ clearInterval(heartbeat);
474
+ };
475
+ },
476
+ cancel() {
477
+ cleanup();
478
+ },
479
+ });
480
+ return new Response(stream, {
481
+ headers: {
482
+ ...headers,
483
+ "Content-Type": "text/event-stream",
484
+ "Cache-Control": "no-cache",
485
+ Connection: "keep-alive",
486
+ },
487
+ });
488
+ }
489
+
425
490
  /**
426
491
  * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
427
492
  */
@@ -436,8 +501,20 @@ export interface StatsServerHandle {
436
501
  // the live handle: probing our own port can time out under load and would
437
502
  // then dead-end in the reclaim path's self-PID guard.
438
503
  const activeServers = new Map<string, StatsServerHandle>();
504
+ /** Dashboards bound by this process (any port); background ingest stops when the last one does. */
505
+ let liveServers = 0;
506
+
507
+ export interface StartServerOptions {
508
+ /** Host judge for the Frustration dashboard's judge runs; replaces any previously registered one. */
509
+ judge?: StatsJudgeProvider;
510
+ }
439
511
 
440
- export async function startServer(port = 3847, hostname = STATS_DASHBOARD_HOSTNAME): Promise<StatsServerHandle> {
512
+ export async function startServer(
513
+ port = 3847,
514
+ hostname = STATS_DASHBOARD_HOSTNAME,
515
+ options: StartServerOptions = {},
516
+ ): Promise<StatsServerHandle> {
517
+ if (options.judge) setStatsJudgeProvider(options.judge);
441
518
  const activeKey = `${hostname}:${port}`;
442
519
  if (port !== 0) {
443
520
  const active = activeServers.get(activeKey);
@@ -449,12 +526,18 @@ export async function startServer(port = 3847, hostname = STATS_DASHBOARD_HOSTNA
449
526
  return { hostname, port, stop: () => {} };
450
527
  }
451
528
  const register = (server: Server<undefined>): StatsServerHandle => {
529
+ liveServers++;
530
+ let stopped = false;
452
531
  const handle: StatsServerHandle = {
453
532
  hostname,
454
533
  port: server.port ?? port,
455
534
  stop: () => {
456
535
  activeServers.delete(activeKey);
457
- server.stop();
536
+ server.stop(true);
537
+ if (stopped) return;
538
+ stopped = true;
539
+ // The last dashboard in this process takes background ingest down with it.
540
+ if (--liveServers === 0) statsLive().stop();
458
541
  },
459
542
  };
460
543
  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
  /**
@@ -611,6 +611,19 @@ function stripStructuredContent(text: string): string {
611
611
  .replace(ANSI_ESCAPE_RE, "");
612
612
  }
613
613
 
614
+ /** Cap on the judge-facing prose stored per user message; longer prose is truncated. */
615
+ export const PROSE_MAX_CHARS = 4000;
616
+
617
+ /**
618
+ * The prose a user message contributes to frustration judging: the same
619
+ * structured-content strip the regex signals score on, trimmed and capped at
620
+ * {@link PROSE_MAX_CHARS}. Empty when the message was nothing but markup.
621
+ */
622
+ export function judgeProse(text: string): string {
623
+ const prose = stripStructuredContent(text.trim()).trim();
624
+ return prose.length > PROSE_MAX_CHARS ? prose.slice(0, PROSE_MAX_CHARS) : prose;
625
+ }
626
+
614
627
  function countNonEmptyLines(text: string): number {
615
628
  let count = 0;
616
629
  for (const line of text.split("\n")) {