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.
- package/README.md +184 -0
- package/bin/ai-usage.js +100 -0
- package/package.json +52 -0
- package/src/aggregate.js +117 -0
- package/src/detectors.js +300 -0
- package/src/loaders.js +249 -0
- package/src/render.js +544 -0
package/src/detectors.js
ADDED
|
@@ -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
|
+
}
|