eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env node
2// πŸ€– Accrue the public AI-build carbon tally (web/status/carbon.json) from
3// local Claude Code transcripts β€” multi-contributor.
4//
5// The tally is the running estimate of the COβ‚‚e cost of the LLM tokens spent
6// building this app (methodology + error bars: docs/comparison-carbon.md).
7// Claude Code writes per-session JSONL transcripts with exact token usage
8// under ~/.claude/projects/<sanitized-checkout-path>/; those transcripts are
9// PRUNED after roughly a week, so this must run at least weekly on EVERY
10// contributor machine or its tokens are lost to the ledger (an unmeasured gap
11// has to be extrapolated by hand). A SessionStart hook in .claude/settings.json
12// runs `check` so agent sessions get nudged automatically when stale.
13//
14// node tools/carbon-tally.mjs # print the would-be entry (dry run)
15// node tools/carbon-tally.mjs --write # append it + recompute totals
16// node tools/carbon-tally.mjs check # fast staleness nudge (the hook);
17// # prints only when stale, always exit 0
18//
19// Each machine contributes entries under a stable anonymous `source` label (a
20// name-pair hashed from user@host, same style as the fleet's box labels β€” the
21// tally is PUBLIC, and the dashboards' rule is no hostnames, ever; override
22// with LCHAT_CARBON_SOURCE). Cutoffs are per-source: a run counts only usage
23// newer than this source's last entry, consulting BOTH the working tree and
24// origin/main's copy, so re-runs and parallel branches never double-count.
25// Residual race: two unmerged branches accruing the same window β€” the contract
26// test's per-source overlap check catches it at merge; keep tally commits in
27// tiny, promptly-merged PRs.
28import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
29import { execSync } from "node:child_process";
30import { join } from "node:path";
31import os from "node:os";
32
33const mode = process.argv.includes("check") ? "check" : process.argv.includes("--write") ? "write" : "dry";
34const STALE_DAYS = Number(process.env.LCHAT_CARBON_STALE_DAYS || 3);
35
36const git = (args) => execSync("git " + args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
37
38// Stable, public-safe source label for this machine: never a hostname (the
39// dashboards' privacy rule) β€” a name-pair hashed from user@host, recognisable
40// to the contributor (run the tool: it prints yours) and meaningless outside.
41const NAMES = ["ada", "grace", "alan", "edsger", "radia", "hedy", "katherine", "margaret",
42 "linus", "barbara", "donald", "hypatia", "annie", "vint", "niklaus", "frances"];
43const DOINGS = ["stargazing", "whistling", "humming", "sketching", "wandering", "gardening",
44 "juggling", "sailing", "painting", "daydreaming", "tinkering", "strolling", "fiddling",
45 "doodling", "pottering", "moseying"];
46function sourceLabel() {
47 if (process.env.LCHAT_CARBON_SOURCE) return process.env.LCHAT_CARBON_SOURCE;
48 const seed = os.userInfo().username + "@" + os.hostname().split(".")[0];
49 let h = 2166136261; // FNV-1a
50 for (const c of seed) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619) >>> 0; }
51 return NAMES[h % NAMES.length] + "-" + DOINGS[(h >>> 4) % DOINGS.length];
52}
53const source = sourceLabel();
54
55// The main checkout's path names the transcript dir β€” resolve it even when
56// run from a worktree, and prefix-match so worktree sessions count too.
57const repoRoot = join(git("rev-parse --path-format=absolute --git-common-dir").trim(), "..");
58const sanitized = repoRoot.replace(/[^a-zA-Z0-9]/g, "-");
59
60// The tally in the checkout this script runs from is the one appended to; the
61// per-source cutoff also consults origin/main's copy, so a fresh branch can't
62// re-count a window another branch already merged.
63const tallyPath = join(process.cwd(), "web/status/carbon.json");
64if (!existsSync(tallyPath)) {
65 console.error("missing " + tallyPath + " β€” run from the repo root");
66 process.exit(mode === "check" ? 0 : 1);
67}
68const tally = JSON.parse(readFileSync(tallyPath, "utf8"));
69// The published carbon.json is { updated_at, methodology, built:{…}, run:{…} }:
70// `built` is this ledger (accrued here), `run` is a live operational block the
71// status publisher injects at upload (never written by this tool). We only ever
72// touch `built`. A pre-nesting flat tally is migrated in place so old checkouts
73// still accrue cleanly.
74if (!tally.built) {
75 tally.built = {
76 unit: tally.unit,
77 factors_mg_co2e_per_output_token: tally.factors_mg_co2e_per_output_token,
78 not_counted: tally.not_counted,
79 entries: tally.entries || [],
80 totals: tally.totals,
81 };
82 for (const k of ["unit", "factors_mg_co2e_per_output_token", "not_counted", "entries", "totals"]) delete tally[k];
83}
84const built = tally.built;
85let mainEntries = [];
86// origin/main may still be flat (pre-nesting) or already nested β€” handle both.
87try { const m = JSON.parse(git("show origin/main:web/status/carbon.json")); mainEntries = (m.built ? m.built.entries : m.entries) || []; } catch {}
88const known = [...built.entries, ...mainEntries];
89const cutoff = Math.max(0, ...known.filter((e) => e.source === source).map((e) => Date.parse(e.period_end)));
90
91if (mode === "check") {
92 // Fast path for the SessionStart hook: no transcript sweep, print only when
93 // stale (stdout lands in the agent's context), never fail the session.
94 const days = cutoff ? (Date.now() - cutoff) / 86400e3 : Infinity;
95 if (days > STALE_DAYS) {
96 const age = cutoff ? "last entry from this machine (" + source + ") is " + Math.floor(days) + " days old"
97 : "no entries yet from this machine (" + source + ")";
98 console.log("Carbon tally is stale: " + age + ". Local transcripts prune after ~a week, so, " +
99 "when convenient this session, accrue it: run `node tools/carbon-tally.mjs --write`, commit " +
100 "web/status/carbon.json (a tiny standalone PR, merged promptly), and copy it to the router box's " +
101 "status-pages dir (docs/status.md Β§ Keeping the carbon tally accruing).");
102 }
103 process.exit(0);
104}
105
106const projectsDir = join(os.homedir(), ".claude", "projects");
107const dirs = readdirSync(projectsDir).filter((d) => d.startsWith(sanitized)).map((d) => join(projectsDir, d));
108if (!dirs.length) {
109 console.error("no transcript dirs under " + projectsDir + " match " + sanitized);
110 process.exit(1);
111}
112
113const seen = new Set();
114const byModel = new Map();
115let out = 0, fresh = 0, cacheRead = 0, noTs = 0;
116let tsMin = Infinity, tsMax = -Infinity;
117const contributing = new Set();
118for (const dir of dirs) {
119 for (const f of readdirSync(dir).filter((n) => n.endsWith(".jsonl"))) {
120 const path = join(dir, f);
121 for (const line of readFileSync(path, "utf8").split("\n")) {
122 if (!line) continue;
123 let j;
124 try { j = JSON.parse(line); } catch { continue; }
125 const m = j.message;
126 const u = m && m.usage;
127 if (!u) continue;
128 const ts = Date.parse(j.timestamp || "");
129 if (Number.isNaN(ts)) { noTs++; continue; } // dateless usage can't be windowed β€” skipped, reported below
130 if (ts <= cutoff) continue;
131 const key = (m.id || "") + "/" + (j.requestId || "");
132 if (key !== "/" && seen.has(key)) continue;
133 seen.add(key);
134 contributing.add(path);
135 tsMin = Math.min(tsMin, ts);
136 tsMax = Math.max(tsMax, ts);
137 out += u.output_tokens || 0;
138 fresh += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0);
139 cacheRead += u.cache_read_input_tokens || 0;
140 const model = m.model || "unknown";
141 byModel.set(model, (byModel.get(model) || 0) + (u.output_tokens || 0));
142 }
143 }
144}
145
146if (out === 0) {
147 console.error("nothing newer than " + new Date(cutoff).toISOString() + " for " + source + " β€” tally already up to date");
148 process.exit(0);
149}
150if (noTs) console.error("note: skipped " + noTs + " usage lines without timestamps");
151
152const factors = built.factors_mg_co2e_per_output_token;
153const kg = (f) => Math.round((out * f) / 1e6 * 10) / 10; // mg β†’ kg, 1 decimal
154const entry = {
155 period_start: new Date(tsMin).toISOString(),
156 period_end: new Date(tsMax).toISOString(),
157 source,
158 basis: "measured",
159 sessions: contributing.size,
160 output_tokens: out,
161 fresh_input_tokens: fresh,
162 cache_read_tokens: cacheRead,
163 kg_low: kg(factors.low),
164 kg_central: kg(factors.central),
165 kg_high: kg(factors.high),
166 note: "local Claude Code transcripts, one dev machine",
167};
168
169console.error("source: " + source + " Β· output tokens by model:");
170for (const [model, n] of [...byModel].sort((a, b) => b[1] - a[1])) {
171 console.error(" " + model + ": " + n.toLocaleString());
172}
173console.log(JSON.stringify(entry, null, 2));
174
175if (mode === "write") {
176 built.entries.push(entry);
177 const sum = (pick) => Math.round(built.entries.reduce((a, e) => a + pick(e), 0) * 10) / 10;
178 built.totals = {
179 output_tokens: built.entries.reduce((a, e) => a + e.output_tokens, 0),
180 kg_low: sum((e) => e.kg_low),
181 kg_central: sum((e) => e.kg_central),
182 kg_high: sum((e) => e.kg_high),
183 };
184 tally.updated_at = new Date(Math.max(...built.entries.map((e) => Date.parse(e.period_end)))).toISOString();
185 writeFileSync(tallyPath, JSON.stringify(tally, null, 2) + "\n");
186 console.error("appended to " + tallyPath + " β€” built totals now " + built.totals.kg_central + " kg central (" +
187 built.totals.kg_low + "–" + built.totals.kg_high + ")");
188} else {
189 console.error("dry run β€” pass --write to append");
190}
191