replay-labs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,715 @@
1
+ import { readdir, readFile, stat, writeFile, mkdir } from "node:fs/promises";
2
+ import { join, resolve, relative, dirname, basename } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { ingestClaudeSession } from "./ingest.js";
5
+ import { analyzeSession } from "./report.js";
6
+ import { MODULES } from "./modules.js";
7
+ import { hasUsableDiffEvidence } from "./generate.js";
8
+ import { findEvidenceSnippet } from "./interaction.js";
9
+
10
+ const MAX_SCAN_FILES = 300;
11
+ const MAX_SESSION_BYTES = 12 * 1024 * 1024;
12
+ const MAX_CODEX_TRANSCRIPT_CHARS = 60000;
13
+
14
+ export async function discoverSessions({ homeDir = homedir(), limit = 80 } = {}) {
15
+ const roots = [
16
+ { tool: "claude", root: join(homeDir, ".claude", "projects") },
17
+ { tool: "codex", root: join(homeDir, ".codex", "sessions") }
18
+ ];
19
+ const sessions = [];
20
+
21
+ for (const source of roots) {
22
+ const files = await findJsonlFiles(source.root, MAX_SCAN_FILES);
23
+ for (const file of files) {
24
+ const item = await inspectSessionFile(file, source.tool, homeDir);
25
+ if (item) sessions.push(item);
26
+ }
27
+ }
28
+
29
+ sessions.sort(compareSessionUsefulness);
30
+ return selectVisibleSessions(sessions, limit).map((session, index) => ({ ...session, rank: index + 1 }));
31
+ }
32
+
33
+ export async function loadDiscoveredSession(sessionPath) {
34
+ const fullPath = resolve(sessionPath);
35
+ const raw = await readFile(fullPath, "utf8");
36
+ if (isClaudePath(fullPath)) {
37
+ const ingested = ingestClaudeSession(raw);
38
+ return { tool: "claude", path: fullPath, ...ingested };
39
+ }
40
+ if (isCodexPath(fullPath)) {
41
+ return { tool: "codex", path: fullPath, ...ingestCodexSession(raw) };
42
+ }
43
+ // Try Claude first because Claude Code sessions carry edit/write tool inputs.
44
+ const claude = ingestClaudeSession(raw);
45
+ if (claude.stats.turns || claude.stats.edits) return { tool: "claude", path: fullPath, ...claude };
46
+ return { tool: "codex", path: fullPath, ...ingestCodexSession(raw) };
47
+ }
48
+
49
+ export async function chooseBestSession(options = {}) {
50
+ const sessions = await discoverSessions(options);
51
+ return bestSessionFrom(sessions);
52
+ }
53
+
54
+ export async function writeSessionInbox({ sessions, outDir }) {
55
+ const destination = resolve(outDir);
56
+ await mkdir(destination, { recursive: true });
57
+ await writeFile(join(destination, "sessions.json"), JSON.stringify({ sessions }, null, 2), "utf8");
58
+ await writeFile(join(destination, "index.html"), generateInboxHtml(sessions), "utf8");
59
+ return destination;
60
+ }
61
+
62
+ export function bestSessionFrom(sessions, { preferReady = true } = {}) {
63
+ const candidates = sessions.filter((s) =>
64
+ s.labPotential !== "weak" && (s.richLabs > 0 || s.hasConcreteEvidence)
65
+ );
66
+ const ranked = (candidates.length ? candidates : sessions).slice()
67
+ .sort((a, b) =>
68
+ (preferReady ? Number(b.richLabs > 0) - Number(a.richLabs > 0) : 0) ||
69
+ Number(b.hasConcreteEvidence) - Number(a.hasConcreteEvidence) ||
70
+ (b.score - a.score) ||
71
+ (b.mtimeMs - a.mtimeMs)
72
+ );
73
+ return ranked[0] || null;
74
+ }
75
+
76
+ function compareSessionUsefulness(a, b) {
77
+ return Number(b.richLabs > 0) - Number(a.richLabs > 0) ||
78
+ Number(b.hasConcreteEvidence) - Number(a.hasConcreteEvidence) ||
79
+ (b.score - a.score) ||
80
+ (b.mtimeMs - a.mtimeMs);
81
+ }
82
+
83
+ async function inspectSessionFile(file, tool, homeDir) {
84
+ const info = await stat(file);
85
+ if (!info.isFile() || info.size > MAX_SESSION_BYTES) return null;
86
+ let raw;
87
+ try {
88
+ raw = await readFile(file, "utf8");
89
+ } catch {
90
+ return null;
91
+ }
92
+
93
+ const ingested = tool === "claude" ? ingestClaudeSession(raw) : ingestCodexSession(raw);
94
+ const analysis = analyzeSession({
95
+ goal: ingested.goal,
96
+ diff: ingested.diff,
97
+ transcript: ingested.transcript,
98
+ diffPath: file,
99
+ transcriptPath: file
100
+ });
101
+ const hasConcreteEvidence = hasUsableDiffEvidence(ingested.diff);
102
+ const richLabs = analysis.decisions.filter((decision) =>
103
+ MODULES[decision.id] &&
104
+ hasUsableDiffEvidence(findEvidenceSnippet(ingested.diff, decision.patterns))
105
+ ).length;
106
+ const score = scoreSession({ ingested, analysis, size: info.size, tool, richLabs, hasConcreteEvidence });
107
+ const project = projectNameFor(file, homeDir, tool, ingested);
108
+
109
+ return {
110
+ id: stableId(tool, file),
111
+ tool,
112
+ path: file,
113
+ project,
114
+ title: titleFromGoal(ingested.goal, project),
115
+ goal: ingested.goal,
116
+ mtimeMs: info.mtimeMs,
117
+ modified: new Date(info.mtimeMs).toISOString(),
118
+ size: info.size,
119
+ stats: ingested.stats,
120
+ fileSignalLabel: hasConcreteEvidence ? "file changes" : "file references",
121
+ decisions: analysis.decisions.map((d) => ({ id: d.id, title: d.title })).slice(0, 5),
122
+ risks: analysis.risks.slice(0, 4),
123
+ richLabs,
124
+ hasConcreteEvidence,
125
+ score: score.score,
126
+ labPotential: score.potential,
127
+ reason: score.reason,
128
+ command: `node ./src/cli.js lab --session ${shellQuote(file)} --out-dir ./reports-${safeSlug(project)}${richLabs ? "" : " --generate"}`
129
+ };
130
+ }
131
+
132
+ function ingestCodexSession(jsonlText) {
133
+ const turns = [];
134
+ const filesTouched = new Set();
135
+ const commandLines = [];
136
+ let goal = null;
137
+ let cwd = null;
138
+
139
+ for (const line of jsonlText.split("\n")) {
140
+ if (!line.trim()) continue;
141
+ let item;
142
+ try { item = JSON.parse(line); } catch { continue; }
143
+ if (item.type === "session_meta" && item.payload?.cwd) {
144
+ cwd = item.payload.cwd;
145
+ continue;
146
+ }
147
+ if (item.type !== "response_item") continue;
148
+ const payload = item.payload;
149
+ if (!payload) continue;
150
+
151
+ if (payload.type === "message") {
152
+ const text = codexContentText(payload.content);
153
+ if (!text || text.startsWith("<permissions instructions>")) continue;
154
+ if (payload.role === "user") {
155
+ const cleaned = cleanCodexText(text);
156
+ if (cleaned && !goal && cleaned.length > 20) goal = cleaned.slice(0, 200);
157
+ if (cleaned) turns.push("User: " + clip(cleaned, 700));
158
+ } else if (payload.role === "assistant") {
159
+ const cleaned = cleanCodexText(text);
160
+ if (cleaned) turns.push("Assistant: " + clip(cleaned, 700));
161
+ }
162
+ continue;
163
+ }
164
+
165
+ if (payload.type === "function_call") {
166
+ const args = safeJson(payload.arguments);
167
+ const command = args?.cmd || args?.command || payload.name || "";
168
+ if (command) {
169
+ commandLines.push(command);
170
+ for (const file of filesFromCommand(command)) filesTouched.add(file);
171
+ turns.push("Tool: " + clip(`${payload.name}: ${command}`, 700));
172
+ }
173
+ }
174
+ }
175
+
176
+ let transcript = turns.join("\n");
177
+ if (transcript.length > MAX_CODEX_TRANSCRIPT_CHARS) {
178
+ transcript = transcript.slice(0, MAX_CODEX_TRANSCRIPT_CHARS / 2) +
179
+ "\n[...session truncated...]\n" +
180
+ transcript.slice(-MAX_CODEX_TRANSCRIPT_CHARS / 2);
181
+ }
182
+
183
+ const diff = [...filesTouched].map((file) =>
184
+ `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@\n+// Codex session touched or inspected this file; use transcript evidence for details.`
185
+ ).join("\n");
186
+
187
+ return {
188
+ goal: goal || "Untitled Codex session",
189
+ transcript,
190
+ diff,
191
+ stats: {
192
+ records: jsonlText.split("\n").filter(Boolean).length,
193
+ turns: turns.length,
194
+ edits: filesTouched.size,
195
+ files: [...filesTouched],
196
+ commands: commandLines.length,
197
+ cwd
198
+ },
199
+ cwd
200
+ };
201
+ }
202
+
203
+ function selectVisibleSessions(sessions, limit) {
204
+ if (!limit || sessions.length <= limit) return sessions.slice(0, limit || sessions.length);
205
+ const selected = [];
206
+ const seen = new Set();
207
+ const tools = [...new Set(sessions.map((session) => session.tool))];
208
+ const minimumPerTool = Math.max(3, Math.floor(limit * 0.15));
209
+
210
+ for (const tool of tools) {
211
+ const toolSessions = sessions.filter((session) => session.tool === tool);
212
+ for (const session of toolSessions.slice(0, minimumPerTool)) {
213
+ if (selected.length >= limit) break;
214
+ selected.push(session);
215
+ seen.add(session.id);
216
+ }
217
+ }
218
+
219
+ for (const session of sessions) {
220
+ if (selected.length >= limit) break;
221
+ if (seen.has(session.id)) continue;
222
+ selected.push(session);
223
+ seen.add(session.id);
224
+ }
225
+
226
+ return selected.sort(compareSessionUsefulness).slice(0, limit);
227
+ }
228
+
229
+ async function findJsonlFiles(root, maxFiles) {
230
+ const found = [];
231
+ async function walk(dir, depth = 0) {
232
+ if (found.length >= maxFiles || depth > 6) return;
233
+ let entries;
234
+ try {
235
+ entries = await readdir(dir, { withFileTypes: true });
236
+ } catch {
237
+ return;
238
+ }
239
+ for (const entry of entries) {
240
+ if (found.length >= maxFiles) return;
241
+ const full = join(dir, entry.name);
242
+ if (entry.isDirectory()) {
243
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
244
+ await walk(full, depth + 1);
245
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
246
+ found.push(full);
247
+ }
248
+ }
249
+ }
250
+ await walk(root);
251
+ return found;
252
+ }
253
+
254
+ function scoreSession({ ingested, analysis, size, tool, richLabs, hasConcreteEvidence }) {
255
+ let score = 0;
256
+ const reasons = [];
257
+ if (ingested.stats.turns >= 4) { score += 2; reasons.push("has enough human/assistant context"); }
258
+ if (ingested.stats.edits >= 1 && hasConcreteEvidence) {
259
+ score += 3;
260
+ reasons.push("has concrete changed-code evidence");
261
+ } else if (ingested.stats.edits >= 1) {
262
+ score += 1;
263
+ reasons.push("has file references, but no concrete diff");
264
+ }
265
+ if (analysis.decisions.length >= 1) { score += 3; reasons.push("contains detectable decisions"); }
266
+ if (richLabs > 0) { score += 5; reasons.push(`${richLabs} lab${richLabs === 1 ? " is" : "s are"} already available`); }
267
+ if (analysis.risks.length >= 2) { score += 1; reasons.push("has concrete risks to teach"); }
268
+ if (/test|error|fail|fix|review|build|deploy|permission|validate|secret|api|design|prd|product/i.test(ingested.transcript)) {
269
+ score += 1;
270
+ reasons.push("mentions verification, failure, product, or boundary work");
271
+ }
272
+ if (tool === "claude" && ingested.stats.edits > 0) score += 1;
273
+ if (size > MAX_SESSION_BYTES / 2) score -= 1;
274
+
275
+ const potential = !hasConcreteEvidence && richLabs === 0
276
+ ? "weak"
277
+ : score >= 10 ? "strong" : score >= 5 ? "medium" : "weak";
278
+
279
+ return {
280
+ score,
281
+ potential,
282
+ reason: reasons.length ? reasons.join("; ") : "low signal or little recoverable evidence"
283
+ };
284
+ }
285
+
286
+ export function generateInboxHtml(sessions, { interactive = false } = {}) {
287
+ const cards = groupedSessionCards(sessions);
288
+ const best = bestSessionFrom(sessions);
289
+ const counts = countByTool(sessions);
290
+ const projectCount = new Set(sessions.map((session) => session.project)).size;
291
+ const readyCount = sessions.filter((s) => s.richLabs > 0).length;
292
+ const generateCount = sessions.filter((s) => !s.richLabs && s.hasConcreteEvidence).length;
293
+ const mapOnlyCount = sessions.filter((s) => !s.richLabs && !s.hasConcreteEvidence).length;
294
+ return `<!doctype html>
295
+ <html lang="en">
296
+ <head>
297
+ <meta charset="utf-8" />
298
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
299
+ <title>Replay Labs Session Inbox</title>
300
+ <style>
301
+ body{margin:0;background:#0d0f12;color:#ebe7df;font:15px/1.55 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
302
+ main{max-width:1180px;margin:0 auto;padding:38px 22px 80px}
303
+ h1{font-size:40px;line-height:1.08;margin:0 0 10px}
304
+ p{color:#9da6b2;margin:0 0 26px}
305
+ .top{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;margin-bottom:20px}
306
+ .eyebrow{color:#34d399;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;margin-bottom:8px}
307
+ .pick{background:#34d399;color:#06291d;padding:12px 14px;border-radius:8px;font-weight:800}
308
+ .actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}
309
+ button{border:0;border-radius:8px;padding:10px 12px;font:inherit;font-weight:800;cursor:pointer}
310
+ button.primary{background:#34d399;color:#052e1e}
311
+ button.secondary{background:#242b35;color:#e7e2da;border:1px solid #35404d}
312
+ button:disabled{opacity:.55;cursor:wait}
313
+ .mission-card{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1.7fr);gap:18px;align-items:start;background:#131821;border:1px solid #252c36;border-radius:12px;padding:18px;margin:0 0 18px}
314
+ .mission-card h2{font-size:18px;margin:0 0 7px}
315
+ .mission-card p{margin:0;color:#9da6b2}
316
+ .mission-steps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
317
+ .mission-step{border:1px solid #303846;background:#0f141b;border-radius:9px;padding:12px}
318
+ .mission-step b{display:block;margin-bottom:3px}
319
+ .mission-step span{color:#8d96a3;font-size:13px}
320
+ .workspace{display:grid;grid-template-columns:250px minmax(0,1fr);gap:18px;align-items:start}
321
+ .side-panel{position:sticky;top:18px;background:#12161d;border:1px solid #252c36;border-radius:12px;padding:14px}
322
+ .side-panel h2{font-size:14px;margin:0 0 10px}
323
+ .side-panel p{font-size:13px;margin:12px 0 0;color:#8d96a3}
324
+ .grid{display:grid;gap:12px;min-width:0}
325
+ .summary{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 14px}
326
+ .summary .tag{font-size:12px;padding:4px 9px}
327
+ .filters{display:grid;gap:8px;margin:0 0 14px}
328
+ .filter-btn{display:flex;justify-content:space-between;gap:10px;width:100%;background:#171d26;color:#cbd5e1;border:1px solid #303846;border-radius:8px;padding:9px 10px;font-size:13px}
329
+ .filter-btn.on{background:#34d399;color:#052e1e;border-color:#34d399}
330
+ .filter-btn span{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;opacity:.78}
331
+ .summary-actions{display:flex;gap:8px;flex-wrap:wrap;margin:0}
332
+ button.compact{background:#171d26;color:#cbd5e1;border:1px solid #303846;border-radius:7px;padding:7px 9px;font-size:12px}
333
+ .active-filter{color:#8d96a3;font-size:13px;margin:0 0 10px}
334
+ .project-group{margin:0 0 12px;border:1px solid #252c36;border-radius:10px;background:#12161d;overflow:hidden}
335
+ .project-group.is-hidden{display:none}
336
+ .project-head{display:flex;justify-content:space-between;gap:14px;align-items:center;margin:0;padding:14px 16px;cursor:pointer;list-style:none}
337
+ .project-head::-webkit-details-marker{display:none}
338
+ .project-head h2{font-size:16px;line-height:1.25;margin:0;overflow-wrap:anywhere}
339
+ .project-head p{margin:3px 0 0;color:#7d8794;font-size:12.5px}
340
+ .project-title{display:flex;gap:9px;align-items:flex-start;min-width:0}
341
+ .toggle-mark{flex:0 0 auto;display:inline-grid;place-items:center;width:19px;height:19px;margin-top:1px;border:1px solid #303846;border-radius:5px;color:#34d399;font:700 13px/1 ui-monospace,SFMono-Regular,Menlo,monospace}
342
+ .toggle-mark::before{content:"+"}
343
+ .project-group[open] .toggle-mark::before{content:"-"}
344
+ .project-count{flex:0 0 auto;color:#7d8794;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;text-transform:uppercase}
345
+ .project-cards{display:grid;gap:10px;padding:0 14px 14px}
346
+ .card{display:grid;grid-template-columns:132px minmax(0,1fr) 170px;gap:18px;align-items:center;min-width:0;max-width:100%;overflow:hidden;background:#151922;border:1px solid #252c36;border-radius:10px;padding:18px}
347
+ .card.is-hidden{display:none}
348
+ .card>*{min-width:0}
349
+ .meta{color:#7d8794;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;text-transform:uppercase}
350
+ .card h2{font-size:18px;line-height:1.25;margin:0 0 5px;overflow-wrap:anywhere}
351
+ .card p{margin:0;color:#9da6b2;overflow-wrap:anywhere}
352
+ .card-actions{min-width:0;text-align:right}
353
+ .card-status{display:none;grid-column:2 / 4;margin-top:-6px;color:#b7c0cc;font-size:13px;line-height:1.45}
354
+ .card-status a{color:#34d399;font-weight:800}
355
+ .result{border:1px solid #34d39944;background:#34d3990d;border-radius:10px;padding:12px 14px}
356
+ .result.warn{border-color:#fbbf2455;background:#fbbf240d}
357
+ .result b{display:block;color:#ebe7df;margin-bottom:2px}
358
+ .result p{margin:0 0 10px;color:#b7c0cc}
359
+ .result-actions{display:flex;gap:9px;flex-wrap:wrap}
360
+ .result-actions a{display:inline-flex;align-items:center;border-radius:8px;padding:8px 10px;text-decoration:none}
361
+ .result-actions a.primary{background:#34d399;color:#052e1e}
362
+ .result-actions a.secondary{border:1px solid #35404d;color:#e7e2da;background:#202632}
363
+ .tags{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px}
364
+ .tag{min-width:0;overflow-wrap:anywhere;border:1px solid #303846;color:#8d96a3;border-radius:5px;padding:2px 7px;font-size:11px}
365
+ .tag.ready{color:#052e1e;background:#34d399;border-color:#34d399}
366
+ .tag.signal{color:#c7d2fe;border-color:#818cf855}
367
+ .tag.limited{color:#fbbf24;border-color:#fbbf2455}
368
+ .strong{color:#052e1e;background:#34d399;border-color:#34d399}
369
+ .medium{color:#fbbf24;border-color:#fbbf2455}
370
+ .weak{color:#f87171;border-color:#f8717155}
371
+ code{color:#c7d2fe}
372
+ .status{margin:18px 0;padding:14px;border:1px solid #2c3440;border-radius:8px;background:#12161d;color:#b7c0cc;display:none}
373
+ .status a{color:#34d399;font-weight:800}
374
+ @media(max-width:900px){.mission-card,.workspace{display:block}.side-panel{position:static;margin:0 0 16px}.mission-steps{grid-template-columns:1fr}.filters{grid-template-columns:repeat(2,minmax(0,1fr))}}
375
+ @media(max-width:760px){main{padding:30px 16px 70px}h1{font-size:32px}.top,.card{display:block}.project-head{align-items:flex-start}.pick{display:inline-block;margin-top:14px}.actions{justify-content:flex-start}.meta{margin-bottom:8px}.card-actions{text-align:left;margin-top:14px}.card-actions code{max-width:100%}.card-status{margin-top:10px}.project-count{display:none}.filters{grid-template-columns:1fr}}
376
+ </style>
377
+ </head>
378
+ <body><main>
379
+ <div class="top"><div><div class="eyebrow">Replay Labs session inbox</div><h1>Replay Labs found ${sessions.length} local AI sessions.</h1>
380
+ <p>Choose a project, then turn one recovered session into a mission: understand the decision, practice the tradeoff, and leave with something reusable. No upload required.</p></div>
381
+ ${best ? interactive
382
+ ? `<div class="actions"><button class="primary" data-choose>${best.richLabs ? "Start suggested ready lab" : "Show suggested decision map"}</button><button class="secondary" data-refresh>Refresh sessions</button></div>`
383
+ : `<div class="pick">Let Replay choose:<br><code>${escapeHtml(best.command)}</code></div>`
384
+ : interactive ? `<div class="actions"><button class="secondary" data-refresh>Refresh sessions</button></div>` : ""}
385
+ </div>
386
+ <section class="mission-card">
387
+ <div><h2>Mission approach</h2><p>Every lab should help someone own the decision, not merely replay what happened.</p></div>
388
+ <div class="mission-steps">
389
+ <div class="mission-step"><b>1. Find the decision</b><span>Recover the useful judgment from a real session.</span></div>
390
+ <div class="mission-step"><b>2. Practice the tradeoff</b><span>Compare evidence, failure modes, and alternatives.</span></div>
391
+ <div class="mission-step"><b>3. Carry it forward</b><span>Leave with a reusable rule or next-session brief.</span></div>
392
+ </div>
393
+ </section>
394
+ <div class="status" id="status"></div>
395
+ <div class="workspace">
396
+ <aside class="side-panel">
397
+ <h2>At a glance</h2>
398
+ <div class="summary">
399
+ <span class="tag ready">${readyCount} ready</span>
400
+ <span class="tag signal">${generateCount} can generate</span>
401
+ <span class="tag limited">${mapOnlyCount} map only</span>
402
+ <span class="tag">${projectCount} projects</span>
403
+ <span class="tag">${counts.claude || 0} Claude</span>
404
+ <span class="tag">${counts.codex || 0} Codex</span>
405
+ </div>
406
+ <h2>Focus</h2>
407
+ <div class="filters">
408
+ <button class="filter-btn on" data-filter="all">All sessions <span>${sessions.length}</span></button>
409
+ <button class="filter-btn" data-filter="ready">Ready labs <span>${readyCount}</span></button>
410
+ <button class="filter-btn" data-filter="generate">Can generate <span>${generateCount}</span></button>
411
+ <button class="filter-btn" data-filter="map">Decision maps <span>${mapOnlyCount}</span></button>
412
+ <button class="filter-btn" data-filter="codex">Codex <span>${counts.codex || 0}</span></button>
413
+ <button class="filter-btn" data-filter="claude">Claude <span>${counts.claude || 0}</span></button>
414
+ </div>
415
+ <div class="summary-actions"><button class="compact" data-expand-all>Expand all</button><button class="compact" data-collapse-all>Collapse scanned</button></div>
416
+ <p>Mission mode is the target experience for labs: decision first, evidence second, transferable ownership last.</p>
417
+ </aside>
418
+ <section>
419
+ <p class="active-filter" data-filter-label>Showing all sessions across ${projectCount} projects.</p>
420
+ <div class="grid">${cards || emptyState()}</div>
421
+ </section>
422
+ </div>
423
+ ${interactive ? inboxScript() : ""}
424
+ </main></body></html>`;
425
+ }
426
+
427
+ function groupedSessionCards(sessions) {
428
+ const groups = new Map();
429
+ for (const session of sessions) {
430
+ if (!groups.has(session.project)) groups.set(session.project, []);
431
+ groups.get(session.project).push(session);
432
+ }
433
+ return [...groups.entries()].map(([project, projectSessions], index) => {
434
+ const counts = countByTool(projectSessions);
435
+ const ready = projectSessions.filter((session) => session.richLabs > 0).length;
436
+ const canGenerate = projectSessions.filter((session) => !session.richLabs && session.hasConcreteEvidence).length;
437
+ const open = index < 2 || ready > 0 || counts.codex > 0;
438
+ const meta = [
439
+ `${projectSessions.length} session${projectSessions.length === 1 ? "" : "s"}`,
440
+ `${ready} ready`,
441
+ `${canGenerate} can generate`,
442
+ `${counts.claude || 0} Claude`,
443
+ `${counts.codex || 0} Codex`
444
+ ].join(" · ");
445
+ return `<details class="project-group" data-project-group${open ? " open" : ""}>
446
+ <summary class="project-head"><div class="project-title"><span class="toggle-mark" aria-hidden="true"></span><div><h2>${escapeHtml(project)}</h2><p>${escapeHtml(meta)}</p></div></div><div class="project-count">Project</div></summary>
447
+ <div class="project-cards">${projectSessions.map(sessionCard).join("\n")}</div>
448
+ </details>`;
449
+ }).join("\n");
450
+ }
451
+
452
+ function countByTool(sessions) {
453
+ return sessions.reduce((acc, session) => {
454
+ acc[session.tool] = (acc[session.tool] || 0) + 1;
455
+ return acc;
456
+ }, {});
457
+ }
458
+
459
+ function emptyState() {
460
+ return `<article class="card" style="display:block">
461
+ <h2>No local sessions found yet.</h2>
462
+ <p>Replay Labs looks for Claude Code sessions in <code>~/.claude/projects</code> and Codex sessions in <code>~/.codex/sessions</code>. No upload or paste is required.</p>
463
+ <p style="margin-top:9px">Run a technical/product session with Claude or Codex, then come back and refresh.</p>
464
+ </article>`;
465
+ }
466
+
467
+ function sessionCard(session) {
468
+ const decisionTags = session.decisions.slice(0, 3)
469
+ .map((d) => `<span class="tag">${escapeHtml(d.title)}</span>`).join("");
470
+ const stateTag = session.richLabs
471
+ ? `<span class="tag ready">${session.richLabs} ready lab${session.richLabs === 1 ? "" : "s"}</span>`
472
+ : session.hasConcreteEvidence
473
+ ? `<span class="tag signal">has changed lines · can try generation</span>`
474
+ : `<span class="tag limited">decision signals · needs changed lines</span>`;
475
+ const action = session.richLabs
476
+ ? `<button class="primary" data-build>Build lab</button>`
477
+ : session.hasConcreteEvidence
478
+ ? `<button class="secondary" data-build data-generate="true">Generate lab</button>`
479
+ : `<button class="secondary" data-build data-map-only="true">Build decision map</button>`;
480
+ const state = session.richLabs ? "ready" : session.hasConcreteEvidence ? "generate" : "map";
481
+ return `<article class="card" data-session-path="${escapeHtml(session.path)}" data-tool="${escapeHtml(session.tool)}" data-state="${state}">
482
+ <div class="meta">${escapeHtml(session.tool)}<br>${escapeHtml(new Date(session.modified).toLocaleString())}</div>
483
+ <div>
484
+ <h2>${escapeHtml(session.title)}</h2>
485
+ <p>${escapeHtml(session.project)} · ${session.stats.turns} turns · ${session.stats.edits} ${escapeHtml(session.fileSignalLabel)} · ${session.richLabs} ready labs</p>
486
+ <div class="tags">${stateTag}${decisionTags}</div>
487
+ <p style="margin-top:9px">${escapeHtml(session.reason)}</p>
488
+ </div>
489
+ <div class="card-actions">${action}</div>
490
+ <div class="card-status"></div>
491
+ </article>`;
492
+ }
493
+
494
+ function inboxScript() {
495
+ return `<script>
496
+ const statusBox = document.getElementById("status");
497
+ let currentFilter = "all";
498
+ function resultHtml(data, heading) {
499
+ const ready = !data.noDecisionSignals && !data.noReadyLabs;
500
+ const title = heading || (ready ? "Lab ready." : data.noDecisionSignals ? "No decision map yet." : "Decision map ready.");
501
+ const body = data.message || (ready ? "Open the lab and start with the strongest decision." : "Open the session map for what Replay could recover.");
502
+ const primary = ready && data.primaryLabHref
503
+ ? '<a class="primary" href="' + data.primaryLabHref + '">Start lab</a>'
504
+ : "";
505
+ return '<div class="result' + (ready ? "" : " warn") + '"><b>' + title + '</b><p>' + body + '</p><div class="result-actions">' +
506
+ primary + '<a class="' + (primary ? "secondary" : "primary") + '" href="' + data.href + '">Open session map</a></div></div>';
507
+ }
508
+ function showStatus(html, target) {
509
+ const box = target || statusBox;
510
+ box.style.display = "block";
511
+ box.innerHTML = html;
512
+ }
513
+ function statusForSession(sessionPath) {
514
+ const cards = Array.from(document.querySelectorAll("[data-session-path]"));
515
+ const card = cards.find((item) => item.dataset.sessionPath === sessionPath);
516
+ if (!card) return null;
517
+ card.scrollIntoView({ behavior: "smooth", block: "center" });
518
+ return card.querySelector(".card-status");
519
+ }
520
+ function cardMatchesFilter(card, filter) {
521
+ if (filter === "all") return true;
522
+ if (filter === "ready" || filter === "generate" || filter === "map") return card.dataset.state === filter;
523
+ if (filter === "codex" || filter === "claude") return card.dataset.tool === filter;
524
+ return true;
525
+ }
526
+ function applyFilter(filter) {
527
+ currentFilter = filter;
528
+ const groups = Array.from(document.querySelectorAll("[data-project-group]"));
529
+ let visibleCards = 0;
530
+ let visibleProjects = 0;
531
+ groups.forEach((group) => {
532
+ const cards = Array.from(group.querySelectorAll("[data-session-path]"));
533
+ let groupHasVisibleCard = false;
534
+ cards.forEach((card) => {
535
+ const visible = cardMatchesFilter(card, filter);
536
+ card.classList.toggle("is-hidden", !visible);
537
+ if (visible) {
538
+ visibleCards += 1;
539
+ groupHasVisibleCard = true;
540
+ }
541
+ });
542
+ group.classList.toggle("is-hidden", !groupHasVisibleCard);
543
+ if (groupHasVisibleCard) {
544
+ visibleProjects += 1;
545
+ if (filter !== "all") group.open = true;
546
+ }
547
+ });
548
+ document.querySelectorAll("[data-filter]").forEach((button) => {
549
+ button.classList.toggle("on", button.dataset.filter === filter);
550
+ });
551
+ const label = document.querySelector("[data-filter-label]");
552
+ if (label) {
553
+ const names = { all: "all sessions", ready: "ready labs", generate: "sessions that can generate", map: "decision maps", codex: "Codex sessions", claude: "Claude sessions" };
554
+ label.textContent = "Showing " + (names[filter] || "sessions") + " across " + visibleProjects + " project" + (visibleProjects === 1 ? "" : "s") + " (" + visibleCards + " sessions).";
555
+ }
556
+ }
557
+ async function buildLab(sessionPath, generate, statusTarget, mapOnly) {
558
+ showStatus(mapOnly ? "Building decision map..." : (generate ? "Generating from changed-line evidence..." : "Building lab locally..."), statusTarget);
559
+ const res = await fetch("/api/build-lab", {
560
+ method: "POST",
561
+ headers: { "content-type": "application/json" },
562
+ body: JSON.stringify({ sessionPath, generate })
563
+ });
564
+ const data = await res.json();
565
+ if (!res.ok) throw new Error(data.error || "Build failed");
566
+ showStatus(resultHtml(data), statusTarget);
567
+ }
568
+ document.addEventListener("click", async (event) => {
569
+ const build = event.target.closest("[data-build]");
570
+ const choose = event.target.closest("[data-choose]");
571
+ const refresh = event.target.closest("[data-refresh]");
572
+ const expandAll = event.target.closest("[data-expand-all]");
573
+ const collapseAll = event.target.closest("[data-collapse-all]");
574
+ const filter = event.target.closest("[data-filter]");
575
+ try {
576
+ if (refresh) { location.reload(); return; }
577
+ if (filter) {
578
+ applyFilter(filter.dataset.filter || "all");
579
+ return;
580
+ }
581
+ if (expandAll) {
582
+ document.querySelectorAll("[data-project-group]:not(.is-hidden)").forEach((group) => { group.open = true; });
583
+ return;
584
+ }
585
+ if (collapseAll) {
586
+ document.querySelectorAll("[data-project-group]:not(.is-hidden)").forEach((group, index) => { group.open = index === 0; });
587
+ return;
588
+ }
589
+ if (choose) {
590
+ choose.disabled = true;
591
+ try {
592
+ showStatus("Replay is choosing a local session with enough evidence...");
593
+ const res = await fetch("/api/choose-lab", { method: "POST" });
594
+ const data = await res.json();
595
+ if (!res.ok) throw new Error(data.error || "Choose failed");
596
+ const prefix = data.noDecisionSignals ? "No decision map is ready yet." : data.noReadyLabs ? "Suggested decision map." : "Suggested ready lab.";
597
+ const target = statusForSession(data.sessionPath) || statusBox;
598
+ showStatus(resultHtml(data, prefix), target);
599
+ if (target !== statusBox) statusBox.style.display = "none";
600
+ } finally {
601
+ choose.disabled = false;
602
+ }
603
+ return;
604
+ }
605
+ if (build) {
606
+ build.disabled = true;
607
+ const card = build.closest("[data-session-path]");
608
+ try {
609
+ await buildLab(card.dataset.sessionPath, build.dataset.generate === "true", card.querySelector(".card-status"), build.dataset.mapOnly === "true");
610
+ } finally {
611
+ build.disabled = false;
612
+ }
613
+ }
614
+ } catch (error) {
615
+ const target = build ? build.closest("[data-session-path]")?.querySelector(".card-status") : null;
616
+ showStatus("<b>Could not complete action.</b><br>" + String(error.message || error), target);
617
+ }
618
+ });
619
+ </script>`;
620
+ }
621
+
622
+ function projectNameFor(file, homeDir, tool, ingested = null) {
623
+ if (tool === "claude") {
624
+ const rel = relative(join(homeDir, ".claude", "projects"), file);
625
+ const head = rel.split(/[\\/]/)[0] || "unknown-project";
626
+ return head.replace(/^-Users-[^-]+-/, "").replaceAll("-", "/");
627
+ }
628
+ if (ingested?.cwd) return projectNameFromPath(ingested.cwd, homeDir);
629
+ const rel = relative(join(homeDir, ".codex", "sessions"), file);
630
+ return rel.split(/[\\/]/).slice(0, 3).join("/") || "codex";
631
+ }
632
+
633
+ function projectNameFromPath(path, homeDir) {
634
+ const rel = relative(homeDir, path);
635
+ if (rel && !rel.startsWith("..")) return rel.replaceAll("\\", "/");
636
+ return String(path).replaceAll("\\", "/").split("/").slice(-3).join("/") || "codex";
637
+ }
638
+
639
+ function titleFromGoal(goal, project) {
640
+ const cleaned = String(goal || "").replace(/\s+/g, " ").trim();
641
+ if (!cleaned || /^untitled/i.test(cleaned)) return project;
642
+ return cleaned.length > 86 ? cleaned.slice(0, 83) + "..." : cleaned;
643
+ }
644
+
645
+ function stableId(tool, file) {
646
+ return `${tool}:${basename(file, ".jsonl")}`;
647
+ }
648
+
649
+ function isClaudePath(path) {
650
+ return path.includes("/.claude/projects/") || path.includes("\\.claude\\projects\\");
651
+ }
652
+
653
+ function isCodexPath(path) {
654
+ return path.includes("/.codex/sessions/") || path.includes("\\.codex\\sessions\\");
655
+ }
656
+
657
+ function codexContentText(content) {
658
+ if (!content) return "";
659
+ if (typeof content === "string") return content;
660
+ if (!Array.isArray(content)) return "";
661
+ return content
662
+ .map((part) => part.type === "input_text" || part.type === "output_text" ? part.text || "" : "")
663
+ .filter(Boolean)
664
+ .join("\n");
665
+ }
666
+
667
+ function cleanCodexText(text) {
668
+ return String(text)
669
+ .replace(/<environment_context>[\s\S]*?<\/environment_context>/g, " ")
670
+ .replace(/<codex_internal_context[\s\S]*?<\/codex_internal_context>/g, " ")
671
+ .replace(/# Files mentioned by the user:[\s\S]*?## My request for Codex:/g, " ")
672
+ .replace(/# In app browser:[\s\S]*?## My request for Codex:/g, " ")
673
+ .replace(/\s+/g, " ")
674
+ .trim();
675
+ }
676
+
677
+ function filesFromCommand(command) {
678
+ const files = [];
679
+ const patterns = [
680
+ /\b(?:src|app|test|tests|docs|scripts|lib|server|client|components)\/[A-Za-z0-9._/-]+\b/g,
681
+ /\b[A-Za-z0-9._/-]+\.(?:js|ts|tsx|jsx|py|go|kt|java|swift|sql|md|json|yaml|yml|toml)\b/g
682
+ ];
683
+ for (const pattern of patterns) {
684
+ for (const match of command.matchAll(pattern)) {
685
+ const file = match[0].replace(/^["']|["']$/g, "");
686
+ if (!file.includes("node_modules")) files.push(file);
687
+ }
688
+ }
689
+ return [...new Set(files)].slice(0, 20);
690
+ }
691
+
692
+ function safeJson(value) {
693
+ try { return JSON.parse(value || "{}"); } catch { return {}; }
694
+ }
695
+
696
+ function clip(text, max) {
697
+ const cleaned = String(text).trim();
698
+ return cleaned.length > max ? cleaned.slice(0, max) + " [...]" : cleaned;
699
+ }
700
+
701
+ function safeSlug(value) {
702
+ return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "session";
703
+ }
704
+
705
+ function shellQuote(value) {
706
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
707
+ }
708
+
709
+ function escapeHtml(value) {
710
+ return String(value)
711
+ .replaceAll("&", "&amp;")
712
+ .replaceAll("<", "&lt;")
713
+ .replaceAll(">", "&gt;")
714
+ .replaceAll('"', "&quot;");
715
+ }