ai-usage-analyzer 0.3.2 → 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/src/loaders.js CHANGED
@@ -15,11 +15,27 @@
15
15
  // model: string,
16
16
  // }
17
17
 
18
- import { readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
19
- import { join } from 'node:path';
20
- import { DatabaseSync } from 'node:sqlite';
21
- import { createInterface } from 'node:readline';
22
- import { createReadStream } from 'node:fs';
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(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
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 = 1 + Math.round(((target - firstThu) / 86400000 - 3 + ((firstThu.getUTCDay() + 6) % 7)) / 7);
36
- return `${target.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
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, '0')}`;
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 '(unknown)';
52
- const home = process.env.HOME || '';
53
- return p.startsWith(home) ? '~' + p.slice(home.length) : p;
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 = colSet.has('tokens_input')
72
- && colSet.has('tokens_output')
73
- && colSet.has('tokens_cache_read')
74
- && colSet.has('tokens_cache_write');
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: 'session table has no token columns' };
102
+ return { records: [], info: "session table has no token columns" };
79
103
  }
80
104
 
81
- const rows = db.prepare(`
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
- `).all();
115
+ `,
116
+ )
117
+ .all();
88
118
  db.close();
89
119
 
90
120
  const records = [];
91
121
  for (const r of rows) {
92
- const tot = (r.tokens_input || 0) + (r.tokens_output || 0)
93
- + (r.tokens_reasoning || 0) + (r.tokens_cache_read || 0)
94
- + (r.tokens_cache_write || 0);
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, 'rollout-');
125
-
126
- for (const f of files) {
127
- try {
128
- const rec = await parseCodexRollout(f);
129
- if (rec) records.push(rec);
130
- } catch (e) {
131
- errors.push(`${f}: ${e.message}`);
132
- }
133
- }
134
- return { records, errors };
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: 'utf8' });
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 { ev = JSON.parse(line); } catch { continue; }
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
- if (t === 'session_meta') {
153
- sessionMeta = { ts: ev.timestamp, cwd: p.cwd || p.CWD || '' };
154
- } else if (t === 'event_msg' && p.type === 'token_count') {
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: 'codex',
176
- sessionId: path.split('-').pop().replace('.jsonl', ''),
221
+ tool: "codex",
222
+ sessionId: path.split("-").pop().replace(".jsonl", ""),
177
223
  project: compactHome(sessionMeta.cwd),
178
- title: '', // not available in codex rollouts
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, // codex doesn't expose cache_write separately
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 { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
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 (e.isFile() && e.name.startsWith(prefix) && e.name.endsWith('.jsonl')) {
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 { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
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 === 'subagents') continue;
280
+ if (e.name === "subagents") continue;
222
281
  walk(full);
223
- } else if (e.isFile() && e.name.endsWith('.jsonl')) {
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
- for (const f of files) {
247
- try {
248
- const rec = await parseClaudeSession(f);
249
- if (rec) records.push(rec);
250
- } catch (e) {
251
- errors.push(`${f}: ${e.message}`);
252
- }
253
- }
254
- return { records, errors };
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('/').pop().replace('.jsonl', '');
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: 'utf8' });
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 { ev = JSON.parse(line); } catch { continue; }
275
- if (ev.type !== 'assistant') continue;
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 === 'string') cwd = ev.cwd;
285
- if (!model && typeof msg.model === 'string') model = msg.model;
286
- const ts = parseIsoMs(ev.timestamp);
287
- if (firstTs === null && ts !== null) firstTs = ts;
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: 'claude',
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
- for (const d of detections) {
326
- if (d.status !== 'present') continue;
327
- if (d.key === 'opencode' || d.key === 'mimocode') {
328
- const r = loadOpencodeStyleSqlite(d.path, d.key);
329
- all.push(...r.records);
330
- if (r.error) errors.push(`${d.name}: ${r.error}`);
331
- if (r.info) errors.push(`${d.name}: ${r.info}`);
332
- } else if (d.key === 'codex') {
333
- const r = await loadCodexRollouts(d.path);
334
- all.push(...r.records);
335
- if (r.errors) errors.push(...r.errors);
336
- } else if (d.key === 'claude') {
337
- const r = await loadClaudeJsonl(d.path);
338
- all.push(...r.records);
339
- if (r.errors) errors.push(...r.errors);
340
- }
341
- // For other tools (copilot, antigravity, gemini), we only have
342
- // presence info — no token data to load.
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, max = -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;