#!/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)\"");
}