ccwatts 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +21 -2
- package/dist/index.js +981 -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,22 @@
|
|
|
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
|
+
```
|
|
13
|
+
|
|
14
|
+
- **Local only.** No network calls, no accounts, no telemetry. It reads token counts, never prompt or response text.
|
|
15
|
+
- **Ranges, not false precision.** Every number shows a low–high range and the method version.
|
|
16
|
+
- **No runtime dependencies.** Node 20+.
|
|
17
|
+
|
|
18
|
+
Try it in your browser first, on sample data: https://chaseholroyd.com/ccwatts/
|
|
19
|
+
|
|
20
|
+
Methodology, source and screenshots: https://github.com/holroydchase-afk/ccwatts
|
|
21
|
+
|
|
22
|
+
MIT license. The methodology and parameters are CC BY 4.0.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { 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.0";
|
|
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
|
+
function formatEnergy(range) {
|
|
29
|
+
const kwh = range.high >= 1e3;
|
|
30
|
+
const scale = kwh ? 1e3 : 1;
|
|
31
|
+
const unit = kwh ? "kWh" : "Wh";
|
|
32
|
+
return {
|
|
33
|
+
central: `${num(range.central / scale)} ${unit}`,
|
|
34
|
+
range: `${num(range.low / scale)} \u2013 ${num(range.high / scale)}`,
|
|
35
|
+
unit
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
var shortModel = (model) => model.replace(/^claude-/, "");
|
|
39
|
+
|
|
40
|
+
// ../core/src/types.ts
|
|
41
|
+
var TOKEN_TYPES = ["input", "cacheWrite", "cacheRead", "output"];
|
|
42
|
+
|
|
43
|
+
// ../core/src/energy.ts
|
|
44
|
+
var zeroRange = () => ({ low: 0, central: 0, high: 0 });
|
|
45
|
+
function addRange(into, from) {
|
|
46
|
+
into.low += from.low;
|
|
47
|
+
into.central += from.central;
|
|
48
|
+
into.high += from.high;
|
|
49
|
+
return into;
|
|
50
|
+
}
|
|
51
|
+
function tierFor(model, params) {
|
|
52
|
+
const id = model.toLowerCase();
|
|
53
|
+
for (const [tier, patterns] of Object.entries(params.tiers)) {
|
|
54
|
+
if (Array.isArray(patterns) && patterns.some((p) => id.includes(p))) return { tier, known: true };
|
|
55
|
+
}
|
|
56
|
+
return { tier: String(params.tiers.fallback), known: false };
|
|
57
|
+
}
|
|
58
|
+
function param(params, id) {
|
|
59
|
+
const p = params.params[id];
|
|
60
|
+
if (!p) throw new Error(`Missing parameter "${id}" in params.json`);
|
|
61
|
+
return p;
|
|
62
|
+
}
|
|
63
|
+
function chain(tier, type) {
|
|
64
|
+
const output = `output_wh_per_1k.${tier}`;
|
|
65
|
+
switch (type) {
|
|
66
|
+
case "output":
|
|
67
|
+
return [output];
|
|
68
|
+
case "input":
|
|
69
|
+
return [output, "input_vs_output"];
|
|
70
|
+
case "cacheWrite":
|
|
71
|
+
return [output, "input_vs_output", "cache_write_vs_input"];
|
|
72
|
+
case "cacheRead":
|
|
73
|
+
return [output, "input_vs_output", "cache_read_vs_input"];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function factor(params, tier, type) {
|
|
77
|
+
let wh = 1 / 1e3;
|
|
78
|
+
let up2 = 0;
|
|
79
|
+
let down2 = 0;
|
|
80
|
+
for (const id of chain(tier, type)) {
|
|
81
|
+
const p = param(params, id);
|
|
82
|
+
wh *= p.value;
|
|
83
|
+
up2 += Math.log(p.high / p.value) ** 2;
|
|
84
|
+
down2 += Math.log(p.value / p.low) ** 2;
|
|
85
|
+
}
|
|
86
|
+
return { whPerToken: wh, up: Math.sqrt(up2), down: Math.sqrt(down2) };
|
|
87
|
+
}
|
|
88
|
+
var empty = () => ({ input: 0, cacheWrite: 0, cacheRead: 0, output: 0 });
|
|
89
|
+
function energyForCounts(counts, tier, params) {
|
|
90
|
+
const total = zeroRange();
|
|
91
|
+
for (const type of TOKEN_TYPES) addRange(total, energyForTokens(counts[type], tier, type, params));
|
|
92
|
+
return total;
|
|
93
|
+
}
|
|
94
|
+
var factorCache = /* @__PURE__ */ new WeakMap();
|
|
95
|
+
function cachedFactor(params, tier, type) {
|
|
96
|
+
let byKey = factorCache.get(params);
|
|
97
|
+
if (!byKey) factorCache.set(params, byKey = /* @__PURE__ */ new Map());
|
|
98
|
+
const key = `${tier}:${type}`;
|
|
99
|
+
let f = byKey.get(key);
|
|
100
|
+
if (!f) byKey.set(key, f = factor(params, tier, type));
|
|
101
|
+
return f;
|
|
102
|
+
}
|
|
103
|
+
function energyForTokens(tokens, tier, type, params) {
|
|
104
|
+
if (tokens <= 0) return zeroRange();
|
|
105
|
+
const f = cachedFactor(params, tier, type);
|
|
106
|
+
const central = tokens * f.whPerToken;
|
|
107
|
+
return { low: central / Math.exp(f.down), central, high: central * Math.exp(f.up) };
|
|
108
|
+
}
|
|
109
|
+
var day = (timestamp) => timestamp.slice(0, 10);
|
|
110
|
+
function estimate(records, params) {
|
|
111
|
+
const total = zeroRange();
|
|
112
|
+
const tokens = empty();
|
|
113
|
+
const groups = {
|
|
114
|
+
model: /* @__PURE__ */ new Map(),
|
|
115
|
+
type: /* @__PURE__ */ new Map(),
|
|
116
|
+
day: /* @__PURE__ */ new Map(),
|
|
117
|
+
project: /* @__PURE__ */ new Map()
|
|
118
|
+
};
|
|
119
|
+
const unknownModels = /* @__PURE__ */ new Set();
|
|
120
|
+
let from = null;
|
|
121
|
+
let to = null;
|
|
122
|
+
const bump = (dim, key, energy2, type, count) => {
|
|
123
|
+
let row2 = groups[dim].get(key);
|
|
124
|
+
if (!row2) groups[dim].set(key, row2 = { key, energy: zeroRange(), tokens: empty() });
|
|
125
|
+
addRange(row2.energy, energy2);
|
|
126
|
+
row2.tokens[type] += count;
|
|
127
|
+
};
|
|
128
|
+
for (const record of records) {
|
|
129
|
+
const { tier, known } = tierFor(record.model, params);
|
|
130
|
+
if (!known) unknownModels.add(record.model);
|
|
131
|
+
const d = day(record.timestamp);
|
|
132
|
+
if (from === null || d < from) from = d;
|
|
133
|
+
if (to === null || d > to) to = d;
|
|
134
|
+
const project = record.project?.name ?? "(unknown)";
|
|
135
|
+
for (const type of TOKEN_TYPES) {
|
|
136
|
+
const count = record.tokens[type];
|
|
137
|
+
if (count <= 0) continue;
|
|
138
|
+
const energy2 = energyForTokens(count, tier, type, params);
|
|
139
|
+
addRange(total, energy2);
|
|
140
|
+
tokens[type] += count;
|
|
141
|
+
bump("model", record.model, energy2, type, count);
|
|
142
|
+
bump("type", type, energy2, type, count);
|
|
143
|
+
bump("day", d, energy2, type, count);
|
|
144
|
+
bump("project", record.project ? `${project}#${record.project.id}` : project, energy2, type, count);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const byEnergy = (rows) => [...rows.values()].sort((a, b) => b.energy.central - a.energy.central);
|
|
148
|
+
const warnings = [...unknownModels].map(
|
|
149
|
+
(m) => `Unknown model "${m}": estimated as ${String(params.tiers.fallback)}.`
|
|
150
|
+
);
|
|
151
|
+
return {
|
|
152
|
+
methodVersion: params.version,
|
|
153
|
+
total,
|
|
154
|
+
tokens,
|
|
155
|
+
responses: records.length,
|
|
156
|
+
byModel: byEnergy(groups.model),
|
|
157
|
+
byType: byEnergy(groups.type),
|
|
158
|
+
byDay: [...groups.day.values()].sort((a, b) => a.key.localeCompare(b.key)),
|
|
159
|
+
byProject: byEnergy(groups.project),
|
|
160
|
+
span: from && to ? { from, to } : null,
|
|
161
|
+
warnings
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
var projectName = (key) => key.split("#")[0] ?? key;
|
|
165
|
+
function since(records, isoStart) {
|
|
166
|
+
return records.filter((r) => r.timestamp >= isoStart);
|
|
167
|
+
}
|
|
168
|
+
function comparisons(kwh, params) {
|
|
169
|
+
const c = params.comparisons;
|
|
170
|
+
const out = [];
|
|
171
|
+
const round = (n) => n >= 10 ? Math.round(n) : Math.round(n * 10) / 10;
|
|
172
|
+
if (c.ev_kwh_per_mile) {
|
|
173
|
+
const miles = kwh / c.ev_kwh_per_mile.value;
|
|
174
|
+
out.push({ id: "ev", amount: miles, text: `driving an EV about ${round(miles)} miles` });
|
|
175
|
+
}
|
|
176
|
+
if (c.fridge_kwh_per_day) {
|
|
177
|
+
const days = kwh / c.fridge_kwh_per_day.value;
|
|
178
|
+
out.push({ id: "fridge", amount: days, text: `running a fridge about ${round(days)} days` });
|
|
179
|
+
}
|
|
180
|
+
if (c.phone_charge_kwh) {
|
|
181
|
+
const charges = kwh / c.phone_charge_kwh.value;
|
|
182
|
+
out.push({ id: "phone", amount: charges, text: `charging a phone about ${round(charges)} times` });
|
|
183
|
+
}
|
|
184
|
+
if (c.us_home_kwh_per_day) {
|
|
185
|
+
const days = kwh / c.us_home_kwh_per_day.value;
|
|
186
|
+
out.push({ id: "home", amount: days, text: `powering a US home about ${round(days)} days` });
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ../core/src/receipt.ts
|
|
192
|
+
function everyday(kwh, params) {
|
|
193
|
+
const all = comparisons(kwh, params);
|
|
194
|
+
const big = all.filter((c) => c.amount >= 1).slice(0, 2);
|
|
195
|
+
const fallback = [...all].sort((a, b) => b.amount - a.amount).slice(0, 1);
|
|
196
|
+
return (big.length ? big : fallback).map((c) => c.text).join(" \xB7 ");
|
|
197
|
+
}
|
|
198
|
+
var repoShort = REPO_URL.replace(/^https:\/\//, "");
|
|
199
|
+
function whose(windowLabel) {
|
|
200
|
+
if (windowLabel === "all time") return "All my Claude Code use so far";
|
|
201
|
+
if (windowLabel.startsWith("since")) return `My Claude Code use ${windowLabel}`;
|
|
202
|
+
return `My ${windowLabel} of Claude Code`;
|
|
203
|
+
}
|
|
204
|
+
function sampleWhose(windowLabel) {
|
|
205
|
+
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`;
|
|
206
|
+
}
|
|
207
|
+
function shareLine({ estimate: e, params, windowLabel, sample }) {
|
|
208
|
+
const t = formatEnergy(e.total);
|
|
209
|
+
const first = everyday(e.total.central / 1e3, params).split(" \xB7 ")[0];
|
|
210
|
+
return `${sample ? sampleWhose(windowLabel) : whose(windowLabel)} used about ${t.central} of electricity (range ${t.range} ${t.unit}), roughly ${first}. Estimated locally with ${APP_NAME}, method v${params.version}: ${repoShort}`;
|
|
211
|
+
}
|
|
212
|
+
function linkedInPost(input) {
|
|
213
|
+
const { estimate: e, params, windowLabel, sample } = input;
|
|
214
|
+
const t = formatEnergy(e.total);
|
|
215
|
+
const cacheShare = e.total.central > 0 ? Math.round((e.byType.find((r) => r.key === "cacheRead")?.energy.central ?? 0) / e.total.central * 100) : 0;
|
|
216
|
+
if (sample) {
|
|
217
|
+
return [
|
|
218
|
+
`(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}), roughly ${everyday(e.total.central / 1e3, params)}.`,
|
|
219
|
+
"",
|
|
220
|
+
`${cacheShare}% of it came from the agent re-reading context it had already seen.`,
|
|
221
|
+
"",
|
|
222
|
+
`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.`,
|
|
223
|
+
"",
|
|
224
|
+
`Try it on your own logs: ${PLAYGROUND_URL}`,
|
|
225
|
+
`Code and method: ${REPO_URL}`
|
|
226
|
+
].join("\n");
|
|
227
|
+
}
|
|
228
|
+
return [
|
|
229
|
+
`I estimated the electricity behind my AI coding. ${whose(windowLabel)}: about ${t.central} (range ${t.range} ${t.unit}), roughly ${everyday(e.total.central / 1e3, params)}.`,
|
|
230
|
+
"",
|
|
231
|
+
`${cacheShare >= 50 ? "The surprise: " : ""}${cacheShare}% of it came from the agent re-reading context it had already seen.`,
|
|
232
|
+
"",
|
|
233
|
+
`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.`,
|
|
234
|
+
"",
|
|
235
|
+
`Try it in your browser: ${PLAYGROUND_URL}`,
|
|
236
|
+
`Code and method: ${REPO_URL}`
|
|
237
|
+
].join("\n");
|
|
238
|
+
}
|
|
239
|
+
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
240
|
+
function row(left, right, width) {
|
|
241
|
+
const dots = Math.max(2, width - left.length - right.length - 2);
|
|
242
|
+
return `${left} ${".".repeat(dots)} ${right}`;
|
|
243
|
+
}
|
|
244
|
+
function receiptSvg({ estimate: e, params, windowLabel, sample }) {
|
|
245
|
+
const W = 1200;
|
|
246
|
+
const H = 627;
|
|
247
|
+
const t = formatEnergy(e.total);
|
|
248
|
+
const kwh = e.total.central / 1e3;
|
|
249
|
+
const compare = everyday(kwh, params).split(" \xB7 ");
|
|
250
|
+
const unit = t.unit;
|
|
251
|
+
const scale = unit === "kWh" ? 1e3 : 1;
|
|
252
|
+
const slipX = 712;
|
|
253
|
+
const slipW = 432;
|
|
254
|
+
const cols = 36;
|
|
255
|
+
const items = [];
|
|
256
|
+
const dash = "-".repeat(cols);
|
|
257
|
+
items.push({ text: "ELECTRICITY RECEIPT", weight: "bold" });
|
|
258
|
+
items.push({ text: e.span ? `${e.span.from} \u2192 ${e.span.to}` : windowLabel, muted: true });
|
|
259
|
+
items.push({ text: dash, muted: true });
|
|
260
|
+
const types = [...TOKEN_TYPES].sort(
|
|
261
|
+
(a, b) => (e.byType.find((r) => r.key === b)?.energy.central ?? 0) - (e.byType.find((r) => r.key === a)?.energy.central ?? 0)
|
|
262
|
+
);
|
|
263
|
+
for (const type of types) {
|
|
264
|
+
const r = e.byType.find((x) => x.key === type);
|
|
265
|
+
items.push({ text: row(TYPE_LABELS[type], `${num((r?.energy.central ?? 0) / scale)} ${unit}`, cols) });
|
|
266
|
+
}
|
|
267
|
+
items.push({ text: dash, muted: true });
|
|
268
|
+
items.push({ text: row("TOTAL (central)", t.central, cols), weight: "bold" });
|
|
269
|
+
items.push({ text: row("RANGE", `${t.range} ${unit}`, cols) });
|
|
270
|
+
items.push({ text: dash, muted: true });
|
|
271
|
+
for (const m of e.byModel.slice(0, 3)) {
|
|
272
|
+
const pct = e.total.central > 0 ? Math.round(m.energy.central / e.total.central * 100) : 0;
|
|
273
|
+
items.push({ text: row(shortModel(m.key), `${pct}%`, cols), muted: true });
|
|
274
|
+
}
|
|
275
|
+
items.push({ text: row("responses", int(e.responses), cols), muted: true });
|
|
276
|
+
items.push({ text: `method v${params.version} \xB7 counts only`, muted: true });
|
|
277
|
+
const lineH = 25;
|
|
278
|
+
const slipTop = 46;
|
|
279
|
+
const textTop = slipTop + 44;
|
|
280
|
+
const barcodeY = textTop + items.length * lineH + 6;
|
|
281
|
+
const slipBottom = barcodeY + 70;
|
|
282
|
+
const days = e.byDay.slice(-30);
|
|
283
|
+
const maxDay = Math.max(...days.map((d) => d.energy.central), 1e-9);
|
|
284
|
+
const barW = (slipW - 64) / Math.max(days.length, 1);
|
|
285
|
+
const barcode = days.map((d, i) => {
|
|
286
|
+
const h = 6 + d.energy.central / maxDay * 40;
|
|
287
|
+
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"/>`;
|
|
288
|
+
}).join("");
|
|
289
|
+
const teeth = [];
|
|
290
|
+
for (let i = 0, x = slipX + slipW; x > slipX; i++, x -= 9) teeth.push(`${x.toFixed(1)},${slipBottom + (i % 2 ? 10 : 0)}`);
|
|
291
|
+
const slipPath = `M${slipX},${slipTop} H${slipX + slipW} V${slipBottom} L${teeth.join(" L")} L${slipX},${slipBottom} Z`;
|
|
292
|
+
const mono = `ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace`;
|
|
293
|
+
const sans = `'Helvetica Neue', Helvetica, Arial, sans-serif`;
|
|
294
|
+
const slipText = items.map((it, i) => {
|
|
295
|
+
const y = textTop + i * lineH;
|
|
296
|
+
const centered = i < 2 || it.text.startsWith("method");
|
|
297
|
+
const x = centered ? slipX + slipW / 2 : slipX + 30;
|
|
298
|
+
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>`;
|
|
299
|
+
}).join("\n ");
|
|
300
|
+
const headline = t.central.split(" ");
|
|
301
|
+
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 }))}">
|
|
302
|
+
<rect width="${W}" height="${H}" fill="#12251c"/>
|
|
303
|
+
<rect x="0" y="${H - 6}" width="${W}" height="6" fill="#7cc4a0"/>
|
|
304
|
+
<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>
|
|
305
|
+
<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>
|
|
306
|
+
<text x="64" y="318" font-family="${sans}" font-size="28" fill="#cfe3d6">of electricity, range ${esc(t.range)} ${unit}</text>
|
|
307
|
+
${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 ")}
|
|
308
|
+
<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>
|
|
309
|
+
<text x="64" y="552" font-family="${mono}" font-size="17" fill="#7cc4a0">${esc(APP_NAME)} \xB7 method v${esc(params.version)}</text>
|
|
310
|
+
<text x="64" y="578" font-family="${mono}" font-size="17" fill="#7cc4a0">${esc(repoShort)}</text>
|
|
311
|
+
<path d="${slipPath}" fill="#f7f5ef"/>
|
|
312
|
+
<g>
|
|
313
|
+
${slipText}
|
|
314
|
+
</g>
|
|
315
|
+
${barcode}
|
|
316
|
+
</svg>
|
|
317
|
+
`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ../core/src/window.ts
|
|
321
|
+
function parseSince(input, now) {
|
|
322
|
+
const value = input.trim().toLowerCase();
|
|
323
|
+
if (value === "all") return { start: null, label: "all time" };
|
|
324
|
+
const rel = /^(\d+)\s*([hdw])$/.exec(value);
|
|
325
|
+
if (rel) {
|
|
326
|
+
const n = Number(rel[1]);
|
|
327
|
+
const unit = rel[2];
|
|
328
|
+
const ms = { h: 36e5, d: 864e5, w: 6048e5 }[unit] * n;
|
|
329
|
+
const word = { h: "hour", d: "day", w: "week" }[unit];
|
|
330
|
+
return { start: new Date(now.getTime() - ms).toISOString(), label: `last ${n} ${word}${n === 1 ? "" : "s"}` };
|
|
331
|
+
}
|
|
332
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value))) {
|
|
333
|
+
return { start: `${value}T00:00:00.000Z`, label: `since ${value}` };
|
|
334
|
+
}
|
|
335
|
+
throw new Error(`Can't read --since "${input}". Use 7d, 12h, 4w, a date like 2026-10-01, or all.`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ../core/src/project.ts
|
|
339
|
+
function fnv1a64(text) {
|
|
340
|
+
let hash = 0xcbf29ce484222325n;
|
|
341
|
+
const prime = 0x100000001b3n;
|
|
342
|
+
for (let i = 0; i < text.length; i++) {
|
|
343
|
+
hash ^= BigInt(text.charCodeAt(i));
|
|
344
|
+
hash = hash * prime & 0xffffffffffffffffn;
|
|
345
|
+
}
|
|
346
|
+
return hash.toString(16).padStart(16, "0");
|
|
347
|
+
}
|
|
348
|
+
function projectRef(cwd) {
|
|
349
|
+
const trimmed = cwd.replace(/[\\/]+$/, "");
|
|
350
|
+
const name = trimmed.split(/[\\/]/).pop() || trimmed;
|
|
351
|
+
return { id: fnv1a64(trimmed), name };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ../core/src/adapters/claude-code.ts
|
|
355
|
+
var num2 = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
356
|
+
var str = (value) => typeof value === "string" && value !== "" ? value : null;
|
|
357
|
+
function toRecord(line) {
|
|
358
|
+
if (line.type !== "assistant") return null;
|
|
359
|
+
const message = line.message;
|
|
360
|
+
const usage = message?.usage;
|
|
361
|
+
const model = str(message?.model);
|
|
362
|
+
const timestamp = str(line.timestamp);
|
|
363
|
+
const messageId = str(message?.id);
|
|
364
|
+
if (!usage || !model || !timestamp || !messageId) return null;
|
|
365
|
+
if (model === "<synthetic>") return null;
|
|
366
|
+
const cwd = str(line.cwd);
|
|
367
|
+
return {
|
|
368
|
+
source: "claude-code",
|
|
369
|
+
key: `${messageId}:${str(line.requestId) ?? ""}`,
|
|
370
|
+
timestamp,
|
|
371
|
+
model,
|
|
372
|
+
project: cwd ? projectRef(cwd) : null,
|
|
373
|
+
tokens: {
|
|
374
|
+
input: num2(usage.input_tokens),
|
|
375
|
+
cacheWrite: num2(usage.cache_creation_input_tokens),
|
|
376
|
+
cacheRead: num2(usage.cache_read_input_tokens),
|
|
377
|
+
output: num2(usage.output_tokens)
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
var claudeCodeAdapter = {
|
|
382
|
+
id: "claude-code",
|
|
383
|
+
parse(text) {
|
|
384
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
385
|
+
for (const raw of text.split("\n")) {
|
|
386
|
+
if (!raw.includes('"assistant"')) continue;
|
|
387
|
+
let line;
|
|
388
|
+
try {
|
|
389
|
+
line = JSON.parse(raw);
|
|
390
|
+
} catch {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const record = toRecord(line);
|
|
394
|
+
if (record) byKey.set(record.key, record);
|
|
395
|
+
}
|
|
396
|
+
return [...byKey.values()];
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// ../core/src/rollup.ts
|
|
401
|
+
function dedupe(records) {
|
|
402
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
403
|
+
for (const record of records) byKey.set(`${record.source}:${record.key}`, record);
|
|
404
|
+
return [...byKey.values()];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ../../methodology/params.json
|
|
408
|
+
var params_default = {
|
|
409
|
+
version: "0.1.0",
|
|
410
|
+
as_of: "2026-10-21",
|
|
411
|
+
license: "CC BY 4.0",
|
|
412
|
+
boundary: "comprehensive: accelerators, host CPU and memory, idle capacity, and data-center overhead (PUE)",
|
|
413
|
+
interval: "low and high are each parameter's plausible bounds, treated as a ~90% interval",
|
|
414
|
+
params: {
|
|
415
|
+
"output_wh_per_1k.opus": {
|
|
416
|
+
value: 1.2,
|
|
417
|
+
low: 0.4,
|
|
418
|
+
high: 3,
|
|
419
|
+
unit: "Wh per 1k output tokens",
|
|
420
|
+
label: "MODELED",
|
|
421
|
+
source: "https://www.theclimatebrink.com/p/the-real-energy-use-of-agentic-ai",
|
|
422
|
+
as_of: "2026-10-21",
|
|
423
|
+
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."
|
|
424
|
+
},
|
|
425
|
+
"output_wh_per_1k.sonnet": {
|
|
426
|
+
value: 0.6,
|
|
427
|
+
low: 0.25,
|
|
428
|
+
high: 1.5,
|
|
429
|
+
unit: "Wh per 1k output tokens",
|
|
430
|
+
label: "MODELED",
|
|
431
|
+
source: "https://arxiv.org/abs/2508.15734",
|
|
432
|
+
as_of: "2026-10-21",
|
|
433
|
+
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."
|
|
434
|
+
},
|
|
435
|
+
"output_wh_per_1k.haiku": {
|
|
436
|
+
value: 0.25,
|
|
437
|
+
low: 0.08,
|
|
438
|
+
high: 0.6,
|
|
439
|
+
unit: "Wh per 1k output tokens",
|
|
440
|
+
label: "GUESS",
|
|
441
|
+
source: "https://www.anthropic.com/pricing",
|
|
442
|
+
as_of: "2026-10-21",
|
|
443
|
+
note: "Smallest tier. Scaled down from Sonnet roughly in line with the price gap between tiers, which is a weak proxy for compute."
|
|
444
|
+
},
|
|
445
|
+
input_vs_output: {
|
|
446
|
+
value: 0.3,
|
|
447
|
+
low: 0.15,
|
|
448
|
+
high: 0.5,
|
|
449
|
+
unit: "ratio",
|
|
450
|
+
label: "MODELED",
|
|
451
|
+
source: "https://www.anthropic.com/pricing",
|
|
452
|
+
as_of: "2026-10-21",
|
|
453
|
+
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."
|
|
454
|
+
},
|
|
455
|
+
cache_write_vs_input: {
|
|
456
|
+
value: 1,
|
|
457
|
+
low: 0.8,
|
|
458
|
+
high: 1.25,
|
|
459
|
+
unit: "ratio",
|
|
460
|
+
label: "MODELED",
|
|
461
|
+
source: "https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
|
|
462
|
+
as_of: "2026-10-21",
|
|
463
|
+
note: "Writing to the cache means processing the tokens fresh, then storing the result. Priced at 1.25x input."
|
|
464
|
+
},
|
|
465
|
+
cache_read_vs_input: {
|
|
466
|
+
value: 0.1,
|
|
467
|
+
low: 0.01,
|
|
468
|
+
high: 0.25,
|
|
469
|
+
unit: "ratio",
|
|
470
|
+
label: "MODELED",
|
|
471
|
+
source: "https://www.theclimatebrink.com/p/the-real-energy-use-of-agentic-ai",
|
|
472
|
+
as_of: "2026-10-21",
|
|
473
|
+
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."
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
tiers: {
|
|
477
|
+
opus: [
|
|
478
|
+
"opus"
|
|
479
|
+
],
|
|
480
|
+
sonnet: [
|
|
481
|
+
"sonnet"
|
|
482
|
+
],
|
|
483
|
+
haiku: [
|
|
484
|
+
"haiku"
|
|
485
|
+
],
|
|
486
|
+
fallback: "sonnet"
|
|
487
|
+
},
|
|
488
|
+
comparisons: {
|
|
489
|
+
ev_kwh_per_mile: {
|
|
490
|
+
value: 0.3,
|
|
491
|
+
unit: "kWh per mile",
|
|
492
|
+
label: "MEASURED",
|
|
493
|
+
source: "https://www.fueleconomy.gov/feg/evsbs.shtml",
|
|
494
|
+
as_of: "2026-10-21",
|
|
495
|
+
note: "Typical EV uses 25-35 kWh per 100 miles (EPA)."
|
|
496
|
+
},
|
|
497
|
+
fridge_kwh_per_day: {
|
|
498
|
+
value: 1.5,
|
|
499
|
+
unit: "kWh per day",
|
|
500
|
+
label: "MEASURED",
|
|
501
|
+
source: "https://www.energystar.gov/products/refrigerators",
|
|
502
|
+
as_of: "2026-10-21",
|
|
503
|
+
note: "A typical US refrigerator uses about 400-600 kWh a year."
|
|
504
|
+
},
|
|
505
|
+
us_home_kwh_per_day: {
|
|
506
|
+
value: 29,
|
|
507
|
+
unit: "kWh per day",
|
|
508
|
+
label: "MEASURED",
|
|
509
|
+
source: "https://www.eia.gov/tools/faqs/faq.php?id=97&t=3",
|
|
510
|
+
as_of: "2026-10-21",
|
|
511
|
+
note: "Average US household used about 10,800 kWh in a year (EIA)."
|
|
512
|
+
},
|
|
513
|
+
phone_charge_kwh: {
|
|
514
|
+
value: 0.019,
|
|
515
|
+
unit: "kWh per smartphone charge",
|
|
516
|
+
label: "MEASURED",
|
|
517
|
+
source: "https://www.epa.gov/energy/greenhouse-gas-equivalencies-calculator-calculations-and-references",
|
|
518
|
+
as_of: "2026-10-21",
|
|
519
|
+
note: "EPA's figure for fully charging one smartphone."
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// ../core/src/params.ts
|
|
525
|
+
var defaultParams = params_default;
|
|
526
|
+
|
|
527
|
+
// ../core/src/history.ts
|
|
528
|
+
var HISTORY_SCHEMA = 1;
|
|
529
|
+
var rowKey = (r) => `${r.date}|${r.source}|${r.model}|${r.projectId ?? ""}`;
|
|
530
|
+
var sum = (t) => TOKEN_TYPES.reduce((s, k) => s + t[k], 0);
|
|
531
|
+
var outputContext = (t) => t.output * (t.input + t.cacheWrite + t.cacheRead);
|
|
532
|
+
function rollup(records) {
|
|
533
|
+
const rows = /* @__PURE__ */ new Map();
|
|
534
|
+
for (const r of records) {
|
|
535
|
+
const base = {
|
|
536
|
+
date: r.timestamp.slice(0, 10),
|
|
537
|
+
source: r.source,
|
|
538
|
+
model: r.model,
|
|
539
|
+
projectId: r.project?.id ?? null,
|
|
540
|
+
projectName: r.project?.name ?? null
|
|
541
|
+
};
|
|
542
|
+
const key = rowKey(base);
|
|
543
|
+
let row2 = rows.get(key);
|
|
544
|
+
if (!row2) rows.set(key, row2 = { ...base, responses: 0, tokens: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 }, outputContext: 0 });
|
|
545
|
+
row2.responses += 1;
|
|
546
|
+
for (const t of TOKEN_TYPES) row2.tokens[t] += r.tokens[t];
|
|
547
|
+
row2.outputContext += outputContext(r.tokens);
|
|
548
|
+
}
|
|
549
|
+
return sortRows([...rows.values()]);
|
|
550
|
+
}
|
|
551
|
+
function sortRows(rows) {
|
|
552
|
+
return rows.sort((a, b) => rowKey(a).localeCompare(rowKey(b)));
|
|
553
|
+
}
|
|
554
|
+
function mergeHistory(stored, fresh) {
|
|
555
|
+
const rows = /* @__PURE__ */ new Map();
|
|
556
|
+
for (const row2 of [...stored, ...fresh]) {
|
|
557
|
+
const key = rowKey(row2);
|
|
558
|
+
const prev = rows.get(key);
|
|
559
|
+
if (!prev || sum(row2.tokens) > sum(prev.tokens) || sum(row2.tokens) === sum(prev.tokens) && moreComplete(row2, prev)) {
|
|
560
|
+
rows.set(key, row2);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return sortRows([...rows.values()]);
|
|
564
|
+
}
|
|
565
|
+
var moreComplete = (row2, prev) => row2.responses > prev.responses || row2.responses === prev.responses && row2.outputContext !== void 0 && prev.outputContext === void 0;
|
|
566
|
+
function rollupRecords(rows) {
|
|
567
|
+
return rows.map((r) => ({
|
|
568
|
+
source: r.source,
|
|
569
|
+
key: rowKey(r),
|
|
570
|
+
timestamp: `${r.date}T00:00:00.000Z`,
|
|
571
|
+
model: r.model,
|
|
572
|
+
project: r.projectId ? { id: r.projectId, name: r.projectName ?? "(unknown)" } : null,
|
|
573
|
+
tokens: { ...r.tokens }
|
|
574
|
+
}));
|
|
575
|
+
}
|
|
576
|
+
function serializeHistory(rows) {
|
|
577
|
+
const header2 = JSON.stringify({ schema: HISTORY_SCHEMA, kind: "daily-rollups", note: "Token counts only. Energy is recomputed on read." });
|
|
578
|
+
return [header2, ...rows.map((r) => JSON.stringify(r))].join("\n") + "\n";
|
|
579
|
+
}
|
|
580
|
+
function parseHistory(text) {
|
|
581
|
+
const rows = [];
|
|
582
|
+
for (const line of text.split("\n")) {
|
|
583
|
+
if (!line.trim()) continue;
|
|
584
|
+
let value;
|
|
585
|
+
try {
|
|
586
|
+
value = JSON.parse(line);
|
|
587
|
+
} catch {
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (isRow(value)) rows.push(value);
|
|
591
|
+
}
|
|
592
|
+
return rows;
|
|
593
|
+
}
|
|
594
|
+
function isRow(v) {
|
|
595
|
+
if (!v || typeof v !== "object") return false;
|
|
596
|
+
const r = v;
|
|
597
|
+
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");
|
|
598
|
+
}
|
|
599
|
+
function weekStart(date) {
|
|
600
|
+
const d = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
|
|
601
|
+
const offset = (d.getUTCDay() + 6) % 7;
|
|
602
|
+
d.setUTCDate(d.getUTCDate() - offset);
|
|
603
|
+
return d.toISOString().slice(0, 10);
|
|
604
|
+
}
|
|
605
|
+
function weekly(days) {
|
|
606
|
+
const weeks = /* @__PURE__ */ new Map();
|
|
607
|
+
for (const d of days) {
|
|
608
|
+
const start = weekStart(d.key);
|
|
609
|
+
let w = weeks.get(start);
|
|
610
|
+
if (!w) weeks.set(start, w = { start, low: 0, central: 0, high: 0, tokens: 0 });
|
|
611
|
+
w.low += d.energy.low;
|
|
612
|
+
w.central += d.energy.central;
|
|
613
|
+
w.high += d.energy.high;
|
|
614
|
+
w.tokens += sum(d.tokens);
|
|
615
|
+
}
|
|
616
|
+
const sorted = [...weeks.values()].sort((a, b) => a.start.localeCompare(b.start));
|
|
617
|
+
if (sorted.length < 2) return sorted;
|
|
618
|
+
const filled = [];
|
|
619
|
+
const cursor = /* @__PURE__ */ new Date(`${sorted[0].start}T00:00:00Z`);
|
|
620
|
+
const last = sorted[sorted.length - 1].start;
|
|
621
|
+
while (cursor.toISOString().slice(0, 10) <= last) {
|
|
622
|
+
const start = cursor.toISOString().slice(0, 10);
|
|
623
|
+
filled.push(weeks.get(start) ?? { start, low: 0, central: 0, high: 0, tokens: 0 });
|
|
624
|
+
cursor.setUTCDate(cursor.getUTCDate() + 7);
|
|
625
|
+
}
|
|
626
|
+
return filled;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// src/dashboard.ts
|
|
630
|
+
import { spawn } from "child_process";
|
|
631
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
|
|
632
|
+
import { join as join2 } from "path";
|
|
633
|
+
|
|
634
|
+
// ../web/src/inject.ts
|
|
635
|
+
var DATA_MARKER = /(<script id="ccwatts-data" type="application\/json">)([\s\S]*?)(<\/script>)/;
|
|
636
|
+
function safeJson(value) {
|
|
637
|
+
return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
638
|
+
}
|
|
639
|
+
function injectData(html, payload) {
|
|
640
|
+
if (!DATA_MARKER.test(html)) throw new Error("Dashboard template is missing its data slot.");
|
|
641
|
+
return html.replace(DATA_MARKER, (_m, open, _old, close) => `${open}${safeJson(payload)}${close}`);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ../web/dist/ccwatts.html
|
|
645
|
+
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-7q/PdAdCG3ZsmZDjCI8gAjVev2OuHqaTGUfCFPxxE+I=\'; 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.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}\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.</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 class="phone-only" id="phone-hint">On a phone? Claude Code keeps its logs on your computer, so open this page there to try your own.</p>\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.0",A="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"},P=e=>Math.round(e).toLocaleString("en-US");function M(e){return e>=100?P(e):e>=1?(Math.round(e*10)/10).toString():e>=.01?(Math.round(e*100)/100).toString():e===0?"0":e.toPrecision(1)}function L(e){let t=e.high>=1e3,n=t?1e3:1,o=t?"kWh":"Wh";return{central:`${M(e.central/n)} ${o}`,range:`${M(e.low/n)} \\u2013 ${M(e.high/n)}`,unit:o}}var O=e=>e.replace(/^claude-/,"");var _=["input","cacheWrite","cacheRead","output"];var V=()=>({low:0,central:0,high:0});function oe(e,t){return e.low+=t.low,e.central+=t.central,e.high+=t.high,e}function $e(e,t){let n=e.toLowerCase();for(let[o,s]of Object.entries(t.tiers))if(Array.isArray(s)&&s.some(r=>n.includes(r)))return{tier:o,known:!0};return{tier:String(t.tiers.fallback),known:!1}}function Oe(e,t){let n=e.params[t];if(!n)throw new Error(`Missing parameter "${t}" in params.json`);return n}function Ne(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 He(e,t,n){let o=.001,s=0,r=0;for(let a of Ne(t,n)){let c=Oe(e,a);o*=c.value,s+=Math.log(c.high/c.value)**2,r+=Math.log(c.value/c.low)**2}return{whPerToken:o,up:Math.sqrt(s),down:Math.sqrt(r)}}var ke=()=>({input:0,cacheWrite:0,cacheRead:0,output:0});var xe=new WeakMap;function Be(e,t,n){let o=xe.get(e);o||xe.set(e,o=new Map);let s=`${t}:${n}`,r=o.get(s);return r||o.set(s,r=He(e,t,n)),r}function be(e,t,n,o){if(e<=0)return V();let s=Be(o,t,n),r=e*s.whPerToken;return{low:r/Math.exp(s.down),central:r,high:r*Math.exp(s.up)}}var Fe=e=>e.slice(0,10);function G(e,t){let n=V(),o=ke(),s={model:new Map,type:new Map,day:new Map,project:new Map},r=new Set,a=null,c=null,k=(l,h,m,p,u)=>{let g=s[l].get(h);g||s[l].set(h,g={key:h,energy:V(),tokens:ke()}),oe(g.energy,m),g.tokens[p]+=u};for(let l of e){let{tier:h,known:m}=$e(l.model,t);m||r.add(l.model);let p=Fe(l.timestamp);(a===null||p<a)&&(a=p),(c===null||p>c)&&(c=p);let u=l.project?.name??"(unknown)";for(let g of _){let y=l.tokens[g];if(y<=0)continue;let T=be(y,h,g,t);oe(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 x=l=>[...l.values()].sort((h,m)=>m.energy.central-h.energy.central),j=[...r].map(l=>`Unknown model "${l}": estimated as ${String(t.tiers.fallback)}.`);return{methodVersion:t.version,total:n,tokens:o,responses:e.length,byModel:x(s.model),byType:x(s.type),byDay:[...s.day.values()].sort((l,h)=>l.key.localeCompare(h.key)),byProject:x(s.project),span:a&&c?{from:a,to:c}:null,warnings:j}}var re=e=>e.split("#")[0]??e;function se(e,t){let n=t.comparisons,o=[],s=r=>r>=10?Math.round(r):Math.round(r*10)/10;if(n.ev_kwh_per_mile){let r=e/n.ev_kwh_per_mile.value;o.push({id:"ev",amount:r,text:`driving an EV about ${s(r)} miles`})}if(n.fridge_kwh_per_day){let r=e/n.fridge_kwh_per_day.value;o.push({id:"fridge",amount:r,text:`running a fridge about ${s(r)} days`})}if(n.phone_charge_kwh){let r=e/n.phone_charge_kwh.value;o.push({id:"phone",amount:r,text:`charging a phone about ${s(r)} times`})}if(n.us_home_kwh_per_day){let r=e/n.us_home_kwh_per_day.value;o.push({id:"home",amount:r,text:`powering a US home about ${s(r)} days`})}return o}function U(e,t){let n=se(e,t),o=n.filter(r=>r.amount>=1).slice(0,2),s=[...n].sort((r,a)=>a.amount-r.amount).slice(0,1);return(o.length?o:s).map(r=>r.text).join(" \\xB7 ")}var Re=A.replace(/^https:\\/\\//,"");function Te(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 ve(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 s=L(e.total),r=U(e.total.central/1e3,t).split(" \\xB7 ")[0];return`${o?ve(n):Te(n)} used about ${s.central} of electricity (range ${s.range} ${s.unit}), roughly ${r}. Estimated locally with ${R}, method v${t.version}: ${Re}`}function ae(e){let{estimate:t,params:n,windowLabel:o,sample:s}=e,r=L(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 s?[`(Sample data) This is what ${R} shows for a sample developer, not a real measurement. ${ve(o).replace("(Sample data) ","")}: about ${r.central} (range ${r.range} ${r.unit}), roughly ${U(t.total.central/1e3,n)}.`,"",`${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: ${A}`].join(`\n`):[`I estimated the electricity behind my AI coding. ${Te(o)}: about ${r.central} (range ${r.range} ${r.unit}), roughly ${U(t.total.central/1e3,n)}.`,"",`${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: ${A}`].join(`\n`)}var E=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 ie({estimate:e,params:t,windowLabel:n,sample:o}){let a=L(e.total),c=e.total.central/1e3,k=U(c,t).split(" \\xB7 "),x=a.unit,j=x==="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=[..._].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],`${M((w?.energy.central??0)/j)} ${x}`,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} ${x}`,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",P(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,S=I+70,D=e.byDay.slice(-30),F=Math.max(...D.map(d=>d.energy.central),1e-9),ge=(h-64)/Math.max(D.length,1),Ie=D.map((d,w)=>{let v=6+d.energy.central/F*40;return`<rect x="${(l+32+w*ge).toFixed(1)}" y="${(I+46-v).toFixed(1)}" width="${Math.max(1,ge-2).toFixed(1)}" height="${v.toFixed(1)}" fill="#1f2a24"/>`}).join(""),fe=[];for(let d=0,w=l+h;w>l;d++,w-=9)fe.push(`${w.toFixed(1)},${S+(d%2?10:0)}`);let We=`M${l},${T} H${l+h} V${S} L${fe.join(" L")} L${l},${S} Z`,Y="ui-monospace, \'SF Mono\', Menlo, Consolas, \'Liberation Mono\', monospace",z="\'Helvetica Neue\', Helvetica, Arial, sans-serif",De=p.map((d,w)=>{let v=B+w*y,we=w<2||d.text.startsWith("method");return`<text x="${we?l+h/2:l+30}" y="${v}" font-family="${Y}" font-size="16.5" ${we?\'text-anchor="middle"\':""} font-weight="${d.weight??"normal"}" fill="${d.muted?"#5e6b63":"#1f2a24"}">${E(d.text)}</text>`}).join(`\n `),ye=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="${E(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 ${E(n.toUpperCase())}</text>\n <text x="60" y="262" font-family="${z}" font-size="148" font-weight="700" fill="#f4f1e8">${E(ye[0]??"")}<tspan font-size="64" font-weight="500" dx="14">${E(ye[1]??"")}</tspan></text>\n <text x="64" y="318" font-family="${z}" font-size="28" fill="#cfe3d6">of electricity, range ${E(a.range)} ${x}</text>\n ${k.map((d,w)=>`<text x="64" y="${384+w*38}" font-family="${z}" font-size="26" fill="#f4f1e8">\\u2248 ${E(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">${E(R)} \\xB7 method v${E(t.version)}</text>\n <text x="64" y="578" font-family="${Y}" font-size="17" fill="#7cc4a0">${E(Re)}</text>\n <path d="${We}" fill="#f7f5ef"/>\n <g>\n ${De}\n </g>\n ${Ie}\n</svg>\n`}function Ye(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 ce(e){let t=e.replace(/[\\\\/]+$/,""),n=t.split(/[\\\\/]/).pop()||t;return{id:Ye(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 ze(e){if(e.type!=="assistant")return null;let t=e.message,n=t?.usage,o=H(t?.model),s=H(e.timestamp),r=H(t?.id);if(!n||!o||!s||!r||o==="<synthetic>")return null;let a=H(e.cwd);return{source:"claude-code",key:`${r}:${H(e.requestId)??""}`,timestamp:s,model:o,project:a?ce(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 le={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 s=ze(o);s&&t.set(s.key,s)}return[...t.values()]}};function ue(e){let t=new Map;for(let n of e)t.set(`${n.source}:${n.key}`,n);return[...t.values()]}var _e={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 $=_e;var Q=e=>`${e.date}|${e.source}|${e.model}|${e.projectId??""}`;var Ee=e=>e.output*(e.input+e.cacheWrite+e.cacheRead);function pe(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},s=Q(o),r=t.get(s);r||t.set(s,r={...o,responses:0,tokens:{input:0,cacheWrite:0,cacheRead:0,output:0},outputContext:0}),r.responses+=1;for(let a of _)r.tokens[a]+=n.tokens[a];r.outputContext+=Ee(n.tokens)}return Ke([...t.values()])}function Ke(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),b=e=>e.replace(/[&<>"\']/g,t=>({"&":"&","<":"<",">":">",\'"\':""","\'":"'"})[t]);function Pe(e,t){try{return JSON.parse(i(e).textContent??"")}catch{return t}}var C=Pe("ccwatts-data",{mode:"playground",generatedAt:null,rows:[]}),Se=Pe("ccwatts-sample",[]),f={rows:C.mode==="dashboard"&&C.rows.length?C.rows:Se,source:C.mode==="dashboard"&&C.rows.length?"history":"sample",window:"30d"},Ve={"7d":"last 7 days","30d":"last 30 days",all:"all time"};function Ge(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"),s=f.source==="history"?Date.now():Date.parse(`${o}T00:00:00Z`)+864e5,r=new Date(s-n*864e5).toISOString().slice(0,10);return e.filter(a=>a.date>=r)}function Je(e){let t=G(X(e),$);return t.responses=e.reduce((n,o)=>n+o.responses,0),t}function Ze(e,t){let n=L(e.total),[o,s]=n.central.split(" ");i("hero-eyebrow").textContent=`Claude Code electricity \\xB7 ${t}`,i("hero-big").innerHTML=e.responses?`${b(o??"")}<small>${b(s??"")}</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,$).replace(" \\xB7 ",", or ")}.`:"";let r=e.span?` \\xB7 ${e.span.from} to ${e.span.to}`:"",a=_.reduce((c,k)=>c+e.tokens[k],0);i("hero-meta").textContent=`${P(e.responses)} responses \\xB7 ${P(a)} tokens${r} \\xB7 method v${$.version}`;for(let c of["7d","30d","all"])i(`w-${c}`).setAttribute("aria-pressed",String(f.window===c))}function Ae(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 s={l:44,r:8,t:22,b:28},r=e.byDay;if(i("legend").innerHTML=["cacheRead","cacheWrite","output","input"].map(u=>`<span><i style="background:var(--t-${u})"></i>${W[u]}</span>`).join(""),!r.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=L(e.total),c=a.unit==="kWh"?1e3:1,k=Math.max(...r.map(u=>u.energy.central))/c,x=et(k/3),j=Math.max(x*Math.ceil(k/x),x),l=u=>s.t+(o-s.t-s.b)*(1-u/j),h=(n-s.l-s.r)/r.length,m=[];for(let u=0;u<=j+1e-9;u+=x)m.push(`<line x1="${s.l}" x2="${n-s.r}" y1="${l(u)}" y2="${l(u)}" stroke="var(--line)" stroke-width="1"/>`),m.push(`<text x="${s.l-6}" y="${l(u)+4}" text-anchor="end">${M(u)}</text>`);m.push(`<text x="0" y="10">${a.unit} per day</text>`),r.forEach((u,g)=>{let y=0,T=s.l+g*h+h*.12,B=Math.max(1,h*.76);for(let I of["cacheRead","cacheWrite","output","input"]){let S=Qe(u,I);if(S<=0)continue;let D=y/c;y+=S;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]} ${M(S/c)} ${a.unit}</title></rect>`)}});let p=r.length===1?[0]:[0,Math.floor((r.length-1)/2),r.length-1];for(let u of new Set(p)){let g=u===0?"start":u===r.length-1?"end":"middle",y=u===0?s.l:u===r.length-1?n-s.r:s.l+(u+.5)*h;m.push(`<text x="${y}" y="${o-8}" text-anchor="${g}">${r[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 Qe(e,t){return ee.get(e.key)?.[t]??0}var ee=new Map;function Xe(e){ee=new Map;for(let t of e){let n=G(X([t]),$),o=ee.get(t.date);o||ee.set(t.date,o={input:0,cacheWrite:0,cacheRead:0,output:0});for(let s of n.byType)o[s.key]+=s.energy.central}}function et(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 Ce(e,t,n,o){let s=Math.max(...t.map(a=>a.energy.central),1e-9),r=t.slice(0,6);i(e).innerHTML=r.length?r.map(a=>{let c=L(a.energy),k=o.total.central?Math.round(a.energy.central/o.total.central*100):0;return`<li><span class="name" title="${b(n(a.key))}">${b(n(a.key))}</span><span class="val">${b(c.central)} \\xB7 ${k}%</span><span class="bar"><span style="width:${(a.energy.central/s*100).toFixed(1)}%"></span></span><span class="val" style="grid-column:1/-1;text-align:left">range ${b(c.range)} ${b(c.unit)}</span></li>`}).join(""):\'<li><span class="name">No usage in this window</span></li>\'}function tt(e){let t=s=>e.total.central?Math.round((e.byType.find(r=>r.key===s)?.energy.central??0)/e.total.central*100):0,n=_.reduce((s,r)=>s+e.tokens[r],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 de="",Ue="",he="";function nt(e,t){let n={estimate:e,params:$,windowLabel:t,sample:f.source==="sample"};de=ie(n),Ue=J(n),he=ae(n),i("card").src=`data:image/svg+xml;charset=utf-8,${encodeURIComponent(de)}`,i("post").textContent=he,i("share-status").textContent=f.source==="sample"?"This card shows sample data. Load your own logs to make yours.":""}function ot(e){try{return new URL(e).hostname.replace(/^www\\./,"")}catch{return"source"}}function rt(){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 ${$.boundary}. Labels say how solid each number is: MODELED is derived from published figures, GUESS is a best estimate with wide bounds. Method v${$.version}, parameters as of ${$.as_of}, licensed ${$.license}.`;let e=Object.entries($.params).map(([t,n])=>`<tr><td data-label="Parameter"><code>${b(t)}</code><div class="meta">${b(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">${b(n.unit)}</td><td data-label="Label"><span class="label">${b(n.label)}</span></td><td data-label="Source"><a class="src" href="${b(n.source)}" rel="noopener noreferrer">${b(ot(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 st={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."},me=null;function te(){let e=Ge(f.rows,f.window);Xe(e);let t=Je(e);me=t;let n=Ve[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=st[f.source],Ze(t,n),Ae(t),Ce("by-model",t.byModel,O,t),Ce("by-project",t.byProject,re,t),tt(t),nt(t,n)}async function Me(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}n.textContent=`Reading ${t.length} file${t.length===1?"":"s"} on this device\\u2026`;let o=[];for(let r of t)o.push(...le.parse(await r.text()));let s=pe(ue(o));if(!s.length){n.textContent="Those files had no Claude Code usage in them.";return}f.rows=s,f.source="files",f.window="all",n.textContent=`Read ${P(o.length)} responses from ${t.length} file${t.length===1?"":"s"}. Nothing was uploaded.`,te()}function je(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 Le(e,t){let n=i("share-status");try{await navigator.clipboard.writeText(e),n.textContent=`Copied the ${t}.`}catch{let o=document.createRange();o.selectNodeContents(i("post")),getSelection()?.removeAllRanges(),getSelection()?.addRange(o),n.textContent="Couldn\'t reach the clipboard. The post text is selected so you can copy it."}}function at(){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&&je(`${R}-receipt.png`,n),"image/png")},e.src=i("card").src}function it(){for(let o of["7d","30d","all"])i(`w-${o}`).addEventListener("click",()=>{f.window=o,te()});if(i("save-png").addEventListener("click",at),i("save-svg").addEventListener("click",()=>je(`${R}-receipt.svg`,new Blob([de],{type:"image/svg+xml"}))),i("copy-line").addEventListener("click",()=>{Le(Ue,"share line")}),i("copy-post").addEventListener("click",()=>{Le(he,"LinkedIn post")}),C.mode==="playground"){let o=i("drop");o.hidden=!1;for(let r of["pick-folder","pick-files"])i(r).addEventListener("change",a=>{let c=a.target.files;c&&Me(c)});i("use-sample").addEventListener("click",()=>{f.rows=Se,f.source="sample",f.window="30d",i("drop-status").textContent="",te()});let s=r=>a=>{a.preventDefault(),o.classList.toggle("over",r)};o.addEventListener("dragover",s(!0)),o.addEventListener("dragleave",s(!1)),o.addEventListener("drop",r=>{s(!1)(r),r.dataTransfer?.files&&Me(r.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;me&&Math.round(o)!==Math.round(t)&&Ae(me),t=o},150)}),i("repo-link").href=A,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${$.version}${n}. Electricity only, not carbon or water.`,document.title=C.mode==="dashboard"?`${R} dashboard`:`${R} playground`}it();rt();te();})();</script>\n</body>\n</html>\n';
|
|
646
|
+
|
|
647
|
+
// src/store.ts
|
|
648
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
649
|
+
import { homedir } from "os";
|
|
650
|
+
import { join } from "path";
|
|
651
|
+
function dataDir() {
|
|
652
|
+
return process.env[`${APP_NAME.toUpperCase()}_HOME`] ?? join(homedir(), `.${APP_NAME}`);
|
|
653
|
+
}
|
|
654
|
+
var historyPath = (dir = dataDir()) => join(dir, "history.jsonl");
|
|
655
|
+
async function readHistory(dir = dataDir()) {
|
|
656
|
+
try {
|
|
657
|
+
return parseHistory(await readFile(historyPath(dir), "utf8"));
|
|
658
|
+
} catch (error) {
|
|
659
|
+
if (error.code === "ENOENT") return [];
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async function writeHistory(rows, dir = dataDir()) {
|
|
664
|
+
await mkdir(dir, { recursive: true });
|
|
665
|
+
const tmp = `${historyPath(dir)}.tmp`;
|
|
666
|
+
await writeFile(tmp, serializeHistory(rows), { mode: 384 });
|
|
667
|
+
await rename(tmp, historyPath(dir));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/dashboard.ts
|
|
671
|
+
async function writeDashboard(rows, dir = dataDir()) {
|
|
672
|
+
await mkdir2(dir, { recursive: true });
|
|
673
|
+
const path = join2(dir, "dashboard.html");
|
|
674
|
+
const html = injectData(ccwatts_default, { mode: "dashboard", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), rows });
|
|
675
|
+
await writeFile2(path, html, { mode: 384 });
|
|
676
|
+
return path;
|
|
677
|
+
}
|
|
678
|
+
function openFile(path) {
|
|
679
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [path]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", path]] : ["xdg-open", [path]];
|
|
680
|
+
try {
|
|
681
|
+
spawn(cmd, args, { detached: true, stdio: "ignore" }).on("error", () => {
|
|
682
|
+
}).unref();
|
|
683
|
+
} catch {
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/logs.ts
|
|
688
|
+
import { readdir, readFile as readFile2, stat } from "fs/promises";
|
|
689
|
+
import { homedir as homedir2 } from "os";
|
|
690
|
+
import { join as join3 } from "path";
|
|
691
|
+
function defaultLogRoots() {
|
|
692
|
+
const fromEnv = process.env.CLAUDE_CONFIG_DIR?.split(",").map((dir) => join3(dir.trim(), "projects"));
|
|
693
|
+
if (fromEnv?.length) return fromEnv;
|
|
694
|
+
return [join3(homedir2(), ".claude", "projects"), join3(homedir2(), ".config", "claude", "projects")];
|
|
695
|
+
}
|
|
696
|
+
async function findJsonl(dir) {
|
|
697
|
+
if (dir.endsWith(".jsonl") && (await stat(dir).catch(() => null))?.isFile()) return [dir];
|
|
698
|
+
let entries;
|
|
699
|
+
try {
|
|
700
|
+
entries = await readdir(dir, { withFileTypes: true, recursive: true });
|
|
701
|
+
} catch {
|
|
702
|
+
return [];
|
|
703
|
+
}
|
|
704
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join3(e.parentPath, e.name));
|
|
705
|
+
}
|
|
706
|
+
async function loadLogs(roots) {
|
|
707
|
+
const files = (await Promise.all(roots.map(findJsonl))).flat();
|
|
708
|
+
const parsed = await Promise.all(files.map(async (f) => claudeCodeAdapter.parse(await readFile2(f, "utf8"))));
|
|
709
|
+
return { roots, files: files.length, records: dedupe(parsed.flat()) };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// src/render.ts
|
|
713
|
+
var energy = formatEnergy;
|
|
714
|
+
var totalTokens2 = (t) => TOKEN_TYPES.reduce((s, k) => s + t[k], 0);
|
|
715
|
+
function shares(rows, total, label, max = 4) {
|
|
716
|
+
const pct = (r) => total > 0 ? Math.round(r.energy.central / total * 100) : 0;
|
|
717
|
+
const shown = rows.slice(0, max).map((r) => `${label(r.key)} ${pct(r)}%`);
|
|
718
|
+
const rest = rows.slice(max).reduce((s, r) => s + r.energy.central, 0);
|
|
719
|
+
if (rest > 0) shown.push(`other ${Math.round(rest / total * 100)}%`);
|
|
720
|
+
return shown.join(" ");
|
|
721
|
+
}
|
|
722
|
+
function header(params, window) {
|
|
723
|
+
return `${APP_NAME} v${APP_VERSION} \xB7 method v${params.version} \xB7 ${window.label}`;
|
|
724
|
+
}
|
|
725
|
+
function summary(e, params, window) {
|
|
726
|
+
if (e.responses === 0) return `${header(params, window)}
|
|
727
|
+
|
|
728
|
+
No Claude Code usage found in this window. Try --since all.`;
|
|
729
|
+
const total = energy(e.total);
|
|
730
|
+
const kwh = e.total.central / 1e3;
|
|
731
|
+
const compare = everyday(kwh, params);
|
|
732
|
+
const top = e.byProject[0];
|
|
733
|
+
const lines = [
|
|
734
|
+
header(params, window),
|
|
735
|
+
"",
|
|
736
|
+
` Electricity ${total.central} (range ${total.range})`,
|
|
737
|
+
` \u2248 ${compare}`,
|
|
738
|
+
"",
|
|
739
|
+
` By model ${shares(e.byModel, e.total.central, shortModel, 3)}`,
|
|
740
|
+
` By type ${shares(e.byType, e.total.central, (k) => TYPE_LABELS[k])}`
|
|
741
|
+
];
|
|
742
|
+
if (top) {
|
|
743
|
+
const t = energy(top.energy);
|
|
744
|
+
lines.push(` Top project ${projectName(top.key)} ${t.central} (range ${t.range})`);
|
|
745
|
+
}
|
|
746
|
+
const span = e.span ? ` \xB7 ${e.span.from} to ${e.span.to}` : "";
|
|
747
|
+
lines.push(` Based on ${int(e.responses)} responses, ${int(totalTokens2(e.tokens))} tokens${span}`);
|
|
748
|
+
for (const w of e.warnings) lines.push(` Note ${w}`);
|
|
749
|
+
lines.push("", ` How this is estimated: ${APP_NAME} --method \xB7 Share: ${APP_NAME} receipt`);
|
|
750
|
+
return lines.join("\n");
|
|
751
|
+
}
|
|
752
|
+
function table(head, rows, alignLeft = 1) {
|
|
753
|
+
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
754
|
+
const line = (cells) => cells.map((c, i) => i < alignLeft ? c.padEnd(widths[i]) : c.padStart(widths[i])).join(" ").trimEnd();
|
|
755
|
+
return [line(head), widths.map((w) => "-".repeat(w)).join(" "), ...rows.map(line)].join("\n");
|
|
756
|
+
}
|
|
757
|
+
var DIM_ROWS = { model: "byModel", type: "byType", day: "byDay", project: "byProject" };
|
|
758
|
+
function breakdown(e, params, window, dim) {
|
|
759
|
+
const rows = e[DIM_ROWS[dim]];
|
|
760
|
+
const unit = energy(e.total).unit;
|
|
761
|
+
const scale = unit === "kWh" ? 1e3 : 1;
|
|
762
|
+
const label = (key) => dim === "type" ? TYPE_LABELS[key] : dim === "project" ? projectName(key) : dim === "model" ? shortModel(key) : key;
|
|
763
|
+
const body = rows.map((r) => [
|
|
764
|
+
label(r.key),
|
|
765
|
+
int(totalTokens2(r.tokens)),
|
|
766
|
+
num(r.energy.central / scale),
|
|
767
|
+
`${num(r.energy.low / scale)} \u2013 ${num(r.energy.high / scale)}`,
|
|
768
|
+
e.total.central > 0 ? `${Math.round(r.energy.central / e.total.central * 100)}%` : "0%"
|
|
769
|
+
]);
|
|
770
|
+
const t = energy(e.total);
|
|
771
|
+
body.push(["total", int(totalTokens2(e.tokens)), num(e.total.central / scale), t.range, "100%"]);
|
|
772
|
+
return [header(params, window), "", table([dim, "tokens", unit, "range", "share"], body)].join("\n");
|
|
773
|
+
}
|
|
774
|
+
function method(params) {
|
|
775
|
+
const lines = [
|
|
776
|
+
`${APP_NAME} method v${params.version} \xB7 parameters as of ${params.as_of} \xB7 ${params.license}`,
|
|
777
|
+
"",
|
|
778
|
+
`Boundary: ${params.boundary}.`,
|
|
779
|
+
`Ranges: ${params.interval}; spreads combine root-sum-of-squares on a log scale.`,
|
|
780
|
+
""
|
|
781
|
+
];
|
|
782
|
+
const rows = Object.entries(params.params).map(([id, p]) => [id, `${p.value}`, `${p.low} \u2013 ${p.high}`, p.unit, p.label]);
|
|
783
|
+
lines.push(table(["parameter", "value", "range", "unit", "label"], rows, 1));
|
|
784
|
+
lines.push("", "Sources:");
|
|
785
|
+
for (const [id, p] of Object.entries(params.params)) lines.push(` ${id}: ${p.source}`);
|
|
786
|
+
lines.push("", "Comparisons (central value only):");
|
|
787
|
+
for (const [id, c] of Object.entries(params.comparisons)) lines.push(` ${id}: ${c.value} ${c.unit} \xB7 ${c.source}`);
|
|
788
|
+
lines.push("", "Full write-up: METHODOLOGY.md");
|
|
789
|
+
return lines.join("\n");
|
|
790
|
+
}
|
|
791
|
+
function json(e, params, window) {
|
|
792
|
+
const rows = (list, dim) => list.map((r) => ({
|
|
793
|
+
key: dim === "project" ? projectName(r.key) : r.key,
|
|
794
|
+
...dim === "project" ? { projectId: r.key.split("#")[1] ?? null } : {},
|
|
795
|
+
wh: r.energy,
|
|
796
|
+
tokens: r.tokens
|
|
797
|
+
}));
|
|
798
|
+
return JSON.stringify(
|
|
799
|
+
{
|
|
800
|
+
app: APP_NAME,
|
|
801
|
+
version: APP_VERSION,
|
|
802
|
+
methodVersion: params.version,
|
|
803
|
+
unit: "Wh",
|
|
804
|
+
window,
|
|
805
|
+
span: e.span,
|
|
806
|
+
responses: e.responses,
|
|
807
|
+
tokens: e.tokens,
|
|
808
|
+
wh: e.total,
|
|
809
|
+
comparisons: comparisons(e.total.central / 1e3, params).map(({ id, amount }) => ({ id, amount })),
|
|
810
|
+
byModel: rows(e.byModel, "model"),
|
|
811
|
+
byType: rows(e.byType, "type"),
|
|
812
|
+
byDay: rows(e.byDay, "day"),
|
|
813
|
+
byProject: rows(e.byProject, "project"),
|
|
814
|
+
warnings: e.warnings
|
|
815
|
+
},
|
|
816
|
+
null,
|
|
817
|
+
2
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
var BARS = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
|
|
821
|
+
function sparkline(values) {
|
|
822
|
+
const max = Math.max(...values, 0);
|
|
823
|
+
return values.map((v) => max === 0 ? BARS[0] : BARS[Math.min(7, Math.round(v / max * 7))]).join("");
|
|
824
|
+
}
|
|
825
|
+
function history(e, params, window, storedDays) {
|
|
826
|
+
if (e.responses === 0) return `${header(params, window)}
|
|
827
|
+
|
|
828
|
+
No history yet. Run ${APP_NAME} once to start recording.`;
|
|
829
|
+
const weeks = weekly(e.byDay);
|
|
830
|
+
const unit = energy(e.total).unit;
|
|
831
|
+
const scale = unit === "kWh" ? 1e3 : 1;
|
|
832
|
+
const rows = weeks.map((w) => [
|
|
833
|
+
`week of ${w.start}`,
|
|
834
|
+
num(w.central / scale),
|
|
835
|
+
`${num(w.low / scale)} \u2013 ${num(w.high / scale)}`,
|
|
836
|
+
"\u2588".repeat(Math.round(w.central / Math.max(...weeks.map((x) => x.central), 1e-9) * 20))
|
|
837
|
+
]);
|
|
838
|
+
const active = weeks.filter((w) => w.central > 0);
|
|
839
|
+
const best = active.reduce((a, b) => b.central < a.central ? b : a, active[0]);
|
|
840
|
+
const worst = active.reduce((a, b) => b.central > a.central ? b : a, active[0]);
|
|
841
|
+
const total = energy(e.total);
|
|
842
|
+
const lines = [
|
|
843
|
+
header(params, window),
|
|
844
|
+
"",
|
|
845
|
+
table(["week", unit, "range", ""], rows),
|
|
846
|
+
"",
|
|
847
|
+
` Trend ${sparkline(weeks.map((w) => w.central))}`,
|
|
848
|
+
` Total ${total.central} (range ${total.range})`,
|
|
849
|
+
` Lightest week ${best.start} ${energy(best).central} (range ${energy(best).range})`,
|
|
850
|
+
` Heaviest week ${worst.start} ${energy(worst).central} (range ${energy(worst).range})`,
|
|
851
|
+
` Recorded ${storedDays} days, kept in your local history even after Claude Code deletes old logs`,
|
|
852
|
+
"",
|
|
853
|
+
` Energy is recalculated from stored token counts with method v${params.version}.`
|
|
854
|
+
];
|
|
855
|
+
return lines.join("\n");
|
|
856
|
+
}
|
|
857
|
+
var csvCell = (v) => typeof v === "number" ? String(v) : /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
|
858
|
+
function historyCsv(rows, params) {
|
|
859
|
+
const head = ["date", "model", "project", "responses", "input", "cache_write", "cache_read", "output", "wh_low", "wh_central", "wh_high", "method_version"];
|
|
860
|
+
const round = (n) => Math.round(n * 1e3) / 1e3;
|
|
861
|
+
const body = rows.map((r) => {
|
|
862
|
+
const wh = energyForCounts(r.tokens, tierFor(r.model, params).tier, params);
|
|
863
|
+
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];
|
|
864
|
+
});
|
|
865
|
+
return [head, ...body].map((cells) => cells.map(csvCell).join(",")).join("\n");
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/index.ts
|
|
869
|
+
var HELP = `Usage: ${APP_NAME} [command] [options]
|
|
870
|
+
|
|
871
|
+
Estimates the electricity your Claude Code usage consumed, as a range.
|
|
872
|
+
Reads local logs only. Nothing leaves your machine.
|
|
873
|
+
|
|
874
|
+
Commands:
|
|
875
|
+
(none) Summary for the time window
|
|
876
|
+
history Weekly trend, all-time total, best and worst weeks
|
|
877
|
+
dashboard Write an offline HTML dashboard of your history and open it
|
|
878
|
+
receipt Share line, LinkedIn post text and a 1200\xD7627 card (SVG)
|
|
879
|
+
|
|
880
|
+
Options:
|
|
881
|
+
--since <when> 7d, 12h, 4w, 2026-10-01 or all (default 30d; history defaults to all)
|
|
882
|
+
--by <dim> Breakdown table: model, day, project or type
|
|
883
|
+
--json Machine-readable output, same numbers, in Wh
|
|
884
|
+
--method Parameters, sources and method version
|
|
885
|
+
--export csv With history: daily rows as CSV on stdout
|
|
886
|
+
--out <file> With receipt: where to write the card (default ./${APP_NAME}-receipt.svg)
|
|
887
|
+
--path <dir> Read logs from this folder instead
|
|
888
|
+
--no-open With dashboard: write the file but don't open it
|
|
889
|
+
--no-history Don't read or update ~/.${APP_NAME}/history.jsonl
|
|
890
|
+
-h, --help Show this help`;
|
|
891
|
+
var DIMS = ["model", "day", "project", "type"];
|
|
892
|
+
var COMMANDS = ["summary", "history", "receipt", "dashboard"];
|
|
893
|
+
async function main() {
|
|
894
|
+
const { values, positionals } = parseArgs({
|
|
895
|
+
allowPositionals: true,
|
|
896
|
+
options: {
|
|
897
|
+
since: { type: "string" },
|
|
898
|
+
by: { type: "string" },
|
|
899
|
+
json: { type: "boolean" },
|
|
900
|
+
method: { type: "boolean" },
|
|
901
|
+
export: { type: "string" },
|
|
902
|
+
out: { type: "string" },
|
|
903
|
+
path: { type: "string" },
|
|
904
|
+
"no-history": { type: "boolean" },
|
|
905
|
+
"no-open": { type: "boolean" },
|
|
906
|
+
help: { type: "boolean", short: "h" }
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
if (values.help) return void console.log(HELP);
|
|
910
|
+
const params = defaultParams;
|
|
911
|
+
if (values.method) return void console.log(method(params));
|
|
912
|
+
const command = positionals[0] ?? "summary";
|
|
913
|
+
if (!COMMANDS.includes(command)) {
|
|
914
|
+
console.error(`Unknown command "${command}".
|
|
915
|
+
|
|
916
|
+
${HELP}`);
|
|
917
|
+
process.exitCode = 1;
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const by = values.by;
|
|
921
|
+
if (by && !DIMS.includes(by)) throw new Error(`--by must be one of: ${DIMS.join(", ")}`);
|
|
922
|
+
if (values.export && values.export !== "csv") throw new Error(`--export supports csv only`);
|
|
923
|
+
const window = parseSince(values.since ?? (command === "history" ? "all" : "30d"), /* @__PURE__ */ new Date());
|
|
924
|
+
const logs = await loadLogs(values.path ? [values.path] : defaultLogRoots());
|
|
925
|
+
let rows = rollup(logs.records);
|
|
926
|
+
if (!values["no-history"]) {
|
|
927
|
+
const stored = await readHistory().catch((error) => {
|
|
928
|
+
console.error(`Couldn't read history (${error instanceof Error ? error.message : String(error)}). Using current logs only.`);
|
|
929
|
+
return [];
|
|
930
|
+
});
|
|
931
|
+
rows = mergeHistory(stored, rows);
|
|
932
|
+
if (logs.records.length > 0) {
|
|
933
|
+
await writeHistory(rows).catch(
|
|
934
|
+
(error) => console.error(`Couldn't save history (${error instanceof Error ? error.message : String(error)}). Continuing without it.`)
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (rows.length === 0) {
|
|
939
|
+
console.log(`No Claude Code logs found. Looked in:
|
|
940
|
+
${logs.roots.map((r) => ` ${r}`).join("\n")}`);
|
|
941
|
+
console.log(`Point ${APP_NAME} at another folder with --path <dir>.`);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (command === "dashboard") {
|
|
945
|
+
const path = await writeDashboard(rows);
|
|
946
|
+
console.log(`Dashboard written to ${path}. It works offline and makes no network requests.`);
|
|
947
|
+
if (!values["no-open"]) openFile(path);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const inWindow = window.start ? rows.filter((r) => r.date >= window.start.slice(0, 10)) : rows;
|
|
951
|
+
const records = window.start && window.label.includes("hour") ? since(logs.records, window.start) : rollupRecords(inWindow);
|
|
952
|
+
const e = estimate(records, params);
|
|
953
|
+
if (!(window.start && window.label.includes("hour"))) {
|
|
954
|
+
e.responses = inWindow.reduce((n, r) => n + r.responses, 0);
|
|
955
|
+
}
|
|
956
|
+
if (command === "history") {
|
|
957
|
+
if (values.export) return void console.log(historyCsv(inWindow, params));
|
|
958
|
+
if (values.json) return void console.log(json(e, params, window));
|
|
959
|
+
return void console.log(history(e, params, window, new Set(rows.map((r) => r.date)).size));
|
|
960
|
+
}
|
|
961
|
+
if (command === "receipt") {
|
|
962
|
+
const input = { estimate: e, params, windowLabel: window.label };
|
|
963
|
+
const out = resolve(values.out ?? `${APP_NAME}-receipt.svg`);
|
|
964
|
+
await writeFile3(out, receiptSvg(input));
|
|
965
|
+
console.log(shareLine(input));
|
|
966
|
+
console.log(`
|
|
967
|
+
Card written to ${out} (1200\xD7627, LinkedIn size). Open ${APP_NAME} dashboard to save it as PNG.`);
|
|
968
|
+
console.log(`
|
|
969
|
+
Ready-to-paste post:
|
|
970
|
+
|
|
971
|
+
${linkedInPost(input)}`);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (values.json) console.log(json(e, params, window));
|
|
975
|
+
else if (by) console.log(breakdown(e, params, window, by));
|
|
976
|
+
else console.log(summary(e, params, window));
|
|
977
|
+
}
|
|
978
|
+
main().catch((error) => {
|
|
979
|
+
console.error(error instanceof Error ? error.message : error);
|
|
980
|
+
process.exitCode = 1;
|
|
981
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccwatts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
}
|