ai-usage-analyzer 0.3.1 → 0.4.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 +9 -7
- package/bin/ai-usage.js +53 -30
- package/package.json +1 -1
- package/src/aggregate.js +41 -21
- package/src/loaders.js +173 -94
- package/src/markdown.js +127 -66
- package/src/render.js +507 -180
- package/src/tui.js +310 -0
package/src/loaders.js
CHANGED
|
@@ -15,11 +15,27 @@
|
|
|
15
15
|
// model: string,
|
|
16
16
|
// }
|
|
17
17
|
|
|
18
|
-
import {
|
|
19
|
-
import { join } from
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
18
|
+
import { createReadStream, readdirSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { createInterface } from "node:readline";
|
|
21
|
+
import { DatabaseSync } from "node:sqlite";
|
|
22
|
+
|
|
23
|
+
// Run fn over every item in items with at most `limit` in-flight at once.
|
|
24
|
+
async function mapConcurrent(items, fn, limit) {
|
|
25
|
+
if (items.length === 0) return [];
|
|
26
|
+
const results = new Array(items.length);
|
|
27
|
+
let next = 0;
|
|
28
|
+
async function worker() {
|
|
29
|
+
while (next < items.length) {
|
|
30
|
+
const i = next++;
|
|
31
|
+
results[i] = await fn(items[i]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
await Promise.all(
|
|
35
|
+
Array.from({ length: Math.min(limit, items.length) }, worker),
|
|
36
|
+
);
|
|
37
|
+
return results;
|
|
38
|
+
}
|
|
23
39
|
|
|
24
40
|
// ---------------------------------------------------------------------------
|
|
25
41
|
// Date helpers
|
|
@@ -28,17 +44,24 @@ import { createReadStream } from 'node:fs';
|
|
|
28
44
|
function isoWeekKey(ts) {
|
|
29
45
|
const d = new Date(ts);
|
|
30
46
|
// ISO week: Thu in current week decides the year
|
|
31
|
-
const target = new Date(
|
|
47
|
+
const target = new Date(
|
|
48
|
+
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
|
|
49
|
+
);
|
|
32
50
|
const dayNum = (target.getUTCDay() + 6) % 7; // Mon=0..Sun=6
|
|
33
51
|
target.setUTCDate(target.getUTCDate() - dayNum + 3); // Thu
|
|
34
52
|
const firstThu = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
|
|
35
|
-
const week =
|
|
36
|
-
|
|
53
|
+
const week =
|
|
54
|
+
1 +
|
|
55
|
+
Math.round(
|
|
56
|
+
((target - firstThu) / 86400000 - 3 + ((firstThu.getUTCDay() + 6) % 7)) /
|
|
57
|
+
7,
|
|
58
|
+
);
|
|
59
|
+
return `${target.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
37
60
|
}
|
|
38
61
|
|
|
39
62
|
function monthKey(ts) {
|
|
40
63
|
const d = new Date(ts);
|
|
41
|
-
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2,
|
|
64
|
+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
42
65
|
}
|
|
43
66
|
|
|
44
67
|
function parseIsoMs(s) {
|
|
@@ -48,9 +71,9 @@ function parseIsoMs(s) {
|
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
function compactHome(p) {
|
|
51
|
-
if (!p) return
|
|
52
|
-
const home = process.env.HOME ||
|
|
53
|
-
return p.startsWith(home) ?
|
|
74
|
+
if (!p) return "(unknown)";
|
|
75
|
+
const home = process.env.HOME || "";
|
|
76
|
+
return p.startsWith(home) ? "~" + p.slice(home.length) : p;
|
|
54
77
|
}
|
|
55
78
|
|
|
56
79
|
// ---------------------------------------------------------------------------
|
|
@@ -67,40 +90,53 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
|
67
90
|
|
|
68
91
|
// Introspect session table for token columns (MimoCode schema is a subset)
|
|
69
92
|
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
|
-
|
|
93
|
+
const colSet = new Set(cols.map((c) => c.name));
|
|
94
|
+
const hasTokens =
|
|
95
|
+
colSet.has("tokens_input") &&
|
|
96
|
+
colSet.has("tokens_output") &&
|
|
97
|
+
colSet.has("tokens_cache_read") &&
|
|
98
|
+
colSet.has("tokens_cache_write");
|
|
75
99
|
|
|
76
100
|
if (!hasTokens) {
|
|
77
101
|
db.close();
|
|
78
|
-
return { records: [], info:
|
|
102
|
+
return { records: [], info: "session table has no token columns" };
|
|
79
103
|
}
|
|
80
104
|
|
|
81
|
-
const
|
|
105
|
+
const hasTimeUpdated = colSet.has("time_updated");
|
|
106
|
+
const rows = db
|
|
107
|
+
.prepare(
|
|
108
|
+
`
|
|
82
109
|
SELECT id, directory, title, time_created,
|
|
110
|
+
${hasTimeUpdated ? "time_updated," : ""}
|
|
83
111
|
tokens_input, tokens_output, tokens_reasoning,
|
|
84
112
|
tokens_cache_read, tokens_cache_write, cost, model
|
|
85
113
|
FROM session
|
|
86
114
|
WHERE time_archived IS NULL
|
|
87
|
-
|
|
115
|
+
`,
|
|
116
|
+
)
|
|
117
|
+
.all();
|
|
88
118
|
db.close();
|
|
89
119
|
|
|
90
120
|
const records = [];
|
|
91
121
|
for (const r of rows) {
|
|
92
|
-
const tot =
|
|
93
|
-
|
|
94
|
-
|
|
122
|
+
const tot =
|
|
123
|
+
(r.tokens_input || 0) +
|
|
124
|
+
(r.tokens_output || 0) +
|
|
125
|
+
(r.tokens_reasoning || 0) +
|
|
126
|
+
(r.tokens_cache_read || 0) +
|
|
127
|
+
(r.tokens_cache_write || 0);
|
|
95
128
|
if (tot === 0) continue;
|
|
96
129
|
records.push({
|
|
97
130
|
tool: toolKey,
|
|
98
131
|
sessionId: r.id,
|
|
99
132
|
project: compactHome(r.directory),
|
|
100
|
-
title: r.title ||
|
|
133
|
+
title: r.title || "",
|
|
101
134
|
week: isoWeekKey(r.time_created),
|
|
102
135
|
month: monthKey(r.time_created),
|
|
103
136
|
ts: r.time_created,
|
|
137
|
+
durationMs: hasTimeUpdated
|
|
138
|
+
? Math.max(0, (r.time_updated || r.time_created) - r.time_created)
|
|
139
|
+
: 0,
|
|
104
140
|
tokensInput: r.tokens_input || 0,
|
|
105
141
|
tokensOutput: r.tokens_output || 0,
|
|
106
142
|
tokensCacheRead: r.tokens_cache_read || 0,
|
|
@@ -108,7 +144,7 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
|
108
144
|
tokensReasoning: r.tokens_reasoning || 0,
|
|
109
145
|
tokensTotal: tot,
|
|
110
146
|
cost: r.cost || 0,
|
|
111
|
-
model: r.model ||
|
|
147
|
+
model: r.model || "",
|
|
112
148
|
});
|
|
113
149
|
}
|
|
114
150
|
return { records };
|
|
@@ -119,19 +155,21 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
|
119
155
|
// ---------------------------------------------------------------------------
|
|
120
156
|
|
|
121
157
|
async function loadCodexRollouts(rootDir) {
|
|
122
|
-
const records = [];
|
|
123
158
|
const errors = [];
|
|
124
|
-
const files = walkJsonl(rootDir,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
159
|
+
const files = walkJsonl(rootDir, "rollout-");
|
|
160
|
+
const results = await mapConcurrent(
|
|
161
|
+
files,
|
|
162
|
+
async (f) => {
|
|
163
|
+
try {
|
|
164
|
+
return await parseCodexRollout(f);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
errors.push(`${f}: ${e.message}`);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
48,
|
|
171
|
+
);
|
|
172
|
+
return { records: results.filter(Boolean), errors };
|
|
135
173
|
}
|
|
136
174
|
|
|
137
175
|
async function parseCodexRollout(path) {
|
|
@@ -139,19 +177,26 @@ async function parseCodexRollout(path) {
|
|
|
139
177
|
// The 'token_count' event accumulates usage; we take the LAST one (final snapshot).
|
|
140
178
|
let sessionMeta = null;
|
|
141
179
|
let lastUsage = null;
|
|
142
|
-
let lastModel =
|
|
180
|
+
let lastModel = "";
|
|
181
|
+
let lastEvTs = null;
|
|
143
182
|
|
|
144
|
-
const stream = createReadStream(path, { encoding:
|
|
183
|
+
const stream = createReadStream(path, { encoding: "utf8" });
|
|
145
184
|
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
146
185
|
for await (const line of rl) {
|
|
147
186
|
if (!line) continue;
|
|
148
187
|
let ev;
|
|
149
|
-
try {
|
|
188
|
+
try {
|
|
189
|
+
ev = JSON.parse(line);
|
|
190
|
+
} catch {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
150
193
|
const t = ev.type;
|
|
151
194
|
const p = ev.payload || {};
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
195
|
+
const evTs = parseIsoMs(ev.timestamp);
|
|
196
|
+
if (evTs) lastEvTs = evTs;
|
|
197
|
+
if (t === "session_meta") {
|
|
198
|
+
sessionMeta = { ts: ev.timestamp, cwd: p.cwd || p.CWD || "" };
|
|
199
|
+
} else if (t === "event_msg" && p.type === "token_count") {
|
|
155
200
|
const info = p.info || {};
|
|
156
201
|
const u = info.total_token_usage;
|
|
157
202
|
if (u) lastUsage = u;
|
|
@@ -163,6 +208,7 @@ async function parseCodexRollout(path) {
|
|
|
163
208
|
|
|
164
209
|
const ts = parseIsoMs(sessionMeta.ts);
|
|
165
210
|
if (!ts) return null;
|
|
211
|
+
const durationMs = lastEvTs && lastEvTs > ts ? lastEvTs - ts : 0;
|
|
166
212
|
|
|
167
213
|
const tokensInput = lastUsage.input_tokens || 0;
|
|
168
214
|
const tokensOutput = lastUsage.output_tokens || 0;
|
|
@@ -172,17 +218,18 @@ async function parseCodexRollout(path) {
|
|
|
172
218
|
if (tot === 0) return null;
|
|
173
219
|
|
|
174
220
|
return {
|
|
175
|
-
tool:
|
|
176
|
-
sessionId: path.split(
|
|
221
|
+
tool: "codex",
|
|
222
|
+
sessionId: path.split("-").pop().replace(".jsonl", ""),
|
|
177
223
|
project: compactHome(sessionMeta.cwd),
|
|
178
|
-
title:
|
|
224
|
+
title: "",
|
|
179
225
|
week: isoWeekKey(ts),
|
|
180
226
|
month: monthKey(ts),
|
|
181
227
|
ts,
|
|
228
|
+
durationMs,
|
|
182
229
|
tokensInput,
|
|
183
230
|
tokensOutput,
|
|
184
231
|
tokensCacheRead,
|
|
185
|
-
tokensCacheWrite: 0,
|
|
232
|
+
tokensCacheWrite: 0, // codex doesn't expose cache_write separately
|
|
186
233
|
tokensReasoning,
|
|
187
234
|
tokensTotal: tot,
|
|
188
235
|
cost: 0,
|
|
@@ -194,11 +241,19 @@ function walkJsonl(root, prefix) {
|
|
|
194
241
|
const out = [];
|
|
195
242
|
function walk(dir) {
|
|
196
243
|
let entries;
|
|
197
|
-
try {
|
|
244
|
+
try {
|
|
245
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
246
|
+
} catch {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
198
249
|
for (const e of entries) {
|
|
199
250
|
const full = join(dir, e.name);
|
|
200
251
|
if (e.isDirectory()) walk(full);
|
|
201
|
-
else if (
|
|
252
|
+
else if (
|
|
253
|
+
e.isFile() &&
|
|
254
|
+
e.name.startsWith(prefix) &&
|
|
255
|
+
e.name.endsWith(".jsonl")
|
|
256
|
+
) {
|
|
202
257
|
out.push(full);
|
|
203
258
|
}
|
|
204
259
|
}
|
|
@@ -214,13 +269,17 @@ function walkClaudeSessions(root) {
|
|
|
214
269
|
const out = [];
|
|
215
270
|
function walk(dir) {
|
|
216
271
|
let entries;
|
|
217
|
-
try {
|
|
272
|
+
try {
|
|
273
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
274
|
+
} catch {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
218
277
|
for (const e of entries) {
|
|
219
278
|
const full = join(dir, e.name);
|
|
220
279
|
if (e.isDirectory()) {
|
|
221
|
-
if (e.name ===
|
|
280
|
+
if (e.name === "subagents") continue;
|
|
222
281
|
walk(full);
|
|
223
|
-
} else if (e.isFile() && e.name.endsWith(
|
|
282
|
+
} else if (e.isFile() && e.name.endsWith(".jsonl")) {
|
|
224
283
|
out.push(full);
|
|
225
284
|
}
|
|
226
285
|
}
|
|
@@ -239,40 +298,51 @@ function walkClaudeSessions(root) {
|
|
|
239
298
|
// across unique messages. cwd / model / timestamp come from the line itself.
|
|
240
299
|
|
|
241
300
|
async function loadClaudeJsonl(rootDir) {
|
|
242
|
-
const records = [];
|
|
243
301
|
const errors = [];
|
|
244
302
|
const files = walkClaudeSessions(rootDir);
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
303
|
+
const results = await mapConcurrent(
|
|
304
|
+
files,
|
|
305
|
+
async (f) => {
|
|
306
|
+
try {
|
|
307
|
+
return await parseClaudeSession(f);
|
|
308
|
+
} catch (e) {
|
|
309
|
+
errors.push(`${f}: ${e.message}`);
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
48,
|
|
314
|
+
);
|
|
315
|
+
return { records: results.filter(Boolean), errors };
|
|
255
316
|
}
|
|
256
317
|
|
|
257
318
|
async function parseClaudeSession(path) {
|
|
258
|
-
const sessionId = path.split(
|
|
319
|
+
const sessionId = path.split("/").pop().replace(".jsonl", "");
|
|
259
320
|
const seenIds = new Set();
|
|
260
|
-
let cwd =
|
|
261
|
-
let model =
|
|
321
|
+
let cwd = "";
|
|
322
|
+
let model = "";
|
|
262
323
|
let firstTs = null;
|
|
324
|
+
let lastTs = null;
|
|
325
|
+
let title = "";
|
|
263
326
|
|
|
264
327
|
let tokensInput = 0;
|
|
265
328
|
let tokensOutput = 0;
|
|
266
329
|
let tokensCacheRead = 0;
|
|
267
330
|
let tokensCacheWrite = 0;
|
|
268
331
|
|
|
269
|
-
const stream = createReadStream(path, { encoding:
|
|
332
|
+
const stream = createReadStream(path, { encoding: "utf8" });
|
|
270
333
|
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
271
334
|
for await (const line of rl) {
|
|
272
335
|
if (!line) continue;
|
|
273
336
|
let ev;
|
|
274
|
-
try {
|
|
275
|
-
|
|
337
|
+
try {
|
|
338
|
+
ev = JSON.parse(line);
|
|
339
|
+
} catch {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
// Grab summary title from the first human message that has one.
|
|
343
|
+
if (!title && ev.type === "summary" && typeof ev.summary === "string")
|
|
344
|
+
title = ev.summary;
|
|
345
|
+
if (ev.type !== "assistant") continue;
|
|
276
346
|
const msg = ev.message || {};
|
|
277
347
|
const usage = msg.usage;
|
|
278
348
|
if (!usage) continue;
|
|
@@ -281,10 +351,11 @@ async function parseClaudeSession(path) {
|
|
|
281
351
|
if (seenIds.has(id)) continue;
|
|
282
352
|
seenIds.add(id);
|
|
283
353
|
}
|
|
284
|
-
if (!cwd && typeof ev.cwd ===
|
|
285
|
-
if (!model && typeof msg.model ===
|
|
286
|
-
const
|
|
287
|
-
if (firstTs === null &&
|
|
354
|
+
if (!cwd && typeof ev.cwd === "string") cwd = ev.cwd;
|
|
355
|
+
if (!model && typeof msg.model === "string") model = msg.model;
|
|
356
|
+
const evTs = parseIsoMs(ev.timestamp);
|
|
357
|
+
if (firstTs === null && evTs !== null) firstTs = evTs;
|
|
358
|
+
if (evTs !== null) lastTs = evTs;
|
|
288
359
|
tokensInput += usage.input_tokens || 0;
|
|
289
360
|
tokensOutput += usage.output_tokens || 0;
|
|
290
361
|
tokensCacheRead += usage.cache_read_input_tokens || 0;
|
|
@@ -294,15 +365,17 @@ async function parseClaudeSession(path) {
|
|
|
294
365
|
const tot = tokensInput + tokensOutput + tokensCacheRead + tokensCacheWrite;
|
|
295
366
|
if (tot === 0) return null;
|
|
296
367
|
const ts = firstTs ?? Date.now();
|
|
368
|
+
const durationMs = firstTs !== null && lastTs !== null ? lastTs - firstTs : 0;
|
|
297
369
|
|
|
298
370
|
return {
|
|
299
|
-
tool:
|
|
371
|
+
tool: "claude",
|
|
300
372
|
sessionId,
|
|
301
373
|
project: compactHome(cwd),
|
|
302
|
-
title
|
|
374
|
+
title,
|
|
303
375
|
week: isoWeekKey(ts),
|
|
304
376
|
month: monthKey(ts),
|
|
305
377
|
ts,
|
|
378
|
+
durationMs,
|
|
306
379
|
tokensInput,
|
|
307
380
|
tokensOutput,
|
|
308
381
|
tokensCacheRead,
|
|
@@ -322,24 +395,29 @@ export async function loadAll(detections) {
|
|
|
322
395
|
const all = [];
|
|
323
396
|
const errors = [];
|
|
324
397
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if (
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
398
|
+
const tasks = detections
|
|
399
|
+
.filter((d) => d.status === "present")
|
|
400
|
+
.map((d) => {
|
|
401
|
+
if (d.key === "opencode" || d.key === "mimocode") {
|
|
402
|
+
return Promise.resolve({
|
|
403
|
+
d,
|
|
404
|
+
r: loadOpencodeStyleSqlite(d.path, d.key),
|
|
405
|
+
});
|
|
406
|
+
} else if (d.key === "codex") {
|
|
407
|
+
return loadCodexRollouts(d.path).then((r) => ({ d, r }));
|
|
408
|
+
} else if (d.key === "claude") {
|
|
409
|
+
return loadClaudeJsonl(d.path).then((r) => ({ d, r }));
|
|
410
|
+
}
|
|
411
|
+
return null;
|
|
412
|
+
})
|
|
413
|
+
.filter(Boolean);
|
|
414
|
+
|
|
415
|
+
for (const item of await Promise.all(tasks)) {
|
|
416
|
+
const { d, r } = item;
|
|
417
|
+
all.push(...r.records);
|
|
418
|
+
if (r.error) errors.push(`${d.name}: ${r.error}`);
|
|
419
|
+
if (r.info) errors.push(`${d.name}: ${r.info}`);
|
|
420
|
+
if (r.errors) errors.push(...r.errors);
|
|
343
421
|
}
|
|
344
422
|
return { records: all, errors };
|
|
345
423
|
}
|
|
@@ -350,7 +428,8 @@ export async function loadAll(detections) {
|
|
|
350
428
|
|
|
351
429
|
export function dateRange(records) {
|
|
352
430
|
if (records.length === 0) return [null, null];
|
|
353
|
-
let min = Infinity,
|
|
431
|
+
let min = Infinity,
|
|
432
|
+
max = -Infinity;
|
|
354
433
|
for (const r of records) {
|
|
355
434
|
if (r.ts < min) min = r.ts;
|
|
356
435
|
if (r.ts > max) max = r.ts;
|