@linxiraos/pi-stats 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +82 -0
  3. package/build.ts +95 -0
  4. package/dist/client/index.css +1 -0
  5. package/dist/client/index.html +24 -0
  6. package/dist/client/index.js +257 -0
  7. package/dist/client/styles.css +1695 -0
  8. package/dist/types/aggregator.d.ts +98 -0
  9. package/dist/types/client/App.d.ts +1 -0
  10. package/dist/types/client/api.d.ts +22 -0
  11. package/dist/types/client/app/AppLayout.d.ts +16 -0
  12. package/dist/types/client/app/NavRail.d.ts +7 -0
  13. package/dist/types/client/app/RangeControl.d.ts +7 -0
  14. package/dist/types/client/app/SyncButton.d.ts +14 -0
  15. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  16. package/dist/types/client/app/TopBar.d.ts +15 -0
  17. package/dist/types/client/app/routes.d.ts +12 -0
  18. package/dist/types/client/components/AgentTokenShare.d.ts +5 -0
  19. package/dist/types/client/components/chart-shared.d.ts +173 -0
  20. package/dist/types/client/components/models-table-shared.d.ts +175 -0
  21. package/dist/types/client/components/range-meta.d.ts +21 -0
  22. package/dist/types/client/data/charts.d.ts +1 -0
  23. package/dist/types/client/data/formatters.d.ts +8 -0
  24. package/dist/types/client/data/useHashRoute.d.ts +8 -0
  25. package/dist/types/client/data/useResource.d.ts +13 -0
  26. package/dist/types/client/data/view-models.d.ts +74 -0
  27. package/dist/types/client/index.d.ts +2 -0
  28. package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
  29. package/dist/types/client/routes/CostsRoute.d.ts +7 -0
  30. package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
  31. package/dist/types/client/routes/GainRoute.d.ts +7 -0
  32. package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
  33. package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
  34. package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
  35. package/dist/types/client/routes/ProvidersRoute.d.ts +7 -0
  36. package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
  37. package/dist/types/client/routes/ToolsRoute.d.ts +7 -0
  38. package/dist/types/client/routes/index.d.ts +10 -0
  39. package/dist/types/client/types.d.ts +63 -0
  40. package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
  41. package/dist/types/client/ui/DataTable.d.ts +17 -0
  42. package/dist/types/client/ui/EmptyState.d.ts +7 -0
  43. package/dist/types/client/ui/ErrorState.d.ts +6 -0
  44. package/dist/types/client/ui/JsonBlock.d.ts +7 -0
  45. package/dist/types/client/ui/MetricCluster.d.ts +5 -0
  46. package/dist/types/client/ui/Panel.d.ts +7 -0
  47. package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
  48. package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
  49. package/dist/types/client/ui/Skeleton.d.ts +8 -0
  50. package/dist/types/client/ui/StatusPill.d.ts +7 -0
  51. package/dist/types/client/ui/index.d.ts +11 -0
  52. package/dist/types/client/useSystemTheme.d.ts +11 -0
  53. package/dist/types/db.d.ts +159 -0
  54. package/dist/types/embedded-client.d.ts +18 -0
  55. package/dist/types/gain-aggregator.d.ts +26 -0
  56. package/dist/types/index.d.ts +7 -0
  57. package/dist/types/parser.d.ts +53 -0
  58. package/dist/types/port-conflict.d.ts +14 -0
  59. package/dist/types/server.d.ts +12 -0
  60. package/dist/types/shared-types.d.ts +403 -0
  61. package/dist/types/sync-worker.d.ts +31 -0
  62. package/dist/types/types.d.ts +164 -0
  63. package/dist/types/usage-windows.d.ts +45 -0
  64. package/dist/types/user-metrics.d.ts +81 -0
  65. package/package.json +94 -0
  66. package/src/aggregator.ts +615 -0
  67. package/src/client/App.tsx +112 -0
  68. package/src/client/api.ts +118 -0
  69. package/src/client/app/AppLayout.tsx +93 -0
  70. package/src/client/app/NavRail.tsx +44 -0
  71. package/src/client/app/RangeControl.tsx +39 -0
  72. package/src/client/app/SyncButton.tsx +75 -0
  73. package/src/client/app/ThemeToggle.tsx +37 -0
  74. package/src/client/app/TopBar.tsx +73 -0
  75. package/src/client/app/routes.ts +86 -0
  76. package/src/client/components/AgentTokenShare.tsx +68 -0
  77. package/src/client/components/chart-shared.tsx +257 -0
  78. package/src/client/components/models-table-shared.tsx +255 -0
  79. package/src/client/components/range-meta.ts +73 -0
  80. package/src/client/css.d.ts +1 -0
  81. package/src/client/data/charts.ts +14 -0
  82. package/src/client/data/formatters.ts +45 -0
  83. package/src/client/data/useHashRoute.ts +88 -0
  84. package/src/client/data/useResource.ts +154 -0
  85. package/src/client/data/view-models.ts +259 -0
  86. package/src/client/index.tsx +10 -0
  87. package/src/client/routes/BehaviorRoute.tsx +623 -0
  88. package/src/client/routes/CostsRoute.tsx +234 -0
  89. package/src/client/routes/ErrorsRoute.tsx +118 -0
  90. package/src/client/routes/GainRoute.tsx +226 -0
  91. package/src/client/routes/ModelsRoute.tsx +430 -0
  92. package/src/client/routes/OverviewRoute.tsx +342 -0
  93. package/src/client/routes/ProjectsRoute.tsx +163 -0
  94. package/src/client/routes/ProvidersRoute.tsx +492 -0
  95. package/src/client/routes/RequestsRoute.tsx +123 -0
  96. package/src/client/routes/ToolsRoute.tsx +463 -0
  97. package/src/client/routes/index.ts +10 -0
  98. package/src/client/styles.css +1384 -0
  99. package/src/client/types.ts +80 -0
  100. package/src/client/ui/AsyncBoundary.tsx +54 -0
  101. package/src/client/ui/DataTable.tsx +122 -0
  102. package/src/client/ui/EmptyState.tsx +16 -0
  103. package/src/client/ui/ErrorState.tsx +25 -0
  104. package/src/client/ui/JsonBlock.tsx +75 -0
  105. package/src/client/ui/MetricCluster.tsx +78 -0
  106. package/src/client/ui/Panel.tsx +24 -0
  107. package/src/client/ui/RequestDrawer.tsx +208 -0
  108. package/src/client/ui/SegmentedControl.tsx +36 -0
  109. package/src/client/ui/Skeleton.tsx +17 -0
  110. package/src/client/ui/StatusPill.tsx +15 -0
  111. package/src/client/ui/index.ts +11 -0
  112. package/src/client/useSystemTheme.ts +87 -0
  113. package/src/db.ts +1702 -0
  114. package/src/embedded-client.generated.txt +0 -0
  115. package/src/embedded-client.ts +26 -0
  116. package/src/gain-aggregator.ts +311 -0
  117. package/src/index.ts +194 -0
  118. package/src/parser.ts +499 -0
  119. package/src/port-conflict.ts +263 -0
  120. package/src/server.ts +396 -0
  121. package/src/shared-types.ts +432 -0
  122. package/src/sync-worker.ts +40 -0
  123. package/src/types.ts +171 -0
  124. package/src/usage-windows.ts +304 -0
  125. package/src/user-metrics.ts +695 -0
  126. package/tailwind.config.js +40 -0
