ccwatts 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +22 -2
- package/dist/index.js +1058 -0
- package/package.json +40 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 the ccwatts authors
|
|
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,23 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ccwatts
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
See how much electricity your AI coding uses. ccwatts reads the logs Claude Code already keeps on your machine and turns token counts into an electricity estimate, always as a range with sourced assumptions.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npx ccwatts # your last 30 days
|
|
7
|
+
npx ccwatts --by project # also: model, day, type
|
|
8
|
+
npx ccwatts history # weekly trend, survives log cleanup
|
|
9
|
+
npx ccwatts receipt # share line and card
|
|
10
|
+
npx ccwatts dashboard # offline HTML dashboard
|
|
11
|
+
npx ccwatts --method # every parameter and its source
|
|
12
|
+
npx ccwatts --demo # no Claude Code logs yet? try synthetic sample data
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- **Local only.** No network calls, no accounts, no telemetry. It reads token counts, never prompt or response text.
|
|
16
|
+
- **Ranges, not false precision.** Every number shows a low–high range and the method version.
|
|
17
|
+
- **No runtime dependencies.** Node 20+.
|
|
18
|
+
|
|
19
|
+
Try it in your browser first, on sample data: https://chaseholroyd.com/ccwatts/
|
|
20
|
+
|
|
21
|
+
Methodology, source and screenshots: https://github.com/holroydchase-afk/ccwatts
|
|
22
|
+
|
|
23
|
+
MIT license. The methodology and parameters are CC BY 4.0.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
import { parseArgs } from "util";
|
|
7
|
+
|
|
8
|
+
// ../core/src/meta.ts
|
|
9
|
+
var APP_NAME = "ccwatts";
|
|
10
|
+
var APP_VERSION = "0.1.1";
|
|
11
|
+
var REPO_URL = "https://github.com/holroydchase-afk/ccwatts";
|
|
12
|
+
var PLAYGROUND_URL = "https://chaseholroyd.com/ccwatts/";
|
|
13
|
+
|
|
14
|
+
// ../core/src/format.ts
|
|
15
|
+
var TYPE_LABELS = {
|
|
16
|
+
input: "input",
|
|
17
|
+
cacheWrite: "cache write",
|
|
18
|
+
cacheRead: "cache read",
|
|
19
|
+
output: "output"
|
|
20
|
+
};
|
|
21
|
+
var int = (n) => Math.round(n).toLocaleString("en-US");
|
|
22
|
+
function num(n) {
|
|
23
|
+
if (n >= 100) return int(n);
|
|
24
|
+
if (n >= 1) return (Math.round(n * 10) / 10).toString();
|
|
25
|
+
if (n >= 0.01) return (Math.round(n * 100) / 100).toString();
|
|
26
|
+
return n === 0 ? "0" : n.toPrecision(1);
|
|
27
|
+
}
|
|
28
|
+
var fixed = (n) => n >= 1 && n < 100 ? n.toFixed(1) : num(n);
|
|
29
|
+
function formatEnergy(range) {
|
|
30
|
+
const kwh = range.high >= 1e3;
|
|
31
|
+
const scale = kwh ? 1e3 : 1;
|
|
32
|
+
const unit = kwh ? "kWh" : "Wh";
|
|
33
|
+
return {
|
|
34
|
+
central: `${fixed(range.central / scale)} ${unit}`,
|
|
35
|
+
range: `${fixed(range.low / scale)} \u2013 ${fixed(range.high / scale)}`,
|
|
36
|
+
unit
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
var shortModel = (model) => model.replace(/^claude-/, "");
|
|
40
|
+
|
|
41
|
+
// ../core/src/types.ts
|
|
42
|
+
var TOKEN_TYPES = ["input", "cacheWrite", "cacheRead", "output"];
|
|
43
|
+
|
|
44
|
+
// ../core/src/energy.ts
|
|
45
|
+
var zeroRange = () => ({ low: 0, central: 0, high: 0 });
|
|
46
|
+
function addRange(into, from) {
|
|
47
|
+
into.low += from.low;
|
|
48
|
+
into.central += from.central;
|
|
49
|
+
into.high += from.high;
|
|
50
|
+
return into;
|
|
51
|
+
}
|
|
52
|
+
function tierFor(model, params) {
|
|
53
|
+
const id = model.toLowerCase();
|
|
54
|
+
for (const [tier, patterns] of Object.entries(params.tiers)) {
|
|
55
|
+
if (Array.isArray(patterns) && patterns.some((p) => id.includes(p))) return { tier, known: true };
|
|
56
|
+
}
|
|
57
|
+
return { tier: String(params.tiers.fallback), known: false };
|
|
58
|
+
}
|
|
59
|
+
function param(params, id) {
|
|
60
|
+
const p = params.params[id];
|
|
61
|
+
if (!p) throw new Error(`Missing parameter "${id}" in params.json`);
|
|
62
|
+
return p;
|
|
63
|
+
}
|
|
64
|
+
function chain(tier, type) {
|
|
65
|
+
const output = `output_wh_per_1k.${tier}`;
|
|
66
|
+
switch (type) {
|
|
67
|
+
case "output":
|
|
68
|
+
return [output];
|
|
69
|
+
case "input":
|
|
70
|
+
return [output, "input_vs_output"];
|
|
71
|
+
case "cacheWrite":
|
|
72
|
+
return [output, "input_vs_output", "cache_write_vs_input"];
|
|
73
|
+
case "cacheRead":
|
|
74
|
+
return [output, "input_vs_output", "cache_read_vs_input"];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function factor(params, tier, type) {
|
|
78
|
+
let wh = 1 / 1e3;
|
|
79
|
+
let up2 = 0;
|
|
80
|
+
let down2 = 0;
|
|
81
|
+
for (const id of chain(tier, type)) {
|
|
82
|
+
const p = param(params, id);
|
|
83
|
+
wh *= p.value;
|
|
84
|
+
up2 += Math.log(p.high / p.value) ** 2;
|
|
85
|
+
down2 += Math.log(p.value / p.low) ** 2;
|
|
86
|
+
}
|
|
87
|
+
return { whPerToken: wh, up: Math.sqrt(up2), down: Math.sqrt(down2) };
|
|
88
|
+
}
|
|
89
|
+
var empty = () => ({ input: 0, cacheWrite: 0, cacheRead: 0, output: 0 });
|
|
90
|
+
function energyForCounts(counts, tier, params) {
|
|
91
|
+
const total = zeroRange();
|
|
92
|
+
for (const type of TOKEN_TYPES) addRange(total, energyForTokens(counts[type], tier, type, params));
|
|
93
|
+
return total;
|
|
94
|
+
}
|
|
95
|
+
var factorCache = /* @__PURE__ */ new WeakMap();
|
|
96
|
+
function cachedFactor(params, tier, type) {
|
|
97
|
+
let byKey = factorCache.get(params);
|
|
98
|
+
if (!byKey) factorCache.set(params, byKey = /* @__PURE__ */ new Map());
|
|
99
|
+
const key = `${tier}:${type}`;
|
|
100
|
+
let f = byKey.get(key);
|
|
101
|
+
if (!f) byKey.set(key, f = factor(params, tier, type));
|
|
102
|
+
return f;
|
|
103
|
+
}
|
|
104
|
+
function energyForTokens(tokens, tier, type, params) {
|
|
105
|
+
if (tokens <= 0) return zeroRange();
|
|
106
|
+
const f = cachedFactor(params, tier, type);
|
|
107
|
+
const central = tokens * f.whPerToken;
|
|
108
|
+
return { low: central / Math.exp(f.down), central, high: central * Math.exp(f.up) };
|
|
109
|
+
}
|
|
110
|
+
var day = (timestamp) => timestamp.slice(0, 10);
|
|
111
|
+
function estimate(records, params) {
|
|
112
|
+
const total = zeroRange();
|
|
113
|
+
const tokens = empty();
|
|
114
|
+
const groups = {
|
|
115
|
+
model: /* @__PURE__ */ new Map(),
|
|
116
|
+
type: /* @__PURE__ */ new Map(),
|
|
117
|
+
day: /* @__PURE__ */ new Map(),
|
|
118
|
+
project: /* @__PURE__ */ new Map()
|
|
119
|
+
};
|
|
120
|
+
const unknownModels = /* @__PURE__ */ new Set();
|
|
121
|
+
let from = null;
|
|
122
|
+
let to = null;
|
|
123
|
+
const bump = (dim, key, energy2, type, count) => {
|
|
124
|
+
let row2 = groups[dim].get(key);
|
|
125
|
+
if (!row2) groups[dim].set(key, row2 = { key, energy: zeroRange(), tokens: empty() });
|
|
126
|
+
addRange(row2.energy, energy2);
|
|
127
|
+
row2.tokens[type] += count;
|
|
128
|
+
};
|
|
129
|
+
for (const record of records) {
|
|
130
|
+
const { tier, known } = tierFor(record.model, params);
|
|
131
|
+
if (!known) unknownModels.add(record.model);
|
|
132
|
+
const d = day(record.timestamp);
|
|
133
|
+
if (from === null || d < from) from = d;
|
|
134
|
+
if (to === null || d > to) to = d;
|
|
135
|
+
const project = record.project?.name ?? "(unknown)";
|
|
136
|
+
for (const type of TOKEN_TYPES) {
|
|
137
|
+
const count = record.tokens[type];
|
|
138
|
+
if (count <= 0) continue;
|
|
139
|
+
const energy2 = energyForTokens(count, tier, type, params);
|
|
140
|
+
addRange(total, energy2);
|
|
141
|
+
tokens[type] += count;
|
|
142
|
+
bump("model", record.model, energy2, type, count);
|
|
143
|
+
bump("type", type, energy2, type, count);
|
|
144
|
+
bump("day", d, energy2, type, count);
|
|
145
|
+
bump("project", record.project ? `${project}#${record.project.id}` : project, energy2, type, count);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const byEnergy = (rows) => [...rows.values()].sort((a, b) => b.energy.central - a.energy.central);
|
|
149
|
+
const warnings = [...unknownModels].map(
|
|
150
|
+
(m) => `Unknown model "${m}": estimated as ${String(params.tiers.fallback)}.`
|
|
151
|
+
);
|
|
152
|
+
return {
|
|
153
|
+
methodVersion: params.version,
|
|
154
|
+
total,
|
|
155
|
+
tokens,
|
|
156
|
+
responses: records.length,
|
|
157
|
+
byModel: byEnergy(groups.model),
|
|
158
|
+
byType: byEnergy(groups.type),
|
|
159
|
+
byDay: [...groups.day.values()].sort((a, b) => a.key.localeCompare(b.key)),
|
|
160
|
+
byProject: byEnergy(groups.project),
|
|
161
|
+
span: from && to ? { from, to } : null,
|
|
162
|
+
warnings
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
var projectName = (key) => key.split("#")[0] ?? key;
|
|
166
|
+
function since(records, isoStart) {
|
|
167
|
+
return records.filter((r) => r.timestamp >= isoStart);
|
|
168
|
+
}
|
|
169
|
+
function phoneText(charges, round) {
|
|
170
|
+
if (round(charges) === 1) return "charging a phone about once";
|
|
171
|
+
if (charges >= 1) return `charging a phone about ${round(charges)} times`;
|
|
172
|
+
const pct = Math.round(charges * 100);
|
|
173
|
+
if (pct < 1) return "under 1% of a phone charge";
|
|
174
|
+
if (pct >= 45 && pct <= 55) return "about half a phone charge";
|
|
175
|
+
return `about ${Math.round(pct / 10) * 10 || pct}% of a phone charge`;
|
|
176
|
+
}
|
|
177
|
+
function comparisons(kwh, params) {
|
|
178
|
+
const c = params.comparisons;
|
|
179
|
+
const out = [];
|
|
180
|
+
const round = (n) => n >= 10 ? Math.round(n) : Math.round(n * 10) / 10;
|
|
181
|
+
const count = (n, one, many) => round(n) === 1 ? `1 ${one}` : `${round(n)} ${many}`;
|
|
182
|
+
if (c.ev_kwh_per_mile) {
|
|
183
|
+
const miles = kwh / c.ev_kwh_per_mile.value;
|
|
184
|
+
out.push({ id: "ev", amount: miles, text: `driving an EV about ${count(miles, "mile", "miles")}` });
|
|
185
|
+
}
|
|
186
|
+
if (c.fridge_kwh_per_day) {
|
|
187
|
+
const days = kwh / c.fridge_kwh_per_day.value;
|
|
188
|
+
out.push({ id: "fridge", amount: days, text: `running a fridge about ${count(days, "day", "days")}` });
|
|
189
|
+
}
|
|
190
|
+
if (c.phone_charge_kwh) {
|
|
191
|
+
const charges = kwh / c.phone_charge_kwh.value;
|
|
192
|
+
out.push({ id: "phone", amount: charges, text: phoneText(charges, round) });
|
|
193
|
+
}
|
|
194
|
+
if (c.us_home_kwh_per_day) {
|
|
195
|
+
const days = kwh / c.us_home_kwh_per_day.value;
|
|
196
|
+
out.push({ id: "home", amount: days, text: `powering a US home about ${count(days, "day", "days")}` });
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ../core/src/receipt.ts
|
|
202
|
+
function everyday(kwh, params) {
|
|
203
|
+
const all = comparisons(kwh, params);
|
|
204
|
+
const big = all.filter((c) => c.amount >= 1).slice(0, 2);
|
|
205
|
+
const fallback = [...all].sort((a, b) => b.amount - a.amount).slice(0, 1);
|
|
206
|
+
return (big.length ? big : fallback).map((c) => c.text).join(" \xB7 ");
|
|
207
|
+
}
|
|
208
|
+
var repoShort = REPO_URL.replace(/^https:\/\//, "");
|
|
209
|
+
function whose(windowLabel) {
|
|
210
|
+
if (windowLabel === "all time") return "All my Claude Code use so far";
|
|
211
|
+
if (windowLabel.startsWith("since")) return `My Claude Code use ${windowLabel}`;
|
|
212
|
+
return `My ${windowLabel} of Claude Code`;
|
|
213
|
+
}
|
|
214
|
+
function sampleWhose(windowLabel) {
|
|
215
|
+
return windowLabel === "all time" ? "(Sample data) All of a sample's Claude Code use" : `(Sample data) ${windowLabel[0].toUpperCase()}${windowLabel.slice(1)} of Claude Code`;
|
|
216
|
+
}
|
|
217
|
+
function shareLine({ estimate: e, params, windowLabel, sample }) {
|
|
218
|
+
const t = formatEnergy(e.total);
|
|
219
|
+
const first = everyday(e.total.central / 1e3, params).split(" \xB7 ")[0];
|
|
220
|
+
return `${sample ? sampleWhose(windowLabel) : whose(windowLabel)} came to about ${t.central} of electricity (range ${t.range} ${t.unit}), like ${first}. Estimated locally with ${APP_NAME}, method v${params.version}: ${repoShort}`;
|
|
221
|
+
}
|
|
222
|
+
function linkedInPost(input) {
|
|
223
|
+
const { estimate: e, params, windowLabel, sample } = input;
|
|
224
|
+
const t = formatEnergy(e.total);
|
|
225
|
+
const cacheShare = e.total.central > 0 ? Math.round((e.byType.find((r) => r.key === "cacheRead")?.energy.central ?? 0) / e.total.central * 100) : 0;
|
|
226
|
+
if (sample) {
|
|
227
|
+
return [
|
|
228
|
+
`(Sample data) This is what ${APP_NAME} shows for a sample developer, not a real measurement. ${sampleWhose(windowLabel).replace("(Sample data) ", "")}: about ${t.central} (range ${t.range} ${t.unit}), like ${everyday(e.total.central / 1e3, params).replace(" \xB7 ", " or ")}.`,
|
|
229
|
+
"",
|
|
230
|
+
`${cacheShare}% of it came from the agent re-reading context it had already seen.`,
|
|
231
|
+
"",
|
|
232
|
+
`It's a range because nobody outside the AI labs knows the real energy per token. Every assumption is sourced, and it runs entirely on your own machine, so no logs leave it.`,
|
|
233
|
+
"",
|
|
234
|
+
`Try it on your own logs: ${PLAYGROUND_URL}`,
|
|
235
|
+
`Code and method: ${REPO_URL}`
|
|
236
|
+
].join("\n");
|
|
237
|
+
}
|
|
238
|
+
return [
|
|
239
|
+
`I estimated the electricity behind my AI coding. ${whose(windowLabel)}: about ${t.central} (range ${t.range} ${t.unit}), like ${everyday(e.total.central / 1e3, params).replace(" \xB7 ", " or ")}.`,
|
|
240
|
+
"",
|
|
241
|
+
`${cacheShare >= 50 ? "The surprise: " : ""}${cacheShare}% of it came from the agent re-reading context it had already seen.`,
|
|
242
|
+
"",
|
|
243
|
+
`It's a range because nobody outside the AI labs knows the real energy per token. Every assumption is sourced, and the tool runs entirely on my laptop, so no logs leave it.`,
|
|
244
|
+
"",
|
|
245
|
+
`Try it in your browser: ${PLAYGROUND_URL}`,
|
|
246
|
+
`Code and method: ${REPO_URL}`
|
|
247
|
+
].join("\n");
|
|
248
|
+
}
|
|
249
|
+
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
250
|
+
function row(left, right, width) {
|
|
251
|
+
const dots = Math.max(2, width - left.length - right.length - 2);
|
|
252
|
+
return `${left} ${".".repeat(dots)} ${right}`;
|
|
253
|
+
}
|
|
254
|
+
function receiptSvg({ estimate: e, params, windowLabel, sample }) {
|
|
255
|
+
const W = 1200;
|
|
256
|
+
const H = 627;
|
|
257
|
+
const t = formatEnergy(e.total);
|
|
258
|
+
const kwh = e.total.central / 1e3;
|
|
259
|
+
const compare = everyday(kwh, params).split(" \xB7 ");
|
|
260
|
+
const unit = t.unit;
|
|
261
|
+
const scale = unit === "kWh" ? 1e3 : 1;
|
|
262
|
+
const slipX = 712;
|
|
263
|
+
const slipW = 432;
|
|
264
|
+
const cols = 36;
|
|
265
|
+
const items = [];
|
|
266
|
+
const dash = "-".repeat(cols);
|
|
267
|
+
items.push({ text: "ELECTRICITY RECEIPT", weight: "bold" });
|
|
268
|
+
items.push({ text: e.span ? `${e.span.from} \u2192 ${e.span.to}` : windowLabel, muted: true });
|
|
269
|
+
items.push({ text: dash, muted: true });
|
|
270
|
+
const types = [...TOKEN_TYPES].sort(
|
|
271
|
+
(a, b) => (e.byType.find((r) => r.key === b)?.energy.central ?? 0) - (e.byType.find((r) => r.key === a)?.energy.central ?? 0)
|
|
272
|
+
);
|
|
273
|
+
for (const type of types) {
|
|
274
|
+
const r = e.byType.find((x) => x.key === type);
|
|
275
|
+
items.push({ text: row(TYPE_LABELS[type], `${num((r?.energy.central ?? 0) / scale)} ${unit}`, cols) });
|
|
276
|
+
}
|
|
277
|
+
items.push({ text: dash, muted: true });
|
|
278
|
+
items.push({ text: row("TOTAL (central)", t.central, cols), weight: "bold" });
|
|
279
|
+
items.push({ text: row("RANGE", `${t.range} ${unit}`, cols) });
|
|
280
|
+
items.push({ text: dash, muted: true });
|
|
281
|
+
for (const m of e.byModel.slice(0, 3)) {
|
|
282
|
+
const pct = e.total.central > 0 ? Math.round(m.energy.central / e.total.central * 100) : 0;
|
|
283
|
+
items.push({ text: row(shortModel(m.key), `${pct}%`, cols), muted: true });
|
|
284
|
+
}
|
|
285
|
+
items.push({ text: row("responses", int(e.responses), cols), muted: true });
|
|
286
|
+
items.push({ text: `method v${params.version} \xB7 counts only`, muted: true });
|
|
287
|
+
const lineH = 25;
|
|
288
|
+
const slipTop = 46;
|
|
289
|
+
const textTop = slipTop + 44;
|
|
290
|
+
const barcodeY = textTop + items.length * lineH + 6;
|
|
291
|
+
const slipBottom = barcodeY + 70;
|
|
292
|
+
const days = e.byDay.slice(-30);
|
|
293
|
+
const maxDay = Math.max(...days.map((d) => d.energy.central), 1e-9);
|
|
294
|
+
const barW = (slipW - 64) / Math.max(days.length, 1);
|
|
295
|
+
const barcode = days.map((d, i) => {
|
|
296
|
+
const h = 6 + d.energy.central / maxDay * 40;
|
|
297
|
+
return `<rect x="${(slipX + 32 + i * barW).toFixed(1)}" y="${(barcodeY + 46 - h).toFixed(1)}" width="${Math.max(1, barW - 2).toFixed(1)}" height="${h.toFixed(1)}" fill="#1f2a24"/>`;
|
|
298
|
+
}).join("");
|
|
299
|
+
const teeth = [];
|
|
300
|
+
for (let i = 0, x = slipX + slipW; x > slipX; i++, x -= 9) teeth.push(`${x.toFixed(1)},${slipBottom + (i % 2 ? 10 : 0)}`);
|
|
301
|
+
const slipPath = `M${slipX},${slipTop} H${slipX + slipW} V${slipBottom} L${teeth.join(" L")} L${slipX},${slipBottom} Z`;
|
|
302
|
+
const mono = `ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace`;
|
|
303
|
+
const sans = `'Helvetica Neue', Helvetica, Arial, sans-serif`;
|
|
304
|
+
const slipText = items.map((it, i) => {
|
|
305
|
+
const y = textTop + i * lineH;
|
|
306
|
+
const centered = i < 2 || it.text.startsWith("method");
|
|
307
|
+
const x = centered ? slipX + slipW / 2 : slipX + 30;
|
|
308
|
+
return `<text x="${x}" y="${y}" font-family="${mono}" font-size="16.5" ${centered ? 'text-anchor="middle"' : ""} font-weight="${it.weight ?? "normal"}" fill="${it.muted ? "#5e6b63" : "#1f2a24"}">${esc(it.text)}</text>`;
|
|
309
|
+
}).join("\n ");
|
|
310
|
+
const headline = t.central.split(" ");
|
|
311
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(shareLine({ estimate: e, params, windowLabel, sample }))}">
|
|
312
|
+
<rect width="${W}" height="${H}" fill="#12251c"/>
|
|
313
|
+
<rect x="0" y="${H - 6}" width="${W}" height="6" fill="#7cc4a0"/>
|
|
314
|
+
<text x="64" y="104" font-family="${mono}" font-size="20" letter-spacing="2" fill="#7cc4a0">${sample ? '<tspan fill="#e0b56a">SAMPLE DATA \xB7 </tspan>' : ""}CLAUDE CODE \xB7 ${esc(windowLabel.toUpperCase())}</text>
|
|
315
|
+
<text x="60" y="262" font-family="${sans}" font-size="148" font-weight="700" fill="#f4f1e8">${esc(headline[0] ?? "")}<tspan font-size="64" font-weight="500" dx="14">${esc(headline[1] ?? "")}</tspan></text>
|
|
316
|
+
<text x="64" y="318" font-family="${sans}" font-size="28" fill="#cfe3d6">of electricity, range ${esc(t.range)} ${unit}</text>
|
|
317
|
+
${compare.map((c, i) => `<text x="64" y="${384 + i * 38}" font-family="${sans}" font-size="26" fill="#f4f1e8">\u2248 ${esc(c)}</text>`).join("\n ")}
|
|
318
|
+
<text x="64" y="520" font-family="${sans}" font-size="20" fill="${sample ? "#e0b56a" : "#9fb8a8"}">${sample ? "Sample data, not a real measurement." : "Estimated on my laptop. No logs left it."}</text>
|
|
319
|
+
<text x="64" y="552" font-family="${mono}" font-size="17" fill="#7cc4a0">${esc(APP_NAME)} \xB7 method v${esc(params.version)}</text>
|
|
320
|
+
<text x="64" y="578" font-family="${mono}" font-size="17" fill="#7cc4a0">${esc(repoShort)}</text>
|
|
321
|
+
<path d="${slipPath}" fill="#f7f5ef"/>
|
|
322
|
+
<g>
|
|
323
|
+
${slipText}
|
|
324
|
+
</g>
|
|
325
|
+
${barcode}
|
|
326
|
+
</svg>
|
|
327
|
+
`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ../core/src/window.ts
|
|
331
|
+
function parseSince(input, now) {
|
|
332
|
+
const value = input.trim().toLowerCase();
|
|
333
|
+
if (value === "all") return { start: null, label: "all time" };
|
|
334
|
+
const rel = /^(\d+)\s*([hdw])$/.exec(value);
|
|
335
|
+
if (rel) {
|
|
336
|
+
const n = Number(rel[1]);
|
|
337
|
+
const unit = rel[2];
|
|
338
|
+
const ms = { h: 36e5, d: 864e5, w: 6048e5 }[unit] * n;
|
|
339
|
+
const word = { h: "hour", d: "day", w: "week" }[unit];
|
|
340
|
+
return { start: new Date(now.getTime() - ms).toISOString(), label: `last ${n} ${word}${n === 1 ? "" : "s"}` };
|
|
341
|
+
}
|
|
342
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value))) {
|
|
343
|
+
return { start: `${value}T00:00:00.000Z`, label: `since ${value}` };
|
|
344
|
+
}
|
|
345
|
+
throw new Error(`Can't read --since "${input}". Use 7d, 12h, 4w, a date like 2026-10-01, or all.`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ../core/src/project.ts
|
|
349
|
+
function fnv1a64(text) {
|
|
350
|
+
let hash = 0xcbf29ce484222325n;
|
|
351
|
+
const prime = 0x100000001b3n;
|
|
352
|
+
for (let i = 0; i < text.length; i++) {
|
|
353
|
+
hash ^= BigInt(text.charCodeAt(i));
|
|
354
|
+
hash = hash * prime & 0xffffffffffffffffn;
|
|
355
|
+
}
|
|
356
|
+
return hash.toString(16).padStart(16, "0");
|
|
357
|
+
}
|
|
358
|
+
function projectRef(cwd) {
|
|
359
|
+
const trimmed = cwd.replace(/[\\/]+$/, "");
|
|
360
|
+
const name = trimmed.split(/[\\/]/).pop() || trimmed;
|
|
361
|
+
return { id: fnv1a64(trimmed), name };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ../core/src/adapters/claude-code.ts
|
|
365
|
+
var num2 = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
366
|
+
var str = (value) => typeof value === "string" && value !== "" ? value : null;
|
|
367
|
+
function toRecord(line) {
|
|
368
|
+
if (line.type !== "assistant") return null;
|
|
369
|
+
const message = line.message;
|
|
370
|
+
const usage = message?.usage;
|
|
371
|
+
const model = str(message?.model);
|
|
372
|
+
const timestamp = str(line.timestamp);
|
|
373
|
+
const messageId = str(message?.id);
|
|
374
|
+
if (!usage || !model || !timestamp || !messageId) return null;
|
|
375
|
+
if (model === "<synthetic>") return null;
|
|
376
|
+
const cwd = str(line.cwd);
|
|
377
|
+
return {
|
|
378
|
+
source: "claude-code",
|
|
379
|
+
key: `${messageId}:${str(line.requestId) ?? ""}`,
|
|
380
|
+
timestamp,
|
|
381
|
+
model,
|
|
382
|
+
project: cwd ? projectRef(cwd) : null,
|
|
383
|
+
tokens: {
|
|
384
|
+
input: num2(usage.input_tokens),
|
|
385
|
+
cacheWrite: num2(usage.cache_creation_input_tokens),
|
|
386
|
+
cacheRead: num2(usage.cache_read_input_tokens),
|
|
387
|
+
output: num2(usage.output_tokens)
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
var claudeCodeAdapter = {
|
|
392
|
+
id: "claude-code",
|
|
393
|
+
parse(text) {
|
|
394
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
395
|
+
for (const raw of text.split("\n")) {
|
|
396
|
+
if (!raw.includes('"assistant"')) continue;
|
|
397
|
+
let line;
|
|
398
|
+
try {
|
|
399
|
+
line = JSON.parse(raw);
|
|
400
|
+
} catch {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const record = toRecord(line);
|
|
404
|
+
if (record) byKey.set(record.key, record);
|
|
405
|
+
}
|
|
406
|
+
return [...byKey.values()];
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// ../core/src/rollup.ts
|
|
411
|
+
function dedupe(records) {
|
|
412
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
413
|
+
for (const record of records) byKey.set(`${record.source}:${record.key}`, record);
|
|
414
|
+
return [...byKey.values()];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ../../methodology/params.json
|
|
418
|
+
var params_default = {
|
|
419
|
+
version: "0.1.0",
|
|
420
|
+
as_of: "2026-10-21",
|
|
421
|
+
license: "CC BY 4.0",
|
|
422
|
+
boundary: "comprehensive: accelerators, host CPU and memory, idle capacity, and data-center overhead (PUE)",
|
|
423
|
+
interval: "low and high are each parameter's plausible bounds, treated as a ~90% interval",
|
|
424
|
+
params: {
|
|
425
|
+
"output_wh_per_1k.opus": {
|
|
426
|
+
value: 1.2,
|
|
427
|
+
low: 0.4,
|
|
428
|
+
high: 3,
|
|
429
|
+
unit: "Wh per 1k output tokens",
|
|
430
|
+
label: "MODELED",
|
|
431
|
+
source: "https://www.theclimatebrink.com/p/the-real-energy-use-of-agentic-ai",
|
|
432
|
+
as_of: "2026-10-21",
|
|
433
|
+
note: "Largest tier. Scaled up from the Sonnet value; Anthropic does not disclose model size or energy. Bounds cover published per-token estimates for frontier models."
|
|
434
|
+
},
|
|
435
|
+
"output_wh_per_1k.sonnet": {
|
|
436
|
+
value: 0.6,
|
|
437
|
+
low: 0.25,
|
|
438
|
+
high: 1.5,
|
|
439
|
+
unit: "Wh per 1k output tokens",
|
|
440
|
+
label: "MODELED",
|
|
441
|
+
source: "https://arxiv.org/abs/2508.15734",
|
|
442
|
+
as_of: "2026-10-21",
|
|
443
|
+
note: "Anchored so a typical chat query (about 1k input, 400 output tokens) lands near Google's measured 0.24 Wh median Gemini prompt and the 0.34 Wh median frontier query (IQR 0.18-0.67). Third-party estimates for Claude output run 0.4-2 Wh per 1k."
|
|
444
|
+
},
|
|
445
|
+
"output_wh_per_1k.haiku": {
|
|
446
|
+
value: 0.25,
|
|
447
|
+
low: 0.08,
|
|
448
|
+
high: 0.6,
|
|
449
|
+
unit: "Wh per 1k output tokens",
|
|
450
|
+
label: "GUESS",
|
|
451
|
+
source: "https://www.anthropic.com/pricing",
|
|
452
|
+
as_of: "2026-10-21",
|
|
453
|
+
note: "Smallest tier. Scaled down from Sonnet roughly in line with the price gap between tiers, which is a weak proxy for compute."
|
|
454
|
+
},
|
|
455
|
+
input_vs_output: {
|
|
456
|
+
value: 0.3,
|
|
457
|
+
low: 0.15,
|
|
458
|
+
high: 0.5,
|
|
459
|
+
unit: "ratio",
|
|
460
|
+
label: "MODELED",
|
|
461
|
+
source: "https://www.anthropic.com/pricing",
|
|
462
|
+
as_of: "2026-10-21",
|
|
463
|
+
note: "Energy of one fresh input token relative to one output token. Input is processed in parallel (prefill), so it costs less per token. Anthropic prices input at 1/5 of output; a Watershed-based estimate uses about 1/3."
|
|
464
|
+
},
|
|
465
|
+
cache_write_vs_input: {
|
|
466
|
+
value: 1,
|
|
467
|
+
low: 0.8,
|
|
468
|
+
high: 1.25,
|
|
469
|
+
unit: "ratio",
|
|
470
|
+
label: "MODELED",
|
|
471
|
+
source: "https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
|
|
472
|
+
as_of: "2026-10-21",
|
|
473
|
+
note: "Writing to the cache means processing the tokens fresh, then storing the result. Priced at 1.25x input."
|
|
474
|
+
},
|
|
475
|
+
cache_read_vs_input: {
|
|
476
|
+
value: 0.1,
|
|
477
|
+
low: 0.01,
|
|
478
|
+
high: 0.25,
|
|
479
|
+
unit: "ratio",
|
|
480
|
+
label: "MODELED",
|
|
481
|
+
source: "https://www.theclimatebrink.com/p/the-real-energy-use-of-agentic-ai",
|
|
482
|
+
as_of: "2026-10-21",
|
|
483
|
+
note: "The parameter that matters most for agents, where cache reads are 90%+ of tokens. Hausfather's central case is 10% of fresh input, tested at 1% and 25%. Anthropic prices cache reads at 0.1x input."
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
tiers: {
|
|
487
|
+
opus: [
|
|
488
|
+
"opus"
|
|
489
|
+
],
|
|
490
|
+
sonnet: [
|
|
491
|
+
"sonnet"
|
|
492
|
+
],
|
|
493
|
+
haiku: [
|
|
494
|
+
"haiku"
|
|
495
|
+
],
|
|
496
|
+
fallback: "sonnet"
|
|
497
|
+
},
|
|
498
|
+
comparisons: {
|
|
499
|
+
ev_kwh_per_mile: {
|
|
500
|
+
value: 0.3,
|
|
501
|
+
unit: "kWh per mile",
|
|
502
|
+
label: "MEASURED",
|
|
503
|
+
source: "https://www.fueleconomy.gov/feg/evsbs.shtml",
|
|
504
|
+
as_of: "2026-10-21",
|
|
505
|
+
note: "Typical EV uses 25-35 kWh per 100 miles (EPA)."
|
|
506
|
+
},
|
|
507
|
+
fridge_kwh_per_day: {
|
|
508
|
+
value: 1.5,
|
|
509
|
+
unit: "kWh per day",
|
|
510
|
+
label: "MEASURED",
|
|
511
|
+
source: "https://www.energystar.gov/products/refrigerators",
|
|
512
|
+
as_of: "2026-10-21",
|
|
513
|
+
note: "A typical US refrigerator uses about 400-600 kWh a year."
|
|
514
|
+
},
|
|
515
|
+
us_home_kwh_per_day: {
|
|
516
|
+
value: 29,
|
|
517
|
+
unit: "kWh per day",
|
|
518
|
+
label: "MEASURED",
|
|
519
|
+
source: "https://www.eia.gov/tools/faqs/faq.php?id=97&t=3",
|
|
520
|
+
as_of: "2026-10-21",
|
|
521
|
+
note: "Average US household used about 10,800 kWh in a year (EIA)."
|
|
522
|
+
},
|
|
523
|
+
phone_charge_kwh: {
|
|
524
|
+
value: 0.019,
|
|
525
|
+
unit: "kWh per smartphone charge",
|
|
526
|
+
label: "MEASURED",
|
|
527
|
+
source: "https://www.epa.gov/energy/greenhouse-gas-equivalencies-calculator-calculations-and-references",
|
|
528
|
+
as_of: "2026-10-21",
|
|
529
|
+
note: "EPA's figure for fully charging one smartphone."
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
// ../core/src/params.ts
|
|
535
|
+
var defaultParams = params_default;
|
|
536
|
+
|
|
537
|
+
// ../core/src/history.ts
|
|
538
|
+
var HISTORY_SCHEMA = 1;
|
|
539
|
+
var rowKey = (r) => `${r.date}|${r.source}|${r.model}|${r.projectId ?? ""}`;
|
|
540
|
+
var sum = (t) => TOKEN_TYPES.reduce((s, k) => s + t[k], 0);
|
|
541
|
+
var outputContext = (t) => t.output * (t.input + t.cacheWrite + t.cacheRead);
|
|
542
|
+
function rollup(records) {
|
|
543
|
+
const rows = /* @__PURE__ */ new Map();
|
|
544
|
+
for (const r of records) {
|
|
545
|
+
const base = {
|
|
546
|
+
date: r.timestamp.slice(0, 10),
|
|
547
|
+
source: r.source,
|
|
548
|
+
model: r.model,
|
|
549
|
+
projectId: r.project?.id ?? null,
|
|
550
|
+
projectName: r.project?.name ?? null
|
|
551
|
+
};
|
|
552
|
+
const key = rowKey(base);
|
|
553
|
+
let row2 = rows.get(key);
|
|
554
|
+
if (!row2) rows.set(key, row2 = { ...base, responses: 0, tokens: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 }, outputContext: 0 });
|
|
555
|
+
row2.responses += 1;
|
|
556
|
+
for (const t of TOKEN_TYPES) row2.tokens[t] += r.tokens[t];
|
|
557
|
+
row2.outputContext += outputContext(r.tokens);
|
|
558
|
+
}
|
|
559
|
+
return sortRows([...rows.values()]);
|
|
560
|
+
}
|
|
561
|
+
function sortRows(rows) {
|
|
562
|
+
return rows.sort((a, b) => rowKey(a).localeCompare(rowKey(b)));
|
|
563
|
+
}
|
|
564
|
+
function mergeHistory(stored, fresh) {
|
|
565
|
+
const rows = /* @__PURE__ */ new Map();
|
|
566
|
+
for (const row2 of [...stored, ...fresh]) {
|
|
567
|
+
const key = rowKey(row2);
|
|
568
|
+
const prev = rows.get(key);
|
|
569
|
+
if (!prev || sum(row2.tokens) > sum(prev.tokens) || sum(row2.tokens) === sum(prev.tokens) && moreComplete(row2, prev)) {
|
|
570
|
+
rows.set(key, row2);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return sortRows([...rows.values()]);
|
|
574
|
+
}
|
|
575
|
+
var moreComplete = (row2, prev) => row2.responses > prev.responses || row2.responses === prev.responses && row2.outputContext !== void 0 && prev.outputContext === void 0;
|
|
576
|
+
function rollupRecords(rows) {
|
|
577
|
+
return rows.map((r) => ({
|
|
578
|
+
source: r.source,
|
|
579
|
+
key: rowKey(r),
|
|
580
|
+
timestamp: `${r.date}T00:00:00.000Z`,
|
|
581
|
+
model: r.model,
|
|
582
|
+
project: r.projectId ? { id: r.projectId, name: r.projectName ?? "(unknown)" } : null,
|
|
583
|
+
tokens: { ...r.tokens }
|
|
584
|
+
}));
|
|
585
|
+
}
|
|
586
|
+
function serializeHistory(rows) {
|
|
587
|
+
const header2 = JSON.stringify({ schema: HISTORY_SCHEMA, kind: "daily-rollups", note: "Token counts only. Energy is recomputed on read." });
|
|
588
|
+
return [header2, ...rows.map((r) => JSON.stringify(r))].join("\n") + "\n";
|
|
589
|
+
}
|
|
590
|
+
function parseHistory(text) {
|
|
591
|
+
const rows = [];
|
|
592
|
+
for (const line of text.split("\n")) {
|
|
593
|
+
if (!line.trim()) continue;
|
|
594
|
+
let value;
|
|
595
|
+
try {
|
|
596
|
+
value = JSON.parse(line);
|
|
597
|
+
} catch {
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (isRow(value)) rows.push(value);
|
|
601
|
+
}
|
|
602
|
+
return rows;
|
|
603
|
+
}
|
|
604
|
+
function isRow(v) {
|
|
605
|
+
if (!v || typeof v !== "object") return false;
|
|
606
|
+
const r = v;
|
|
607
|
+
return typeof r.date === "string" && typeof r.model === "string" && typeof r.source === "string" && typeof r.responses === "number" && !!r.tokens && TOKEN_TYPES.every((t) => typeof r.tokens[t] === "number") && (r.outputContext === void 0 || typeof r.outputContext === "number");
|
|
608
|
+
}
|
|
609
|
+
function weekStart(date) {
|
|
610
|
+
const d = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
|
|
611
|
+
const offset = (d.getUTCDay() + 6) % 7;
|
|
612
|
+
d.setUTCDate(d.getUTCDate() - offset);
|
|
613
|
+
return d.toISOString().slice(0, 10);
|
|
614
|
+
}
|
|
615
|
+
function weekly(days) {
|
|
616
|
+
const weeks = /* @__PURE__ */ new Map();
|
|
617
|
+
for (const d of days) {
|
|
618
|
+
const start = weekStart(d.key);
|
|
619
|
+
let w = weeks.get(start);
|
|
620
|
+
if (!w) weeks.set(start, w = { start, low: 0, central: 0, high: 0, tokens: 0 });
|
|
621
|
+
w.low += d.energy.low;
|
|
622
|
+
w.central += d.energy.central;
|
|
623
|
+
w.high += d.energy.high;
|
|
624
|
+
w.tokens += sum(d.tokens);
|
|
625
|
+
}
|
|
626
|
+
const sorted = [...weeks.values()].sort((a, b) => a.start.localeCompare(b.start));
|
|
627
|
+
if (sorted.length < 2) return sorted;
|
|
628
|
+
const filled = [];
|
|
629
|
+
const cursor = /* @__PURE__ */ new Date(`${sorted[0].start}T00:00:00Z`);
|
|
630
|
+
const last = sorted[sorted.length - 1].start;
|
|
631
|
+
while (cursor.toISOString().slice(0, 10) <= last) {
|
|
632
|
+
const start = cursor.toISOString().slice(0, 10);
|
|
633
|
+
filled.push(weeks.get(start) ?? { start, low: 0, central: 0, high: 0, tokens: 0 });
|
|
634
|
+
cursor.setUTCDate(cursor.getUTCDate() + 7);
|
|
635
|
+
}
|
|
636
|
+
return filled;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// src/dashboard.ts
|
|
640
|
+
import { spawn } from "child_process";
|
|
641
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
|
|
642
|
+
import { join as join2 } from "path";
|
|
643
|
+
|
|
644
|
+
// ../web/src/inject.ts
|
|
645
|
+
var DATA_MARKER = /(<script id="ccwatts-data" type="application\/json">)([\s\S]*?)(<\/script>)/;
|
|
646
|
+
function safeJson(value) {
|
|
647
|
+
return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
648
|
+
}
|
|
649
|
+
function injectData(html, payload) {
|
|
650
|
+
if (!DATA_MARKER.test(html)) throw new Error("Dashboard template is missing its data slot.");
|
|
651
|
+
return html.replace(DATA_MARKER, (_m, open, _old, close) => `${open}${safeJson(payload)}${close}`);
|
|
652
|
+
}
|
|
653
|
+
var SAMPLE_MARKER = /<script id="ccwatts-sample" type="application\/json">([\s\S]*?)<\/script>/;
|
|
654
|
+
function readSample(html) {
|
|
655
|
+
const json2 = SAMPLE_MARKER.exec(html)?.[1];
|
|
656
|
+
if (!json2) throw new Error("Page is missing its sample data.");
|
|
657
|
+
return JSON.parse(json2);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ../web/dist/ccwatts.html
|
|
661
|
+
var ccwatts_default = '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; script-src \'sha256-wA+ap0z0OmyKk8/OnIKkpDoFfHm2rZcmC63kF0+LITE=\'; style-src \'unsafe-inline\'; img-src data: blob:; base-uri \'none\'; form-action \'none\'">\n<meta name="referrer" content="no-referrer">\n<title>ccwatts</title>\n<meta name="description" content="Estimate the electricity your Claude Code usage consumed, as a sourced range. Runs entirely in your browser.">\n<!-- Link preview for LinkedIn and others. The crawler fetches the image; this page never does. -->\n<meta property="og:type" content="website">\n<meta property="og:title" content="How much electricity does your Claude Code use?">\n<meta property="og:description" content="A sourced kWh range from your own logs. Runs in your browser, and your logs never leave it.">\n<meta property="og:url" content="https://chaseholroyd.com/ccwatts/">\n<meta property="og:image" content="https://chaseholroyd.com/ccwatts/og.png">\n<meta property="og:image:width" content="1200">\n<meta property="og:image:height" content="627">\n<meta property="og:image:alt" content="ccwatts: how much electricity does your Claude Code use?">\n<meta name="twitter:card" content="summary_large_image">\n<style>\n/* Layout: one reading column; the headline reads like the top of a utility bill, the share card like a till receipt. */\n:root{\n --bg:#f4f2ea; --surface:#fbfaf6; --ink:#1f2a24; --muted:#5e6b63; --line:#d9ddd3; --accent:#2f6b4f; --accent-ink:#ffffff;\n --t-cacheRead:#2f6b4f; --t-cacheWrite:#c08a2e; --t-output:#b4553a; --t-input:#4a6fa5;\n --sample:#8a5a12; --control:#7f8a83;\n --sans:"Helvetica Neue",Helvetica,Arial,system-ui,sans-serif;\n --mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;\n color-scheme:light;\n}\n@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){\n --bg:#10201a; --surface:#16291f; --ink:#e8eee9; --muted:#9fb3a6; --line:#29402f; --accent:#7cc4a0; --accent-ink:#10201a;\n --t-cacheRead:#7cc4a0; --t-cacheWrite:#e0b56a; --t-output:#e58a6e; --t-input:#8fb2e8; --sample:#e0b56a; --control:#5d7a68; color-scheme:dark}}\n:root[data-theme="dark"]{\n --bg:#10201a; --surface:#16291f; --ink:#e8eee9; --muted:#9fb3a6; --line:#29402f; --accent:#7cc4a0; --accent-ink:#10201a;\n --t-cacheRead:#7cc4a0; --t-cacheWrite:#e0b56a; --t-output:#e58a6e; --t-input:#8fb2e8; --sample:#e0b56a; --control:#5d7a68; color-scheme:dark}\n*{box-sizing:border-box}\n[hidden]{display:none!important}\nbody{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:16px;line-height:1.55}\n.wrap{max-width:960px;margin:0 auto;padding-block:28px 64px;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));display:flex;flex-direction:column;gap:40px}\nheader.top{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px}\n.back{font-size:15px;display:inline-flex;align-items:center;min-height:44px;text-decoration:none;color:var(--muted);align-self:flex-start;margin-block:-12px -28px}\n.back:hover{color:var(--accent);text-decoration:underline}\na:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}\n.intro{display:flex;flex-direction:column;gap:8px}\nh1{font-size:clamp(26px,5vw,36px);line-height:1.15;margin:0;letter-spacing:-.01em;text-wrap:balance}\n.lede{margin:0;font-size:17px;color:var(--muted);max-width:60ch}\n.brand{font-family:var(--mono);font-weight:700;font-size:18px;letter-spacing:.02em}\n.brand span{color:var(--accent)}\n.pill{font-family:var(--mono);font-size:12px;letter-spacing:.06em;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:4px 10px;color:var(--muted)}\n.pill.sample{border-color:var(--sample);color:var(--sample)}\n.privacy{display:flex;gap:10px;align-items:flex-start;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:15px}\n.privacy b{color:var(--accent)}\n.hero{display:flex;flex-direction:column;gap:6px}\n.eyebrow{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}\n.big{font-weight:700;font-size:clamp(56px,13vw,112px);line-height:1;letter-spacing:-.02em;font-variant-numeric:tabular-nums}\n.big small{font-size:.42em;font-weight:500;margin-left:.2em;letter-spacing:0}\n.range{font-size:clamp(18px,3vw,22px)}\n.compare{color:var(--muted);font-size:17px}\n.meta{font-family:var(--mono);font-size:13px;color:var(--muted)}\n.controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center}\nbutton,.btn{font:inherit;font-size:15px;min-height:44px;display:inline-flex;align-items:center;border:1px solid var(--control);background:var(--surface);color:var(--ink);border-radius:8px;padding:8px 14px;cursor:pointer}\nbutton:hover,.btn:hover{border-color:var(--accent)}\nbutton:focus-visible,.btn:focus-visible,input:focus-visible,.vh:focus-visible+.btn{outline:2px solid var(--accent);outline-offset:2px}\n/* Visually hidden but still focusable, so the styled label stays reachable by keyboard. */\n#hero-meta span{white-space:nowrap}\n.vh{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}\nbutton[aria-pressed="true"]{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}\n.primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}\n.drop{border:1.5px dashed var(--line);border-radius:12px;padding:20px;display:flex;flex-direction:column;gap:10px;background:var(--surface)}\n.drop.over{border-color:var(--accent)}\n.drop p{margin:0;color:var(--muted);font-size:14px}\ncode{font-family:var(--mono);font-size:.92em}\nsection{display:flex;flex-direction:column;gap:14px;min-width:0}\nh2{font-size:20px;margin:0;text-wrap:balance}\n.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:32px}\n.chart{width:100%;height:auto;display:block}\n.chart text{fill:var(--muted);font-family:var(--mono);font-size:11px}\n.legend{display:flex;flex-wrap:wrap;gap:14px;font-size:13px;color:var(--muted)}\n.legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:6px;vertical-align:-1px}\n.rank{display:flex;flex-direction:column;gap:10px;margin:0;padding:0;list-style:none}\n.rank li{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;font-size:14px}\n.rank .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.rank .val{font-family:var(--mono);font-variant-numeric:tabular-nums;color:var(--muted);text-align:right}\n.rank .bar{grid-column:1/-1;height:8px;background:var(--line);border-radius:4px;overflow:hidden}\n.rank .bar span{display:block;height:100%;background:var(--accent)}\n.explain{max-width:65ch;margin:0}\n.tablewrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px}\ntable{border-collapse:collapse;width:100%;font-size:14px;min-width:620px}\nth,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top}\nth{font-family:var(--mono);font-size:12px;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);font-weight:500}\ntd.num{font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap}\ntr:last-child td{border-bottom:0}\n.label{font-family:var(--mono);font-size:11px;padding:2px 6px;border-radius:4px;border:1px solid currentColor}\na{color:var(--accent)}\na.src{display:inline-flex;align-items:center;min-height:44px}\n.phone-only{display:none}\n@media (max-width:600px){\n .phone-only{display:block}\n /* Method table: one card per parameter instead of a sideways scroll. */\n table{min-width:0}\n thead{display:none}\n table,tbody,tr,td{display:block}\n tr{padding:10px 12px;border-bottom:1px solid var(--line)}\n tr:last-child{border-bottom:0}\n td{border:0;padding:2px 0;white-space:normal}\n td[data-label]:not([data-label="Parameter"])::before{content:attr(data-label) " ";font-family:var(--mono);font-size:12px;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-right:8px}\n td.num{white-space:normal}\n}\n.card{max-width:100%;height:auto;border-radius:10px;border:1px solid var(--line);display:block}\n.sharetext{font-size:14px;background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px;white-space:pre-wrap;margin:0}\n.status{font-size:14px;color:var(--muted);min-height:1.4em}\n.export>summary{cursor:pointer;min-height:44px;padding:11px 0;box-sizing:border-box;font-size:15px;color:var(--ink)}\n.export>*+*{margin-top:10px}\n.export label{display:block;font-size:14px;color:var(--muted)}\n.export textarea{display:block;font-family:var(--mono);font-size:13px;width:100%;box-sizing:border-box;border:1px solid var(--control);border-radius:8px;padding:10px;background:var(--surface);color:var(--ink)}\nkbd{font-family:var(--mono);font-size:.85em;border:1px solid var(--line);border-radius:4px;padding:0 4px}\nfooter{color:var(--muted);font-size:13px;border-top:1px solid var(--line);padding-top:16px;display:flex;flex-direction:column;gap:6px}\n@media (prefers-reduced-motion:no-preference){.rank .bar span{transition:width .3s ease}}\n</style>\n</head>\n<body>\n<main class="wrap">\n <a class="back" id="back-link" href="#" hidden>\u2190 Chase Holroyd\'s portfolio</a>\n <header class="top">\n <div class="brand">cc<span>watts</span></div>\n <span class="pill" id="source-pill">Loading</span>\n </header>\n\n <div class="intro">\n <h1>How much electricity does your AI coding use?</h1>\n <p class="lede" id="lede">ccwatts estimates the electricity behind Claude Code, an AI coding agent, from the token counts in its local logs. You\'re looking at sample data; try your own logs below.</p>\n </div>\n\n <div class="privacy" id="privacy"><span aria-hidden="true">\u{1F512}</span><div><b>Your logs never leave this browser.</b> This page makes no network requests. It reads token counts only, never your prompts or code.</div></div>\n\n <section class="hero" aria-live="polite">\n <span class="eyebrow" id="hero-eyebrow">Claude Code electricity</span>\n <div class="big" id="hero-big">\u2013</div>\n <div class="range" id="hero-range"></div>\n <div class="compare" id="hero-compare"></div>\n <div class="meta" id="hero-meta"></div>\n <div class="controls" role="group" aria-label="Time window">\n <button type="button" data-window="7d" id="w-7d">7 days</button>\n <button type="button" data-window="30d" id="w-30d">30 days</button>\n <button type="button" data-window="all" id="w-all">All time</button>\n </div>\n </section>\n\n <section class="drop" id="drop" hidden>\n <h2>Try it on your own logs</h2>\n <p>Claude Code saves logs in <code>~/.claude/projects</code>. Pick that folder, or drop any <code>.jsonl</code> files here. Files are read by this page on your device and never uploaded. On a Mac the folder is hidden: in the picker, press <kbd>\u2318</kbd> <kbd>\u21E7</kbd> <kbd>.</kbd> to show it.</p>\n <div class="controls">\n <input type="file" class="vh" id="pick-folder" webkitdirectory multiple>\n <label class="btn primary" for="pick-folder">Choose folder</label>\n <input type="file" class="vh" id="pick-files" accept=".jsonl" multiple>\n <label class="btn" for="pick-files">Choose files</label>\n <button type="button" id="use-sample">Use sample data</button>\n </div>\n <p id="cli-line">Prefer the terminal? Run <code id="cli-cmd">npx ccwatts</code> with Node 20+. Same numbers, plus a history that outlasts Claude Code\'s log cleanup.</p>\n <p class="phone-only" id="phone-hint">On a phone? Claude Code keeps its logs on the computer it runs on. Open this page there, or use the prompt below if you run Claude Code on the web.</p>\n <details class="export" id="export">\n <summary>Use Claude Code on the web, or can\'t find the folder?</summary>\n <p>Paste this prompt into a Claude Code session. It writes a copy of that machine\'s usage with token counts only, and no prompts or code. Then drop the file here, or paste what it prints into the box below.</p>\n <pre class="sharetext" id="export-prompt">Make a counts-only copy of my Claude Code usage for ccwatts.\n\nRead every .jsonl file under ~/.claude/projects. For each line whose "type" is "assistant" and that has message.usage, write one JSON line containing only:\n- type, timestamp, requestId\n- cwd, reduced to the last folder name (never the full path)\n- message.id and message.model\n- message.usage with input_tokens, cache_creation_input_tokens, cache_read_input_tokens and output_tokens\n\nLeave out everything else, especially message content. Save the lines as ccwatts-usage.jsonl and give me the file to download. If you can\'t offer a download, print the lines in one code block instead.</pre>\n <div class="controls">\n <button type="button" id="copy-prompt">Copy prompt</button>\n </div>\n <label for="paste-log">Paste the lines it printed</label>\n <textarea id="paste-log" rows="4" spellcheck="false" placeholder=\'{"type":"assistant", \u2026}\'></textarea>\n <div class="controls">\n <button type="button" id="read-paste">Read pasted lines</button>\n </div>\n </details>\n <div class="status" id="drop-status"></div>\n </section>\n\n <section>\n <h2>Over time</h2>\n <div class="legend" id="legend"></div>\n <svg class="chart" id="chart" viewBox="0 0 720 240" role="img" aria-label="Daily electricity by token type"></svg>\n <p class="meta" id="chart-note"></p>\n </section>\n\n <div class="grid2">\n <section><h2>By model</h2><ul class="rank" id="by-model"></ul></section>\n <section><h2>By project</h2><ul class="rank" id="by-project"></ul></section>\n </div>\n\n <section>\n <h2>What drives your number</h2>\n <p class="explain" id="explain"></p>\n </section>\n\n <section>\n <h2>Share it</h2>\n <img class="card" id="card" alt="Electricity receipt card" width="1200" height="627">\n <div class="controls">\n <button type="button" class="primary" id="save-png">Save PNG for LinkedIn</button>\n <button type="button" id="save-svg">Save SVG</button>\n <button type="button" id="copy-line">Copy share line</button>\n <button type="button" id="copy-post">Copy LinkedIn post</button>\n </div>\n <div class="status" id="share-status"></div>\n <pre class="sharetext" id="post"></pre>\n </section>\n\n <section>\n <h2>How this is estimated</h2>\n <p class="explain" id="method-intro"></p>\n <div class="tablewrap"><table id="params"></table></div>\n </section>\n\n <footer>\n <div id="footer-version"></div>\n <div>Open source. Read the code and the full methodology on <a id="repo-link" href="#" rel="noopener noreferrer">GitHub</a>.</div>\n <div>Built by <a id="built-by" href="#" rel="noopener noreferrer">Chase Holroyd</a>.</div>\n </footer>\n</main>\n<script id="ccwatts-data" type="application/json">{"mode":"playground","generatedAt":null,"rows":[]}</script>\n<script id="ccwatts-sample" type="application/json">[{"date":"2026-09-21","source":"claude-code","model":"claude-opus-4-6","projectId":"0bb3418a8c1418a8","projectName":"hearth","responses":47,"tokens":{"input":481,"cacheWrite":224858,"cacheRead":5433921,"output":21302},"outputContext":2453872321},{"date":"2026-09-21","source":"claude-code","model":"claude-sonnet-4-6","projectId":"367b937ac815b163","projectName":"ccwatts","responses":17,"tokens":{"input":190,"cacheWrite":86694,"cacheRead":873313,"output":7714},"outputContext":425266546},{"date":"2026-09-22","source":"claude-code","model":"claude-sonnet-4-6","projectId":"d11d2b07dcb20490","projectName":"portfolio-site","responses":40,"tokens":{"input":393,"cacheWrite":206396,"cacheRead":4259456,"output":20119},"outputContext":2244470466},{"date":"2026-09-23","source":"claude-code","model":"claude-opus-4-6","projectId":"d11d2b07dcb20490","projectName":"portfolio-site","responses":52,"tokens":{"input":503,"cacheWrite":212076,"cacheRead":5914935,"output":28587},"outputContext":3409886608},{"date":"2026-09-24","source":"claude-code","model":"claude-haiku-4-5","projectId":"367b937ac815b163","projectName":"ccwatts","responses":21,"tokens":{"input":190,"cacheWrite":94458,"cacheRead":1218020,"output":10292},"outputContext":590700102},{"date":"2026-09-24","source":"claude-code","model":"claude-opus-4-6","projectId":"0bb3418a8c1418a8","projectName":"hearth","responses":95,"tokens":{"input":977,"cacheWrite":439114,"cacheRead":11248882,"output":44237},"outputContext":5490113479},{"date":"2026-09-25","source":"claude-code","model":"claude-opus-4-6","projectId":"48cee83ad258011a","projectName":"dotfiles","responses":19,"tokens":{"input":209,"cacheWrite":93535,"cacheRead":1038543,"output":9584},"outputContext":589452635},{"date":"2026-09-27","source":"claude-code","model":"claude-sonnet-4-6","projectId":"48cee83ad258011a","projectName":"dotfiles","responses":64,"tokens":{"input":655,"cacheWrite":253381,"cacheRead":7300740,"output":31952},"outputContext":3769121520},{"date":"2026-09-28","source":"claude-code","model":"claude-opus-4-6","projectId":"48cee83ad258011a","projectName":"dotfiles","responses":28,"tokens":{"input":276,"cacheWrite":140541,"cacheRead":2230744,"output":13360},"outputContext":1153747881},{"date":"2026-09-29","source":"claude-code","model":"claude-haiku-4-5","projectId":"0bb3418a8c1418a8","projectName":"hearth","responses":38,"tokens":{"input":356,"cacheWrite":152315,"cacheRead":3272451,"output":18960},"outputContext":1641654756},{"date":"2026-09-29","source":"claude-code","model":"claude-haiku-4-5","projectId":"367b937ac815b163","projectName":"ccwatts","responses":32,"tokens":{"input":340,"cacheWrite":134170,"cacheRead":2682489,"output":16997},"outputContext":1568755458},{"date":"2026-09-30","source":"claude-code","model":"claude-sonnet-4-6","projectId":"0bb3418a8c1418a8","projectName":"hearth","responses":31,"tokens":{"input":353,"cacheWrite":135035,"cacheRead":2371226,"output":16712},"outputContext":1324962703},{"date":"2026-10-01","source":"claude-code","model":"claude-opus-4-6","projectId":"0bb3418a8c1418a8","projectName":"hearth","responses":16,"tokens":{"input":138,"cacheWrite":80233,"cacheRead":768856,"output":6943},"outputContext":382418077},{"date":"2026-10-01","source":"claude-code","model":"claude-sonnet-4-6","projectId":"367b937ac815b163","projectName":"ccwatts","responses":24,"tokens":{"input":303,"cacheWrite":108412,"cacheRead":1401918,"output":11697},"outputContext":695089914},{"date":"2026-10-02","source":"claude-code","model":"claude-opus-4-6","projectId":"48cee83ad258011a","projectName":"dotfiles","responses":50,"tokens":{"input":467,"cacheWrite":243377,"cacheRead":6114791,"output":24218},"outputContext":3052033764},{"date":"2026-10-02","source":"claude-code","model":"claude-sonnet-4-6","projectId":"48cee83ad258011a","projectName":"dotfiles","responses":31,"tokens":{"input":291,"cacheWrite":156640,"cacheRead":2602032,"output":14670},"outputContext":1290375171},{"date":"2026-10-03","source":"claude-code","model":"claude-sonnet-4-6","projectId":"d11d2b07dcb20490","projectName":"portfolio-site","responses":67,"tokens":{"input":677,"cacheWrite":296672,"cacheRead":8900888,"output":33220},"outputContext":4724382924},{"date":"2026-10-05","source":"claude-code","model":"claude-opus-4-6","projectId":"367b937ac815b163","projectName":"ccwatts","responses":61,"tokens":{"input":601,"cacheWrite":250048,"cacheRead":7595553,"output":29379},"outputContext":3577266281},{"date":"2026-10-05","source":"claude-code","model":"claude-opus-4-6","projectId":"48cee83ad258011a","projectName":"dotfiles","responses":32,"tokens":{"input":349,"cacheWrite":148766,"cacheRead":2857812,"output":18526},"outputContext":1716421621},{"date":"2026-10-06","source":"claude-code","model":"claude-haiku-4-5","projectId":"48cee83ad258011a","projectName":"dotfiles","responses":42,"tokens":{"input":371,"cacheWrite":175342,"cacheRead":4217525,"output":20010},"outputContext":2086579461},{"date":"2026-10-07","source":"claude-code","model":"claude-haiku-4-5","projectId":"48cee83ad258011a","projectName":"dotfiles","responses":53,"tokens":{"input":519,"cacheWrite":232336,"cacheRead":6626491,"output":26226},"outputContext":3535583255},{"date":"2026-10-07","source":"claude-code","model":"claude-sonnet-4-6","projectId":"367b937ac815b163","projectName":"ccwatts","responses":27,"tokens":{"input":299,"cacheWrite":126737,"cacheRead":2032642,"output":15950},"outputContext":1332905565},{"date":"2026-10-08","source":"claude-code","model":"claude-opus-4-6","projectId":"0bb3418a8c1418a8","projectName":"hearth","responses":72,"tokens":{"input":805,"cacheWrite":314745,"cacheRead":9938479,"output":36755},"outputContext":5269927123},{"date":"2026-10-08","source":"claude-code","model":"claude-opus-4-6","projectId":"d11d2b07dcb20490","projectName":"portfolio-site","responses":71,"tokens":{"input":823,"cacheWrite":315410,"cacheRead":9639360,"output":33996},"outputContext":4708628928},{"date":"2026-10-08","source":"claude-code","model":"claude-sonnet-4-6","projectId":"48cee83ad258011a","projectName":"dotfiles","responses":29,"tokens":{"input":288,"cacheWrite":144763,"cacheRead":2133553,"output":18135},"outputContext":1399436901},{"date":"2026-10-09","source":"claude-code","model":"claude-opus-4-6","projectId":"367b937ac815b163","projectName":"ccwatts","responses":32,"tokens":{"input":316,"cacheWrite":129817,"cacheRead":2427569,"output":15515},"outputContext":1186325442},{"date":"2026-10-09","source":"claude-code","model":"claude-opus-4-6","projectId":"d11d2b07dcb20490","projectName":"portfolio-site","responses":15,"tokens":{"input":122,"cacheWrite":58466,"cacheRead":581654,"output":7239},"outputContext":307424407},{"date":"2026-10-09","source":"claude-code","model":"claude-sonnet-4-6","projectId":"367b937ac815b163","projectName":"ccwatts","responses":52,"tokens":{"input":438,"cacheWrite":243114,"cacheRead":6293294,"output":26306},"outputContext":3344828844},{"date":"2026-10-10","source":"claude-code","model":"claude-opus-4-6","projectId":"367b937ac815b163","projectName":"ccwatts","responses":66,"tokens":{"input":673,"cacheWrite":273538,"cacheRead":8614195,"output":31043},"outputContext":4430386287},{"date":"2026-10-11","source":"claude-code","model":"claude-sonnet-4-6","projectId":"367b937ac815b163","projectName":"ccwatts","responses":71,"tokens":{"input":885,"cacheWrite":325807,"cacheRead":9676339,"output":33624},"outputContext":4753439717},{"date":"2026-10-12","source":"claude-code","model":"claude-opus-4-6","projectId":"48cee83ad258011a","projectName":"dotfiles","responses":16,"tokens":{"input":186,"cacheWrite":90712,"cacheRead":869169,"output":8201},"outputContext":521288313},{"date":"2026-10-12","source":"claude-code","model":"claude-opus-4-6","projectId":"d11d2b07dcb20490","projectName":"portfolio-site","responses":46,"tokens":{"input":504,"cacheWrite":208473,"cacheRead":4885505,"output":22366},"outputContext":2523675175},{"date":"2026-10-13","source":"claude-code","model":"claude-opus-4-6","projectId":"367b937ac815b163","projectName":"ccwatts","responses":20,"tokens":{"input":244,"cacheWrite":111650,"cacheRead":1255969,"output":9499},"outputContext":611397728},{"date":"2026-10-13","source":"claude-code","model":"claude-sonnet-4-6","projectId":"367b937ac815b163","projectName":"ccwatts","responses":25,"tokens":{"input":221,"cacheWrite":111841,"cacheRead":1586210,"output":11735},"outputContext":798440653},{"date":"2026-10-14","source":"claude-code","model":"claude-opus-4-6","projectId":"0bb3418a8c1418a8","projectName":"hearth","responses":43,"tokens":{"input":530,"cacheWrite":183803,"cacheRead":4389683,"output":20586},"outputContext":2203768836},{"date":"2026-10-14","source":"claude-code","model":"claude-sonnet-4-6","projectId":"367b937ac815b163","projectName":"ccwatts","responses":59,"tokens":{"input":674,"cacheWrite":217090,"cacheRead":6575361,"output":28579},"outputContext":3253006122},{"date":"2026-10-15","source":"claude-code","model":"claude-haiku-4-5","projectId":"367b937ac815b163","projectName":"ccwatts","responses":74,"tokens":{"input":825,"cacheWrite":309110,"cacheRead":10212638,"output":38706},"outputContext":5535358176},{"date":"2026-10-15","source":"claude-code","model":"claude-sonnet-4-6","projectId":"367b937ac815b163","projectName":"ccwatts","responses":20,"tokens":{"input":265,"cacheWrite":109170,"cacheRead":1296443,"output":8112},"outputContext":599370597},{"date":"2026-10-15","source":"claude-code","model":"claude-sonnet-4-6","projectId":"d11d2b07dcb20490","projectName":"portfolio-site","responses":31,"tokens":{"input":253,"cacheWrite":156477,"cacheRead":2747706,"output":13093},"outputContext":1311357449},{"date":"2026-10-16","source":"claude-code","model":"claude-haiku-4-5","projectId":"0bb3418a8c1418a8","projectName":"hearth","responses":28,"tokens":{"input":336,"cacheWrite":130298,"cacheRead":1943815,"output":14701},"outputContext":1081930686},{"date":"2026-10-16","source":"claude-code","model":"claude-opus-4-6","projectId":"d11d2b07dcb20490","projectName":"portfolio-site","responses":48,"tokens":{"input":494,"cacheWrite":215976,"cacheRead":5622539,"output":24681},"outputContext":3062604147},{"date":"2026-10-17","source":"claude-code","model":"claude-opus-4-6","projectId":"d11d2b07dcb20490","projectName":"portfolio-site","responses":56,"tokens":{"input":530,"cacheWrite":237670,"cacheRead":6869143,"output":25467},"outputContext":3159646087},{"date":"2026-10-18","source":"claude-code","model":"claude-haiku-4-5","projectId":"48cee83ad258011a","projectName":"dotfiles","responses":38,"tokens":{"input":416,"cacheWrite":162013,"cacheRead":3360821,"output":20262},"outputContext":1907957687},{"date":"2026-10-19","source":"claude-code","model":"claude-haiku-4-5","projectId":"d11d2b07dcb20490","projectName":"portfolio-site","responses":97,"tokens":{"input":1108,"cacheWrite":414100,"cacheRead":10652494,"output":48446},"outputContext":5589764863},{"date":"2026-10-19","source":"claude-code","model":"claude-sonnet-4-6","projectId":"d11d2b07dcb20490","projectName":"portfolio-site","responses":65,"tokens":{"input":708,"cacheWrite":292947,"cacheRead":8732693,"output":35185},"outputContext":4755010745},{"date":"2026-10-20","source":"claude-code","model":"claude-haiku-4-5","projectId":"d11d2b07dcb20490","projectName":"portfolio-site","responses":66,"tokens":{"input":726,"cacheWrite":297357,"cacheRead":8882173,"output":32673},"outputContext":4543520242},{"date":"2026-10-20","source":"claude-code","model":"claude-sonnet-4-6","projectId":"d11d2b07dcb20490","projectName":"portfolio-site","responses":128,"tokens":{"input":1264,"cacheWrite":557853,"cacheRead":16226037,"output":59579},"outputContext":7838321066}]</script>\n<script>"use strict";(()=>{var R="ccwatts",ne="0.1.1",S="https://github.com/holroydchase-afk/ccwatts",q="https://chaseholroyd.com/ccwatts/",K="https://chaseholroyd.com/ccwatts.html";var W={input:"input",cacheWrite:"cache write",cacheRead:"cache read",output:"output"},L=e=>Math.round(e).toLocaleString("en-US");function A(e){return e>=100?L(e):e>=1?(Math.round(e*10)/10).toString():e>=.01?(Math.round(e*100)/100).toString():e===0?"0":e.toPrecision(1)}var oe=e=>e>=1&&e<100?e.toFixed(1):A(e);function M(e){let t=e.high>=1e3,n=t?1e3:1,o=t?"kWh":"Wh";return{central:`${oe(e.central/n)} ${o}`,range:`${oe(e.low/n)} \\u2013 ${oe(e.high/n)}`,unit:o}}var O=e=>e.replace(/^claude-/,"");var E=["input","cacheWrite","cacheRead","output"];var V=()=>({low:0,central:0,high:0});function re(e,t){return e.low+=t.low,e.central+=t.central,e.high+=t.high,e}function Re(e,t){let n=e.toLowerCase();for(let[o,r]of Object.entries(t.tiers))if(Array.isArray(r)&&r.some(s=>n.includes(s)))return{tier:o,known:!0};return{tier:String(t.tiers.fallback),known:!1}}function He(e,t){let n=e.params[t];if(!n)throw new Error(`Missing parameter "${t}" in params.json`);return n}function Be(e,t){let n=`output_wh_per_1k.${e}`;switch(t){case"output":return[n];case"input":return[n,"input_vs_output"];case"cacheWrite":return[n,"input_vs_output","cache_write_vs_input"];case"cacheRead":return[n,"input_vs_output","cache_read_vs_input"]}}function Fe(e,t,n){let o=.001,r=0,s=0;for(let a of Be(t,n)){let c=He(e,a);o*=c.value,r+=Math.log(c.high/c.value)**2,s+=Math.log(c.value/c.low)**2}return{whPerToken:o,up:Math.sqrt(r),down:Math.sqrt(s)}}var $e=()=>({input:0,cacheWrite:0,cacheRead:0,output:0});var be=new WeakMap;function Ye(e,t,n){let o=be.get(e);o||be.set(e,o=new Map);let r=`${t}:${n}`,s=o.get(r);return s||o.set(r,s=Fe(e,t,n)),s}function Te(e,t,n,o){if(e<=0)return V();let r=Ye(o,t,n),s=e*r.whPerToken;return{low:s/Math.exp(r.down),central:s,high:s*Math.exp(r.up)}}var ze=e=>e.slice(0,10);function G(e,t){let n=V(),o=$e(),r={model:new Map,type:new Map,day:new Map,project:new Map},s=new Set,a=null,c=null,k=(l,h,m,p,u)=>{let g=r[l].get(h);g||r[l].set(h,g={key:h,energy:V(),tokens:$e()}),re(g.energy,m),g.tokens[p]+=u};for(let l of e){let{tier:h,known:m}=Re(l.model,t);m||s.add(l.model);let p=ze(l.timestamp);(a===null||p<a)&&(a=p),(c===null||p>c)&&(c=p);let u=l.project?.name??"(unknown)";for(let g of E){let y=l.tokens[g];if(y<=0)continue;let T=Te(y,h,g,t);re(n,T),o[g]+=y,k("model",l.model,T,g,y),k("type",g,T,g,y),k("day",p,T,g,y),k("project",l.project?`${u}#${l.project.id}`:u,T,g,y)}}let $=l=>[...l.values()].sort((h,m)=>m.energy.central-h.energy.central),j=[...s].map(l=>`Unknown model "${l}": estimated as ${String(t.tiers.fallback)}.`);return{methodVersion:t.version,total:n,tokens:o,responses:e.length,byModel:$(r.model),byType:$(r.type),byDay:[...r.day.values()].sort((l,h)=>l.key.localeCompare(h.key)),byProject:$(r.project),span:a&&c?{from:a,to:c}:null,warnings:j}}var se=e=>e.split("#")[0]??e;function qe(e,t){if(t(e)===1)return"charging a phone about once";if(e>=1)return`charging a phone about ${t(e)} times`;let n=Math.round(e*100);return n<1?"under 1% of a phone charge":n>=45&&n<=55?"about half a phone charge":`about ${Math.round(n/10)*10||n}% of a phone charge`}function ae(e,t){let n=t.comparisons,o=[],r=a=>a>=10?Math.round(a):Math.round(a*10)/10,s=(a,c,k)=>r(a)===1?`1 ${c}`:`${r(a)} ${k}`;if(n.ev_kwh_per_mile){let a=e/n.ev_kwh_per_mile.value;o.push({id:"ev",amount:a,text:`driving an EV about ${s(a,"mile","miles")}`})}if(n.fridge_kwh_per_day){let a=e/n.fridge_kwh_per_day.value;o.push({id:"fridge",amount:a,text:`running a fridge about ${s(a,"day","days")}`})}if(n.phone_charge_kwh){let a=e/n.phone_charge_kwh.value;o.push({id:"phone",amount:a,text:qe(a,r)})}if(n.us_home_kwh_per_day){let a=e/n.us_home_kwh_per_day.value;o.push({id:"home",amount:a,text:`powering a US home about ${s(a,"day","days")}`})}return o}function U(e,t){let n=ae(e,t),o=n.filter(s=>s.amount>=1).slice(0,2),r=[...n].sort((s,a)=>a.amount-s.amount).slice(0,1);return(o.length?o:r).map(s=>s.text).join(" \\xB7 ")}var ve=S.replace(/^https:\\/\\//,"");function Ee(e){return e==="all time"?"All my Claude Code use so far":e.startsWith("since")?`My Claude Code use ${e}`:`My ${e} of Claude Code`}function _e(e){return e==="all time"?"(Sample data) All of a sample\'s Claude Code use":`(Sample data) ${e[0].toUpperCase()}${e.slice(1)} of Claude Code`}function J({estimate:e,params:t,windowLabel:n,sample:o}){let r=M(e.total),s=U(e.total.central/1e3,t).split(" \\xB7 ")[0];return`${o?_e(n):Ee(n)} came to about ${r.central} of electricity (range ${r.range} ${r.unit}), like ${s}. Estimated locally with ${R}, method v${t.version}: ${ve}`}function ie(e){let{estimate:t,params:n,windowLabel:o,sample:r}=e,s=M(t.total),a=t.total.central>0?Math.round((t.byType.find(c=>c.key==="cacheRead")?.energy.central??0)/t.total.central*100):0;return r?[`(Sample data) This is what ${R} shows for a sample developer, not a real measurement. ${_e(o).replace("(Sample data) ","")}: about ${s.central} (range ${s.range} ${s.unit}), like ${U(t.total.central/1e3,n).replace(" \\xB7 "," or ")}.`,"",`${a}% of it came from the agent re-reading context it had already seen.`,"","It\'s a range because nobody outside the AI labs knows the real energy per token. Every assumption is sourced, and it runs entirely on your own machine, so no logs leave it.","",`Try it on your own logs: ${q}`,`Code and method: ${S}`].join(`\n`):[`I estimated the electricity behind my AI coding. ${Ee(o)}: about ${s.central} (range ${s.range} ${s.unit}), like ${U(t.total.central/1e3,n).replace(" \\xB7 "," or ")}.`,"",`${a>=50?"The surprise: ":""}${a}% of it came from the agent re-reading context it had already seen.`,"","It\'s a range because nobody outside the AI labs knows the real energy per token. Every assumption is sourced, and the tool runs entirely on my laptop, so no logs leave it.","",`Try it in your browser: ${q}`,`Code and method: ${S}`].join(`\n`)}var _=e=>e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function N(e,t,n){let o=Math.max(2,n-e.length-t.length-2);return`${e} ${".".repeat(o)} ${t}`}function ce({estimate:e,params:t,windowLabel:n,sample:o}){let a=M(e.total),c=e.total.central/1e3,k=U(c,t).split(" \\xB7 "),$=a.unit,j=$==="kWh"?1e3:1,l=712,h=432,m=36,p=[],u="-".repeat(m);p.push({text:"ELECTRICITY RECEIPT",weight:"bold"}),p.push({text:e.span?`${e.span.from} \\u2192 ${e.span.to}`:n,muted:!0}),p.push({text:u,muted:!0});let g=[...E].sort((d,w)=>(e.byType.find(v=>v.key===w)?.energy.central??0)-(e.byType.find(v=>v.key===d)?.energy.central??0));for(let d of g){let w=e.byType.find(v=>v.key===d);p.push({text:N(W[d],`${A((w?.energy.central??0)/j)} ${$}`,m)})}p.push({text:u,muted:!0}),p.push({text:N("TOTAL (central)",a.central,m),weight:"bold"}),p.push({text:N("RANGE",`${a.range} ${$}`,m)}),p.push({text:u,muted:!0});for(let d of e.byModel.slice(0,3)){let w=e.total.central>0?Math.round(d.energy.central/e.total.central*100):0;p.push({text:N(O(d.key),`${w}%`,m),muted:!0})}p.push({text:N("responses",L(e.responses),m),muted:!0}),p.push({text:`method v${t.version} \\xB7 counts only`,muted:!0});let y=25,T=46,B=T+44,I=B+p.length*y+6,P=I+70,D=e.byDay.slice(-30),F=Math.max(...D.map(d=>d.energy.central),1e-9),ye=(h-64)/Math.max(D.length,1),De=D.map((d,w)=>{let v=6+d.energy.central/F*40;return`<rect x="${(l+32+w*ye).toFixed(1)}" y="${(I+46-v).toFixed(1)}" width="${Math.max(1,ye-2).toFixed(1)}" height="${v.toFixed(1)}" fill="#1f2a24"/>`}).join(""),we=[];for(let d=0,w=l+h;w>l;d++,w-=9)we.push(`${w.toFixed(1)},${P+(d%2?10:0)}`);let Oe=`M${l},${T} H${l+h} V${P} L${we.join(" L")} L${l},${P} Z`,Y="ui-monospace, \'SF Mono\', Menlo, Consolas, \'Liberation Mono\', monospace",z="\'Helvetica Neue\', Helvetica, Arial, sans-serif",Ne=p.map((d,w)=>{let v=B+w*y,xe=w<2||d.text.startsWith("method");return`<text x="${xe?l+h/2:l+30}" y="${v}" font-family="${Y}" font-size="16.5" ${xe?\'text-anchor="middle"\':""} font-weight="${d.weight??"normal"}" fill="${d.muted?"#5e6b63":"#1f2a24"}">${_(d.text)}</text>`}).join(`\n `),ke=a.central.split(" ");return`<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="627" viewBox="0 0 1200 627" role="img" aria-label="${_(J({estimate:e,params:t,windowLabel:n,sample:o}))}">\n <rect width="1200" height="627" fill="#12251c"/>\n <rect x="0" y="621" width="1200" height="6" fill="#7cc4a0"/>\n <text x="64" y="104" font-family="${Y}" font-size="20" letter-spacing="2" fill="#7cc4a0">${o?\'<tspan fill="#e0b56a">SAMPLE DATA \\xB7 </tspan>\':""}CLAUDE CODE \\xB7 ${_(n.toUpperCase())}</text>\n <text x="60" y="262" font-family="${z}" font-size="148" font-weight="700" fill="#f4f1e8">${_(ke[0]??"")}<tspan font-size="64" font-weight="500" dx="14">${_(ke[1]??"")}</tspan></text>\n <text x="64" y="318" font-family="${z}" font-size="28" fill="#cfe3d6">of electricity, range ${_(a.range)} ${$}</text>\n ${k.map((d,w)=>`<text x="64" y="${384+w*38}" font-family="${z}" font-size="26" fill="#f4f1e8">\\u2248 ${_(d)}</text>`).join(`\n `)}\n <text x="64" y="520" font-family="${z}" font-size="20" fill="${o?"#e0b56a":"#9fb8a8"}">${o?"Sample data, not a real measurement.":"Estimated on my laptop. No logs left it."}</text>\n <text x="64" y="552" font-family="${Y}" font-size="17" fill="#7cc4a0">${_(R)} \\xB7 method v${_(t.version)}</text>\n <text x="64" y="578" font-family="${Y}" font-size="17" fill="#7cc4a0">${_(ve)}</text>\n <path d="${Oe}" fill="#f7f5ef"/>\n <g>\n ${Ne}\n </g>\n ${De}\n</svg>\n`}function Ke(e){let t=0xcbf29ce484222325n,n=0x100000001b3n;for(let o=0;o<e.length;o++)t^=BigInt(e.charCodeAt(o)),t=t*n&0xffffffffffffffffn;return t.toString(16).padStart(16,"0")}function le(e){let t=e.replace(/[\\\\/]+$/,""),n=t.split(/[\\\\/]/).pop()||t;return{id:Ke(t),name:n}}var Z=e=>typeof e=="number"&&Number.isFinite(e)&&e>0?e:0,H=e=>typeof e=="string"&&e!==""?e:null;function Ve(e){if(e.type!=="assistant")return null;let t=e.message,n=t?.usage,o=H(t?.model),r=H(e.timestamp),s=H(t?.id);if(!n||!o||!r||!s||o==="<synthetic>")return null;let a=H(e.cwd);return{source:"claude-code",key:`${s}:${H(e.requestId)??""}`,timestamp:r,model:o,project:a?le(a):null,tokens:{input:Z(n.input_tokens),cacheWrite:Z(n.cache_creation_input_tokens),cacheRead:Z(n.cache_read_input_tokens),output:Z(n.output_tokens)}}}var ue={id:"claude-code",parse(e){let t=new Map;for(let n of e.split(`\n`)){if(!n.includes(\'"assistant"\'))continue;let o;try{o=JSON.parse(n)}catch{continue}let r=Ve(o);r&&t.set(r.key,r)}return[...t.values()]}};function pe(e){let t=new Map;for(let n of e)t.set(`${n.source}:${n.key}`,n);return[...t.values()]}var Ce={version:"0.1.0",as_of:"2026-10-21",license:"CC BY 4.0",boundary:"comprehensive: accelerators, host CPU and memory, idle capacity, and data-center overhead (PUE)",interval:"low and high are each parameter\'s plausible bounds, treated as a ~90% interval",params:{"output_wh_per_1k.opus":{value:1.2,low:.4,high:3,unit:"Wh per 1k output tokens",label:"MODELED",source:"https://www.theclimatebrink.com/p/the-real-energy-use-of-agentic-ai",as_of:"2026-10-21",note:"Largest tier. Scaled up from the Sonnet value; Anthropic does not disclose model size or energy. Bounds cover published per-token estimates for frontier models."},"output_wh_per_1k.sonnet":{value:.6,low:.25,high:1.5,unit:"Wh per 1k output tokens",label:"MODELED",source:"https://arxiv.org/abs/2508.15734",as_of:"2026-10-21",note:"Anchored so a typical chat query (about 1k input, 400 output tokens) lands near Google\'s measured 0.24 Wh median Gemini prompt and the 0.34 Wh median frontier query (IQR 0.18-0.67). Third-party estimates for Claude output run 0.4-2 Wh per 1k."},"output_wh_per_1k.haiku":{value:.25,low:.08,high:.6,unit:"Wh per 1k output tokens",label:"GUESS",source:"https://www.anthropic.com/pricing",as_of:"2026-10-21",note:"Smallest tier. Scaled down from Sonnet roughly in line with the price gap between tiers, which is a weak proxy for compute."},input_vs_output:{value:.3,low:.15,high:.5,unit:"ratio",label:"MODELED",source:"https://www.anthropic.com/pricing",as_of:"2026-10-21",note:"Energy of one fresh input token relative to one output token. Input is processed in parallel (prefill), so it costs less per token. Anthropic prices input at 1/5 of output; a Watershed-based estimate uses about 1/3."},cache_write_vs_input:{value:1,low:.8,high:1.25,unit:"ratio",label:"MODELED",source:"https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",as_of:"2026-10-21",note:"Writing to the cache means processing the tokens fresh, then storing the result. Priced at 1.25x input."},cache_read_vs_input:{value:.1,low:.01,high:.25,unit:"ratio",label:"MODELED",source:"https://www.theclimatebrink.com/p/the-real-energy-use-of-agentic-ai",as_of:"2026-10-21",note:"The parameter that matters most for agents, where cache reads are 90%+ of tokens. Hausfather\'s central case is 10% of fresh input, tested at 1% and 25%. Anthropic prices cache reads at 0.1x input."}},tiers:{opus:["opus"],sonnet:["sonnet"],haiku:["haiku"],fallback:"sonnet"},comparisons:{ev_kwh_per_mile:{value:.3,unit:"kWh per mile",label:"MEASURED",source:"https://www.fueleconomy.gov/feg/evsbs.shtml",as_of:"2026-10-21",note:"Typical EV uses 25-35 kWh per 100 miles (EPA)."},fridge_kwh_per_day:{value:1.5,unit:"kWh per day",label:"MEASURED",source:"https://www.energystar.gov/products/refrigerators",as_of:"2026-10-21",note:"A typical US refrigerator uses about 400-600 kWh a year."},us_home_kwh_per_day:{value:29,unit:"kWh per day",label:"MEASURED",source:"https://www.eia.gov/tools/faqs/faq.php?id=97&t=3",as_of:"2026-10-21",note:"Average US household used about 10,800 kWh in a year (EIA)."},phone_charge_kwh:{value:.019,unit:"kWh per smartphone charge",label:"MEASURED",source:"https://www.epa.gov/energy/greenhouse-gas-equivalencies-calculator-calculations-and-references",as_of:"2026-10-21",note:"EPA\'s figure for fully charging one smartphone."}}};var b=Ce;var Q=e=>`${e.date}|${e.source}|${e.model}|${e.projectId??""}`;var Me=e=>e.output*(e.input+e.cacheWrite+e.cacheRead);function de(e){let t=new Map;for(let n of e){let o={date:n.timestamp.slice(0,10),source:n.source,model:n.model,projectId:n.project?.id??null,projectName:n.project?.name??null},r=Q(o),s=t.get(r);s||t.set(r,s={...o,responses:0,tokens:{input:0,cacheWrite:0,cacheRead:0,output:0},outputContext:0}),s.responses+=1;for(let a of E)s.tokens[a]+=n.tokens[a];s.outputContext+=Me(n.tokens)}return Je([...t.values()])}function Je(e){return e.sort((t,n)=>Q(t).localeCompare(Q(n)))}function X(e){return e.map(t=>({source:t.source,key:Q(t),timestamp:`${t.date}T00:00:00.000Z`,model:t.model,project:t.projectId?{id:t.projectId,name:t.projectName??"(unknown)"}:null,tokens:{...t.tokens}}))}var i=e=>document.getElementById(e),x=e=>e.replace(/[&<>"\']/g,t=>({"&":"&","<":"<",">":">",\'"\':""","\'":"'"})[t]);function Se(e,t){try{return JSON.parse(i(e).textContent??"")}catch{return t}}var C=Se("ccwatts-data",{mode:"playground",generatedAt:null,rows:[]}),Ae=Se("ccwatts-sample",[]),f={rows:C.mode==="dashboard"&&C.rows.length?C.rows:Ae,source:C.mode==="dashboard"&&C.rows.length?"history":"sample",window:"30d"},Ze={"7d":"last 7 days","30d":"last 30 days",all:"all time"};function Qe(e,t){if(t==="all"||e.length===0)return e;let n=t==="7d"?7:30,o=e.reduce((a,c)=>c.date>a?c.date:a,"0000-00-00"),r=f.source==="history"?Date.now():Date.parse(`${o}T00:00:00Z`)+864e5,s=new Date(r-n*864e5).toISOString().slice(0,10);return e.filter(a=>a.date>=s)}function Xe(e){let t=G(X(e),b);return t.responses=e.reduce((n,o)=>n+o.responses,0),t}function et(e,t){let n=M(e.total),[o,r]=n.central.split(" ");i("hero-eyebrow").textContent=`Claude Code electricity \\xB7 ${t}`,i("hero-big").innerHTML=e.responses?`${x(o??"")}<small>${x(r??"")}</small>`:"\\u2013",i("hero-range").textContent=e.responses?`Plausible range ${n.range} ${n.unit}, from the bounds on every assumption.`:"No usage in this window.",i("hero-compare").textContent=e.responses?`That\'s like ${U(e.total.central/1e3,b).replace(" \\xB7 ",", or ")}.`:"";let s=E.reduce((c,k)=>c+e.tokens[k],0),a=[`${L(e.responses)} responses`,`${L(s)} tokens`,...e.span?[`${e.span.from} to ${e.span.to}`]:[],`method v${b.version}`];i("hero-meta").innerHTML=a.map(c=>`<span>${x(c)}</span>`).join(" \\xB7 ");for(let c of["7d","30d","all"])i(`w-${c}`).setAttribute("aria-pressed",String(f.window===c))}function Ue(e){let t=i("chart"),n=Math.round(Math.min(720,Math.max(320,t.getBoundingClientRect().width||720))),o=240;t.setAttribute("viewBox",`0 0 ${n} ${o}`);let r={l:44,r:8,t:22,b:28},s=e.byDay;if(i("legend").innerHTML=["cacheRead","cacheWrite","output","input"].map(u=>`<span><i style="background:var(--t-${u})"></i>${W[u]}</span>`).join(""),!s.length){t.innerHTML=`<text x="${n/2}" y="${o/2}" text-anchor="middle">No usage in this window</text>`,i("chart-note").textContent="";return}let a=M(e.total),c=a.unit==="kWh"?1e3:1,k=Math.max(...s.map(u=>u.energy.central))/c,$=ot(k/3),j=Math.max($*Math.ceil(k/$),$),l=u=>r.t+(o-r.t-r.b)*(1-u/j),h=(n-r.l-r.r)/s.length,m=[];for(let u=0;u<=j+1e-9;u+=$)m.push(`<line x1="${r.l}" x2="${n-r.r}" y1="${l(u)}" y2="${l(u)}" stroke="var(--line)" stroke-width="1"/>`),m.push(`<text x="${r.l-6}" y="${l(u)+4}" text-anchor="end">${A(u)}</text>`);m.push(`<text x="0" y="10">${a.unit} per day</text>`),s.forEach((u,g)=>{let y=0,T=r.l+g*h+h*.12,B=Math.max(1,h*.76);for(let I of["cacheRead","cacheWrite","output","input"]){let P=tt(u,I);if(P<=0)continue;let D=y/c;y+=P;let F=y/c;m.push(`<rect x="${T.toFixed(1)}" y="${l(F).toFixed(1)}" width="${B.toFixed(1)}" height="${Math.max(.5,l(D)-l(F)).toFixed(1)}" fill="var(--t-${I})"><title>${u.key} ${W[I]} ${A(P/c)} ${a.unit}</title></rect>`)}});let p=s.length===1?[0]:[0,Math.floor((s.length-1)/2),s.length-1];for(let u of new Set(p)){let g=u===0?"start":u===s.length-1?"end":"middle",y=u===0?r.l:u===s.length-1?n-r.r:r.l+(u+.5)*h;m.push(`<text x="${y}" y="${o-8}" text-anchor="${g}">${s[u].key.slice(5)}</text>`)}t.innerHTML=m.join(""),i("chart-note").textContent=`Bars show the central estimate per day. The total for this window is ${a.central}, with a range of ${a.range} ${a.unit}.`}function tt(e,t){return ee.get(e.key)?.[t]??0}var ee=new Map;function nt(e){ee=new Map;for(let t of e){let n=G(X([t]),b),o=ee.get(t.date);o||ee.set(t.date,o={input:0,cacheWrite:0,cacheRead:0,output:0});for(let r of n.byType)o[r.key]+=r.energy.central}}function ot(e){if(e<=0)return 1;let t=10**Math.floor(Math.log10(e)),n=e/t;return(n<=1?1:n<=2?2:n<=5?5:10)*t}function Le(e,t,n,o){let r=Math.max(...t.map(a=>a.energy.central),1e-9),s=t.slice(0,6);i(e).innerHTML=s.length?s.map(a=>{let c=M(a.energy),k=o.total.central?Math.round(a.energy.central/o.total.central*100):0;return`<li><span class="name" title="${x(n(a.key))}">${x(n(a.key))}</span><span class="val">${x(c.central)} \\xB7 ${k}%</span><span class="bar"><span style="width:${(a.energy.central/r*100).toFixed(1)}%"></span></span><span class="val" style="grid-column:1/-1;text-align:left">range ${x(c.range)} ${x(c.unit)}</span></li>`}).join(""):\'<li><span class="name">No usage in this window</span></li>\'}function rt(e){let t=r=>e.total.central?Math.round((e.byType.find(s=>s.key===r)?.energy.central??0)/e.total.central*100):0,n=E.reduce((r,s)=>r+e.tokens[s],0),o=n?Math.round(e.tokens.cacheRead/n*100):0;i("explain").textContent=e.responses?`Each time Claude Code takes a step, it re-reads the conversation so far. Most of that comes from a cache, which is cheap per token but happens constantly. Here, cache reads were ${o}% of tokens and about ${t("cacheRead")}% of the electricity. Writing new context into the cache was ${t("cacheWrite")}%, and the text the model wrote back was ${t("output")}%. Long sessions grow the context the agent re-reads, so starting fresh sessions for new tasks is the simplest way to lower your number.`:"Load some usage to see what drives it."}var me="",je="",ge="";function st(e,t){let n={estimate:e,params:b,windowLabel:t,sample:f.source==="sample"};me=ce(n),je=J(n),ge=ie(n),i("card").src=`data:image/svg+xml;charset=utf-8,${encodeURIComponent(me)}`,i("post").textContent=ge,i("share-status").textContent=f.source==="sample"?"This card shows sample data. Load your own logs to make yours.":""}function at(e){try{return new URL(e).hostname.replace(/^www\\./,"")}catch{return"source"}}function it(){i("method-intro").textContent=`Energy = tokens \\xD7 energy per token, by model family and token type. Each parameter below has a low and high bound, and they combine into the range you see. The boundary is ${b.boundary}. Labels say how solid each number is: MODELED is derived from published figures, GUESS is a best estimate with wide bounds. Method v${b.version}, parameters as of ${b.as_of}, licensed ${b.license}.`;let e=Object.entries(b.params).map(([t,n])=>`<tr><td data-label="Parameter"><code>${x(t)}</code><div class="meta">${x(n.note)}</div></td><td class="num" data-label="Value">${n.value}</td><td class="num" data-label="Range">${n.low} \\u2013 ${n.high}</td><td data-label="Unit">${x(n.unit)}</td><td data-label="Label"><span class="label">${x(n.label)}</span></td><td data-label="Source"><a class="src" href="${x(n.source)}" rel="noopener noreferrer">${x(at(n.source))}</a></td></tr>`).join("");i("params").innerHTML=`<thead><tr><th>Parameter</th><th>Value</th><th>Range</th><th>Unit</th><th>Label</th><th>Source</th></tr></thead><tbody>${e}</tbody>`}var ct={sample:"ccwatts estimates the electricity behind Claude Code, an AI coding agent, from the token counts in its local logs. You\'re looking at sample data; try your own logs below.",files:"ccwatts estimates the electricity behind Claude Code, an AI coding agent, from the token counts in its local logs. You\'re looking at the files you loaded; nothing was uploaded.",history:"ccwatts estimates the electricity behind your Claude Code use from the token counts in its local logs, as a range with every assumption sourced."},fe=null;function te(){let e=Qe(f.rows,f.window);nt(e);let t=Xe(e);fe=t;let n=Ze[f.window],o=i("source-pill");o.textContent=f.source==="sample"?"Sample data":f.source==="history"?"Your history":"Your files",o.classList.toggle("sample",f.source==="sample"),i("lede").textContent=ct[f.source],et(t,n),Ue(t),Le("by-model",t.byModel,O,t),Le("by-project",t.byProject,se,t),rt(t),st(t,n)}async function Pe(e){let t=[...e].filter(r=>r.name.endsWith(".jsonl")),n=i("drop-status");if(!t.length){n.textContent="No .jsonl files found there. Claude Code keeps them in ~/.claude/projects.";return}let o=`${t.length} file${t.length===1?"":"s"}`;n.textContent=`Reading ${o} on this device\\u2026`,Ie(await Promise.all(t.map(r=>r.text())),o)}function Ie(e,t){let n=i("drop-status"),o=[];for(let s of e)o.push(...ue.parse(s));let r=de(pe(o));if(!r.length){n.textContent=`${t==="pasted text"?"That text":"Those files"} had no Claude Code usage in them.`;return}f.rows=r,f.source="files",f.window="all",n.textContent=`Read ${L(o.length)} responses from ${t}. Nothing was uploaded.`,te()}function We(e,t){let n=document.createElement("a");n.href=URL.createObjectURL(t),n.download=e,document.body.append(n),n.click(),n.remove(),setTimeout(()=>URL.revokeObjectURL(n.href),1e3)}async function he(e,t,n="share-status",o="post"){let r=i(n);try{await navigator.clipboard.writeText(e),r.textContent=`Copied the ${t}.`}catch{let s=document.createRange();s.selectNodeContents(i(o)),getSelection()?.removeAllRanges(),getSelection()?.addRange(s),r.textContent=`Couldn\'t reach the clipboard. The ${o==="post"?"post text":t} is selected so you can copy it.`}}function lt(){let e=new Image;e.onload=()=>{let t=document.createElement("canvas");t.width=1200,t.height=627,t.getContext("2d").drawImage(e,0,0,1200,627),t.toBlob(n=>n&&We(`${R}-receipt.png`,n),"image/png")},e.src=i("card").src}function ut(){for(let o of["7d","30d","all"])i(`w-${o}`).addEventListener("click",()=>{f.window=o,te()});if(i("save-png").addEventListener("click",lt),i("save-svg").addEventListener("click",()=>We(`${R}-receipt.svg`,new Blob([me],{type:"image/svg+xml"}))),i("copy-line").addEventListener("click",()=>{he(je,"share line")}),i("copy-post").addEventListener("click",()=>{he(ge,"LinkedIn post")}),C.mode==="playground"){let o=i("drop");o.hidden=!1,i("copy-prompt").addEventListener("click",()=>{he(i("export-prompt").textContent??"","prompt","drop-status","export-prompt")}),i("read-paste").addEventListener("click",()=>{Ie([i("paste-log").value],"pasted text")});for(let s of["pick-folder","pick-files"])i(s).addEventListener("change",a=>{let c=a.target.files;c&&Pe(c)});i("use-sample").addEventListener("click",()=>{f.rows=Ae,f.source="sample",f.window="30d",i("drop-status").textContent="",te()});let r=s=>a=>{a.preventDefault(),o.classList.toggle("over",s)};o.addEventListener("dragover",r(!0)),o.addEventListener("dragleave",r(!1)),o.addEventListener("drop",s=>{r(!1)(s),s.dataTransfer?.files&&Pe(s.dataTransfer.files)})}let e=0,t=i("chart").getBoundingClientRect().width;if(window.addEventListener("resize",()=>{clearTimeout(e),e=window.setTimeout(()=>{let o=i("chart").getBoundingClientRect().width;fe&&Math.round(o)!==Math.round(t)&&Ue(fe),t=o},150)}),i("repo-link").href=S,i("cli-cmd").textContent=`npx ${R}`,i("built-by").href=K,C.mode==="playground"){let o=i("back-link");o.href=K,o.hidden=!1}let n=C.generatedAt?` \\xB7 written ${C.generatedAt.slice(0,16).replace("T"," ")} UTC`:"";i("footer-version").textContent=`${R} v${ne} \\xB7 method v${b.version}${n}. Electricity only, not carbon or water.`,document.title=C.mode==="dashboard"?`${R} dashboard`:`${R} playground`}ut();it();te();})();</script>\n</body>\n</html>\n';
|
|
662
|
+
|
|
663
|
+
// src/store.ts
|
|
664
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
665
|
+
import { homedir } from "os";
|
|
666
|
+
import { join } from "path";
|
|
667
|
+
function dataDir() {
|
|
668
|
+
return process.env[`${APP_NAME.toUpperCase()}_HOME`] ?? join(homedir(), `.${APP_NAME}`);
|
|
669
|
+
}
|
|
670
|
+
var historyPath = (dir = dataDir()) => join(dir, "history.jsonl");
|
|
671
|
+
async function readHistory(dir = dataDir()) {
|
|
672
|
+
try {
|
|
673
|
+
return parseHistory(await readFile(historyPath(dir), "utf8"));
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if (error.code === "ENOENT") return [];
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async function writeHistory(rows, dir = dataDir()) {
|
|
680
|
+
await mkdir(dir, { recursive: true });
|
|
681
|
+
const tmp = `${historyPath(dir)}.tmp`;
|
|
682
|
+
await writeFile(tmp, serializeHistory(rows), { mode: 384 });
|
|
683
|
+
await rename(tmp, historyPath(dir));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// src/dashboard.ts
|
|
687
|
+
async function writeDashboard(rows, dir = dataDir()) {
|
|
688
|
+
await mkdir2(dir, { recursive: true });
|
|
689
|
+
const path = join2(dir, "dashboard.html");
|
|
690
|
+
const html = injectData(ccwatts_default, { mode: "dashboard", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), rows });
|
|
691
|
+
await writeFile2(path, html, { mode: 384 });
|
|
692
|
+
return path;
|
|
693
|
+
}
|
|
694
|
+
function openFile(path) {
|
|
695
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [path]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", path]] : ["xdg-open", [path]];
|
|
696
|
+
try {
|
|
697
|
+
spawn(cmd, args, { detached: true, stdio: "ignore" }).on("error", () => {
|
|
698
|
+
}).unref();
|
|
699
|
+
} catch {
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
var sampleRows = () => readSample(ccwatts_default);
|
|
703
|
+
|
|
704
|
+
// src/logs.ts
|
|
705
|
+
import { readdir, readFile as readFile2, stat } from "fs/promises";
|
|
706
|
+
import { homedir as homedir2 } from "os";
|
|
707
|
+
import { join as join3 } from "path";
|
|
708
|
+
function defaultLogRoots() {
|
|
709
|
+
const fromEnv = process.env.CLAUDE_CONFIG_DIR?.split(",").map((dir) => join3(dir.trim(), "projects"));
|
|
710
|
+
if (fromEnv?.length) return fromEnv;
|
|
711
|
+
return [join3(homedir2(), ".claude", "projects"), join3(homedir2(), ".config", "claude", "projects")];
|
|
712
|
+
}
|
|
713
|
+
async function findJsonl(dir) {
|
|
714
|
+
if (dir.endsWith(".jsonl") && (await stat(dir).catch(() => null))?.isFile()) return [dir];
|
|
715
|
+
let entries;
|
|
716
|
+
try {
|
|
717
|
+
entries = await readdir(dir, { withFileTypes: true, recursive: true });
|
|
718
|
+
} catch {
|
|
719
|
+
return [];
|
|
720
|
+
}
|
|
721
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join3(e.parentPath, e.name));
|
|
722
|
+
}
|
|
723
|
+
async function loadLogs(roots) {
|
|
724
|
+
const files = (await Promise.all(roots.map(findJsonl))).flat();
|
|
725
|
+
const parsed = await Promise.all(files.map(async (f) => claudeCodeAdapter.parse(await readFile2(f, "utf8"))));
|
|
726
|
+
return { roots, files: files.length, records: dedupe(parsed.flat()) };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// src/render.ts
|
|
730
|
+
var energy = formatEnergy;
|
|
731
|
+
var totalTokens2 = (t) => TOKEN_TYPES.reduce((s, k) => s + t[k], 0);
|
|
732
|
+
function shares(rows, total, label, max = 4) {
|
|
733
|
+
const pct = (r) => total > 0 ? Math.round(r.energy.central / total * 100) : 0;
|
|
734
|
+
const shown = rows.slice(0, max).map((r) => `${label(r.key)} ${pct(r)}%`);
|
|
735
|
+
const rest = rows.slice(max).reduce((s, r) => s + r.energy.central, 0);
|
|
736
|
+
if (rest > 0) shown.push(`other ${Math.round(rest / total * 100)}%`);
|
|
737
|
+
return shown.join(" ");
|
|
738
|
+
}
|
|
739
|
+
function header(params, window) {
|
|
740
|
+
return `${APP_NAME} v${APP_VERSION} \xB7 method v${params.version} \xB7 ${window.label}`;
|
|
741
|
+
}
|
|
742
|
+
function summary(e, params, window, sample = false) {
|
|
743
|
+
if (e.responses === 0) return `${header(params, window)}
|
|
744
|
+
|
|
745
|
+
No Claude Code usage found in this window. Try --since all.`;
|
|
746
|
+
const total = energy(e.total);
|
|
747
|
+
const kwh = e.total.central / 1e3;
|
|
748
|
+
const compare = everyday(kwh, params);
|
|
749
|
+
const top = e.byProject[0];
|
|
750
|
+
const lines = [
|
|
751
|
+
header(params, window),
|
|
752
|
+
"",
|
|
753
|
+
` Electricity ${total.central} (range ${total.range})`,
|
|
754
|
+
` \u2248 ${compare}`,
|
|
755
|
+
"",
|
|
756
|
+
` By model ${shares(e.byModel, e.total.central, shortModel, 3)}`,
|
|
757
|
+
` By type ${shares(e.byType, e.total.central, (k) => TYPE_LABELS[k])}`
|
|
758
|
+
];
|
|
759
|
+
if (top) {
|
|
760
|
+
const t = energy(top.energy);
|
|
761
|
+
lines.push(` Top project ${projectName(top.key)} ${t.central} (range ${t.range})`);
|
|
762
|
+
}
|
|
763
|
+
const span = e.span ? ` \xB7 ${e.span.from} to ${e.span.to}` : "";
|
|
764
|
+
lines.push(` Based on ${int(e.responses)} responses, ${int(totalTokens2(e.tokens))} tokens${span}`);
|
|
765
|
+
for (const w of e.warnings) lines.push(` Note ${w}`);
|
|
766
|
+
const next = sample ? `Your own usage: ${APP_NAME}` : `Share: ${APP_NAME} receipt`;
|
|
767
|
+
lines.push("", ` How this is estimated: ${APP_NAME} --method \xB7 ${next}`);
|
|
768
|
+
return lines.join("\n");
|
|
769
|
+
}
|
|
770
|
+
function table(head, rows, alignLeft = 1) {
|
|
771
|
+
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
772
|
+
const line = (cells) => cells.map((c, i) => i < alignLeft ? c.padEnd(widths[i]) : c.padStart(widths[i])).join(" ").trimEnd();
|
|
773
|
+
return [line(head), widths.map((w) => "-".repeat(w)).join(" "), ...rows.map(line)].join("\n");
|
|
774
|
+
}
|
|
775
|
+
var DIM_ROWS = { model: "byModel", type: "byType", day: "byDay", project: "byProject" };
|
|
776
|
+
function breakdown(e, params, window, dim) {
|
|
777
|
+
const rows = e[DIM_ROWS[dim]];
|
|
778
|
+
const unit = energy(e.total).unit;
|
|
779
|
+
const scale = unit === "kWh" ? 1e3 : 1;
|
|
780
|
+
const label = (key) => dim === "type" ? TYPE_LABELS[key] : dim === "project" ? projectName(key) : dim === "model" ? shortModel(key) : key;
|
|
781
|
+
const body = rows.map((r) => [
|
|
782
|
+
label(r.key),
|
|
783
|
+
int(totalTokens2(r.tokens)),
|
|
784
|
+
num(r.energy.central / scale),
|
|
785
|
+
`${num(r.energy.low / scale)} \u2013 ${num(r.energy.high / scale)}`,
|
|
786
|
+
e.total.central > 0 ? `${Math.round(r.energy.central / e.total.central * 100)}%` : "0%"
|
|
787
|
+
]);
|
|
788
|
+
const t = energy(e.total);
|
|
789
|
+
body.push(["total", int(totalTokens2(e.tokens)), num(e.total.central / scale), t.range, "100%"]);
|
|
790
|
+
return [header(params, window), "", table([dim, "tokens", unit, "range", "share"], body)].join("\n");
|
|
791
|
+
}
|
|
792
|
+
function method(params) {
|
|
793
|
+
const lines = [
|
|
794
|
+
`${APP_NAME} method v${params.version} \xB7 parameters as of ${params.as_of} \xB7 ${params.license}`,
|
|
795
|
+
"",
|
|
796
|
+
`Boundary: ${params.boundary}.`,
|
|
797
|
+
`Ranges: ${params.interval}; spreads combine root-sum-of-squares on a log scale.`,
|
|
798
|
+
""
|
|
799
|
+
];
|
|
800
|
+
const rows = Object.entries(params.params).map(([id, p]) => [id, `${p.value}`, `${p.low} \u2013 ${p.high}`, p.unit, p.label]);
|
|
801
|
+
lines.push(table(["parameter", "value", "range", "unit", "label"], rows, 1));
|
|
802
|
+
lines.push("", "Sources:");
|
|
803
|
+
for (const [id, p] of Object.entries(params.params)) lines.push(` ${id}: ${p.source}`);
|
|
804
|
+
lines.push("", "Comparisons (central value only):");
|
|
805
|
+
for (const [id, c] of Object.entries(params.comparisons)) lines.push(` ${id}: ${c.value} ${c.unit} \xB7 ${c.source}`);
|
|
806
|
+
lines.push("", "Full write-up: METHODOLOGY.md");
|
|
807
|
+
return lines.join("\n");
|
|
808
|
+
}
|
|
809
|
+
function json(e, params, window, sample = false) {
|
|
810
|
+
const rows = (list, dim) => list.map((r) => ({
|
|
811
|
+
key: dim === "project" ? projectName(r.key) : r.key,
|
|
812
|
+
...dim === "project" ? { projectId: r.key.split("#")[1] ?? null } : {},
|
|
813
|
+
wh: r.energy,
|
|
814
|
+
tokens: r.tokens
|
|
815
|
+
}));
|
|
816
|
+
return JSON.stringify(
|
|
817
|
+
{
|
|
818
|
+
app: APP_NAME,
|
|
819
|
+
version: APP_VERSION,
|
|
820
|
+
methodVersion: params.version,
|
|
821
|
+
unit: "Wh",
|
|
822
|
+
...sample ? { sample: true } : {},
|
|
823
|
+
window,
|
|
824
|
+
span: e.span,
|
|
825
|
+
responses: e.responses,
|
|
826
|
+
tokens: e.tokens,
|
|
827
|
+
wh: e.total,
|
|
828
|
+
comparisons: comparisons(e.total.central / 1e3, params).map(({ id, amount }) => ({ id, amount })),
|
|
829
|
+
byModel: rows(e.byModel, "model"),
|
|
830
|
+
byType: rows(e.byType, "type"),
|
|
831
|
+
byDay: rows(e.byDay, "day"),
|
|
832
|
+
byProject: rows(e.byProject, "project"),
|
|
833
|
+
warnings: e.warnings
|
|
834
|
+
},
|
|
835
|
+
null,
|
|
836
|
+
2
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
var BARS = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
|
|
840
|
+
function sparkline(values) {
|
|
841
|
+
const max = Math.max(...values, 0);
|
|
842
|
+
return values.map((v) => max === 0 ? BARS[0] : BARS[Math.min(7, Math.round(v / max * 7))]).join("");
|
|
843
|
+
}
|
|
844
|
+
function history(e, params, window, storedDays) {
|
|
845
|
+
if (e.responses === 0) return `${header(params, window)}
|
|
846
|
+
|
|
847
|
+
No history yet. Run ${APP_NAME} once to start recording.`;
|
|
848
|
+
const weeks = weekly(e.byDay);
|
|
849
|
+
const unit = energy(e.total).unit;
|
|
850
|
+
const scale = unit === "kWh" ? 1e3 : 1;
|
|
851
|
+
const rows = weeks.map((w) => [
|
|
852
|
+
`week of ${w.start}`,
|
|
853
|
+
num(w.central / scale),
|
|
854
|
+
`${num(w.low / scale)} \u2013 ${num(w.high / scale)}`,
|
|
855
|
+
"\u2588".repeat(Math.round(w.central / Math.max(...weeks.map((x) => x.central), 1e-9) * 20))
|
|
856
|
+
]);
|
|
857
|
+
const active = weeks.filter((w) => w.central > 0);
|
|
858
|
+
const best = active.reduce((a, b) => b.central < a.central ? b : a, active[0]);
|
|
859
|
+
const worst = active.reduce((a, b) => b.central > a.central ? b : a, active[0]);
|
|
860
|
+
const total = energy(e.total);
|
|
861
|
+
const lines = [
|
|
862
|
+
header(params, window),
|
|
863
|
+
"",
|
|
864
|
+
table(["week", unit, "range", ""], rows),
|
|
865
|
+
"",
|
|
866
|
+
` Trend ${sparkline(weeks.map((w) => w.central))}`,
|
|
867
|
+
` Total ${total.central} (range ${total.range})`,
|
|
868
|
+
` Lightest week ${best.start} ${energy(best).central} (range ${energy(best).range})`,
|
|
869
|
+
` Heaviest week ${worst.start} ${energy(worst).central} (range ${energy(worst).range})`,
|
|
870
|
+
` Recorded ${storedDays} days, kept in your local history even after Claude Code deletes old logs`,
|
|
871
|
+
"",
|
|
872
|
+
` Energy is recalculated from stored token counts with method v${params.version}.`
|
|
873
|
+
];
|
|
874
|
+
return lines.join("\n");
|
|
875
|
+
}
|
|
876
|
+
var csvCell = (v) => typeof v === "number" ? String(v) : /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
|
877
|
+
function historyCsv(rows, params) {
|
|
878
|
+
const head = ["date", "model", "project", "responses", "input", "cache_write", "cache_read", "output", "wh_low", "wh_central", "wh_high", "method_version"];
|
|
879
|
+
const round = (n) => Math.round(n * 1e3) / 1e3;
|
|
880
|
+
const body = rows.map((r) => {
|
|
881
|
+
const wh = energyForCounts(r.tokens, tierFor(r.model, params).tier, params);
|
|
882
|
+
return [r.date, r.model, r.projectName ?? "", r.responses, r.tokens.input, r.tokens.cacheWrite, r.tokens.cacheRead, r.tokens.output, round(wh.low), round(wh.central), round(wh.high), params.version];
|
|
883
|
+
});
|
|
884
|
+
return [head, ...body].map((cells) => cells.map(csvCell).join(",")).join("\n");
|
|
885
|
+
}
|
|
886
|
+
function statusLine(e, params) {
|
|
887
|
+
if (e.responses === 0) return `\u26A1 ${APP_NAME}: no usage yet this session`;
|
|
888
|
+
const t = formatEnergy(e.total);
|
|
889
|
+
return `\u26A1 ~${t.central} this session (range ${t.range}) \xB7 method v${params.version}`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// src/index.ts
|
|
893
|
+
var HELP = `Usage: ${APP_NAME} [command] [options]
|
|
894
|
+
|
|
895
|
+
Estimates the electricity your Claude Code usage consumed, as a range.
|
|
896
|
+
Reads local logs only. Nothing leaves your machine.
|
|
897
|
+
|
|
898
|
+
Commands:
|
|
899
|
+
(none) Summary for the time window
|
|
900
|
+
history Weekly trend, all-time total, best and worst weeks
|
|
901
|
+
dashboard Write an offline HTML dashboard of your history and open it
|
|
902
|
+
receipt Share line, LinkedIn post text and a 1200\xD7627 card (SVG)
|
|
903
|
+
statusline One line for the Claude Code status line (reads its JSON on stdin)
|
|
904
|
+
|
|
905
|
+
Options:
|
|
906
|
+
--since <when> 7d, 12h, 4w, 2026-10-01 or all (default 30d; history defaults to all)
|
|
907
|
+
--by <dim> Breakdown table: model, day, project or type
|
|
908
|
+
--json Machine-readable output, same numbers, in Wh
|
|
909
|
+
--method Parameters, sources and method version
|
|
910
|
+
--export csv With history: daily rows as CSV on stdout
|
|
911
|
+
--out <file> With receipt: where to write the card (default ./${APP_NAME}-receipt.svg)
|
|
912
|
+
--demo Try it on synthetic sample data (no logs needed)
|
|
913
|
+
--path <dir> Estimate just this folder or .jsonl file (history is left alone)
|
|
914
|
+
--no-open With dashboard: write the file but don't open it
|
|
915
|
+
--no-history Don't read or update ~/.${APP_NAME}/history.jsonl
|
|
916
|
+
-v, --version Show the version
|
|
917
|
+
-h, --help Show this help
|
|
918
|
+
|
|
919
|
+
Logs somewhere else? Set CLAUDE_CONFIG_DIR, as Claude Code does, to keep your history.`;
|
|
920
|
+
var DIMS = ["model", "day", "project", "type"];
|
|
921
|
+
var COMMANDS = ["summary", "history", "receipt", "dashboard", "statusline"];
|
|
922
|
+
function plainArgError(error) {
|
|
923
|
+
const code = error.code;
|
|
924
|
+
const message = error instanceof Error ? error.message : "";
|
|
925
|
+
const option = message.match(/'(-{1,2}[\w-]+)/)?.[1];
|
|
926
|
+
const help = `Run ${APP_NAME} --help to see the options.`;
|
|
927
|
+
if (code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") return new Error(`Unknown option "${option}". ${help}`);
|
|
928
|
+
if (code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE") return new Error(`${option} needs a value. ${help}`);
|
|
929
|
+
return void 0;
|
|
930
|
+
}
|
|
931
|
+
async function main() {
|
|
932
|
+
const { values, positionals } = parseArgsPlain({
|
|
933
|
+
allowPositionals: true,
|
|
934
|
+
options: {
|
|
935
|
+
since: { type: "string" },
|
|
936
|
+
by: { type: "string" },
|
|
937
|
+
json: { type: "boolean" },
|
|
938
|
+
method: { type: "boolean" },
|
|
939
|
+
export: { type: "string" },
|
|
940
|
+
out: { type: "string" },
|
|
941
|
+
path: { type: "string" },
|
|
942
|
+
demo: { type: "boolean" },
|
|
943
|
+
"no-history": { type: "boolean" },
|
|
944
|
+
"no-open": { type: "boolean" },
|
|
945
|
+
version: { type: "boolean", short: "v" },
|
|
946
|
+
help: { type: "boolean", short: "h" }
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
if (values.help) return void console.log(HELP);
|
|
950
|
+
if (values.version) return void console.log(`${APP_NAME} ${APP_VERSION}`);
|
|
951
|
+
const params = defaultParams;
|
|
952
|
+
if (values.method) return void console.log(method(params));
|
|
953
|
+
const command = positionals[0] ?? "summary";
|
|
954
|
+
if (!COMMANDS.includes(command)) {
|
|
955
|
+
console.error(`Unknown command "${command}".
|
|
956
|
+
|
|
957
|
+
${HELP}`);
|
|
958
|
+
process.exitCode = 1;
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (command === "statusline") return void console.log(await sessionLine(values.path));
|
|
962
|
+
const by = values.by;
|
|
963
|
+
if (by && !DIMS.includes(by)) throw new Error(`--by must be one of: ${DIMS.join(", ")}`);
|
|
964
|
+
if (values.export && values.export !== "csv") throw new Error(`--export supports csv only`);
|
|
965
|
+
const demo = Boolean(values.demo);
|
|
966
|
+
if (demo && (command === "receipt" || command === "dashboard")) {
|
|
967
|
+
throw new Error(
|
|
968
|
+
`${command} is for your own usage, so it doesn't run on sample data. The playground shows the sample ${command === "receipt" ? "card" : "dashboard"}: ${PLAYGROUND_URL}`
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
const logs = demo ? { roots: [], files: 0, records: [] } : await loadLogs(values.path ? [values.path] : defaultLogRoots());
|
|
972
|
+
const sample = demo ? sampleRows() : [];
|
|
973
|
+
const now = demo ? /* @__PURE__ */ new Date(`${sample.map((r) => r.date).sort().at(-1)}T23:59:59Z`) : /* @__PURE__ */ new Date();
|
|
974
|
+
const window = parseSince(values.since ?? (command === "history" || demo ? "all" : "30d"), now);
|
|
975
|
+
let rows = demo ? sample : rollup(logs.records);
|
|
976
|
+
if (!values["no-history"] && !values.path && !demo) {
|
|
977
|
+
const stored = await readHistory().catch((error) => {
|
|
978
|
+
console.error(`Couldn't read history (${error instanceof Error ? error.message : String(error)}). Using current logs only.`);
|
|
979
|
+
return [];
|
|
980
|
+
});
|
|
981
|
+
rows = mergeHistory(stored, rows);
|
|
982
|
+
if (logs.records.length > 0) {
|
|
983
|
+
await writeHistory(rows).catch(
|
|
984
|
+
(error) => console.error(`Couldn't save history (${error instanceof Error ? error.message : String(error)}). Continuing without it.`)
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if (rows.length === 0) {
|
|
989
|
+
console.log(`No Claude Code logs found. Looked in:
|
|
990
|
+
${logs.roots.map((r) => ` ${r}`).join("\n")}`);
|
|
991
|
+
console.log(`Point ${APP_NAME} at another folder with --path <dir>.`);
|
|
992
|
+
console.log(`No Claude Code yet? See it on sample data with ${APP_NAME} --demo, or in your browser: ${PLAYGROUND_URL}`);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (command === "dashboard") {
|
|
996
|
+
const path = await writeDashboard(rows);
|
|
997
|
+
console.log(`Dashboard written to ${path}. It works offline and makes no network requests.`);
|
|
998
|
+
if (!values["no-open"]) openFile(path);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
const inWindow = window.start ? rows.filter((r) => r.date >= window.start.slice(0, 10)) : rows;
|
|
1002
|
+
const records = window.start && window.label.includes("hour") ? since(logs.records, window.start) : rollupRecords(inWindow);
|
|
1003
|
+
const e = estimate(records, params);
|
|
1004
|
+
if (!(window.start && window.label.includes("hour"))) {
|
|
1005
|
+
e.responses = inWindow.reduce((n, r) => n + r.responses, 0);
|
|
1006
|
+
}
|
|
1007
|
+
const label = demo && !values.json && !values.export ? "Sample data (synthetic), not your usage.\n\n" : "";
|
|
1008
|
+
if (command === "history") {
|
|
1009
|
+
if (values.export) return void console.log(historyCsv(inWindow, params));
|
|
1010
|
+
if (values.json) return void console.log(json(e, params, window, demo));
|
|
1011
|
+
if (label) process.stdout.write(label);
|
|
1012
|
+
return void console.log(history(e, params, window, new Set(rows.map((r) => r.date)).size));
|
|
1013
|
+
}
|
|
1014
|
+
if (command === "receipt") {
|
|
1015
|
+
const input = { estimate: e, params, windowLabel: window.label };
|
|
1016
|
+
const out = resolve(values.out ?? `${APP_NAME}-receipt.svg`);
|
|
1017
|
+
await writeFile3(out, receiptSvg(input));
|
|
1018
|
+
console.log(shareLine(input));
|
|
1019
|
+
console.log(`
|
|
1020
|
+
Card written to ${out} (1200\xD7627, LinkedIn size). Open ${APP_NAME} dashboard to save it as PNG.`);
|
|
1021
|
+
console.log(`
|
|
1022
|
+
Ready-to-paste post:
|
|
1023
|
+
|
|
1024
|
+
${linkedInPost(input)}`);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (values.json) return void console.log(json(e, params, window, demo));
|
|
1028
|
+
if (label) process.stdout.write(label);
|
|
1029
|
+
if (by) console.log(breakdown(e, params, window, by));
|
|
1030
|
+
else console.log(summary(e, params, window, demo));
|
|
1031
|
+
}
|
|
1032
|
+
function parseArgsPlain(config) {
|
|
1033
|
+
try {
|
|
1034
|
+
return parseArgs(config);
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
throw plainArgError(error) ?? error;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
async function sessionLine(path) {
|
|
1040
|
+
try {
|
|
1041
|
+
const file = path ?? JSON.parse(await readStdin()).transcript_path;
|
|
1042
|
+
if (!file) return `\u26A1 ${APP_NAME}: waiting for the first response`;
|
|
1043
|
+
const records = dedupe(claudeCodeAdapter.parse(await readFile3(file, "utf8")));
|
|
1044
|
+
return statusLine(estimate(records, defaultParams), defaultParams);
|
|
1045
|
+
} catch {
|
|
1046
|
+
return `\u26A1 ${APP_NAME}: no estimate yet`;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
async function readStdin() {
|
|
1050
|
+
if (process.stdin.isTTY) return "{}";
|
|
1051
|
+
let text = "";
|
|
1052
|
+
for await (const chunk of process.stdin) text += chunk;
|
|
1053
|
+
return text || "{}";
|
|
1054
|
+
}
|
|
1055
|
+
main().catch((error) => {
|
|
1056
|
+
console.error(error instanceof Error ? error.message : error);
|
|
1057
|
+
process.exitCode = 1;
|
|
1058
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccwatts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Estimate the electricity your Claude Code usage consumes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ccwatts": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup",
|
|
16
|
+
"prepack": "cp ../../LICENSE LICENSE"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@ccwatts/core": "0.0.0"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/holroydchase-afk/ccwatts.git",
|
|
25
|
+
"directory": "packages/cli"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://chaseholroyd.com/ccwatts/",
|
|
34
|
+
"keywords": [
|
|
35
|
+
"claude-code",
|
|
36
|
+
"energy",
|
|
37
|
+
"electricity",
|
|
38
|
+
"kwh",
|
|
39
|
+
"carbon",
|
|
40
|
+
"cli"
|
|
41
|
+
]
|
|
42
|
+
}
|