| 1 | <!-- 🤖 Public prompt registry: every Claude Code session transcript behind this |
| 2 | codebase, published whole with credentials redacted and each redaction |
| 3 | annotated in place (tools/prompt-registry.mjs builds the corpus; the |
| 4 | redaction bar and its rationale live in docs/status.md § The prompt |
| 5 | registry). Fully self-contained: inline CSS/JS, no external requests |
| 6 | except ./prompts/index.json, ./prompts/prompts.json and |
| 7 | ./prompts/<session>.jsonl (relative). Unlike fleet/books, the corpus is |
| 8 | accrued data uploaded from a contributor machine, republished as-is — |
| 9 | never generated on the box. --> |
| 10 | <!doctype html> |
| 11 | <html lang="en"> |
| 12 | <head> |
| 13 | <meta charset="utf-8" /> |
| 14 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 15 | <meta name="color-scheme" content="light dark" /> |
| 16 | <title>Eleven — the prompt registry</title> |
| 17 | <link rel="icon" type="image/png" href="./icon-192.png" /> |
| 18 | <link rel="stylesheet" href="./status.css" /> |
| 19 | <style> |
| 20 | /* Prompt-ledger-only styles; the tokens, reset, masthead, and the shared |
| 21 | nav live in status.css. This page is wider than the others and adds the |
| 22 | search controls, the sessions table, and the transcript viewer. */ |
| 23 | .wrap { max-width: 860px; margin: 0 auto; } |
| 24 | a:focus-visible, summary:focus-visible, button:focus-visible, input:focus-visible { |
| 25 | outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; |
| 26 | } |
| 27 | #viewer-title { font-size: 1.5rem; margin: 8px 0 4px; } |
| 28 | h2 { font-size: 1.1rem; margin: 28px 0 8px; } |
| 29 | .sub, .meta { color: var(--ink-2); font-size: 0.9rem; } |
| 30 | .card { |
| 31 | background: var(--surface); border: 1px solid var(--border); |
| 32 | border-radius: 10px; padding: 14px 16px; margin: 14px 0; |
| 33 | } |
| 34 | .policy { border-left: 4px solid var(--series); } |
| 35 | .totals { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 24px; } |
| 36 | .totals div b { font-size: 1.3rem; display: block; font-variant-numeric: tabular-nums; } |
| 37 | /* search + contributor filter */ |
| 38 | .controls { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin: 12px 0; } |
| 39 | .controls input[type="search"] { |
| 40 | flex: 1 1 240px; min-height: 36px; padding: 4px 12px; |
| 41 | background: var(--surface); color: var(--ink); |
| 42 | border: 1px solid var(--grid); border-radius: 8px; font: inherit; |
| 43 | } |
| 44 | button.chip { |
| 45 | min-height: 32px; padding: 2px 12px; cursor: pointer; font: inherit; font-size: 0.9rem; |
| 46 | background: var(--surface); color: var(--ink); |
| 47 | border: 1px solid var(--grid); border-radius: 999px; |
| 48 | } |
| 49 | button.chip[aria-pressed="true"] { border-color: var(--series); color: var(--series); font-weight: 600; } |
| 50 | /* prompt list */ |
| 51 | ol.prompts { list-style: none; padding: 0; } |
| 52 | ol.prompts li { border-bottom: 1px solid var(--grid); padding: 10px 0; } |
| 53 | ol.prompts .when { white-space: nowrap; color: var(--ink-2); font-size: 0.85rem; } |
| 54 | ol.prompts .text { overflow-wrap: anywhere; } |
| 55 | details.card > summary { font-weight: 600; } |
| 56 | details.card[open] > summary { margin-bottom: 4px; } |
| 57 | .controls input[type="date"] { |
| 58 | min-height: 36px; padding: 4px 8px; font: inherit; font-size: 0.9rem; |
| 59 | background: var(--surface); color: var(--ink); |
| 60 | border: 1px solid var(--grid); border-radius: 8px; |
| 61 | } |
| 62 | .controls label { color: var(--ink-2); font-size: 0.9rem; } |
| 63 | .table-scroll { overflow-x: auto; } /* wide table scrolls itself, never the page */ |
| 64 | table { width: 100%; border-collapse: collapse; font-size: 0.92rem; } |
| 65 | #sessions-table th:first-child, #sessions-table td:first-child { white-space: nowrap; } |
| 66 | #sessions-table tbody tr.clickable { cursor: pointer; } |
| 67 | #sessions-table tbody tr.clickable:hover { background: rgba(127, 127, 127, 0.08); } |
| 68 | th, td { text-align: left; padding: 8px 10px 8px 0; border-bottom: 1px solid var(--grid); vertical-align: top; } |
| 69 | th { color: var(--ink-2); font-weight: 600; } |
| 70 | td.num, th.num { text-align: right; padding-right: 12px; white-space: nowrap; font-variant-numeric: tabular-nums; } |
| 71 | .snippet { color: var(--ink-2); overflow-wrap: anywhere; } |
| 72 | .lbl { color: var(--ink-2); font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.04em; margin-right: 6px; } |
| 73 | .subline { font-size: 0.82rem; } |
| 74 | .when-sub { font-size: 0.78rem; color: var(--ink-2); } |
| 75 | .when-sub a { color: inherit; } |
| 76 | mark.redaction { |
| 77 | background: var(--redact-bg); color: var(--redact-ink); |
| 78 | border-radius: 4px; padding: 0 4px; font-size: 0.85em; white-space: nowrap; |
| 79 | } |
| 80 | a.gh svg { vertical-align: -2px; margin-right: 3px; } |
| 81 | /* viewer */ |
| 82 | .msg { margin: 10px 0; } |
| 83 | .prompt { |
| 84 | background: var(--surface); border: 1px solid var(--border); |
| 85 | border-left: 4px solid var(--series); |
| 86 | border-radius: 10px; padding: 10px 14px; white-space: pre-wrap; overflow-wrap: anywhere; |
| 87 | } |
| 88 | .prompt.anchored { outline: 2px solid var(--series); } |
| 89 | details { margin: 4px 0 4px 12px; } |
| 90 | summary { cursor: pointer; color: var(--ink-2); font-size: 0.9rem; } |
| 91 | details pre { |
| 92 | white-space: pre-wrap; overflow-wrap: anywhere; overflow-x: auto; |
| 93 | background: var(--surface); border: 1px solid var(--border); |
| 94 | border-radius: 8px; padding: 10px 12px; font-size: 0.85rem; margin-top: 6px; |
| 95 | } |
| 96 | .legend dt { font-weight: 600; margin-top: 8px; } |
| 97 | .legend dd { color: var(--ink-2); margin-left: 0; } |
| 98 | .backlink { display: inline-block; margin: 8px 0; } |
| 99 | #viewer-status[hidden], #viewer[hidden], #home[hidden] { display: none; } |
| 100 | </style> |
| 101 | </head> |
| 102 | <body> |
| 103 | <div class="wrap"> |
| 104 | <header data-title="The prompt registry"></header> |
| 105 | <nav class="status-nav" aria-label="Status"></nav> |
| 106 | <script src="./status-nav.js"></script> |
| 107 | |
| 108 | <div id="home"> |
| 109 | <!-- 👨 Paul's wording — do not rewrite --> |
| 110 | <p class="sub">Every AI session transcript behind Eleven, published whole, secrets |
| 111 | redacted. If an AI vendor can see it, you should be able to see it too.</p> |
| 112 | |
| 113 | <details class="card policy"> |
| 114 | <summary>The policy — what's published, what's redacted, what can be withheld</summary> |
| 115 | <p style="margin-top:8px">🤖 <strong>Eleven is largely built with AI, and this is the provenance record.</strong> |
| 116 | Each entry below is a complete Claude Code session transcript — the prompts, the |
| 117 | model's replies, and the commands it ran. Credentials are the only thing removed: |
| 118 | every secret, token, key, and the private ops runbook are redacted in place, and |
| 119 | every redaction is annotated with why (legend below).</p> |
| 120 | <p style="margin-top:8px">🤖 Today this covers Paul's own sessions, published by his |
| 121 | choice. The intent is that <em>every contribution built with AI includes its prompt |
| 122 | transcript</em> — that becomes a requirement for all contributors only once agreed |
| 123 | with them, not by surprise.</p> |
| 124 | <p style="margin-top:8px">🤖 Contributors also keep a <em>small personal redaction |
| 125 | budget</em>: a handful of things anyone may remove for personal reasons, no |
| 126 | justification owed. Those show as <mark class="redaction">redacted: personal</mark> |
| 127 | and are counted in the open like every other redaction — the published count is |
| 128 | what keeps the budget honest.</p> |
| 129 | <p style="margin-top:8px">🤖 Sessions are in the ledger by default — opting in is what |
| 130 | contributing means here — but a whole session can be <em>withheld</em> (it stays on |
| 131 | the record below: date, prompt count, and a reason, with the content withheld) or, |
| 132 | in the rarest cases, omitted with only the aggregate count published. Withholding |
| 133 | is for what redaction can't fix: where sharing would cause or attract harm to a |
| 134 | person, would reveal secrets whose exposure could cause harm (including national |
| 135 | security), or would carry consequences beyond revealing the human's intent — the |
| 136 | thing transparency is for. One case is mandatory, not optional: any session whose |
| 137 | content could compromise the security of the centralised services (the push relay, |
| 138 | home, cloud and billing, connect, the router) is withheld whole, because a |
| 139 | credential can be redacted but a narrative that maps the defences cannot.</p> |
| 140 | </details> |
| 141 | |
| 142 | <details class="card"> |
| 143 | <summary>What gets redacted, and why</summary> |
| 144 | <dl class="legend" id="legend"></dl> |
| 145 | </details> |
| 146 | |
| 147 | <p class="meta">🤖 Verifying the record: each contributor's corpus carries a |
| 148 | <code>manifest.sha256.json</code> naming the hash of every published file, and its |
| 149 | history in the public |
| 150 | <a href="https://github.com/elevenmessenger/releases/commits/main/prompt-registry">releases |
| 151 | repo's hash ledger</a> is the tamper-evidence — if a published transcript ever |
| 152 | changed after the fact, that history shows when.</p> |
| 153 | |
| 154 | <div class="card totals" id="totals" aria-live="polite"></div> |
| 155 | |
| 156 | <p class="meta">🤖 Prefer the story to the log? <a href="./milestones.html">Milestones</a> |
| 157 | — the key moments of building Eleven, each linked to the session where it |
| 158 | happened.</p> |
| 159 | |
| 160 | <h2>Sessions</h2> |
| 161 | <p class="meta">Local transcripts prune after roughly a week, so the registry accrues |
| 162 | from each run of <code>tools/prompt-registry.mjs</code> forward; sessions that pruned |
| 163 | before the first run are lost. Newest first. Search matches summaries and every |
| 164 | prompt inside each session.</p> |
| 165 | <div class="controls"> |
| 166 | <input type="search" id="q" placeholder="Search sessions…" aria-label="Search sessions" /> |
| 167 | <span id="chips" role="group" aria-label="Filter by contributor"></span> |
| 168 | <label>from <input type="date" id="from" aria-label="Show sessions from date" /></label> |
| 169 | <label>to <input type="date" id="to" aria-label="Show sessions up to date" /></label> |
| 170 | </div> |
| 171 | <p class="meta" id="session-count" aria-live="polite"></p> |
| 172 | <div class="table-scroll"> |
| 173 | <table id="sessions-table"> |
| 174 | <thead><tr><th>Who</th><th>Model</th><th>Summary</th></tr></thead> |
| 175 | <tbody id="sessions"></tbody> |
| 176 | </table> |
| 177 | </div> |
| 178 | <div id="more" aria-hidden="true"></div> |
| 179 | </div> |
| 180 | |
| 181 | <div id="viewer" hidden> |
| 182 | <a class="backlink" href="#" id="back">← all sessions</a> |
| 183 | <h1 id="viewer-title">Session</h1> |
| 184 | <p class="meta" id="viewer-meta"></p> |
| 185 | <p class="meta" id="viewer-status" aria-live="polite"></p> |
| 186 | <div id="messages"></div> |
| 187 | </div> |
| 188 | </div> |
| 189 | |
| 190 | <script> |
| 191 | (async function () { |
| 192 | "use strict"; |
| 193 | const $ = (id) => document.getElementById(id); |
| 194 | const REDACT_RE = /⟦redacted: ([a-z-]+)⟧/g; |
| 195 | const PAGE = 25; |
| 196 | let WHY = {}; |
| 197 | |
| 198 | const fmtInt = (n) => Number(n).toLocaleString(); |
| 199 | // "1 contributor, 2 contributors" — never a bare label beside a count. |
| 200 | const PLURAL = new Intl.PluralRules(); |
| 201 | const plural = (n, one, many) => PLURAL.select(n) === "one" ? one : (many || one + "s"); |
| 202 | const nOf = (n, one, many) => fmtInt(n) + " " + plural(n, one, many); |
| 203 | const fmtBytes = (n) => n > 1048576 ? (n / 1048576).toLocaleString(undefined, { maximumFractionDigits: 1 }) + " MB" : fmtInt(Math.round(n / 1024)) + " KB"; |
| 204 | // "12 Jul 2026 09:32" — composed from date + time parts, no "at". |
| 205 | const fmtDate = (iso) => { |
| 206 | if (!iso) return "—"; |
| 207 | const d = new Date(iso); |
| 208 | return d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) + |
| 209 | " " + d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); |
| 210 | }; |
| 211 | // Friendly model family for display; the exact model id rides in title. |
| 212 | const modelName = (id) => { |
| 213 | const m = /claude-([a-z]+)/.exec(id); |
| 214 | return m ? m[1][0].toUpperCase() + m[1].slice(1) : id; |
| 215 | }; |
| 216 | |
| 217 | // GitHub issue/PR links render as icon-prefixed titled links (titles are |
| 218 | // build-time resolved into issues.json — the repo is private, so viewers |
| 219 | // can't fetch them). ICON_GH is a static in-code string (octicon mark). |
| 220 | const GH_RE = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/\d+/g; |
| 221 | 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"; |
| 222 | let ISSUES = {}; |
| 223 | function ghLink(url) { |
| 224 | const a = document.createElement("a"); |
| 225 | a.href = url; a.target = "_blank"; a.rel = "noopener"; a.className = "gh"; |
| 226 | const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); |
| 227 | svg.setAttribute("viewBox", "0 0 16 16"); |
| 228 | svg.setAttribute("width", "14"); svg.setAttribute("height", "14"); |
| 229 | svg.setAttribute("aria-hidden", "true"); |
| 230 | const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); |
| 231 | path.setAttribute("fill", "currentColor"); |
| 232 | path.setAttribute("d", ICON_GH); |
| 233 | svg.appendChild(path); |
| 234 | a.appendChild(svg); |
| 235 | // Compact link text — #457 (or lchat#1 cross-repo); the issue title |
| 236 | // appears on hover via title="". |
| 237 | const info = ISSUES[url]; |
| 238 | const m = /github\.com\/[\w.-]+\/([\w.-]+)\/(?:issues|pull)\/(\d+)/.exec(url); |
| 239 | const home = info ? info.repo === "elevenmessenger/messenger" : m && m[1] === "messenger"; |
| 240 | a.appendChild(document.createTextNode(m ? (home ? "" : m[1]) + "#" + m[2] : url)); |
| 241 | a.title = info ? info.title : url; |
| 242 | return a; |
| 243 | } |
| 244 | function appendPlain(el, text) { |
| 245 | let last = 0; |
| 246 | for (const m of text.matchAll(GH_RE)) { |
| 247 | el.appendChild(document.createTextNode(text.slice(last, m.index))); |
| 248 | el.appendChild(ghLink(m[0])); |
| 249 | last = m.index + m[0].length; |
| 250 | } |
| 251 | el.appendChild(document.createTextNode(text.slice(last))); |
| 252 | } |
| 253 | |
| 254 | // Append text into el with ⟦redacted:…⟧ markers rendered as annotated marks |
| 255 | // and GitHub links as titled icon links. DOM nodes only — transcript content |
| 256 | // must never reach innerHTML. |
| 257 | function appendRedactable(el, text) { |
| 258 | let last = 0; |
| 259 | for (const m of text.matchAll(REDACT_RE)) { |
| 260 | appendPlain(el, text.slice(last, m.index)); |
| 261 | const mk = document.createElement("mark"); |
| 262 | mk.className = "redaction"; |
| 263 | mk.textContent = "redacted: " + m[1]; |
| 264 | mk.title = WHY[m[1]] || ""; |
| 265 | el.appendChild(mk); |
| 266 | last = m.index + m[0].length; |
| 267 | } |
| 268 | appendPlain(el, text.slice(last)); |
| 269 | } |
| 270 | |
| 271 | function collapsible(label, text) { |
| 272 | const d = document.createElement("details"); |
| 273 | const s = document.createElement("summary"); |
| 274 | s.textContent = label; |
| 275 | d.appendChild(s); |
| 276 | const pre = document.createElement("pre"); |
| 277 | appendRedactable(pre, text.length > 200000 ? text.slice(0, 200000) + "\n… (truncated view — full text is in the JSONL)" : text); |
| 278 | d.appendChild(pre); |
| 279 | return d; |
| 280 | } |
| 281 | |
| 282 | // The corpus is per-contributor: prompts/contributors.json lists every |
| 283 | // slug, each with its own index/prompts/issues under prompts/<slug>/…, and |
| 284 | // the page merges them here. A corpus published before the manifest existed |
| 285 | // (flat prompts/index.json) still loads via the fallback. |
| 286 | const prefixById = new Map(); // session id → "slug/" for transcript fetches |
| 287 | const jf = async (p) => { const r = await fetch(p); if (!r.ok) throw 0; return r.json(); }; |
| 288 | let index, promptsDoc; |
| 289 | try { |
| 290 | let sessions = [], prompts = [], why = {}, omitted = 0, source = null; |
| 291 | try { |
| 292 | const manifest = await jf("./prompts/contributors.json"); |
| 293 | const parts = (await Promise.all((manifest.contributors || []).map(async (c) => { |
| 294 | try { |
| 295 | const idx = await jf("./prompts/" + encodeURIComponent(c.slug) + "/index.json"); |
| 296 | const pr = await jf("./prompts/" + encodeURIComponent(c.slug) + "/prompts.json").catch(() => ({ prompts: [] })); |
| 297 | const iss = await jf("./prompts/" + encodeURIComponent(c.slug) + "/issues.json").catch(() => ({})); |
| 298 | return { slug: c.slug, idx, pr, iss }; |
| 299 | } catch { return null; } // listed but not uploaded yet |
| 300 | }))).filter(Boolean); |
| 301 | if (!parts.length) throw 0; |
| 302 | for (const p of parts) { |
| 303 | for (const s of p.idx.sessions || []) { prefixById.set(s.id, p.slug + "/"); sessions.push(s); } |
| 304 | prompts.push(...(p.pr.prompts || [])); |
| 305 | Object.assign(why, p.idx.why || {}); |
| 306 | Object.assign(ISSUES, p.iss || {}); |
| 307 | omitted += (p.idx.totals && p.idx.totals.omitted) || 0; |
| 308 | source = source || p.idx.source; |
| 309 | } |
| 310 | } catch { |
| 311 | // pre-manifest flat layout |
| 312 | const idx = await jf("./prompts/index.json"); |
| 313 | sessions = idx.sessions || []; why = idx.why || {}; |
| 314 | omitted = (idx.totals && idx.totals.omitted) || 0; source = idx.source; |
| 315 | prompts = (await jf("./prompts/prompts.json").catch(() => ({ prompts: [] }))).prompts || []; |
| 316 | try { Object.assign(ISSUES, await jf("./prompts/issues.json")); } catch {} |
| 317 | } |
| 318 | sessions.sort((a, b) => (b.started_at || "").localeCompare(a.started_at || "")); |
| 319 | index = { sessions, why, source, totals: { |
| 320 | sessions: sessions.length, |
| 321 | prompts: sessions.reduce((a, s) => a + (s.prompts || 0), 0), |
| 322 | redactions: sessions.reduce((a, s) => a + (s.redactions || 0), 0), |
| 323 | withheld: sessions.filter((s) => s.withheld).length, |
| 324 | omitted, |
| 325 | } }; |
| 326 | promptsDoc = { prompts }; |
| 327 | } catch { |
| 328 | $("totals").textContent = "The registry corpus hasn’t been uploaded yet."; |
| 329 | return; |
| 330 | } |
| 331 | WHY = index.why || {}; |
| 332 | const allPrompts = promptsDoc.prompts || []; |
| 333 | const contributors = [...new Set(allPrompts.map((p) => p.c).filter(Boolean))]; |
| 334 | |
| 335 | // legend |
| 336 | for (const [cat, why] of Object.entries(WHY)) { |
| 337 | const dt = document.createElement("dt"); dt.textContent = cat; |
| 338 | const dd = document.createElement("dd"); dd.textContent = why; |
| 339 | $("legend").append(dt, dd); |
| 340 | } |
| 341 | |
| 342 | // totals — withheld/omitted appear only when non-zero, so the common case |
| 343 | // stays a clean four tiles. |
| 344 | const t = index.totals; |
| 345 | const tiles = [[plural(t.sessions, "session"), fmtInt(t.sessions)], |
| 346 | [plural(t.prompts, "prompt"), fmtInt(t.prompts)], |
| 347 | [plural(t.redactions, "redaction"), fmtInt(t.redactions)], |
| 348 | contributors.length ? [plural(contributors.length, "contributor"), fmtInt(contributors.length)] |
| 349 | : ["machine", index.source]]; |
| 350 | if (t.withheld) tiles.push(["withheld", fmtInt(t.withheld)]); |
| 351 | if (t.omitted) tiles.push(["omitted", fmtInt(t.omitted)]); |
| 352 | for (const [label, val] of tiles) { |
| 353 | const div = document.createElement("div"); |
| 354 | const b = document.createElement("b"); b.textContent = String(val); |
| 355 | div.appendChild(b); div.appendChild(document.createTextNode(label)); |
| 356 | $("totals").appendChild(div); |
| 357 | } |
| 358 | |
| 359 | // ---- one sessions list: contributor chips + deep search + pagination ---- |
| 360 | // Search matches a session's summary/snippets AND every prompt inside it |
| 361 | // (prompts.json), but the results are sessions. |
| 362 | const filter = { q: "", c: null, from: "", to: "" }; |
| 363 | |
| 364 | const promptTextBySession = new Map(); |
| 365 | for (const p of allPrompts) { |
| 366 | promptTextBySession.set(p.s, (promptTextBySession.get(p.s) || "") + "\n" + (p.t || "").toLowerCase()); |
| 367 | } |
| 368 | |
| 369 | function chip(label, value) { |
| 370 | const b = document.createElement("button"); |
| 371 | b.className = "chip"; b.type = "button"; |
| 372 | b.textContent = label; |
| 373 | b.setAttribute("aria-pressed", String(filter.c === value)); |
| 374 | b.addEventListener("click", () => { |
| 375 | filter.c = value; |
| 376 | for (const c of $("chips").children) c.setAttribute("aria-pressed", "false"); |
| 377 | b.setAttribute("aria-pressed", "true"); |
| 378 | renderSessions(); |
| 379 | }); |
| 380 | return b; |
| 381 | } |
| 382 | $("chips").appendChild(chip("everyone", null)); |
| 383 | for (const c of contributors) { |
| 384 | const count = index.sessions.filter((s) => s.contributor === c).length; |
| 385 | $("chips").appendChild(chip(c + " (" + fmtInt(count) + ")", c)); |
| 386 | } |
| 387 | $("chips").children[0].setAttribute("aria-pressed", "true"); |
| 388 | |
| 389 | function sessionMatches(s) { |
| 390 | if (filter.c && s.contributor !== filter.c) return false; |
| 391 | // Date range compares ISO date prefixes — cheap and TZ-tolerant enough |
| 392 | // for a day-granular filter. |
| 393 | const day = (s.started_at || "").slice(0, 10); |
| 394 | if (filter.from && (!day || day < filter.from)) return false; |
| 395 | if (filter.to && (!day || day > filter.to)) return false; |
| 396 | if (!filter.q) return true; |
| 397 | const meta = [s.summary, s.first_prompt, s.last_prompt, s.withheld, (s.models || []).join(" ")] |
| 398 | .filter(Boolean).join("\n").toLowerCase(); |
| 399 | return meta.includes(filter.q) || (promptTextBySession.get(s.id) || "").includes(filter.q); |
| 400 | } |
| 401 | |
| 402 | let debounce; |
| 403 | $("q").addEventListener("input", () => { |
| 404 | clearTimeout(debounce); |
| 405 | debounce = setTimeout(() => { |
| 406 | filter.q = $("q").value.trim().toLowerCase(); |
| 407 | renderSessions(); |
| 408 | }, 150); |
| 409 | }); |
| 410 | for (const id of ["from", "to"]) { |
| 411 | $(id).addEventListener("change", () => { filter[id] = $(id).value; renderSessions(); }); |
| 412 | } |
| 413 | |
| 414 | const byId = new Map(index.sessions.map((s) => [s.id, s])); |
| 415 | // Infinite scroll: filtered rows append in PAGE-sized batches as the |
| 416 | // sentinel below the table approaches the viewport. |
| 417 | let hits = [], shown = 0; |
| 418 | function buildRow(s) { |
| 419 | const tr = document.createElement("tr"); |
| 420 | if (!s.withheld) { |
| 421 | // Whole row opens the session; the date link inside stays the |
| 422 | // keyboard path. Real links (GitHub etc.) keep their own click. |
| 423 | tr.className = "clickable"; |
| 424 | tr.addEventListener("click", (e) => { |
| 425 | if (e.target.closest("a")) return; |
| 426 | location.hash = "#s=" + s.id; |
| 427 | }); |
| 428 | } |
| 429 | // Who: name with the date beneath, de-emphasised; the date is the link |
| 430 | // into the session (tombstones get plain text — nothing to open). |
| 431 | const tdWho = document.createElement("td"); |
| 432 | const who = document.createElement("div"); |
| 433 | who.textContent = s.contributor || s.source || "—"; |
| 434 | tdWho.appendChild(who); |
| 435 | const when = document.createElement("div"); |
| 436 | when.className = "when-sub"; |
| 437 | if (s.withheld) { |
| 438 | when.textContent = fmtDate(s.started_at); |
| 439 | } else { |
| 440 | const a = document.createElement("a"); |
| 441 | a.href = "#s=" + s.id; |
| 442 | a.textContent = fmtDate(s.started_at); |
| 443 | when.appendChild(a); |
| 444 | } |
| 445 | tdWho.appendChild(when); |
| 446 | tr.appendChild(tdWho); |
| 447 | const tdModel = document.createElement("td"); |
| 448 | tdModel.className = "meta"; |
| 449 | tdModel.textContent = (s.models || []).map(modelName).join(", ") || "—"; |
| 450 | tdModel.title = (s.models || []).join(", "); // exact ids on hover |
| 451 | tr.appendChild(tdModel); |
| 452 | if (s.withheld) { |
| 453 | const tdW = document.createElement("td"); |
| 454 | const mk = document.createElement("mark"); |
| 455 | mk.className = "redaction"; |
| 456 | mk.textContent = "session withheld — " + s.withheld; |
| 457 | mk.title = WHY["session-withheld"] || ""; |
| 458 | tdW.appendChild(mk); |
| 459 | tdW.appendChild(document.createTextNode(" " + nOf(s.prompts, "prompt") + ", content not published")); |
| 460 | tr.appendChild(tdW); |
| 461 | return tr; |
| 462 | } |
| 463 | // Labelled lines so a summary is never mistaken for a prompt: the Haiku |
| 464 | // summary headlines when present, with the first (and latest) prompts |
| 465 | // beneath; without one, the first prompt headlines under its own label. |
| 466 | const tdSum = document.createElement("td"); |
| 467 | const line = (label, text, muted) => { |
| 468 | const div = document.createElement("div"); |
| 469 | if (muted) div.className = "snippet subline"; |
| 470 | const l = document.createElement("span"); l.className = "lbl"; l.textContent = label; |
| 471 | div.appendChild(l); |
| 472 | appendRedactable(div, text); |
| 473 | tdSum.appendChild(div); |
| 474 | }; |
| 475 | if (s.summary) { |
| 476 | line("summary", s.summary, false); |
| 477 | if (s.first_prompt) line("first", s.first_prompt, true); |
| 478 | } else { |
| 479 | line("first prompt", s.first_prompt || "(no text prompt — resumed or tool-driven session)", false); |
| 480 | } |
| 481 | if (s.last_prompt && s.prompts > 1) line("latest", s.last_prompt, true); |
| 482 | tr.appendChild(tdSum); |
| 483 | return tr; |
| 484 | } |
| 485 | function addMore() { |
| 486 | const body = $("sessions"); |
| 487 | for (const s of hits.slice(shown, shown + PAGE)) body.appendChild(buildRow(s)); |
| 488 | shown = Math.min(shown + PAGE, hits.length); |
| 489 | } |
| 490 | function renderSessions() { |
| 491 | hits = index.sessions.filter(sessionMatches); |
| 492 | $("session-count").textContent = fmtInt(hits.length) + " of " + nOf(index.sessions.length, "session"); |
| 493 | $("sessions").textContent = ""; |
| 494 | shown = 0; |
| 495 | addMore(); |
| 496 | } |
| 497 | new IntersectionObserver((entries) => { |
| 498 | for (const e of entries) if (e.isIntersecting && shown < hits.length) addMore(); |
| 499 | }, { rootMargin: "600px" }).observe($("more")); |
| 500 | renderSessions(); |
| 501 | |
| 502 | // ---- session viewer ---- |
| 503 | const textBlocks = (c) => typeof c === "string" ? [{ type: "text", text: c }] : Array.isArray(c) ? c : []; |
| 504 | // Same predicate as the builder, so prompt ordinals anchor correctly. |
| 505 | const isPrompt = (t) => t && !t.startsWith("<") && !t.startsWith("Caveat:") && !t.startsWith("[Request interrupted"); |
| 506 | |
| 507 | async function showSession(id, anchorN) { |
| 508 | const s = byId.get(id); |
| 509 | if (!s || s.withheld) { location.hash = ""; return; } |
| 510 | $("home").hidden = true; $("viewer").hidden = false; |
| 511 | $("viewer-title").textContent = "Session " + id.slice(0, 8); |
| 512 | $("viewer-meta").textContent = fmtDate(s.started_at) + " → " + fmtDate(s.ended_at) + |
| 513 | " · " + nOf(s.prompts, "prompt") + " · " + nOf(s.redactions, "redaction") + " · " + fmtBytes(s.bytes) + |
| 514 | ((s.models || []).length ? " · " + s.models.join(", ") : ""); |
| 515 | const box = $("messages"); |
| 516 | box.textContent = ""; |
| 517 | $("viewer-status").hidden = false; |
| 518 | $("viewer-status").textContent = "Loading transcript (" + fmtBytes(s.bytes) + ")…"; |
| 519 | let raw, gists = {}; |
| 520 | try { |
| 521 | raw = await (await fetch("./prompts/" + (prefixById.get(id) || "") + encodeURIComponent(id) + ".jsonl")).text(); |
| 522 | } catch { |
| 523 | $("viewer-status").textContent = "Couldn’t load the transcript."; |
| 524 | return; |
| 525 | } |
| 526 | try { |
| 527 | const r = await fetch("./prompts/" + (prefixById.get(id) || "") + encodeURIComponent(id) + ".sum.json"); |
| 528 | if (r.ok) gists = (await r.json()).gists || {}; |
| 529 | } catch {} // no gists — labels fall back to each reply's first line |
| 530 | $("viewer-status").hidden = true; |
| 531 | let anchored = null, promptN = 0, li = -1; // li counts parsed lines, matching the builder's keys |
| 532 | for (const line of raw.split("\n")) { |
| 533 | if (!line.trim()) continue; |
| 534 | let o; try { o = JSON.parse(line); } catch { continue; } |
| 535 | li++; |
| 536 | if (o.type === "user" && !o.isMeta) { |
| 537 | for (const b of textBlocks(o.message && o.message.content)) { |
| 538 | if (b.type === "text" && b.text) { |
| 539 | const div = document.createElement("div"); div.className = "msg"; |
| 540 | if (!isPrompt(b.text)) { |
| 541 | div.appendChild(collapsible("context sent with the prompt", b.text)); |
| 542 | } else { |
| 543 | promptN++; |
| 544 | const p = document.createElement("div"); p.className = "prompt"; |
| 545 | p.tabIndex = -1; |
| 546 | appendRedactable(p, b.text); div.appendChild(p); |
| 547 | if (promptN === anchorN) { p.classList.add("anchored"); anchored = p; } |
| 548 | } |
| 549 | box.appendChild(div); |
| 550 | } else if (b.type === "tool_result") { |
| 551 | const t = textBlocks(b.content).filter((x) => x.type === "text").map((x) => x.text).join("\n"); |
| 552 | if (t) box.appendChild(collapsible("tool result", t)); |
| 553 | } |
| 554 | } |
| 555 | } else if (o.type === "assistant") { |
| 556 | const blocks = textBlocks(o.message && o.message.content); |
| 557 | for (let bi = 0; bi < blocks.length; bi++) { |
| 558 | const b = blocks[bi]; |
| 559 | if (b.type === "text" && b.text) { |
| 560 | // Haiku gist when the corpus has one; the reply's own first line |
| 561 | // is the AI-free fallback — either way the collapsed row says |
| 562 | // what's inside before you open it. |
| 563 | const gist = gists[li + ":" + bi] || b.text.split("\n").find((l) => l.trim())?.slice(0, 80) || ""; |
| 564 | box.appendChild(collapsible("Claude replied — " + gist, b.text)); |
| 565 | } |
| 566 | else if (b.type === "thinking" && b.thinking) box.appendChild(collapsible("thinking", b.thinking)); |
| 567 | else if (b.type === "tool_use") box.appendChild(collapsible("⚙ " + (b.name || "tool"), JSON.stringify(b.input, null, 2))); |
| 568 | } |
| 569 | } |
| 570 | } |
| 571 | if (anchored) { anchored.scrollIntoView({ block: "center" }); anchored.focus({ preventScroll: true }); } |
| 572 | else window.scrollTo(0, 0); |
| 573 | } |
| 574 | |
| 575 | function route() { |
| 576 | const m = location.hash.match(/^#s=([A-Za-z0-9-]+)(?:&p=(\d+))?$/); |
| 577 | if (m) showSession(m[1], m[2] ? Number(m[2]) : null); |
| 578 | else { $("viewer").hidden = true; $("home").hidden = false; } |
| 579 | } |
| 580 | $("back").addEventListener("click", (e) => { e.preventDefault(); location.hash = ""; }); |
| 581 | addEventListener("hashchange", route); |
| 582 | route(); |
| 583 | })(); |
| 584 | </script> |
| 585 | </body> |
| 586 | </html> |
| 587 | |