ai-usage-analyzer 0.3.2 → 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 CHANGED
@@ -1,17 +1,11 @@
1
1
  # ai-usage-analyzer
2
2
 
3
- TUI analyzer for local AI coding agent token consumption. Auto-detects
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 — no hardcoded paths.
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 # default TUI
35
- ai-usage-analyzer --top 10 # show top 10 heaviest sessions
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` | presence only |
45
- | Codex | `~/.codex/sessions/YYYY/MM/DD/` | yes |
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
- ├── render.js TUI • markdown.js → Markdown report
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
@@ -2,30 +2,41 @@
2
2
  // AI Usage Analyzer - TUI for local AI coding agent token consumption
3
3
  // Auto-detects: Claude Code, Codex, OpenCode, MimoCode, Copilot, Antigravity, Gemini
4
4
 
5
- import { detectAll } from '../src/detectors.js';
6
- import { loadAll, dateRange } from '../src/loaders.js';
7
- import { overall } from '../src/aggregate.js';
5
+ import { overall } from "../src/aggregate.js";
6
+ import { detectAll } from "../src/detectors.js";
7
+ import { dateRange, loadAll } from "../src/loaders.js";
8
+ import { renderMarkdown } from "../src/markdown.js";
8
9
  import {
9
- renderHeader, renderDetections, renderOverview,
10
- renderPerProject, renderPerTool, renderPerToolPerMonth,
11
- renderPerMonth, renderPerWeek,
12
- renderTopSessions, renderNotes,
13
- } from '../src/render.js';
14
- import { renderMarkdown } from '../src/markdown.js';
10
+ renderDetections,
11
+ renderHeader,
12
+ renderNotes,
13
+ renderOverview,
14
+ renderPerMonth,
15
+ renderPerProject,
16
+ renderPerTool,
17
+ renderPerToolPerMonth,
18
+ renderPerWeek,
19
+ renderTopSessions,
20
+ } from "../src/render.js";
21
+ import { startDashboard } from "../src/server.js";
22
+ import { collectQuotas, renderQuota } from "../src/quota.js";
15
23
 
16
24
  const args = process.argv.slice(2);
17
- const hasFlag = (name) => args.includes(`--${name}`) || args.includes(`-${name}`);
18
- const showHelp = hasFlag('help') || hasFlag('h');
19
- const jsonOut = hasFlag('json');
20
- const mdOut = hasFlag('markdown') || hasFlag('md');
25
+ const hasFlag = (name) =>
26
+ args.includes(`--${name}`) || args.includes(`-${name}`);
27
+ const showHelp = hasFlag("help") || hasFlag("h");
28
+ const jsonOut = hasFlag("json");
29
+ const mdOut = hasFlag("markdown") || hasFlag("md");
30
+ const quotaOut = hasFlag("quota");
31
+ const staticOut = hasFlag("static") || hasFlag("no-interactive");
21
32
  const topN = (() => {
22
- const i = args.indexOf('--top');
33
+ const i = args.indexOf("--top");
23
34
  if (i < 0) return 5;
24
35
  const v = parseInt(args[i + 1], 10);
25
36
  return Number.isFinite(v) && v > 0 ? v : 5;
26
37
  })();
27
38
  const yearFilter = (() => {
28
- const i = args.indexOf('--year');
39
+ const i = args.indexOf("--year");
29
40
  if (i < 0) return null;
30
41
  const v = parseInt(args[i + 1], 10);
31
42
  return Number.isFinite(v) && v > 1970 && v < 3000 ? v : null;
@@ -33,24 +44,26 @@ const yearFilter = (() => {
33
44
 
34
45
  if (showHelp) {
35
46
  console.log(`
36
- ai-usage-analyzer — local AI coding agent token consumption TUI
47
+ ai-usage-analyzer — local AI coding agent usage dashboard
37
48
 
38
49
  Usage:
39
50
  ai-usage [options]
40
51
 
41
52
  Options:
42
- -h, --help Show this help
43
- --json Output machine-readable JSON instead of TUI
44
- --markdown, --md Output as a Markdown report (GitHub-flavored tables)
45
- --top N Show top N heaviest sessions (default: 5)
46
- --year YYYY Filter records to a single year (e.g. --year 2026)
53
+ -h, --help Show this help
54
+ --json Output machine-readable JSON instead of TUI
55
+ --markdown, --md Output as a Markdown report (GitHub-flavored tables)
56
+ --static, --no-interactive Disable interactive mode; dump all sections at once
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)
47
60
 
48
61
  Examples:
49
- ai-usage # default TUI
50
- ai-usage --year 2026 # TUI, only 2026 sessions
62
+ ai-usage # open dashboard
63
+ ai-usage --static --year 2026 # report, only 2026 sessions
51
64
  ai-usage --json | jq .summary # pipe to jq
52
65
  ai-usage --md > report.md # save as markdown
53
- ai-usage --top 20 # show top 20 sessions
66
+ ai-usage --static --top 20 # show top 20 sessions
54
67
 
