@oh-my-pi-zen/omp-stats 16.3.6-zen.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 +197 -0
- package/README.md +82 -0
- package/build.ts +95 -0
- package/dist/client/index.css +1 -0
- package/dist/client/index.html +24 -0
- package/dist/client/index.js +257 -0
- package/dist/client/styles.css +1656 -0
- package/dist/types/aggregator.d.ts +87 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/api.d.ts +21 -0
- package/dist/types/client/app/AppLayout.d.ts +16 -0
- package/dist/types/client/app/NavRail.d.ts +7 -0
- package/dist/types/client/app/RangeControl.d.ts +7 -0
- package/dist/types/client/app/SyncButton.d.ts +14 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/TopBar.d.ts +15 -0
- package/dist/types/client/app/routes.d.ts +12 -0
- package/dist/types/client/components/AgentTokenShare.d.ts +5 -0
- package/dist/types/client/components/chart-shared.d.ts +173 -0
- package/dist/types/client/components/models-table-shared.d.ts +175 -0
- package/dist/types/client/components/range-meta.d.ts +21 -0
- package/dist/types/client/data/charts.d.ts +1 -0
- package/dist/types/client/data/formatters.d.ts +8 -0
- package/dist/types/client/data/useHashRoute.d.ts +8 -0
- package/dist/types/client/data/useResource.d.ts +13 -0
- package/dist/types/client/data/view-models.d.ts +67 -0
- package/dist/types/client/index.d.ts +2 -0
- package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
- package/dist/types/client/routes/CostsRoute.d.ts +7 -0
- package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
- package/dist/types/client/routes/GainRoute.d.ts +7 -0
- package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
- package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
- package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
- package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
- package/dist/types/client/routes/ToolsRoute.d.ts +7 -0
- package/dist/types/client/routes/index.d.ts +9 -0
- package/dist/types/client/types.d.ts +63 -0
- package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
- package/dist/types/client/ui/DataTable.d.ts +17 -0
- package/dist/types/client/ui/EmptyState.d.ts +7 -0
- package/dist/types/client/ui/ErrorState.d.ts +6 -0
- package/dist/types/client/ui/JsonBlock.d.ts +7 -0
- package/dist/types/client/ui/MetricCluster.d.ts +5 -0
- package/dist/types/client/ui/Panel.d.ts +7 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
- package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
- package/dist/types/client/ui/Skeleton.d.ts +8 -0
- package/dist/types/client/ui/StatusPill.d.ts +7 -0
- package/dist/types/client/ui/index.d.ts +11 -0
- package/dist/types/client/useSystemTheme.d.ts +11 -0
- package/dist/types/db.d.ts +144 -0
- package/dist/types/embedded-client.d.ts +18 -0
- package/dist/types/gain-aggregator.d.ts +26 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/parser.d.ts +53 -0
- package/dist/types/server.d.ts +7 -0
- package/dist/types/shared-types.d.ts +301 -0
- package/dist/types/sync-worker.d.ts +31 -0
- package/dist/types/types.d.ts +164 -0
- package/dist/types/user-metrics.d.ts +72 -0
- package/package.json +95 -0
- package/src/aggregator.ts +501 -0
- package/src/client/App.tsx +109 -0
- package/src/client/api.ts +102 -0
- package/src/client/app/AppLayout.tsx +93 -0
- package/src/client/app/NavRail.tsx +44 -0
- package/src/client/app/RangeControl.tsx +39 -0
- package/src/client/app/SyncButton.tsx +75 -0
- package/src/client/app/ThemeToggle.tsx +37 -0
- package/src/client/app/TopBar.tsx +73 -0
- package/src/client/app/routes.ts +69 -0
- package/src/client/components/AgentTokenShare.tsx +68 -0
- package/src/client/components/chart-shared.tsx +257 -0
- package/src/client/components/models-table-shared.tsx +255 -0
- package/src/client/components/range-meta.ts +73 -0
- package/src/client/css.d.ts +1 -0
- package/src/client/data/charts.ts +14 -0
- package/src/client/data/formatters.ts +45 -0
- package/src/client/data/useHashRoute.ts +87 -0
- package/src/client/data/useResource.ts +154 -0
- package/src/client/data/view-models.ts +251 -0
- package/src/client/index.tsx +10 -0
- package/src/client/routes/BehaviorRoute.tsx +623 -0
- package/src/client/routes/CostsRoute.tsx +234 -0
- package/src/client/routes/ErrorsRoute.tsx +118 -0
- package/src/client/routes/GainRoute.tsx +226 -0
- package/src/client/routes/ModelsRoute.tsx +430 -0
- package/src/client/routes/OverviewRoute.tsx +342 -0
- package/src/client/routes/ProjectsRoute.tsx +163 -0
- package/src/client/routes/RequestsRoute.tsx +123 -0
- package/src/client/routes/ToolsRoute.tsx +463 -0
- package/src/client/routes/index.ts +9 -0
- package/src/client/styles.css +1330 -0
- package/src/client/types.ts +80 -0
- package/src/client/ui/AsyncBoundary.tsx +54 -0
- package/src/client/ui/DataTable.tsx +122 -0
- package/src/client/ui/EmptyState.tsx +16 -0
- package/src/client/ui/ErrorState.tsx +25 -0
- package/src/client/ui/JsonBlock.tsx +75 -0
- package/src/client/ui/MetricCluster.tsx +67 -0
- package/src/client/ui/Panel.tsx +24 -0
- package/src/client/ui/RequestDrawer.tsx +208 -0
- package/src/client/ui/SegmentedControl.tsx +36 -0
- package/src/client/ui/Skeleton.tsx +17 -0
- package/src/client/ui/StatusPill.tsx +15 -0
- package/src/client/ui/index.ts +11 -0
- package/src/client/useSystemTheme.ts +87 -0
- package/src/db.ts +1530 -0
- package/src/embedded-client.generated.txt +0 -0
- package/src/embedded-client.ts +26 -0
- package/src/gain-aggregator.ts +281 -0
- package/src/index.ts +194 -0
- package/src/parser.ts +450 -0
- package/src/server.ts +352 -0
- package/src/shared-types.ts +323 -0
- package/src/sync-worker.ts +40 -0
- package/src/types.ts +171 -0
- package/src/user-metrics.ts +685 -0
- package/tailwind.config.js +40 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { isEnoent } from "@oh-my-pi-zen/pi-utils";
|
|
6
|
+
import { $ } from "bun";
|
|
7
|
+
import {
|
|
8
|
+
getBehaviorDashboardStats,
|
|
9
|
+
getCostDashboardStats,
|
|
10
|
+
getDashboardStats,
|
|
11
|
+
getModelDashboardStats,
|
|
12
|
+
getOverviewStats,
|
|
13
|
+
getRecentErrors,
|
|
14
|
+
getRecentRequests,
|
|
15
|
+
getRequestDetails,
|
|
16
|
+
getToolDashboardStats,
|
|
17
|
+
getTotalMessageCount,
|
|
18
|
+
syncAllSessions,
|
|
19
|
+
} from "./aggregator";
|
|
20
|
+
import { decodeEmbeddedClientArchive } from "./embedded-client";
|
|
21
|
+
import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
|
|
22
|
+
import { getGainDashboardStats } from "./gain-aggregator";
|
|
23
|
+
|
|
24
|
+
const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
|
|
25
|
+
|
|
26
|
+
const CLIENT_DIR = path.join(import.meta.dir, "client");
|
|
27
|
+
const STATIC_DIR = path.join(import.meta.dir, "..", "dist", "client");
|
|
28
|
+
const IS_BUN_COMPILED =
|
|
29
|
+
Boolean(process.env.PI_COMPILED || Bun.env.PI_COMPILED) ||
|
|
30
|
+
import.meta.url.includes("$bunfs") ||
|
|
31
|
+
import.meta.url.includes("~BUN") ||
|
|
32
|
+
import.meta.url.includes("%7EBUN");
|
|
33
|
+
// The prepacked npm bundle (coding-agent dist/cli.js) constant-folds
|
|
34
|
+
// process.env.PI_BUNDLED at build time. Like compiled binaries, it ships no
|
|
35
|
+
// dashboard sources or prebuilt dist/client next to the bundle, so the
|
|
36
|
+
// embedded archive is the only viable asset source.
|
|
37
|
+
const IS_PREBUILT = IS_BUN_COMPILED || Boolean(process.env.PI_BUNDLED || Bun.env.PI_BUNDLED);
|
|
38
|
+
const USE_EMBEDDED_CLIENT = EMBEDDED_CLIENT_ARCHIVE !== null || IS_PREBUILT;
|
|
39
|
+
|
|
40
|
+
const EMBEDDED_CLIENT_DIR_ROOT = path.join(os.tmpdir(), "omp-stats-client");
|
|
41
|
+
let embeddedClientDirPromise: Promise<string> | null = null;
|
|
42
|
+
|
|
43
|
+
function sanitizeArchivePath(archivePath: string): string | null {
|
|
44
|
+
const normalized = archivePath.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
45
|
+
if (!normalized || normalized === ".") return null;
|
|
46
|
+
if (normalized.includes("..") || path.isAbsolute(normalized)) return null;
|
|
47
|
+
return normalized;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function extractEmbeddedClientArchive(archiveBytes: Buffer, outputDir: string): Promise<void> {
|
|
51
|
+
const archive = new Bun.Archive(archiveBytes);
|
|
52
|
+
const files = await archive.files();
|
|
53
|
+
const extractRoot = path.resolve(outputDir);
|
|
54
|
+
|
|
55
|
+
for (const [archivePath, file] of files) {
|
|
56
|
+
const sanitizedPath = sanitizeArchivePath(archivePath);
|
|
57
|
+
if (!sanitizedPath) continue;
|
|
58
|
+
const destinationPath = path.resolve(extractRoot, sanitizedPath);
|
|
59
|
+
if (!destinationPath.startsWith(extractRoot + path.sep)) {
|
|
60
|
+
throw new Error(`Archive entry escapes extraction directory: ${archivePath}`);
|
|
61
|
+
}
|
|
62
|
+
await Bun.write(destinationPath, file);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function getEmbeddedClientDir(): Promise<string> {
|
|
67
|
+
if (!USE_EMBEDDED_CLIENT) return STATIC_DIR;
|
|
68
|
+
if (embeddedClientDirPromise) return embeddedClientDirPromise;
|
|
69
|
+
|
|
70
|
+
if (!EMBEDDED_CLIENT_ARCHIVE) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"Embedded stats client bundle missing. Rebuild the omp binary or npm bundle with embedded stats assets.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
embeddedClientDirPromise = (async () => {
|
|
77
|
+
const bundleHash = Bun.hash(EMBEDDED_CLIENT_ARCHIVE).toString(16);
|
|
78
|
+
const outputDir = path.join(EMBEDDED_CLIENT_DIR_ROOT, bundleHash);
|
|
79
|
+
const markerPath = path.join(outputDir, "index.html");
|
|
80
|
+
try {
|
|
81
|
+
const marker = await fs.stat(markerPath);
|
|
82
|
+
if (marker.isFile()) return outputDir;
|
|
83
|
+
} catch {}
|
|
84
|
+
|
|
85
|
+
await fs.rm(outputDir, { recursive: true, force: true });
|
|
86
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
87
|
+
await extractEmbeddedClientArchive(EMBEDDED_CLIENT_ARCHIVE, outputDir);
|
|
88
|
+
return outputDir;
|
|
89
|
+
})();
|
|
90
|
+
|
|
91
|
+
return embeddedClientDirPromise;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function getLatestMtime(dir: string): Promise<number> {
|
|
95
|
+
let entries: Dirent[];
|
|
96
|
+
try {
|
|
97
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// Tolerate missing source trees (e.g. installs without the dashboard
|
|
100
|
+
// sources); the caller falls back to prebuilt assets or a clear build
|
|
101
|
+
// failure instead of crashing on the scan.
|
|
102
|
+
if (isEnoent(err)) return 0;
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const promises = [];
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const fullPath = path.join(dir, entry.name);
|
|
109
|
+
if (entry.isDirectory()) {
|
|
110
|
+
promises.push(getLatestMtime(fullPath));
|
|
111
|
+
} else if (entry.isFile()) {
|
|
112
|
+
promises.push(fs.stat(fullPath).then(stats => stats.mtimeMs));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let latest = 0;
|
|
117
|
+
await Promise.allSettled(promises).then(results => {
|
|
118
|
+
for (const result of results) {
|
|
119
|
+
if (result.status === "fulfilled") {
|
|
120
|
+
latest = Math.max(latest, result.value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
return latest;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const ensureClientBuild = async () => {
|
|
128
|
+
if (USE_EMBEDDED_CLIENT) return;
|
|
129
|
+
const indexPath = path.join(STATIC_DIR, "index.html");
|
|
130
|
+
const cssPath = path.join(STATIC_DIR, "styles.css");
|
|
131
|
+
const clientSourceMtime = await getLatestMtime(CLIENT_DIR);
|
|
132
|
+
const tailwindConfigPath = path.join(import.meta.dir, "..", "tailwind.config.js");
|
|
133
|
+
let tailwindConfigMtime = 0;
|
|
134
|
+
try {
|
|
135
|
+
const tailwindConfigStats = await fs.stat(tailwindConfigPath);
|
|
136
|
+
tailwindConfigMtime = tailwindConfigStats.mtimeMs;
|
|
137
|
+
} catch {}
|
|
138
|
+
const sourceMtime = Math.max(clientSourceMtime, tailwindConfigMtime);
|
|
139
|
+
let shouldBuild = true;
|
|
140
|
+
try {
|
|
141
|
+
const [indexStats, cssStats] = await Promise.all([fs.stat(indexPath), fs.stat(cssPath)]);
|
|
142
|
+
if (
|
|
143
|
+
indexStats.isFile() &&
|
|
144
|
+
cssStats.isFile() &&
|
|
145
|
+
indexStats.mtimeMs >= sourceMtime &&
|
|
146
|
+
cssStats.mtimeMs >= sourceMtime
|
|
147
|
+
) {
|
|
148
|
+
shouldBuild = false;
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
shouldBuild = true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!shouldBuild) return;
|
|
155
|
+
|
|
156
|
+
await fs.rm(STATIC_DIR, { recursive: true, force: true });
|
|
157
|
+
|
|
158
|
+
console.log("Building stats client...");
|
|
159
|
+
const packageRoot = path.join(import.meta.dir, "..");
|
|
160
|
+
const buildResult = await $`bun run build.ts`.cwd(packageRoot).quiet().nothrow();
|
|
161
|
+
if (buildResult.exitCode !== 0) {
|
|
162
|
+
const output = buildResult.text().trim();
|
|
163
|
+
const details = output ? `\n${output}` : "";
|
|
164
|
+
throw new Error(`Failed to build stats client (exit ${buildResult.exitCode})${details}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const indexHtml = `<!DOCTYPE html>
|
|
168
|
+
<html lang="en">
|
|
169
|
+
<head>
|
|
170
|
+
<meta charset="UTF-8">
|
|
171
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
172
|
+
<title>AI Usage Statistics</title>
|
|
173
|
+
<link rel="stylesheet" href="styles.css">
|
|
174
|
+
</head>
|
|
175
|
+
<body>
|
|
176
|
+
<div id="root"></div>
|
|
177
|
+
<script src="index.js" type="module"></script>
|
|
178
|
+
</body>
|
|
179
|
+
</html>`;
|
|
180
|
+
|
|
181
|
+
await Bun.write(path.join(STATIC_DIR, "index.html"), indexHtml);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Handle API requests.
|
|
186
|
+
*/
|
|
187
|
+
async function handleApi(req: Request): Promise<Response> {
|
|
188
|
+
const url = new URL(req.url);
|
|
189
|
+
const path = url.pathname;
|
|
190
|
+
|
|
191
|
+
// Stats reads are DB-only; explicit /api/sync does the expensive session scan.
|
|
192
|
+
const range = url.searchParams.get("range");
|
|
193
|
+
|
|
194
|
+
if (path === "/api/stats") {
|
|
195
|
+
const stats = await getDashboardStats(range);
|
|
196
|
+
return Response.json(stats);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (path === "/api/stats/overview") {
|
|
200
|
+
const stats = await getOverviewStats(range);
|
|
201
|
+
return Response.json(stats);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (path === "/api/stats/model-dashboard") {
|
|
205
|
+
const stats = await getModelDashboardStats(range);
|
|
206
|
+
return Response.json(stats);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (path === "/api/stats/costs") {
|
|
210
|
+
const stats = await getCostDashboardStats(range);
|
|
211
|
+
return Response.json(stats);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (path === "/api/stats/behavior") {
|
|
215
|
+
const stats = await getBehaviorDashboardStats(range);
|
|
216
|
+
return Response.json(stats);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (path === "/api/stats/tools") {
|
|
220
|
+
const stats = await getToolDashboardStats(range);
|
|
221
|
+
return Response.json(stats);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (path === "/api/stats/recent") {
|
|
225
|
+
const limit = url.searchParams.get("limit");
|
|
226
|
+
const stats = await getRecentRequests(limit ? parseInt(limit, 10) : undefined);
|
|
227
|
+
return Response.json(stats);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (path === "/api/stats/errors") {
|
|
231
|
+
const limit = url.searchParams.get("limit");
|
|
232
|
+
const stats = await getRecentErrors(limit ? parseInt(limit, 10) : undefined);
|
|
233
|
+
return Response.json(stats);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (path === "/api/stats/models") {
|
|
237
|
+
const stats = await getDashboardStats(range);
|
|
238
|
+
return Response.json(stats.byModel);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (path === "/api/stats/folders") {
|
|
242
|
+
const stats = await getDashboardStats(range);
|
|
243
|
+
return Response.json(stats.byFolder);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (path === "/api/stats/timeseries") {
|
|
247
|
+
const stats = await getDashboardStats(range);
|
|
248
|
+
return Response.json(stats.timeSeries);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (path.startsWith("/api/request/")) {
|
|
252
|
+
const id = path.split("/").pop();
|
|
253
|
+
if (!id) return new Response("Bad Request", { status: 400 });
|
|
254
|
+
const details = await getRequestDetails(parseInt(id, 10));
|
|
255
|
+
if (!details) return new Response("Not Found", { status: 404 });
|
|
256
|
+
return Response.json(details);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (path === "/api/sync") {
|
|
260
|
+
const result = await syncAllSessions();
|
|
261
|
+
const count = await getTotalMessageCount();
|
|
262
|
+
return Response.json({ ...result, totalMessages: count });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (path === "/api/stats/gain") {
|
|
266
|
+
const project = url.searchParams.get("project");
|
|
267
|
+
const stats = await getGainDashboardStats(range, project);
|
|
268
|
+
return Response.json(stats);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return new Response("Not Found", { status: 404 });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Handle static file requests.
|
|
276
|
+
*/
|
|
277
|
+
async function handleStatic(requestPath: string): Promise<Response> {
|
|
278
|
+
const staticDir = await getEmbeddedClientDir();
|
|
279
|
+
const filePath = requestPath === "/" ? "/index.html" : requestPath;
|
|
280
|
+
const fullPath = path.join(staticDir, filePath);
|
|
281
|
+
|
|
282
|
+
const file = Bun.file(fullPath);
|
|
283
|
+
if (await file.exists()) {
|
|
284
|
+
return new Response(file);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// SPA fallback
|
|
288
|
+
const index = Bun.file(path.join(staticDir, "index.html"));
|
|
289
|
+
if (await index.exists()) {
|
|
290
|
+
return new Response(index);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return new Response("Not Found", { status: 404 });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Start the HTTP server.
|
|
298
|
+
*/
|
|
299
|
+
export async function startServer(port = 3847): Promise<{ port: number; stop: () => void }> {
|
|
300
|
+
await ensureClientBuild();
|
|
301
|
+
|
|
302
|
+
const server = Bun.serve({
|
|
303
|
+
port,
|
|
304
|
+
async fetch(req) {
|
|
305
|
+
const url = new URL(req.url);
|
|
306
|
+
const path = url.pathname;
|
|
307
|
+
|
|
308
|
+
// CORS headers for local development
|
|
309
|
+
const corsHeaders = {
|
|
310
|
+
"Access-Control-Allow-Origin": "*",
|
|
311
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
312
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
if (req.method === "OPTIONS") {
|
|
316
|
+
return new Response(null, { headers: corsHeaders });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
let response: Response;
|
|
321
|
+
|
|
322
|
+
if (path.startsWith("/api/")) {
|
|
323
|
+
response = await handleApi(req);
|
|
324
|
+
} else {
|
|
325
|
+
response = await handleStatic(path);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Add CORS headers to all responses
|
|
329
|
+
const headers = new Headers(response.headers);
|
|
330
|
+
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
331
|
+
headers.set(key, value);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return new Response(response.body, {
|
|
335
|
+
status: response.status,
|
|
336
|
+
headers,
|
|
337
|
+
});
|
|
338
|
+
} catch (error) {
|
|
339
|
+
console.error("Server error:", error);
|
|
340
|
+
return Response.json(
|
|
341
|
+
{ error: error instanceof Error ? error.message : "Unknown error" },
|
|
342
|
+
{ status: 500, headers: corsHeaders },
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
port: server.port ?? port,
|
|
350
|
+
stop: () => server.stop(),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared type definitions consumed by both the server-side stats code and the
|
|
3
|
+
* standalone client bundle. Keep this file free of any imports from server-only
|
|
4
|
+
* packages (e.g. `@oh-my-pi-zen/pi-ai`, `bun:sqlite`) so the client can import it
|
|
5
|
+
* without dragging server dependencies into its bundle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Aggregated stats for a model or folder.
|
|
10
|
+
*/
|
|
11
|
+
export interface AggregatedStats {
|
|
12
|
+
/** Total number of requests */
|
|
13
|
+
totalRequests: number;
|
|
14
|
+
/** Number of successful requests */
|
|
15
|
+
successfulRequests: number;
|
|
16
|
+
/** Number of failed requests */
|
|
17
|
+
failedRequests: number;
|
|
18
|
+
/** Error rate (0-1) */
|
|
19
|
+
errorRate: number;
|
|
20
|
+
/** Total input tokens */
|
|
21
|
+
totalInputTokens: number;
|
|
22
|
+
/** Total output tokens */
|
|
23
|
+
totalOutputTokens: number;
|
|
24
|
+
/** Total cache read tokens */
|
|
25
|
+
totalCacheReadTokens: number;
|
|
26
|
+
/** Total cache write tokens */
|
|
27
|
+
totalCacheWriteTokens: number;
|
|
28
|
+
/** Cache hit rate (0-1) */
|
|
29
|
+
cacheRate: number;
|
|
30
|
+
/** Total cost */
|
|
31
|
+
totalCost: number;
|
|
32
|
+
/** Total premium requests */
|
|
33
|
+
totalPremiumRequests: number;
|
|
34
|
+
/** Average duration in ms */
|
|
35
|
+
avgDuration: number | null;
|
|
36
|
+
/** Average TTFT in ms */
|
|
37
|
+
avgTtft: number | null;
|
|
38
|
+
/** Average tokens per second (output tokens / duration) */
|
|
39
|
+
avgTokensPerSecond: number | null;
|
|
40
|
+
/** Time range */
|
|
41
|
+
firstTimestamp: number;
|
|
42
|
+
lastTimestamp: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Stats grouped by model.
|
|
47
|
+
*/
|
|
48
|
+
export interface ModelStats extends AggregatedStats {
|
|
49
|
+
model: string;
|
|
50
|
+
provider: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Stats grouped by folder.
|
|
55
|
+
*/
|
|
56
|
+
export interface FolderStats extends AggregatedStats {
|
|
57
|
+
folder: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Time series data point.
|
|
62
|
+
*/
|
|
63
|
+
export interface TimeSeriesPoint {
|
|
64
|
+
/** Bucket timestamp (start of hour/day) */
|
|
65
|
+
timestamp: number;
|
|
66
|
+
/** Request count */
|
|
67
|
+
requests: number;
|
|
68
|
+
/** Error count */
|
|
69
|
+
errors: number;
|
|
70
|
+
/** Total tokens */
|
|
71
|
+
tokens: number;
|
|
72
|
+
/** Total cost */
|
|
73
|
+
cost: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Model usage time series data point (daily buckets).
|
|
78
|
+
*/
|
|
79
|
+
export interface ModelTimeSeriesPoint {
|
|
80
|
+
/** Bucket timestamp (start of day) */
|
|
81
|
+
timestamp: number;
|
|
82
|
+
/** Model name */
|
|
83
|
+
model: string;
|
|
84
|
+
/** Provider name */
|
|
85
|
+
provider: string;
|
|
86
|
+
/** Request count */
|
|
87
|
+
requests: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Model performance time series data point (daily buckets).
|
|
92
|
+
*/
|
|
93
|
+
export interface ModelPerformancePoint {
|
|
94
|
+
/** Bucket timestamp (start of day) */
|
|
95
|
+
timestamp: number;
|
|
96
|
+
/** Model name */
|
|
97
|
+
model: string;
|
|
98
|
+
/** Provider name */
|
|
99
|
+
provider: string;
|
|
100
|
+
/** Request count */
|
|
101
|
+
requests: number;
|
|
102
|
+
/** Average TTFT in ms */
|
|
103
|
+
avgTtft: number | null;
|
|
104
|
+
/** Average tokens per second */
|
|
105
|
+
avgTokensPerSecond: number | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Cost time series data point (daily buckets).
|
|
110
|
+
*/
|
|
111
|
+
export interface CostTimeSeriesPoint {
|
|
112
|
+
/** Bucket timestamp (start of day) */
|
|
113
|
+
timestamp: number;
|
|
114
|
+
/** Model name */
|
|
115
|
+
model: string;
|
|
116
|
+
/** Provider name */
|
|
117
|
+
provider: string;
|
|
118
|
+
/** Total cost for this bucket */
|
|
119
|
+
cost: number;
|
|
120
|
+
/** Cost breakdown */
|
|
121
|
+
costInput: number;
|
|
122
|
+
costOutput: number;
|
|
123
|
+
costCacheRead: number;
|
|
124
|
+
costCacheWrite: number;
|
|
125
|
+
/** Request count */
|
|
126
|
+
requests: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Overall dashboard stats.
|
|
131
|
+
*/
|
|
132
|
+
export interface DashboardStats {
|
|
133
|
+
overall: AggregatedStats;
|
|
134
|
+
byModel: ModelStats[];
|
|
135
|
+
byFolder: FolderStats[];
|
|
136
|
+
byAgentType: AgentTypeStats[];
|
|
137
|
+
timeSeries: TimeSeriesPoint[];
|
|
138
|
+
modelSeries: ModelTimeSeriesPoint[];
|
|
139
|
+
modelPerformanceSeries: ModelPerformancePoint[];
|
|
140
|
+
costSeries: CostTimeSeriesPoint[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Which agent produced a message, derived from its transcript file location
|
|
145
|
+
* inside the session directory: the top-level `<project>/<file>.jsonl` is the
|
|
146
|
+
* `main` agent, an `__advisor.jsonl` is the passive `advisor`, and any other
|
|
147
|
+
* nested transcript is a task `subagent`.
|
|
148
|
+
*/
|
|
149
|
+
export type AgentType = "main" | "subagent" | "advisor";
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Token usage aggregated by {@link AgentType} over the active range. Token
|
|
153
|
+
* columns are explicit so the dashboard's share denominator matches the
|
|
154
|
+
* counts it renders (input + output + cache read + cache write).
|
|
155
|
+
*/
|
|
156
|
+
export interface AgentTypeStats {
|
|
157
|
+
agentType: AgentType;
|
|
158
|
+
/** Total number of requests */
|
|
159
|
+
totalRequests: number;
|
|
160
|
+
/** Total input tokens */
|
|
161
|
+
totalInputTokens: number;
|
|
162
|
+
/** Total output tokens */
|
|
163
|
+
totalOutputTokens: number;
|
|
164
|
+
/** Total cache read tokens */
|
|
165
|
+
totalCacheReadTokens: number;
|
|
166
|
+
/** Total cache write tokens */
|
|
167
|
+
totalCacheWriteTokens: number;
|
|
168
|
+
/** Total cost */
|
|
169
|
+
totalCost: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Behavior time-series point (daily bucket, per responding model).
|
|
174
|
+
*/
|
|
175
|
+
export interface BehaviorTimeSeriesPoint {
|
|
176
|
+
/** Bucket timestamp (start of day) */
|
|
177
|
+
timestamp: number;
|
|
178
|
+
/** Responding model ("unknown" if user msg never got a reply) */
|
|
179
|
+
model: string;
|
|
180
|
+
/** Responding provider */
|
|
181
|
+
provider: string;
|
|
182
|
+
/** Number of user messages in bucket */
|
|
183
|
+
messages: number;
|
|
184
|
+
/** Total yelling sentences in bucket */
|
|
185
|
+
yelling: number;
|
|
186
|
+
/** Total profanity hits in bucket */
|
|
187
|
+
profanity: number;
|
|
188
|
+
/** Total anguish signal in bucket */
|
|
189
|
+
anguish: number;
|
|
190
|
+
/** Total corrective-negation hits in bucket */
|
|
191
|
+
negation: number;
|
|
192
|
+
/** Total user-repeating-themselves hits in bucket */
|
|
193
|
+
repetition: number;
|
|
194
|
+
/** Total second-person blame hits in bucket */
|
|
195
|
+
blame: number;
|
|
196
|
+
/** Total characters in bucket */
|
|
197
|
+
chars: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface BehaviorOverallStats {
|
|
201
|
+
totalMessages: number;
|
|
202
|
+
totalYelling: number;
|
|
203
|
+
totalProfanity: number;
|
|
204
|
+
totalAnguish: number;
|
|
205
|
+
totalNegation: number;
|
|
206
|
+
totalRepetition: number;
|
|
207
|
+
totalBlame: number;
|
|
208
|
+
totalChars: number;
|
|
209
|
+
firstTimestamp: number;
|
|
210
|
+
lastTimestamp: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Per-model behavioral aggregate over the active range.
|
|
215
|
+
*/
|
|
216
|
+
export interface BehaviorModelStats {
|
|
217
|
+
model: string;
|
|
218
|
+
provider: string;
|
|
219
|
+
totalMessages: number;
|
|
220
|
+
totalYelling: number;
|
|
221
|
+
totalProfanity: number;
|
|
222
|
+
totalAnguish: number;
|
|
223
|
+
totalNegation: number;
|
|
224
|
+
totalRepetition: number;
|
|
225
|
+
totalBlame: number;
|
|
226
|
+
totalChars: number;
|
|
227
|
+
lastTimestamp: number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface BehaviorDashboardStats {
|
|
231
|
+
overall: BehaviorOverallStats;
|
|
232
|
+
byModel: BehaviorModelStats[];
|
|
233
|
+
behaviorSeries: BehaviorTimeSeriesPoint[];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Token savings from a single source type. */
|
|
237
|
+
export interface GainSourceTotals {
|
|
238
|
+
savedTokens: number;
|
|
239
|
+
savedBytes: number;
|
|
240
|
+
hits: number;
|
|
241
|
+
/** originalBytes - savedBytes, when original is known */
|
|
242
|
+
outputBytes: number;
|
|
243
|
+
/** Total original bytes before compression, when known */
|
|
244
|
+
originalBytes: number;
|
|
245
|
+
/** savedBytes / originalBytes when both are known, else null */
|
|
246
|
+
reductionPercent: number | null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Per-source breakdown. */
|
|
250
|
+
export type GainSource = "snapcompact";
|
|
251
|
+
|
|
252
|
+
/** Time-series point for gain (daily bucket). */
|
|
253
|
+
export interface GainTimeSeriesPoint {
|
|
254
|
+
date: string;
|
|
255
|
+
snapcompact: number;
|
|
256
|
+
total: number;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Complete gain dashboard payload. */
|
|
260
|
+
export interface GainDashboardStats {
|
|
261
|
+
/** Aggregate across all sources for the active range. */
|
|
262
|
+
overall: GainSourceTotals;
|
|
263
|
+
/** Per-source breakdown. */
|
|
264
|
+
bySource: Record<GainSource, GainSourceTotals>;
|
|
265
|
+
/** Daily time series. */
|
|
266
|
+
timeSeries: GainTimeSeriesPoint[];
|
|
267
|
+
/** Active project filter (cwd prefix), or null for all projects. */
|
|
268
|
+
project: string | null;
|
|
269
|
+
/** All distinct projects seen in the data, for the selector. */
|
|
270
|
+
projects: string[];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Aggregated usage for a single tool over the active range.
|
|
275
|
+
*
|
|
276
|
+
* Token/cost fields are the *real* provider usage of the assistant turns that
|
|
277
|
+
* invoked the tool, split evenly across that turn's tool calls so the numbers
|
|
278
|
+
* stay additive (a turn with 3 calls contributes a third of its usage to each
|
|
279
|
+
* tool). Payload fields (`argsChars`/`resultChars`) are raw character counts
|
|
280
|
+
* of the serialized arguments and the text fed back into context — a size
|
|
281
|
+
* proxy, not provider-counted tokens.
|
|
282
|
+
*/
|
|
283
|
+
export interface ToolUsageStats {
|
|
284
|
+
/** Tool name as recorded on the tool call. */
|
|
285
|
+
tool: string;
|
|
286
|
+
/** Number of tool calls. */
|
|
287
|
+
calls: number;
|
|
288
|
+
/** Calls whose result came back with `isError`. */
|
|
289
|
+
errors: number;
|
|
290
|
+
/** Serialized tool-call argument characters. */
|
|
291
|
+
argsChars: number;
|
|
292
|
+
/** Text characters of tool results fed back into context. */
|
|
293
|
+
resultChars: number;
|
|
294
|
+
/** Total provider tokens of invoking turns, attributed per call share. */
|
|
295
|
+
totalTokensShare: number;
|
|
296
|
+
/** Output tokens of invoking turns, attributed per call share. */
|
|
297
|
+
outputTokensShare: number;
|
|
298
|
+
/** Cost (USD) of invoking turns, attributed per call share. */
|
|
299
|
+
costShare: number;
|
|
300
|
+
/** Unix ms of the most recent call in range. */
|
|
301
|
+
lastUsed: number;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Per-(tool, model) breakdown with the same attribution as {@link ToolUsageStats}. */
|
|
305
|
+
export interface ToolModelStats extends ToolUsageStats {
|
|
306
|
+
model: string;
|
|
307
|
+
provider: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Tool-call time-series point (one bucket per tool). */
|
|
311
|
+
export interface ToolTimeSeriesPoint {
|
|
312
|
+
timestamp: number;
|
|
313
|
+
tool: string;
|
|
314
|
+
calls: number;
|
|
315
|
+
errors: number;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Complete tools dashboard payload. */
|
|
319
|
+
export interface ToolDashboardStats {
|
|
320
|
+
byTool: ToolUsageStats[];
|
|
321
|
+
byToolModel: ToolModelStats[];
|
|
322
|
+
series: ToolTimeSeriesPoint[];
|
|
323
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateless parse worker for `syncAllSessions`. The main thread owns the
|
|
3
|
+
* SQLite handle; workers receive `{ sessionFile, fromOffset }`, run
|
|
4
|
+
* `parseSessionFile` (which is pure I/O + CPU, no DB), and post the
|
|
5
|
+
* structured-clone-safe result back. One in-flight request per worker so
|
|
6
|
+
* the main thread can fan jobs out 1:1 with the pool size.
|
|
7
|
+
*
|
|
8
|
+
* A `{ kind: "ping" }` request is also accepted and replies with
|
|
9
|
+
* `{ ok: true, kind: "pong" }` — used by `smokeTestSyncWorker` to prove the
|
|
10
|
+
* worker actually spawns and runs in compiled binaries (regression coverage
|
|
11
|
+
* for issue #1011 / PR #1027, where the worker silently failed to load).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type ParseSessionResult, parseSessionFile } from "./parser";
|
|
15
|
+
|
|
16
|
+
export type SyncWorkerRequest = { kind?: "parse"; sessionFile: string; fromOffset: number } | { kind: "ping" };
|
|
17
|
+
|
|
18
|
+
export type SyncWorkerResponse =
|
|
19
|
+
| { ok: true; kind?: "parse"; result: ParseSessionResult }
|
|
20
|
+
| { ok: true; kind: "pong" }
|
|
21
|
+
| { ok: false; error: string };
|
|
22
|
+
|
|
23
|
+
declare const self: Worker & {
|
|
24
|
+
onmessage: ((event: MessageEvent<SyncWorkerRequest>) => void) | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
self.onmessage = async event => {
|
|
28
|
+
const request = event.data;
|
|
29
|
+
try {
|
|
30
|
+
if (request.kind === "ping") {
|
|
31
|
+
self.postMessage({ ok: true, kind: "pong" } satisfies SyncWorkerResponse);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const result = await parseSessionFile(request.sessionFile, request.fromOffset);
|
|
35
|
+
self.postMessage({ ok: true, result } satisfies SyncWorkerResponse);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
const error = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
38
|
+
self.postMessage({ ok: false, error } satisfies SyncWorkerResponse);
|
|
39
|
+
}
|
|
40
|
+
};
|