ai-usage-analyzer 0.1.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.
@@ -0,0 +1,300 @@
1
+ // Auto-detect AI coding agent data directories.
2
+ //
3
+ // Strategy:
4
+ // 1. Honor $AI_USAGE_PATHS_JSON if set (JSON map of { toolKey: "/abs/path" })
5
+ // 2. Honor per-tool env var (e.g. $CLAUDE_HOME, $CODEX_HOME, $OPENCODE_HOME)
6
+ // 3. Probe well-known locations per platform (mac/linux) relative to $HOME
7
+ // 4. Return a status for each tool: 'present' | 'absent' | 'disabled'
8
+ //
9
+ // Tools that share the opencode SQLite schema are auto-registered.
10
+
11
+ import { existsSync, statSync, readdirSync } from 'node:fs';
12
+ import { join, isAbsolute } from 'node:path';
13
+ import { homedir, platform } from 'node:os';
14
+ import { env, exitCode } from 'node:process';
15
+ import { createRequire } from 'node:module';
16
+ const require = createRequire(import.meta.url);
17
+
18
+ const HOME = homedir();
19
+ const OS = platform(); // 'darwin' | 'linux' | 'win32'
20
+
21
+ // macOS Application Support helper
22
+ const APP_SUPPORT = OS === 'darwin'
23
+ ? join(HOME, 'Library', 'Application Support')
24
+ : process.env.XDG_DATA_HOME
25
+ ? join(process.env.XDG_DATA_HOME, '..') // XDG_DATA_HOME/../ = ~/.local/share
26
+ : join(HOME, '.local', 'share');
27
+
28
+ const CONFIG_HOME = process.env.XDG_CONFIG_HOME || join(HOME, '.config');
29
+
30
+ // Probe = array of candidate paths; first one that exists wins.
31
+ function firstExisting(paths) {
32
+ for (const p of paths) {
33
+ if (p && existsSync(p)) return p;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ function isFile(p) {
39
+ try { return statSync(p).isFile(); } catch { return false; }
40
+ }
41
+ function isDir(p) {
42
+ try { return statSync(p).isDirectory(); } catch { return false; }
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Detector definitions. Each entry returns:
47
+ // { key, name, kind, status, path, count, details }
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const DETECTORS = [
51
+ // -----------------------------------------------------------------------
52
+ // Claude Code
53
+ // -----------------------------------------------------------------------
54
+ {
55
+ key: 'claude',
56
+ name: 'Claude Code',
57
+ kind: 'jsonl',
58
+ envVar: 'CLAUDE_HOME',
59
+ candidatePaths: () => {
60
+ const base = env.CLAUDE_HOME || join(HOME, '.claude');
61
+ return [
62
+ join(base, 'transcripts'),
63
+ join(base, 'projects'),
64
+ ];
65
+ },
66
+ count: (p) => {
67
+ if (!p) return 0;
68
+ let n = 0;
69
+ try {
70
+ for (const f of readdirSync(p)) {
71
+ if (f.startsWith('ses_') && f.endsWith('.jsonl')) n++;
72
+ }
73
+ } catch {}
74
+ return n;
75
+ },
76
+ hasTokens: false, // transcripts only contain text, no token counts
77
+ description: '~/.claude/transcripts/*.jsonl (no token data stored locally)',
78
+ },
79
+
80
+ // -----------------------------------------------------------------------
81
+ // Codex
82
+ // -----------------------------------------------------------------------
83
+ {
84
+ key: 'codex',
85
+ name: 'Codex',
86
+ kind: 'jsonl-rollout',
87
+ envVar: 'CODEX_HOME',
88
+ candidatePaths: () => [
89
+ env.CODEX_HOME || join(HOME, '.codex', 'sessions'),
90
+ ],
91
+ count: (p) => {
92
+ if (!p) return 0;
93
+ let n = 0;
94
+ function walk(dir) {
95
+ try {
96
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
97
+ const full = join(dir, e.name);
98
+ if (e.isDirectory()) walk(full);
99
+ else if (e.isFile() && e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) n++;
100
+ }
101
+ } catch {}
102
+ }
103
+ walk(p);
104
+ return n;
105
+ },
106
+ hasTokens: true,
107
+ description: '~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl',
108
+ },
109
+
110
+ // -----------------------------------------------------------------------
111
+ // OpenCode
112
+ // -----------------------------------------------------------------------
113
+ {
114
+ key: 'opencode',
115
+ name: 'OpenCode',
116
+ kind: 'sqlite',
117
+ envVar: 'OPENCODE_HOME',
118
+ candidatePaths: () => [
119
+ env.OPENCODE_HOME
120
+ ? join(env.OPENCODE_HOME, 'opencode.db')
121
+ : join(HOME, '.local', 'share', 'opencode', 'opencode.db'),
122
+ ],
123
+ count: (p) => {
124
+ if (!p) return 0;
125
+ try {
126
+ const { DatabaseSync } = require('node:sqlite');
127
+ const db = new DatabaseSync(p, { readOnly: true });
128
+ return db.prepare('SELECT COUNT(*) AS c FROM session').get().c;
129
+ } catch { return 0; }
130
+ },
131
+ hasTokens: true,
132
+ description: '~/.local/share/opencode/opencode.db (tokens + cost)',
133
+ },
134
+
135
+ // -----------------------------------------------------------------------
136
+ // MimoCode (same schema as OpenCode)
137
+ // -----------------------------------------------------------------------
138
+ {
139
+ key: 'mimocode',
140
+ name: 'MimoCode',
141
+ kind: 'sqlite',
142
+ envVar: 'MIMOCODE_HOME',
143
+ candidatePaths: () => [
144
+ env.MIMOCODE_HOME
145
+ ? join(env.MIMOCODE_HOME, 'mimocode.db')
146
+ : join(HOME, '.local', 'share', 'mimocode', 'mimocode.db'),
147
+ ],
148
+ count: (p) => {
149
+ if (!p) return 0;
150
+ try {
151
+ const { DatabaseSync } = require('node:sqlite');
152
+ const db = new DatabaseSync(p, { readOnly: true });
153
+ return db.prepare('SELECT COUNT(*) AS c FROM session').get().c;
154
+ } catch { return 0; }
155
+ },
156
+ hasTokens: true,
157
+ description: '~/.local/share/mimocode/mimocode.db (tokens + cost)',
158
+ },
159
+
160
+ // -----------------------------------------------------------------------
161
+ // GitHub Copilot CLI
162
+ // -----------------------------------------------------------------------
163
+ {
164
+ key: 'copilot',
165
+ name: 'GitHub Copilot',
166
+ kind: 'jsonl-events',
167
+ envVar: 'COPILOT_HOME',
168
+ candidatePaths: () => {
169
+ const base = env.COPILOT_HOME || join(HOME, '.copilot');
170
+ return [
171
+ join(base, 'session-state'),
172
+ base,
173
+ ];
174
+ },
175
+ count: (p) => {
176
+ if (!p) return 0;
177
+ let n = 0;
178
+ function walk(dir) {
179
+ try {
180
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
181
+ const full = join(dir, e.name);
182
+ if (e.isDirectory()) walk(full);
183
+ else if (e.isFile() && e.name.endsWith('.jsonl')) n++;
184
+ }
185
+ } catch {}
186
+ }
187
+ walk(p);
188
+ return n;
189
+ },
190
+ hasTokens: false,
191
+ description: '~/.copilot/session-state/*/events.jsonl (no token data)',
192
+ },
193
+
194
+ // -----------------------------------------------------------------------
195
+ // Antigravity (VS Code variant) - mostly cache; no token data
196
+ // -----------------------------------------------------------------------
197
+ {
198
+ key: 'antigravity',
199
+ name: 'Antigravity',
200
+ kind: 'dir',
201
+ envVar: 'ANTIGRAVITY_HOME',
202
+ candidatePaths: () => {
203
+ const base = env.ANTIGRAVITY_HOME || join(APP_SUPPORT, 'Antigravity');
204
+ return [
205
+ base,
206
+ join(HOME, '.antigravity'),
207
+ ];
208
+ },
209
+ count: (p) => {
210
+ if (!p) return 0;
211
+ let n = 0;
212
+ try {
213
+ for (const e of readdirSync(p)) {
214
+ const full = join(p, e);
215
+ if (statSync(full).isDirectory()) n++;
216
+ }
217
+ } catch {}
218
+ return n;
219
+ },
220
+ hasTokens: false,
221
+ description: '~/Library/Application Support/Antigravity (no token data)',
222
+ },
223
+
224
+ // -----------------------------------------------------------------------
225
+ // Gemini CLI
226
+ // -----------------------------------------------------------------------
227
+ {
228
+ key: 'gemini',
229
+ name: 'Gemini CLI',
230
+ kind: 'protobuf',
231
+ envVar: 'GEMINI_HOME',
232
+ candidatePaths: () => {
233
+ const base = env.GEMINI_HOME || join(HOME, '.gemini');
234
+ return [
235
+ join(base, 'antigravity', 'conversations'),
236
+ join(base, 'conversations'),
237
+ ];
238
+ },
239
+ count: (p) => {
240
+ if (!p) return 0;
241
+ let n = 0;
242
+ try {
243
+ for (const f of readdirSync(p)) {
244
+ if (f.endsWith('.pb')) n++;
245
+ }
246
+ } catch {}
247
+ return n;
248
+ },
249
+ hasTokens: false,
250
+ description: '~/.gemini/antigravity/conversations/*.pb (binary, no token data)',
251
+ },
252
+ ];
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Public: run all detectors
256
+ // ---------------------------------------------------------------------------
257
+
258
+ export function detectAll(opts = {}) {
259
+ const override = opts.override || (env.AI_USAGE_PATHS_JSON
260
+ ? safeParseJSON(env.AI_USAGE_PATHS_JSON)
261
+ : {});
262
+
263
+ const results = [];
264
+ for (const def of DETECTORS) {
265
+ let candidatePaths = def.candidatePaths();
266
+
267
+ // Apply override if user supplied one
268
+ if (override[def.key]) {
269
+ const ov = override[def.key];
270
+ if (typeof ov === 'string' && isAbsolute(ov)) {
271
+ candidatePaths = [ov, ...candidatePaths];
272
+ }
273
+ }
274
+
275
+ const path = firstExisting(candidatePaths);
276
+ const status = path ? 'present' : 'absent';
277
+ const count = path ? def.count(path) : 0;
278
+ results.push({
279
+ key: def.key,
280
+ name: def.name,
281
+ kind: def.kind,
282
+ status,
283
+ path,
284
+ count,
285
+ hasTokens: def.hasTokens,
286
+ description: def.description,
287
+ });
288
+ }
289
+ return results;
290
+ }
291
+
292
+ function safeParseJSON(s) {
293
+ try { return JSON.parse(s); } catch { return {}; }
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Public: just keys in order (for stable UI columns)
298
+ // ---------------------------------------------------------------------------
299
+
300
+ export const TOOL_ORDER = DETECTORS.map(d => d.key);
package/src/loaders.js ADDED
@@ -0,0 +1,249 @@
1
+ // Loaders: turn detector results into a unified stream of session records.
2
+ //
3
+ // Unified record shape:
4
+ // {
5
+ // tool: 'opencode' | 'codex' | 'mimocode' | string,
6
+ // sessionId: string,
7
+ // project: string, // working directory or '(unknown)'
8
+ // title: string,
9
+ // week: 'YYYY-Www', // ISO week, UTC
10
+ // month: 'YYYY-MM',
11
+ // ts: number, // session start, ms since epoch
12
+ // tokensInput, tokensOutput, tokensCacheRead, tokensCacheWrite, tokensReasoning,
13
+ // tokensTotal: number,
14
+ // cost: number, // USD
15
+ // model: string,
16
+ // }
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';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Date helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function isoWeekKey(ts) {
29
+ const d = new Date(ts);
30
+ // ISO week: Thu in current week decides the year
31
+ const target = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
32
+ const dayNum = (target.getUTCDay() + 6) % 7; // Mon=0..Sun=6
33
+ target.setUTCDate(target.getUTCDate() - dayNum + 3); // Thu
34
+ 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')}`;
37
+ }
38
+
39
+ function monthKey(ts) {
40
+ const d = new Date(ts);
41
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
42
+ }
43
+
44
+ function parseIsoMs(s) {
45
+ if (!s) return null;
46
+ const t = Date.parse(s);
47
+ return Number.isFinite(t) ? t : null;
48
+ }
49
+
50
+ function compactHome(p) {
51
+ if (!p) return '(unknown)';
52
+ const home = process.env.HOME || '';
53
+ return p.startsWith(home) ? '~' + p.slice(home.length) : p;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // SQLite loader (OpenCode + MimoCode share schema)
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function loadOpencodeStyleSqlite(dbPath, toolKey) {
61
+ let db;
62
+ try {
63
+ db = new DatabaseSync(dbPath, { readOnly: true });
64
+ } catch (e) {
65
+ return { records: [], error: `cannot open ${dbPath}: ${e.message}` };
66
+ }
67
+
68
+ // Introspect session table for token columns (MimoCode schema is a subset)
69
+ 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');
75
+
76
+ if (!hasTokens) {
77
+ db.close();
78
+ return { records: [], info: 'session table has no token columns' };
79
+ }
80
+
81
+ const rows = db.prepare(`
82
+ SELECT id, directory, title, time_created,
83
+ tokens_input, tokens_output, tokens_reasoning,
84
+ tokens_cache_read, tokens_cache_write, cost, model
85
+ FROM session
86
+ WHERE time_archived IS NULL
87
+ `).all();
88
+ db.close();
89
+
90
+ const records = [];
91
+ 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);
95
+ if (tot === 0) continue;
96
+ records.push({
97
+ tool: toolKey,
98
+ sessionId: r.id,
99
+ project: compactHome(r.directory),
100
+ title: r.title || '',
101
+ week: isoWeekKey(r.time_created),
102
+ month: monthKey(r.time_created),
103
+ ts: r.time_created,
104
+ tokensInput: r.tokens_input || 0,
105
+ tokensOutput: r.tokens_output || 0,
106
+ tokensCacheRead: r.tokens_cache_read || 0,
107
+ tokensCacheWrite: r.tokens_cache_write || 0,
108
+ tokensReasoning: r.tokens_reasoning || 0,
109
+ tokensTotal: tot,
110
+ cost: r.cost || 0,
111
+ model: r.model || '',
112
+ });
113
+ }
114
+ return { records };
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Codex JSONL rollout loader
119
+ // ---------------------------------------------------------------------------
120
+
121
+ async function loadCodexRollouts(rootDir) {
122
+ const records = [];
123
+ 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 };
135
+ }
136
+
137
+ async function parseCodexRollout(path) {
138
+ // 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
+ let sessionMeta = null;
141
+ let lastUsage = null;
142
+ let lastModel = '';
143
+
144
+ const stream = createReadStream(path, { encoding: 'utf8' });
145
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
146
+ for await (const line of rl) {
147
+ if (!line) continue;
148
+ let ev;
149
+ try { ev = JSON.parse(line); } catch { continue; }
150
+ const t = ev.type;
151
+ 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') {
155
+ const info = p.info || {};
156
+ const u = info.total_token_usage;
157
+ if (u) lastUsage = u;
158
+ const m = info.model || p.model;
159
+ if (m) lastModel = m;
160
+ }
161
+ }
162
+ if (!sessionMeta || !lastUsage) return null;
163
+
164
+ const ts = parseIsoMs(sessionMeta.ts);
165
+ if (!ts) return null;
166
+
167
+ const tokensInput = lastUsage.input_tokens || 0;
168
+ const tokensOutput = lastUsage.output_tokens || 0;
169
+ const tokensCacheRead = lastUsage.cached_input_tokens || 0;
170
+ const tokensReasoning = lastUsage.reasoning_output_tokens || 0;
171
+ const tot = tokensInput + tokensOutput + tokensCacheRead + tokensReasoning;
172
+ if (tot === 0) return null;
173
+
174
+ return {
175
+ tool: 'codex',
176
+ sessionId: path.split('-').pop().replace('.jsonl', ''),
177
+ project: compactHome(sessionMeta.cwd),
178
+ title: '', // not available in codex rollouts
179
+ week: isoWeekKey(ts),
180
+ month: monthKey(ts),
181
+ ts,
182
+ tokensInput,
183
+ tokensOutput,
184
+ tokensCacheRead,
185
+ tokensCacheWrite: 0, // codex doesn't expose cache_write separately
186
+ tokensReasoning,
187
+ tokensTotal: tot,
188
+ cost: 0,
189
+ model: lastModel,
190
+ };
191
+ }
192
+
193
+ function walkJsonl(root, prefix) {
194
+ const out = [];
195
+ function walk(dir) {
196
+ let entries;
197
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
198
+ for (const e of entries) {
199
+ const full = join(dir, e.name);
200
+ if (e.isDirectory()) walk(full);
201
+ else if (e.isFile() && e.name.startsWith(prefix) && e.name.endsWith('.jsonl')) {
202
+ out.push(full);
203
+ }
204
+ }
205
+ }
206
+ walk(root);
207
+ return out;
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Public: load all session records for given detectors
212
+ // ---------------------------------------------------------------------------
213
+
214
+ export async function loadAll(detections) {
215
+ const all = [];
216
+ const errors = [];
217
+
218
+ for (const d of detections) {
219
+ if (d.status !== 'present') continue;
220
+ if (d.key === 'opencode' || d.key === 'mimocode') {
221
+ const r = loadOpencodeStyleSqlite(d.path, d.key);
222
+ all.push(...r.records);
223
+ if (r.error) errors.push(`${d.name}: ${r.error}`);
224
+ if (r.info) errors.push(`${d.name}: ${r.info}`);
225
+ } else if (d.key === 'codex') {
226
+ const r = await loadCodexRollouts(d.path);
227
+ all.push(...r.records);
228
+ if (r.errors) errors.push(...r.errors);
229
+ }
230
+ // For other tools (claude, copilot, antigravity, gemini), we only have
231
+ // presence info — no token data to load.
232
+ }
233
+ return { records: all, errors };
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Public: date range helper
238
+ // ---------------------------------------------------------------------------
239
+
240
+ export function dateRange(records) {
241
+ if (records.length === 0) return [null, null];
242
+ let min = Infinity, max = -Infinity;
243
+ for (const r of records) {
244
+ if (r.ts < min) min = r.ts;
245
+ if (r.ts > max) max = r.ts;
246
+ }
247
+ const fmt = (ms) => new Date(ms).toISOString().slice(0, 10);
248
+ return [fmt(min), fmt(max)];
249
+ }