@f5-sales-demo/xcsh-stats 19.51.2

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/src/index.ts ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { parseArgs } from "node:util";
4
+ import { formatDuration, formatNumber, formatPercent } from "@f5xc-salesdemos/pi-utils";
5
+ import { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
6
+ import { closeDb } from "./db";
7
+ import { startServer } from "./server";
8
+
9
+ export { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
10
+ export { closeDb } from "./db";
11
+ export { startServer } from "./server";
12
+ export type {
13
+ AggregatedStats,
14
+ DashboardStats,
15
+ FolderStats,
16
+ MessageStats,
17
+ ModelPerformancePoint,
18
+ ModelStats,
19
+ ModelTimeSeriesPoint,
20
+ TimeSeriesPoint,
21
+ } from "./types";
22
+
23
+ /**
24
+ * Format cost in dollars.
25
+ */
26
+ function formatCost(n: number): string {
27
+ if (n < 0.01) return `$${n.toFixed(4)}`;
28
+ if (n < 1) return `$${n.toFixed(3)}`;
29
+ return `$${n.toFixed(2)}`;
30
+ }
31
+
32
+ function normalizePremiumRequests(n: number): number {
33
+ return Math.round((n + Number.EPSILON) * 100) / 100;
34
+ }
35
+
36
+ /**
37
+ * Print stats summary to console.
38
+ */
39
+ async function printStats(): Promise<void> {
40
+ const stats = await getDashboardStats();
41
+ const { overall, byModel, byFolder } = stats;
42
+
43
+ console.log("\n=== AI Usage Statistics ===\n");
44
+
45
+ console.log("Overall:");
46
+ console.log(` Requests: ${formatNumber(overall.totalRequests)} (${formatNumber(overall.failedRequests)} errors)`);
47
+ console.log(` Error Rate: ${formatPercent(overall.errorRate)}`);
48
+ console.log(` Total Tokens: ${formatNumber(overall.totalInputTokens + overall.totalOutputTokens)}`);
49
+ console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`);
50
+ console.log(` Total Cost: ${formatCost(overall.totalCost)}`);
51
+ console.log(` Premium Requests: ${formatNumber(normalizePremiumRequests(overall.totalPremiumRequests ?? 0))}`);
52
+ console.log(` Avg Duration: ${overall.avgDuration !== null ? formatDuration(overall.avgDuration) : "-"}`);
53
+ console.log(` Avg TTFT: ${overall.avgTtft !== null ? formatDuration(overall.avgTtft) : "-"}`);
54
+ if (overall.avgTokensPerSecond !== null) {
55
+ console.log(` Avg Tokens/s: ${overall.avgTokensPerSecond.toFixed(1)}`);
56
+ }
57
+
58
+ if (byModel.length > 0) {
59
+ console.log("\nBy Model:");
60
+ for (const m of byModel.slice(0, 10)) {
61
+ console.log(
62
+ ` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost)}, ${formatPercent(m.cacheRate)} cache`,
63
+ );
64
+ }
65
+ }
66
+
67
+ if (byFolder.length > 0) {
68
+ console.log("\nBy Folder:");
69
+ for (const f of byFolder.slice(0, 10)) {
70
+ console.log(` ${f.folder}: ${formatNumber(f.totalRequests)} reqs, ${formatCost(f.totalCost)}`);
71
+ }
72
+ }
73
+
74
+ console.log("");
75
+ }
76
+
77
+ /**
78
+ * Main CLI entry point.
79
+ */
80
+ async function main(): Promise<void> {
81
+ const { values } = parseArgs({
82
+ options: {
83
+ port: { type: "string", short: "p", default: "3847" },
84
+ json: { type: "boolean", short: "j", default: false },
85
+ sync: { type: "boolean", short: "s", default: false },
86
+ help: { type: "boolean", short: "h", default: false },
87
+ },
88
+ allowPositionals: true,
89
+ });
90
+
91
+ if (values.help) {
92
+ console.log(`
93
+ xcsh-stats - AI Usage Statistics Dashboard
94
+
95
+ Usage:
96
+ xcsh-stats [options]
97
+
98
+ Options:
99
+ -p, --port <port> Port for the dashboard server (default: 3847)
100
+ -j, --json Output stats as JSON and exit
101
+ -s, --sync Sync session files and show summary
102
+ -h, --help Show this help message
103
+
104
+ Examples:
105
+ xcsh-stats # Start dashboard server
106
+ xcsh-stats --json # Print stats as JSON
107
+ xcsh-stats --port 8080 # Start on custom port
108
+ xcsh-stats --sync # Sync and show summary
109
+ `);
110
+ return;
111
+ }
112
+
113
+ try {
114
+ // Sync first
115
+ console.log("Syncing session files...");
116
+ const { processed, files } = await syncAllSessions();
117
+ const total = await getTotalMessageCount();
118
+ console.log(`Synced ${processed} new entries from ${files} files (${total} total)\n`);
119
+
120
+ if (values.json) {
121
+ const stats = await getDashboardStats();
122
+ console.log(JSON.stringify(stats, null, 2));
123
+ return;
124
+ }
125
+
126
+ if (values.sync) {
127
+ await printStats();
128
+ return;
129
+ }
130
+
131
+ // Start server
132
+ const port = parseInt(values.port || "3847", 10);
133
+ const { port: actualPort } = await startServer(port);
134
+ console.log(`Dashboard available at: http://localhost:${actualPort}`);
135
+ console.log("Press Ctrl+C to stop\n");
136
+
137
+ // Keep process running
138
+ process.on("SIGINT", () => {
139
+ console.log("\nShutting down...");
140
+ closeDb();
141
+ process.exit(0);
142
+ });
143
+ } catch (error) {
144
+ console.error("Error:", error);
145
+ closeDb();
146
+ process.exit(1);
147
+ }
148
+ }
149
+
150
+ // Run if executed directly
151
+ if (import.meta.main) {
152
+ main();
153
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,169 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import type { AssistantMessage } from "@f5xc-salesdemos/pi-ai";
4
+ import { getSessionsDir, isEnoent } from "@f5xc-salesdemos/pi-utils";
5
+ import type { MessageStats, SessionEntry, SessionMessageEntry } from "./types";
6
+
7
+ /**
8
+ * Extract folder name from session filename.
9
+ * Session files are named like: --work--pi--/timestamp_uuid.jsonl
10
+ * The folder part uses -- as path separator.
11
+ */
12
+ function extractFolderFromPath(sessionPath: string): string {
13
+ const sessionsDir = getSessionsDir();
14
+ const rel = path.relative(sessionsDir, sessionPath);
15
+ const projectDir = rel.split(path.sep)[0];
16
+ // Convert --work--pi-- to /work/pi
17
+ return projectDir.replace(/^--/, "/").replace(/--/g, "/");
18
+ }
19
+
20
+ /**
21
+ * Check if an entry is an assistant message.
22
+ */
23
+ function isAssistantMessage(entry: SessionEntry): entry is SessionMessageEntry {
24
+ if (entry.type !== "message") return false;
25
+ const msgEntry = entry as SessionMessageEntry;
26
+ return msgEntry.message?.role === "assistant";
27
+ }
28
+
29
+ /**
30
+ * Extract stats from an assistant message entry.
31
+ */
32
+ function extractStats(sessionFile: string, folder: string, entry: SessionMessageEntry): MessageStats | null {
33
+ const msg = entry.message as AssistantMessage;
34
+ if (msg?.role !== "assistant") return null;
35
+
36
+ return {
37
+ sessionFile,
38
+ entryId: entry.id,
39
+ folder,
40
+ model: msg.model,
41
+ provider: msg.provider,
42
+ api: msg.api,
43
+ timestamp: msg.timestamp,
44
+ duration: msg.duration ?? null,
45
+ ttft: msg.ttft ?? null,
46
+ stopReason: msg.stopReason,
47
+ errorMessage: msg.errorMessage ?? null,
48
+ usage: msg.usage,
49
+ };
50
+ }
51
+
52
+ const LF = 0x0a;
53
+
54
+ function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[]; read: number } {
55
+ const entries: SessionEntry[] = [];
56
+ let cursor = 0;
57
+
58
+ while (cursor < bytes.length) {
59
+ const { values, error, read, done } = Bun.JSONL.parseChunk(bytes, cursor, bytes.length);
60
+ if (values.length > 0) {
61
+ entries.push(...(values as SessionEntry[]));
62
+ }
63
+
64
+ if (error) {
65
+ const nextNewline = bytes.indexOf(LF, Math.max(read, cursor));
66
+ if (nextNewline === -1) break;
67
+ cursor = nextNewline + 1;
68
+ continue;
69
+ }
70
+
71
+ if (read <= cursor) break;
72
+ cursor = read;
73
+ if (done) break;
74
+ }
75
+
76
+ return { entries, read: cursor };
77
+ }
78
+
79
+ /**
80
+ * Parse a session file and extract all assistant message stats.
81
+ * Uses incremental reading with offset tracking.
82
+ */
83
+ export async function parseSessionFile(
84
+ sessionPath: string,
85
+ fromOffset = 0,
86
+ ): Promise<{ stats: MessageStats[]; newOffset: number }> {
87
+ let bytes: Uint8Array;
88
+ try {
89
+ bytes = await Bun.file(sessionPath).bytes();
90
+ } catch (err) {
91
+ if (isEnoent(err)) return { stats: [], newOffset: fromOffset };
92
+ throw err;
93
+ }
94
+
95
+ const folder = extractFolderFromPath(sessionPath);
96
+ const stats: MessageStats[] = [];
97
+ const start = Math.max(0, Math.min(fromOffset, bytes.length));
98
+ const unprocessed = bytes.subarray(start);
99
+ const { entries, read } = parseSessionEntriesLenient(unprocessed);
100
+ for (const entry of entries) {
101
+ if (isAssistantMessage(entry)) {
102
+ const msgStats = extractStats(sessionPath, folder, entry);
103
+ if (msgStats) stats.push(msgStats);
104
+ }
105
+ }
106
+
107
+ return { stats, newOffset: start + read };
108
+ }
109
+
110
+ /**
111
+ * List all session directories (folders).
112
+ */
113
+ export async function listSessionFolders(): Promise<string[]> {
114
+ try {
115
+ const sessionsDir = getSessionsDir();
116
+ const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
117
+ return entries.filter(e => e.isDirectory()).map(e => path.join(sessionsDir, e.name));
118
+ } catch {
119
+ return [];
120
+ }
121
+ }
122
+
123
+ /**
124
+ * List all session files in a folder.
125
+ */
126
+ export async function listSessionFiles(folderPath: string): Promise<string[]> {
127
+ try {
128
+ const entries = await fs.readdir(folderPath, { recursive: true, withFileTypes: true });
129
+ return entries.filter(e => e.isFile() && e.name.endsWith(".jsonl")).map(e => path.join(e.parentPath, e.name));
130
+ } catch {
131
+ return [];
132
+ }
133
+ }
134
+
135
+ /**
136
+ * List all session files across all folders.
137
+ */
138
+ export async function listAllSessionFiles(): Promise<string[]> {
139
+ const folders = await listSessionFolders();
140
+ const allFiles: string[] = [];
141
+
142
+ for (const folder of folders) {
143
+ const files = await listSessionFiles(folder);
144
+ allFiles.push(...files);
145
+ }
146
+
147
+ return allFiles;
148
+ }
149
+
150
+ /**
151
+ * Find a specific entry in a session file.
152
+ */
153
+ export async function getSessionEntry(sessionPath: string, entryId: string): Promise<SessionEntry | null> {
154
+ let bytes: Uint8Array;
155
+ try {
156
+ bytes = await Bun.file(sessionPath).bytes();
157
+ } catch (err) {
158
+ if (isEnoent(err)) return null;
159
+ throw err;
160
+ }
161
+
162
+ const { entries } = parseSessionEntriesLenient(bytes);
163
+ for (const entry of entries) {
164
+ if ("id" in entry && entry.id === entryId) {
165
+ return entry;
166
+ }
167
+ }
168
+ return null;
169
+ }
package/src/server.ts ADDED
@@ -0,0 +1,300 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { $ } from "bun";
5
+ import {
6
+ getDashboardStats,
7
+ getRecentErrors,
8
+ getRecentRequests,
9
+ getRequestDetails,
10
+ getTotalMessageCount,
11
+ syncAllSessions,
12
+ } from "./aggregator";
13
+ import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
14
+
15
+ const getEmbeddedClientArchive = (() => {
16
+ const txt = embeddedClientArchiveTxt.replaceAll(/[\s\r\n]/g, "").trim();
17
+ if (!txt) return null;
18
+ return () => Buffer.from(txt, "base64");
19
+ })();
20
+
21
+ const CLIENT_DIR = path.join(import.meta.dir, "client");
22
+ const STATIC_DIR = path.join(import.meta.dir, "..", "dist", "client");
23
+ const IS_BUN_COMPILED =
24
+ Bun.env.PI_COMPILED ||
25
+ import.meta.url.includes("$bunfs") ||
26
+ import.meta.url.includes("~BUN") ||
27
+ import.meta.url.includes("%7EBUN");
28
+
29
+ const COMPILED_CLIENT_DIR_ROOT = path.join(os.tmpdir(), "xcsh-stats-client");
30
+ let compiledClientDirPromise: Promise<string> | null = null;
31
+
32
+ function sanitizeArchivePath(archivePath: string): string | null {
33
+ const normalized = archivePath.replaceAll("\\", "/").replace(/^\.\//, "");
34
+ if (!normalized || normalized === ".") return null;
35
+ if (normalized.includes("..") || path.isAbsolute(normalized)) return null;
36
+ return normalized;
37
+ }
38
+
39
+ async function extractEmbeddedClientArchive(archiveBytes: Buffer, outputDir: string): Promise<void> {
40
+ const archive = new Bun.Archive(archiveBytes);
41
+ const files = await archive.files();
42
+ const extractRoot = path.resolve(outputDir);
43
+
44
+ for (const [archivePath, file] of files) {
45
+ const sanitizedPath = sanitizeArchivePath(archivePath);
46
+ if (!sanitizedPath) continue;
47
+ const destinationPath = path.resolve(extractRoot, sanitizedPath);
48
+ if (!destinationPath.startsWith(extractRoot + path.sep)) {
49
+ throw new Error(`Archive entry escapes extraction directory: ${archivePath}`);
50
+ }
51
+ await Bun.write(destinationPath, file);
52
+ }
53
+ }
54
+
55
+ async function getCompiledClientDir(): Promise<string> {
56
+ if (!IS_BUN_COMPILED) return STATIC_DIR;
57
+ if (compiledClientDirPromise) return compiledClientDirPromise;
58
+
59
+ const archiveBytes = getEmbeddedClientArchive?.();
60
+ if (!archiveBytes) {
61
+ throw new Error("Compiled stats client bundle missing. Rebuild binary with embedded stats assets.");
62
+ }
63
+
64
+ compiledClientDirPromise = (async () => {
65
+ const bundleHash = Bun.hash(archiveBytes).toString(16);
66
+ const outputDir = path.join(COMPILED_CLIENT_DIR_ROOT, bundleHash);
67
+ const markerPath = path.join(outputDir, "index.html");
68
+ try {
69
+ const marker = await fs.stat(markerPath);
70
+ if (marker.isFile()) return outputDir;
71
+ } catch {}
72
+
73
+ await fs.rm(outputDir, { recursive: true, force: true });
74
+ await fs.mkdir(outputDir, { recursive: true });
75
+ await extractEmbeddedClientArchive(archiveBytes, outputDir);
76
+ return outputDir;
77
+ })();
78
+
79
+ return compiledClientDirPromise;
80
+ }
81
+
82
+ async function getLatestMtime(dir: string): Promise<number> {
83
+ const entries = await fs.readdir(dir, { withFileTypes: true });
84
+
85
+ const promises = [];
86
+ for (const entry of entries) {
87
+ const fullPath = path.join(dir, entry.name);
88
+ if (entry.isDirectory()) {
89
+ promises.push(getLatestMtime(fullPath));
90
+ } else if (entry.isFile()) {
91
+ promises.push(fs.stat(fullPath).then(stats => stats.mtimeMs));
92
+ }
93
+ }
94
+
95
+ let latest = 0;
96
+ await Promise.allSettled(promises).then(results => {
97
+ for (const result of results) {
98
+ if (result.status === "fulfilled") {
99
+ latest = Math.max(latest, result.value);
100
+ }
101
+ }
102
+ });
103
+ return latest;
104
+ }
105
+
106
+ const ensureClientBuild = async () => {
107
+ if (IS_BUN_COMPILED) return;
108
+ const indexPath = path.join(STATIC_DIR, "index.html");
109
+ const cssPath = path.join(STATIC_DIR, "styles.css");
110
+ const clientSourceMtime = await getLatestMtime(CLIENT_DIR);
111
+ const tailwindConfigPath = path.join(import.meta.dir, "..", "tailwind.config.js");
112
+ let tailwindConfigMtime = 0;
113
+ try {
114
+ const tailwindConfigStats = await fs.stat(tailwindConfigPath);
115
+ tailwindConfigMtime = tailwindConfigStats.mtimeMs;
116
+ } catch {}
117
+ const sourceMtime = Math.max(clientSourceMtime, tailwindConfigMtime);
118
+ let shouldBuild = true;
119
+ try {
120
+ const [indexStats, cssStats] = await Promise.all([fs.stat(indexPath), fs.stat(cssPath)]);
121
+ if (
122
+ indexStats.isFile() &&
123
+ cssStats.isFile() &&
124
+ indexStats.mtimeMs >= sourceMtime &&
125
+ cssStats.mtimeMs >= sourceMtime
126
+ ) {
127
+ shouldBuild = false;
128
+ }
129
+ } catch {
130
+ shouldBuild = true;
131
+ }
132
+
133
+ if (!shouldBuild) return;
134
+
135
+ await fs.rm(STATIC_DIR, { recursive: true, force: true });
136
+
137
+ console.log("Building stats client...");
138
+ const packageRoot = path.join(import.meta.dir, "..");
139
+ const buildResult = await $`bun run build.ts`.cwd(packageRoot).quiet().nothrow();
140
+ if (buildResult.exitCode !== 0) {
141
+ const output = buildResult.text().trim();
142
+ const details = output ? `\n${output}` : "";
143
+ throw new Error(`Failed to build stats client (exit ${buildResult.exitCode})${details}`);
144
+ }
145
+
146
+ const indexHtml = `<!DOCTYPE html>
147
+ <html lang="en">
148
+ <head>
149
+ <meta charset="UTF-8">
150
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
151
+ <title>AI Usage Statistics</title>
152
+ <link rel="stylesheet" href="styles.css">
153
+ </head>
154
+ <body>
155
+ <div id="root"></div>
156
+ <script src="index.js" type="module"></script>
157
+ </body>
158
+ </html>`;
159
+
160
+ await Bun.write(path.join(STATIC_DIR, "index.html"), indexHtml);
161
+ };
162
+
163
+ /**
164
+ * Handle API requests.
165
+ */
166
+ async function handleApi(req: Request): Promise<Response> {
167
+ const url = new URL(req.url);
168
+ const path = url.pathname;
169
+
170
+ // Sync sessions before returning stats
171
+ await syncAllSessions();
172
+
173
+ if (path === "/api/stats") {
174
+ const stats = await getDashboardStats();
175
+ return Response.json(stats);
176
+ }
177
+
178
+ if (path === "/api/stats/recent") {
179
+ const limit = url.searchParams.get("limit");
180
+ const stats = await getRecentRequests(limit ? parseInt(limit, 10) : undefined);
181
+ return Response.json(stats);
182
+ }
183
+
184
+ if (path === "/api/stats/errors") {
185
+ const limit = url.searchParams.get("limit");
186
+ const stats = await getRecentErrors(limit ? parseInt(limit, 10) : undefined);
187
+ return Response.json(stats);
188
+ }
189
+
190
+ if (path === "/api/stats/models") {
191
+ const stats = await getDashboardStats();
192
+ return Response.json(stats.byModel);
193
+ }
194
+
195
+ if (path === "/api/stats/folders") {
196
+ const stats = await getDashboardStats();
197
+ return Response.json(stats.byFolder);
198
+ }
199
+
200
+ if (path === "/api/stats/timeseries") {
201
+ const stats = await getDashboardStats();
202
+ return Response.json(stats.timeSeries);
203
+ }
204
+
205
+ if (path.startsWith("/api/request/")) {
206
+ const id = path.split("/").pop();
207
+ if (!id) return new Response("Bad Request", { status: 400 });
208
+ const details = await getRequestDetails(parseInt(id, 10));
209
+ if (!details) return new Response("Not Found", { status: 404 });
210
+ return Response.json(details);
211
+ }
212
+
213
+ if (path === "/api/sync") {
214
+ const result = await syncAllSessions();
215
+ const count = await getTotalMessageCount();
216
+ return Response.json({ ...result, totalMessages: count });
217
+ }
218
+
219
+ return new Response("Not Found", { status: 404 });
220
+ }
221
+
222
+ /**
223
+ * Handle static file requests.
224
+ */
225
+ async function handleStatic(requestPath: string): Promise<Response> {
226
+ const staticDir = IS_BUN_COMPILED ? await getCompiledClientDir() : STATIC_DIR;
227
+ const filePath = requestPath === "/" ? "/index.html" : requestPath;
228
+ const fullPath = path.join(staticDir, filePath);
229
+
230
+ const file = Bun.file(fullPath);
231
+ if (await file.exists()) {
232
+ return new Response(file);
233
+ }
234
+
235
+ // SPA fallback
236
+ const index = Bun.file(path.join(staticDir, "index.html"));
237
+ if (await index.exists()) {
238
+ return new Response(index);
239
+ }
240
+
241
+ return new Response("Not Found", { status: 404 });
242
+ }
243
+
244
+ /**
245
+ * Start the HTTP server.
246
+ */
247
+ export async function startServer(port = 3847): Promise<{ port: number; stop: () => void }> {
248
+ await ensureClientBuild();
249
+
250
+ const server = Bun.serve({
251
+ port,
252
+ async fetch(req) {
253
+ const url = new URL(req.url);
254
+ const path = url.pathname;
255
+
256
+ // CORS headers for local development
257
+ const corsHeaders = {
258
+ "Access-Control-Allow-Origin": "*",
259
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
260
+ "Access-Control-Allow-Headers": "Content-Type",
261
+ };
262
+
263
+ if (req.method === "OPTIONS") {
264
+ return new Response(null, { headers: corsHeaders });
265
+ }
266
+
267
+ try {
268
+ let response: Response;
269
+
270
+ if (path.startsWith("/api/")) {
271
+ response = await handleApi(req);
272
+ } else {
273
+ response = await handleStatic(path);
274
+ }
275
+
276
+ // Add CORS headers to all responses
277
+ const headers = new Headers(response.headers);
278
+ for (const [key, value] of Object.entries(corsHeaders)) {
279
+ headers.set(key, value);
280
+ }
281
+
282
+ return new Response(response.body, {
283
+ status: response.status,
284
+ headers,
285
+ });
286
+ } catch (error) {
287
+ console.error("Server error:", error);
288
+ return Response.json(
289
+ { error: error instanceof Error ? error.message : "Unknown error" },
290
+ { status: 500, headers: corsHeaders },
291
+ );
292
+ }
293
+ },
294
+ });
295
+
296
+ return {
297
+ port: server.port ?? port,
298
+ stop: () => server.stop(),
299
+ };
300
+ }