eleven / messenger Public

The prompt registry: publish AI session transcripts, credentials redacted (#784)

* web+docs: the prompt registry — publish AI session transcripts, credentials redacted

status.elevenmessenger.com/prompts.html: the provenance record of AI-assisted
work. tools/prompt-registry.mjs sweeps this machine's Claude Code transcripts
(worktree sessions included), redacts every credential class it knows
(private keys, provider key formats, JWTs, admin-link fragments, auth
headers, env secrets, structured password/token fields, the gitignored
CLAUDE.local.md runbook wholesale, base64 binaries, plus a high-entropy
fail-closed backstop), annotates each redaction in place with why, and
refuses to emit the corpus unless a symmetric re-scan of its own output comes
back clean. The corpus uploads straight to the status bucket (too large to
commit); prompts.html is a committed viewer riding the 5-minute republish.

The registry's privacy bar is deliberately different from the other feeds —
credentials-only, transcripts whole (Paul's decision, 2026-07-12) — and
docs/status.md scopes it. Publishing a machine's registry is that
contributor's choice; today it covers Paul's sessions only, and the
transcript-per-contribution requirement is NOT yet in force for anyone else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: prompts.html — nowrap the session date column, table scrolls itself

* web: prompt registry v2 — search, pagination, contributor chips, per-session models, localized numbers, --also old checkout paths

* tools: prompt-registry — <synthetic> is a harness stub marker, not a model

* tools+web: Haiku one-line session summaries — sessions index becomes date/who/model/summary

Summaries are generated from the session's REDACTED prompts only, cached per
session across runs, re-redacted + fail-closed-verified like everything else.
No ANTHROPIC_API_KEY → the index falls back to each session's first prompt
(the tool never requires AI).

* tools+web: session index shows both the summary and the session's latest prompt

* tools+web: Haiku reply gists on collapsed rows + contributor personal redaction budget

Substantial assistant replies get one-line Haiku gists (batched, cached in
<session>.sum.json, built from redacted text, verified fail-closed) so the
viewer's collapsed 'Claude replied' rows say what's inside; first line of the
reply is the AI-free fallback. --personal <file> gives each contributor a
small personal redaction budget — published as ⟦redacted: personal⟧ and
counted per session, so the budget polices itself in the open.

* tools+web+docs: session-level opt-out — withhold tombstones + omit, mandatory for centralised-service security

Sessions are in the ledger by default (opting in is what contributing means);
--sessions <file> lets a contributor 'withhold <id> <reason>' (tombstone:
date, prompt count, reason — no content, no transcript file) or 'omit <id>'
(aggregate count only). Reasons vocabulary: personal-safety / security /
beyond-intent. Withholding is MANDATORY for sessions whose content could
compromise the centralised services — a credential can be redacted, a
narrative that maps the defences cannot; docs carry the marker-sweep recipe
(≥3 hits → withhold, conservative by design).

Also: redact JSON object KEYS, not just values (a hostname→stats map leaked a
personally-redacted string through a key), and the same fix in the fail-closed
verifier.

* web: one searchable sessions list (deep search over prompts) + pluralised, localised counts; CLAUDE.md: number-display convention

* web: prompts.html top line — Paul's wording, human-certified

* web: prompts.html — visible 🤖 authorship markers on the policy card paragraphs

* web: label session index lines — SUMMARY / FIRST / LATEST, never confusable

* web: sessions table — name over de-emphasised date link, capitalised headers, friendly model names with exact id on hover

* web: smaller date line under the contributor name

* web+tools: accordion folds (policy + legend), infinite scroll, date-range filter, GitHub issue links with build-time titles

The policy card and redaction legend collapse to two closed <details> folds;
the sessions list infinite-scrolls via an IntersectionObserver sentinel
(pager removed); from/to date inputs filter by session start date. GitHub
issue/PR URLs render as octicon-prefixed links titled from prompts/issues.json
— resolved at build time via gh (the repo is private, viewers can't), cached
across runs, scrubbed + fail-closed-verified like all corpus data.

* web: spread the totals tiles across the card

* tools+web+docs: email redaction category, third-party withhold reason, clickable rows, smaller sublines, the summary URL rule

- every email address redacts corpus-wide (people's addresses stay out of the
  public ledger); carried cache values re-pass through TODAY'S rules so a
  category added later reaches summaries/gists cached before it existed
- withhold vocabulary gains third-party: a session centred on an identifiable
  person who didn't opt in withholds whole — blanket email redaction hides
  addresses, not narratives about people
- summaries follow THE SUMMARY RULE: end with the session's most relevant
  GitHub issue/PR URL, which the page renders as a titled octicon link
- sessions rows are fully clickable with a hover state (links inside keep
  their own click; the date link remains the keyboard path); FIRST/LATEST
  sublines render smaller

* web: compact GitHub links — #457 with the issue title on hover

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 04dd5a0baf9709cea531c2335b4933ce87b2ae94 parent 25d440d
5 files changed, +1214 −5
  • CLAUDE.md +6 −0
  • deploy/status-publish.sh +4 −0
  • docs/status.md +94 −5
  • tools/prompt-registry.mjs +544 −0
  • web/status/prompts.html +566 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 4a7fc81..0f933a9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -282,6 +282,12 @@ open with the note itself) carry the 🤖 on their first line.
render inline, occupying layout space — nothing overlays content someone may
be reading or about to tap. Existing floating-`#toast` uses migrate to
inline feedback as they're touched (see `docs/onboarding-and-home-server.md`).
+- 🤖 **Every displayed number pluralises and localises.** Any count shown with
+ a label handles pluralisation ("1 contributor" / "2 contributors" — never
+ "1 contributors"), and digits render with locale delimiting
+ (`toLocaleString()` on web / `NumberFormatter`–`Text(_, format:)` on
+ native: 2,723 not 2723; prefer `font-variant-numeric: tabular-nums` where
+ columns align). Never concatenate a bare `n + " things"`.
- 🤖 **Accessibility is a shipping requirement — WCAG 2.2 AA** (statement:
`docs/accessibility.md`). New/changed UI is keyboard-operable with visible,
managed focus; icon-only controls are labelled (give DOM nodes their ARIA as
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh
index de70942..0709b6a 100755
--- a/deploy/status-publish.sh
+++ b/deploy/status-publish.sh
@@ -901,6 +901,10 @@ publish() {
# page uploads even when the JSON hasn't been staged.
[ -f "$PAGES_DIR/security.html" ] && upload "$PAGES_DIR/security.html" security.html "text/html; charset=utf-8" "max-age=300"
[ -f "$PAGES_DIR/security.json" ] && upload "$PAGES_DIR/security.json" security.json "application/json" "max-age=300"
+ # The prompt registry viewer. Its corpus (prompts/…) is NOT uploaded here:
+ # a contributor accrues + syncs it straight to the bucket — far too large
+ # for a 5-minute republish (docs/status.md § The prompt registry).
+ [ -f "$PAGES_DIR/prompts.html" ] && upload "$PAGES_DIR/prompts.html" prompts.html "text/html; charset=utf-8" "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
diff --git a/docs/status.md b/docs/status.md
index f3d8da6..5257bcc 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -1,4 +1,4 @@
-# 🤖 Public status dashboards — fleet, books, security, carbon & contributors
+# 🤖 Public status dashboards — fleet, books, security, carbon, contributors & prompts
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
@@ -44,6 +44,13 @@ public *ledger* repo is still roadmap — see `docs/planet-scale.md`).
box republishes it as-is, and an absent `security.json` renders as "scan
pending" — never a fake clean. Vulnerability reports:
team@elevenmessenger.com (swap to a dedicated security@ mailbox when one exists).
+- **The prompt registry** (`prompts.html` + the `prompts/` corpus in the
+ bucket) — the provenance record of AI-assisted work: whole Claude Code
+ session transcripts, credentials redacted and every redaction annotated in
+ place. Accrued data like the carbon tally, but far too large to commit: a
+ contributor runs `tools/prompt-registry.mjs` and uploads the corpus straight
+ to the bucket (see *The prompt registry* below). The page is a committed
+ viewer, republished with the others.
Pieces: generator `deploy/status-publish.sh` (box timer, mirrors
`deploy/fleet-backup.sh`), viewer pages `web/status/`, bucket + IAM
@@ -85,8 +92,55 @@ aggregation boundary lives in the generator and holds by construction:
greps), never private activity. Anyone listed can hide themselves with
`"hidden": true` in the committed JSON.
- **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.
+ data, never prompt or transcript content in *this feed* — the tally reads
+ token *usage numbers* from local transcripts and publishes only their sums.
+ (Transcript *content* publishes only through the prompt registry, below,
+ under its own explicitly different bar.)
+- **The prompt registry publishes transcripts on purpose — its bar is
+ credentials-only** (Paul's decision, 2026-07-12; it supersedes the
+ no-hostnames rule for this feed alone). Transcripts are a contributor's own
+ working record, published *whole* as provenance: hostnames and ops narrative
+ stay (instance hostnames are already public record via Certificate
+ Transparency logs), while every secret, token, key, and the gitignored
+ CLAUDE.local.md runbook are redacted with an in-place annotation saying why.
+ The build is fail-closed: `tools/prompt-registry.mjs` re-scans its own
+ output with the same detectors and refuses to emit the corpus on any hit.
+ Publishing a machine's registry is **that contributor's choice** — the tool
+ only reads the machine it runs on, and nothing here auto-collects anyone's
+ transcripts. Each contributor also keeps a **small personal redaction
+ budget** (`--personal <file>`: literal strings or `/regex/`, one per line):
+ things removed for personal reasons, no justification owed. They publish as
+ `⟦redacted: personal⟧` and are **counted per session like every other
+ category** — the public count is the budget's honesty mechanism; a row with
+ three personal redactions reads very differently from one with three
+ hundred.
+- **Sessions are opt-in by default, with two session-level escape hatches**
+ (`--sessions <file>`): `withhold <id> <reason>` publishes a **tombstone**
+ (date, prompt count, reason — no content), `omit <id>` leaves only the
+ aggregate omitted count. Reasons vocabulary: `personal-safety` (sharing
+ would cause or attract harm to a person), `security` (would reveal secrets
+ whose exposure could cause harm, incl. national security), `third-party`
+ (the session centres on an identifiable person who didn't opt into the
+ ledger — the blanket email redaction hides addresses, but a session *about*
+ someone withholds whole), `beyond-intent` (consequences beyond revealing
+ the human's intent — the thing transparency is for). **One case is mandatory: centralised-service security.** A session
+ whose content could compromise the push relay, home, cloud/billing,
+ connect, the tunnel gateway, or the router box is withheld *whole* —
+ credential redaction is not enough when the narrative itself maps the
+ defences (where key material lives, how backups restore, what the sandbox
+ permits). Sweep before every accrual and review the hits:
+
+ ```sh
+ find ~/.claude/projects -maxdepth 2 -name '*.jsonl' -path '*<repo>*' -print0 |
+ while IFS= read -r -d '' f; do
+ n=$(grep -c -E 'AGE-SECRET-KEY|secrets-vault|sops -d |vault-bundle|/data/push\.env|LCHAT_APNS_KEY|control\.token|litestream\.env|sk_live_|whsec_|LCHAT_STRIPE_SECRET|/data/acme|LCHAT_DNSIMPLE_TOKEN|LoadCredential' "$f")
+ [ "${n:-0}" -ge 3 ] && echo "withhold $(basename "$f" .jsonl) security"
+ done >> <your --sessions file>
+ ```
+
+ The ≥3 threshold is deliberately conservative — over-withholding is
+ recoverable (drop the line, rebuild), publication to a versioned public
+ bucket is not.
- **Security: status + count, never a name — the disclosure line.** The
ledger publishes posture: dependency findings ship as `clean` or "N
advisories under remediation" — **never** the CVE/GHSA/OSV ids, affected
@@ -100,8 +154,9 @@ aggregation boundary lives in the generator and holds by construction:
the summarizer refuses to write an output containing an id-shaped string,
and the contract test greps for every id grammar.
- 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.
+ or it doesn't ship (the prompt registry, a whole-transcript feed by design,
+ is the one standing exception). The `--dry-run` self-check and the contract
+ test both fail if a fixture hostname or per-payer id reaches the output.
## What's in the JSONs
@@ -287,6 +342,40 @@ 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).
+## The prompt registry
+
+The provenance record of AI-assisted work: whole Claude Code session
+transcripts, published at `prompts.html` with credentials redacted and every
+redaction annotated in place (the redaction bar and its rationale: *The
+privacy rules* above). Like the carbon tally, the raw material is the local
+transcripts that **prune after roughly a week** — accrue before they age out
+or the sessions are lost to the registry.
+
+Where it stands today: **Paul's sessions, published by his own choice.** The
+direction is that every contribution built with AI includes its prompt
+transcript, but that becomes a *requirement* on other contributors only once
+agreed with them — don't encode it as a rule anywhere until that conversation
+has happened.
+
+Accruing a machine's sessions:
+
+1. `node tools/prompt-registry.mjs --out <dir>` (from the repo root) — sweeps
+ this machine's transcripts for this repo (worktree sessions included),
+ redacts, and writes `<dir>/prompts/` (the redacted JSONL corpus +
+ `index.json`). The build is **fail-closed**: outputs are re-scanned with
+ the same detectors and the corpus is not emitted on any hit. Publishing is
+ whole-corpus-per-machine for now; per-session accrual can come later if
+ two machines ever need to merge into one index.
+2. Upload the corpus straight to the status bucket (it's far too large to
+ commit — a quarter GB and growing):
+ `aws s3 sync <dir>/prompts s3://eleven-status-<account>/prompts
+ --cache-control max-age=300` (run it on the box with the status-publisher
+ creds — `set -a; . /data/status.env; set +a` — or anywhere with
+ equivalent PutObject rights).
+3. `prompts.html` itself is a committed viewer page: copy it to the box's
+ `/opt/lchat/status-pages/` like the other pages; the 5-minute publish
+ re-uploads it.
+
## Install (router box)
1. **Apply the infra** (deliberate, named — not yet applied):
diff --git a/tools/prompt-registry.mjs b/tools/prompt-registry.mjs
new file mode 100644
index 0000000..66e2d45
--- /dev/null
+++ b/tools/prompt-registry.mjs
@@ -0,0 +1,544 @@
+#!/usr/bin/env node
+// 🤖 Build the public prompt registry (status.elevenmessenger.com/prompts.html)
+// from local Claude Code transcripts — every AI session behind this codebase,
+// published whole, with credentials REDACTED and each redaction annotated.
+//
+// Why publish transcripts at all: the same transparency leg as the carbon
+// tally and the contributors page — this codebase is largely AI-built, and
+// the registry is the provenance record. The redaction bar (Paul's call,
+// 2026-07-12) is CREDENTIALS-ONLY: secrets, tokens, keys, and the gitignored
+// CLAUDE.local.md ops runbook are removed and annotated in place; hostnames
+// and ops narrative stay (instance hostnames are already public record via
+// Certificate Transparency logs).
+//
+// node tools/prompt-registry.mjs --out <dir> # build the corpus
+// node tools/prompt-registry.mjs --out <dir> --check-only # scan, no write
+// --also <path> extra checkout path whose sessions to include
+// (repeatable) — e.g. the repo's pre-rename path:
+// dormant project dirs escape Claude Code's pruning,
+// so the oldest surviving sessions live there
+// --contributor <name> the human name sessions publish under (defaults
+// to the anonymous machine label) — publishing under
+// your name is the same choice as publishing at all
+// --personal <file> contributor's personal redaction list: one literal
+// string (or /regex/) per line, removed as
+// ⟦redacted: personal⟧. The policy allows a SMALL
+// budget of these for personal reasons; the count
+// publishes per session like every other category,
+// so the budget is kept honest in the open.
+// --sessions <file> session-level controls. Sessions are in the
+// ledger by default (opting in is what contributing
+// means), but per line a contributor may:
+// withhold <session-id> <reason> — publish a
+// tombstone (date, prompt count, reason) with
+// the content withheld
+// omit <session-id> — no per-session trace; only
+// the aggregate omitted count publishes
+// Reason vocabulary (docs/status.md): personal-safety
+// (sharing would cause or attract harm to a person),
+// security (would reveal secrets whose exposure
+// could cause harm, incl. national security),
+// third-party (the session centres on an
+// identifiable person who didn't opt into the
+// ledger), beyond-intent (consequences beyond
+// revealing the human's intent — the thing
+// transparency is for).
+//
+// With ANTHROPIC_API_KEY set, each session gets a one-line Haiku summary for
+// the index, and each substantial assistant reply gets a one-line gist
+// (<session>.sum.json) so the viewer's collapsed "Claude replied" rows tell
+// you what's inside before you open them. Both are generated from REDACTED
+// text only (nothing unredacted leaves this machine), cached across runs
+// (transcripts are append-only, so cached keys never go stale), and passed
+// through the same fail-closed verifier as everything else. Without a key the
+// index falls back to each session's first prompt and the viewer to each
+// reply's first line: the tool never *requires* AI (CLAUDE.md: built to
+// outlive it).
+//
+// Output layout (uploaded to the status bucket under prompts/):
+// <dir>/prompts/index.json — session index + redaction tallies
+// <dir>/prompts/prompts.json — every prompt (capped text), for the page's
+// search / pagination / by-contributor views
+// <dir>/prompts/<session>.jsonl — the redacted transcript, original shape
+//
+// FAIL-CLOSED CONTRACT: after building, every output file is re-scanned with
+// the same detector set (plus sentinel phrases from CLAUDE.local.md); any hit
+// aborts the build with a non-zero exit. Nothing partial is ever left behind
+// for an upload script to grab: outputs are written to a temp dir and moved
+// into place only after the whole corpus passes.
+//
+// Known limits (documented, not hidden): base64 blobs are removed wholesale
+// because images can't be text-audited; the high-entropy backstop over-redacts
+// (ciphertext samples, wrapped keys, message ids look exactly like tokens —
+// losing those from a public copy costs nothing); a std-base64 secret whose
+// '/' lands unluckily can split below the backstop's length floor — the
+// contextual rules (env assignments, auth headers, url params, known
+// prefixes) are the real net for labelled secrets.
+import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, rmSync, renameSync, statSync } from "node:fs";
+import { execSync } from "node:child_process";
+import { join, basename } from "node:path";
+import os from "node:os";
+
+const args = process.argv.slice(2);
+const argAll = (flag) => args.flatMap((a, i) => (a === flag && args[i + 1] ? [args[i + 1]] : []));
+const outRoot = argAll("--out")[0] || null;
+const alsoRoots = argAll("--also");
+const checkOnly = args.includes("--check-only");
+if (!outRoot) {
+ console.error("usage: node tools/prompt-registry.mjs --out <dir> [--check-only] [--also <path>]… [--contributor <name>]");
+ process.exit(1);
+}
+
+const git = (a) => execSync("git " + a, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
+
+// Same public-safe machine label as the carbon tally (never a hostname).
+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();
+const contributor = argAll("--contributor")[0] || source;
+
+// Session-level controls: withhold (tombstone) / omit (aggregate count only).
+const sessionCtl = new Map();
+for (const f of argAll("--sessions")) {
+ for (const line of readFileSync(f, "utf8").split("\n")) {
+ const t = line.trim();
+ if (!t || t.startsWith("#")) continue;
+ const [mode, sid, ...rest] = t.split(/\s+/);
+ if ((mode === "withhold" || mode === "omit") && sid) {
+ sessionCtl.set(sid, { mode, reason: rest.join(" ") || "unspecified" });
+ } else {
+ console.error("bad --sessions line (want 'withhold <id> <reason>' or 'omit <id>'): " + t);
+ process.exit(1);
+ }
+ }
+}
+let omittedCount = 0;
+
+// ---- redaction rules ---------------------------------------------------------
+// Each category carries the "why" that the annotation and the registry page
+// show. Order matters: structural blocks first (so one marker replaces a whole
+// runbook or image, not a thousand token-shaped shreds), the entropy backstop
+// last.
+const WHY = {
+ "claude-local-md": "the gitignored CLAUDE.local.md ops runbook (server addresses, token-recovery paths, secret locations) — excluded wholesale",
+ "private-key": "cryptographic private key material",
+ "binary-payload": "base64 binary (image/file) removed — binary can't be text-audited for embedded secrets",
+ "api-key": "provider API key or token (recognised key format)",
+ "jwt": "signed bearer token (JWT)",
+ "url-token": "credential-bearing URL parameter or fragment (e.g. an admin link)",
+ "auth-header": "HTTP authorization header value",
+ "env-secret": "secret-valued environment assignment (…TOKEN / KEY / SECRET / PASSWORD = …)",
+ "kv-secret": "password/token field in structured output",
+ "high-entropy": "long high-entropy string — could be a credential, redacted fail-closed",
+ "email": "an email address — people's addresses stay out of the public ledger",
+ "personal": "removed at the contributor's request — the policy allows each contributor a small personal redaction budget, and the count is published",
+ "session-withheld": "a whole session withheld by its contributor, on the record as a tombstone — allowed where sharing would cause or attract harm to a person (personal-safety), reveal secrets whose exposure could cause harm (security), or carry consequences beyond revealing the human's intent (beyond-intent)",
+};
+const mark = (cat) => "⟦redacted: " + cat + "⟧";
+
+// The CLAUDE.local.md block as embedded in every session's context reminder:
+// from its "Contents of …CLAUDE.local.md…" header to the next top-level
+// context section (or end of string).
+const LOCAL_MD_BLOCK = /Contents of [^\n]*CLAUDE\.local\.md[^\n]*\n[\s\S]*?(?=\nContents of |\n# userEmail|\n# currentDate|$)/g;
+// Sentinels: phrases that live in CLAUDE.local.md and nowhere legitimate. Any
+// survivor means the block regex missed a variant — nuke the whole containing
+// string, annotated, rather than ship it.
+const LOCAL_MD_SENTINELS = ["sync-claude-local.sh", "CLAUDE.local.md (deploy runbook)"];
+
+// Personal redactions run FIRST so a personal phrase wins over (and isn't
+// split by) the mechanical rules.
+const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+const personalRules = argAll("--personal").flatMap((f) =>
+ readFileSync(f, "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"))
+ .map((l) => ["personal", l.length > 2 && l.startsWith("/") && l.endsWith("/")
+ ? new RegExp(l.slice(1, -1), "gi") : new RegExp(escapeRe(l), "gi")]));
+
+const RULES = [
+ ...personalRules,
+ ["claude-local-md", LOCAL_MD_BLOCK],
+ ["private-key", /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g],
+ ["private-key", /\bAGE-SECRET-KEY-1[A-Z0-9]{8,}/g],
+ ["binary-payload", /(?:data:[a-z0-9.+/-]+;base64,)?[A-Za-z0-9+/=\n]{1024,}/g],
+ ["api-key", /\bsk-ant-[A-Za-z0-9_-]{8,}/g],
+ ["api-key", /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{8,}/g],
+ ["api-key", /\bwhsec_[A-Za-z0-9]{8,}/g],
+ ["api-key", /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g],
+ ["api-key", /\bgithub_pat_[A-Za-z0-9_]{20,}/g],
+ ["api-key", /\bxox[a-z]-[A-Za-z0-9-]{10,}/g],
+ ["api-key", /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g],
+ ["jwt", /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/g],
+ ["url-token", /([#?&](?:token|secret|key|auth|access_token|invite)=)[A-Za-z0-9._~%-]{6,}/gi],
+ ["email", /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g],
+ ["auth-header", /((?:Authorization|X-Api-Key|X-Auth-Token)["']?\s*[:=]\s*["']?(?:Bearer\s+|Basic\s+|token\s+)?)(?!(?:Bearer|Basic|token)\b)[A-Za-z0-9._~+/=-]{6,}/gi],
+ ["env-secret", /\b([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|APIKEY|API_KEY|PRIVATE_KEY|KEY))=(["']?)[^\s"']{6,}\2/g],
+ ["kv-secret", /("(?:password|token|secret|credential|api_key|admin_token)"\s*:\s*")([^"⟦]{6,})(")/gi],
+ // Backstop: base64url-ish runs ≥36 chars mixing upper+lower+digit (an lchat
+ // token is exactly 43 such chars). Pure hex (git SHAs) and UUIDs never mix
+ // case, so they survive.
+ ["high-entropy", /(?=[A-Za-z0-9_+-]*[A-Z])(?=[A-Za-z0-9_+-]*[a-z])(?=[A-Za-z0-9_+-]*[0-9])\b[A-Za-z0-9_+-]{36,}={0,2}\b/g],
+ ["high-entropy", /(?=[A-Za-z0-9+/]*[A-Z])(?=[A-Za-z0-9+/]*[a-z])(?=[A-Za-z0-9+/]*[0-9])\b[A-Za-z0-9+/]{44,}={0,2}\b/g],
+];
+
+function redactString(s, tally) {
+ for (const [cat, re] of RULES) {
+ re.lastIndex = 0;
+ if (!re.test(s)) continue;
+ re.lastIndex = 0;
+ s = s.replace(re, (...m) => {
+ tally[cat] = (tally[cat] || 0) + 1;
+ // Rules with capture groups keep the non-secret label (param name, env
+ // var name, header prefix) and redact only the value.
+ if (cat === "url-token" || cat === "auth-header") return m[1] + mark(cat);
+ if (cat === "env-secret") return m[1] + "=" + m[2] + mark(cat) + m[2];
+ if (cat === "kv-secret") return m[1] + mark(cat) + m[3];
+ return mark(cat);
+ });
+ }
+ for (const sn of LOCAL_MD_SENTINELS) {
+ if (s.includes(sn)) { // fail closed: the block regex missed a variant
+ tally["claude-local-md"] = (tally["claude-local-md"] || 0) + 1;
+ return mark("claude-local-md");
+ }
+ }
+ return s;
+}
+
+function walk(v, tally) {
+ if (typeof v === "string") return redactString(v, tally);
+ if (Array.isArray(v)) return v.map((x) => walk(x, tally));
+ if (v && typeof v === "object") {
+ const o = {};
+ // Keys are content too (tool results serialize hostname→stats maps and
+ // the like) — redact them the same as values.
+ for (const [k, val] of Object.entries(v)) o[redactString(k, tally)] = walk(val, tally);
+ return o;
+ }
+ return v;
+}
+
+// Post-build detector: the same rules re-run over every PARSED string of the
+// finished files — symmetric with how redaction walked them (running over the
+// serialized JSONL instead would manufacture matches out of `\t`/`\\` escape
+// sequences the parsed-string redactor never saw). Markers become a single
+// space first, so a redacted value must leave nothing behind that still
+// detects, and a marker can never join two innocent runs into a match.
+function detectStrings(v, hits, path) {
+ if (typeof v === "string") {
+ const s = v.replace(/⟦redacted: [a-z-]+⟧/g, " ");
+ for (const [cat, re] of RULES) {
+ re.lastIndex = 0;
+ if (re.test(s)) hits.push(cat + " @" + path);
+ }
+ for (const sn of LOCAL_MD_SENTINELS) if (s.includes(sn)) hits.push("sentinel:" + sn + " @" + path);
+ } else if (Array.isArray(v)) v.forEach((x, i) => detectStrings(x, hits, path + "." + i));
+ else if (v && typeof v === "object") {
+ for (const [k, val] of Object.entries(v)) {
+ detectStrings(k, hits, path + ".(key)"); // keys are content too
+ detectStrings(val, hits, path + "." + k);
+ }
+ }
+}
+function detect(text, whole) {
+ const hits = [];
+ if (whole) { // index.json is one pretty-printed document, not JSONL
+ try { detectStrings(JSON.parse(text), hits, "doc"); } catch { hits.push("unparseable doc"); }
+ return hits;
+ }
+ let n = 0;
+ for (const line of text.split("\n")) {
+ n++;
+ if (!line.trim()) continue;
+ try { detectStrings(JSON.parse(line), hits, "line" + n); } catch { hits.push("unparseable @line" + n); }
+ if (hits.length) break; // one finding fails the file; no need to keep scanning
+ }
+ return hits;
+}
+
+// ---- gather sessions -----------------------------------------------------------
+// Same dir resolution as the carbon tally: the main checkout's sanitized path
+// plus prefix-matched worktree dirs — plus any --also paths (a renamed/moved
+// checkout leaves its sessions under the old sanitized path).
+const repoRoot = join(git("rev-parse --path-format=absolute --git-common-dir").trim(), "..");
+const prefixes = [repoRoot, ...alsoRoots].map((p) => p.replace(/[^a-zA-Z0-9]/g, "-"));
+const projectsDir = join(os.homedir(), ".claude", "projects");
+const dirs = readdirSync(projectsDir).filter((d) => prefixes.some((p) => d.startsWith(p))).map((d) => join(projectsDir, d));
+
+const files = dirs.flatMap((d) =>
+ readdirSync(d).filter((f) => f.endsWith(".jsonl")).map((f) => join(d, f)));
+console.log("transcript dirs: " + dirs.length + ", sessions: " + files.length + " (machine label: " + source + ")");
+
+const tmp = outRoot + ".building";
+rmSync(tmp, { recursive: true, force: true });
+mkdirSync(join(tmp, "prompts"), { recursive: true });
+
+// Real user prompts in a user turn: plain-string content, or its text blocks —
+// skipping tool results, harness wrappers (<system-reminder>, <command-…>),
+// and interrupted-request stubs. The viewer applies the SAME predicate so a
+// prompt's ordinal here anchors to the right card there.
+const promptTexts = (msg) => {
+ const c = msg?.message?.content;
+ const texts = typeof c === "string" ? [c]
+ : Array.isArray(c) ? c.filter((b) => b.type === "text").map((b) => b.text) : [];
+ return texts.filter((t) => t && !t.startsWith("<") && !t.startsWith("Caveat:") && !t.startsWith("[Request interrupted"));
+};
+
+const sessions = [];
+const allPrompts = [];
+const sessionReplies = new Map(); // id → [{k: "line:block", t}] assistant texts worth a gist
+let grandTally = {};
+for (const file of files) {
+ const id = basename(file, ".jsonl");
+ const ctl = sessionCtl.get(id);
+ if (ctl?.mode === "omit") { omittedCount++; continue; }
+ const withheld = ctl?.mode === "withhold" ? ctl.reason : null;
+ const tally = {};
+ const outLines = [];
+ const models = new Set(); // every model that spoke in the session (incl. subagents)
+ let first = null, last = null, prompts = 0, snippet = null, lastSnippet = null;
+ for (const line of readFileSync(file, "utf8").split("\n")) {
+ if (!line.trim()) continue;
+ let obj;
+ try { obj = JSON.parse(line); } catch { continue; } // torn tail line of a live session
+ const red = walk(obj, tally);
+ if (red.timestamp) { last = red.timestamp; first = first || red.timestamp; }
+ // "<synthetic>" marks harness-synthesized stubs (errors etc.), not a model
+ if (red.type === "assistant" && red.message && red.message.model && red.message.model !== "<synthetic>") models.add(red.message.model);
+ if (red.type === "user" && !red.isMeta) {
+ for (const t of promptTexts(red)) {
+ prompts++;
+ snippet = snippet || t.slice(0, 240);
+ lastSnippet = t.slice(0, 240);
+ if (!withheld) allPrompts.push({ c: contributor, s: id, ts: red.timestamp || null, n: prompts, t: t.slice(0, 1200) });
+ }
+ }
+ // Collect substantial replies for gisting, keyed "parsedLine:block" — the
+ // viewer counts parsed lines the same way, so keys anchor across both.
+ if (!withheld && red.type === "assistant" && Array.isArray(red.message?.content)) {
+ red.message.content.forEach((b, bi) => {
+ if (b.type === "text" && b.text && b.text.length > 200) {
+ if (!sessionReplies.has(id)) sessionReplies.set(id, []);
+ sessionReplies.get(id).push({ k: outLines.length + ":" + bi, t: b.text.slice(0, 1500) });
+ }
+ });
+ }
+ outLines.push(JSON.stringify(red));
+ }
+ if (outLines.length === 0) continue;
+ if (withheld) {
+ // Tombstone: the session is on the record — when, how much, and why it's
+ // withheld — but no content, no snippets, no transcript file.
+ sessions.push({ id, source, contributor, models: [...models].sort(),
+ started_at: first, ended_at: last, prompts, withheld });
+ continue;
+ }
+ const body = outLines.join("\n") + "\n";
+ if (!checkOnly) writeFileSync(join(tmp, "prompts", id + ".jsonl"), body);
+ const redactions = Object.values(tally).reduce((a, b) => a + b, 0);
+ for (const [k, v] of Object.entries(tally)) grandTally[k] = (grandTally[k] || 0) + v;
+ sessions.push({ id, source, contributor, models: [...models].sort(),
+ started_at: first, ended_at: last, prompts,
+ lines: outLines.length, bytes: Buffer.byteLength(body), redactions,
+ redacted: tally, first_prompt: snippet, last_prompt: lastSnippet });
+}
+sessions.sort((a, b) => (b.started_at || "").localeCompare(a.started_at || ""));
+allPrompts.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
+
+// ---- one-line session summaries (Haiku, optional) --------------------------
+// Input is the session's redacted prompts only. Cached by id+line-count so a
+// re-run only summarizes new or still-growing sessions.
+const HAIKU = "claude-haiku-4-5-20251001";
+const apiKey = process.env.ANTHROPIC_API_KEY;
+const prevSummaries = new Map();
+try {
+ for (const s of JSON.parse(readFileSync(join(outRoot, "prompts", "index.json"), "utf8")).sessions) {
+ if (s.summary) prevSummaries.set(s.id + ":" + s.lines, s.summary);
+ }
+} catch {} // first run, or no prior corpus — nothing cached
+
+async function haiku(content, maxTokens) {
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
+ method: "POST",
+ headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
+ body: JSON.stringify({ model: HAIKU, max_tokens: maxTokens, messages: [{ role: "user", content }] }),
+ });
+ if (!res.ok) throw new Error("api " + res.status);
+ return (await res.json()).content?.find((b) => b.type === "text")?.text?.trim() || null;
+}
+
+// THE SUMMARY RULE (applies to any summary generator, API or subagent): if
+// the session's prompts reference a GitHub issue/PR URL, the summary ends
+// with the single most relevant one verbatim — the page renders it as a
+// titled link, so the summary carries its provenance. The URL doesn't count
+// toward the length limit (hence the wider slice below).
+async function summarize(sess) {
+ const texts = allPrompts.filter((p) => p.s === sess.id).map((p) => p.t.slice(0, 400)).reverse();
+ if (!texts.length) return null;
+ const out = await haiku(
+ "One line, at most 90 characters, plain text, no quotes or trailing period: " +
+ "what was this coding session about? Base it only on the user's prompts below. " +
+ "If the prompts reference a GitHub issue or pull-request URL, append the single " +
+ "most relevant one verbatim (full https://github.com/… URL) to the end of the " +
+ "line — it does not count toward the length limit.\n\n" +
+ texts.join("\n---\n").slice(0, 8000), 120);
+ return out ? out.split("\n")[0].slice(0, 220) : null;
+}
+
+// One-line gists of substantial assistant replies, batched 12-per-call.
+// Returns k→gist for the batch, or null when the reply didn't parse.
+async function gistBatch(batch) {
+ const out = await haiku(
+ "Each numbered block below is one assistant reply from a coding session. " +
+ "For each, write one line (at most 80 characters, plain text) saying what the reply does or concludes. " +
+ "Return ONLY a JSON array of strings, one per block, same order.\n\n" +
+ batch.map((r, i) => (i + 1) + ".\n" + r.t).join("\n\n"), 800);
+ const m = out && out.match(/\[[\s\S]*\]/);
+ const arr = m ? JSON.parse(m[0]) : null;
+ if (!Array.isArray(arr) || arr.length !== batch.length) throw new Error("bad batch shape");
+ const map = {};
+ batch.forEach((r, i) => { if (typeof arr[i] === "string" && arr[i]) map[r.k] = redactString(arr[i].slice(0, 120), {}); });
+ return map;
+}
+
+if (apiKey && !checkOnly) {
+ let done = 0, fresh = 0, failed = 0;
+ const queue = sessions.filter((s) => !s.withheld); // tombstones get no summary
+ await Promise.all(Array.from({ length: 4 }, async () => {
+ for (let s; (s = queue.shift()); ) {
+ // Carried cache values re-pass through TODAY'S rules — a redaction
+ // category added later must reach summaries cached before it existed.
+ const cached = prevSummaries.get(s.id + ":" + s.lines);
+ if (cached) s.summary = redactString(cached, {}) || undefined;
+ else {
+ try { s.summary = redactString((await summarize(s)) || "", {}) || undefined; fresh++; }
+ catch { failed++; } // no summary — the page falls back to first_prompt
+ }
+ if (++done % 50 === 0) console.log("summaries: " + done + "/" + sessions.length);
+ }
+ }));
+ console.log("summaries: " + sessions.length + " sessions (" + fresh + " new, " + failed + " failed)");
+} else if (!checkOnly) {
+ console.log("summaries: skipped (no ANTHROPIC_API_KEY) — index falls back to first prompts");
+ for (const s of sessions) {
+ if (s.withheld) continue;
+ const c = prevSummaries.get(s.id + ":" + s.lines);
+ if (c) s.summary = redactString(c, {}) || undefined; // re-pass today's rules
+ }
+}
+
+// ---- per-reply gists (<session>.sum.json) -----------------------------------
+// Cached per reply key; a keyless run still carries prior gists forward so a
+// rebuild never loses them. Transcripts are append-only, so keys never drift.
+if (!checkOnly) {
+ const loadPrior = (id) => {
+ try { return JSON.parse(readFileSync(join(outRoot, "prompts", id + ".sum.json"), "utf8")).gists || {}; }
+ catch { return {}; }
+ };
+ let calls = 0, failed = 0, total = 0, cachedN = 0;
+ const queue = [...sessionReplies.entries()];
+ await Promise.all(Array.from({ length: 4 }, async () => {
+ for (let e; (e = queue.shift()); ) {
+ const [id, list] = e;
+ const gists = loadPrior(id);
+ total += list.length;
+ cachedN += list.filter((r) => gists[r.k]).length;
+ if (apiKey) {
+ const missing = list.filter((r) => !gists[r.k]);
+ for (let i = 0; i < missing.length; i += 12) {
+ try { Object.assign(gists, await gistBatch(missing.slice(i, i + 12))); calls++; }
+ catch { failed++; } // viewer falls back to the reply's first line
+ }
+ }
+ const keep = {};
+ for (const r of list) if (gists[r.k]) {
+ const g = redactString(gists[r.k], {}); // re-pass today's rules
+ if (g) keep[r.k] = g;
+ }
+ if (Object.keys(keep).length) writeFileSync(join(tmp, "prompts", id + ".sum.json"), JSON.stringify({ gists: keep }));
+ }
+ }));
+ console.log(apiKey
+ ? "reply gists: " + total + " replies (" + cachedN + " cached), " + calls + " batch calls, " + failed + " failed"
+ : "reply gists: skipped (no ANTHROPIC_API_KEY) — " + cachedN + "/" + total + " carried from cache");
+}
+
+// ---- GitHub issue titles (optional — needs an authed gh CLI) ---------------
+// The repo is private, so viewers can't resolve titles client-side; resolve
+// them here, cache across runs, scrub, and let the verifier scan the result.
+// URLs come from prompts and summaries only (not whole transcripts).
+const ISSUE_RE = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/\d+/g;
+if (!checkOnly) {
+ const urls = new Set();
+ for (const p of allPrompts) for (const m of p.t.matchAll(ISSUE_RE)) urls.add(m[0]);
+ for (const s of sessions) {
+ if (s.withheld) continue;
+ for (const f of [s.summary, s.first_prompt, s.last_prompt]) {
+ if (f) for (const m of f.matchAll(ISSUE_RE)) urls.add(m[0]);
+ }
+ }
+ let issues = {};
+ try { issues = JSON.parse(readFileSync(join(outRoot, "prompts", "issues.json"), "utf8")); } catch {}
+ let fetched = 0, failedI = 0;
+ for (const u of urls) {
+ if (issues[u]) continue;
+ const m = /github\.com\/([\w.-]+\/[\w.-]+)\/(?:issues|pull)\/(\d+)/.exec(u);
+ try {
+ // /issues/{n} serves PR titles too.
+ const title = execSync("gh api repos/" + m[1] + "/issues/" + m[2] + " --jq .title",
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
+ if (title) { issues[u] = { repo: m[1], n: Number(m[2]), title: redactString(title, {}).slice(0, 140) }; fetched++; }
+ } catch { failedI++; } // no gh / no access — the page falls back to a bare link
+ }
+ const keepI = {};
+ for (const u of urls) if (issues[u]) keepI[u] = issues[u];
+ writeFileSync(join(tmp, "prompts", "issues.json"), JSON.stringify(keepI));
+ console.log("issue titles: " + Object.keys(keepI).length + "/" + urls.size + " resolved (" + fetched + " fetched, " + failedI + " failed)");
+}
+
+const index = {
+ generated_at: new Date().toISOString(),
+ source,
+ note: "Every Claude Code session transcript behind this codebase from this machine, credentials redacted and annotated in place. Local transcripts prune after roughly a week, so the registry accrues per run; sessions predating the tool's first run are lost to pruning.",
+ why: WHY,
+ totals: { sessions: sessions.length, prompts: sessions.reduce((a, s) => a + s.prompts, 0),
+ redactions: sessions.reduce((a, s) => a + (s.redactions || 0), 0),
+ withheld: sessions.filter((s) => s.withheld).length, omitted: omittedCount },
+ sessions,
+};
+if (!checkOnly) {
+ writeFileSync(join(tmp, "prompts", "index.json"), JSON.stringify(index, null, 1));
+ writeFileSync(join(tmp, "prompts", "prompts.json"),
+ JSON.stringify({ generated_at: index.generated_at, prompts: allPrompts }));
+}
+
+// ---- fail-closed verification --------------------------------------------------
+let bad = 0;
+if (!checkOnly) {
+ for (const f of readdirSync(join(tmp, "prompts"))) {
+ const hits = detect(readFileSync(join(tmp, "prompts", f), "utf8"), f.endsWith(".json"));
+ if (hits.length) { bad++; console.error("LEAK " + f + ": " + hits.join(", ")); }
+ }
+ if (bad) {
+ console.error("\n" + bad + " file(s) failed post-redaction scan — corpus NOT published, left in " + tmp);
+ process.exit(1);
+ }
+ rmSync(join(outRoot, "prompts"), { recursive: true, force: true });
+ mkdirSync(outRoot, { recursive: true });
+ renameSync(join(tmp, "prompts"), join(outRoot, "prompts"));
+ rmSync(tmp, { recursive: true, force: true });
+}
+
+const mb = (n) => (n / 1048576).toFixed(1) + " MB";
+console.log("sessions: " + sessions.length + ", prompts: " + index.totals.prompts +
+ ", redactions: " + index.totals.redactions + " " + JSON.stringify(grandTally));
+console.log("corpus: " + mb(sessions.reduce((a, s) => a + (s.bytes || 0), 0)) +
+ (checkOnly ? " (check-only, nothing written)" : " → " + join(outRoot, "prompts")));
diff --git a/web/status/prompts.html b/web/status/prompts.html
new file mode 100644
index 0000000..97997a8
--- /dev/null
+++ b/web/status/prompts.html
@@ -0,0 +1,566 @@
+<!-- 🤖 Public prompt registry: every Claude Code session transcript behind this
+ codebase, published whole with credentials redacted and each redaction
+ annotated in place (tools/prompt-registry.mjs builds the corpus; the
+ redaction bar and its rationale live in docs/status.md § The prompt
+ registry). Fully self-contained: inline CSS/JS, no external requests
+ except ./prompts/index.json, ./prompts/prompts.json and
+ ./prompts/<session>.jsonl (relative). Unlike fleet/books, the corpus is
+ accrued data uploaded from a contributor machine, republished as-is —
+ never generated on the box. -->
+<!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 prompt registry</title>
+<link rel="icon" type="image/png" href="./icon-192.png" />
+<style>
+ /* Same validated palette roles as index.html / carbon.html. */
+ :root {
+ --plane: #f9f9f7; --surface: #fcfcfb;
+ --ink: #0b0b0b; --ink-2: #52514e;
+ --grid: #e1e0d9; --border: rgba(11,11,11,0.10);
+ --series: #4a3aa7;
+ --bad: #b23030;
+ --link: #4a3aa7;
+ --redact-bg: #f3e9d8; --redact-ink: #7a5b16;
+ }
+ @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;
+ --link: #9085e9;
+ --redact-bg: #3a3220; --redact-ink: #e0c274;
+ }
+ }
+ * { 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: 860px; margin: 0 auto; }
+ a { color: var(--link); }
+ a:focus-visible, summary:focus-visible, button:focus-visible, input:focus-visible {
+ outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px;
+ }
+ h1 { font-size: 1.5rem; margin: 8px 0 4px; }
+ h2 { font-size: 1.1rem; margin: 28px 0 8px; }
+ .sub, .meta { color: var(--ink-2); font-size: 0.9rem; }
+ .card {
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: 10px; padding: 14px 16px; margin: 14px 0;
+ }
+ .policy { border-left: 4px solid var(--series); }
+ .totals { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 24px; }
+ .totals div b { font-size: 1.3rem; display: block; font-variant-numeric: tabular-nums; }
+ /* search + contributor filter */
+ .controls { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
+ .controls input[type="search"] {
+ flex: 1 1 240px; min-height: 36px; padding: 4px 12px;
+ background: var(--surface); color: var(--ink);
+ border: 1px solid var(--grid); border-radius: 8px; font: inherit;
+ }
+ button.chip {
+ min-height: 32px; padding: 2px 12px; cursor: pointer; font: inherit; font-size: 0.9rem;
+ background: var(--surface); color: var(--ink);
+ border: 1px solid var(--grid); border-radius: 999px;
+ }
+ button.chip[aria-pressed="true"] { border-color: var(--series); color: var(--series); font-weight: 600; }
+ /* prompt list */
+ ol.prompts { list-style: none; padding: 0; }
+ ol.prompts li { border-bottom: 1px solid var(--grid); padding: 10px 0; }
+ ol.prompts .when { white-space: nowrap; color: var(--ink-2); font-size: 0.85rem; }
+ ol.prompts .text { overflow-wrap: anywhere; }
+ details.card > summary { font-weight: 600; }
+ details.card[open] > summary { margin-bottom: 4px; }
+ .controls input[type="date"] {
+ min-height: 36px; padding: 4px 8px; font: inherit; font-size: 0.9rem;
+ background: var(--surface); color: var(--ink);
+ border: 1px solid var(--grid); border-radius: 8px;
+ }
+ .controls label { color: var(--ink-2); font-size: 0.9rem; }
+ .table-scroll { overflow-x: auto; } /* wide table scrolls itself, never the page */
+ table { width: 100%; border-collapse: collapse; font-size: 0.92rem; }
+ #sessions-table th:first-child, #sessions-table td:first-child { white-space: nowrap; }
+ #sessions-table tbody tr.clickable { cursor: pointer; }
+ #sessions-table tbody tr.clickable:hover { background: rgba(127, 127, 127, 0.08); }
+ th, td { text-align: left; padding: 8px 10px 8px 0; border-bottom: 1px solid var(--grid); vertical-align: top; }
+ th { color: var(--ink-2); font-weight: 600; }
+ td.num, th.num { text-align: right; padding-right: 12px; white-space: nowrap; font-variant-numeric: tabular-nums; }
+ .snippet { color: var(--ink-2); overflow-wrap: anywhere; }
+ .lbl { color: var(--ink-2); font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.04em; margin-right: 6px; }
+ .subline { font-size: 0.82rem; }
+ .when-sub { font-size: 0.78rem; color: var(--ink-2); }
+ .when-sub a { color: inherit; }
+ mark.redaction {
+ background: var(--redact-bg); color: var(--redact-ink);
+ border-radius: 4px; padding: 0 4px; font-size: 0.85em; white-space: nowrap;
+ }
+ a.gh svg { vertical-align: -2px; margin-right: 3px; }
+ /* viewer */
+ .msg { margin: 10px 0; }
+ .prompt {
+ background: var(--surface); border: 1px solid var(--border);
+ border-left: 4px solid var(--series);
+ border-radius: 10px; padding: 10px 14px; white-space: pre-wrap; overflow-wrap: anywhere;
+ }
+ .prompt.anchored { outline: 2px solid var(--series); }
+ details { margin: 4px 0 4px 12px; }
+ summary { cursor: pointer; color: var(--ink-2); font-size: 0.9rem; }
+ details pre {
+ white-space: pre-wrap; overflow-wrap: anywhere; overflow-x: auto;
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: 8px; padding: 10px 12px; font-size: 0.85rem; margin-top: 6px;
+ }
+ .legend dt { font-weight: 600; margin-top: 8px; }
+ .legend dd { color: var(--ink-2); margin-left: 0; }
+ nav.status-nav { margin-bottom: 4px; font-size: 0.9rem; }
+ .backlink { display: inline-block; margin: 8px 0; }
+ #viewer-status[hidden], #viewer[hidden], #home[hidden] { display: none; }
+</style>
+</head>
+<body>
+<div class="wrap">
+ <nav class="status-nav" aria-label="Status pages">
+ <a href="./index.html">fleet</a> · <a href="./books.html">the books</a> ·
+ <a href="./security.html">security</a> · <a href="./carbon.html">carbon</a> ·
+ <a href="./contributors.html">contributors</a> · <strong>prompts</strong>
+ </nav>
+
+ <div id="home">
+ <h1>The prompt registry</h1>
+ <!-- 👨 Paul's wording — do not rewrite -->
+ <p class="sub">Every AI session transcript behind Eleven, published whole, secrets
+ redacted. If an AI vendor can see it, you should be able to see it too.</p>
+
+ <details class="card policy">
+ <summary>The policy — what's published, what's redacted, what can be withheld</summary>
+ <p style="margin-top:8px">🤖 <strong>Eleven is largely built with AI, and this is the provenance record.</strong>
+ Each entry below is a complete Claude Code session transcript — the prompts, the
+ model's replies, and the commands it ran. Credentials are the only thing removed:
+ every secret, token, key, and the private ops runbook are redacted in place, and
+ every redaction is annotated with why (legend below).</p>
+ <p style="margin-top:8px">🤖 Today this covers Paul's own sessions, published by his
+ choice. The intent is that <em>every contribution built with AI includes its prompt
+ transcript</em> — that becomes a requirement for all contributors only once agreed
+ with them, not by surprise.</p>
+ <p style="margin-top:8px">🤖 Contributors also keep a <em>small personal redaction
+ budget</em>: a handful of things anyone may remove for personal reasons, no
+ justification owed. Those show as <mark class="redaction">redacted: personal</mark>
+ and are counted in the open like every other redaction — the published count is
+ what keeps the budget honest.</p>
+ <p style="margin-top:8px">🤖 Sessions are in the ledger by default — opting in is what
+ contributing means here — but a whole session can be <em>withheld</em> (it stays on
+ the record below: date, prompt count, and a reason, with the content withheld) or,
+ in the rarest cases, omitted with only the aggregate count published. Withholding
+ is for what redaction can't fix: where sharing would cause or attract harm to a
+ person, would reveal secrets whose exposure could cause harm (including national
+ security), or would carry consequences beyond revealing the human's intent — the
+ thing transparency is for. One case is mandatory, not optional: any session whose
+ content could compromise the security of the centralised services (the push relay,
+ home, cloud and billing, connect, the router) is withheld whole, because a
+ credential can be redacted but a narrative that maps the defences cannot.</p>
+ </details>
+
+ <details class="card">
+ <summary>What gets redacted, and why</summary>
+ <dl class="legend" id="legend"></dl>
+ </details>
+
+ <div class="card totals" id="totals" aria-live="polite"></div>
+
+ <h2>Sessions</h2>
+ <p class="meta">Local transcripts prune after roughly a week, so the registry accrues
+ from each run of <code>tools/prompt-registry.mjs</code> forward; sessions that pruned
+ before the first run are lost. Newest first. Search matches summaries and every
+ prompt inside each session.</p>
+ <div class="controls">
+ <input type="search" id="q" placeholder="Search sessions…" aria-label="Search sessions" />
+ <span id="chips" role="group" aria-label="Filter by contributor"></span>
+ <label>from <input type="date" id="from" aria-label="Show sessions from date" /></label>
+ <label>to <input type="date" id="to" aria-label="Show sessions up to date" /></label>
+ </div>
+ <p class="meta" id="session-count" aria-live="polite"></p>
+ <div class="table-scroll">
+ <table id="sessions-table">
+ <thead><tr><th>Who</th><th>Model</th><th>Summary</th></tr></thead>
+ <tbody id="sessions"></tbody>
+ </table>
+ </div>
+ <div id="more" aria-hidden="true"></div>
+ </div>
+
+ <div id="viewer" hidden>
+ <a class="backlink" href="#" id="back">← all sessions</a>
+ <h1 id="viewer-title">Session</h1>
+ <p class="meta" id="viewer-meta"></p>
+ <p class="meta" id="viewer-status" aria-live="polite"></p>
+ <div id="messages"></div>
+ </div>
+</div>
+
+<script>
+(async function () {
+ "use strict";
+ const $ = (id) => document.getElementById(id);
+ const REDACT_RE = /⟦redacted: ([a-z-]+)⟧/g;
+ const PAGE = 25;
+ let WHY = {};
+
+ const fmtInt = (n) => Number(n).toLocaleString();
+ // "1 contributor, 2 contributors" — never a bare label beside a count.
+ const PLURAL = new Intl.PluralRules();
+ const plural = (n, one, many) => PLURAL.select(n) === "one" ? one : (many || one + "s");
+ const nOf = (n, one, many) => fmtInt(n) + " " + plural(n, one, many);
+ const fmtBytes = (n) => n > 1048576 ? (n / 1048576).toLocaleString(undefined, { maximumFractionDigits: 1 }) + " MB" : fmtInt(Math.round(n / 1024)) + " KB";
+ // "12 Jul 2026 09:32" — composed from date + time parts, no "at".
+ const fmtDate = (iso) => {
+ if (!iso) return "—";
+ const d = new Date(iso);
+ return d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) +
+ " " + d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
+ };
+ // Friendly model family for display; the exact model id rides in title.
+ const modelName = (id) => {
+ const m = /claude-([a-z]+)/.exec(id);
+ return m ? m[1][0].toUpperCase() + m[1].slice(1) : id;
+ };
+
+ // GitHub issue/PR links render as icon-prefixed titled links (titles are
+ // build-time resolved into issues.json — the repo is private, so viewers
+ // can't fetch them). ICON_GH is a static in-code string (octicon mark).
+ const GH_RE = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/\d+/g;
+ const ICON_GH = "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z";
+ let ISSUES = {};
+ function ghLink(url) {
+ const a = document.createElement("a");
+ a.href = url; a.target = "_blank"; a.rel = "noopener"; a.className = "gh";
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("viewBox", "0 0 16 16");
+ svg.setAttribute("width", "14"); svg.setAttribute("height", "14");
+ svg.setAttribute("aria-hidden", "true");
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("fill", "currentColor");
+ path.setAttribute("d", ICON_GH);
+ svg.appendChild(path);
+ a.appendChild(svg);
+ // Compact link text — #457 (or lchat#1 cross-repo); the issue title
+ // appears on hover via title="".
+ const info = ISSUES[url];
+ const m = /github\.com\/[\w.-]+\/([\w.-]+)\/(?:issues|pull)\/(\d+)/.exec(url);
+ const home = info ? info.repo === "elevenmessenger/messenger" : m && m[1] === "messenger";
+ a.appendChild(document.createTextNode(m ? (home ? "" : m[1]) + "#" + m[2] : url));
+ a.title = info ? info.title : url;
+ return a;
+ }
+ function appendPlain(el, text) {
+ let last = 0;
+ for (const m of text.matchAll(GH_RE)) {
+ el.appendChild(document.createTextNode(text.slice(last, m.index)));
+ el.appendChild(ghLink(m[0]));
+ last = m.index + m[0].length;
+ }
+ el.appendChild(document.createTextNode(text.slice(last)));
+ }
+
+ // Append text into el with ⟦redacted:…⟧ markers rendered as annotated marks
+ // and GitHub links as titled icon links. DOM nodes only — transcript content
+ // must never reach innerHTML.
+ function appendRedactable(el, text) {
+ let last = 0;
+ for (const m of text.matchAll(REDACT_RE)) {
+ appendPlain(el, text.slice(last, m.index));
+ const mk = document.createElement("mark");
+ mk.className = "redaction";
+ mk.textContent = "redacted: " + m[1];
+ mk.title = WHY[m[1]] || "";
+ el.appendChild(mk);
+ last = m.index + m[0].length;
+ }
+ appendPlain(el, text.slice(last));
+ }
+
+ function collapsible(label, text) {
+ const d = document.createElement("details");
+ const s = document.createElement("summary");
+ s.textContent = label;
+ d.appendChild(s);
+ const pre = document.createElement("pre");
+ appendRedactable(pre, text.length > 200000 ? text.slice(0, 200000) + "\n… (truncated view — full text is in the JSONL)" : text);
+ d.appendChild(pre);
+ return d;
+ }
+
+ let index, promptsDoc;
+ try {
+ index = await (await fetch("./prompts/index.json")).json();
+ promptsDoc = await (await fetch("./prompts/prompts.json")).json();
+ } catch {
+ $("totals").textContent = "The registry corpus hasn’t been uploaded yet.";
+ return;
+ }
+ try {
+ const r = await fetch("./prompts/issues.json");
+ if (r.ok) ISSUES = await r.json();
+ } catch {} // no titles — GitHub links fall back to repo#n
+ WHY = index.why || {};
+ const allPrompts = promptsDoc.prompts || [];
+ const contributors = [...new Set(allPrompts.map((p) => p.c).filter(Boolean))];
+
+ // legend
+ for (const [cat, why] of Object.entries(WHY)) {
+ const dt = document.createElement("dt"); dt.textContent = cat;
+ const dd = document.createElement("dd"); dd.textContent = why;
+ $("legend").append(dt, dd);
+ }
+
+ // totals — withheld/omitted appear only when non-zero, so the common case
+ // stays a clean four tiles.
+ const t = index.totals;
+ const tiles = [[plural(t.sessions, "session"), fmtInt(t.sessions)],
+ [plural(t.prompts, "prompt"), fmtInt(t.prompts)],
+ [plural(t.redactions, "redaction"), fmtInt(t.redactions)],
+ contributors.length ? [plural(contributors.length, "contributor"), fmtInt(contributors.length)]
+ : ["machine", index.source]];
+ if (t.withheld) tiles.push(["withheld", fmtInt(t.withheld)]);
+ if (t.omitted) tiles.push(["omitted", fmtInt(t.omitted)]);
+ for (const [label, val] of tiles) {
+ const div = document.createElement("div");
+ const b = document.createElement("b"); b.textContent = String(val);
+ div.appendChild(b); div.appendChild(document.createTextNode(label));
+ $("totals").appendChild(div);
+ }
+
+ // ---- one sessions list: contributor chips + deep search + pagination ----
+ // Search matches a session's summary/snippets AND every prompt inside it
+ // (prompts.json), but the results are sessions.
+ const filter = { q: "", c: null, from: "", to: "" };
+
+ const promptTextBySession = new Map();
+ for (const p of allPrompts) {
+ promptTextBySession.set(p.s, (promptTextBySession.get(p.s) || "") + "\n" + (p.t || "").toLowerCase());
+ }
+
+ function chip(label, value) {
+ const b = document.createElement("button");
+ b.className = "chip"; b.type = "button";
+ b.textContent = label;
+ b.setAttribute("aria-pressed", String(filter.c === value));
+ b.addEventListener("click", () => {
+ filter.c = value;
+ for (const c of $("chips").children) c.setAttribute("aria-pressed", "false");
+ b.setAttribute("aria-pressed", "true");
+ renderSessions();
+ });
+ return b;
+ }
+ $("chips").appendChild(chip("everyone", null));
+ for (const c of contributors) {
+ const count = index.sessions.filter((s) => s.contributor === c).length;
+ $("chips").appendChild(chip(c + " (" + fmtInt(count) + ")", c));
+ }
+ $("chips").children[0].setAttribute("aria-pressed", "true");
+
+ function sessionMatches(s) {
+ if (filter.c && s.contributor !== filter.c) return false;
+ // Date range compares ISO date prefixes — cheap and TZ-tolerant enough
+ // for a day-granular filter.
+ const day = (s.started_at || "").slice(0, 10);
+ if (filter.from && (!day || day < filter.from)) return false;
+ if (filter.to && (!day || day > filter.to)) return false;
+ if (!filter.q) return true;
+ const meta = [s.summary, s.first_prompt, s.last_prompt, s.withheld, (s.models || []).join(" ")]
+ .filter(Boolean).join("\n").toLowerCase();
+ return meta.includes(filter.q) || (promptTextBySession.get(s.id) || "").includes(filter.q);
+ }
+
+ let debounce;
+ $("q").addEventListener("input", () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => {
+ filter.q = $("q").value.trim().toLowerCase();
+ renderSessions();
+ }, 150);
+ });
+ for (const id of ["from", "to"]) {
+ $(id).addEventListener("change", () => { filter[id] = $(id).value; renderSessions(); });
+ }
+
+ const byId = new Map(index.sessions.map((s) => [s.id, s]));
+ // Infinite scroll: filtered rows append in PAGE-sized batches as the
+ // sentinel below the table approaches the viewport.
+ let hits = [], shown = 0;
+ function buildRow(s) {
+ const tr = document.createElement("tr");
+ if (!s.withheld) {
+ // Whole row opens the session; the date link inside stays the
+ // keyboard path. Real links (GitHub etc.) keep their own click.
+ tr.className = "clickable";
+ tr.addEventListener("click", (e) => {
+ if (e.target.closest("a")) return;
+ location.hash = "#s=" + s.id;
+ });
+ }
+ // Who: name with the date beneath, de-emphasised; the date is the link
+ // into the session (tombstones get plain text — nothing to open).
+ const tdWho = document.createElement("td");
+ const who = document.createElement("div");
+ who.textContent = s.contributor || s.source || "—";
+ tdWho.appendChild(who);
+ const when = document.createElement("div");
+ when.className = "when-sub";
+ if (s.withheld) {
+ when.textContent = fmtDate(s.started_at);
+ } else {
+ const a = document.createElement("a");
+ a.href = "#s=" + s.id;
+ a.textContent = fmtDate(s.started_at);
+ when.appendChild(a);
+ }
+ tdWho.appendChild(when);
+ tr.appendChild(tdWho);
+ const tdModel = document.createElement("td");
+ tdModel.className = "meta";
+ tdModel.textContent = (s.models || []).map(modelName).join(", ") || "—";
+ tdModel.title = (s.models || []).join(", "); // exact ids on hover
+ tr.appendChild(tdModel);
+ if (s.withheld) {
+ const tdW = document.createElement("td");
+ const mk = document.createElement("mark");
+ mk.className = "redaction";
+ mk.textContent = "session withheld — " + s.withheld;
+ mk.title = WHY["session-withheld"] || "";
+ tdW.appendChild(mk);
+ tdW.appendChild(document.createTextNode(" " + nOf(s.prompts, "prompt") + ", content not published"));
+ tr.appendChild(tdW);
+ return tr;
+ }
+ // Labelled lines so a summary is never mistaken for a prompt: the Haiku
+ // summary headlines when present, with the first (and latest) prompts
+ // beneath; without one, the first prompt headlines under its own label.
+ const tdSum = document.createElement("td");
+ const line = (label, text, muted) => {
+ const div = document.createElement("div");
+ if (muted) div.className = "snippet subline";
+ const l = document.createElement("span"); l.className = "lbl"; l.textContent = label;
+ div.appendChild(l);
+ appendRedactable(div, text);
+ tdSum.appendChild(div);
+ };
+ if (s.summary) {
+ line("summary", s.summary, false);
+ if (s.first_prompt) line("first", s.first_prompt, true);
+ } else {
+ line("first prompt", s.first_prompt || "(no text prompt — resumed or tool-driven session)", false);
+ }
+ if (s.last_prompt && s.prompts > 1) line("latest", s.last_prompt, true);
+ tr.appendChild(tdSum);
+ return tr;
+ }
+ function addMore() {
+ const body = $("sessions");
+ for (const s of hits.slice(shown, shown + PAGE)) body.appendChild(buildRow(s));
+ shown = Math.min(shown + PAGE, hits.length);
+ }
+ function renderSessions() {
+ hits = index.sessions.filter(sessionMatches);
+ $("session-count").textContent = fmtInt(hits.length) + " of " + nOf(index.sessions.length, "session");
+ $("sessions").textContent = "";
+ shown = 0;
+ addMore();
+ }
+ new IntersectionObserver((entries) => {
+ for (const e of entries) if (e.isIntersecting && shown < hits.length) addMore();
+ }, { rootMargin: "600px" }).observe($("more"));
+ renderSessions();
+
+ // ---- session viewer ----
+ const textBlocks = (c) => typeof c === "string" ? [{ type: "text", text: c }] : Array.isArray(c) ? c : [];
+ // Same predicate as the builder, so prompt ordinals anchor correctly.
+ const isPrompt = (t) => t && !t.startsWith("<") && !t.startsWith("Caveat:") && !t.startsWith("[Request interrupted");
+
+ async function showSession(id, anchorN) {
+ const s = byId.get(id);
+ if (!s || s.withheld) { location.hash = ""; return; }
+ $("home").hidden = true; $("viewer").hidden = false;
+ $("viewer-title").textContent = "Session " + id.slice(0, 8);
+ $("viewer-meta").textContent = fmtDate(s.started_at) + " → " + fmtDate(s.ended_at) +
+ " · " + nOf(s.prompts, "prompt") + " · " + nOf(s.redactions, "redaction") + " · " + fmtBytes(s.bytes) +
+ ((s.models || []).length ? " · " + s.models.join(", ") : "");
+ const box = $("messages");
+ box.textContent = "";
+ $("viewer-status").hidden = false;
+ $("viewer-status").textContent = "Loading transcript (" + fmtBytes(s.bytes) + ")…";
+ let raw, gists = {};
+ try {
+ raw = await (await fetch("./prompts/" + encodeURIComponent(id) + ".jsonl")).text();
+ } catch {
+ $("viewer-status").textContent = "Couldn’t load the transcript.";
+ return;
+ }
+ try {
+ const r = await fetch("./prompts/" + encodeURIComponent(id) + ".sum.json");
+ if (r.ok) gists = (await r.json()).gists || {};
+ } catch {} // no gists — labels fall back to each reply's first line
+ $("viewer-status").hidden = true;
+ let anchored = null, promptN = 0, li = -1; // li counts parsed lines, matching the builder's keys
+ for (const line of raw.split("\n")) {
+ if (!line.trim()) continue;
+ let o; try { o = JSON.parse(line); } catch { continue; }
+ li++;
+ if (o.type === "user" && !o.isMeta) {
+ for (const b of textBlocks(o.message && o.message.content)) {
+ if (b.type === "text" && b.text) {
+ const div = document.createElement("div"); div.className = "msg";
+ if (!isPrompt(b.text)) {
+ div.appendChild(collapsible("context sent with the prompt", b.text));
+ } else {
+ promptN++;
+ const p = document.createElement("div"); p.className = "prompt";
+ p.tabIndex = -1;
+ appendRedactable(p, b.text); div.appendChild(p);
+ if (promptN === anchorN) { p.classList.add("anchored"); anchored = p; }
+ }
+ box.appendChild(div);
+ } else if (b.type === "tool_result") {
+ const t = textBlocks(b.content).filter((x) => x.type === "text").map((x) => x.text).join("\n");
+ if (t) box.appendChild(collapsible("tool result", t));
+ }
+ }
+ } else if (o.type === "assistant") {
+ const blocks = textBlocks(o.message && o.message.content);
+ for (let bi = 0; bi < blocks.length; bi++) {
+ const b = blocks[bi];
+ if (b.type === "text" && b.text) {
+ // Haiku gist when the corpus has one; the reply's own first line
+ // is the AI-free fallback — either way the collapsed row says
+ // what's inside before you open it.
+ const gist = gists[li + ":" + bi] || b.text.split("\n").find((l) => l.trim())?.slice(0, 80) || "";
+ box.appendChild(collapsible("Claude replied — " + gist, b.text));
+ }
+ else if (b.type === "thinking" && b.thinking) box.appendChild(collapsible("thinking", b.thinking));
+ else if (b.type === "tool_use") box.appendChild(collapsible("⚙ " + (b.name || "tool"), JSON.stringify(b.input, null, 2)));
+ }
+ }
+ }
+ if (anchored) { anchored.scrollIntoView({ block: "center" }); anchored.focus({ preventScroll: true }); }
+ else window.scrollTo(0, 0);
+ }
+
+ function route() {
+ const m = location.hash.match(/^#s=([A-Za-z0-9-]+)(?:&p=(\d+))?$/);
+ if (m) showSession(m[1], m[2] ? Number(m[2]) : null);
+ else { $("viewer").hidden = true; $("home").hidden = false; }
+ }
+ $("back").addEventListener("click", (e) => { e.preventDefault(); location.hash = ""; });
+ addEventListener("hashchange", route);
+ route();
+})();
+</script>
+</body>
+</html>