ai-wrapped 0.0.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/.0spec/config.toml +50 -0
- package/.0spec/flows/ai-stats-build.toml +291 -0
- package/.0spec/flows/ai-stats-fix.toml +285 -0
- package/.0spec/flows/ai-stats.toml +400 -0
- package/.github/workflows/publish.yml +28 -0
- package/CLAUDE.md +111 -0
- package/README.md +64 -0
- package/bun.lock +635 -0
- package/electrobun.config.ts +25 -0
- package/package.json +36 -0
- package/public/tray-icon.png +0 -0
- package/src/bun/aggregator.test.ts +49 -0
- package/src/bun/aggregator.ts +130 -0
- package/src/bun/discovery/claude.ts +18 -0
- package/src/bun/discovery/codex.ts +20 -0
- package/src/bun/discovery/copilot.ts +13 -0
- package/src/bun/discovery/droid.ts +13 -0
- package/src/bun/discovery/gemini.ts +13 -0
- package/src/bun/discovery/index.ts +28 -0
- package/src/bun/discovery/opencode.ts +13 -0
- package/src/bun/discovery/types.ts +13 -0
- package/src/bun/discovery/utils.ts +48 -0
- package/src/bun/index.ts +722 -0
- package/src/bun/normalizer.test.ts +101 -0
- package/src/bun/normalizer.ts +454 -0
- package/src/bun/parsers/claude.ts +234 -0
- package/src/bun/parsers/codex.test.ts +180 -0
- package/src/bun/parsers/codex.ts +435 -0
- package/src/bun/parsers/copilot.ts +4 -0
- package/src/bun/parsers/droid.ts +4 -0
- package/src/bun/parsers/gemini.ts +4 -0
- package/src/bun/parsers/generic.test.ts +97 -0
- package/src/bun/parsers/generic.ts +260 -0
- package/src/bun/parsers/index.ts +37 -0
- package/src/bun/parsers/opencode.ts +4 -0
- package/src/bun/parsers/types.ts +23 -0
- package/src/bun/pricing.ts +52 -0
- package/src/bun/scan.ts +77 -0
- package/src/bun/session-schema.ts +1 -0
- package/src/bun/store.ts +283 -0
- package/src/mainview/App.tsx +42 -0
- package/src/mainview/components/AgentBadge.tsx +17 -0
- package/src/mainview/components/Dashboard.tsx +229 -0
- package/src/mainview/components/DashboardCharts.tsx +499 -0
- package/src/mainview/components/EmptyState.tsx +17 -0
- package/src/mainview/components/Sidebar.tsx +30 -0
- package/src/mainview/components/StatsCards.tsx +118 -0
- package/src/mainview/hooks/useDashboardData.ts +315 -0
- package/src/mainview/hooks/useRPC.ts +29 -0
- package/src/mainview/index.css +195 -0
- package/src/mainview/index.html +12 -0
- package/src/mainview/index.ts +12 -0
- package/src/mainview/lib/constants.ts +32 -0
- package/src/mainview/lib/formatters.ts +82 -0
- package/src/shared/constants.ts +1 -0
- package/src/shared/schema.ts +71 -0
- package/src/shared/session-types.ts +61 -0
- package/src/shared/types.ts +59 -0
- package/src/types/electrobun-bun.d.ts +117 -0
- package/src/types/electrobun-root.d.ts +3 -0
- package/src/types/electrobun-view.d.ts +38 -0
- package/tsconfig.json +18 -0
- package/tsconfig.typecheck.json +11 -0
- package/vite.config.ts +23 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import type { FileCandidate } from "../discovery";
|
|
4
|
+
import { extractText, normalizeTimestamp, normalizeTokenUsage, resolveEventKind } from "../normalizer";
|
|
5
|
+
import type { SessionEvent } from "../session-schema";
|
|
6
|
+
import type { RawParsedSession, SessionParser } from "./types";
|
|
7
|
+
|
|
8
|
+
const asRecord = (value: unknown): Record<string, unknown> | null => {
|
|
9
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
10
|
+
return value as Record<string, unknown>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const getString = (value: unknown): string | null => {
|
|
14
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const parseJsonl = (content: string): Array<Record<string, unknown>> => {
|
|
18
|
+
const records: Array<Record<string, unknown>> = [];
|
|
19
|
+
for (const line of content.split(/\r?\n/)) {
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
if (!trimmed) continue;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
24
|
+
const record = asRecord(parsed);
|
|
25
|
+
if (record) records.push(record);
|
|
26
|
+
} catch {
|
|
27
|
+
// Skip malformed JSONL lines.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return records;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const extractCodexSessionId = (candidate: FileCandidate, records: Array<Record<string, unknown>>): string => {
|
|
34
|
+
for (const record of records) {
|
|
35
|
+
if (record.type === "session_meta") {
|
|
36
|
+
const payload = asRecord(record.payload);
|
|
37
|
+
const payloadId = getString(payload?.id);
|
|
38
|
+
if (payloadId) return payloadId;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const match = basename(candidate.path).match(/^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/);
|
|
43
|
+
if (match?.[1]) return match[1];
|
|
44
|
+
|
|
45
|
+
return basename(candidate.path).replace(/\.jsonl$/, "");
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const detectDelta = (payloadType: string | null, payload: Record<string, unknown> | null): boolean => {
|
|
49
|
+
if (payloadType?.toLowerCase().includes("delta")) return true;
|
|
50
|
+
if (!payload) return false;
|
|
51
|
+
|
|
52
|
+
if (typeof payload.isDelta === "boolean") return payload.isDelta;
|
|
53
|
+
if (payload.delta !== undefined || payload.content_delta !== undefined) return true;
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(payload.content)) {
|
|
56
|
+
return payload.content.some((entry) => {
|
|
57
|
+
const block = asRecord(entry);
|
|
58
|
+
if (!block) return false;
|
|
59
|
+
const blockType = getString(block.type);
|
|
60
|
+
return Boolean(blockType && blockType.toLowerCase().includes("delta"));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return false;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const extractCodexText = (record: Record<string, unknown>, payload: Record<string, unknown> | null): string | null => {
|
|
68
|
+
if (payload) {
|
|
69
|
+
const payloadText = extractText(payload.content ?? payload.message ?? payload.text ?? payload.summary ?? payload);
|
|
70
|
+
if (payloadText) return payloadText;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return extractText(record.content ?? record.text ?? record.message ?? record);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
interface CodexUsageTotals {
|
|
77
|
+
inputTokens: number;
|
|
78
|
+
cacheReadTokens: number;
|
|
79
|
+
outputTokens: number;
|
|
80
|
+
reasoningTokens: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const toFiniteNumber = (value: unknown): number | null => {
|
|
84
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
85
|
+
if (typeof value === "string") {
|
|
86
|
+
const parsed = Number(value);
|
|
87
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const toCodexUsageTotals = (value: unknown): CodexUsageTotals | null => {
|
|
93
|
+
const record = asRecord(value);
|
|
94
|
+
if (!record) return null;
|
|
95
|
+
|
|
96
|
+
const inputTokens = toFiniteNumber(record.input_tokens);
|
|
97
|
+
const cacheReadTokens = toFiniteNumber(record.cached_input_tokens) ?? 0;
|
|
98
|
+
const outputTokens = toFiniteNumber(record.output_tokens);
|
|
99
|
+
const reasoningTokens = toFiniteNumber(record.reasoning_output_tokens) ?? 0;
|
|
100
|
+
|
|
101
|
+
if (inputTokens === null && outputTokens === null) return null;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
inputTokens: Math.max(0, inputTokens ?? 0),
|
|
105
|
+
cacheReadTokens: Math.max(0, cacheReadTokens),
|
|
106
|
+
outputTokens: Math.max(0, outputTokens ?? 0),
|
|
107
|
+
reasoningTokens: Math.max(0, reasoningTokens),
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const subtractUsageTotals = (current: CodexUsageTotals, previous: CodexUsageTotals | null): CodexUsageTotals => {
|
|
112
|
+
if (!previous) return current;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
inputTokens: Math.max(0, current.inputTokens - previous.inputTokens),
|
|
116
|
+
cacheReadTokens: Math.max(0, current.cacheReadTokens - previous.cacheReadTokens),
|
|
117
|
+
outputTokens: Math.max(0, current.outputTokens - previous.outputTokens),
|
|
118
|
+
reasoningTokens: Math.max(0, current.reasoningTokens - previous.reasoningTokens),
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const toTokenUsage = (usage: CodexUsageTotals): SessionEvent["tokens"] => {
|
|
123
|
+
const uncachedInputTokens = Math.max(0, usage.inputTokens - usage.cacheReadTokens);
|
|
124
|
+
const nonReasoningOutputTokens = Math.max(0, usage.outputTokens - usage.reasoningTokens);
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
uncachedInputTokens === 0 &&
|
|
128
|
+
nonReasoningOutputTokens === 0 &&
|
|
129
|
+
usage.cacheReadTokens === 0 &&
|
|
130
|
+
usage.reasoningTokens === 0
|
|
131
|
+
) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
inputTokens: uncachedInputTokens,
|
|
137
|
+
outputTokens: nonReasoningOutputTokens,
|
|
138
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
139
|
+
cacheWriteTokens: 0,
|
|
140
|
+
reasoningTokens: usage.reasoningTokens,
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const extractTokenCountUsage = (
|
|
145
|
+
payload: Record<string, unknown> | null,
|
|
146
|
+
previousTotals: CodexUsageTotals | null,
|
|
147
|
+
): { tokens: SessionEvent["tokens"]; nextTotals: CodexUsageTotals | null } => {
|
|
148
|
+
const info = asRecord(payload?.info);
|
|
149
|
+
const totalUsage = toCodexUsageTotals(info?.total_token_usage);
|
|
150
|
+
const lastUsage = toCodexUsageTotals(info?.last_token_usage);
|
|
151
|
+
|
|
152
|
+
if (totalUsage) {
|
|
153
|
+
const deltaUsage = subtractUsageTotals(totalUsage, previousTotals);
|
|
154
|
+
const deltaTokens = toTokenUsage(deltaUsage);
|
|
155
|
+
if (deltaTokens) {
|
|
156
|
+
return { tokens: deltaTokens, nextTotals: totalUsage };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!previousTotals && lastUsage) {
|
|
160
|
+
return { tokens: toTokenUsage(lastUsage), nextTotals: totalUsage };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { tokens: null, nextTotals: totalUsage };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (lastUsage) {
|
|
167
|
+
return { tokens: toTokenUsage(lastUsage), nextTotals: previousTotals };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { tokens: null, nextTotals: previousTotals };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const createEvent = (
|
|
174
|
+
sessionId: string,
|
|
175
|
+
lineIndex: number,
|
|
176
|
+
record: Record<string, unknown>,
|
|
177
|
+
kind: SessionEvent["kind"],
|
|
178
|
+
options: {
|
|
179
|
+
role?: string | null;
|
|
180
|
+
text?: string | null;
|
|
181
|
+
toolName?: string | null;
|
|
182
|
+
toolInput?: string | null;
|
|
183
|
+
toolOutput?: string | null;
|
|
184
|
+
model?: string | null;
|
|
185
|
+
parentId?: string | null;
|
|
186
|
+
messageId?: string | null;
|
|
187
|
+
isDelta?: boolean;
|
|
188
|
+
kindType?: string | null;
|
|
189
|
+
tokens?: SessionEvent["tokens"];
|
|
190
|
+
} = {},
|
|
191
|
+
): SessionEvent => {
|
|
192
|
+
const payload = asRecord(record.payload);
|
|
193
|
+
|
|
194
|
+
const kindType = options.kindType ?? getString(payload?.type) ?? getString(record.type);
|
|
195
|
+
const messageId = options.messageId ?? getString(payload?.id) ?? getString(payload?.call_id);
|
|
196
|
+
const eventScope = kindType ?? kind;
|
|
197
|
+
const idSuffix = messageId ? `${eventScope}:${messageId}` : `${eventScope}:${lineIndex}`;
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
// Keep IDs unique across event kinds and repeated delta frames.
|
|
201
|
+
id: `${sessionId}:${kind}:${idSuffix}:${lineIndex}`,
|
|
202
|
+
sessionId,
|
|
203
|
+
kind,
|
|
204
|
+
timestamp: normalizeTimestamp(record.timestamp),
|
|
205
|
+
role: options.role ?? getString(payload?.role) ?? getString(record.role),
|
|
206
|
+
text: options.text ?? null,
|
|
207
|
+
toolName: options.toolName ?? null,
|
|
208
|
+
toolInput: options.toolInput ?? null,
|
|
209
|
+
toolOutput: options.toolOutput ?? null,
|
|
210
|
+
model: options.model ?? null,
|
|
211
|
+
parentId: options.parentId ?? null,
|
|
212
|
+
messageId,
|
|
213
|
+
isDelta: Boolean(options.isDelta),
|
|
214
|
+
tokens: options.tokens ?? null,
|
|
215
|
+
costUsd: null,
|
|
216
|
+
};
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export const codexParser: SessionParser = {
|
|
220
|
+
source: "codex",
|
|
221
|
+
async parse(candidate: FileCandidate): Promise<RawParsedSession | null> {
|
|
222
|
+
try {
|
|
223
|
+
const content = await readFile(candidate.path, "utf8");
|
|
224
|
+
const records = parseJsonl(content);
|
|
225
|
+
if (records.length === 0) return null;
|
|
226
|
+
|
|
227
|
+
const sessionId = extractCodexSessionId(candidate, records);
|
|
228
|
+
|
|
229
|
+
let cwd: string | null = null;
|
|
230
|
+
let gitBranch: string | null = null;
|
|
231
|
+
let model: string | null = null;
|
|
232
|
+
let cliVersion: string | null = null;
|
|
233
|
+
let title: string | null = null;
|
|
234
|
+
let previousCodexTotalUsage: CodexUsageTotals | null = null;
|
|
235
|
+
|
|
236
|
+
const events: SessionEvent[] = [];
|
|
237
|
+
|
|
238
|
+
for (let lineIndex = 0; lineIndex < records.length; lineIndex += 1) {
|
|
239
|
+
const record = records[lineIndex] ?? {};
|
|
240
|
+
const type = getString(record.type);
|
|
241
|
+
const payload = asRecord(record.payload);
|
|
242
|
+
const payloadType = getString(payload?.type);
|
|
243
|
+
|
|
244
|
+
if (type === "session_meta") {
|
|
245
|
+
const git = asRecord(payload?.git);
|
|
246
|
+
|
|
247
|
+
cwd = cwd ?? getString(payload?.cwd);
|
|
248
|
+
gitBranch = gitBranch ?? getString(git?.branch);
|
|
249
|
+
cliVersion = cliVersion ?? getString(payload?.cli_version);
|
|
250
|
+
model = model ?? getString(payload?.model_provider);
|
|
251
|
+
|
|
252
|
+
events.push(
|
|
253
|
+
createEvent(sessionId, lineIndex, record, "meta", {
|
|
254
|
+
text: extractText(payload),
|
|
255
|
+
messageId: getString(payload?.id),
|
|
256
|
+
kindType: type,
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (type === "turn_context") {
|
|
263
|
+
model = getString(payload?.model) ?? model;
|
|
264
|
+
cwd = getString(payload?.cwd) ?? cwd;
|
|
265
|
+
|
|
266
|
+
events.push(
|
|
267
|
+
createEvent(sessionId, lineIndex, record, "meta", {
|
|
268
|
+
text: extractText(payload),
|
|
269
|
+
model,
|
|
270
|
+
kindType: type,
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (type === "response_item") {
|
|
277
|
+
if (payloadType === "message") {
|
|
278
|
+
const role = getString(payload?.role);
|
|
279
|
+
const text = extractCodexText(record, payload);
|
|
280
|
+
|
|
281
|
+
if (!title && role === "user" && text) {
|
|
282
|
+
title = text.slice(0, 200);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
events.push(
|
|
286
|
+
createEvent(sessionId, lineIndex, record, resolveEventKind(payloadType, role), {
|
|
287
|
+
role,
|
|
288
|
+
text,
|
|
289
|
+
model,
|
|
290
|
+
isDelta: detectDelta(payloadType, payload),
|
|
291
|
+
kindType: payloadType,
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (payloadType === "function_call" || payloadType === "custom_tool_call" || payloadType === "web_search_call") {
|
|
298
|
+
const toolInput = payload?.arguments ?? payload?.input ?? payload?.query;
|
|
299
|
+
|
|
300
|
+
events.push(
|
|
301
|
+
createEvent(sessionId, lineIndex, record, "tool_call", {
|
|
302
|
+
role: "assistant",
|
|
303
|
+
toolName: getString(payload?.name),
|
|
304
|
+
toolInput: toolInput ? JSON.stringify(toolInput) : null,
|
|
305
|
+
model,
|
|
306
|
+
messageId: getString(payload?.call_id) ?? getString(payload?.id),
|
|
307
|
+
isDelta: detectDelta(payloadType, payload),
|
|
308
|
+
kindType: payloadType,
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (
|
|
315
|
+
payloadType === "function_call_output" ||
|
|
316
|
+
payloadType === "custom_tool_call_output" ||
|
|
317
|
+
payloadType === "web_search_call_output"
|
|
318
|
+
) {
|
|
319
|
+
events.push(
|
|
320
|
+
createEvent(sessionId, lineIndex, record, "tool_result", {
|
|
321
|
+
role: "tool",
|
|
322
|
+
toolOutput: extractText(payload?.output ?? payload?.result),
|
|
323
|
+
parentId: getString(payload?.call_id),
|
|
324
|
+
messageId: getString(payload?.call_id) ?? getString(payload?.id),
|
|
325
|
+
model,
|
|
326
|
+
isDelta: detectDelta(payloadType, payload),
|
|
327
|
+
kindType: payloadType,
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
events.push(
|
|
334
|
+
createEvent(sessionId, lineIndex, record, resolveEventKind(payloadType, null), {
|
|
335
|
+
text: extractCodexText(record, payload),
|
|
336
|
+
model,
|
|
337
|
+
isDelta: detectDelta(payloadType, payload),
|
|
338
|
+
kindType: payloadType,
|
|
339
|
+
tokens: normalizeTokenUsage(payload?.usage ?? payload?.tokens ?? asRecord(payload?.data)?.usage),
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (type === "event_msg") {
|
|
346
|
+
const messageType = payloadType;
|
|
347
|
+
|
|
348
|
+
if (messageType === "token_count") {
|
|
349
|
+
const { tokens, nextTotals } = extractTokenCountUsage(payload, previousCodexTotalUsage);
|
|
350
|
+
previousCodexTotalUsage = nextTotals;
|
|
351
|
+
|
|
352
|
+
events.push(
|
|
353
|
+
createEvent(sessionId, lineIndex, record, "meta", {
|
|
354
|
+
role: "meta",
|
|
355
|
+
text: getString(payload?.text) ?? getString(payload?.message) ?? extractCodexText(record, payload),
|
|
356
|
+
model,
|
|
357
|
+
kindType: messageType,
|
|
358
|
+
tokens,
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (messageType === "user_message") {
|
|
365
|
+
const text = getString(payload?.message) ?? extractText(payload);
|
|
366
|
+
if (!title && text) {
|
|
367
|
+
title = text.slice(0, 200);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
events.push(
|
|
371
|
+
createEvent(sessionId, lineIndex, record, "user", {
|
|
372
|
+
role: "user",
|
|
373
|
+
text,
|
|
374
|
+
model,
|
|
375
|
+
kindType: messageType,
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (messageType === "agent_message") {
|
|
382
|
+
events.push(
|
|
383
|
+
createEvent(sessionId, lineIndex, record, "assistant", {
|
|
384
|
+
role: "assistant",
|
|
385
|
+
text: getString(payload?.message) ?? extractText(payload),
|
|
386
|
+
model,
|
|
387
|
+
kindType: messageType,
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
events.push(
|
|
394
|
+
createEvent(sessionId, lineIndex, record, resolveEventKind(messageType, null), {
|
|
395
|
+
text: getString(payload?.text) ?? getString(payload?.message) ?? extractCodexText(record, payload),
|
|
396
|
+
model,
|
|
397
|
+
kindType: messageType,
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
events.push(
|
|
404
|
+
createEvent(sessionId, lineIndex, record, resolveEventKind(type, null), {
|
|
405
|
+
text: extractCodexText(record, payload),
|
|
406
|
+
model,
|
|
407
|
+
kindType: type,
|
|
408
|
+
isDelta: detectDelta(type, payload ?? asRecord(record)),
|
|
409
|
+
}),
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (events.length === 0) return null;
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
sessionId,
|
|
417
|
+
source: "codex",
|
|
418
|
+
filePath: candidate.path,
|
|
419
|
+
fileSizeBytes: candidate.size,
|
|
420
|
+
metadata: {
|
|
421
|
+
cwd,
|
|
422
|
+
gitBranch,
|
|
423
|
+
model,
|
|
424
|
+
cliVersion,
|
|
425
|
+
title,
|
|
426
|
+
},
|
|
427
|
+
events,
|
|
428
|
+
};
|
|
429
|
+
} catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
export const parseCodex = codexParser.parse;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseGeneric } from "./generic";
|
|
6
|
+
|
|
7
|
+
describe("parseGeneric token extraction", () => {
|
|
8
|
+
test("parses top-level usage objects (droid completion style)", async () => {
|
|
9
|
+
const fixtureDir = mkdtempSync(join(tmpdir(), "ai-stats-generic-"));
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const filePath = join(fixtureDir, "stream.jsonl");
|
|
13
|
+
const content = JSON.stringify({
|
|
14
|
+
type: "completion",
|
|
15
|
+
sessionId: "sid-top-level-usage",
|
|
16
|
+
timestamp: 1767812644000,
|
|
17
|
+
finalText: "Done",
|
|
18
|
+
usage: { input_tokens: 10, output_tokens: 4 },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
writeFileSync(filePath, content, "utf8");
|
|
22
|
+
const fileStat = statSync(filePath);
|
|
23
|
+
|
|
24
|
+
const parsed = await parseGeneric(
|
|
25
|
+
{
|
|
26
|
+
path: filePath,
|
|
27
|
+
source: "droid",
|
|
28
|
+
mtime: fileStat.mtimeMs,
|
|
29
|
+
size: fileStat.size,
|
|
30
|
+
},
|
|
31
|
+
"droid",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(parsed).not.toBeNull();
|
|
35
|
+
if (!parsed) return;
|
|
36
|
+
|
|
37
|
+
expect(parsed.events[0]?.tokens).toEqual({
|
|
38
|
+
inputTokens: 10,
|
|
39
|
+
outputTokens: 4,
|
|
40
|
+
cacheReadTokens: 0,
|
|
41
|
+
cacheWriteTokens: 0,
|
|
42
|
+
reasoningTokens: 0,
|
|
43
|
+
});
|
|
44
|
+
} finally {
|
|
45
|
+
rmSync(fixtureDir, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("parses payload.info.last_token_usage shapes", async () => {
|
|
50
|
+
const fixtureDir = mkdtempSync(join(tmpdir(), "ai-stats-generic-"));
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const filePath = join(fixtureDir, "token-count.jsonl");
|
|
54
|
+
const content = JSON.stringify({
|
|
55
|
+
type: "event_msg",
|
|
56
|
+
timestamp: "2026-02-03T11:39:00Z",
|
|
57
|
+
payload: {
|
|
58
|
+
type: "token_count",
|
|
59
|
+
info: {
|
|
60
|
+
last_token_usage: {
|
|
61
|
+
input_tokens: 100,
|
|
62
|
+
cached_input_tokens: 30,
|
|
63
|
+
output_tokens: 15,
|
|
64
|
+
reasoning_output_tokens: 5,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
writeFileSync(filePath, content, "utf8");
|
|
71
|
+
const fileStat = statSync(filePath);
|
|
72
|
+
|
|
73
|
+
const parsed = await parseGeneric(
|
|
74
|
+
{
|
|
75
|
+
path: filePath,
|
|
76
|
+
source: "codex",
|
|
77
|
+
mtime: fileStat.mtimeMs,
|
|
78
|
+
size: fileStat.size,
|
|
79
|
+
},
|
|
80
|
+
"codex",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(parsed).not.toBeNull();
|
|
84
|
+
if (!parsed) return;
|
|
85
|
+
|
|
86
|
+
expect(parsed.events[0]?.tokens).toEqual({
|
|
87
|
+
inputTokens: 100,
|
|
88
|
+
outputTokens: 15,
|
|
89
|
+
cacheReadTokens: 30,
|
|
90
|
+
cacheWriteTokens: 0,
|
|
91
|
+
reasoningTokens: 5,
|
|
92
|
+
});
|
|
93
|
+
} finally {
|
|
94
|
+
rmSync(fixtureDir, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|