@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.
- package/CHANGELOG.md +16 -0
- package/README.md +13 -11
- package/build.ts +5 -49
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/index.js +105 -126
- package/dist/types/aggregator.d.ts +25 -22
- package/dist/types/client/api.d.ts +11 -7
- package/dist/types/client/app/LiveChip.d.ts +2 -0
- package/dist/types/client/app/Shell.d.ts +16 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/nav.d.ts +18 -0
- package/dist/types/client/charts/BarList.d.ts +17 -0
- package/dist/types/client/charts/Chart.d.ts +49 -0
- package/dist/types/client/charts/Legend.d.ts +16 -0
- package/dist/types/client/charts/ShareBar.d.ts +11 -0
- package/dist/types/client/charts/Sparkline.d.ts +11 -0
- package/dist/types/client/charts/TimeChart.d.ts +9 -0
- package/dist/types/client/charts/index.d.ts +7 -0
- package/dist/types/client/charts/types.d.ts +25 -0
- package/dist/types/client/charts/useWidth.d.ts +3 -0
- package/dist/types/client/data/colors.d.ts +25 -0
- package/dist/types/client/data/formatters.d.ts +9 -1
- package/dist/types/client/data/live.d.ts +29 -0
- package/dist/types/client/data/query.d.ts +35 -0
- package/dist/types/client/data/range.d.ts +31 -0
- package/dist/types/client/data/series.d.ts +28 -0
- package/dist/types/client/data/useHashRoute.d.ts +18 -4
- package/dist/types/client/data/view-models.d.ts +111 -28
- package/dist/types/client/index.d.ts +0 -1
- package/dist/types/client/routes/CostsRoute.d.ts +2 -2
- package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
- package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
- package/dist/types/client/routes/GainRoute.d.ts +1 -2
- package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
- package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
- package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
- package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
- package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
- package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
- package/dist/types/client/routes/TracesRoute.d.ts +2 -2
- package/dist/types/client/routes/index.d.ts +1 -1
- package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
- package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
- package/dist/types/client/traces/trace-colors.d.ts +18 -12
- package/dist/types/client/ui/Badge.d.ts +19 -0
- package/dist/types/client/ui/Card.d.ts +25 -0
- package/dist/types/client/ui/Drawer.d.ts +25 -0
- package/dist/types/client/ui/JsonBlock.d.ts +2 -2
- package/dist/types/client/ui/Modal.d.ts +23 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
- package/dist/types/client/ui/SearchInput.d.ts +8 -0
- package/dist/types/client/ui/Segmented.d.ts +15 -0
- package/dist/types/client/ui/Stat.d.ts +36 -0
- package/dist/types/client/ui/States.d.ts +41 -0
- package/dist/types/client/ui/Table.d.ts +48 -0
- package/dist/types/client/ui/index.d.ts +9 -10
- package/dist/types/db.d.ts +90 -106
- package/dist/types/frustration.d.ts +70 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/live.d.ts +30 -0
- package/dist/types/rollup.d.ts +115 -0
- package/dist/types/server.d.ts +6 -1
- package/dist/types/shared-types.d.ts +116 -56
- package/dist/types/types.d.ts +4 -0
- package/dist/types/user-metrics.d.ts +8 -0
- package/package.json +7 -13
- package/src/aggregator.ts +276 -211
- package/src/client/App.tsx +38 -80
- package/src/client/api.ts +65 -7
- package/src/client/app/LiveChip.tsx +97 -0
- package/src/client/app/Shell.tsx +181 -0
- package/src/client/app/ThemeToggle.tsx +5 -2
- package/src/client/app/nav.ts +78 -0
- package/src/client/charts/BarList.tsx +48 -0
- package/src/client/charts/Chart.tsx +388 -0
- package/src/client/charts/Legend.tsx +61 -0
- package/src/client/charts/ShareBar.tsx +26 -0
- package/src/client/charts/Sparkline.tsx +38 -0
- package/src/client/charts/TimeChart.tsx +22 -0
- package/src/client/charts/index.ts +7 -0
- package/src/client/charts/types.ts +27 -0
- package/src/client/charts/useWidth.ts +19 -0
- package/src/client/data/colors.ts +45 -0
- package/src/client/data/formatters.ts +30 -9
- package/src/client/data/live.tsx +85 -0
- package/src/client/data/query.ts +166 -0
- package/src/client/data/range.ts +82 -0
- package/src/client/data/series.ts +85 -0
- package/src/client/data/useHashRoute.ts +41 -83
- package/src/client/data/view-models.ts +347 -140
- package/src/client/index.tsx +3 -6
- package/src/client/routes/CostsRoute.tsx +394 -222
- package/src/client/routes/ErrorsRoute.tsx +401 -98
- package/src/client/routes/FrustrationRoute.tsx +924 -0
- package/src/client/routes/GainRoute.tsx +209 -206
- package/src/client/routes/ModelsRoute.tsx +540 -431
- package/src/client/routes/OverviewRoute.tsx +283 -311
- package/src/client/routes/ProjectsRoute.tsx +298 -156
- package/src/client/routes/ProvidersRoute.tsx +948 -416
- package/src/client/routes/RequestsRoute.tsx +295 -107
- package/src/client/routes/ToolsRoute.tsx +495 -398
- package/src/client/routes/TracesRoute.tsx +136 -117
- package/src/client/routes/costs.css +24 -0
- package/src/client/routes/errors.css +60 -0
- package/src/client/routes/frustration.css +123 -0
- package/src/client/routes/index.ts +1 -1
- package/src/client/routes/models.css +43 -0
- package/src/client/routes/projects.css +6 -0
- package/src/client/routes/providers.css +28 -0
- package/src/client/routes/tools.css +14 -0
- package/src/client/styles.css +1396 -1299
- package/src/client/traces/AggregatesPanel.tsx +55 -74
- package/src/client/traces/Minimap.tsx +6 -15
- package/src/client/traces/SpanDrawer.tsx +128 -194
- package/src/client/traces/SummaryStrip.tsx +21 -24
- package/src/client/traces/TimelineCanvas.tsx +27 -44
- package/src/client/traces/TraceView.tsx +175 -130
- package/src/client/traces/TranscriptList.tsx +13 -41
- package/src/client/traces/trace-colors.ts +60 -53
- package/src/client/traces/traces.css +279 -0
- package/src/client/ui/Badge.tsx +29 -0
- package/src/client/ui/Card.tsx +61 -0
- package/src/client/ui/Drawer.tsx +93 -0
- package/src/client/ui/JsonBlock.tsx +26 -44
- package/src/client/ui/Modal.tsx +106 -0
- package/src/client/ui/RequestDrawer.tsx +163 -188
- package/src/client/ui/SearchInput.tsx +25 -0
- package/src/client/ui/Segmented.tsx +61 -0
- package/src/client/ui/Stat.tsx +104 -0
- package/src/client/ui/States.tsx +81 -0
- package/src/client/ui/Table.tsx +229 -0
- package/src/client/ui/index.ts +9 -10
- package/src/client/ui/modal.css +54 -0
- package/src/client/ui/request-drawer.css +40 -0
- package/src/db.ts +379 -983
- package/src/frustration.ts +473 -0
- package/src/index.ts +22 -22
- package/src/live.ts +259 -0
- package/src/parser.ts +55 -66
- package/src/rollup.ts +951 -0
- package/src/server.ts +123 -40
- package/src/shared-types.ts +120 -56
- package/src/trace.ts +14 -7
- package/src/types.ts +4 -0
- package/src/user-metrics.ts +13 -0
- package/dist/client/styles.css +0 -1976
- package/dist/types/client/app/AppLayout.d.ts +0 -16
- package/dist/types/client/app/NavRail.d.ts +0 -7
- package/dist/types/client/app/RangeControl.d.ts +0 -7
- package/dist/types/client/app/SyncButton.d.ts +0 -14
- package/dist/types/client/app/TopBar.d.ts +0 -15
- package/dist/types/client/app/routes.d.ts +0 -12
- package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
- package/dist/types/client/components/chart-shared.d.ts +0 -178
- package/dist/types/client/components/models-table-shared.d.ts +0 -175
- package/dist/types/client/components/range-meta.d.ts +0 -21
- package/dist/types/client/data/charts.d.ts +0 -1
- package/dist/types/client/data/useResource.d.ts +0 -13
- package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
- package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
- package/dist/types/client/ui/DataTable.d.ts +0 -17
- package/dist/types/client/ui/EmptyState.d.ts +0 -7
- package/dist/types/client/ui/ErrorState.d.ts +0 -6
- package/dist/types/client/ui/MetricCluster.d.ts +0 -5
- package/dist/types/client/ui/Panel.d.ts +0 -7
- package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
- package/dist/types/client/ui/Skeleton.d.ts +0 -8
- package/dist/types/client/ui/StatusPill.d.ts +0 -7
- package/src/client/app/AppLayout.tsx +0 -93
- package/src/client/app/NavRail.tsx +0 -44
- package/src/client/app/RangeControl.tsx +0 -39
- package/src/client/app/SyncButton.tsx +0 -75
- package/src/client/app/TopBar.tsx +0 -73
- package/src/client/app/routes.ts +0 -93
- package/src/client/components/AgentTokenShare.tsx +0 -68
- package/src/client/components/chart-shared.tsx +0 -273
- package/src/client/components/models-table-shared.tsx +0 -255
- package/src/client/components/range-meta.ts +0 -73
- package/src/client/data/charts.ts +0 -14
- package/src/client/data/useResource.ts +0 -154
- package/src/client/routes/BehaviorRoute.tsx +0 -623
- package/src/client/ui/AsyncBoundary.tsx +0 -54
- package/src/client/ui/DataTable.tsx +0 -122
- package/src/client/ui/EmptyState.tsx +0 -16
- package/src/client/ui/ErrorState.tsx +0 -25
- package/src/client/ui/MetricCluster.tsx +0 -92
- package/src/client/ui/Panel.tsx +0 -24
- package/src/client/ui/SegmentedControl.tsx +0 -36
- package/src/client/ui/Skeleton.tsx +0 -17
- package/src/client/ui/StatusPill.tsx +0 -15
- 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, "
|
|
149
|
-
const
|
|
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
|
-
|
|
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;
|
|
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/
|
|
233
|
-
const stats = await
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
return Response.json({
|
|
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
|
-
|
|
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(
|
|
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);
|
package/src/shared-types.ts
CHANGED
|
@@ -194,68 +194,95 @@ export interface AgentTypeStats {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
/**
|
|
197
|
-
*
|
|
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
|
|
200
|
-
/**
|
|
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
|
-
/**
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
|
|
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
|
-
*
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
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
|
-
| {
|
|
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 +=
|
|
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
|
/**
|
package/src/user-metrics.ts
CHANGED
|
@@ -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")) {
|