#!/usr/bin/env node // ๐Ÿค– Accrue the public AI-build carbon tally (web/status/carbon.json) from // local Claude Code transcripts โ€” multi-contributor. // // The tally is the running estimate of the COโ‚‚e cost of the LLM tokens spent // building this app (methodology + error bars: docs/comparison-carbon.md). // Claude Code writes per-session JSONL transcripts with exact token usage // under ~/.claude/projects//; those transcripts are // PRUNED after roughly a week, so this must run at least weekly on EVERY // contributor machine or its tokens are lost to the ledger (an unmeasured gap // has to be extrapolated by hand). A SessionStart hook in .claude/settings.json // runs `check` so agent sessions get nudged automatically when stale. // // node tools/carbon-tally.mjs # print the would-be entry (dry run) // node tools/carbon-tally.mjs --write # append it + recompute totals // node tools/carbon-tally.mjs check # fast staleness nudge (the hook); // # prints only when stale, always exit 0 // // Each machine contributes entries under a stable anonymous `source` label (a // name-pair hashed from user@host, same style as the fleet's box labels โ€” the // tally is PUBLIC, and the dashboards' rule is no hostnames, ever; override // with LCHAT_CARBON_SOURCE). Cutoffs are per-source: a run counts only usage // newer than this source's last entry, consulting BOTH the working tree and // origin/main's copy, so re-runs and parallel branches never double-count. // Residual race: two unmerged branches accruing the same window โ€” the contract // test's per-source overlap check catches it at merge; keep tally commits in // tiny, promptly-merged PRs. import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs"; import { execSync } from "node:child_process"; import { join } from "node:path"; import os from "node:os"; const mode = process.argv.includes("check") ? "check" : process.argv.includes("--write") ? "write" : "dry"; const STALE_DAYS = Number(process.env.LCHAT_CARBON_STALE_DAYS || 3); const git = (args) => execSync("git " + args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); // Stable, public-safe source label for this machine: never a hostname (the // dashboards' privacy rule) โ€” a name-pair hashed from user@host, recognisable // to the contributor (run the tool: it prints yours) and meaningless outside. const NAMES = ["ada", "grace", "alan", "edsger", "radia", "hedy", "katherine", "margaret", "linus", "barbara", "donald", "hypatia", "annie", "vint", "niklaus", "frances"]; const DOINGS = ["stargazing", "whistling", "humming", "sketching", "wandering", "gardening", "juggling", "sailing", "painting", "daydreaming", "tinkering", "strolling", "fiddling", "doodling", "pottering", "moseying"]; function sourceLabel() { if (process.env.LCHAT_CARBON_SOURCE) return process.env.LCHAT_CARBON_SOURCE; const seed = os.userInfo().username + "@" + os.hostname().split(".")[0]; let h = 2166136261; // FNV-1a for (const c of seed) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619) >>> 0; } return NAMES[h % NAMES.length] + "-" + DOINGS[(h >>> 4) % DOINGS.length]; } const source = sourceLabel(); // The main checkout's path names the transcript dir โ€” resolve it even when // run from a worktree, and prefix-match so worktree sessions count too. const repoRoot = join(git("rev-parse --path-format=absolute --git-common-dir").trim(), ".."); const sanitized = repoRoot.replace(/[^a-zA-Z0-9]/g, "-"); // The tally in the checkout this script runs from is the one appended to; the // per-source cutoff also consults origin/main's copy, so a fresh branch can't // re-count a window another branch already merged. const tallyPath = join(process.cwd(), "web/status/carbon.json"); if (!existsSync(tallyPath)) { console.error("missing " + tallyPath + " โ€” run from the repo root"); process.exit(mode === "check" ? 0 : 1); } const tally = JSON.parse(readFileSync(tallyPath, "utf8")); // The published carbon.json is { updated_at, methodology, built:{โ€ฆ}, run:{โ€ฆ} }: // `built` is this ledger (accrued here), `run` is a live operational block the // status publisher injects at upload (never written by this tool). We only ever // touch `built`. A pre-nesting flat tally is migrated in place so old checkouts // still accrue cleanly. if (!tally.built) { tally.built = { unit: tally.unit, factors_mg_co2e_per_output_token: tally.factors_mg_co2e_per_output_token, not_counted: tally.not_counted, entries: tally.entries || [], totals: tally.totals, }; for (const k of ["unit", "factors_mg_co2e_per_output_token", "not_counted", "entries", "totals"]) delete tally[k]; } const built = tally.built; let mainEntries = []; // origin/main may still be flat (pre-nesting) or already nested โ€” handle both. try { const m = JSON.parse(git("show origin/main:web/status/carbon.json")); mainEntries = (m.built ? m.built.entries : m.entries) || []; } catch {} const known = [...built.entries, ...mainEntries]; const cutoff = Math.max(0, ...known.filter((e) => e.source === source).map((e) => Date.parse(e.period_end))); if (mode === "check") { // Fast path for the SessionStart hook: no transcript sweep, print only when // stale (stdout lands in the agent's context), never fail the session. const days = cutoff ? (Date.now() - cutoff) / 86400e3 : Infinity; if (days > STALE_DAYS) { const age = cutoff ? "last entry from this machine (" + source + ") is " + Math.floor(days) + " days old" : "no entries yet from this machine (" + source + ")"; console.log("Carbon tally is stale: " + age + ". Local transcripts prune after ~a week, so, " + "when convenient this session, accrue it: run `node tools/carbon-tally.mjs --write`, commit " + "web/status/carbon.json (a tiny standalone PR, merged promptly), and copy it to the router box's " + "status-pages dir (docs/status.md ยง Keeping the carbon tally accruing)."); } process.exit(0); } const projectsDir = join(os.homedir(), ".claude", "projects"); const dirs = readdirSync(projectsDir).filter((d) => d.startsWith(sanitized)).map((d) => join(projectsDir, d)); if (!dirs.length) { console.error("no transcript dirs under " + projectsDir + " match " + sanitized); process.exit(1); } const seen = new Set(); const byModel = new Map(); let out = 0, fresh = 0, cacheRead = 0, noTs = 0; let tsMin = Infinity, tsMax = -Infinity; const contributing = new Set(); for (const dir of dirs) { for (const f of readdirSync(dir).filter((n) => n.endsWith(".jsonl"))) { const path = join(dir, f); for (const line of readFileSync(path, "utf8").split("\n")) { if (!line) continue; let j; try { j = JSON.parse(line); } catch { continue; } const m = j.message; const u = m && m.usage; if (!u) continue; const ts = Date.parse(j.timestamp || ""); if (Number.isNaN(ts)) { noTs++; continue; } // dateless usage can't be windowed โ€” skipped, reported below if (ts <= cutoff) continue; const key = (m.id || "") + "/" + (j.requestId || ""); if (key !== "/" && seen.has(key)) continue; seen.add(key); contributing.add(path); tsMin = Math.min(tsMin, ts); tsMax = Math.max(tsMax, ts); out += u.output_tokens || 0; fresh += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0); cacheRead += u.cache_read_input_tokens || 0; const model = m.model || "unknown"; byModel.set(model, (byModel.get(model) || 0) + (u.output_tokens || 0)); } } } if (out === 0) { console.error("nothing newer than " + new Date(cutoff).toISOString() + " for " + source + " โ€” tally already up to date"); process.exit(0); } if (noTs) console.error("note: skipped " + noTs + " usage lines without timestamps"); const factors = built.factors_mg_co2e_per_output_token; const kg = (f) => Math.round((out * f) / 1e6 * 10) / 10; // mg โ†’ kg, 1 decimal const entry = { period_start: new Date(tsMin).toISOString(), period_end: new Date(tsMax).toISOString(), source, basis: "measured", sessions: contributing.size, output_tokens: out, fresh_input_tokens: fresh, cache_read_tokens: cacheRead, kg_low: kg(factors.low), kg_central: kg(factors.central), kg_high: kg(factors.high), note: "local Claude Code transcripts, one dev machine", }; console.error("source: " + source + " ยท output tokens by model:"); for (const [model, n] of [...byModel].sort((a, b) => b[1] - a[1])) { console.error(" " + model + ": " + n.toLocaleString()); } console.log(JSON.stringify(entry, null, 2)); if (mode === "write") { built.entries.push(entry); const sum = (pick) => Math.round(built.entries.reduce((a, e) => a + pick(e), 0) * 10) / 10; built.totals = { output_tokens: built.entries.reduce((a, e) => a + e.output_tokens, 0), kg_low: sum((e) => e.kg_low), kg_central: sum((e) => e.kg_central), kg_high: sum((e) => e.kg_high), }; tally.updated_at = new Date(Math.max(...built.entries.map((e) => Date.parse(e.period_end)))).toISOString(); writeFileSync(tallyPath, JSON.stringify(tally, null, 2) + "\n"); console.error("appended to " + tallyPath + " โ€” built totals now " + built.totals.kg_central + " kg central (" + built.totals.kg_low + "โ€“" + built.totals.kg_high + ")"); } else { console.error("dry run โ€” pass --write to append"); }