#!/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 # build the corpus // node tools/prompt-registry.mjs --out --check-only # scan, no write // --also 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 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 contributor's personal redaction list: one literal // string (or /regex/) per line, removed as // ⟦redacted: personal⟧. A /regex/ line also matches // itself literally, so a path-shaped literal is // never silently reinterpreted as a pattern. 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 session-level controls. Sessions are in the // ledger by default (opting in is what contributing // means), but per line a contributor may: // withhold — publish a // tombstone (date, prompt count, reason) with // the content withheld // omit — 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 // (.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/): // /prompts/index.json — session index + redaction tallies // /prompts/prompts.json — every prompt (capped text), for the page's // search / pagination / by-contributor views // /prompts/.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 { createHash } from "node:crypto"; 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 checkMode = args.includes("check"); const outRoot = argAll("--out")[0] || (checkMode ? join(os.homedir(), ".cache", "eleven-registry") : null); const alsoRoots = argAll("--also"); const checkOnly = args.includes("--check-only"); if (!outRoot) { console.error("usage: node tools/prompt-registry.mjs --out [check] [--check-only] [--also ]… [--contributor ]"); process.exit(1); } const git = (a) => execSync("git " + a, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); // ---- `check` — the SessionStart staleness nudge ------------------------------ // Mirrors carbon-tally.mjs `check`: no transcript sweep, no redaction — read // the last build's generated_at and print only when this machine is sitting on // unpublished sessions going stale (stdout lands in the agent's context; the // raw transcripts prune after ~a week). A machine with no corpus at --out // (default ~/.cache/eleven-registry) stays silent: publishing is opt-in // (docs/status.md § The prompt registry), so the nudge follows that choice, // never precedes it. Never fails the session. const STALE_DAYS = Number(process.env.LCHAT_REGISTRY_STALE_DAYS || 3); if (checkMode) { try { const promptsDir = join(outRoot, "prompts"); // Newest generated_at across slug corpora (plus the pre-multi flat layout). const built = Math.max(...readdirSync(promptsDir, { withFileTypes: true }) .map((d) => (d.isDirectory() ? join(promptsDir, d.name, "index.json") : d.name === "index.json" ? join(promptsDir, d.name) : null)) .filter((p) => p && existsSync(p)) .map((p) => Date.parse(JSON.parse(readFileSync(p, "utf8")).generated_at || ""))); const days = (Date.now() - built) / 86400e3; if (Number.isFinite(days) && days > STALE_DAYS) { // Only nag over real material: any of this repo's transcripts touched // since the last build (mtime scan — same dir resolution as the build). const root = join(git("rev-parse --path-format=absolute --git-common-dir").trim(), ".."); const prefix = root.replace(/[^a-zA-Z0-9]/g, "-"); const projects = join(os.homedir(), ".claude", "projects"); const grown = readdirSync(projects).filter((d) => d.startsWith(prefix)) .flatMap((d) => readdirSync(join(projects, d)).filter((f) => f.endsWith(".jsonl")).map((f) => join(projects, d, f))) .filter((f) => statSync(f).mtimeMs > built).length; if (grown) console.log("Prompt registry is stale: this machine's last build is " + Math.floor(days) + " days old and " + grown + " session transcript(s) have been touched since. Local transcripts prune " + "after ~a week, so, when convenient this session, accrue it: security sweep, build, haiku-subagent " + "summaries for new sessions, rebuild, publish — docs/status.md § The prompt registry (this machine's " + "exact flags are in the operator's notes)."); } } catch {} // no corpus yet, or unreadable — not a contributor machine; stay quiet process.exit(0); } // 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; // Multi-contributor layout: each machine publishes under its own slug prefix // (prompts//…) so corpora never clobber each other; the page merges // every contributor listed in prompts/contributors.json. const slug = contributor.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || 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 ' or 'omit '): " + 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, "\\$&"); // A slash-wrapped line is a /regex/ — but it ALSO gets a literal rule (first, // so a whole-literal occurrence is redacted in one piece before the pattern // nibbles at substrings). A literal that merely LOOKS slash-wrapped (a path, // say) would otherwise be silently reinterpreted as a pattern that can miss // its own text — and the fail-closed verifier shares these rules, so it can't // catch what a rule can't match (this leaked a personal string three times // before the 2026-07-27 accrual caught it by literal grep). const personalRules = argAll("--personal").flatMap((f) => readFileSync(f, "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")) .flatMap((l) => l.length > 2 && l.startsWith("/") && l.endsWith("/") ? [["personal", new RegExp(escapeRe(l), "gi")], ["personal", new RegExp(l.slice(1, -1), "gi")]] : [["personal", 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", slug), { recursive: true }); // Cache reads prefer the slug prefix, falling back to the pre-multi flat // layout so an existing corpus's summaries/gists survive the migration. const priorPath = (f) => { const p = join(outRoot, "prompts", slug, f); return existsSync(p) ? p : join(outRoot, "prompts", f); }; // Real user prompts in a user turn: plain-string content, or its text blocks — // skipping tool results, harness wrappers (, ), // 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; } // "" marks harness-synthesized stubs (errors etc.), not a model if (red.type === "assistant" && red.message && red.message.model && red.message.model !== "") 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", slug, 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(priorPath("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 (.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(priorPath(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", slug, 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(priorPath("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", slug, "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", slug, "index.json"), JSON.stringify(index, null, 1)); writeFileSync(join(tmp, "prompts", slug, "prompts.json"), JSON.stringify({ generated_at: index.generated_at, prompts: allPrompts })); } // ---- contributors manifest ---------------------------------------------------- // Merge this machine's entry into the LIVE manifest (public read, no creds // needed) so uploading it never drops other contributors. If the manifest // can't be reached, only this machine's entry is written and the log says // not to upload contributors.json from that build. const MANIFEST_URL = process.env.LCHAT_REGISTRY_MANIFEST_URL || "https://status.elevenmessenger.com/prompts/contributors.json"; let manifestLive = false; if (!checkOnly) { let manifest = { contributors: [] }; try { const r = await fetch(MANIFEST_URL); if (r.ok) { const j = await r.json(); if (Array.isArray(j.contributors)) { manifest = j; manifestLive = true; } } else if (r.status === 403 || r.status === 404) { manifestLive = true; // the bucket answered: no manifest yet — first publish } } catch {} manifest.contributors = [...manifest.contributors.filter((c) => c.slug !== slug), { slug, name: contributor }] .sort((a, b) => a.slug.localeCompare(b.slug)); writeFileSync(join(tmp, "prompts", "contributors.json"), JSON.stringify(manifest, null, 1)); } // ---- fail-closed verification -------------------------------------------------- let bad = 0; if (!checkOnly) { const files = [ ...readdirSync(join(tmp, "prompts")).filter((f) => f.endsWith(".json")).map((f) => join(tmp, "prompts", f)), ...readdirSync(join(tmp, "prompts", slug)).map((f) => join(tmp, "prompts", slug, f)), ]; for (const path of files) { const f = basename(path); const hits = detect(readFileSync(path, "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); } // The hash ledger entry: sha256 of every published file under this slug, // committed to the PUBLIC elevenmessenger/releases repo (its git history is // the tamper-evidence — see docs/status.md § The prompt registry). Written // after verification so it hashes exactly what publishes; also uploaded // with the corpus so the bucket is self-describing. const hashed = {}; for (const f of readdirSync(join(tmp, "prompts", slug)).sort()) { hashed[f] = "sha256:" + createHash("sha256").update(readFileSync(join(tmp, "prompts", slug, f))).digest("hex"); } const ledger = { generated_at: index.generated_at, contributor, slug, totals: index.totals, files: hashed }; writeFileSync(join(tmp, "prompts", slug, "manifest.sha256.json"), JSON.stringify(ledger, null, 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", slug))); if (!checkOnly) { console.log("\nsync (own prefix only, then the manifest):" + "\n aws s3 sync " + join(outRoot, "prompts", slug) + " s3:///prompts/" + slug + " --delete --cache-control max-age=300" + (manifestLive ? "\n aws s3 cp " + join(outRoot, "prompts", "contributors.json") + " s3:///prompts/contributors.json --cache-control max-age=300" : "\n ⚠ manifest built OFFLINE — do NOT upload contributors.json (it would drop other contributors); re-run online first")); console.log("ledger (public tamper-evidence — commit the hash manifest to elevenmessenger/releases):" + "\n gh api -X PUT repos/elevenmessenger/releases/contents/prompt-registry/" + slug + ".json" + " -f message='prompt-registry: accrue " + slug + "'" + " -f content=\"$(base64 -i " + join(outRoot, "prompts", slug, "manifest.sha256.json") + ")\"" + " -f sha=\"$(gh api repos/elevenmessenger/releases/contents/prompt-registry/" + slug + ".json --jq .sha 2>/dev/null || true)\""); }