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 +30 -15
- package/bin/ai-usage.js +66 -35
- package/package.json +8 -6
- package/src/aggregate.js +48 -21
- package/src/live.js +132 -0
- package/src/loaders.js +259 -114
- package/src/markdown.js +133 -70
- package/src/pricing.js +49 -0
- package/src/quota.js +194 -0
- package/src/render.js +512 -189
- package/src/server.js +67 -0
- package/src/tools.js +5 -5
- package/src/tui.js +326 -0
- 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
|
@@ -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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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) =>
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
43
|
-
--json
|
|
44
|
-
--markdown, --md
|
|
45
|
-
--
|
|
46
|
-
--
|
|
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 #
|
|
50
|
-
ai-usage --year 2026
|
|
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
|
|
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(
|
|
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 =
|
|
85
|
-
|
|
86
|
-
|
|
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,
|
|
109
|
-
|
|
132
|
+
records,
|
|
133
|
+
detections,
|
|
134
|
+
errors,
|
|
135
|
+
dateRange: range,
|
|
136
|
+
topN,
|
|
137
|
+
generatedAt: new Date().toISOString(),
|
|
110
138
|
});
|
|
111
|
-
|
|
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
|
-
//
|
|
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(
|
|
169
|
+
console.log(sections.join("\n\n"));
|
|
139
170
|
}
|
|
140
171
|
|
|
141
|
-
main().catch(err => {
|
|
142
|
-
console.error(
|
|
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.
|
|
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
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
// Group session records by various dimensions and compute stats.
|
|
2
2
|
|
|
3
3
|
const MONTH_NAMES = {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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,
|
|
31
|
-
tokensInput: sum(records,
|
|
32
|
-
tokensOutput: sum(records,
|
|
33
|
-
tokensCacheRead: sum(records,
|
|
34
|
-
tokensCacheWrite: sum(records,
|
|
35
|
-
tokensReasoning: sum(records,
|
|
36
|
-
cost: sum(records,
|
|
37
|
-
|
|
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({
|
|
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(
|
|
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
|
+
}
|