@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.
- package/CHANGELOG.md +22 -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 +143 -87
- 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
|
@@ -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
|
-
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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, "
|
|
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);
|
|
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
|
-
|
|
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;
|
|
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/
|
|
233
|
-
const stats = await
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
return Response.json({
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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);
|
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
|
/**
|