ai-usage-analyzer 0.4.0 → 0.5.1
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 +38 -19
- package/bin/ai-usage.js +24 -19
- package/package.json +8 -8
- package/src/aggregate.js +24 -2
- package/src/live.js +132 -0
- package/src/loaders.js +90 -27
- package/src/markdown.js +13 -10
- package/src/pricing.js +49 -0
- package/src/quota.js +194 -0
- package/src/render.js +20 -14
- package/src/server.js +68 -0
- package/src/tools.js +5 -49
- package/src/tui.js +24 -8
- package/web/app.js +1059 -0
- package/web/dates.js +35 -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 +233 -0
- package/web/style.css +369 -0
package/README.md
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
# ai-usage-analyzer
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
**Claude Code**, **Codex**, **OpenCode**,
|
|
5
|
-
**Antigravity**, and **Gemini CLI** data directories
|
|
3
|
+
A local web dashboard for AI coding agent token consumption. Auto-detects
|
|
4
|
+
**Claude Code**, **Codex**, **OpenCode**,
|
|
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,28 +25,27 @@ 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
|
-
| MimoCode | `~/.local/share/mimocode/mimocode.db` | yes (+cost) |
|
|
48
|
-
| GitHub Copilot | `~/.copilot/session-state/` | presence only |
|
|
49
42
|
| Antigravity | `~/Library/Application Support/Antigravity` | presence only |
|
|
50
43
|
| Gemini CLI | `~/.gemini/antigravity/conversations` | presence only |
|
|
51
44
|
|
|
52
45
|
## Path overrides
|
|
53
46
|
|
|
54
47
|
Override any tool's base path with an env var (`CLAUDE_HOME`, `CODEX_HOME`,
|
|
55
|
-
`OPENCODE_HOME`, `
|
|
48
|
+
`OPENCODE_HOME`, `ANTIGRAVITY_HOME`,
|
|
56
49
|
`GEMINI_HOME`), or pass all at once via JSON:
|
|
57
50
|
|
|
58
51
|
```bash
|
|
@@ -65,14 +58,40 @@ For tools that record token data, the analyzer shows input, output, cache
|
|
|
65
58
|
read, cache write, and reasoning tokens. Cache hits are cheap; reasoning is
|
|
66
59
|
the extended-thinking/chain-of-thought cost.
|
|
67
60
|
|
|
61
|
+
Average daily tokens divides total tokens by calendar days, including days
|
|
62
|
+
without usage. The dashboard uses the selected date range in your local time.
|
|
63
|
+
All time uses the first through last recorded day. Terminal, Markdown, and JSON
|
|
64
|
+
reports use the first through last recorded day in UTC; JSON exposes the value
|
|
65
|
+
as `summary.avgDailyTokens`.
|
|
66
|
+
|
|
67
|
+
## Quotas
|
|
68
|
+
|
|
69
|
+
Reports stay offline unless `--quota` is supplied. This requests Claude and Codex
|
|
70
|
+
usage limits using your existing logins. Vendor endpoints are internal and can
|
|
71
|
+
change. Codex credentials may be refreshed and written back to `auth.json`.
|
|
72
|
+
|
|
73
|
+
## Costs
|
|
74
|
+
|
|
75
|
+
Claude and Codex costs use bundled API rates per model and are estimates,
|
|
76
|
+
not subscription charges or invoices. `~` marks an estimate, `*` marks a
|
|
77
|
+
partial total, and `n/a` means unavailable. Unknown models are listed rather
|
|
78
|
+
than assigned a fallback price. OpenCode costs are read from
|
|
79
|
+
its local database. Pricing uses standard public API rates and the
|
|
80
|
+
five-minute Claude cache-write rate; regional, batch, and other modifiers may
|
|
81
|
+
differ. Rates are versioned with the app and can become outdated.
|
|
82
|
+
|
|
68
83
|
## How it works
|
|
69
84
|
|
|
70
85
|
```
|
|
71
86
|
src/
|
|
72
87
|
├── detectors.js auto-path discovery (env → well-known locations)
|
|
73
88
|
├── loaders.js SQLite + JSONL parsers → unified session record
|
|
89
|
+
├── quota.js Claude and Codex quota snapshots
|
|
90
|
+
├── pricing.js bundled model pricing and estimates
|
|
91
|
+
├── live.js incremental live usage monitor
|
|
74
92
|
├── aggregate.js per-project / per-week / per-month grouping
|
|
75
|
-
├──
|
|
93
|
+
├── server.js local dashboard server
|
|
94
|
+
├── render.js static terminal report • markdown.js → Markdown report
|
|
76
95
|
bin/
|
|
77
96
|
└── ai-usage.js entry point
|
|
78
97
|
```
|
package/bin/ai-usage.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// AI Usage Analyzer - TUI for local AI coding agent token consumption
|
|
3
|
-
// Auto-detects: Claude Code, Codex, OpenCode, MimoCode, Copilot, Antigravity, Gemini
|
|
4
3
|
|
|
5
4
|
import { overall } from "../src/aggregate.js";
|
|
6
5
|
import { detectAll } from "../src/detectors.js";
|
|
@@ -18,7 +17,8 @@ import {
|
|
|
18
17
|
renderPerWeek,
|
|
19
18
|
renderTopSessions,
|
|
20
19
|
} from "../src/render.js";
|
|
21
|
-
import {
|
|
20
|
+
import { startDashboard } from "../src/server.js";
|
|
21
|
+
import { collectQuotas, renderQuota } from "../src/quota.js";
|
|
22
22
|
|
|
23
23
|
const args = process.argv.slice(2);
|
|
24
24
|
const hasFlag = (name) =>
|
|
@@ -26,6 +26,7 @@ const hasFlag = (name) =>
|
|
|
26
26
|
const showHelp = hasFlag("help") || hasFlag("h");
|
|
27
27
|
const jsonOut = hasFlag("json");
|
|
28
28
|
const mdOut = hasFlag("markdown") || hasFlag("md");
|
|
29
|
+
const quotaOut = hasFlag("quota");
|
|
29
30
|
const staticOut = hasFlag("static") || hasFlag("no-interactive");
|
|
30
31
|
const topN = (() => {
|
|
31
32
|
const i = args.indexOf("--top");
|
|
@@ -42,7 +43,7 @@ const yearFilter = (() => {
|
|
|
42
43
|
|
|
43
44
|
if (showHelp) {
|
|
44
45
|
console.log(`
|
|
45
|
-
ai-usage-analyzer — local AI coding agent
|
|
46
|
+
ai-usage-analyzer — local AI coding agent usage dashboard
|
|
46
47
|
|
|
47
48
|
Usage:
|
|
48
49
|
ai-usage [options]
|
|
@@ -52,27 +53,26 @@ Options:
|
|
|
52
53
|
--json Output machine-readable JSON instead of TUI
|
|
53
54
|
--markdown, --md Output as a Markdown report (GitHub-flavored tables)
|
|
54
55
|
--static, --no-interactive Disable interactive mode; dump all sections at once
|
|
55
|
-
--
|
|
56
|
-
--
|
|
56
|
+
--quota Include live quota in JSON, Markdown, or static reports
|
|
57
|
+
--top N Show top N sessions in reports (default: 5)
|
|
58
|
+
--year YYYY Filter reports to a single year (e.g. --year 2026)
|
|
57
59
|
|
|
58
60
|
Examples:
|
|
59
|
-
ai-usage #
|
|
60
|
-
ai-usage --year 2026
|
|
61
|
+
ai-usage # open dashboard
|
|
62
|
+
ai-usage --static --year 2026 # report, only 2026 sessions
|
|
61
63
|
ai-usage --json | jq .summary # pipe to jq
|
|
62
64
|
ai-usage --md > report.md # save as markdown
|
|
63
|
-
ai-usage --top 20
|
|
65
|
+
ai-usage --static --top 20 # show top 20 sessions
|
|
64
66
|
|
|
65
67
|
Environment overrides (per-tool data path):
|
|
66
|
-
CLAUDE_HOME, CODEX_HOME, OPENCODE_HOME,
|
|
67
|
-
|
|
68
|
+
CLAUDE_HOME, CODEX_HOME, OPENCODE_HOME,
|
|
69
|
+
ANTIGRAVITY_HOME, GEMINI_HOME,
|
|
68
70
|
AI_USAGE_PATHS_JSON='{"codex":"/custom/path",...}'
|
|
69
71
|
|
|
70
72
|
Supported tools:
|
|
71
73
|
• Claude Code — ~/.claude/projects (tokens from per-message usage)
|
|
72
74
|
• Codex — ~/.codex/sessions (tokens from token_count events)
|
|
73
75
|
• OpenCode — ~/.local/share/opencode/opencode.db (tokens + cost)
|
|
74
|
-
• MimoCode — ~/.local/share/mimocode/mimocode.db (tokens + cost)
|
|
75
|
-
• GitHub Copilot — ~/.copilot/session-state (presence only)
|
|
76
76
|
• Antigravity — ~/Library/Application Support/Antigravity (presence only)
|
|
77
77
|
• Gemini CLI — ~/.gemini/antigravity/conversations (presence only)
|
|
78
78
|
`);
|
|
@@ -85,6 +85,10 @@ if (jsonOut && mdOut) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
async function main() {
|
|
88
|
+
if (!jsonOut && !mdOut && !staticOut) {
|
|
89
|
+
await startDashboard();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
88
92
|
const t0 = Date.now();
|
|
89
93
|
const detections = detectAll();
|
|
90
94
|
const { records: allRecords, errors } = await loadAll(detections);
|
|
@@ -101,9 +105,13 @@ async function main() {
|
|
|
101
105
|
const range = dateRange(records);
|
|
102
106
|
const tot = overall(records);
|
|
103
107
|
|
|
108
|
+
const quotaPromise = quotaOut ? collectQuotas() : null;
|
|
109
|
+
|
|
104
110
|
if (jsonOut) {
|
|
111
|
+
const quotas = quotaPromise ? await quotaPromise : null;
|
|
105
112
|
const out = {
|
|
106
113
|
detections,
|
|
114
|
+
...(quotas ? { quotas } : {}),
|
|
107
115
|
summary: tot,
|
|
108
116
|
dateRange: range,
|
|
109
117
|
sessions: records,
|
|
@@ -125,22 +133,18 @@ async function main() {
|
|
|
125
133
|
topN,
|
|
126
134
|
generatedAt: new Date().toISOString(),
|
|
127
135
|
});
|
|
128
|
-
|
|
136
|
+
const quota = quotaPromise ? await quotaPromise : null;
|
|
137
|
+
process.stdout.write(out + (quota ? `\n## Quotas\n\n\`\`\`\n${renderQuota(quota)}\n\`\`\`\n` : ""));
|
|
129
138
|
return;
|
|
130
139
|
}
|
|
131
140
|
|
|
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
141
|
// Static fallback (--static, --no-interactive, or piped output)
|
|
139
142
|
const sections = [
|
|
140
143
|
renderHeader({
|
|
141
144
|
totalSessions: records.length,
|
|
142
145
|
totalTokens: tot.tokensTotal,
|
|
143
146
|
totalCost: tot.cost,
|
|
147
|
+
costStatus: tot.costStatus,
|
|
144
148
|
dateRange: range,
|
|
145
149
|
}),
|
|
146
150
|
renderDetections(detections),
|
|
@@ -157,6 +161,7 @@ async function main() {
|
|
|
157
161
|
);
|
|
158
162
|
}
|
|
159
163
|
sections.push(renderNotes(detections, errors));
|
|
164
|
+
if (quotaPromise) sections.push(renderQuota(await quotaPromise));
|
|
160
165
|
|
|
161
166
|
console.log(sections.join("\n\n"));
|
|
162
167
|
}
|
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.1",
|
|
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",
|
|
@@ -27,12 +28,10 @@
|
|
|
27
28
|
"claude-code",
|
|
28
29
|
"codex",
|
|
29
30
|
"opencode",
|
|
30
|
-
"mimocode",
|
|
31
|
-
"copilot",
|
|
32
31
|
"antigravity",
|
|
33
32
|
"gemini",
|
|
34
|
-
"
|
|
35
|
-
"
|
|
33
|
+
"cli",
|
|
34
|
+
"dashboard"
|
|
36
35
|
],
|
|
37
36
|
"author": "ade",
|
|
38
37
|
"license": "MIT",
|
|
@@ -48,6 +47,7 @@
|
|
|
48
47
|
"boxen": "^7.1.1",
|
|
49
48
|
"chalk": "^5.3.0",
|
|
50
49
|
"cli-table3": "^0.6.5",
|
|
51
|
-
"gradient-string": "^2.0.2"
|
|
50
|
+
"gradient-string": "^2.0.2",
|
|
51
|
+
"gsap": "^3.15.0"
|
|
52
52
|
}
|
|
53
53
|
}
|
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
|
}
|
|
@@ -121,8 +128,23 @@ export function perToolPerMonth(records) {
|
|
|
121
128
|
return out.sort((a, b) => b.tokensTotal - a.tokensTotal);
|
|
122
129
|
}
|
|
123
130
|
|
|
124
|
-
export function overall(records) {
|
|
125
|
-
|
|
131
|
+
export function overall(records, dayCount) {
|
|
132
|
+
const totals = summarize(records);
|
|
133
|
+
if (dayCount === undefined) {
|
|
134
|
+
let first = Infinity;
|
|
135
|
+
let last = -Infinity;
|
|
136
|
+
for (const record of records) {
|
|
137
|
+
if (!Number.isFinite(record.ts) || !Number.isFinite(new Date(record.ts).getTime())) continue;
|
|
138
|
+
const day = Math.floor(record.ts / 86400000);
|
|
139
|
+
first = Math.min(first, day);
|
|
140
|
+
last = Math.max(last, day);
|
|
141
|
+
}
|
|
142
|
+
dayCount = last - first + 1;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
...totals,
|
|
146
|
+
avgDailyTokens: Number.isFinite(dayCount) && dayCount > 0 ? totals.tokensTotal / dayCount : 0,
|
|
147
|
+
};
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
export function topSessions(records, n = 5) {
|
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
|
+
}
|