| 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. |
| 28 | import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs"; |
| 29 | import { execSync } from "node:child_process"; |
| 30 | import { join } from "node:path"; |
| 31 | import os from "node:os"; |
| 32 | |
| 33 | const mode = process.argv.includes("check") ? "check" : process.argv.includes("--write") ? "write" : "dry"; |
| 34 | const STALE_DAYS = Number(process.env.LCHAT_CARBON_STALE_DAYS || 3); |
| 35 | |
| 36 | const 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. |
| 41 | const NAMES = ["ada", "grace", "alan", "edsger", "radia", "hedy", "katherine", "margaret", |
| 42 | "linus", "barbara", "donald", "hypatia", "annie", "vint", "niklaus", "frances"]; |
| 43 | const DOINGS = ["stargazing", "whistling", "humming", "sketching", "wandering", "gardening", |
| 44 | "juggling", "sailing", "painting", "daydreaming", "tinkering", "strolling", "fiddling", |
| 45 | "doodling", "pottering", "moseying"]; |
| 46 | function 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 | } |
| 53 | const 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. |
| 57 | const repoRoot = join(git("rev-parse --path-format=absolute --git-common-dir").trim(), ".."); |
| 58 | const 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. |
| 63 | const tallyPath = join(process.cwd(), "web/status/carbon.json"); |
| 64 | if (!existsSync(tallyPath)) { |
| 65 | console.error("missing " + tallyPath + " β run from the repo root"); |
| 66 | process.exit(mode === "check" ? 0 : 1); |
| 67 | } |
| 68 | const 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. |
| 74 | if (!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 | } |
| 84 | const built = tally.built; |
| 85 | let mainEntries = []; |
| 86 | // origin/main may still be flat (pre-nesting) or already nested β handle both. |
| 87 | try { const m = JSON.parse(git("show origin/main:web/status/carbon.json")); mainEntries = (m.built ? m.built.entries : m.entries) || []; } catch {} |
| 88 | const known = [...built.entries, ...mainEntries]; |
| 89 | const cutoff = Math.max(0, ...known.filter((e) => e.source === source).map((e) => Date.parse(e.period_end))); |
| 90 | |
| 91 | if (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 | |
| 106 | const projectsDir = join(os.homedir(), ".claude", "projects"); |
| 107 | const dirs = readdirSync(projectsDir).filter((d) => d.startsWith(sanitized)).map((d) => join(projectsDir, d)); |
| 108 | if (!dirs.length) { |
| 109 | console.error("no transcript dirs under " + projectsDir + " match " + sanitized); |
| 110 | process.exit(1); |
| 111 | } |
| 112 | |
| 113 | const seen = new Set(); |
| 114 | const byModel = new Map(); |
| 115 | let out = 0, fresh = 0, cacheRead = 0, noTs = 0; |
| 116 | let tsMin = Infinity, tsMax = -Infinity; |
| 117 | const contributing = new Set(); |
| 118 | for (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 | |
| 146 | if (out === 0) { |
| 147 | console.error("nothing newer than " + new Date(cutoff).toISOString() + " for " + source + " β tally already up to date"); |
| 148 | process.exit(0); |
| 149 | } |
| 150 | if (noTs) console.error("note: skipped " + noTs + " usage lines without timestamps"); |
| 151 | |
| 152 | const factors = built.factors_mg_co2e_per_output_token; |
| 153 | const kg = (f) => Math.round((out * f) / 1e6 * 10) / 10; // mg β kg, 1 decimal |
| 154 | const 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 | |
| 169 | console.error("source: " + source + " Β· output tokens by model:"); |
| 170 | for (const [model, n] of [...byModel].sort((a, b) => b[1] - a[1])) { |
| 171 | console.error(" " + model + ": " + n.toLocaleString()); |
| 172 | } |
| 173 | console.log(JSON.stringify(entry, null, 2)); |
| 174 | |
| 175 | if (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 | |