ai-usage-analyzer 0.4.0 → 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 +22 -14
- package/package.json +8 -6
- package/src/aggregate.js +7 -0
- package/src/live.js +132 -0
- package/src/loaders.js +89 -23
- package/src/markdown.js +12 -10
- package/src/pricing.js +49 -0
- package/src/quota.js +194 -0
- package/src/render.js +17 -12
- package/src/server.js +67 -0
- package/src/tools.js +5 -5
- package/src/tui.js +24 -8
- 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/README.md
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
# ai-usage-analyzer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A local web dashboard for AI coding agent token consumption. Auto-detects
|
|
4
4
|
**Claude Code**, **Codex**, **OpenCode**, **MimoCode**, **GitHub Copilot**,
|
|
5
|
-
**Antigravity**, and **Gemini CLI** data directories
|
|
5
|
+
**Antigravity**, and **Gemini CLI** data directories.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
│ ◆ AI TOKEN ANALYZER ◆ │
|
|
10
|
-
│ │
|
|
11
|
-
│ 411 sessions • 1.56B total tokens • $29.40 USD │
|
|
12
|
-
│ range: 2026-04-01 → 2026-06-27 │
|
|
13
|
-
╰──────────────────────────────────────────────────────╯
|
|
14
|
-
```
|
|
7
|
+
The default command starts a local server and opens the dashboard in your browser.
|
|
8
|
+
Usage data stays on your device. Press Ctrl+C in the terminal to stop the server.
|
|
15
9
|
|
|
16
10
|
## Install
|
|
17
11
|
|
|
@@ -31,18 +25,19 @@ The short alias `ai-usage` is also installed and works identically.
|
|
|
31
25
|
## Usage
|
|
32
26
|
|
|
33
27
|
```bash
|
|
34
|
-
ai-usage-analyzer #
|
|
35
|
-
ai-usage-analyzer --top 10
|
|
28
|
+
ai-usage-analyzer # open the dashboard
|
|
29
|
+
ai-usage-analyzer --static --top 10 # terminal report with top 10 sessions
|
|
36
30
|
ai-usage-analyzer --json # machine-readable JSON
|
|
37
31
|
ai-usage-analyzer --md > report.md # save as markdown
|
|
32
|
+
ai-usage-analyzer --json --quota # include a live quota snapshot
|
|
38
33
|
```
|
|
39
34
|
|
|
40
35
|
## Supported tools
|
|
41
36
|
|
|
42
37
|
| Tool | Default path | Tokens |
|
|
43
38
|
|---|---|---|
|
|
44
|
-
| Claude Code | `~/.claude/projects` |
|
|
45
|
-
| Codex | `~/.codex/sessions
|
|
39
|
+
| Claude Code | `~/.claude/projects` | yes (estimated cost) |
|
|
40
|
+
| Codex | `~/.codex/sessions/` and `archived_sessions/` | yes (estimated cost) |
|
|
46
41
|
| OpenCode | `~/.local/share/opencode/opencode.db` | yes (+cost) |
|
|
47
42
|
| MimoCode | `~/.local/share/mimocode/mimocode.db` | yes (+cost) |
|
|
48
43
|
| GitHub Copilot | `~/.copilot/session-state/` | presence only |
|
|
@@ -65,14 +60,34 @@ For tools that record token data, the analyzer shows input, output, cache
|
|
|
65
60
|
read, cache write, and reasoning tokens. Cache hits are cheap; reasoning is
|
|
66
61
|
the extended-thinking/chain-of-thought cost.
|
|
67
62
|
|
|
63
|
+
## Quotas
|
|
64
|
+
|
|
65
|
+
Reports stay offline unless `--quota` is supplied. This requests Claude and Codex
|
|
66
|
+
usage limits using your existing logins. Vendor endpoints are internal and can
|
|
67
|
+
change. Codex credentials may be refreshed and written back to `auth.json`.
|
|
68
|
+
|
|
69
|
+
## Costs
|
|
70
|
+
|
|
71
|
+
Claude and Codex costs use bundled API rates per model and are estimates,
|
|
72
|
+
not subscription charges or invoices. `~` marks an estimate, `*` marks a
|
|
73
|
+
partial total, and `n/a` means unavailable. Unknown models are listed rather
|
|
74
|
+
than assigned a fallback price. OpenCode and MimoCode costs are read from
|
|
75
|
+
their local databases. Pricing uses standard public API rates and the
|
|
76
|
+
five-minute Claude cache-write rate; regional, batch, and other modifiers may
|
|
77
|
+
differ. Rates are versioned with the app and can become outdated.
|
|
78
|
+
|
|
68
79
|
## How it works
|
|
69
80
|
|
|
70
81
|
```
|
|
71
82
|
src/
|
|
72
83
|
├── detectors.js auto-path discovery (env → well-known locations)
|
|
73
84
|
├── loaders.js SQLite + JSONL parsers → unified session record
|
|
85
|
+
├── quota.js Claude and Codex quota snapshots
|
|
86
|
+
├── pricing.js bundled model pricing and estimates
|
|
87
|
+
├── live.js incremental live usage monitor
|
|
74
88
|
├── aggregate.js per-project / per-week / per-month grouping
|
|
75
|
-
├──
|
|
89
|
+
├── server.js local dashboard server
|
|
90
|
+
├── render.js static terminal report • markdown.js → Markdown report
|
|
76
91
|
bin/
|
|
77
92
|
└── ai-usage.js entry point
|
|
78
93
|
```
|
package/bin/ai-usage.js
CHANGED
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
renderPerWeek,
|
|
19
19
|
renderTopSessions,
|
|
20
20
|
} from "../src/render.js";
|
|
21
|
-
import {
|
|
21
|
+
import { startDashboard } from "../src/server.js";
|
|
22
|
+
import { collectQuotas, renderQuota } from "../src/quota.js";
|
|
22
23
|
|
|
23
24
|
const args = process.argv.slice(2);
|
|
24
25
|
const hasFlag = (name) =>
|
|
@@ -26,6 +27,7 @@ const hasFlag = (name) =>
|
|
|
26
27
|
const showHelp = hasFlag("help") || hasFlag("h");
|
|
27
28
|
const jsonOut = hasFlag("json");
|
|
28
29
|
const mdOut = hasFlag("markdown") || hasFlag("md");
|
|
30
|
+
const quotaOut = hasFlag("quota");
|
|
29
31
|
const staticOut = hasFlag("static") || hasFlag("no-interactive");
|
|
30
32
|
const topN = (() => {
|
|
31
33
|
const i = args.indexOf("--top");
|
|
@@ -42,7 +44,7 @@ const yearFilter = (() => {
|
|
|
42
44
|
|
|
43
45
|
if (showHelp) {
|
|
44
46
|
console.log(`
|
|
45
|
-
ai-usage-analyzer — local AI coding agent
|
|
47
|
+
ai-usage-analyzer — local AI coding agent usage dashboard
|
|
46
48
|
|
|
47
49
|
Usage:
|
|
48
50
|
ai-usage [options]
|
|
@@ -52,15 +54,16 @@ Options:
|
|
|
52
54
|
--json Output machine-readable JSON instead of TUI
|
|
53
55
|
--markdown, --md Output as a Markdown report (GitHub-flavored tables)
|
|
54
56
|
--static, --no-interactive Disable interactive mode; dump all sections at once
|
|
55
|
-
--
|
|
56
|
-
--
|
|
57
|
+
--quota Include live quota in JSON, Markdown, or static reports
|
|
58
|
+
--top N Show top N sessions in reports (default: 5)
|
|
59
|
+
--year YYYY Filter reports to a single year (e.g. --year 2026)
|
|
57
60
|
|
|
58
61
|
Examples:
|
|
59
|
-
ai-usage #
|
|
60
|
-
ai-usage --year 2026
|
|
62
|
+
ai-usage # open dashboard
|
|
63
|
+
ai-usage --static --year 2026 # report, only 2026 sessions
|
|
61
64
|
ai-usage --json | jq .summary # pipe to jq
|
|
62
65
|
ai-usage --md > report.md # save as markdown
|
|
63
|
-
ai-usage --top 20
|
|
66
|
+
ai-usage --static --top 20 # show top 20 sessions
|
|
64
67
|
|
|
65
68
|
Environment overrides (per-tool data path):
|
|
66
69
|
CLAUDE_HOME, CODEX_HOME, OPENCODE_HOME, MIMOCODE_HOME,
|
|
@@ -85,6 +88,10 @@ if (jsonOut && mdOut) {
|
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
async function main() {
|
|
91
|
+
if (!jsonOut && !mdOut && !staticOut) {
|
|
92
|
+
await startDashboard();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
88
95
|
const t0 = Date.now();
|
|
89
96
|
const detections = detectAll();
|
|
90
97
|
const { records: allRecords, errors } = await loadAll(detections);
|
|
@@ -101,9 +108,13 @@ async function main() {
|
|
|
101
108
|
const range = dateRange(records);
|
|
102
109
|
const tot = overall(records);
|
|
103
110
|
|
|
111
|
+
const quotaPromise = quotaOut ? collectQuotas() : null;
|
|
112
|
+
|
|
104
113
|
if (jsonOut) {
|
|
114
|
+
const quotas = quotaPromise ? await quotaPromise : null;
|
|
105
115
|
const out = {
|
|
106
116
|
detections,
|
|
117
|
+
...(quotas ? { quotas } : {}),
|
|
107
118
|
summary: tot,
|
|
108
119
|
dateRange: range,
|
|
109
120
|
sessions: records,
|
|
@@ -125,22 +136,18 @@ async function main() {
|
|
|
125
136
|
topN,
|
|
126
137
|
generatedAt: new Date().toISOString(),
|
|
127
138
|
});
|
|
128
|
-
|
|
139
|
+
const quota = quotaPromise ? await quotaPromise : null;
|
|
140
|
+
process.stdout.write(out + (quota ? `\n## Quotas\n\n\`\`\`\n${renderQuota(quota)}\n\`\`\`\n` : ""));
|
|
129
141
|
return;
|
|
130
142
|
}
|
|
131
143
|
|
|
132
|
-
// Interactive TUI (default when stdout is a TTY and --static not given)
|
|
133
|
-
if (!staticOut) {
|
|
134
|
-
const handled = await runInteractive({ records, detections, errors, topN });
|
|
135
|
-
if (handled) return;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
144
|
// Static fallback (--static, --no-interactive, or piped output)
|
|
139
145
|
const sections = [
|
|
140
146
|
renderHeader({
|
|
141
147
|
totalSessions: records.length,
|
|
142
148
|
totalTokens: tot.tokensTotal,
|
|
143
149
|
totalCost: tot.cost,
|
|
150
|
+
costStatus: tot.costStatus,
|
|
144
151
|
dateRange: range,
|
|
145
152
|
}),
|
|
146
153
|
renderDetections(detections),
|
|
@@ -157,6 +164,7 @@ async function main() {
|
|
|
157
164
|
);
|
|
158
165
|
}
|
|
159
166
|
sections.push(renderNotes(detections, errors));
|
|
167
|
+
if (quotaPromise) sections.push(renderQuota(await quotaPromise));
|
|
160
168
|
|
|
161
169
|
console.log(sections.join("\n\n"));
|
|
162
170
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-usage-analyzer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Local web dashboard for AI coding agent token usage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.33.0",
|
|
7
7
|
"bin": {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"bin",
|
|
13
13
|
"src",
|
|
14
|
-
"README.md"
|
|
14
|
+
"README.md",
|
|
15
|
+
"web"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
17
18
|
"start": "node bin/ai-usage.js",
|
|
@@ -31,8 +32,8 @@
|
|
|
31
32
|
"copilot",
|
|
32
33
|
"antigravity",
|
|
33
34
|
"gemini",
|
|
34
|
-
"
|
|
35
|
-
"
|
|
35
|
+
"cli",
|
|
36
|
+
"dashboard"
|
|
36
37
|
],
|
|
37
38
|
"author": "ade",
|
|
38
39
|
"license": "MIT",
|
|
@@ -48,6 +49,7 @@
|
|
|
48
49
|
"boxen": "^7.1.1",
|
|
49
50
|
"chalk": "^5.3.0",
|
|
50
51
|
"cli-table3": "^0.6.5",
|
|
51
|
-
"gradient-string": "^2.0.2"
|
|
52
|
+
"gradient-string": "^2.0.2",
|
|
53
|
+
"gsap": "^3.15.0"
|
|
52
54
|
}
|
|
53
55
|
}
|
package/src/aggregate.js
CHANGED
|
@@ -34,6 +34,11 @@ export function groupBy(records, keyFn) {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function summarize(records) {
|
|
37
|
+
const statuses = records.map(r => r.costStatus || 'recorded');
|
|
38
|
+
const incomplete = statuses.some(status => status === 'partial' || status === 'unavailable');
|
|
39
|
+
const known = statuses.some(status => status !== 'unavailable');
|
|
40
|
+
const costStatus = !statuses.length ? 'unavailable' : incomplete ? (known ? 'partial' : 'unavailable') : statuses.includes('estimated') ? 'estimated' : 'recorded';
|
|
41
|
+
const unknownModels = [...new Set(records.flatMap(r => r.unknownModels || []))];
|
|
37
42
|
return {
|
|
38
43
|
n: records.length,
|
|
39
44
|
tokensTotal: sum(records, "tokensTotal"),
|
|
@@ -43,6 +48,8 @@ function summarize(records) {
|
|
|
43
48
|
tokensCacheWrite: sum(records, "tokensCacheWrite"),
|
|
44
49
|
tokensReasoning: sum(records, "tokensReasoning"),
|
|
45
50
|
cost: sum(records, "cost"),
|
|
51
|
+
costStatus,
|
|
52
|
+
unknownModels,
|
|
46
53
|
avg: avg(records, "tokensTotal"),
|
|
47
54
|
};
|
|
48
55
|
}
|
package/src/live.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { openSync, closeSync, fstatSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
|
|
5
|
+
const count = value => Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0;
|
|
6
|
+
|
|
7
|
+
export class LiveUsageTailer {
|
|
8
|
+
constructor(detections) {
|
|
9
|
+
this.roots = [];
|
|
10
|
+
for (const d of detections) {
|
|
11
|
+
if (!d.path || !['claude', 'codex'].includes(d.key) || d.status === 'absent') continue;
|
|
12
|
+
if (d.key === 'codex' && basename(d.path) === 'archived_sessions') continue;
|
|
13
|
+
this.roots.push({ path: d.path, tool: d.key });
|
|
14
|
+
}
|
|
15
|
+
this.directories = new Map();
|
|
16
|
+
this.files = new Map();
|
|
17
|
+
this.events = [];
|
|
18
|
+
this.started = false;
|
|
19
|
+
this.lastFull = 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
tick(now = Date.now()) {
|
|
23
|
+
const full = !this.started || now - this.lastFull >= 60000;
|
|
24
|
+
if (full) this.lastFull = now;
|
|
25
|
+
const discovered = new Set();
|
|
26
|
+
const walk = (dir, tool) => {
|
|
27
|
+
let st;
|
|
28
|
+
try { st = statSync(dir); } catch { return; }
|
|
29
|
+
if (!st.isDirectory()) return;
|
|
30
|
+
const key = `${tool}:${dir}`;
|
|
31
|
+
let cached = this.directories.get(key);
|
|
32
|
+
if (!cached || cached.mtime !== st.mtimeMs || full) {
|
|
33
|
+
let entries;
|
|
34
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
35
|
+
cached = { mtime: st.mtimeMs, dirs: [], files: [] };
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (entry.isDirectory() && !(tool === 'claude' && entry.name === 'subagents') && !(tool === 'codex' && entry.name === 'archived_sessions')) cached.dirs.push(join(dir, entry.name));
|
|
38
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl') && (tool !== 'codex' || entry.name.startsWith('rollout-'))) cached.files.push(join(dir, entry.name));
|
|
39
|
+
}
|
|
40
|
+
this.directories.set(key, cached);
|
|
41
|
+
}
|
|
42
|
+
for (const child of cached.dirs) walk(child, tool);
|
|
43
|
+
for (const file of cached.files) {
|
|
44
|
+
const id = `${tool}:${file}`;
|
|
45
|
+
discovered.add(id);
|
|
46
|
+
if (!this.files.has(id)) this.files.set(id, { path: file, tool, offset: null, partial: Buffer.alloc(0), seen: new Map(), total: null, mtime: 0 });
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
for (const root of this.roots) walk(root.path, root.tool);
|
|
50
|
+
for (const [key, file] of this.files) {
|
|
51
|
+
if (!discovered.has(key)) { this.files.delete(key); continue; }
|
|
52
|
+
if (file.offset !== null && !full && now - file.mtime > 600000) continue;
|
|
53
|
+
this.readFile(file, now);
|
|
54
|
+
}
|
|
55
|
+
this.started = true;
|
|
56
|
+
this.events = this.events.filter(e => e.ts > now - 3600000);
|
|
57
|
+
return this.rate(now, 600);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
readFile(file, now) {
|
|
61
|
+
let fd;
|
|
62
|
+
try {
|
|
63
|
+
fd = openSync(file.path, 'r');
|
|
64
|
+
const st = fstatSync(fd);
|
|
65
|
+
const identity = `${st.dev}:${st.ino}`;
|
|
66
|
+
let changedBoundary = false;
|
|
67
|
+
if (file.offset !== null && file.offset > 0 && file.boundary && st.size >= file.offset) {
|
|
68
|
+
const sample = Buffer.alloc(file.boundary.length);
|
|
69
|
+
const size = readSync(fd, sample, 0, sample.length, file.offset - sample.length);
|
|
70
|
+
changedBoundary = size !== sample.length || !sample.equals(file.boundary);
|
|
71
|
+
}
|
|
72
|
+
if (file.offset === null) {
|
|
73
|
+
file.offset = this.started ? 0 : st.size;
|
|
74
|
+
file.identity = identity;
|
|
75
|
+
} else if (file.identity !== identity || st.size < file.offset || changedBoundary) {
|
|
76
|
+
file.offset = 0;
|
|
77
|
+
file.identity = identity;
|
|
78
|
+
file.partial = Buffer.alloc(0);
|
|
79
|
+
file.seen.clear();
|
|
80
|
+
file.total = null;
|
|
81
|
+
}
|
|
82
|
+
file.mtime = st.mtimeMs;
|
|
83
|
+
while (file.offset < st.size) {
|
|
84
|
+
const buffer = Buffer.allocUnsafe(Math.min(65536, st.size - file.offset));
|
|
85
|
+
const size = readSync(fd, buffer, 0, buffer.length, file.offset);
|
|
86
|
+
if (!size) break;
|
|
87
|
+
file.offset += size;
|
|
88
|
+
const data = Buffer.concat([file.partial, buffer.subarray(0, size)]);
|
|
89
|
+
let from = 0, end;
|
|
90
|
+
while ((end = data.indexOf(10, from)) !== -1) {
|
|
91
|
+
this.consume(file, data.subarray(from, end).toString('utf8'), now);
|
|
92
|
+
from = end + 1;
|
|
93
|
+
}
|
|
94
|
+
file.partial = data.subarray(from);
|
|
95
|
+
}
|
|
96
|
+
const sample = Buffer.alloc(Math.min(64, file.offset));
|
|
97
|
+
if (sample.length) {
|
|
98
|
+
const size = readSync(fd, sample, 0, sample.length, file.offset - sample.length);
|
|
99
|
+
file.boundary = sample.subarray(0, size);
|
|
100
|
+
} else file.boundary = sample;
|
|
101
|
+
} catch {} finally { if (fd !== undefined) closeSync(fd); }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
consume(file, line, now) {
|
|
105
|
+
let event;
|
|
106
|
+
try { event = JSON.parse(line); } catch { return; }
|
|
107
|
+
let tokens = 0;
|
|
108
|
+
if (file.tool === 'claude' && event.type === 'assistant' && event.message?.usage) {
|
|
109
|
+
const usage = event.message.usage;
|
|
110
|
+
const id = event.message.id ? JSON.stringify([event.message.id, event.requestId || null]) : null;
|
|
111
|
+
const previous = id ? file.seen.get(id) || {} : {};
|
|
112
|
+
const next = {};
|
|
113
|
+
for (const field of FIELDS) {
|
|
114
|
+
next[field] = Math.max(count(previous[field]), count(usage[field]));
|
|
115
|
+
tokens += next[field] - count(previous[field]);
|
|
116
|
+
}
|
|
117
|
+
if (id) file.seen.set(id, next);
|
|
118
|
+
} else if (file.tool === 'codex' && event.type === 'event_msg' && event.payload?.type === 'token_count') {
|
|
119
|
+
const info = event.payload.info || {};
|
|
120
|
+
const total = info.total_token_usage;
|
|
121
|
+
const key = total ? JSON.stringify(total) : null;
|
|
122
|
+
if (key && key === file.total) return;
|
|
123
|
+
if (key) file.total = key;
|
|
124
|
+
if (info.last_token_usage) tokens = count(info.last_token_usage.input_tokens) + count(info.last_token_usage.output_tokens);
|
|
125
|
+
}
|
|
126
|
+
if (tokens > 0) this.events.push({ ts: now, tokens });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
rate(now = Date.now(), seconds = 600) {
|
|
130
|
+
return this.events.filter(e => e.ts > now - seconds * 1000 && e.ts <= now).reduce((sum, e) => sum + e.tokens, 0) * 60 / seconds;
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/loaders.js
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
// }
|
|
17
17
|
|
|
18
18
|
import { createReadStream, readdirSync } from "node:fs";
|
|
19
|
-
import { join } from "node:path";
|
|
19
|
+
import { basename, dirname, join } from "node:path";
|
|
20
20
|
import { createInterface } from "node:readline";
|
|
21
21
|
import { DatabaseSync } from "node:sqlite";
|
|
22
|
+
import { estimateUsage } from "./pricing.js";
|
|
22
23
|
|
|
23
24
|
// Run fn over every item in items with at most `limit` in-flight at once.
|
|
24
25
|
async function mapConcurrent(items, fn, limit) {
|
|
@@ -70,6 +71,11 @@ function parseIsoMs(s) {
|
|
|
70
71
|
return Number.isFinite(t) ? t : null;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
function tokenCount(value) {
|
|
75
|
+
const n = Number(value);
|
|
76
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
73
79
|
function compactHome(p) {
|
|
74
80
|
if (!p) return "(unknown)";
|
|
75
81
|
const home = process.env.HOME || "";
|
|
@@ -144,6 +150,7 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
|
144
150
|
tokensReasoning: r.tokens_reasoning || 0,
|
|
145
151
|
tokensTotal: tot,
|
|
146
152
|
cost: r.cost || 0,
|
|
153
|
+
costStatus: r.cost == null ? "unavailable" : "recorded",
|
|
147
154
|
model: r.model || "",
|
|
148
155
|
});
|
|
149
156
|
}
|
|
@@ -156,7 +163,9 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
|
156
163
|
|
|
157
164
|
async function loadCodexRollouts(rootDir) {
|
|
158
165
|
const errors = [];
|
|
159
|
-
const
|
|
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));
|
|
160
169
|
const results = await mapConcurrent(
|
|
161
170
|
files,
|
|
162
171
|
async (f) => {
|
|
@@ -169,15 +178,20 @@ async function loadCodexRollouts(rootDir) {
|
|
|
169
178
|
},
|
|
170
179
|
48,
|
|
171
180
|
);
|
|
172
|
-
|
|
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 };
|
|
173
184
|
}
|
|
174
185
|
|
|
175
186
|
async function parseCodexRollout(path) {
|
|
176
187
|
// Use a streaming reader to avoid loading huge files into memory.
|
|
177
|
-
// The 'token_count' event accumulates usage; we take the LAST one (final snapshot).
|
|
178
188
|
let sessionMeta = null;
|
|
179
|
-
let
|
|
189
|
+
let previous = null;
|
|
190
|
+
let forkWaiting = false;
|
|
191
|
+
let inheritedTotal = null;
|
|
192
|
+
const usageEvents = [];
|
|
180
193
|
let lastModel = "";
|
|
194
|
+
let provider = "openai";
|
|
181
195
|
let lastEvTs = null;
|
|
182
196
|
|
|
183
197
|
const stream = createReadStream(path, { encoding: "utf8" });
|
|
@@ -195,31 +209,73 @@ async function parseCodexRollout(path) {
|
|
|
195
209
|
const evTs = parseIsoMs(ev.timestamp);
|
|
196
210
|
if (evTs) lastEvTs = evTs;
|
|
197
211
|
if (t === "session_meta") {
|
|
198
|
-
sessionMeta = { ts: ev.timestamp, cwd: p.cwd || p.CWD || "" };
|
|
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;
|
|
199
218
|
} else if (t === "event_msg" && p.type === "token_count") {
|
|
200
219
|
const info = p.info || {};
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
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;
|
|
204
254
|
if (m) lastModel = m;
|
|
255
|
+
if (delta) usageEvents.push({ model: lastModel, provider, aggregated: !last, usage: delta });
|
|
205
256
|
}
|
|
206
257
|
}
|
|
207
|
-
if (!sessionMeta || !
|
|
258
|
+
if (!sessionMeta || !usageEvents.length) return null;
|
|
208
259
|
|
|
209
260
|
const ts = parseIsoMs(sessionMeta.ts);
|
|
210
261
|
if (!ts) return null;
|
|
211
262
|
const durationMs = lastEvTs && lastEvTs > ts ? lastEvTs - ts : 0;
|
|
212
263
|
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
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
|
+
}
|
|
217
273
|
const tot = tokensInput + tokensOutput + tokensCacheRead + tokensReasoning;
|
|
218
274
|
if (tot === 0) return null;
|
|
219
275
|
|
|
220
276
|
return {
|
|
221
277
|
tool: "codex",
|
|
222
|
-
sessionId:
|
|
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"),
|
|
223
279
|
project: compactHome(sessionMeta.cwd),
|
|
224
280
|
title: "",
|
|
225
281
|
week: isoWeekKey(ts),
|
|
@@ -232,8 +288,9 @@ async function parseCodexRollout(path) {
|
|
|
232
288
|
tokensCacheWrite: 0, // codex doesn't expose cache_write separately
|
|
233
289
|
tokensReasoning,
|
|
234
290
|
tokensTotal: tot,
|
|
235
|
-
|
|
291
|
+
...estimateUsage("codex", usageEvents),
|
|
236
292
|
model: lastModel,
|
|
293
|
+
usageEvents,
|
|
237
294
|
};
|
|
238
295
|
}
|
|
239
296
|
|
|
@@ -294,8 +351,7 @@ function walkClaudeSessions(root) {
|
|
|
294
351
|
//
|
|
295
352
|
// Each top-level file under projects/<encoded-cwd>/<UUID>.jsonl is one session.
|
|
296
353
|
// Per assistant line, `message.usage` carries the per-turn token counts; we
|
|
297
|
-
//
|
|
298
|
-
// 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.
|
|
299
355
|
|
|
300
356
|
async function loadClaudeJsonl(rootDir) {
|
|
301
357
|
const errors = [];
|
|
@@ -317,7 +373,9 @@ async function loadClaudeJsonl(rootDir) {
|
|
|
317
373
|
|
|
318
374
|
async function parseClaudeSession(path) {
|
|
319
375
|
const sessionId = path.split("/").pop().replace(".jsonl", "");
|
|
320
|
-
const
|
|
376
|
+
const usages = new Map();
|
|
377
|
+
const usageModels = new Map();
|
|
378
|
+
let anonymous = 0;
|
|
321
379
|
let cwd = "";
|
|
322
380
|
let model = "";
|
|
323
381
|
let firstTs = null;
|
|
@@ -347,15 +405,21 @@ async function parseClaudeSession(path) {
|
|
|
347
405
|
const usage = msg.usage;
|
|
348
406
|
if (!usage) continue;
|
|
349
407
|
const id = msg.id;
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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]));
|
|
353
413
|
}
|
|
414
|
+
usages.set(key, merged);
|
|
415
|
+
if (msg.model) usageModels.set(key, msg.model);
|
|
354
416
|
if (!cwd && typeof ev.cwd === "string") cwd = ev.cwd;
|
|
355
417
|
if (!model && typeof msg.model === "string") model = msg.model;
|
|
356
418
|
const evTs = parseIsoMs(ev.timestamp);
|
|
357
419
|
if (firstTs === null && evTs !== null) firstTs = evTs;
|
|
358
420
|
if (evTs !== null) lastTs = evTs;
|
|
421
|
+
}
|
|
422
|
+
for (const usage of usages.values()) {
|
|
359
423
|
tokensInput += usage.input_tokens || 0;
|
|
360
424
|
tokensOutput += usage.output_tokens || 0;
|
|
361
425
|
tokensCacheRead += usage.cache_read_input_tokens || 0;
|
|
@@ -367,6 +431,7 @@ async function parseClaudeSession(path) {
|
|
|
367
431
|
const ts = firstTs ?? Date.now();
|
|
368
432
|
const durationMs = firstTs !== null && lastTs !== null ? lastTs - firstTs : 0;
|
|
369
433
|
|
|
434
|
+
const usageEvents = [...usages].map(([key, usage]) => ({ model: usageModels.get(key) || "", usage }));
|
|
370
435
|
return {
|
|
371
436
|
tool: "claude",
|
|
372
437
|
sessionId,
|
|
@@ -382,8 +447,9 @@ async function parseClaudeSession(path) {
|
|
|
382
447
|
tokensCacheWrite,
|
|
383
448
|
tokensReasoning: 0,
|
|
384
449
|
tokensTotal: tot,
|
|
385
|
-
|
|
450
|
+
...estimateUsage("claude", usageEvents),
|
|
386
451
|
model,
|
|
452
|
+
usageEvents,
|
|
387
453
|
};
|
|
388
454
|
}
|
|
389
455
|
|