ai-usage-analyzer 0.3.2 → 0.5.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.
- package/README.md +30 -15
- package/bin/ai-usage.js +66 -35
- package/package.json +8 -6
- package/src/aggregate.js +48 -21
- package/src/live.js +132 -0
- package/src/loaders.js +259 -114
- package/src/markdown.js +133 -70
- package/src/pricing.js +49 -0
- package/src/quota.js +194 -0
- package/src/render.js +512 -189
- package/src/server.js +67 -0
- package/src/tools.js +5 -5
- package/src/tui.js +326 -0
- package/web/app.js +952 -0
- package/web/favicon.svg +1 -0
- package/web/fonts/OFL.txt +93 -0
- package/web/fonts/manrope.woff2 +0 -0
- package/web/icons/claude.svg +1 -0
- package/web/icons/openai.svg +1 -0
- package/web/icons/opencode.svg +1 -0
- package/web/index.html +199 -0
- package/web/style.css +324 -0
package/src/loaders.js
CHANGED
|
@@ -15,11 +15,28 @@
|
|
|
15
15
|
// model: string,
|
|
16
16
|
// }
|
|
17
17
|
|
|
18
|
-
import {
|
|
19
|
-
import { join } from
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
18
|
+
import { createReadStream, readdirSync } from "node:fs";
|
|
19
|
+
import { basename, dirname, join } from "node:path";
|
|
20
|
+
import { createInterface } from "node:readline";
|
|
21
|
+
import { DatabaseSync } from "node:sqlite";
|
|
22
|
+
import { estimateUsage } from "./pricing.js";
|
|
23
|
+
|
|
24
|
+
// Run fn over every item in items with at most `limit` in-flight at once.
|
|
25
|
+
async function mapConcurrent(items, fn, limit) {
|
|
26
|
+
if (items.length === 0) return [];
|
|
27
|
+
const results = new Array(items.length);
|
|
28
|
+
let next = 0;
|
|
29
|
+
async function worker() {
|
|
30
|
+
while (next < items.length) {
|
|
31
|
+
const i = next++;
|
|
32
|
+
results[i] = await fn(items[i]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
await Promise.all(
|
|
36
|
+
Array.from({ length: Math.min(limit, items.length) }, worker),
|
|
37
|
+
);
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
23
40
|
|
|
24
41
|
// ---------------------------------------------------------------------------
|
|
25
42
|
// Date helpers
|
|
@@ -28,17 +45,24 @@ import { createReadStream } from 'node:fs';
|
|
|
28
45
|
function isoWeekKey(ts) {
|
|
29
46
|
const d = new Date(ts);
|
|
30
47
|
// ISO week: Thu in current week decides the year
|
|
31
|
-
const target = new Date(
|
|
48
|
+
const target = new Date(
|
|
49
|
+
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
|
|
50
|
+
);
|
|
32
51
|
const dayNum = (target.getUTCDay() + 6) % 7; // Mon=0..Sun=6
|
|
33
52
|
target.setUTCDate(target.getUTCDate() - dayNum + 3); // Thu
|
|
34
53
|
const firstThu = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
|
|
35
|
-
const week =
|
|
36
|
-
|
|
54
|
+
const week =
|
|
55
|
+
1 +
|
|
56
|
+
Math.round(
|
|
57
|
+
((target - firstThu) / 86400000 - 3 + ((firstThu.getUTCDay() + 6) % 7)) /
|
|
58
|
+
7,
|
|
59
|
+
);
|
|
60
|
+
return `${target.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
37
61
|
}
|
|
38
62
|
|
|
39
63
|
function monthKey(ts) {
|
|
40
64
|
const d = new Date(ts);
|
|
41
|
-
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2,
|
|
65
|
+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
42
66
|
}
|
|
43
67
|
|
|
44
68
|
function parseIsoMs(s) {
|
|
@@ -47,10 +71,15 @@ function parseIsoMs(s) {
|
|
|
47
71
|
return Number.isFinite(t) ? t : null;
|
|
48
72
|
}
|
|
49
73
|
|
|
74
|
+
function tokenCount(value) {
|
|
75
|
+
const n = Number(value);
|
|
76
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
50
79
|
function compactHome(p) {
|
|
51
|
-
if (!p) return
|
|
52
|
-
const home = process.env.HOME ||
|
|
53
|
-
return p.startsWith(home) ?
|
|
80
|
+
if (!p) return "(unknown)";
|
|
81
|
+
const home = process.env.HOME || "";
|
|
82
|
+
return p.startsWith(home) ? "~" + p.slice(home.length) : p;
|
|
54
83
|
}
|
|
55
84
|
|
|
56
85
|
// ---------------------------------------------------------------------------
|
|
@@ -67,40 +96,53 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
|
67
96
|
|
|
68
97
|
// Introspect session table for token columns (MimoCode schema is a subset)
|
|
69
98
|
const cols = db.prepare(`PRAGMA table_info(session)`).all();
|
|
70
|
-
const colSet = new Set(cols.map(c => c.name));
|
|
71
|
-
const hasTokens =
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
99
|
+
const colSet = new Set(cols.map((c) => c.name));
|
|
100
|
+
const hasTokens =
|
|
101
|
+
colSet.has("tokens_input") &&
|
|
102
|
+
colSet.has("tokens_output") &&
|
|
103
|
+
colSet.has("tokens_cache_read") &&
|
|
104
|
+
colSet.has("tokens_cache_write");
|
|
75
105
|
|
|
76
106
|
if (!hasTokens) {
|
|
77
107
|
db.close();
|
|
78
|
-
return { records: [], info:
|
|
108
|
+
return { records: [], info: "session table has no token columns" };
|
|
79
109
|
}
|
|
80
110
|
|
|
81
|
-
const
|
|
111
|
+
const hasTimeUpdated = colSet.has("time_updated");
|
|
112
|
+
const rows = db
|
|
113
|
+
.prepare(
|
|
114
|
+
`
|
|
82
115
|
SELECT id, directory, title, time_created,
|
|
116
|
+
${hasTimeUpdated ? "time_updated," : ""}
|
|
83
117
|
tokens_input, tokens_output, tokens_reasoning,
|
|
84
118
|
tokens_cache_read, tokens_cache_write, cost, model
|
|
85
119
|
FROM session
|
|
86
120
|
WHERE time_archived IS NULL
|
|
87
|
-
|
|
121
|
+
`,
|
|
122
|
+
)
|
|
123
|
+
.all();
|
|
88
124
|
db.close();
|
|
89
125
|
|
|
90
126
|
const records = [];
|
|
91
127
|
for (const r of rows) {
|
|
92
|
-
const tot =
|
|
93
|
-
|
|
94
|
-
|
|
128
|
+
const tot =
|
|
129
|
+
(r.tokens_input || 0) +
|
|
130
|
+
(r.tokens_output || 0) +
|
|
131
|
+
(r.tokens_reasoning || 0) +
|
|
132
|
+
(r.tokens_cache_read || 0) +
|
|
133
|
+
(r.tokens_cache_write || 0);
|
|
95
134
|
if (tot === 0) continue;
|
|
96
135
|
records.push({
|
|
97
136
|
tool: toolKey,
|
|
98
137
|
sessionId: r.id,
|
|
99
138
|
project: compactHome(r.directory),
|
|
100
|
-
title: r.title ||
|
|
139
|
+
title: r.title || "",
|
|
101
140
|
week: isoWeekKey(r.time_created),
|
|
102
141
|
month: monthKey(r.time_created),
|
|
103
142
|
ts: r.time_created,
|
|
143
|
+
durationMs: hasTimeUpdated
|
|
144
|
+
? Math.max(0, (r.time_updated || r.time_created) - r.time_created)
|
|
145
|
+
: 0,
|
|
104
146
|
tokensInput: r.tokens_input || 0,
|
|
105
147
|
tokensOutput: r.tokens_output || 0,
|
|
106
148
|
tokensCacheRead: r.tokens_cache_read || 0,
|
|
@@ -108,7 +150,8 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
|
108
150
|
tokensReasoning: r.tokens_reasoning || 0,
|
|
109
151
|
tokensTotal: tot,
|
|
110
152
|
cost: r.cost || 0,
|
|
111
|
-
|
|
153
|
+
costStatus: r.cost == null ? "unavailable" : "recorded",
|
|
154
|
+
model: r.model || "",
|
|
112
155
|
});
|
|
113
156
|
}
|
|
114
157
|
return { records };
|
|
@@ -119,74 +162,135 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
|
119
162
|
// ---------------------------------------------------------------------------
|
|
120
163
|
|
|
121
164
|
async function loadCodexRollouts(rootDir) {
|
|
122
|
-
const records = [];
|
|
123
165
|
const errors = [];
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
166
|
+
const roots = basename(rootDir) === "sessions" ? [rootDir, join(dirname(rootDir), "archived_sessions")] : [rootDir];
|
|
167
|
+
const files = [...new Set(roots.flatMap((root) => walkJsonl(root, "rollout-")))];
|
|
168
|
+
files.sort((a, b) => Number(a.split(/[\\/]/).includes("archived_sessions")) - Number(b.split(/[\\/]/).includes("archived_sessions")) || a.localeCompare(b));
|
|
169
|
+
const results = await mapConcurrent(
|
|
170
|
+
files,
|
|
171
|
+
async (f) => {
|
|
172
|
+
try {
|
|
173
|
+
return await parseCodexRollout(f);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
errors.push(`${f}: ${e.message}`);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
48,
|
|
180
|
+
);
|
|
181
|
+
const sessions = new Map();
|
|
182
|
+
for (const record of results) if (record && !sessions.has(record.sessionId)) sessions.set(record.sessionId, record);
|
|
183
|
+
return { records: [...sessions.values()], errors };
|
|
135
184
|
}
|
|
136
185
|
|
|
137
186
|
async function parseCodexRollout(path) {
|
|
138
187
|
// Use a streaming reader to avoid loading huge files into memory.
|
|
139
|
-
// The 'token_count' event accumulates usage; we take the LAST one (final snapshot).
|
|
140
188
|
let sessionMeta = null;
|
|
141
|
-
let
|
|
142
|
-
let
|
|
143
|
-
|
|
144
|
-
const
|
|
189
|
+
let previous = null;
|
|
190
|
+
let forkWaiting = false;
|
|
191
|
+
let inheritedTotal = null;
|
|
192
|
+
const usageEvents = [];
|
|
193
|
+
let lastModel = "";
|
|
194
|
+
let provider = "openai";
|
|
195
|
+
let lastEvTs = null;
|
|
196
|
+
|
|
197
|
+
const stream = createReadStream(path, { encoding: "utf8" });
|
|
145
198
|
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
146
199
|
for await (const line of rl) {
|
|
147
200
|
if (!line) continue;
|
|
148
201
|
let ev;
|
|
149
|
-
try {
|
|
202
|
+
try {
|
|
203
|
+
ev = JSON.parse(line);
|
|
204
|
+
} catch {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
150
207
|
const t = ev.type;
|
|
151
208
|
const p = ev.payload || {};
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
209
|
+
const evTs = parseIsoMs(ev.timestamp);
|
|
210
|
+
if (evTs) lastEvTs = evTs;
|
|
211
|
+
if (t === "session_meta") {
|
|
212
|
+
sessionMeta = { ts: ev.timestamp, cwd: p.cwd || p.CWD || "", id: typeof p.id === "string" ? p.id : null };
|
|
213
|
+
forkWaiting = Boolean(p.forked_from_id);
|
|
214
|
+
if (typeof p.model_provider === "string" && p.model_provider) provider = p.model_provider;
|
|
215
|
+
} else if (t === "turn_context") {
|
|
216
|
+
forkWaiting = false;
|
|
217
|
+
lastModel = p.model_info?.slug || p.model || p.model_name || lastModel;
|
|
218
|
+
} else if (t === "event_msg" && p.type === "token_count") {
|
|
155
219
|
const info = p.info || {};
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
const
|
|
220
|
+
const fields = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'reasoning_output_tokens'];
|
|
221
|
+
const normalize = (u) => u && Object.fromEntries(fields.map((field) => [field, tokenCount(u[field] ?? (field === 'cached_input_tokens' ? u.cache_read_input_tokens : 0))]));
|
|
222
|
+
const total = normalize(info.total_token_usage);
|
|
223
|
+
const last = normalize(info.last_token_usage);
|
|
224
|
+
if (forkWaiting) {
|
|
225
|
+
if (total) {
|
|
226
|
+
previous = total;
|
|
227
|
+
inheritedTotal = info.total_token_usage.total_tokens ?? null;
|
|
228
|
+
}
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (inheritedTotal !== null && info.total_token_usage?.total_tokens === inheritedTotal) {
|
|
232
|
+
if (total) previous = total;
|
|
233
|
+
inheritedTotal = null;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
inheritedTotal = null;
|
|
237
|
+
let delta = null;
|
|
238
|
+
if (total && previous) {
|
|
239
|
+
if (fields.every((field) => total[field] === previous[field])) continue;
|
|
240
|
+
const regressed = fields.some((field) => total[field] < previous[field]);
|
|
241
|
+
if (regressed) {
|
|
242
|
+
const sum = (u) => fields.reduce((n, field) => n + u[field], 0);
|
|
243
|
+
if (last && sum(total) > 0 && sum(last) > 0 && (sum(total) * 100 >= sum(previous) * 98 || sum(total) + sum(last) * 2 >= sum(previous))) continue;
|
|
244
|
+
delta = last;
|
|
245
|
+
} else {
|
|
246
|
+
delta = last || Object.fromEntries(fields.map((field) => [field, total[field] - previous[field]]));
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
delta = last || total;
|
|
250
|
+
}
|
|
251
|
+
if (total) previous = total;
|
|
252
|
+
else if (last && previous) previous = Object.fromEntries(fields.map((field) => [field, previous[field] + last[field]]));
|
|
253
|
+
const m = info.model || info.model_name || p.model;
|
|
159
254
|
if (m) lastModel = m;
|
|
255
|
+
if (delta) usageEvents.push({ model: lastModel, provider, aggregated: !last, usage: delta });
|
|
160
256
|
}
|
|
161
257
|
}
|
|
162
|
-
if (!sessionMeta || !
|
|
258
|
+
if (!sessionMeta || !usageEvents.length) return null;
|
|
163
259
|
|
|
164
260
|
const ts = parseIsoMs(sessionMeta.ts);
|
|
165
261
|
if (!ts) return null;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
262
|
+
const durationMs = lastEvTs && lastEvTs > ts ? lastEvTs - ts : 0;
|
|
263
|
+
|
|
264
|
+
let tokensInput = 0, tokensOutput = 0, tokensCacheRead = 0, tokensReasoning = 0;
|
|
265
|
+
for (const { usage } of usageEvents) {
|
|
266
|
+
const cached = Math.min(usage.input_tokens, usage.cached_input_tokens);
|
|
267
|
+
const reasoning = Math.min(usage.output_tokens, usage.reasoning_output_tokens);
|
|
268
|
+
tokensInput += usage.input_tokens - cached;
|
|
269
|
+
tokensOutput += usage.output_tokens - reasoning;
|
|
270
|
+
tokensCacheRead += cached;
|
|
271
|
+
tokensReasoning += reasoning;
|
|
272
|
+
}
|
|
171
273
|
const tot = tokensInput + tokensOutput + tokensCacheRead + tokensReasoning;
|
|
172
274
|
if (tot === 0) return null;
|
|
173
275
|
|
|
174
276
|
return {
|
|
175
|
-
tool:
|
|
176
|
-
sessionId:
|
|
277
|
+
tool: "codex",
|
|
278
|
+
sessionId: sessionMeta.id || basename(path).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\.jsonl$)/i)?.[0] || basename(path, ".jsonl"),
|
|
177
279
|
project: compactHome(sessionMeta.cwd),
|
|
178
|
-
title:
|
|
280
|
+
title: "",
|
|
179
281
|
week: isoWeekKey(ts),
|
|
180
282
|
month: monthKey(ts),
|
|
181
283
|
ts,
|
|
284
|
+
durationMs,
|
|
182
285
|
tokensInput,
|
|
183
286
|
tokensOutput,
|
|
184
287
|
tokensCacheRead,
|
|
185
|
-
tokensCacheWrite: 0,
|
|
288
|
+
tokensCacheWrite: 0, // codex doesn't expose cache_write separately
|
|
186
289
|
tokensReasoning,
|
|
187
290
|
tokensTotal: tot,
|
|
188
|
-
|
|
291
|
+
...estimateUsage("codex", usageEvents),
|
|
189
292
|
model: lastModel,
|
|
293
|
+
usageEvents,
|
|
190
294
|
};
|
|
191
295
|
}
|
|
192
296
|
|
|
@@ -194,11 +298,19 @@ function walkJsonl(root, prefix) {
|
|
|
194
298
|
const out = [];
|
|
195
299
|
function walk(dir) {
|
|
196
300
|
let entries;
|
|
197
|
-
try {
|
|
301
|
+
try {
|
|
302
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
303
|
+
} catch {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
198
306
|
for (const e of entries) {
|
|
199
307
|
const full = join(dir, e.name);
|
|
200
308
|
if (e.isDirectory()) walk(full);
|
|
201
|
-
else if (
|
|
309
|
+
else if (
|
|
310
|
+
e.isFile() &&
|
|
311
|
+
e.name.startsWith(prefix) &&
|
|
312
|
+
e.name.endsWith(".jsonl")
|
|
313
|
+
) {
|
|
202
314
|
out.push(full);
|
|
203
315
|
}
|
|
204
316
|
}
|
|
@@ -214,13 +326,17 @@ function walkClaudeSessions(root) {
|
|
|
214
326
|
const out = [];
|
|
215
327
|
function walk(dir) {
|
|
216
328
|
let entries;
|
|
217
|
-
try {
|
|
329
|
+
try {
|
|
330
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
331
|
+
} catch {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
218
334
|
for (const e of entries) {
|
|
219
335
|
const full = join(dir, e.name);
|
|
220
336
|
if (e.isDirectory()) {
|
|
221
|
-
if (e.name ===
|
|
337
|
+
if (e.name === "subagents") continue;
|
|
222
338
|
walk(full);
|
|
223
|
-
} else if (e.isFile() && e.name.endsWith(
|
|
339
|
+
} else if (e.isFile() && e.name.endsWith(".jsonl")) {
|
|
224
340
|
out.push(full);
|
|
225
341
|
}
|
|
226
342
|
}
|
|
@@ -235,56 +351,75 @@ function walkClaudeSessions(root) {
|
|
|
235
351
|
//
|
|
236
352
|
// Each top-level file under projects/<encoded-cwd>/<UUID>.jsonl is one session.
|
|
237
353
|
// Per assistant line, `message.usage` carries the per-turn token counts; we
|
|
238
|
-
//
|
|
239
|
-
// across unique messages. cwd / model / timestamp come from the line itself.
|
|
354
|
+
// merge streamed usage by message and request, then sum unique responses. cwd / model / timestamp come from the line itself.
|
|
240
355
|
|
|
241
356
|
async function loadClaudeJsonl(rootDir) {
|
|
242
|
-
const records = [];
|
|
243
357
|
const errors = [];
|
|
244
358
|
const files = walkClaudeSessions(rootDir);
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
359
|
+
const results = await mapConcurrent(
|
|
360
|
+
files,
|
|
361
|
+
async (f) => {
|
|
362
|
+
try {
|
|
363
|
+
return await parseClaudeSession(f);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
errors.push(`${f}: ${e.message}`);
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
48,
|
|
370
|
+
);
|
|
371
|
+
return { records: results.filter(Boolean), errors };
|
|
255
372
|
}
|
|
256
373
|
|
|
257
374
|
async function parseClaudeSession(path) {
|
|
258
|
-
const sessionId = path.split(
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
let
|
|
375
|
+
const sessionId = path.split("/").pop().replace(".jsonl", "");
|
|
376
|
+
const usages = new Map();
|
|
377
|
+
const usageModels = new Map();
|
|
378
|
+
let anonymous = 0;
|
|
379
|
+
let cwd = "";
|
|
380
|
+
let model = "";
|
|
262
381
|
let firstTs = null;
|
|
382
|
+
let lastTs = null;
|
|
383
|
+
let title = "";
|
|
263
384
|
|
|
264
385
|
let tokensInput = 0;
|
|
265
386
|
let tokensOutput = 0;
|
|
266
387
|
let tokensCacheRead = 0;
|
|
267
388
|
let tokensCacheWrite = 0;
|
|
268
389
|
|
|
269
|
-
const stream = createReadStream(path, { encoding:
|
|
390
|
+
const stream = createReadStream(path, { encoding: "utf8" });
|
|
270
391
|
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
271
392
|
for await (const line of rl) {
|
|
272
393
|
if (!line) continue;
|
|
273
394
|
let ev;
|
|
274
|
-
try {
|
|
275
|
-
|
|
395
|
+
try {
|
|
396
|
+
ev = JSON.parse(line);
|
|
397
|
+
} catch {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
// Grab summary title from the first human message that has one.
|
|
401
|
+
if (!title && ev.type === "summary" && typeof ev.summary === "string")
|
|
402
|
+
title = ev.summary;
|
|
403
|
+
if (ev.type !== "assistant") continue;
|
|
276
404
|
const msg = ev.message || {};
|
|
277
405
|
const usage = msg.usage;
|
|
278
406
|
if (!usage) continue;
|
|
279
407
|
const id = msg.id;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
408
|
+
const key = id ? JSON.stringify([id, ev.requestId || null]) : `anonymous:${anonymous++}`;
|
|
409
|
+
const previous = usages.get(key) || {};
|
|
410
|
+
const merged = {};
|
|
411
|
+
for (const field of ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"]) {
|
|
412
|
+
merged[field] = Math.max(tokenCount(previous[field]), tokenCount(usage[field]));
|
|
283
413
|
}
|
|
284
|
-
|
|
285
|
-
if (
|
|
286
|
-
|
|
287
|
-
if (
|
|
414
|
+
usages.set(key, merged);
|
|
415
|
+
if (msg.model) usageModels.set(key, msg.model);
|
|
416
|
+
if (!cwd && typeof ev.cwd === "string") cwd = ev.cwd;
|
|
417
|
+
if (!model && typeof msg.model === "string") model = msg.model;
|
|
418
|
+
const evTs = parseIsoMs(ev.timestamp);
|
|
419
|
+
if (firstTs === null && evTs !== null) firstTs = evTs;
|
|
420
|
+
if (evTs !== null) lastTs = evTs;
|
|
421
|
+
}
|
|
422
|
+
for (const usage of usages.values()) {
|
|
288
423
|
tokensInput += usage.input_tokens || 0;
|
|
289
424
|
tokensOutput += usage.output_tokens || 0;
|
|
290
425
|
tokensCacheRead += usage.cache_read_input_tokens || 0;
|
|
@@ -294,23 +429,27 @@ async function parseClaudeSession(path) {
|
|
|
294
429
|
const tot = tokensInput + tokensOutput + tokensCacheRead + tokensCacheWrite;
|
|
295
430
|
if (tot === 0) return null;
|
|
296
431
|
const ts = firstTs ?? Date.now();
|
|
432
|
+
const durationMs = firstTs !== null && lastTs !== null ? lastTs - firstTs : 0;
|
|
297
433
|
|
|
434
|
+
const usageEvents = [...usages].map(([key, usage]) => ({ model: usageModels.get(key) || "", usage }));
|
|
298
435
|
return {
|
|
299
|
-
tool:
|
|
436
|
+
tool: "claude",
|
|
300
437
|
sessionId,
|
|
301
438
|
project: compactHome(cwd),
|
|
302
|
-
title
|
|
439
|
+
title,
|
|
303
440
|
week: isoWeekKey(ts),
|
|
304
441
|
month: monthKey(ts),
|
|
305
442
|
ts,
|
|
443
|
+
durationMs,
|
|
306
444
|
tokensInput,
|
|
307
445
|
tokensOutput,
|
|
308
446
|
tokensCacheRead,
|
|
309
447
|
tokensCacheWrite,
|
|
310
448
|
tokensReasoning: 0,
|
|
311
449
|
tokensTotal: tot,
|
|
312
|
-
|
|
450
|
+
...estimateUsage("claude", usageEvents),
|
|
313
451
|
model,
|
|
452
|
+
usageEvents,
|
|
314
453
|
};
|
|
315
454
|
}
|
|
316
455
|
|
|
@@ -322,24 +461,29 @@ export async function loadAll(detections) {
|
|
|
322
461
|
const all = [];
|
|
323
462
|
const errors = [];
|
|
324
463
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if (
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
464
|
+
const tasks = detections
|
|
465
|
+
.filter((d) => d.status === "present")
|
|
466
|
+
.map((d) => {
|
|
467
|
+
if (d.key === "opencode" || d.key === "mimocode") {
|
|
468
|
+
return Promise.resolve({
|
|
469
|
+
d,
|
|
470
|
+
r: loadOpencodeStyleSqlite(d.path, d.key),
|
|
471
|
+
});
|
|
472
|
+
} else if (d.key === "codex") {
|
|
473
|
+
return loadCodexRollouts(d.path).then((r) => ({ d, r }));
|
|
474
|
+
} else if (d.key === "claude") {
|
|
475
|
+
return loadClaudeJsonl(d.path).then((r) => ({ d, r }));
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
478
|
+
})
|
|
479
|
+
.filter(Boolean);
|
|
480
|
+
|
|
481
|
+
for (const item of await Promise.all(tasks)) {
|
|
482
|
+
const { d, r } = item;
|
|
483
|
+
all.push(...r.records);
|
|
484
|
+
if (r.error) errors.push(`${d.name}: ${r.error}`);
|
|
485
|
+
if (r.info) errors.push(`${d.name}: ${r.info}`);
|
|
486
|
+
if (r.errors) errors.push(...r.errors);
|
|
343
487
|
}
|
|
344
488
|
return { records: all, errors };
|
|
345
489
|
}
|
|
@@ -350,7 +494,8 @@ export async function loadAll(detections) {
|
|
|
350
494
|
|
|
351
495
|
export function dateRange(records) {
|
|
352
496
|
if (records.length === 0) return [null, null];
|
|
353
|
-
let min = Infinity,
|
|
497
|
+
let min = Infinity,
|
|
498
|
+
max = -Infinity;
|
|
354
499
|
for (const r of records) {
|
|
355
500
|
if (r.ts < min) min = r.ts;
|
|
356
501
|
if (r.ts > max) max = r.ts;
|