eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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