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.
package/src/cli.js ADDED
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { createServer as createNetServer } from "node:net";
6
+ import { once } from "node:events";
7
+ import { dirname, resolve } from "node:path";
8
+ import { generateLearningReport } from "./report.js";
9
+ import { generateDecisionReplayHtml } from "./interaction.js";
10
+ import { buildSessionBundle } from "./pipeline.js";
11
+ import { ensureAppDataDirs } from "./storage.js";
12
+
13
+ function printUsage() {
14
+ console.log(`Usage:
15
+ replay-labs [app] [--port 4177] [--root .] [--no-open]
16
+ replay-labs serve [--port 4177] [--root .] [--no-open]
17
+ replay-labs scan [--out-dir ./replay-inbox] [--limit 80]
18
+ replay-labs choose [--out-dir ./reports-recommended] [--generate]
19
+ replay-labs lab --session /path/to/session.jsonl [--out-dir ./reports-session] [--generate]
20
+ replay-labs session --goal "..." --diff ./session.diff --transcript ./session.md [--out-dir ./reports]
21
+ replay-labs learn --goal "Add password reset" --diff ./session.diff --transcript ./session.md --out ./report.md
22
+ replay-labs interact --goal "Add password reset" --diff ./session.diff --transcript ./session.md --out ./replay.html
23
+ replay-labs patterns --out ./reports/patterns
24
+
25
+ replay-labs session analyzes the session, ranks its decisions,
26
+ generates one lab per known pattern plus the session map (index.html).
27
+
28
+ replay-labs scan is the Session Inbox: it discovers local Claude/Codex sessions
29
+ without upload or paste. replay-labs choose lets Replay Labs pick the suggested
30
+ lab session. replay-labs lab builds from one selected local session.
31
+
32
+ Options:
33
+ --goal Human-readable goal for the session
34
+ --diff Path to git diff or patch text
35
+ --transcript Path to transcript, JSONL, or markdown log
36
+ --out Output path (report file, lab html, or patterns directory)
37
+ --out-dir Output directory for session maps, inboxes, and labs
38
+ --session Path to a local Claude/Codex .jsonl session
39
+ --limit Max sessions to show in replay scan (default 80)
40
+ --port Port for replay-labs serve (default 4177)
41
+ --root Directory replay-labs serve exposes (default cwd)
42
+ --no-open Start the local app without opening a browser
43
+
44
+ Running replay-labs with no command starts the local app and opens the inbox.
45
+ replay-labs serve hosts the lab AND the review endpoint (POST /api/review).
46
+ Repair and transfer stages get real LLM review when served; opened as a
47
+ plain file they fall back to labeled heuristics.
48
+
49
+ The shorter replay command is also installed as an alias.
50
+ `);
51
+ }
52
+
53
+ function parseArgs(argv) {
54
+ let [command, ...rest] = argv;
55
+ if (command && command.startsWith("--")) {
56
+ rest = argv;
57
+ command = "app";
58
+ }
59
+ const args = { command };
60
+
61
+ for (let index = 0; index < rest.length; index += 1) {
62
+ const token = rest[index];
63
+ if (!token.startsWith("--")) {
64
+ throw new Error(`Unexpected argument: ${token}`);
65
+ }
66
+
67
+ const key = token.slice(2);
68
+ const value = rest[index + 1];
69
+ if (!value || value.startsWith("--")) {
70
+ args[key] = true; // bare boolean flag, e.g. --generate
71
+ continue;
72
+ }
73
+
74
+ args[key] = value;
75
+ index += 1;
76
+ }
77
+
78
+ return args;
79
+ }
80
+
81
+ async function findOpenPort(preferredPort, host = "127.0.0.1") {
82
+ const preferred = Number(preferredPort || 4177);
83
+ if (preferred === 0) return 0;
84
+ for (let port = preferred; port < preferred + 25; port += 1) {
85
+ if (await portIsAvailable(port, host)) return port;
86
+ }
87
+ return 0;
88
+ }
89
+
90
+ async function portIsAvailable(port, host) {
91
+ const server = createNetServer();
92
+ server.unref();
93
+ return new Promise((resolvePort) => {
94
+ server.once("error", () => resolvePort(false));
95
+ server.once("listening", () => {
96
+ server.close(() => resolvePort(true));
97
+ });
98
+ server.listen(port, host);
99
+ });
100
+ }
101
+
102
+ function openBrowser(url) {
103
+ const command = process.platform === "darwin"
104
+ ? "open"
105
+ : process.platform === "win32"
106
+ ? "cmd"
107
+ : "xdg-open";
108
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
109
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
110
+ child.unref();
111
+ }
112
+
113
+ async function startLocalApp(args) {
114
+ const { startServer } = await import("./server.js");
115
+ const paths = await ensureAppDataDirs();
116
+ const host = args.host || "127.0.0.1";
117
+ const port = await findOpenPort(args.port || 4177, host);
118
+ const server = startServer({
119
+ root: args.root || process.cwd(),
120
+ port,
121
+ host,
122
+ artifactRoot: paths.labsDir
123
+ });
124
+ await once(server, "listening");
125
+ const address = server.address();
126
+ const actualPort = typeof address === "object" && address ? address.port : port;
127
+ const inboxUrl = `http://${host}:${actualPort}/inbox`;
128
+ console.log(`privacy: Replay Labs reads local AI session files and writes generated labs to ${paths.root}`);
129
+ if (args.open !== false && args["no-open"] !== true) {
130
+ openBrowser(inboxUrl);
131
+ }
132
+ }
133
+
134
+ async function readTextFile(path, label) {
135
+ try {
136
+ return await readFile(resolve(path), "utf8");
137
+ } catch (error) {
138
+ throw new Error(`Could not read ${label} at ${path}: ${error.message}`);
139
+ }
140
+ }
141
+
142
+ async function main() {
143
+ const args = parseArgs(process.argv.slice(2));
144
+
145
+ if (args.command === "help" || args.command === "--help") {
146
+ printUsage();
147
+ return;
148
+ }
149
+
150
+ if (!args.command || args.command === "app" || args.command === "serve") {
151
+ await startLocalApp(args);
152
+ return;
153
+ }
154
+
155
+ async function runSessionPipeline({ goal, diff, transcript, diffPath, transcriptPath, outDir, generate = false, maxGenerated = 1 }) {
156
+ if (generate) console.log("Generating labs for unseen decisions (this calls the model)…");
157
+ const { labs, files, indexPath } = await buildSessionBundle({
158
+ goal, diff, transcript, diffPath, transcriptPath,
159
+ outDir,
160
+ generate,
161
+ maxGenerated
162
+ });
163
+ for (const file of files) console.log(`Wrote ${file}`);
164
+ console.log(`Session map: ${labs.length} decisions, ${labs.filter((l) => l.rich).length} labs available.`);
165
+ console.log(`Open: ${indexPath}`);
166
+ return labs;
167
+ }
168
+
169
+ if (args.command === "session") {
170
+ for (const key of ["goal", "diff", "transcript"]) {
171
+ if (!args[key]) throw new Error(`Missing required option --${key}`);
172
+ }
173
+ const diff = await readTextFile(args.diff, "diff");
174
+ const transcript = await readTextFile(args.transcript, "transcript");
175
+ await runSessionPipeline({
176
+ goal: args.goal, diff, transcript,
177
+ diffPath: args.diff, transcriptPath: args.transcript,
178
+ outDir: resolve(args["out-dir"] || "./reports"),
179
+ generate: args.generate === true || args.generate === "true",
180
+ maxGenerated: parseInt(args["max-generated"] || "1", 10)
181
+ });
182
+ return;
183
+ }
184
+
185
+ if (args.command === "scan") {
186
+ const { bestSessionFrom, discoverSessions, writeSessionInbox } = await import("./discovery.js");
187
+ const sessions = await discoverSessions({ limit: parseInt(args.limit || "80", 10) });
188
+ const outDir = resolve(args["out-dir"] || "./replay-inbox");
189
+ await writeSessionInbox({ sessions, outDir });
190
+ console.log(`Replay Labs found ${sessions.length} local AI sessions.`);
191
+ const ready = sessions.filter((s) => s.richLabs > 0).length;
192
+ const canGenerate = sessions.filter((s) => s.richLabs === 0 && s.hasConcreteEvidence).length;
193
+ const mapOnly = sessions.filter((s) => s.richLabs === 0 && !s.hasConcreteEvidence).length;
194
+ const strong = sessions.filter((s) => s.labPotential === "strong").length;
195
+ const medium = sessions.filter((s) => s.labPotential === "medium").length;
196
+ console.log(`${ready} ready labs, ${canGenerate} sessions with diff evidence, ${mapOnly} decision-map-only sessions.`);
197
+ console.log(`${strong} sessions with enough evidence, ${medium} medium signal sessions.`);
198
+ console.log(`Inbox: ${resolve(outDir, "index.html")}`);
199
+ const best = bestSessionFrom(sessions);
200
+ if (best) {
201
+ console.log(`Try: ${best.command}`);
202
+ console.log(`Or: node ./src/cli.js choose --out-dir ./reports-recommended`);
203
+ }
204
+ return;
205
+ }
206
+
207
+ if (args.command === "choose") {
208
+ const { chooseBestSession, loadDiscoveredSession } = await import("./discovery.js");
209
+ const selected = await chooseBestSession({ limit: parseInt(args.limit || "80", 10) });
210
+ if (!selected) throw new Error("No local Claude/Codex sessions found.");
211
+ console.log(`Replay chose: ${selected.title}`);
212
+ console.log(`Reason: ${selected.reason}`);
213
+ const loaded = await loadDiscoveredSession(selected.path);
214
+ console.log(`Loaded ${loaded.tool} session: ${loaded.stats.turns} turns, ${loaded.stats.edits} file signals`);
215
+ await runSessionPipeline({
216
+ goal: args.goal || loaded.goal,
217
+ diff: loaded.diff,
218
+ transcript: loaded.transcript,
219
+ diffPath: selected.path,
220
+ transcriptPath: selected.path,
221
+ outDir: resolve(args["out-dir"] || "./reports-recommended"),
222
+ generate: args.generate === true || args.generate === "true",
223
+ maxGenerated: parseInt(args["max-generated"] || "1", 10)
224
+ });
225
+ return;
226
+ }
227
+
228
+ if (args.command === "lab") {
229
+ if (!args.session) throw new Error("Missing required option --session (path to a local Claude/Codex .jsonl session)");
230
+ const { loadDiscoveredSession } = await import("./discovery.js");
231
+ const loaded = await loadDiscoveredSession(args.session);
232
+ console.log(`Loaded ${loaded.tool} session: ${loaded.stats.turns} turns, ${loaded.stats.edits} file signals`);
233
+ await runSessionPipeline({
234
+ goal: args.goal || loaded.goal,
235
+ diff: loaded.diff,
236
+ transcript: loaded.transcript,
237
+ diffPath: args.session,
238
+ transcriptPath: args.session,
239
+ outDir: resolve(args["out-dir"] || "./reports-selected"),
240
+ generate: args.generate === true || args.generate === "true",
241
+ maxGenerated: parseInt(args["max-generated"] || "1", 10)
242
+ });
243
+ return;
244
+ }
245
+
246
+ if (args.command === "ingest") {
247
+ if (!args.session) throw new Error("Missing required option --session (path to a Claude Code .jsonl transcript)");
248
+ const { ingestClaudeSession } = await import("./ingest.js");
249
+ const { readdir, stat } = await import("node:fs/promises");
250
+ let sessionPath = resolve(args.session);
251
+ if ((await stat(sessionPath)).isDirectory()) {
252
+ const files = (await readdir(sessionPath)).filter((f) => f.endsWith(".jsonl"));
253
+ const stats = await Promise.all(files.map(async (f) => ({ f, m: (await stat(resolve(sessionPath, f))).mtimeMs })));
254
+ stats.sort((a, b) => b.m - a.m);
255
+ if (!stats.length) throw new Error(`No .jsonl transcripts in ${sessionPath}`);
256
+ sessionPath = resolve(sessionPath, stats[0].f);
257
+ console.log(`Latest session: ${sessionPath}`);
258
+ }
259
+ const raw = await readTextFile(sessionPath, "session transcript");
260
+ const { goal, transcript, diff, stats } = ingestClaudeSession(raw);
261
+ console.log(`Ingested: ${stats.records} records -> ${stats.turns} turns, ${stats.edits} edits across ${stats.files.length} files`);
262
+ await runSessionPipeline({
263
+ goal: args.goal || goal, diff, transcript,
264
+ diffPath: sessionPath, transcriptPath: sessionPath,
265
+ outDir: resolve(args["out-dir"] || "./reports-session"),
266
+ generate: args.generate === true || args.generate === "true",
267
+ maxGenerated: parseInt(args["max-generated"] || "1", 10)
268
+ });
269
+ return;
270
+ }
271
+
272
+ if (args.command === "patterns") {
273
+ const { PATTERNS, generatePatternHtml } = await import("./patterns.js");
274
+ const outDir = resolve(args.out || "./reports/patterns");
275
+ await mkdir(outDir, { recursive: true });
276
+ for (const slug of Object.keys(PATTERNS)) {
277
+ const outPath = resolve(outDir, `${slug}.html`);
278
+ await writeFile(outPath, generatePatternHtml(slug), "utf8");
279
+ console.log(`Wrote ${outPath}`);
280
+ }
281
+ return;
282
+ }
283
+
284
+ if (args.command !== "learn" && args.command !== "interact") {
285
+ throw new Error(`Unknown command: ${args.command}`);
286
+ }
287
+
288
+ for (const key of ["goal", "diff", "transcript", "out"]) {
289
+ if (!args[key]) {
290
+ throw new Error(`Missing required option --${key}`);
291
+ }
292
+ }
293
+
294
+ const diff = await readTextFile(args.diff, "diff");
295
+ const transcript = await readTextFile(args.transcript, "transcript");
296
+ const input = {
297
+ goal: args.goal,
298
+ diff,
299
+ transcript,
300
+ diffPath: args.diff,
301
+ transcriptPath: args.transcript
302
+ };
303
+ const report = args.command === "interact"
304
+ ? generateDecisionReplayHtml(input)
305
+ : generateLearningReport(input);
306
+
307
+ const outPath = resolve(args.out);
308
+ await mkdir(dirname(outPath), { recursive: true });
309
+ await writeFile(outPath, report, "utf8");
310
+ console.log(`Wrote ${outPath}`);
311
+ }
312
+
313
+ main().catch((error) => {
314
+ console.error(`replay: ${error.message}`);
315
+ process.exitCode = 1;
316
+ });