burntokens 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/dist/aggregate.js +218 -0
- package/dist/cli.js +240 -0
- package/dist/config.js +47 -0
- package/dist/cost.js +48 -0
- package/dist/discover.js +60 -0
- package/dist/footprint.js +69 -0
- package/dist/footprint.json +24 -0
- package/dist/format.js +55 -0
- package/dist/live.js +22 -0
- package/dist/models.js +24 -0
- package/dist/parse.js +148 -0
- package/dist/pricing.json +28 -0
- package/dist/server.js +166 -0
- package/dist/statusline.js +57 -0
- package/dist/store.js +168 -0
- package/dist/types.js +1 -0
- package/dist/watch.js +33 -0
- package/dist/web/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/web/assets/index-4RP3ZSIk.js +216 -0
- package/dist/web/assets/index-APFoPC3L.css +1 -0
- package/dist/web/assets/instrument-serif-latin-400-italic-DKMiL14s.woff2 +0 -0
- package/dist/web/assets/instrument-serif-latin-400-normal-DnYpCC2O.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +21 -0
- package/package.json +67 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sanket Gangani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,115 @@
|
|
|
1
|
-
#
|
|
1
|
+
# TokenBurn 🔥
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Watch your Claude Code tokens burn in real time. Local, open source, slightly judgmental.**
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx burntokens
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
TokenBurn reads the transcript logs Claude Code already writes on your machine and turns them into a live dashboard: a big glowing counter that ticks while you work, history by day / week / month / project / model, what it all *would* have cost at API prices, and a lake that slowly drains as your estimated water footprint grows.
|
|
10
|
+
|
|
11
|
+
It runs 100% locally. No accounts, no telemetry, nothing leaves your machine.
|
|
12
|
+
|
|
13
|
+
> Not affiliated with or endorsed by Anthropic.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Requires Node 20+. The npm package is called `burntokens` (the name `tokenburn` was too close to an existing package). Once installed, the command is `tokenburn` (`burntokens` works too).
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx burntokens # one-off: parse logs, start the dashboard, open the browser
|
|
21
|
+
npm i -g burntokens # or install globally; this gives you the `tokenburn` command
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Modes
|
|
25
|
+
|
|
26
|
+
| Command | What it does |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `tokenburn` | Dashboard at `http://127.0.0.1:4242`, live-updating as you use Claude Code |
|
|
29
|
+
| `tokenburn --summary` | Compact table in the terminal |
|
|
30
|
+
| `tokenburn --json` | All aggregates as JSON, for scripting |
|
|
31
|
+
| `tokenburn statusline` | One line for Claude Code's custom status line |
|
|
32
|
+
|
|
33
|
+
Options: `--dir <path>` (logs location), `--port <n>`, `--no-open`, `--no-cache`, `--days <n>`, `--project <key>`, `--model <id>`. Run `tokenburn --help` for the full list.
|
|
34
|
+
|
|
35
|
+
## Status line
|
|
36
|
+
|
|
37
|
+
Claude Code can show a custom status line under its prompt. Add this to `~/.claude/settings.json`:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"statusLine": {
|
|
42
|
+
"type": "command",
|
|
43
|
+
"command": "tokenburn statusline"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
It prints something like:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
302.6M session · 🔥 331.2M today · $128.73 api-eq · ≈11 water bottles
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Claude Code passes the current `session_id` on stdin, which is how the session figure is found. The command only reads files modified today (plus the current session's file), so it stays fast with gigabytes of history. Use a global install (`npm i -g burntokens`) rather than `npx` here so it does not hit the network on every refresh.
|
|
55
|
+
|
|
56
|
+
## Where the numbers come from
|
|
57
|
+
|
|
58
|
+
**Tokens.** Claude Code writes JSONL transcripts to `~/.claude/projects/**/*.jsonl` (or `$CLAUDE_CONFIG_DIR/projects`). Each assistant turn carries `model` and `usage` with input, output, cache-write and cache-read token counts. The same message is written several times (once per content block), so TokenBurn de-duplicates on message id + request id across all files. Subagent transcripts are included. The observed format is documented in [docs/log-format.md](docs/log-format.md).
|
|
59
|
+
|
|
60
|
+
**Cost.** Every cost in the UI is **API-equivalent**: what the same tokens would cost on pay-as-you-go at the prices on [Anthropic's pricing page](https://platform.claude.com/docs/en/about-claude/pricing). Prices live in [`src/pricing.json`](src/pricing.json) with the date they were checked. If you are on a Pro or Max subscription you are not billed per token, so treat it as a "what if". Models missing from the table are shown with cost "unknown" rather than a guess. Cache writes are priced by TTL (5-minute vs 1-hour) when the log records the split.
|
|
61
|
+
|
|
62
|
+
**Water and energy (the lake).** There is no official per-token figure from Anthropic, so this is deliberately labelled *rough estimate, for fun*. The defaults in [`src/footprint.json`](src/footprint.json):
|
|
63
|
+
|
|
64
|
+
- 600 Wh per million **output** tokens, derived from the widely cited ~0.3 Wh per typical chat response of a few hundred tokens ([Epoch AI](https://epoch.ai/gradient-updates/how-much-energy-does-chatgpt-use)) and consistent with the ~0.34 Wh median for frontier-scale models in [Energy Use of AI Inference (2025)](https://arxiv.org/abs/2509.20241).
|
|
65
|
+
- Input, cache-write and cache-read tokens are weighted at 0.1, 0.1 and 0.01 of an output token. Prefill is far cheaper than generation and cache hits cheaper still; the exact ratios are a judgement call, not a measurement.
|
|
66
|
+
- 2 litres of water per kWh, a blend of on-site cooling and a share of power-generation water ([Making AI Less "Thirsty"](https://arxiv.org/abs/2304.03271)).
|
|
67
|
+
|
|
68
|
+
The result is then expressed on a ladder: sips → cups → bottles → buckets → bathtubs → hot tubs → backyard pools → Olympic pools → a small lake. The lake drains on a log scale from one sip to one small lake. Every factor is editable in the dashboard settings and saved to `~/.tokenburn/config.json`. Please do not quote these numbers as fact.
|
|
69
|
+
|
|
70
|
+
## Privacy
|
|
71
|
+
|
|
72
|
+
- Reads log files only. Never writes to them.
|
|
73
|
+
- Reads only the `usage`, `model`, `timestamp`, id and `cwd` fields. Prompt and response content is never parsed, stored or displayed.
|
|
74
|
+
- The server binds to `127.0.0.1` only and rejects cross-origin requests.
|
|
75
|
+
- No network calls, ever. No fonts, analytics or update checks.
|
|
76
|
+
- The only files TokenBurn writes are `~/.tokenburn/config.json` (your settings) and `~/.tokenburn/cache.json` (parsed token counts, so large histories load fast). Delete them any time; `--no-cache` skips the cache.
|
|
77
|
+
|
|
78
|
+
## FAQ
|
|
79
|
+
|
|
80
|
+
**Does this include claude.ai web or desktop chat usage?**
|
|
81
|
+
No. Those don't expose token counts and scraping undocumented endpoints is fragile. TokenBurn covers Claude Code only.
|
|
82
|
+
|
|
83
|
+
**Why is cost "unknown" for some sessions?**
|
|
84
|
+
The model id isn't in `src/pricing.json`. Add it (or open a PR) and the cost appears.
|
|
85
|
+
|
|
86
|
+
**Why are cache reads such a big share?**
|
|
87
|
+
Claude Code sends your whole conversation on every turn and most of it is served from the prompt cache. That's normal, and it's why cache-read pricing is ~10x cheaper. Use "Without cache reads" on the tokens chart to see the rest.
|
|
88
|
+
|
|
89
|
+
**The numbers don't match `/usage` exactly.**
|
|
90
|
+
`/usage` is per session and live. TokenBurn de-duplicates across files and counts subagent usage, so small differences are expected. Large differences are a bug: please file an issue with `tokenburn --json` output (it contains no content).
|
|
91
|
+
|
|
92
|
+
**Logs live somewhere else.**
|
|
93
|
+
`tokenburn --dir /path/to/.claude` or set `CLAUDE_CONFIG_DIR`.
|
|
94
|
+
|
|
95
|
+
**Windows?**
|
|
96
|
+
Logs are read from `%USERPROFILE%\.claude\projects`. The dashboard and summary modes work; the status line works with `tokenburn statusline` too.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm install
|
|
102
|
+
npm start # build everything, then run the dashboard (easiest)
|
|
103
|
+
npm run dev # build the dashboard, run the CLI from TypeScript source
|
|
104
|
+
npm test # vitest, synthetic fixtures only
|
|
105
|
+
npm run build # tsc + vite → dist/
|
|
106
|
+
npm run dev:web # vite dev server for the dashboard (proxies /api to :4242)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
If port 4242 is taken (for example an older TokenBurn is still running), the next free port is used and printed in the terminal.
|
|
110
|
+
|
|
111
|
+
Layout: `src/` is the CLI + server (discover → parse → aggregate → cost/footprint → server/watch), `web/` is the Vite + React dashboard, `test/fixtures/` holds synthetic JSONL (no real data).
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT. Not affiliated with or endorsed by Anthropic. Costs are estimates; water and energy figures are rough and for fun.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { costBreakdownOfEvent, costOf, costOfEvent } from "./cost.js";
|
|
2
|
+
export function filterEvents(events, f = {}) {
|
|
3
|
+
const projects = f.projects?.length ? new Set(f.projects) : null;
|
|
4
|
+
const models = f.models?.length ? new Set(f.models) : null;
|
|
5
|
+
return events.filter((e) => (f.from === undefined || e.ts >= f.from) &&
|
|
6
|
+
(f.to === undefined || e.ts < f.to) &&
|
|
7
|
+
(!projects || projects.has(e.project)) &&
|
|
8
|
+
(!models || models.has(e.model)));
|
|
9
|
+
}
|
|
10
|
+
export function emptyTotals() {
|
|
11
|
+
return { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0 };
|
|
12
|
+
}
|
|
13
|
+
export function addEvent(t, e) {
|
|
14
|
+
t.input += e.input;
|
|
15
|
+
t.output += e.output;
|
|
16
|
+
t.cacheWrite += e.cacheWrite5m + e.cacheWrite1h;
|
|
17
|
+
t.cacheRead += e.cacheRead;
|
|
18
|
+
t.total += e.input + e.output + e.cacheWrite5m + e.cacheWrite1h + e.cacheRead;
|
|
19
|
+
return t;
|
|
20
|
+
}
|
|
21
|
+
export function tokenTotals(events) {
|
|
22
|
+
const t = emptyTotals();
|
|
23
|
+
for (const e of events)
|
|
24
|
+
addEvent(t, e);
|
|
25
|
+
return t;
|
|
26
|
+
}
|
|
27
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
28
|
+
/** Local-time calendar day: YYYY-MM-DD */
|
|
29
|
+
export function dayKey(ts) {
|
|
30
|
+
const d = new Date(ts);
|
|
31
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
32
|
+
}
|
|
33
|
+
/** Local-time month: YYYY-MM */
|
|
34
|
+
export function monthKey(ts) {
|
|
35
|
+
const d = new Date(ts);
|
|
36
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
|
|
37
|
+
}
|
|
38
|
+
/** Local-time week, keyed by its Monday: YYYY-MM-DD */
|
|
39
|
+
export function weekKey(ts) {
|
|
40
|
+
return dayKey(startOfWeek(ts));
|
|
41
|
+
}
|
|
42
|
+
export function startOfDay(ts) {
|
|
43
|
+
const d = new Date(ts);
|
|
44
|
+
d.setHours(0, 0, 0, 0);
|
|
45
|
+
return d.getTime();
|
|
46
|
+
}
|
|
47
|
+
export function startOfWeek(ts) {
|
|
48
|
+
const d = new Date(startOfDay(ts));
|
|
49
|
+
const dow = (d.getDay() + 6) % 7; // Monday = 0
|
|
50
|
+
d.setDate(d.getDate() - dow);
|
|
51
|
+
return d.getTime();
|
|
52
|
+
}
|
|
53
|
+
export function startOfMonth(ts) {
|
|
54
|
+
const d = new Date(ts);
|
|
55
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
56
|
+
}
|
|
57
|
+
export function groupBy(events, keyOf) {
|
|
58
|
+
const map = new Map();
|
|
59
|
+
for (const e of events) {
|
|
60
|
+
const k = keyOf(e);
|
|
61
|
+
let b = map.get(k);
|
|
62
|
+
if (!b) {
|
|
63
|
+
b = { key: k, ...emptyTotals(), usd: 0, unknownTokens: 0, requests: 0 };
|
|
64
|
+
map.set(k, b);
|
|
65
|
+
}
|
|
66
|
+
addEvent(b, e);
|
|
67
|
+
b.requests++;
|
|
68
|
+
const c = costOfEvent(e);
|
|
69
|
+
if (c === null)
|
|
70
|
+
b.unknownTokens += e.input + e.output + e.cacheWrite5m + e.cacheWrite1h + e.cacheRead;
|
|
71
|
+
else
|
|
72
|
+
b.usd += c;
|
|
73
|
+
}
|
|
74
|
+
return [...map.values()].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
75
|
+
}
|
|
76
|
+
export const byDay = (ev) => groupBy(ev, (e) => dayKey(e.ts));
|
|
77
|
+
export const byWeek = (ev) => groupBy(ev, (e) => weekKey(e.ts));
|
|
78
|
+
export const byMonth = (ev) => groupBy(ev, (e) => monthKey(e.ts));
|
|
79
|
+
export const byModel = (ev) => groupBy(ev, (e) => e.model).sort((a, b) => b.total - a.total);
|
|
80
|
+
export function byProject(ev) {
|
|
81
|
+
const labels = new Map();
|
|
82
|
+
const list = [...ev];
|
|
83
|
+
for (const e of list)
|
|
84
|
+
if (!labels.has(e.project))
|
|
85
|
+
labels.set(e.project, e.projectLabel);
|
|
86
|
+
return groupBy(list, (e) => e.project)
|
|
87
|
+
.map((b) => ({ ...b, label: labels.get(b.key) ?? b.key }))
|
|
88
|
+
.sort((a, b) => b.total - a.total);
|
|
89
|
+
}
|
|
90
|
+
/** Fill in missing days between first and last so charts show gaps honestly. */
|
|
91
|
+
export function fillDays(buckets, from, to) {
|
|
92
|
+
if (!buckets.length && from === undefined)
|
|
93
|
+
return [];
|
|
94
|
+
const start = from !== undefined ? startOfDay(from) : Date.parse(buckets[0].key + "T00:00");
|
|
95
|
+
const end = to !== undefined ? startOfDay(to - 1) : Date.parse(buckets[buckets.length - 1].key + "T00:00");
|
|
96
|
+
const have = new Map(buckets.map((b) => [b.key, b]));
|
|
97
|
+
const out = [];
|
|
98
|
+
const d = new Date(start);
|
|
99
|
+
while (d.getTime() <= end) {
|
|
100
|
+
const k = dayKey(d.getTime());
|
|
101
|
+
out.push(have.get(k) ?? { key: k, ...emptyTotals(), usd: 0, unknownTokens: 0, requests: 0 });
|
|
102
|
+
d.setDate(d.getDate() + 1);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
export function sessions(events) {
|
|
107
|
+
const map = new Map();
|
|
108
|
+
for (const e of events) {
|
|
109
|
+
let s = map.get(e.sessionId);
|
|
110
|
+
if (!s) {
|
|
111
|
+
s = {
|
|
112
|
+
sessionId: e.sessionId,
|
|
113
|
+
project: e.project,
|
|
114
|
+
projectLabel: e.projectLabel,
|
|
115
|
+
model: e.model,
|
|
116
|
+
models: [],
|
|
117
|
+
start: e.ts,
|
|
118
|
+
end: e.ts,
|
|
119
|
+
durationMs: 0,
|
|
120
|
+
...emptyTotals(),
|
|
121
|
+
usd: 0,
|
|
122
|
+
unknownTokens: 0,
|
|
123
|
+
requests: 0,
|
|
124
|
+
modelTokens: new Map(),
|
|
125
|
+
};
|
|
126
|
+
map.set(e.sessionId, s);
|
|
127
|
+
}
|
|
128
|
+
addEvent(s, e);
|
|
129
|
+
s.requests++;
|
|
130
|
+
s.start = Math.min(s.start, e.ts);
|
|
131
|
+
s.end = Math.max(s.end, e.ts);
|
|
132
|
+
const c = costOfEvent(e);
|
|
133
|
+
if (c === null)
|
|
134
|
+
s.unknownTokens += e.input + e.output + e.cacheWrite5m + e.cacheWrite1h + e.cacheRead;
|
|
135
|
+
else
|
|
136
|
+
s.usd += c;
|
|
137
|
+
const tk = e.input + e.output + e.cacheWrite5m + e.cacheWrite1h + e.cacheRead;
|
|
138
|
+
s.modelTokens.set(e.model, (s.modelTokens.get(e.model) ?? 0) + tk);
|
|
139
|
+
}
|
|
140
|
+
return [...map.values()]
|
|
141
|
+
.map(({ modelTokens, ...s }) => {
|
|
142
|
+
const sorted = [...modelTokens.entries()].sort((a, b) => b[1] - a[1]);
|
|
143
|
+
return { ...s, model: sorted[0]?.[0] ?? s.model, models: sorted.map((x) => x[0]), durationMs: s.end - s.start };
|
|
144
|
+
})
|
|
145
|
+
.sort((a, b) => b.end - a.end);
|
|
146
|
+
}
|
|
147
|
+
export function periodCard(label, events, from, now) {
|
|
148
|
+
const slice = filterEvents(events, { from, to: now + 1 });
|
|
149
|
+
const t = tokenTotals(slice);
|
|
150
|
+
const c = costOf(slice);
|
|
151
|
+
const models = byModel(slice);
|
|
152
|
+
return { label, from, ...t, usd: c.usd, unknownTokens: c.unknownTokens, topModel: models[0]?.key ?? null, requests: slice.length };
|
|
153
|
+
}
|
|
154
|
+
/** Tokens and cost by local hour of day (0-23). */
|
|
155
|
+
export function byHourOfDay(events) {
|
|
156
|
+
const out = Array.from({ length: 24 }, (_, h) => ({ key: String(h), ...emptyTotals(), usd: 0, unknownTokens: 0, requests: 0 }));
|
|
157
|
+
for (const e of events) {
|
|
158
|
+
const b = out[new Date(e.ts).getHours()];
|
|
159
|
+
addEvent(b, e);
|
|
160
|
+
b.requests++;
|
|
161
|
+
const c = costOfEvent(e);
|
|
162
|
+
if (c === null)
|
|
163
|
+
b.unknownTokens += e.input + e.output + e.cacheWrite5m + e.cacheWrite1h + e.cacheRead;
|
|
164
|
+
else
|
|
165
|
+
b.usd += c;
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
export function costByKind(events) {
|
|
170
|
+
const t = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cacheSavings: 0 };
|
|
171
|
+
for (const e of events) {
|
|
172
|
+
const b = costBreakdownOfEvent(e);
|
|
173
|
+
if (!b)
|
|
174
|
+
continue;
|
|
175
|
+
t.input += b.input;
|
|
176
|
+
t.output += b.output;
|
|
177
|
+
t.cacheWrite += b.cacheWrite;
|
|
178
|
+
t.cacheRead += b.cacheRead;
|
|
179
|
+
t.cacheSavings += b.cacheSavings;
|
|
180
|
+
}
|
|
181
|
+
return t;
|
|
182
|
+
}
|
|
183
|
+
export function buildSummary(all, f = {}, now = Date.now(), sessionLimit = 50) {
|
|
184
|
+
const events = filterEvents(all, f);
|
|
185
|
+
const t = tokenTotals(events);
|
|
186
|
+
const c = costOf(events);
|
|
187
|
+
const sess = sessions(events);
|
|
188
|
+
let first = null;
|
|
189
|
+
let last = null;
|
|
190
|
+
for (const e of events) {
|
|
191
|
+
if (first === null || e.ts < first)
|
|
192
|
+
first = e.ts;
|
|
193
|
+
if (last === null || e.ts > last)
|
|
194
|
+
last = e.ts;
|
|
195
|
+
}
|
|
196
|
+
// Period cards respect project/model filters but always use the calendar window.
|
|
197
|
+
const scoped = filterEvents(all, { projects: f.projects, models: f.models });
|
|
198
|
+
return {
|
|
199
|
+
generatedAt: now,
|
|
200
|
+
range: { from: f.from ?? null, to: f.to ?? null },
|
|
201
|
+
totals: { ...t, usd: c.usd, unknownTokens: c.unknownTokens, unknownModels: c.unknownModels, requests: events.length, sessions: sess.length },
|
|
202
|
+
costByKind: costByKind(events),
|
|
203
|
+
hours: byHourOfDay(events),
|
|
204
|
+
periods: {
|
|
205
|
+
today: periodCard("Today", scoped, startOfDay(now), now),
|
|
206
|
+
week: periodCard("This week", scoped, startOfWeek(now), now),
|
|
207
|
+
month: periodCard("This month", scoped, startOfMonth(now), now),
|
|
208
|
+
},
|
|
209
|
+
days: fillDays(byDay(events), f.from, f.to),
|
|
210
|
+
weeks: byWeek(events),
|
|
211
|
+
months: byMonth(events),
|
|
212
|
+
models: byModel(events),
|
|
213
|
+
projects: byProject(events),
|
|
214
|
+
sessions: sess.slice(0, sessionLimit),
|
|
215
|
+
first,
|
|
216
|
+
last,
|
|
217
|
+
};
|
|
218
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { resolveProjectsDir } from "./discover.js";
|
|
7
|
+
import { UsageStore } from "./store.js";
|
|
8
|
+
import { buildSummary, startOfDay } from "./aggregate.js";
|
|
9
|
+
import { footprintOf } from "./footprint.js";
|
|
10
|
+
import { loadConfig } from "./config.js";
|
|
11
|
+
import { modelLabel, pricing } from "./cost.js";
|
|
12
|
+
import { c, fmtDuration, fmtLiters, fmtTokens, fmtUSD, fmtWh } from "./format.js";
|
|
13
|
+
import { createApp } from "./server.js";
|
|
14
|
+
import { watchProjects } from "./watch.js";
|
|
15
|
+
import { statusLine } from "./statusline.js";
|
|
16
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
|
|
18
|
+
const VERSION = pkg.version;
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
const a = { mode: "dashboard", port: 4242, open: true, noCache: false };
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const x = argv[i];
|
|
23
|
+
const next = () => argv[++i];
|
|
24
|
+
if (x === "statusline")
|
|
25
|
+
a.mode = "statusline";
|
|
26
|
+
else if (x === "--summary" || x === "summary")
|
|
27
|
+
a.mode = "summary";
|
|
28
|
+
else if (x === "--json" || x === "json")
|
|
29
|
+
a.mode = "json";
|
|
30
|
+
else if (x === "--help" || x === "-h")
|
|
31
|
+
a.mode = "help";
|
|
32
|
+
else if (x === "--version" || x === "-v")
|
|
33
|
+
a.mode = "version";
|
|
34
|
+
else if (x === "--dir")
|
|
35
|
+
a.dir = next();
|
|
36
|
+
else if (x.startsWith("--dir="))
|
|
37
|
+
a.dir = x.slice(6);
|
|
38
|
+
else if (x === "--port")
|
|
39
|
+
a.port = Number(next());
|
|
40
|
+
else if (x.startsWith("--port="))
|
|
41
|
+
a.port = Number(x.slice(7));
|
|
42
|
+
else if (x === "--no-open")
|
|
43
|
+
a.open = false;
|
|
44
|
+
else if (x === "--no-cache")
|
|
45
|
+
a.noCache = true;
|
|
46
|
+
else if (x === "--days")
|
|
47
|
+
a.days = Number(next());
|
|
48
|
+
else if (x.startsWith("--days="))
|
|
49
|
+
a.days = Number(x.slice(7));
|
|
50
|
+
else if (x === "--project")
|
|
51
|
+
(a.projects ??= []).push(next());
|
|
52
|
+
else if (x === "--model")
|
|
53
|
+
(a.models ??= []).push(next());
|
|
54
|
+
else {
|
|
55
|
+
console.error(`unknown argument: ${x}`);
|
|
56
|
+
a.mode = "help";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return a;
|
|
60
|
+
}
|
|
61
|
+
const HELP = `tokenburn ${VERSION} — watch your Claude Code tokens burn.
|
|
62
|
+
|
|
63
|
+
usage:
|
|
64
|
+
tokenburn parse logs, start the dashboard, open the browser
|
|
65
|
+
tokenburn --summary print a compact table to the terminal
|
|
66
|
+
tokenburn --json dump aggregates as JSON (for scripting)
|
|
67
|
+
tokenburn statusline one-line summary for Claude Code's status line
|
|
68
|
+
|
|
69
|
+
options:
|
|
70
|
+
--dir <path> logs directory (default: ~/.claude/projects or $CLAUDE_CONFIG_DIR)
|
|
71
|
+
--port <n> dashboard port (default 4242, binds to 127.0.0.1 only)
|
|
72
|
+
--no-open do not open the browser
|
|
73
|
+
--no-cache ignore ~/.tokenburn/cache.json and re-read every file
|
|
74
|
+
--days <n> only the last n days (summary/json)
|
|
75
|
+
--project <key> filter by project folder key (repeatable)
|
|
76
|
+
--model <id> filter by model id (repeatable)
|
|
77
|
+
|
|
78
|
+
Costs are API-equivalent estimates. Water/energy numbers are rough and for fun.
|
|
79
|
+
`;
|
|
80
|
+
function filtersFrom(a) {
|
|
81
|
+
const f = {};
|
|
82
|
+
if (a.days)
|
|
83
|
+
f.from = startOfDay(Date.now() - (a.days - 1) * 86_400_000);
|
|
84
|
+
if (a.projects)
|
|
85
|
+
f.projects = a.projects;
|
|
86
|
+
if (a.models)
|
|
87
|
+
f.models = a.models;
|
|
88
|
+
return f;
|
|
89
|
+
}
|
|
90
|
+
function openBrowser(url) {
|
|
91
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
92
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
93
|
+
try {
|
|
94
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* ignore */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function printSummary(store, f) {
|
|
101
|
+
const cfg = loadConfig();
|
|
102
|
+
const s = buildSummary(store.events, f);
|
|
103
|
+
const fp = footprintOf(s.totals, cfg.footprint);
|
|
104
|
+
const row = (label, v) => ` ${c.gray(label.padEnd(14))} ${v}`;
|
|
105
|
+
const t = s.totals;
|
|
106
|
+
console.log("");
|
|
107
|
+
console.log(` ${c.ember("🔥 tokenburn")} ${c.dim(`v${VERSION} · ${store.fileCount} files · ${store.projectsDir}`)}`);
|
|
108
|
+
console.log("");
|
|
109
|
+
console.log(` ${c.bold(c.ember(fmtTokens(t.total)))} ${c.dim("tokens burned all time")} ${c.bold(fmtUSD(t.usd))} ${c.dim("API-equivalent")}`);
|
|
110
|
+
console.log("");
|
|
111
|
+
console.log(row("input", fmtTokens(t.input)));
|
|
112
|
+
console.log(row("output", fmtTokens(t.output)));
|
|
113
|
+
console.log(row("cache write", fmtTokens(t.cacheWrite)));
|
|
114
|
+
console.log(row("cache read", fmtTokens(t.cacheRead)));
|
|
115
|
+
console.log(row("requests", t.requests.toLocaleString("en-US")));
|
|
116
|
+
console.log(row("sessions", t.sessions.toLocaleString("en-US")));
|
|
117
|
+
if (t.unknownModels.length)
|
|
118
|
+
console.log(row("unpriced", `${fmtTokens(t.unknownTokens)} tokens from ${t.unknownModels.join(", ")}`));
|
|
119
|
+
console.log("");
|
|
120
|
+
const period = (p) => `${p.label.padEnd(11)} ${fmtTokens(p.total).padStart(8)} ${fmtUSD(p.usd).padStart(9)} ${p.topModel ? c.dim(modelLabel(p.topModel)) : ""}`;
|
|
121
|
+
console.log(` ${c.bold("period")} ${"tokens".padStart(8)} ${"api-eq".padStart(9)} top model`);
|
|
122
|
+
for (const p of [s.periods.today, s.periods.week, s.periods.month])
|
|
123
|
+
console.log(" " + period(p));
|
|
124
|
+
console.log("");
|
|
125
|
+
console.log(` ${c.bold("by model")}`);
|
|
126
|
+
for (const m of s.models.slice(0, 8))
|
|
127
|
+
console.log(` ${modelLabel(m.key).padEnd(12)} ${fmtTokens(m.total).padStart(8)} ${fmtUSD(m.usd).padStart(9)} ${c.dim(m.requests.toLocaleString("en-US") + " req")}`);
|
|
128
|
+
console.log("");
|
|
129
|
+
console.log(` ${c.bold("by project")}`);
|
|
130
|
+
for (const p of s.projects.slice(0, 8))
|
|
131
|
+
console.log(` ${p.label.slice(0, 24).padEnd(24)} ${fmtTokens(p.total).padStart(8)} ${fmtUSD(p.usd).padStart(9)}`);
|
|
132
|
+
console.log("");
|
|
133
|
+
console.log(` ${c.bold("recent sessions")}`);
|
|
134
|
+
for (const x of s.sessions.slice(0, 6))
|
|
135
|
+
console.log(` ${x.projectLabel.slice(0, 18).padEnd(18)} ${modelLabel(x.model).padEnd(10)} ${fmtDuration(x.durationMs).padStart(7)} ${fmtTokens(x.total).padStart(8)} ${fmtUSD(x.usd).padStart(9)}`);
|
|
136
|
+
console.log("");
|
|
137
|
+
console.log(` ${c.blue("💧 the lake")} ${fp.water.text} ${c.dim(`(${fmtLiters(fp.liters)})`)} ${c.dim("·")} ⚡ ${fp.energy.text} ${c.dim(`(${fmtWh(fp.wh)})`)}`);
|
|
138
|
+
console.log(` ${c.dim(fp.water.quip)}`);
|
|
139
|
+
console.log("");
|
|
140
|
+
console.log(c.dim(` Costs are API-equivalent (pricing checked ${pricing.checked}); subscription users are not billed per token.`));
|
|
141
|
+
console.log(c.dim(` Water & energy are rough third-party estimates, for fun. Edit factors in the dashboard settings.`));
|
|
142
|
+
if (store.warnings.malformedLines)
|
|
143
|
+
console.log(c.dim(` Skipped ${store.warnings.malformedLines} malformed lines.`));
|
|
144
|
+
console.log("");
|
|
145
|
+
}
|
|
146
|
+
async function main() {
|
|
147
|
+
const a = parseArgs(process.argv.slice(2));
|
|
148
|
+
if (a.mode === "help")
|
|
149
|
+
return console.log(HELP);
|
|
150
|
+
if (a.mode === "version")
|
|
151
|
+
return console.log(VERSION);
|
|
152
|
+
const projectsDir = resolveProjectsDir({ dir: a.dir });
|
|
153
|
+
if (a.mode === "statusline") {
|
|
154
|
+
let stdin = null;
|
|
155
|
+
if (!process.stdin.isTTY) {
|
|
156
|
+
try {
|
|
157
|
+
stdin = fs.readFileSync(0, "utf8");
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
stdin = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
process.stdout.write(statusLine(projectsDir, stdin) + "\n");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!fs.existsSync(projectsDir)) {
|
|
167
|
+
console.error(`No Claude Code logs found at ${projectsDir}. Use --dir to point at them.`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
const store = new UsageStore(projectsDir);
|
|
171
|
+
const t0 = Date.now();
|
|
172
|
+
store.loadAll({ cache: !a.noCache });
|
|
173
|
+
const loadMs = Date.now() - t0;
|
|
174
|
+
if (a.mode === "json") {
|
|
175
|
+
const cfg = loadConfig();
|
|
176
|
+
const s = buildSummary(store.events, filtersFrom(a), Date.now(), 1000);
|
|
177
|
+
console.log(JSON.stringify({ version: VERSION, projectsDir, loadMs, warnings: store.warnings, pricing: { source: pricing.source, checked: pricing.checked }, summary: s, footprint: footprintOf(s.totals, cfg.footprint) }, null, 2));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (a.mode === "summary")
|
|
181
|
+
return printSummary(store, filtersFrom(a));
|
|
182
|
+
// dist/cli.js serves dist/web; `tsx src/cli.ts` (npm run dev) serves the built dist/web too.
|
|
183
|
+
const webDir = [path.join(here, "web"), path.join(here, "..", "dist", "web")].find((d) => fs.existsSync(path.join(d, "index.html"))) ??
|
|
184
|
+
path.join(here, "web");
|
|
185
|
+
if (!fs.existsSync(path.join(webDir, "index.html"))) {
|
|
186
|
+
console.error(c.ember("\n The dashboard has not been built yet. Run: npm run build\n"));
|
|
187
|
+
}
|
|
188
|
+
let port = 0;
|
|
189
|
+
let app = null;
|
|
190
|
+
// If the port is busy (e.g. an older tokenburn is still running), try the next few.
|
|
191
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
192
|
+
const tryPort = a.port + attempt;
|
|
193
|
+
const candidate = createApp({ store, webDir, port: tryPort, version: VERSION });
|
|
194
|
+
try {
|
|
195
|
+
port = await candidate.listen();
|
|
196
|
+
app = candidate;
|
|
197
|
+
if (attempt > 0)
|
|
198
|
+
console.log(c.dim(` port ${a.port} was busy, using ${port} instead`));
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
if (e?.code !== "EADDRINUSE")
|
|
203
|
+
throw e;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!app) {
|
|
207
|
+
console.error(`ports ${a.port}-${a.port + 9} are all busy. try --port <n>.`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
const url = `http://127.0.0.1:${port}`;
|
|
211
|
+
console.log(`\n ${c.ember("🔥 tokenburn")} ${c.dim(`v${VERSION}`)}\n ${c.dim(`read ${store.fileCount} files in ${loadMs}ms · ${fmtTokens(store.events.length)} requests`)}\n ${c.bold(url)} ${c.dim("(127.0.0.1 only)")}\n ${c.dim("watching " + projectsDir)}\n`);
|
|
212
|
+
const server = app;
|
|
213
|
+
const watcher = watchProjects(projectsDir, (file) => {
|
|
214
|
+
const added = store.loadFile(file);
|
|
215
|
+
if (added.length) {
|
|
216
|
+
server.broadcast(added);
|
|
217
|
+
store.saveCacheSoon();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
if (a.open)
|
|
221
|
+
openBrowser(url);
|
|
222
|
+
let closing = false;
|
|
223
|
+
const shutdown = async () => {
|
|
224
|
+
if (closing)
|
|
225
|
+
return;
|
|
226
|
+
closing = true;
|
|
227
|
+
// Never hang on exit: if something refuses to close, leave anyway.
|
|
228
|
+
setTimeout(() => process.exit(0), 1500).unref();
|
|
229
|
+
await watcher.close();
|
|
230
|
+
store.saveCache();
|
|
231
|
+
await server.close();
|
|
232
|
+
process.exit(0);
|
|
233
|
+
};
|
|
234
|
+
process.on("SIGINT", shutdown);
|
|
235
|
+
process.on("SIGTERM", shutdown);
|
|
236
|
+
}
|
|
237
|
+
main().catch((e) => {
|
|
238
|
+
console.error(e);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { defaultFootprint } from "./footprint.js";
|
|
5
|
+
export function configDir(env = process.env) {
|
|
6
|
+
return env.TOKENBURN_HOME ?? path.join(os.homedir(), ".tokenburn");
|
|
7
|
+
}
|
|
8
|
+
export function configPath(env) {
|
|
9
|
+
return path.join(configDir(env), "config.json");
|
|
10
|
+
}
|
|
11
|
+
export function loadConfig(env) {
|
|
12
|
+
const base = { footprint: structuredClone(defaultFootprint), theme: "dark" };
|
|
13
|
+
try {
|
|
14
|
+
const raw = JSON.parse(fs.readFileSync(configPath(env), "utf8"));
|
|
15
|
+
return mergeConfig(base, raw);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return base;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function mergeConfig(base, patch) {
|
|
22
|
+
const out = structuredClone(base);
|
|
23
|
+
if (patch && typeof patch === "object") {
|
|
24
|
+
const fp = patch.footprint;
|
|
25
|
+
if (fp && typeof fp === "object") {
|
|
26
|
+
if (typeof fp.whPerMillionOutputTokens === "number" && fp.whPerMillionOutputTokens >= 0)
|
|
27
|
+
out.footprint.whPerMillionOutputTokens = fp.whPerMillionOutputTokens;
|
|
28
|
+
if (typeof fp.litersPerKWh === "number" && fp.litersPerKWh >= 0)
|
|
29
|
+
out.footprint.litersPerKWh = fp.litersPerKWh;
|
|
30
|
+
if (fp.tokenWeights && typeof fp.tokenWeights === "object") {
|
|
31
|
+
for (const k of ["input", "output", "cacheWrite", "cacheRead"]) {
|
|
32
|
+
const v = fp.tokenWeights[k];
|
|
33
|
+
if (typeof v === "number" && v >= 0)
|
|
34
|
+
out.footprint.tokenWeights[k] = v;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (patch.theme === "dark" || patch.theme === "light")
|
|
39
|
+
out.theme = patch.theme;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
export function saveConfig(cfg, env) {
|
|
44
|
+
const dir = configDir(env);
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
fs.writeFileSync(configPath(env), JSON.stringify(cfg, null, 2) + "\n");
|
|
47
|
+
}
|