package/src/parser.ts ADDED
@@ -0,0 +1,499 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import {
4
+ type AssistantMessage,
5
+ coerceServiceTierByFamily,
6
+ getPriorityPremiumRequests,
7
+ resolveModelServiceTier,
8
+ type ServiceTierByFamily,
9
+ type ToolCall,
10
+ type ToolResultMessage,
11
+ type Usage,
12
+ } from "@linxiraos/pi-ai";
13
+ import { getSessionsDir, isEnoent, readLines } from "@linxiraos/pi-utils";
14
+ import type {
15
+ AgentType,
16
+ MessageStats,
17
+ SessionEntry,
18
+ SessionMessageEntry,
19
+ SessionServiceTierChangeEntry,
20
+ ToolCallStats,
21
+ ToolResultLink,
22
+ UserMessageLink,
23
+ UserMessageStats,
24
+ } from "./types";
25
+ import { computeUserMessageMetrics } from "./user-metrics";
26
+
27
+ /** Basename of an advisor agent's transcript inside a session artifacts dir. */
28
+ const ADVISOR_TRANSCRIPT_BASENAME = "__advisor.jsonl";
29
+
30
+ /**
31
+ * Classify which agent produced a transcript from its path within the sessions
32
+ * directory. Layout: `<sessionsDir>/<project>/<file>.jsonl` is the `main`
33
+ * agent; subagent and advisor transcripts live nested one level deeper inside
34
+ * the session's artifacts dir (`<project>/<session>/<id>.jsonl`,
35
+ * `<project>/<session>/__advisor.jsonl`). Any advisor transcript
36
+ * (`__advisor.jsonl` or `__advisor.<slug>.jsonl`) — at any depth, including a
37
+ * subagent's own advisor — counts as `advisor`; every other nested transcript
38
+ * is a task `subagent`.
39
+ */
40
+ export function classifyAgentType(sessionPath: string): AgentType {
41
+ const base = path.basename(sessionPath);
42
+ if (base === ADVISOR_TRANSCRIPT_BASENAME || (base.startsWith("__advisor.") && base.endsWith(".jsonl"))) {
43
+ return "advisor";
44
+ }
45
+ const rel = path.relative(getSessionsDir(), sessionPath);
46
+ // `<project>/<file>.jsonl` -> 2 segments. Deeper nesting is a subagent.
47
+ return rel.split(path.sep).length <= 2 ? "main" : "subagent";
48
+ }
49
+
50
+ /**
51
+ * Extract folder name from session filename.
52
+ * Session files are named like: --work--pi--/timestamp_uuid.jsonl
53
+ * The folder part uses -- as path separator.
54
+ */
55
+ function extractFolderFromPath(sessionPath: string): string {
56
+ const sessionsDir = getSessionsDir();
57
+ const rel = path.relative(sessionsDir, sessionPath);
58
+ const projectDir = rel.split(path.sep)[0];
59
+ // Convert --work--pi-- to /work/pi
60
+ return projectDir.replace(/^--/, "/").replace(/--/g, "/");
61
+ }
62
+
63
+ /**
64
+ * Check if an entry is an assistant message.
65
+ */
66
+ function isAssistantMessage(entry: SessionEntry): entry is SessionMessageEntry {
67
+ if (entry.type !== "message") return false;
68
+ const msgEntry = entry as SessionMessageEntry;
69
+ // Legacy sessions (pre-id tracking) recorded message entries without an `id`.
70
+ // They're not linkable and would violate the messages.entry_id NOT NULL
71
+ // constraint, so skip them at the parser boundary.
72
+ if (typeof msgEntry.id !== "string" || msgEntry.id.length === 0) return false;
73
+ return msgEntry.message?.role === "assistant";
74
+ }
75
+
76
+ /**
77
+ * Check if an entry is a user message (non-toolResult).
78
+ */
79
+ function isUserMessage(entry: SessionEntry): entry is SessionMessageEntry {
80
+ if (entry.type !== "message") return false;
81
+ const msgEntry = entry as SessionMessageEntry;
82
+ if (typeof msgEntry.id !== "string" || msgEntry.id.length === 0) return false;
83
+ return msgEntry.message?.role === "user";
84
+ }
85
+
86
+ /**
87
+ * Check if an entry is a service-tier change.
88
+ */
89
+ function isServiceTierChange(entry: SessionEntry): entry is SessionServiceTierChangeEntry {
90
+ return entry.type === "service_tier_change";
91
+ }
92
+
93
+ /**
94
+ * Check if an entry is a tool-result message.
95
+ */
96
+ function isToolResultMessage(entry: SessionEntry): entry is SessionMessageEntry {
97
+ if (entry.type !== "message") return false;
98
+ return (entry as SessionMessageEntry).message?.role === "toolResult";
99
+ }
100
+
101
+ /**
102
+ * Extract plain text from a user message content payload.
103
+ */
104
+ function extractUserText(content: unknown): string {
105
+ if (typeof content === "string") return content;
106
+ if (!Array.isArray(content)) return "";
107
+ const parts: string[] = [];
108
+ for (const block of content) {
109
+ if (block && typeof block === "object" && (block as { type?: unknown }).type === "text") {
110
+ const text = (block as { text?: unknown }).text;
111
+ if (typeof text === "string") parts.push(text);
112
+ }
113
+ }
114
+ return parts.join("");
115
+ }
116
+
117
+ /**
118
+ * Build user-message stats from an entry. Returns null for empty/synthetic content.
119
+ */
120
+ function extractUserStats(sessionFile: string, folder: string, entry: SessionMessageEntry): UserMessageStats | null {
121
+ const msg = entry.message as { role: "user"; content?: unknown; synthetic?: boolean };
122
+ if (msg.role !== "user" || msg.synthetic) return null;
123
+ const text = extractUserText(msg.content);
124
+ if (!text.trim()) return null;
125
+ const metrics = computeUserMessageMetrics(text);
126
+ const ts = Date.parse(entry.timestamp);
127
+ return {
128
+ sessionFile,
129
+ entryId: entry.id,
130
+ folder,
131
+ timestamp: Number.isFinite(ts) ? ts : 0,
132
+ model: null,
133
+ provider: null,
134
+ chars: metrics.chars,
135
+ words: metrics.words,
136
+ yelling: metrics.yelling,
137
+ profanity: metrics.profanity,
138
+ anguish: metrics.anguish,
139
+ negation: metrics.negation,
140
+ repetition: metrics.repetition,
141
+ blame: metrics.blame,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Extract stats from an assistant message entry.
147
+ *
148
+ * Session JSONL on disk is not guaranteed to match the current
149
+ * `AssistantMessage` shape: crash-truncated turns, sessions written by older
150
+ * versions, and foreign producers all flow through this parser. Every field
151
+ * returned here feeds a NOT NULL column in stats.db, so malformed entries are
152
+ * coerced (missing `stopReason`, token counts, `timestamp`) or skipped
153
+ * (missing `model`/`provider`/`api`/`usage`) instead of crashing the whole
154
+ * sync with a constraint violation.
155
+ */
156
+ function extractStats(
157
+ sessionFile: string,
158
+ folder: string,
159
+ entry: SessionMessageEntry,
160
+ currentServiceTier: ServiceTierByFamily | undefined,
161
+ agentType: AgentType,
162
+ ): MessageStats | null {
163
+ const msg = entry.message as AssistantMessage;
164
+ if (msg?.role !== "assistant") return null;
165
+ if (typeof msg.model !== "string" || typeof msg.provider !== "string" || typeof msg.api !== "string") return null;
166
+ const rawUsage = msg.usage as Partial<Usage> | undefined;
167
+ if (!rawUsage || typeof rawUsage !== "object") return null;
168
+
169
+ // Backfill: when the session recorded `priority` as the active service tier
170
+ // at this point but the AI usage payload was captured before priority
171
+ // requests were folded into `premiumRequests`, derive the count here so the
172
+ // "Premium Reqs" stat aggregates priority traffic on re-sync. Trust any
173
+ // non-zero value already in `usage.premiumRequests` (Copilot multipliers or
174
+ // the new AI code path) and only synthesise when the field is missing/zero.
175
+ const recorded = rawUsage.premiumRequests ?? 0;
176
+ const model = { provider: msg.provider, api: msg.api, id: msg.model };
177
+ const tier = resolveModelServiceTier(currentServiceTier, model);
178
+ const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(tier, model);
179
+ const wellFormed =
180
+ typeof rawUsage.input === "number" &&
181
+ typeof rawUsage.output === "number" &&
182
+ typeof rawUsage.cacheRead === "number" &&
183
+ typeof rawUsage.cacheWrite === "number" &&
184
+ typeof rawUsage.totalTokens === "number";
185
+ const usage: Usage =
186
+ wellFormed && derived === recorded
187
+ ? (rawUsage as Usage)
188
+ : {
189
+ ...rawUsage,
190
+ input: rawUsage.input ?? 0,
191
+ output: rawUsage.output ?? 0,
192
+ cacheRead: rawUsage.cacheRead ?? 0,
193
+ cacheWrite: rawUsage.cacheWrite ?? 0,
194
+ totalTokens: rawUsage.totalTokens ?? 0,
195
+ cost: rawUsage.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
196
+ premiumRequests: derived,
197
+ };
198
+
199
+ return {
200
+ sessionFile,
201
+ entryId: entry.id,
202
+ folder,
203
+ model: msg.model,
204
+ provider: msg.provider,
205
+ api: msg.api,
206
+ timestamp: coerceEntryTimestamp(msg.timestamp, entry),
207
+ duration: msg.duration ?? null,
208
+ ttft: msg.ttft ?? null,
209
+ // A message persisted without a terminal stop reason never completed
210
+ // normally: classify by whether it carried an error.
211
+ stopReason: msg.stopReason ?? (msg.errorMessage ? "error" : "aborted"),
212
+ errorMessage: msg.errorMessage ?? null,
213
+ usage,
214
+ agentType,
215
+ };
216
+ }
217
+
218
+ /** Message timestamp, falling back to the entry's ISO timestamp, then 0. */
219
+ function coerceEntryTimestamp(timestamp: number | undefined, entry: SessionMessageEntry): number {
220
+ if (typeof timestamp === "number" && Number.isFinite(timestamp)) return timestamp;
221
+ const ts = Date.parse(entry.timestamp);
222
+ return Number.isFinite(ts) ? ts : 0;
223
+ }
224
+
225
+ /**
226
+ * Extract one {@link ToolCallStats} per `toolCall` content block of an
227
+ * assistant message. Returns an empty array for turns without tool calls.
228
+ */
229
+ function extractToolCalls(
230
+ sessionFile: string,
231
+ folder: string,
232
+ entry: SessionMessageEntry,
233
+ agentType: AgentType,
234
+ ): ToolCallStats[] {
235
+ const msg = entry.message as AssistantMessage;
236
+ if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return [];
237
+ // `tool_calls` columns are NOT NULL: skip turns that can't be attributed
238
+ // (malformed persisted entries — see extractStats) and blocks missing ids.
239
+ if (typeof msg.model !== "string" || typeof msg.provider !== "string") return [];
240
+
241
+ const blocks = msg.content.filter(
242
+ (block): block is ToolCall =>
243
+ block !== null &&
244
+ typeof block === "object" &&
245
+ block.type === "toolCall" &&
246
+ typeof block.id === "string" &&
247
+ typeof block.name === "string",
248
+ );
249
+ if (blocks.length === 0) return [];
250
+
251
+ return blocks.map(block => {
252
+ let argsChars = 0;
253
+ try {
254
+ argsChars = JSON.stringify(block.arguments ?? {}).length;
255
+ } catch {
256
+ // Non-serializable arguments (shouldn't happen in persisted JSONL); size unknown.
257
+ }
258
+ return {
259
+ sessionFile,
260
+ entryId: entry.id,
261
+ toolCallId: block.id,
262
+ folder,
263
+ toolName: block.name,
264
+ model: msg.model,
265
+ provider: msg.provider,
266
+ timestamp: coerceEntryTimestamp(msg.timestamp, entry),
267
+ agentType,
268
+ callsInTurn: blocks.length,
269
+ argsChars,
270
+ };
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Build the result linkage for a `toolResult` entry: text characters fed back
276
+ * into context plus the error flag, keyed to the originating call.
277
+ */
278
+ function extractToolResultLink(sessionFile: string, entry: SessionMessageEntry): ToolResultLink | null {
279
+ const msg = entry.message as ToolResultMessage;
280
+ if (msg.role !== "toolResult" || typeof msg.toolCallId !== "string" || msg.toolCallId.length === 0) return null;
281
+ let resultChars = 0;
282
+ if (Array.isArray(msg.content)) {
283
+ for (const block of msg.content) {
284
+ if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
285
+ resultChars += block.text.length;
286
+ }
287
+ }
288
+ }
289
+ return {
290
+ sessionFile,
291
+ toolCallId: msg.toolCallId,
292
+ resultChars,
293
+ isError: msg.isError === true,
294
+ };
295
+ }
296
+
297
+ const LF = 0x0a;
298
+ const CR = 0x0d;
299
+ const jsonLineDecoder = new TextDecoder();
300
+
301
+ function parseJsonLine(bytes: Uint8Array, start: number, end: number): SessionEntry | null {
302
+ while (end > start && bytes[end - 1] === CR) end--;
303
+ if (end <= start) return null;
304
+ try {
305
+ return JSON.parse(jsonLineDecoder.decode(bytes.subarray(start, end))) as SessionEntry;
306
+ } catch {
307
+ return null;
308
+ }
309
+ }
310
+
311
+ function visitSessionEntriesLenient(bytes: Uint8Array, visit: (entry: SessionEntry) => void): number {
312
+ let cursor = 0;
313
+ let read = 0;
314
+
315
+ while (cursor < bytes.length) {
316
+ const newline = bytes.indexOf(LF, cursor);
317
+ const hasNewline = newline !== -1;
318
+ const lineEnd = hasNewline ? newline : bytes.length;
319
+ const entry = parseJsonLine(bytes, cursor, lineEnd);
320
+ if (entry) {
321
+ visit(entry);
322
+ read = hasNewline ? newline + 1 : lineEnd;
323
+ } else if (hasNewline) {
324
+ read = newline + 1;
325
+ } else {
326
+ break;
327
+ }
328
+ cursor = hasNewline ? newline + 1 : lineEnd;
329
+ }
330
+
331
+ return read;
332
+ }
333
+
334
+ function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[]; read: number } {
335
+ const entries: SessionEntry[] = [];
336
+ const read = visitSessionEntriesLenient(bytes, entry => entries.push(entry));
337
+ return { entries, read };
338
+ }
339
+
340
+ function scanLastServiceTier(bytes: Uint8Array): ServiceTierByFamily | undefined {
341
+ let currentServiceTier: ServiceTierByFamily | undefined;
342
+ visitSessionEntriesLenient(bytes, entry => {
343
+ if (isServiceTierChange(entry)) currentServiceTier = coerceServiceTierByFamily(entry.serviceTier);
344
+ });
345
+ return currentServiceTier;
346
+ }
347
+ /**
348
+ * Parse a session file and extract all assistant message stats.
349
+ * Uses incremental reading with offset tracking.
350
+ *
351
+ * Service-tier carry-over: `currentServiceTier` is a session-scoped piece of
352
+ * state derived from `service_tier_change` entries that affects whether
353
+ * subsequent OpenAI assistant replies count as premium requests. Incremental
354
+ * syncs that resume past the most-recent tier change would otherwise lose
355
+ * that state and silently record `premiumRequests = 0` for priority traffic
356
+ * (the coding-agent stopped folding the tier into `usage.premiumRequests`
357
+ * after 13f59162e — the parser is now the sole source of truth). When
358
+ * `fromOffset > 0` we therefore scan the bytes preceding `fromOffset`
359
+ * for the latest service-tier value before parsing the unprocessed tail.
360
+ * The scan only keeps the current tier and does not materialize prefix
361
+ * entries, preserving offset-based memory behavior for large sessions.
362
+ */
363
+ export interface ParseSessionResult {
364
+ stats: MessageStats[];
365
+ userStats: UserMessageStats[];
366
+ userLinks: UserMessageLink[];
367
+ toolCalls: ToolCallStats[];
368
+ toolResults: ToolResultLink[];
369
+ newOffset: number;
370
+ }
371
+ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Promise<ParseSessionResult> {
372
+ let bytes: Uint8Array;
373
+ try {
374
+ bytes = await Bun.file(sessionPath).bytes();
375
+ } catch (err) {
376
+ if (isEnoent(err))
377
+ return { stats: [], userStats: [], userLinks: [], toolCalls: [], toolResults: [], newOffset: fromOffset };
378
+ throw err;
379
+ }
380
+
381
+ const folder = extractFolderFromPath(sessionPath);
382
+ const agentType = classifyAgentType(sessionPath);
383
+ const stats: MessageStats[] = [];
384
+ const userStats: UserMessageStats[] = [];
385
+ const userLinks: UserMessageLink[] = [];
386
+ const toolCalls: ToolCallStats[] = [];
387
+ const toolResults: ToolResultLink[] = [];
388
+ const userByEntryId = new Map<string, UserMessageStats>();
389
+ const start = Math.max(0, Math.min(fromOffset, bytes.length));
390
+ const unprocessed = bytes.subarray(start);
391
+ const { entries, read } = parseSessionEntriesLenient(unprocessed);
392
+ let currentServiceTier: ServiceTierByFamily | undefined;
393
+ if (start > 0) {
394
+ currentServiceTier = scanLastServiceTier(bytes.subarray(0, start));
395
+ }
396
+ for (const entry of entries) {
397
+ if (isServiceTierChange(entry)) {
398
+ currentServiceTier = coerceServiceTierByFamily(entry.serviceTier);
399
+ continue;
400
+ }
401
+ if (isUserMessage(entry)) {
402
+ const userMsg = extractUserStats(sessionPath, folder, entry);
403
+ if (userMsg) {
404
+ userStats.push(userMsg);
405
+ userByEntryId.set(entry.id, userMsg);
406
+ }
407
+ continue;
408
+ }
409
+ if (isToolResultMessage(entry)) {
410
+ const link = extractToolResultLink(sessionPath, entry);
411
+ if (link) toolResults.push(link);
412
+ continue;
413
+ }
414
+ if (isAssistantMessage(entry)) {
415
+ const msgStats = extractStats(sessionPath, folder, entry, currentServiceTier, agentType);
416
+ if (msgStats) stats.push(msgStats);
417
+ toolCalls.push(...extractToolCalls(sessionPath, folder, entry, agentType));
418
+ // Link assistant's responding model back to the user message it answered.
419
+ const parentId = (entry as SessionMessageEntry).parentId;
420
+ if (parentId) {
421
+ const msg = entry.message as AssistantMessage;
422
+ if (msg.model && msg.provider) {
423
+ // Emit unconditionally. The aggregator's UPDATE is guarded by
424
+ // `model IS NULL` so this is idempotent: a no-op for already
425
+ // linked rows, a fix-up for fresh inserts (which start NULL
426
+ // because the user row is recorded before its reply lands) and
427
+ // for cross-pass orphans whose parent was committed by an
428
+ // earlier incremental sync.
429
+ userLinks.push({
430
+ sessionFile: sessionPath,
431
+ entryId: parentId,
432
+ model: msg.model,
433
+ provider: msg.provider,
434
+ });
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ return { stats, userStats, userLinks, toolCalls, toolResults, newOffset: start + read };
441
+ }
442
+
443
+ /**
444
+ * List all session directories (folders).
445
+ */
446
+ export async function listSessionFolders(): Promise<string[]> {
447
+ try {
448
+ const sessionsDir = getSessionsDir();
449
+ const entries = await fs.readdir(sessionsDir, { withFileTypes: true });
450
+ return entries.filter(e => e.isDirectory()).map(e => path.join(sessionsDir, e.name));
451
+ } catch {
452
+ return [];
453
+ }
454
+ }
455
+
456
+ /**
457
+ * List all session files in a folder.
458
+ */
459
+ export async function listSessionFiles(folderPath: string): Promise<string[]> {
460
+ try {
461
+ const entries = await fs.readdir(folderPath, { recursive: true, withFileTypes: true });
462
+ return entries.filter(e => e.isFile() && e.name.endsWith(".jsonl")).map(e => path.join(e.parentPath, e.name));
463
+ } catch {
464
+ return [];
465
+ }
466
+ }
467
+
468
+ /**
469
+ * List all session files across all folders.
470
+ */
471
+ export async function listAllSessionFiles(): Promise<string[]> {
472
+ const folders = await listSessionFolders();
473
+ const allFiles: string[] = [];
474
+
475
+ for (const folder of folders) {
476
+ const files = await listSessionFiles(folder);
477
+ allFiles.push(...files);
478
+ }
479
+
480
+ return allFiles;
481
+ }
482
+
483
+ /**
484
+ * Find a specific entry in a session file.
485
+ */
486
+ export async function getSessionEntry(sessionPath: string, entryId: string): Promise<SessionEntry | null> {
487
+ try {
488
+ for await (const line of readLines(Bun.file(sessionPath).stream())) {
489
+ const entry = parseJsonLine(line, 0, line.length);
490
+ if (entry && "id" in entry && entry.id === entryId) {
491
+ return entry;
492
+ }
493
+ }
494
+ } catch (err) {
495
+ if (isEnoent(err)) return null;
496
+ throw err;
497
+ }
498
+ return null;
499
+ }