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/LICENSE +22 -0
- package/README.md +134 -0
- package/examples/password-reset-transcript.md +27 -0
- package/examples/password-reset.diff +101 -0
- package/package.json +47 -0
- package/scripts/capture-git-working-diff.js +56 -0
- package/scripts/create-added-files-diff.js +33 -0
- package/scripts/extract-claude-transcript.js +86 -0
- package/scripts/extract-codex-transcript.js +119 -0
- package/src/cli.js +316 -0
- package/src/discovery.js +715 -0
- package/src/generate.js +406 -0
- package/src/ingest.js +124 -0
- package/src/interaction.js +1161 -0
- package/src/lab-ui.js +1339 -0
- package/src/modules.js +643 -0
- package/src/overview.js +147 -0
- package/src/patterns.js +322 -0
- package/src/pipeline.js +68 -0
- package/src/report.js +516 -0
- package/src/review.js +238 -0
- package/src/server.js +199 -0
- package/src/storage.js +34 -0
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
|
+
});
|