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>
5 files changed,
+1214
−5
CLAUDE.md+6 −0deploy/status-publish.sh+4 −0docs/status.md+94 −5tools/prompt-registry.mjs+544 −0web/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> |