55
68
  Environment overrides (per-tool data path):
56
69
  CLAUDE_HOME, CODEX_HOME, OPENCODE_HOME, MIMOCODE_HOME,
@@ -70,27 +83,38 @@ Supported tools:
70
83
  }
71
84
 
72
85
  if (jsonOut && mdOut) {
73
- console.error('Error: --json and --markdown are mutually exclusive.');
86
+ console.error("Error: --json and --markdown are mutually exclusive.");
74
87
  process.exit(2);
75
88
  }
76
89
 
77
90
  async function main() {
91
+ if (!jsonOut && !mdOut && !staticOut) {
92
+ await startDashboard();
93
+ return;
94
+ }
78
95
  const t0 = Date.now();
79
96
  const detections = detectAll();
80
97
  const { records: allRecords, errors } = await loadAll(detections);
81
98
 
82
99
  // Apply --year filter before any aggregation so dateRange and totals
83
100
  // reflect the filtered set.
84
- const records = yearFilter !== null
85
- ? allRecords.filter(r => r.month && r.month.startsWith(`${yearFilter}-`))
86
- : allRecords;
101
+ const records =
102
+ yearFilter !== null
103
+ ? allRecords.filter(
104
+ (r) => r.month && r.month.startsWith(`${yearFilter}-`),
105
+ )
106
+ : allRecords;
87
107
 
88
108
  const range = dateRange(records);
89
109
  const tot = overall(records);
90
110
 
111
+ const quotaPromise = quotaOut ? collectQuotas() : null;
112
+
91
113
  if (jsonOut) {
114
+ const quotas = quotaPromise ? await quotaPromise : null;
92
115
  const out = {
93
116
  detections,
117
+ ...(quotas ? { quotas } : {}),
94
118
  summary: tot,
95
119
  dateRange: range,
96
120
  sessions: records,
@@ -105,19 +129,25 @@ async function main() {
105
129
 
106
130
  if (mdOut) {
107
131
  const out = renderMarkdown({
108
- records, detections, errors,
109
- dateRange: range, topN, generatedAt: new Date().toISOString(),
132
+ records,
133
+ detections,
134
+ errors,
135
+ dateRange: range,
136
+ topN,
137
+ generatedAt: new Date().toISOString(),
110
138
  });
111
- process.stdout.write(out);
139
+ const quota = quotaPromise ? await quotaPromise : null;
140
+ process.stdout.write(out + (quota ? `\n## Quotas\n\n\`\`\`\n${renderQuota(quota)}\n\`\`\`\n` : ""));
112
141
  return;
113
142
  }
114
143
 
115
- // TUI render
144
+ // Static fallback (--static, --no-interactive, or piped output)
116
145
  const sections = [
117
146
  renderHeader({
118
147
  totalSessions: records.length,
119
148
  totalTokens: tot.tokensTotal,
120
149
  totalCost: tot.cost,
150
+ costStatus: tot.costStatus,
121
151
  dateRange: range,
122
152
  }),
123
153
  renderDetections(detections),
@@ -134,11 +164,12 @@ async function main() {
134
164
  );
135
165
  }
136
166
  sections.push(renderNotes(detections, errors));
167
+ if (quotaPromise) sections.push(renderQuota(await quotaPromise));
137
168
 
138
- console.log(sections.join('\n\n'));
169
+ console.log(sections.join("\n\n"));
139
170
  }
140
171
 
141
- main().catch(err => {
142
- console.error('Fatal error:', err);
172
+ main().catch((err) => {
173
+ console.error("Fatal error:", err);
143
174
  process.exit(1);
144
175
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ai-usage-analyzer",
3
- "version": "0.3.2",
4
- "description": "TUI analyzer for local AI coding agent token usage. Auto-detects Claude Code, Codex, OpenCode, MimoCode, Copilot, Antigravity, and Gemini.",
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
- "tui",
35
- "cli"
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
@@ -1,9 +1,18 @@
1
1
  // Group session records by various dimensions and compute stats.
2
2
 
3
3
  const MONTH_NAMES = {
4
- '01': 'Jan', '02': 'Feb', '03': 'Mar', '04': 'Apr',
5
- '05': 'May', '06': 'Jun', '07': 'Jul', '08': 'Aug',
6
- '09': 'Sep', '10': 'Oct', '11': 'Nov', '12': 'Dec',
4
+ "01": "Jan",
5
+ "02": "Feb",
6
+ "03": "Mar",
7
+ "04": "Apr",
8
+ "05": "May",
9
+ "06": "Jun",
10
+ "07": "Jul",
11
+ "08": "Aug",
12
+ "09": "Sep",
13
+ 10: "Oct",
14
+ 11: "Nov",
15
+ 12: "Dec",
7
16
  };
8
17
 
9
18
  function sum(arr, key) {
@@ -25,16 +34,23 @@ export function groupBy(records, keyFn) {
25
34
  }
26
35
 
27
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 || []))];
28
42
  return {
29
43
  n: records.length,
30
- tokensTotal: sum(records, 'tokensTotal'),
31
- tokensInput: sum(records, 'tokensInput'),
32
- tokensOutput: sum(records, 'tokensOutput'),
33
- tokensCacheRead: sum(records, 'tokensCacheRead'),
34
- tokensCacheWrite: sum(records, 'tokensCacheWrite'),
35
- tokensReasoning: sum(records, 'tokensReasoning'),
36
- cost: sum(records, 'cost'),
37
- avg: avg(records, 'tokensTotal'),
44
+ tokensTotal: sum(records, "tokensTotal"),
45
+ tokensInput: sum(records, "tokensInput"),
46
+ tokensOutput: sum(records, "tokensOutput"),
47
+ tokensCacheRead: sum(records, "tokensCacheRead"),
48
+ tokensCacheWrite: sum(records, "tokensCacheWrite"),
49
+ tokensReasoning: sum(records, "tokensReasoning"),
50
+ cost: sum(records, "cost"),
51
+ costStatus,
52
+ unknownModels,
53
+ avg: avg(records, "tokensTotal"),
38
54
  };
39
55
  }
40
56
 
@@ -46,21 +62,34 @@ function toolBreakdown(records) {
46
62
  return byTool;
47
63
  }
48
64
 
65
+ function toolSessionCount(records) {
66
+ const nByTool = {};
67
+ for (const r of records) {
68
+ nByTool[r.tool] = (nByTool[r.tool] || 0) + 1;
69
+ }
70
+ return nByTool;
71
+ }
72
+
49
73
  export function perProject(records) {
50
74
  // One row per project — tool mix is shown via the stacked bar / byTool,
51
75
  // not as a separate column. Detailed per-tool-per-project breakdown
52
76
  // is no longer surfaced here; the "Per Tool per Month" section is the
53
77
  // place to see per-tool data over time.
54
- const m = groupBy(records, r => r.project);
78
+ const m = groupBy(records, (r) => r.project);
55
79
  const out = [];
56
80
  for (const [project, arr] of m) {
57
- out.push({ project, ...summarize(arr), byTool: toolBreakdown(arr) });
81
+ out.push({
82
+ project,
83
+ ...summarize(arr),
84
+ byTool: toolBreakdown(arr),
85
+ nByTool: toolSessionCount(arr),
86
+ });
58
87
  }
59
88
  return out.sort((a, b) => b.tokensTotal - a.tokensTotal);
60
89
  }
61
90
 
62
91
  export function perMonth(records) {
63
- const m = groupBy(records, r => r.month);
92
+ const m = groupBy(records, (r) => r.month);
64
93
  const out = [];
65
94
  for (const [month, arr] of m) {
66
95
  out.push({ month, ...summarize(arr), byTool: toolBreakdown(arr) });
@@ -69,7 +98,7 @@ export function perMonth(records) {
69
98
  }
70
99
 
71
100
  export function perWeek(records) {
72
- const m = groupBy(records, r => r.week);
101
+ const m = groupBy(records, (r) => r.week);
73
102
  const out = [];
74
103
  for (const [week, arr] of m) {
75
104
  out.push({ week, ...summarize(arr), byTool: toolBreakdown(arr) });
@@ -78,7 +107,7 @@ export function perWeek(records) {
78
107
  }
79
108
 
80
109
  export function perTool(records) {
81
- const m = groupBy(records, r => r.tool);
110
+ const m = groupBy(records, (r) => r.tool);
82
111
  const out = [];
83
112
  for (const [tool, arr] of m) {
84
113
  out.push({ tool, ...summarize(arr) });
@@ -90,10 +119,10 @@ export function perToolPerMonth(records) {
90
119
  // Cross-tab: one row per (tool, month). Lets you see how a single tool's
91
120
  // usage is distributed across months — and avoids the hardcoded OC/CX/MM
92
121
  // column problem in the per-month table.
93
- const m = groupBy(records, r => `${r.tool}\u0001${r.month}`);
122
+ const m = groupBy(records, (r) => `${r.tool}\u0001${r.month}`);
94
123
  const out = [];
95
124
  for (const [k, arr] of m) {
96
- const [tool, month] = k.split('\u0001');
125
+ const [tool, month] = k.split("\u0001");
97
126
  out.push({ tool, month, ...summarize(arr) });
98
127
  }
99
128
  return out.sort((a, b) => b.tokensTotal - a.tokensTotal);
@@ -104,9 +133,7 @@ export function overall(records) {
104
133
  }
105
134
 
106
135
  export function topSessions(records, n = 5) {
107
- return [...records]
108
- .sort((a, b) => b.tokensTotal - a.tokensTotal)
109
- .slice(0, n);
136
+ return [...records].sort((a, b) => b.tokensTotal - a.tokensTotal).slice(0, n);
110
137
  }
111
138
 
112
139
  export function tokenBreakdown(s) {
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
+ }