AI-build carbon tally: running ledger + public status page with accrual chart (#734)
* docs+status: AI-build carbon tally — running ledger, public dashboard page, weekly accrual The carbon comparison doc's honesty section said "AI inference isn't counted"; now the AI cost of BUILDING the app is. New section in docs/comparison-carbon.md: exact token counts from local Claude Code transcripts (~34M output tokens so far, first week extrapolated commit-weighted after pruning), converted at 0.1–3 mg CO2e per output token (Google/Mistral public calibration points, geometric-mean central 0.55) → ~3–100 kg, central ~19 kg — like driving a petrol car ~110 km, or ~9,000 member-years of actually running the service. The number accrues, so it ships as a running tally: - web/status/carbon.json — the committed ledger (per-period entries + factors + totals), seeded with the first measurement. - web/status/carbon.html — third public dashboard at status.elevenmessenger.com: cumulative chart over time (central line + low–high wash, crosshair + keyboard-focusable points), stat tiles, everyday equivalents, per-period ledger. - tools/carbon-tally.mjs — sweeps transcripts (worktree sessions included), counts only usage newer than the last entry (re-runs never double-count), --write appends + recomputes totals. - deploy/status-publish.sh republishes the committed tally with the pages; contract test pins the page fields AND the ledger arithmetic (totals = sum, kg = tokens × factors, periods non-overlapping). - Keep-it-accruing notes: CLAUDE.md convention (weekly — transcripts prune!) + docs/status.md recipe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * status: carbon tally goes multi-contributor + self-nudging Every contributor machine now accrues its own entries under a stable anonymous source label (name-pair hashed from user@host — the tally is public, so no hostnames, same rule as the fleet's box labels; override with LCHAT_CARBON_SOURCE). Cutoffs are per-source and consult both the working tree and origin/main, so re-runs, other machines, and parallel branches never double-count; the one blind spot (two open branches accruing the same window) is caught by the contract test's per-source overlap check — keep tally commits in tiny promptly-merged PRs. Automation: a SessionStart hook in .claude/settings.json runs the new fast `carbon-tally.mjs check` (no transcript sweep, ~50ms) and tells the session to accrue when this machine's entries are >5 days old — the transcripts it measures from prune after about a week, so the nudge is what keeps the ledger gapless. Page: ledger rows and the chart tooltip carry the source; entries render in time order even when machines interleave. Docs + CLAUDE.md convention updated for the automated flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
11 files changed,
+791
−12
.claude/settings.json+14 −0CLAUDE.md+15 −0deploy/status-publish.sh+11 −2docs/comparison-carbon.md+64 −3docs/status.md+54 −3test/status-contract.test.mjs+49 −2tools/carbon-tally.mjs+173 −0web/status/books.html+1 −1web/status/carbon.html+362 −0web/status/carbon.json+47 −0web/status/index.html+1 −1
diff --git a/.claude/settings.json b/.claude/settings.json| index d0c4ced..d3c6c30 100644 |
| --- a/.claude/settings.json |
| +++ b/.claude/settings.json |
| @@ -1,5 +1,19 @@ |
| { |
| "enabledPlugins": { |
| "stripe@claude-plugins-official": true |
| + }, |
| + "hooks": { |
| + "SessionStart": [ |
| + { |
| + "hooks": [ |
| + { |
| + "type": "command", |
| + "command": "node tools/carbon-tally.mjs check 2>/dev/null || true", |
| + "timeout": 15, |
| + "statusMessage": "Checking carbon tally freshness" |
| + } |
| + ] |
| + } |
| + ] |
| } |
| } |
diff --git a/CLAUDE.md b/CLAUDE.md| index 11c4875..ca8c231 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -313,6 +313,21 @@ open with the note itself) carry the 🤖 on their first line. |
| hand-measured and drift every release, so re-measure and update them (and the |
| "as of" date) every so often — a quick `git ls-files '*.go' …| wc -l` for |
| lines, and `du -h` on the built binaries / `.app` bundles for sizes. |
| +- 🤖 **Keep the carbon tally accruing — act on the session nudge.** |
| + `web/status/carbon.json` is the public running estimate of the AI tokens |
| + spent building this app, per contributor machine (served at |
| + status.elevenmessenger.com/carbon.html; methodology |
| + `docs/comparison-carbon.md`). A `SessionStart` hook runs |
| + `node tools/carbon-tally.mjs check` and tells the session when this |
| + machine's entries are stale (>5 days) — its raw material, local Claude Code |
| + transcripts, prunes after about a week, so a missed week is lost to the |
| + ledger. When nudged (or before starting long work): run |
| + `node tools/carbon-tally.mjs --write` (per-source cutoffs — re-runs and |
| + other machines never double-count), commit the tally as a **tiny standalone |
| + PR merged promptly** (two open branches accruing the same window is the one |
| + race the cutoffs can't see; the contract test catches it on main), and copy |
| + it to the router box's status-pages dir (`docs/status.md § Keeping the |
| + carbon tally accruing`). |
| ## 🤖 Cross-platform event contract (web ↔ native) |
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh| index b538957..e50556a 100755 |
| --- a/deploy/status-publish.sh |
| +++ b/deploy/status-publish.sh |
| @@ -39,6 +39,11 @@ |
| # lines, or timestamps of individual payments. The Stripe key is read for |
| # the API calls and must never be echoed, logged, or uploaded. |
| # |
| +# carbon.json — NOT generated here: it's COMMITTED data (the AI-build carbon |
| +# tally, web/status/carbon.json, accrued by tools/carbon-tally.mjs), |
| +# staged with the pages and republished as-is. Aggregate token counts and |
| +# kg estimates only — never per-session data. |
| +# |
| # Adding a field? It must be an aggregate over the whole fleet / all payers, |
| # or it doesn't ship. |
| # ============================================================================== |
| @@ -64,7 +69,8 @@ |
| # (infra/prod/status.tf; Put/Get/List on the status |
| # bucket only, no Delete). chown lchat, chmod 0600. |
| # /opt/lchat/status-pages/ — the viewer pages, copied from web/status/ |
| -# (index.html, books.html, icon-192.png). |
| +# (index.html, books.html, carbon.html, carbon.json, |
| +# icon-192.png). |
| # aws CLI + sqlite3 (apt install awscli sqlite3). |
| set -euo pipefail |
| @@ -553,11 +559,14 @@ publish() { |
| if [ -d "$PAGES_DIR" ]; then |
| [ -f "$PAGES_DIR/index.html" ] && upload "$PAGES_DIR/index.html" index.html "text/html; charset=utf-8" "max-age=300" |
| [ -f "$PAGES_DIR/books.html" ] && upload "$PAGES_DIR/books.html" books.html "text/html; charset=utf-8" "max-age=300" |
| + [ -f "$PAGES_DIR/carbon.html" ] && upload "$PAGES_DIR/carbon.html" carbon.html "text/html; charset=utf-8" "max-age=300" |
| + # The carbon tally is committed data (see header) — republished, not built. |
| + [ -f "$PAGES_DIR/carbon.json" ] && upload "$PAGES_DIR/carbon.json" carbon.json "application/json" "max-age=300" |
| [ -f "$PAGES_DIR/icon-192.png" ] && upload "$PAGES_DIR/icon-192.png" icon-192.png "image/png" "max-age=86400" |
| else |
| echo "warning: $PAGES_DIR missing — JSONs published, pages skipped (copy web/status/* there)" >&2 |
| fi |
| - echo "published to s3://$BUCKET (fleet + books, $(now_iso))" |
| + echo "published to s3://$BUCKET (fleet + books + pages, $(now_iso))" |
| } |
| # ---- install (root) ---------------------------------------------------------- |
diff --git a/docs/comparison-carbon.md b/docs/comparison-carbon.md| index 6c42214..8b85601 100644 |
| --- a/docs/comparison-carbon.md |
| +++ b/docs/comparison-carbon.md |
| @@ -136,9 +136,12 @@ Honesty section, in the spirit of the siblings: |
| market-based-zero renewable contract versus this stack on a coal grid |
| inverts the operational numbers. Embodied carbon — ~10 servers versus |
| ~500 — favors the small fleet regardless of who signs which PPA. |
| -- **AI inference isn't counted.** Heavy LLM use by the bot sidecars would |
| - dwarf either infrastructure's footprint — and applies to both models |
| - equally, so it cancels out of the ratio and dominates the absolutes. |
| +- **Runtime AI inference isn't counted.** Heavy LLM use by the bot sidecars |
| + would dwarf either infrastructure's footprint — and applies to both models |
| + equally, so it cancels out of the ratio and dominates the absolutes. (It is |
| + at least *measurable* exactly: the sidecars run on an API key, so the |
| + provider console has precise token counts.) The AI cost of *building* the |
| + app, though, is now estimated — see the next section. |
| - **Client-side work moves joules, it doesn't create many.** Crypto and |
| image downscaling run on members' devices; the marginal energy per |
| message on a phone is micro-joules against a battery already being |
| @@ -148,6 +151,58 @@ Honesty section, in the spirit of the siblings: |
| the gap further; this comparison deliberately gives Rails the benefit of |
| doing only what Eleven does. |
| +## What the AI that built it cost |
| + |
| +The estimates above stop at the servers. But this codebase is AI-written, |
| +and the honest ledger has to include the tokens it took to write it — which |
| +turn out to dwarf, by orders of magnitude, the infrastructure they produced |
| +(at today's scale; at the 1M-space scale above, the fleet takes over). |
| + |
| +**The tokens are measurable.** Claude Code keeps per-session transcripts |
| +with exact token counts. Summed over every session against this repo on the |
| +primary dev machine (the first measurement, 2026-07-03 → 2026-07-11, 162 |
| +sessions — transcripts from the repo's first week, 2026-06-27 → 07-02, had |
| +already been auto-pruned, so that week is extrapolated from the measured |
| +window's per-commit rate): |
| + |
| +| | tokens | |
| +|---|---| |
| +| Output (generated text + code) | **~25M measured + ~9M extrapolated ≈ 34M** | |
| +| Fresh input (prompt prefill) | ~145M measured | |
| +| Cache reads | ~7.9B measured | |
| + |
| +**The conversion is the soft part.** No frontier lab publishes per-token |
| +energy or carbon. The best public calibration points disagree by ~30×: |
| +Google's production study of Gemini serving works out to roughly 0.1 mg |
| +CO₂e per output token, while Mistral's lifecycle study for Large 2 implies |
| +~2.9 mg. We band with **0.1–3 mg CO₂e per output token, central 0.55 mg** |
| +(the geometric mean), priced on output tokens only — decode dominates |
| +serving energy, and nobody publishes a price for cached prefill, which is |
| +~97% of tokens processed here. That un-priced cache-read pile, plus |
| +cloud-side agent runs and any contributor machine that hasn't accrued its |
| +entries yet, make this a floor. |
| + |
| +**Result so far: ~3–100 kg CO₂e, central ~19 kg** — like driving a petrol |
| +car ~110 km, or ~8 litres of petrol. Set against the fleet's ~2 g per member |
| +per year, the central estimate equals roughly **9,000 member-years of |
| +actually running the service**. E2EE keeps the *servers* from burning watts |
| +on members' content; the watts moved upstream, into the model that wrote |
| +the code. |
| + |
| +**This number accrues, so it's kept as a running tally**: the ledger lives |
| +in [`web/status/carbon.json`](../web/status/carbon.json) and publishes with |
| +the public dashboards at |
| +[status.elevenmessenger.com/carbon.html](https://status.elevenmessenger.com/carbon.html) |
| +(cumulative chart + per-period ledger; `docs/status.md`). It covers **every |
| +contributor machine**, each accruing entries under an anonymous stable |
| +`source` label, and **keeping it up to date is automated**: a `SessionStart` |
| +hook nudges any Claude session in this repo when its machine's entries go |
| +stale (the transcripts it measures from are pruned after about a week), and |
| +`node tools/carbon-tally.mjs --write` appends only what's newer than that |
| +machine's last entry — recipe in `docs/status.md § Keeping the carbon tally |
| +accruing`. A gap that slips past the pruning window gets an |
| +explicitly-flagged `extrapolated` entry, like the first week did. |
| + |
| ## The one-sentence version |
| **~2 g versus ~75 g CO₂e per member per year — the difference between an |
| @@ -162,9 +217,15 @@ provisioning forever.** |
| - [Datadog Container Report — utilization findings](https://www.datadoghq.com/container-report/) |
| - [AWS sustainability — fleet PUE](https://sustainability.aboutamazon.com/products-services/aws-cloud) |
| - [IEA — global electricity carbon intensity](https://www.iea.org/energy-system/electricity) |
| +- [Google Cloud — measuring the environmental impact of AI inference](https://cloud.google.com/blog/products/infrastructure/measuring-the-environmental-impact-of-ai-inference) |
| +- [Mistral AI — lifecycle analysis of Large 2](https://mistral.ai/news/our-contribution-to-a-global-environmental-standard-for-ai) |
| +- [Epoch AI — how much energy does ChatGPT use?](https://epoch.ai/gradient-updates/how-much-energy-does-chatgpt-use) |
| +- [EcoLogits — provider-side LLM impact methodology](https://ecologits.ai/) |
| - Live fleet measurements: production router box, 2026-07-11 (per-unit |
| RSS via systemd; 15 warm instances + 4 services + 5 sidecars ≈ 550 MB, |
| load 0.21 on 2 ARM vCPUs) |
| +- Build-token measurements: local Claude Code transcripts, swept by |
| + `tools/carbon-tally.mjs` into `web/status/carbon.json` (the running tally) |
| - The plan: [#550 — planet-scale over S3](https://github.com/elevenmessenger/messenger/issues/550) |
| *Snapshot as of July 2026 — coefficients and grids move; re-measure before |
diff --git a/docs/status.md b/docs/status.md| index 4727c90..dbca0a4 100644 |
| --- a/docs/status.md |
| +++ b/docs/status.md |
| @@ -1,6 +1,6 @@ |
| -# 🤖 Public status dashboards — fleet state & the books |
| +# 🤖 Public status dashboards — fleet state, the books & the carbon tally |
| -Two world-readable pages, published every 5 minutes from the router box: the |
| +Three world-readable pages, published every 5 minutes from the router box: the |
| running half of #550 Part 11/12's transparency direction (the append-only |
| public *ledger* repo is still roadmap — see `docs/planet-scale.md`). |
| @@ -13,6 +13,13 @@ public *ledger* repo is still roadmap — see `docs/planet-scale.md`). |
| - **The books** (`books.html` + `books.json`) — income, in aggregate: active |
| paid subscriptions (total and by tier — tier names are the public price |
| book), MRR in EUR, and gross collected this month. |
| +- **The carbon tally** (`carbon.html` + `carbon.json`) — the running estimate |
| + of the CO₂e cost of the LLM tokens spent *building* Eleven, as a cumulative |
| + chart + ledger with everyday equivalents. Unlike the other two feeds, the |
| + JSON is **committed data** (`web/status/carbon.json`, accrued by |
| + `tools/carbon-tally.mjs` — see *Keeping the carbon tally accruing* below); |
| + the box timer just republishes it with the pages. Methodology + error bars: |
| + `docs/comparison-carbon.md`. |
| Pieces: generator `deploy/status-publish.sh` (box timer, mirrors |
| `deploy/fleet-backup.sh`), viewer pages `web/status/`, bucket + IAM |
| @@ -41,6 +48,9 @@ aggregation boundary lives in the generator and holds by construction: |
| customer ids, emails, subscription ids, per-payer lines, or timestamps of |
| individual payments. Expenses/disbursements publish *itemized* — but those |
| belong to the future ledger repo, not these dashboards. |
| +- **Carbon: aggregate token counts and kg estimates only.** Never per-session |
| + data, never prompt or transcript content — the tally reads token *usage |
| + numbers* from local transcripts and publishes only their sums. |
| - Adding a field? It must be an aggregate over the whole fleet / all payers, |
| or it doesn't ship. The `--dry-run` self-check and the contract test both |
| fail if a fixture hostname or per-payer id reaches the output. |
| @@ -105,6 +115,47 @@ to a month (yearly ÷ 12, etc.), EUR items only. Collected-this-month is the |
| gross of positive `charge`/`payment` balance transactions since the month |
| started (refunds and payouts excluded — it's gross income, not net cash). |
| +`carbon.json` (committed at `web/status/carbon.json`, republished as-is): an |
| +`entries[]` ledger of measurement periods — `period_start`/`period_end`, |
| +`source` (which contributor machine, as an anonymous name-pair label — same |
| +no-hostnames rule as the fleet's box labels), `basis` |
| +(`measured` | `extrapolated`), `sessions`, `output_tokens`, |
| +`fresh_input_tokens`, `cache_read_tokens`, `kg_low`/`kg_central`/`kg_high` — |
| +plus the conversion `factors_mg_co2e_per_output_token`, a `totals` block, and |
| +a `not_counted` honesty string. The contract test pins the arithmetic: totals |
| +must equal the ledger sum, each entry's kg must derive from its tokens × the |
| +factors, and each source's periods must be chronological and non-overlapping. |
| + |
| +## Keeping the carbon tally accruing |
| + |
| +The tally's raw material is the local Claude Code transcripts on **each |
| +contributor's machine**, and those are **pruned after roughly a week** — a |
| +missed week is lost to the ledger and has to be extrapolated by hand (like |
| +the tally's first entry). Every contributor machine accrues its own entries |
| +under a stable anonymous `source` label (hashed from user@host; the tool |
| +prints yours; override with `LCHAT_CARBON_SOURCE`). |
| + |
| +**The nudge is automatic.** A `SessionStart` hook in `.claude/settings.json` |
| +runs `node tools/carbon-tally.mjs check` (fast — no transcript sweep): when |
| +this machine's newest entry is older than 5 days (`LCHAT_CARBON_STALE_DAYS`), |
| +the session is told to accrue. The accrual itself: |
| + |
| +1. `node tools/carbon-tally.mjs --write` (from the repo root) — sweeps this |
| + machine's transcripts and appends a `measured` entry. Cutoffs are |
| + **per-source** and consult both the working tree and `origin/main`, so |
| + repeated runs and parallel machines never double-count. |
| +2. Commit the updated `web/status/carbon.json` — prefer a tiny standalone PR, |
| + merged promptly: two long-lived branches accruing the same window is the |
| + one race the cutoffs can't see, and the contract test fails main on the |
| + overlap (fix: drop the younger duplicate entry, re-run `--write`). |
| +3. Copy `web/status/carbon.json` to the box's `/opt/lchat/status-pages/` (with |
| + `carbon.html` if the page changed); the next 5-minute publish uploads it. |
| + |
| +The conversion factors live in the JSON so a recalibration (better public |
| +data) is one committed change; if the factors change, recompute the whole |
| +ledger's kg columns rather than mixing eras (`docs/comparison-carbon.md` |
| +carries the sources and the "as of" date). |
| + |
| ## Install (router box) |
| 1. **Apply the infra** (deliberate, named — not yet applied): |
| @@ -114,7 +165,7 @@ started (refunds and payouts excluded — it's gross income, not net cash). |
| 2. **Creds on the box**: write `/data/status.env` with |
| `AWS_ACCESS_KEY_ID=…` / `AWS_SECRET_ACCESS_KEY=…`; `chown lchat:lchat`, |
| `chmod 0600`. |
| -3. **Pages on the box**: `scp web/status/{index.html,books.html,icon-192.png} |
| +3. **Pages on the box**: `scp web/status/{index.html,books.html,carbon.html,carbon.json,icon-192.png} |
| root@<box>:/opt/lchat/status-pages/`. |
| 4. **Install the timer**: `scp deploy/status-publish.sh root@<box>:/opt/lchat/` |
| then `ssh root@<box> /opt/lchat/status-publish.sh install`. The unit runs |
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs| index e6b3470..3137ac3 100644 |
| --- a/test/status-contract.test.mjs |
| +++ b/test/status-contract.test.mjs |
| @@ -57,6 +57,10 @@ function resolve(obj, path) { |
| const indexHtml = read("web/status/index.html"); |
| const booksHtml = read("web/status/books.html"); |
| +// The carbon tally is COMMITTED data (accrued by tools/carbon-tally.mjs), not |
| +// dry-run output — the real file is its own fixture. |
| +const carbon = JSON.parse(read("web/status/carbon.json")); |
| +const carbonHtml = read("web/status/carbon.html"); |
| await test("fleet page fields all exist in the generator's fleet.json", () => { |
| for (const f of fields(indexHtml)) { |
| @@ -71,10 +75,16 @@ await test("books page fields all exist in one of the two books shapes", () => { |
| } |
| }); |
| +await test("carbon page fields all exist in the committed carbon.json", () => { |
| + for (const f of fields(carbonHtml)) { |
| + assert.notEqual(resolve(carbon, f), undefined, "carbon.json is missing " + f); |
| + } |
| +}); |
| + |
| // A manifest entry nobody reads is drift too: each leaf property must appear |
| // in the page's render code, not just the manifest. |
| await test("every declared field is actually read by its page", () => { |
| - for (const [html, fs] of [[indexHtml, fields(indexHtml)], [booksHtml, fields(booksHtml)]]) { |
| + for (const [html, fs] of [[indexHtml, fields(indexHtml)], [booksHtml, fields(booksHtml)], [carbonHtml, fields(carbonHtml)]]) { |
| const body = html.slice(html.indexOf("];") + 2); |
| for (const f of fs) { |
| const leaf = f.split(".").pop().replace("[]", ""); |
| @@ -94,12 +104,49 @@ await test("degraded books shape carries available:false + reason", () => { |
| // belt and suspenders on what's in git): no fixture hostname, subscription, |
| // customer, or transaction id may appear in published output. |
| await test("fixtures contain no per-payer ids or hostnames", () => { |
| - for (const j of [fleet, fleetGF, fleetAC, books, booksDown]) { |
| + for (const j of [fleet, fleetGF, fleetAC, books, booksDown, carbon]) { |
| const s = JSON.stringify(j); |
| assert.ok(!/sub_|cus_|txn_|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80)); |
| } |
| }); |
| +// The carbon tally must stay internally consistent as entries accrue — the |
| +// totals are the sum of the ledger, each entry's kg derives from its output |
| +// tokens × the published factors, and each SOURCE's periods are chronological |
| +// and non-overlapping (tools/carbon-tally.mjs appends strictly after that |
| +// source's last period_end; an overlap means two branches double-counted the |
| +// same window — drop the younger entry and re-run --write). Sources are |
| +// anonymous name-pair labels, same rule as the fleet's box labels: the tally |
| +// is public, so never a hostname or username. |
| +await test("carbon tally arithmetic + per-source chronology hold", () => { |
| + const f = carbon.factors_mg_co2e_per_output_token; |
| + assert.ok(f.low < f.central && f.central < f.high, "factors ordered"); |
| + let out = 0, kg = { low: 0, central: 0, high: 0 }; |
| + const prevEnd = new Map(); |
| + for (const e of carbon.entries) { |
| + assert.match(e.source, /^[a-z]+-[a-z]+$/, "source must be a name-pair label, got " + e.source); |
| + assert.ok(["measured", "extrapolated"].includes(e.basis), "basis word: " + e.basis); |
| + assert.ok(Date.parse(e.period_start) < Date.parse(e.period_end), "period runs forward"); |
| + assert.ok(Date.parse(e.period_start) >= (prevEnd.get(e.source) ?? -Infinity), |
| + e.source + " periods overlap at " + e.period_start); |
| + prevEnd.set(e.source, Date.parse(e.period_end)); |
| + for (const band of ["low", "central", "high"]) { |
| + const want = (e.output_tokens * f[band]) / 1e6; // mg → kg |
| + assert.ok(Math.abs(e["kg_" + band] - want) <= 0.06, |
| + "entry kg_" + band + " ≠ tokens × factor (" + e["kg_" + band] + " vs " + want.toFixed(2) + ")"); |
| + kg[band] += e["kg_" + band]; |
| + } |
| + out += e.output_tokens; |
| + } |
| + assert.equal(carbon.totals.output_tokens, out, "totals.output_tokens is the ledger sum"); |
| + for (const band of ["low", "central", "high"]) { |
| + assert.ok(Math.abs(carbon.totals["kg_" + band] - kg[band]) <= 0.05, |
| + "totals.kg_" + band + " is the ledger sum"); |
| + } |
| + assert.equal(Date.parse(carbon.updated_at), Math.max(...carbon.entries.map((e) => Date.parse(e.period_end))), |
| + "updated_at is the newest period_end"); |
| +}); |
| + |
| // Capacity + hosts publish ABSTRACT levels only (Paul's call: bucketed words, |
| // never needles). The schema is an ALLOWLIST — any extra key under hosts / |
| // capacity fails, so a precise number (remaining slots, GB, percentages, cap |
diff --git a/tools/carbon-tally.mjs b/tools/carbon-tally.mjs| new file mode 100644 |
| index 0000000..caff488 |
| --- /dev/null |
| +++ b/tools/carbon-tally.mjs |
| @@ -0,0 +1,173 @@ |
| +#!/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/<sanitized-checkout-path>/; 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 || 5); |
| + |
| +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")); |
| +let mainEntries = []; |
| +try { mainEntries = JSON.parse(git("show origin/main:web/status/carbon.json")).entries; } catch {} |
| +const known = [...tally.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 = tally.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") { |
| + tally.entries.push(entry); |
| + const sum = (pick) => Math.round(tally.entries.reduce((a, e) => a + pick(e), 0) * 10) / 10; |
| + tally.totals = { |
| + output_tokens: tally.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(...tally.entries.map((e) => Date.parse(e.period_end)))).toISOString(); |
| + writeFileSync(tallyPath, JSON.stringify(tally, null, 2) + "\n"); |
| + console.error("appended to " + tallyPath + " — totals now " + tally.totals.kg_central + " kg central (" + |
| + tally.totals.kg_low + "–" + tally.totals.kg_high + ")"); |
| +} else { |
| + console.error("dry run — pass --write to append"); |
| +} |
diff --git a/web/status/books.html b/web/status/books.html| index be710bc..819e5af 100644 |
| --- a/web/status/books.html |
| +++ b/web/status/books.html |
| @@ -112,7 +112,7 @@ |
| </div> |
| <footer> |
| - <p><a href="./index.html">Fleet status</a></p> |
| + <p><a href="./index.html">Fleet status</a> · <a href="./carbon.html">The carbon tally</a></p> |
| <p>Published every 5 minutes from Stripe aggregates. Tier names are the |
| public price book; amounts are EUR. Expenses and disbursements will join |
| these pages as the public ledger lands (see the roadmap in |
diff --git a/web/status/carbon.html b/web/status/carbon.html| new file mode 100644 |
| index 0000000..6f78b35 |
| --- /dev/null |
| +++ b/web/status/carbon.html |
| @@ -0,0 +1,362 @@ |
| +<!-- 🤖 Public AI-build carbon tally (the honesty leg of docs/comparison-carbon.md). |
| + Fully self-contained: inline CSS/JS, no external requests except |
| + ./carbon.json (relative). Unlike fleet/books, the JSON is COMMITTED data |
| + (web/status/carbon.json, accrued by tools/carbon-tally.mjs), republished |
| + as-is by deploy/status-publish.sh — aggregate token counts and kg only. --> |
| +<!doctype html> |
| +<html lang="en"> |
| +<head> |
| +<meta charset="utf-8" /> |
| +<meta name="viewport" content="width=device-width, initial-scale=1" /> |
| +<meta name="color-scheme" content="light dark" /> |
| +<title>Eleven — the carbon tally</title> |
| +<link rel="icon" type="image/png" href="./icon-192.png" /> |
| +<style> |
| + /* Same validated palette roles as index.html (dataviz six-checks, light + |
| + dark); violet series hue, secondary ink for all small text (>=4.5:1). */ |
| + :root { |
| + --plane: #f9f9f7; --surface: #fcfcfb; |
| + --ink: #0b0b0b; --ink-2: #52514e; |
| + --grid: #e1e0d9; --border: rgba(11,11,11,0.10); |
| + --series: #4a3aa7; |
| + --bad: #b23030; --bad-mark: #d03b3b; |
| + --link: #4a3aa7; |
| + } |
| + @media (prefers-color-scheme: dark) { |
| + :root { |
| + --plane: #0d0d0d; --surface: #1a1a19; |
| + --ink: #ffffff; --ink-2: #c3c2b7; |
| + --grid: #2c2c2a; --border: rgba(255,255,255,0.10); |
| + --series: #9085e9; |
| + --bad: #e66767; --bad-mark: #e66767; |
| + --link: #9085e9; |
| + } |
| + } |
| + * { box-sizing: border-box; margin: 0; } |
| + body { |
| + background: var(--plane); color: var(--ink); |
| + font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; |
| + padding: 24px 16px 48px; |
| + } |
| + .wrap { max-width: 760px; margin: 0 auto; } |
| + a { color: var(--link); } |
| + a:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; } |
| + |
| + header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; } |
| + header img { width: 44px; height: 44px; border-radius: 10px; } |
| + header h1 { font-size: 22px; font-weight: 650; } |
| + header .brand { color: var(--ink-2); font-size: 14px; } |
| + .lede { color: var(--ink-2); margin: 6px 0 2px; } |
| + .asof { font-weight: 600; margin: 10px 0 24px; } |
| + |
| + .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-bottom: 12px; } |
| + .tile { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; } |
| + .tile .label { font-size: 13px; color: var(--ink-2); } |
| + .tile .value { font-size: 30px; font-weight: 600; margin-top: 2px; } |
| + .equiv { color: var(--ink-2); margin: 0 0 28px; } |
| + |
| + section h2 { font-size: 15px; font-weight: 650; margin: 0 0 10px; } |
| + section { margin-bottom: 28px; } |
| + |
| + /* Cumulative chart: single series (no legend box — the title names it), |
| + 2px line, 10%-wash uncertainty band, hairline gridlines, crosshair + |
| + one tooltip. The ledger list below is the chart's table view. */ |
| + figure { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 8px 6px; position: relative; } |
| + figure svg { display: block; width: 100%; height: auto; } |
| + figcaption { color: var(--ink-2); font-size: 13px; padding: 4px 8px 8px; } |
| + #tip { |
| + display: none; position: absolute; z-index: 1; pointer-events: none; |
| + background: var(--surface); border: 1px solid var(--border); border-radius: 8px; |
| + padding: 8px 10px; font-size: 13px; max-width: 240px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); |
| + } |
| + #tip .big { font-size: 15px; font-weight: 650; } |
| + #tip .sub { color: var(--ink-2); } |
| + .pt:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 50%; } |
| + |
| + /* Measurement ledger: single-series horizontal bars (kg, central estimate); |
| + rows are real text, bars aria-hidden — same mark spec as books.html. */ |
| + .bars { list-style: none; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; } |
| + .bars li { display: grid; grid-template-columns: minmax(120px, 200px) 1fr 5.5em; align-items: center; gap: 12px; padding: 10px 16px; } |
| + .bars li + li { border-top: 1px solid var(--grid); } |
| + .bars .period { font-size: 14px; font-weight: 600; } |
| + .bars .basis { display: block; font-size: 12px; font-weight: 400; color: var(--ink-2); } |
| + .bars .track { height: 12px; } |
| + .bars .bar { height: 12px; min-width: 2px; background: var(--series); border-radius: 0 4px 4px 0; } |
| + .bars .kg { font-size: 14px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; } |
| + |
| + #error { display: none; background: var(--surface); border: 1px solid var(--bad-mark); border-radius: 12px; padding: 14px 16px; margin-bottom: 24px; } |
| + #error.show { display: block; } |
| + |
| + footer { margin-top: 36px; color: var(--ink-2); font-size: 14px; } |
| + footer p { margin-top: 6px; } |
| +</style> |
| +</head> |
| +<body> |
| +<div class="wrap"> |
| + <header> |
| + <img src="./icon-192.png" alt="" /> |
| + <div> |
| + <p class="brand">Eleven Messenger</p> |
| + <h1>The carbon tally</h1> |
| + </div> |
| + </header> |
| + <p class="lede">Eleven is AI-written, and the honest carbon ledger includes |
| + the AI: this is a running estimate of the CO₂e cost of the LLM tokens spent |
| + <em>building</em> the app. Token counts are exact; the carbon conversion is |
| + an estimate with wide error bars, because no model provider publishes |
| + per-token figures — <span id="factors">—</span>. Full methodology and |
| + honesty notes: |
| + <a href="https://github.com/elevenmessenger/messenger/blob/main/docs/comparison-carbon.md">the |
| + carbon comparison</a>.</p> |
| + <p class="asof">Last measured <time id="asof" datetime="">—</time></p> |
| + |
| + <p id="error" role="alert">Couldn’t load the carbon tally. It may be mid-publish — this page retries every minute.</p> |
| + |
| + <div id="tally"> |
| + <section aria-labelledby="h-total"> |
| + <h2 id="h-total">Estimated so far</h2> |
| + <div class="tiles"> |
| + <div class="tile"><div class="label">Central estimate</div><div class="value" id="t-central">—</div></div> |
| + <div class="tile"><div class="label">Honest range</div><div class="value" id="t-range">—</div></div> |
| + <div class="tile"><div class="label">Output tokens</div><div class="value" id="t-tokens">—</div></div> |
| + </div> |
| + <p class="equiv" id="equiv"></p> |
| + </section> |
| + |
| + <section aria-labelledby="h-chart"> |
| + <h2 id="h-chart">Accrual over time</h2> |
| + <figure> |
| + <svg id="chart" viewBox="0 0 720 240" role="img" aria-label=""></svg> |
| + <div id="tip"></div> |
| + <figcaption>Cumulative kg CO₂e — the line is the central estimate, the |
| + wash is the low–high band. Exact figures are in the ledger below.</figcaption> |
| + </figure> |
| + </section> |
| + |
| + <section aria-labelledby="h-entries"> |
| + <h2 id="h-entries">The ledger (central kg per period)</h2> |
| + <ul class="bars" id="entries"><li><span class="period">Loading…</span></li></ul> |
| + <p class="equiv" id="notcounted"></p> |
| + </section> |
| + </div> |
| + |
| + <footer> |
| + <p><a href="./index.html">Fleet status</a> · <a href="./books.html">The books</a></p> |
| + <p>Accrues when we re-measure (at least weekly — the tally is committed to |
| + the repo and republished with these dashboards). The running fleet’s own |
| + footprint is tiny by comparison: see the carbon comparison linked above.</p> |
| + </footer> |
| +</div> |
| + |
| +<script> |
| +// contract: the fields this page reads from ./carbon.json — kept in sync with |
| +// web/status/carbon.json (committed) and checked by test/status-contract.test.mjs. |
| +const FIELDS = [ |
| + "updated_at", |
| + "factors_mg_co2e_per_output_token.low", |
| + "factors_mg_co2e_per_output_token.central", |
| + "factors_mg_co2e_per_output_token.high", |
| + "not_counted", |
| + "entries[].period_start", "entries[].period_end", "entries[].source", |
| + "entries[].basis", "entries[].output_tokens", "entries[].kg_low", |
| + "entries[].kg_central", "entries[].kg_high", "entries[].note", |
| + "totals.output_tokens", "totals.kg_low", "totals.kg_central", "totals.kg_high", |
| +]; |
| + |
| +const $ = (id) => document.getElementById(id); |
| +const day = (iso) => new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" }); |
| +const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }); |
| +const NS = "http://www.w3.org/2000/svg"; |
| + |
| +// Everyday equivalents (docs/comparison-carbon.md): a typical petrol car emits |
| +// ~170 g CO₂e/km, burning 1 L of petrol ≈ 2.3 kg, and the fleet estimate is |
| +// ~2 g CO₂e per member per year. |
| +const CAR_G_PER_KM = 170, PETROL_KG_PER_L = 2.3, FLEET_G_PER_MEMBER_YEAR = 2; |
| + |
| +// All rendering is textContent / createElement — never innerHTML of fetched |
| +// data, even though the feed is our own. |
| +function render(d) { |
| + $("asof").dateTime = d.updated_at; |
| + $("asof").textContent = new Date(d.updated_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); |
| + |
| + const f = d.factors_mg_co2e_per_output_token; |
| + $("factors").textContent = "we band public calibration points at " + f.low + "–" + f.high + |
| + " mg CO₂e per output token, central " + f.central + " mg"; |
| + |
| + $("t-central").textContent = "~" + d.totals.kg_central + " kg CO₂e"; |
| + $("t-range").textContent = d.totals.kg_low + "–" + d.totals.kg_high + " kg"; |
| + $("t-tokens").textContent = compact.format(d.totals.output_tokens); |
| + |
| + const km = Math.round(d.totals.kg_central * 1000 / CAR_G_PER_KM); |
| + const litres = Math.round(d.totals.kg_central / PETROL_KG_PER_L); |
| + const memberYears = Math.round(d.totals.kg_central * 1000 / FLEET_G_PER_MEMBER_YEAR); |
| + $("equiv").textContent = "Centrally, that’s like driving a petrol car about " + km + |
| + " km (burning ~" + litres + " litres of petrol) — or roughly " + |
| + memberYears.toLocaleString() + " member-years of actually running the service."; |
| + |
| + chart(d); |
| + |
| + const list = $("entries"); |
| + list.replaceChildren(); |
| + // Ledger order = time order, even when contributor machines interleave. |
| + const entries = (d.entries || []).slice().sort((a, b) => Date.parse(a.period_end) - Date.parse(b.period_end)); |
| + const max = Math.max(1, ...entries.map((e) => e.kg_central)); |
| + for (const e of entries) { |
| + const li = document.createElement("li"); |
| + const period = document.createElement("span"); |
| + period.className = "period"; |
| + period.textContent = day(e.period_start) + " – " + day(e.period_end); |
| + const basis = document.createElement("span"); |
| + basis.className = "basis"; |
| + basis.textContent = e.source + " · " + e.basis + " · " + compact.format(e.output_tokens) + " output tokens"; |
| + basis.title = e.note || ""; |
| + period.append(basis); |
| + const track = document.createElement("span"); |
| + track.className = "track"; |
| + track.setAttribute("aria-hidden", "true"); |
| + const bar = document.createElement("span"); |
| + bar.className = "bar"; |
| + bar.style.width = (100 * e.kg_central / max) + "%"; |
| + bar.style.display = "block"; |
| + track.append(bar); |
| + const kg = document.createElement("span"); |
| + kg.className = "kg"; |
| + kg.textContent = e.kg_central + " kg"; |
| + li.append(period, track, kg); |
| + list.append(li); |
| + } |
| + |
| + $("notcounted").textContent = "Known undercounts, on purpose: " + d.not_counted + "."; |
| +} |
| + |
| +// Cumulative line over time: central line + low–high wash. Crosshair snaps to |
| +// the nearest measurement; points are keyboard-focusable with the same tooltip. |
| +function chart(d) { |
| + const svg = $("chart"); |
| + svg.replaceChildren(); |
| + const round1 = (v) => Math.round(v * 10) / 10; |
| + // Cumulate in period_end order across ALL sources; the curve starts at the |
| + // earliest period_start any source measured from. |
| + const ordered = d.entries.slice().sort((a, b) => Date.parse(a.period_end) - Date.parse(b.period_end)); |
| + const t0 = Math.min(...d.entries.map((e) => Date.parse(e.period_start))); |
| + const pts = [{ t: t0, c: 0, lo: 0, hi: 0 }]; |
| + let c = 0, lo = 0, hi = 0; |
| + for (const e of ordered) { |
| + c = round1(c + e.kg_central); lo = round1(lo + e.kg_low); hi = round1(hi + e.kg_high); |
| + pts.push({ t: Date.parse(e.period_end), c, lo, hi, e }); |
| + } |
| + const W = 720, H = 240, m = { l: 44, r: 86, t: 14, b: 26 }; |
| + const span = Math.max(1, pts[pts.length - 1].t - pts[0].t); |
| + const x = (t) => m.l + (W - m.l - m.r) * (t - pts[0].t) / span; |
| + // Clean y ticks: pick the smallest 1/2/5×10ⁿ step that needs ≤ 4 intervals. |
| + let step = 1; |
| + for (const s of [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]) { if (hi / s <= 4) { step = s; break; } } |
| + const yMax = Math.max(step, Math.ceil(hi / step) * step); |
| + const y = (v) => H - m.b - (H - m.b - m.t) * v / yMax; |
| + const el = (name, attrs, text) => { |
| + const n = document.createElementNS(NS, name); |
| + for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); |
| + if (text !== undefined) n.textContent = text; |
| + svg.append(n); |
| + return n; |
| + }; |
| + |
| + svg.setAttribute("aria-label", "Cumulative carbon estimate from " + day(pts[0].t) + |
| + " to " + day(pts[pts.length - 1].t) + ": central " + c + " kg CO₂e, range " + lo + " to " + hi + " kg."); |
| + |
| + for (let v = 0; v <= yMax; v += step) { |
| + el("line", { x1: m.l, x2: W - m.r, y1: y(v), y2: y(v), stroke: "var(--grid)", "stroke-width": 1 }); |
| + el("text", { x: m.l - 8, y: y(v) + 4, "text-anchor": "end", "font-size": 12, |
| + fill: "var(--ink-2)", style: "font-variant-numeric: tabular-nums" }, String(v)); |
| + } |
| + for (const p of pts) { |
| + el("text", { x: x(p.t), y: H - 8, "text-anchor": "middle", "font-size": 12, fill: "var(--ink-2)" }, day(p.t)); |
| + } |
| + |
| + const path = (pick) => pts.map((p, i) => (i ? "L" : "M") + x(p.t) + " " + y(pick(p))).join(" "); |
| + el("path", { d: path((p) => p.hi) + " " + pts.slice().reverse().map((p) => "L" + x(p.t) + " " + y(p.lo)).join(" ") + " Z", |
| + fill: "var(--series)", opacity: 0.1, stroke: "none" }); |
| + el("path", { d: path((p) => p.c), fill: "none", stroke: "var(--series)", |
| + "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" }); |
| + |
| + // Selective direct labels at the right edge; ticks + tooltip carry the rest. |
| + const end = pts[pts.length - 1]; |
| + el("text", { x: x(end.t) + 10, y: y(end.hi) + 4, "font-size": 12, fill: "var(--ink-2)" }, "high " + end.hi); |
| + el("text", { x: x(end.t) + 10, y: y(end.c) + 4, "font-size": 12, "font-weight": 650, fill: "var(--ink)" }, "~" + end.c + " kg"); |
| + el("text", { x: x(end.t) + 10, y: y(end.lo) + 12, "font-size": 12, fill: "var(--ink-2)" }, "low " + end.lo); |
| + |
| + const hair = el("line", { y1: m.t, y2: H - m.b, stroke: "var(--grid)", "stroke-width": 1, visibility: "hidden" }); |
| + const dots = pts.map((p) => el("circle", { cx: x(p.t), cy: y(p.c), r: 4.5, fill: "var(--series)", |
| + stroke: "var(--surface)", "stroke-width": 2 })); |
| + |
| + const tip = $("tip"); |
| + const show = (p, dot) => { |
| + hair.setAttribute("x1", x(p.t)); hair.setAttribute("x2", x(p.t)); |
| + hair.setAttribute("visibility", "visible"); |
| + dots.forEach((d2, i) => d2.setAttribute("r", pts[i] === p ? 6 : 4.5)); |
| + tip.replaceChildren(); |
| + const big = document.createElement("div"); |
| + big.className = "big"; |
| + big.textContent = "~" + p.c + " kg CO₂e"; |
| + const when = document.createElement("div"); |
| + when.className = "sub"; |
| + when.textContent = "by " + day(p.t) + " · range " + p.lo + "–" + p.hi + " kg"; |
| + tip.append(big, when); |
| + if (p.e) { |
| + const delta = document.createElement("div"); |
| + delta.className = "sub"; |
| + delta.textContent = "+" + p.e.kg_central + " kg (" + p.e.source + ", " + p.e.basis + ", " + |
| + compact.format(p.e.output_tokens) + " output tokens)"; |
| + tip.append(delta); |
| + } |
| + tip.style.display = "block"; |
| + const box = svg.getBoundingClientRect(); |
| + const px = (x(p.t) / W) * box.width; |
| + tip.style.left = Math.min(Math.max(0, px - 60), box.width - tip.offsetWidth - 4) + "px"; |
| + tip.style.top = Math.max(0, (y(p.c) / H) * box.height - tip.offsetHeight - 14) + "px"; |
| + void dot; |
| + }; |
| + const hide = () => { |
| + hair.setAttribute("visibility", "hidden"); |
| + dots.forEach((d2) => d2.setAttribute("r", 4.5)); |
| + tip.style.display = "none"; |
| + }; |
| + |
| + // The crosshair finds the X: the whole plot is the hit area, snapping to the |
| + // nearest measurement — nobody has to land on a 2px line. |
| + svg.addEventListener("pointermove", (ev) => { |
| + const box = svg.getBoundingClientRect(); |
| + const mx = ((ev.clientX - box.left) / box.width) * W; |
| + let best = pts[0]; |
| + for (const p of pts) if (Math.abs(x(p.t) - mx) < Math.abs(x(best.t) - mx)) best = p; |
| + show(best); |
| + }); |
| + svg.addEventListener("pointerleave", hide); |
| + pts.forEach((p, i) => { |
| + const dot = dots[i]; |
| + dot.classList.add("pt"); |
| + dot.setAttribute("tabindex", "0"); |
| + dot.setAttribute("role", "img"); |
| + dot.setAttribute("aria-label", "By " + day(p.t) + ": about " + p.c + " kg CO₂e, range " + p.lo + " to " + p.hi); |
| + dot.addEventListener("focus", () => show(p, dot)); |
| + dot.addEventListener("blur", hide); |
| + }); |
| +} |
| + |
| +async function refresh() { |
| + try { |
| + const res = await fetch("./carbon.json", { cache: "no-cache" }); |
| + if (!res.ok) throw new Error("HTTP " + res.status); |
| + render(await res.json()); |
| + $("error").classList.remove("show"); |
| + } catch (e) { |
| + // Keep the previous render (no skeleton flash); surface the failure inline. |
| + $("error").classList.add("show"); |
| + } |
| +} |
| +refresh(); |
| +setInterval(refresh, 60 * 1000); |
| +void FIELDS; |
| +</script> |
| +</body> |
| +</html> |
diff --git a/web/status/carbon.json b/web/status/carbon.json| new file mode 100644 |
| index 0000000..a0c05c1 |
| --- /dev/null |
| +++ b/web/status/carbon.json |
| @@ -0,0 +1,47 @@ |
| +{ |
| + "updated_at": "2026-07-11T19:26:21.699Z", |
| + "unit": "kg CO2e", |
| + "methodology": "https://github.com/elevenmessenger/messenger/blob/main/docs/comparison-carbon.md", |
| + "factors_mg_co2e_per_output_token": { |
| + "low": 0.1, |
| + "central": 0.55, |
| + "high": 3 |
| + }, |
| + "not_counted": "cache reads (no public per-token figure exists), cloud-side agent runs, and contributor machines that haven't accrued their entries yet", |
| + "entries": [ |
| + { |
| + "period_start": "2026-06-27T00:00:00.000Z", |
| + "period_end": "2026-07-03T00:00:00.000Z", |
| + "source": "radia-doodling", |
| + "basis": "extrapolated", |
| + "sessions": null, |
| + "output_tokens": 9098065, |
| + "fresh_input_tokens": null, |
| + "cache_read_tokens": null, |
| + "kg_low": 0.9, |
| + "kg_central": 5, |
| + "kg_high": 27.3, |
| + "note": "transcripts pruned before the first measurement; commit-weighted share (165 of the 616 commits to 2026-07-11) of the first measured window's per-commit rate" |
| + }, |
| + { |
| + "period_start": "2026-07-03T08:19:42.191Z", |
| + "period_end": "2026-07-11T19:26:21.699Z", |
| + "source": "radia-doodling", |
| + "basis": "measured", |
| + "sessions": 162, |
| + "output_tokens": 24889647, |
| + "fresh_input_tokens": 144657973, |
| + "cache_read_tokens": 7949132294, |
| + "kg_low": 2.5, |
| + "kg_central": 13.7, |
| + "kg_high": 74.7, |
| + "note": "local Claude Code transcripts, one dev machine" |
| + } |
| + ], |
| + "totals": { |
| + "output_tokens": 33987712, |
| + "kg_low": 3.4, |
| + "kg_central": 18.7, |
| + "kg_high": 102 |
| + } |
| +} |
diff --git a/web/status/index.html b/web/status/index.html| index e9405e5..acc1fb0 100644 |
| --- a/web/status/index.html |
| +++ b/web/status/index.html |
| @@ -177,7 +177,7 @@ |
| </section> |
| <footer> |
| - <p><a href="./books.html">The books — income, in aggregate</a></p> |
| + <p><a href="./books.html">The books — income, in aggregate</a> · <a href="./carbon.html">The carbon tally — what the AI build cost</a></p> |
| <p>Published every 5 minutes from aggregate counters on the fleet. |
| By design it contains no hostnames and nothing about any individual space.</p> |
| </footer> |