@gajae-code/stats 0.1.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.
Files changed (67) hide show
  1. package/README.md +82 -0
  2. package/build.ts +84 -0
  3. package/dist/client/index.css +1 -0
  4. package/dist/client/index.html +13 -0
  5. package/dist/client/index.js +257 -0
  6. package/dist/client/styles.css +1159 -0
  7. package/dist/types/aggregator.d.ts +65 -0
  8. package/dist/types/client/App.d.ts +1 -0
  9. package/dist/types/client/api.d.ts +10 -0
  10. package/dist/types/client/components/BehaviorChart.d.ts +6 -0
  11. package/dist/types/client/components/BehaviorModelsTable.d.ts +7 -0
  12. package/dist/types/client/components/BehaviorSummary.d.ts +7 -0
  13. package/dist/types/client/components/ChartsContainer.d.ts +7 -0
  14. package/dist/types/client/components/CostChart.d.ts +6 -0
  15. package/dist/types/client/components/CostSummary.d.ts +6 -0
  16. package/dist/types/client/components/Header.d.ts +12 -0
  17. package/dist/types/client/components/ModelsTable.d.ts +8 -0
  18. package/dist/types/client/components/RequestDetail.d.ts +6 -0
  19. package/dist/types/client/components/RequestList.d.ts +8 -0
  20. package/dist/types/client/components/StatsGrid.d.ts +6 -0
  21. package/dist/types/client/components/chart-shared.d.ts +187 -0
  22. package/dist/types/client/components/models-table-shared.d.ts +195 -0
  23. package/dist/types/client/components/range-meta.d.ts +21 -0
  24. package/dist/types/client/index.d.ts +1 -0
  25. package/dist/types/client/types.d.ts +62 -0
  26. package/dist/types/client/useSystemTheme.d.ts +2 -0
  27. package/dist/types/db.d.ts +93 -0
  28. package/dist/types/index.d.ts +5 -0
  29. package/dist/types/parser.d.ts +40 -0
  30. package/dist/types/server.d.ts +7 -0
  31. package/dist/types/shared-types.d.ts +192 -0
  32. package/dist/types/sync-worker.d.ts +31 -0
  33. package/dist/types/types.d.ts +120 -0
  34. package/dist/types/user-metrics.d.ts +72 -0
  35. package/package.json +91 -0
  36. package/src/aggregator.ts +454 -0
  37. package/src/client/App.tsx +221 -0
  38. package/src/client/api.ts +65 -0
  39. package/src/client/components/BehaviorChart.tsx +189 -0
  40. package/src/client/components/BehaviorModelsTable.tsx +342 -0
  41. package/src/client/components/BehaviorSummary.tsx +95 -0
  42. package/src/client/components/ChartsContainer.tsx +221 -0
  43. package/src/client/components/CostChart.tsx +171 -0
  44. package/src/client/components/CostSummary.tsx +53 -0
  45. package/src/client/components/Header.tsx +72 -0
  46. package/src/client/components/ModelsTable.tsx +265 -0
  47. package/src/client/components/RequestDetail.tsx +172 -0
  48. package/src/client/components/RequestList.tsx +73 -0
  49. package/src/client/components/StatsGrid.tsx +135 -0
  50. package/src/client/components/chart-shared.tsx +320 -0
  51. package/src/client/components/models-table-shared.tsx +275 -0
  52. package/src/client/components/range-meta.ts +72 -0
  53. package/src/client/css.d.ts +1 -0
  54. package/src/client/index.tsx +6 -0
  55. package/src/client/styles.css +306 -0
  56. package/src/client/types.ts +78 -0
  57. package/src/client/useSystemTheme.ts +31 -0
  58. package/src/db.ts +1100 -0
  59. package/src/embedded-client.generated.txt +7 -0
  60. package/src/index.ts +182 -0
  61. package/src/parser.ts +334 -0
  62. package/src/server.ts +325 -0
  63. package/src/shared-types.ts +204 -0
  64. package/src/sync-worker.ts +40 -0
  65. package/src/types.ts +125 -0
  66. package/src/user-metrics.ts +686 -0
  67. package/tailwind.config.js +40 -0
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Embedded stats dashboard bundle for compiled binaries.
3
+ *
4
+ * This file is generated by `bun --cwd=packages/stats scripts/generate-client-bundle.ts --generate` during
5
+ * binary builds. The checked-in value is intentionally empty.
6
+ */
7
+ export const EMBEDDED_CLIENT_ARCHIVE_TAR_GZ_BASE64 = "";
package/src/index.ts ADDED
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { parseArgs } from "node:util";
4
+ import { formatDuration, formatNumber, formatPercent } from "@gajae-code/utils";
5
+ import { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
6
+ import { closeDb } from "./db";
7
+ import { startServer } from "./server";
8
+
9
+ export {
10
+ getDashboardStats,
11
+ getTotalMessageCount,
12
+ type SyncOptions,
13
+ type SyncProgress,
14
+ smokeTestSyncWorker,
15
+ syncAllSessions,
16
+ } from "./aggregator";
17
+ export { closeDb } from "./db";
18
+ export { startServer } from "./server";
19
+ export type {
20
+ AggregatedStats,
21
+ DashboardStats,
22
+ FolderStats,
23
+ MessageStats,
24
+ ModelPerformancePoint,
25
+ ModelStats,
26
+ ModelTimeSeriesPoint,
27
+ TimeSeriesPoint,
28
+ } from "./types";
29
+
30
+ /**
31
+ * Format cost in dollars.
32
+ */
33
+ function formatCost(n: number): string {
34
+ if (n < 0.01) return `$${n.toFixed(4)}`;
35
+ if (n < 1) return `$${n.toFixed(3)}`;
36
+ return `$${n.toFixed(2)}`;
37
+ }
38
+
39
+ function normalizePremiumRequests(n: number): number {
40
+ return Math.round((n + Number.EPSILON) * 100) / 100;
41
+ }
42
+
43
+ /**
44
+ * Print stats summary to console.
45
+ */
46
+ async function printStats(): Promise<void> {
47
+ const stats = await getDashboardStats();
48
+ const { overall, byModel, byFolder } = stats;
49
+
50
+ console.log("\n=== AI Usage Statistics ===\n");
51
+
52
+ console.log("Overall:");
53
+ console.log(` Requests: ${formatNumber(overall.totalRequests)} (${formatNumber(overall.failedRequests)} errors)`);
54
+ console.log(` Error Rate: ${formatPercent(overall.errorRate)}`);
55
+ console.log(` Total Tokens: ${formatNumber(overall.totalInputTokens + overall.totalOutputTokens)}`);
56
+ console.log(` Input Tokens: ${formatNumber(overall.totalInputTokens)}`);
57
+ console.log(` Output Tokens: ${formatNumber(overall.totalOutputTokens)}`);
58
+ console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`);
59
+ console.log(` Total Cost: ${formatCost(overall.totalCost)}`);
60
+ console.log(` Premium Requests: ${formatNumber(normalizePremiumRequests(overall.totalPremiumRequests ?? 0))}`);
61
+ console.log(` Avg Duration: ${overall.avgDuration !== null ? formatDuration(overall.avgDuration) : "-"}`);
62
+ console.log(` Avg TTFT: ${overall.avgTtft !== null ? formatDuration(overall.avgTtft) : "-"}`);
63
+ if (overall.avgTokensPerSecond !== null) {
64
+ console.log(` Avg Tokens/s: ${overall.avgTokensPerSecond.toFixed(1)}`);
65
+ }
66
+
67
+ if (byModel.length > 0) {
68
+ console.log("\nBy Model:");
69
+ for (const m of byModel.slice(0, 10)) {
70
+ console.log(
71
+ ` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost)}, ${formatPercent(m.cacheRate)} cache`,
72
+ );
73
+ }
74
+ }
75
+
76
+ if (byFolder.length > 0) {
77
+ console.log("\nBy Folder:");
78
+ for (const f of byFolder.slice(0, 10)) {
79
+ console.log(` ${f.folder}: ${formatNumber(f.totalRequests)} reqs, ${formatCost(f.totalCost)}`);
80
+ }
81
+ }
82
+
83
+ console.log("");
84
+ }
85
+
86
+ /**
87
+ * Main CLI entry point.
88
+ */
89
+ async function main(): Promise<void> {
90
+ const { values } = parseArgs({
91
+ options: {
92
+ port: { type: "string", short: "p", default: "3847" },
93
+ json: { type: "boolean", short: "j", default: false },
94
+ sync: { type: "boolean", short: "s", default: false },
95
+ help: { type: "boolean", short: "h", default: false },
96
+ },
97
+ allowPositionals: true,
98
+ });
99
+
100
+ if (values.help) {
101
+ console.log(`
102
+ gjc-stats - AI Usage Statistics Dashboard
103
+
104
+ Usage:
105
+ gjc-stats [options]
106
+
107
+ Options:
108
+ -p, --port <port> Port for the dashboard server (default: 3847)
109
+ -j, --json Output stats as JSON and exit
110
+ -s, --sync Sync session files and show summary
111
+ -h, --help Show this help message
112
+
113
+ Examples:
114
+ gjc-stats # Start dashboard server
115
+ gjc-stats --json # Print stats as JSON
116
+ gjc-stats --port 8080 # Start on custom port
117
+ gjc-stats --sync # Sync and show summary
118
+ `);
119
+ return;
120
+ }
121
+
122
+ try {
123
+ // Sync first
124
+ const tty = process.stderr.isTTY === true;
125
+ process.stderr.write("Syncing session files...\n");
126
+ let lastWidth = 0;
127
+ let lastRender = 0;
128
+ const { processed, files } = await syncAllSessions({
129
+ onProgress: event => {
130
+ if (!tty) return;
131
+ const now = Date.now();
132
+ if (event.current < event.total && now - lastRender < 33) return;
133
+ lastRender = now;
134
+ const marker = "/sessions/";
135
+ const idx = event.sessionFile.indexOf(marker);
136
+ const short = idx >= 0 ? event.sessionFile.slice(idx + marker.length) : event.sessionFile;
137
+ const pct = ((event.current / event.total) * 100).toFixed(0).padStart(3, " ");
138
+ const line = `[${event.current}/${event.total}] ${pct}% ${short}`;
139
+ const columns = process.stderr.columns ?? 120;
140
+ const clipped = line.length > columns - 1 ? `${line.slice(0, columns - 2)}\u2026` : line;
141
+ process.stderr.write(`\r${clipped.padEnd(lastWidth)}`);
142
+ lastWidth = clipped.length;
143
+ },
144
+ });
145
+ if (tty && lastWidth > 0) process.stderr.write(`\r${" ".repeat(lastWidth)}\r`);
146
+ const total = await getTotalMessageCount();
147
+ console.log(`Synced ${processed} new entries from ${files} files (${total} total)\n`);
148
+
149
+ if (values.json) {
150
+ const stats = await getDashboardStats();
151
+ console.log(JSON.stringify(stats, null, 2));
152
+ return;
153
+ }
154
+
155
+ if (values.sync) {
156
+ await printStats();
157
+ return;
158
+ }
159
+
160
+ // Start server
161
+ const port = parseInt(values.port || "3847", 10);
162
+ const { port: actualPort } = await startServer(port);
163
+ console.log(`Dashboard available at: http://localhost:${actualPort}`);
164
+ console.log("Press Ctrl+C to stop\n");
165
+
166
+ // Keep process running
167
+ process.on("SIGINT", () => {
168
+ console.log("\nShutting down...");
169
+ closeDb();
170
+ process.exit(0);
171
+ });
172
+ } catch (error) {
173
+ console.error("Error:", error);
174
+ closeDb();
175
+ process.exit(1);
176
+ }
177
+ }
178
+
179
+ // Run if executed directly
180
+ if (import.meta.main) {
181
+ main();
182
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,334 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { type AssistantMessage, getPriorityPremiumRequests, type ServiceTier } from "@gajae-code/ai";
4
+ import { getSessionsDir, isEnoent } from "@gajae-code/utils";
5
+ import type {
6
+ MessageStats,
7
+ SessionEntry,
8
+ SessionMessageEntry,
9
+ SessionServiceTierChangeEntry,
10
+ UserMessageLink,
11
+ UserMessageStats,
12
+ } from "./types";
13
+ import { computeUserMessageMetrics } from "./user-metrics";
14
+
15
+ /**
16
+ * Extract folder name from session filename.
17
+ * Session files are named like: --work--pi--/timestamp_uuid.jsonl
18
+ * The folder part uses -- as path separator.
19
+ */
20
+ function extractFolderFromPath(sessionPath: string): string {
21
+ const sessionsDir = getSessionsDir();
22
+ const rel = path.relative(sessionsDir, sessionPath);
23
+ const projectDir = rel.split(path.sep)[0];
24
+ // Convert --work--pi-- to /work/pi
25
+ return projectDir.replace(/^--/, "/").replace(/--/g, "/");
26
+ }
27
+
28
+ /**
29
+ * Check if an entry is an assistant message.
30
+ */
31
+ function isAssistantMessage(entry: SessionEntry): entry is SessionMessageEntry {
32
+ if (entry.type !== "message") return false;
33
+ const msgEntry = entry as SessionMessageEntry;
34
+ // Legacy sessions (pre-id tracking) recorded message entries without an `id`.
35
+ // They're not linkable and would violate the messages.entry_id NOT NULL
36
+ // constraint, so skip them at the parser boundary.
37
+ if (typeof msgEntry.id !== "string" || msgEntry.id.length === 0) return false;
38
+ return msgEntry.message?.role === "assistant";
39
+ }
40
+
41
+ /**
42
+ * Check if an entry is a user message (non-toolResult).
43
+ */
44
+ function isUserMessage(entry: SessionEntry): entry is SessionMessageEntry {
45
+ if (entry.type !== "message") return false;
46
+ const msgEntry = entry as SessionMessageEntry;
47
+ if (typeof msgEntry.id !== "string" || msgEntry.id.length === 0) return false;
48
+ return msgEntry.message?.role === "user";
49
+ }
50
+
51
+ /**
52
+ * Check if an entry is a service-tier change.
53
+ */
54
+ function isServiceTierChange(entry: SessionEntry): entry is SessionServiceTierChangeEntry {
55
+ return entry.type === "service_tier_change";
56
+ }
57
+
58
+ /**
59
+ * Extract plain text from a user message content payload.
60
+ */
61
+ function extractUserText(content: unknown): string {
62
+ if (typeof content === "string") return content;
63
+ if (!Array.isArray(content)) return "";
64
+ const parts: string[] = [];
65
+ for (const block of content) {
66
+ if (block && typeof block === "object" && (block as { type?: unknown }).type === "text") {
67
+ const text = (block as { text?: unknown }).text;
68
+ if (typeof text === "string") parts.push(text);
69
+ }
70
+ }
71
+ return parts.join("");
72
+ }
73
+
74
+ /**
75
+ * Build user-message stats from an entry. Returns null for empty/synthetic content.
76
+ */
77
+ function extractUserStats(sessionFile: string, folder: string, entry: SessionMessageEntry): UserMessageStats | null {
78
+ const msg = entry.message as { role: "user"; content?: unknown; synthetic?: boolean };
79
+ if (msg.role !== "user" || msg.synthetic) return null;
80
+ const text = extractUserText(msg.content);
81
+ if (!text.trim()) return null;
82
+ const metrics = computeUserMessageMetrics(text);
83
+ const ts = Date.parse(entry.timestamp);
84
+ return {
85
+ sessionFile,
86
+ entryId: entry.id,
87
+ folder,
88
+ timestamp: Number.isFinite(ts) ? ts : 0,
89
+ model: null,
90
+ provider: null,
91
+ chars: metrics.chars,
92
+ words: metrics.words,
93
+ yelling: metrics.yelling,
94
+ profanity: metrics.profanity,
95
+ anguish: metrics.anguish,
96
+ negation: metrics.negation,
97
+ repetition: metrics.repetition,
98
+ blame: metrics.blame,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Extract stats from an assistant message entry.
104
+ */
105
+ function extractStats(
106
+ sessionFile: string,
107
+ folder: string,
108
+ entry: SessionMessageEntry,
109
+ currentServiceTier: ServiceTier | undefined,
110
+ ): MessageStats | null {
111
+ const msg = entry.message as AssistantMessage;
112
+ if (!msg || msg.role !== "assistant") return null;
113
+
114
+ // Backfill: when the session recorded `priority` as the active service tier
115
+ // at this point but the AI usage payload was captured before priority
116
+ // requests were folded into `premiumRequests`, derive the count here so the
117
+ // "Premium Reqs" stat aggregates priority traffic on re-sync. Trust any
118
+ // non-zero value already in `usage.premiumRequests` (Copilot multipliers or
119
+ // the new AI code path) and only synthesise when the field is missing/zero.
120
+ const recorded = msg.usage.premiumRequests ?? 0;
121
+ const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(currentServiceTier, msg.provider);
122
+ const usage = derived === recorded ? msg.usage : { ...msg.usage, premiumRequests: derived };
123
+
124
+ return {
125
+ sessionFile,
126
+ entryId: entry.id,
127
+ folder,
128
+ model: msg.model,
129
+ provider: msg.provider,
130
+ api: msg.api,
131
+ timestamp: msg.timestamp,
132
+ duration: msg.duration ?? null,
133
+ ttft: msg.ttft ?? null,
134
+ stopReason: msg.stopReason,
135
+ errorMessage: msg.errorMessage ?? null,
136
+ usage,
137
+ };
138
+ }
139
+
140
+ const LF = 0x0a;
141
+
142
+ function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[]; read: number } {
143
+ const entries: SessionEntry[] = [];
144
+ let cursor = 0;
145
+
146
+ while (cursor < bytes.length) {
147
+ const { values, error, read, done } = Bun.JSONL.parseChunk(bytes, cursor, bytes.length);
148
+ if (values.length > 0) {
149
+ entries.push(...(values as SessionEntry[]));
150
+ }
151
+
152
+ if (error) {
153
+ const nextNewline = bytes.indexOf(LF, Math.max(read, cursor));
154
+ if (nextNewline === -1) break;
155
+ cursor = nextNewline + 1;
156
+ continue;
157
+ }
158
+
159
+ if (read <= cursor) break;
160
+ cursor = read;
161
+ if (done) break;
162
+ }
163
+
164
+ return { entries, read: cursor };
165
+ }
166
+
167
+ function scanLastServiceTier(bytes: Uint8Array): ServiceTier | undefined {
168
+ let cursor = 0;
169
+ let currentServiceTier: ServiceTier | undefined;
170
+
171
+ while (cursor < bytes.length) {
172
+ const { values, error, read, done } = Bun.JSONL.parseChunk(bytes, cursor, bytes.length);
173
+ for (const value of values as SessionEntry[]) {
174
+ if (isServiceTierChange(value)) currentServiceTier = value.serviceTier ?? undefined;
175
+ }
176
+
177
+ if (error) {
178
+ const nextNewline = bytes.indexOf(LF, Math.max(read, cursor));
179
+ if (nextNewline === -1) break;
180
+ cursor = nextNewline + 1;
181
+ continue;
182
+ }
183
+
184
+ if (read <= cursor) break;
185
+ cursor = read;
186
+ if (done) break;
187
+ }
188
+
189
+ return currentServiceTier;
190
+ }
191
+ /**
192
+ * Parse a session file and extract all assistant message stats.
193
+ * Uses incremental reading with offset tracking.
194
+ *
195
+ * Service-tier carry-over: `currentServiceTier` is a session-scoped piece of
196
+ * state derived from `service_tier_change` entries that affects whether
197
+ * subsequent OpenAI assistant replies count as premium requests. Incremental
198
+ * syncs that resume past the most-recent tier change would otherwise lose
199
+ * that state and silently record `premiumRequests = 0` for priority traffic
200
+ * (the coding-agent stopped folding the tier into `usage.premiumRequests`
201
+ * after 13f59162e — the parser is now the sole source of truth). When
202
+ * `fromOffset > 0` we therefore scan the bytes preceding `fromOffset`
203
+ * for the latest service-tier value before parsing the unprocessed tail.
204
+ * The scan only keeps the current tier and does not materialize prefix
205
+ * entries, preserving offset-based memory behavior for large sessions.
206
+ */
207
+ export interface ParseSessionResult {
208
+ stats: MessageStats[];
209
+ userStats: UserMessageStats[];
210
+ userLinks: UserMessageLink[];
211
+ newOffset: number;
212
+ }
213
+ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Promise<ParseSessionResult> {
214
+ let bytes: Uint8Array;
215
+ try {
216
+ bytes = await Bun.file(sessionPath).bytes();
217
+ } catch (err) {
218
+ if (isEnoent(err)) return { stats: [], userStats: [], userLinks: [], newOffset: fromOffset };
219
+ throw err;
220
+ }
221
+
222
+ const folder = extractFolderFromPath(sessionPath);
223
+ const stats: MessageStats[] = [];
224
+ const userStats: UserMessageStats[] = [];
225
+ const userLinks: UserMessageLink[] = [];
226
+ const userByEntryId = new Map<string, UserMessageStats>();
227
+ const start = Math.max(0, Math.min(fromOffset, bytes.length));
228
+ const unprocessed = bytes.subarray(start);
229
+ const { entries, read } = parseSessionEntriesLenient(unprocessed);
230
+ let currentServiceTier: ServiceTier | undefined;
231
+ if (start > 0) {
232
+ currentServiceTier = scanLastServiceTier(bytes.subarray(0, start));
233
+ }
234
+ for (const entry of entries) {
235
+ if (isServiceTierChange(entry)) {
236
+ currentServiceTier = entry.serviceTier ?? undefined;
237
+ continue;
238
+ }
239
+ if (isUserMessage(entry)) {
240
+ const userMsg = extractUserStats(sessionPath, folder, entry);
241
+ if (userMsg) {
242
+ userStats.push(userMsg);
243
+ userByEntryId.set(entry.id, userMsg);
244
+ }
245
+ continue;
246
+ }
247
+ if (isAssistantMessage(entry)) {
248
+ const msgStats = extractStats(sessionPath, folder, entry, currentServiceTier);
249
+ if (msgStats) stats.push(msgStats);
250
+ // Link assistant's responding model back to the user message it answered.
251
+ const parentId = (entry as SessionMessageEntry).parentId;
252
+ if (parentId) {
253
+ const msg = entry.message as AssistantMessage;
254
+ if (msg.model && msg.provider) {
255
+ // Emit unconditionally. The aggregator's UPDATE is guarded by
256
+ // `model IS NULL` so this is idempotent: a no-op for already
257
+ // linked rows, a fix-up for fresh inserts (which start NULL
258
+ // because the user row is recorded before its reply lands) and
259
+ // for cross-pass orphans whose parent was committed by an
260
+ // earlier incremental sync.
261
+ userLinks.push({
262
+ sessionFile: sessionPath,
263
+ entryId: parentId,
264
+ model: msg.model,
265
+ provider: msg.provider,
266
+ });
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ return { stats, userStats, userLinks, newOffset: start + read };
273
+ }
274
+
275
+ /**
276
+ * List all session directories (folders).
277
+ */
278
+ export async function listSessionFolders(): Promise<string[]> {
279
+ try {
280
+ const sessionsDir = getSessionsDir();
281
+ const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
282
+ return entries.filter(e => e.isDirectory()).map(e => path.join(sessionsDir, e.name));
283
+ } catch {
284
+ return [];
285
+ }
286
+ }
287
+
288
+ /**
289
+ * List all session files in a folder.
290
+ */
291
+ export async function listSessionFiles(folderPath: string): Promise<string[]> {
292
+ try {
293
+ const entries = await fs.readdir(folderPath, { recursive: true, withFileTypes: true });
294
+ return entries.filter(e => e.isFile() && e.name.endsWith(".jsonl")).map(e => path.join(e.parentPath, e.name));
295
+ } catch {
296
+ return [];
297
+ }
298
+ }
299
+
300
+ /**
301
+ * List all session files across all folders.
302
+ */
303
+ export async function listAllSessionFiles(): Promise<string[]> {
304
+ const folders = await listSessionFolders();
305
+ const allFiles: string[] = [];
306
+
307
+ for (const folder of folders) {
308
+ const files = await listSessionFiles(folder);
309
+ allFiles.push(...files);
310
+ }
311
+
312
+ return allFiles;
313
+ }
314
+
315
+ /**
316
+ * Find a specific entry in a session file.
317
+ */
318
+ export async function getSessionEntry(sessionPath: string, entryId: string): Promise<SessionEntry | null> {
319
+ let bytes: Uint8Array;
320
+ try {
321
+ bytes = await Bun.file(sessionPath).bytes();
322
+ } catch (err) {
323
+ if (isEnoent(err)) return null;
324
+ throw err;
325
+ }
326
+
327
+ const { entries } = parseSessionEntriesLenient(bytes);
328
+ for (const entry of entries) {
329
+ if ("id" in entry && entry.id === entryId) {
330
+ return entry;
331
+ }
332
+ }
333
+ return null;
334
+ }