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/review.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Rubrics are the contract between the lab UI, the LLM reviewer, and the
|
|
4
|
+
// heuristic fallback. Keyed by module id, then stage. Each criterion must be
|
|
5
|
+
// observable in the learner's submission.
|
|
6
|
+
export const RUBRICS = {
|
|
7
|
+
"runtime-boundary": {
|
|
8
|
+
repair: {
|
|
9
|
+
title: "Repair the naive voice page so you would ship it",
|
|
10
|
+
context:
|
|
11
|
+
"Next.js App Router page. Naive version calls browser-only APIs with no boundary or guards:\n" +
|
|
12
|
+
"export default function Page() {\n" +
|
|
13
|
+
" const recognition = new window.SpeechRecognition();\n" +
|
|
14
|
+
" localStorage.setItem(\"goals\", \"[]\");\n" +
|
|
15
|
+
"}",
|
|
16
|
+
criteria: [
|
|
17
|
+
{ id: "boundary", name: "Browser behavior isolated behind a client boundary", required: true },
|
|
18
|
+
{ id: "guards", name: "Browser APIs feature-checked before use", required: true },
|
|
19
|
+
{ id: "unsupported", name: "Unsupported browser gets a designed state, not a crash", required: true },
|
|
20
|
+
{ id: "denied", name: "Permission denial is a handled state", required: false },
|
|
21
|
+
{ id: "verify", name: "Names how the failure states get verified", required: false }
|
|
22
|
+
],
|
|
23
|
+
passRule: "all required criteria pass, plus at least one of the optional two",
|
|
24
|
+
intentNote:
|
|
25
|
+
"Pseudo-code and comments are VALID evidence for the design criteria (unsupported, denied, verify) — " +
|
|
26
|
+
"e.g. '// denied -> mic-help screen with retry' earns the denied criterion. " +
|
|
27
|
+
"The client boundary and the capability guards must be real code, not comments. " +
|
|
28
|
+
"Judge the thinking, not React fluency."
|
|
29
|
+
},
|
|
30
|
+
transfer: {
|
|
31
|
+
title: "Transfer the judgment to geolocation + camera + localStorage",
|
|
32
|
+
context:
|
|
33
|
+
"Scenario: a future AI session adds geolocation, camera capture, and localStorage to a " +
|
|
34
|
+
"Next.js dashboard. It works in the browser during development. The learner writes a plan.",
|
|
35
|
+
criteria: [
|
|
36
|
+
{ id: "boundary", name: "Isolates the browser-capability work behind a client boundary", required: true },
|
|
37
|
+
{ id: "guards", name: "Feature-detects geolocation/camera before use", required: true },
|
|
38
|
+
{ id: "failure-states", name: "Designs denied/unsupported states up front", required: true },
|
|
39
|
+
{ id: "no-overfit", name: "Reasons about runtime ownership, not 'use client' as magic syntax", required: false },
|
|
40
|
+
{ id: "verify", name: "Verification beyond 'works in dev'", required: false }
|
|
41
|
+
],
|
|
42
|
+
passRule: "all required criteria pass, plus at least one of the optional two"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"secret-boundary": {
|
|
46
|
+
repair: {
|
|
47
|
+
title: "Move the Anthropic call behind a server boundary you would ship",
|
|
48
|
+
context:
|
|
49
|
+
"Naive version: a client module with NEXT_PUBLIC_ANTHROPIC_KEY calling api.anthropic.com " +
|
|
50
|
+
"directly from the browser. The learner rewrites it (API route sketch plus what the client " +
|
|
51
|
+
"now calls). Next.js App Router conventions apply.",
|
|
52
|
+
criteria: [
|
|
53
|
+
{ id: "route", name: "A server-side route owns the provider call", required: true },
|
|
54
|
+
{ id: "secret", name: "Secret comes from server-only env (no NEXT_PUBLIC anywhere)", required: true },
|
|
55
|
+
{ id: "validation", name: "Request input is validated before use", required: true },
|
|
56
|
+
{ id: "safe-errors", name: "Errors to the client never leak provider details or the key", required: false },
|
|
57
|
+
{ id: "abuse", name: "Considers abuse: rate, size, or token caps", required: false }
|
|
58
|
+
],
|
|
59
|
+
passRule: "all required criteria pass, plus at least one of the optional two",
|
|
60
|
+
intentNote:
|
|
61
|
+
"Pseudo-code and comments are VALID evidence for safe-errors and abuse — " +
|
|
62
|
+
"e.g. '// 429 after 20 req/min per IP' earns the abuse criterion. " +
|
|
63
|
+
"Route ownership, the server-only secret, and input validation must be real code. " +
|
|
64
|
+
"Judge the thinking, not framework fluency."
|
|
65
|
+
},
|
|
66
|
+
transfer: {
|
|
67
|
+
title: "Transfer the judgment to Stripe checkout + webhook",
|
|
68
|
+
context:
|
|
69
|
+
"Scenario: a future AI session adds Stripe checkout and a webhook that marks orders paid. " +
|
|
70
|
+
"Works with test keys in dev. The learner writes the plan that makes it shippable.",
|
|
71
|
+
criteria: [
|
|
72
|
+
{ id: "secret", name: "Stripe secret key lives server-side only", required: true },
|
|
73
|
+
{ id: "webhook", name: "Webhook is trusted via signature verification, not by URL secrecy", required: true },
|
|
74
|
+
{ id: "validation", name: "Validates event/order data before marking anything paid", required: true },
|
|
75
|
+
{ id: "idempotency", name: "Considers replayed/duplicate events (idempotency)", required: false },
|
|
76
|
+
{ id: "verify", name: "Verification beyond 'works with test keys'", required: false }
|
|
77
|
+
],
|
|
78
|
+
passRule: "all required criteria pass, plus at least one of the optional two"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function getRubric(moduleId, stage) {
|
|
84
|
+
const mod = RUBRICS[moduleId] || RUBRICS["runtime-boundary"];
|
|
85
|
+
return mod[stage];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function reviewPrompt(rubric, submission) {
|
|
89
|
+
return `You are the reviewer inside Replay, a lab that turns real AI coding sessions into
|
|
90
|
+
professional judgment training. Review the learner's submission against the rubric.
|
|
91
|
+
|
|
92
|
+
Be concrete and tough but fair, like a senior engineer reviewing a teammate's fix.
|
|
93
|
+
Every note must reference what the learner actually wrote (or failed to write) —
|
|
94
|
+
no generic praise, no hedging. A criterion passes only on evidence in the submission.
|
|
95
|
+
|
|
96
|
+
TASK: ${rubric.title}
|
|
97
|
+
CONTEXT:
|
|
98
|
+
${rubric.context}
|
|
99
|
+
|
|
100
|
+
RUBRIC (id | name | required):
|
|
101
|
+
${rubric.criteria.map((c) => `${c.id} | ${c.name} | ${c.required}`).join("\n")}
|
|
102
|
+
|
|
103
|
+
PASS RULE: ${rubric.passRule}
|
|
104
|
+
${rubric.intentNote ? `EVIDENCE POLICY: ${rubric.intentNote}` : ""}
|
|
105
|
+
|
|
106
|
+
LEARNER SUBMISSION:
|
|
107
|
+
<<<
|
|
108
|
+
${submission}
|
|
109
|
+
>>>
|
|
110
|
+
|
|
111
|
+
Respond with STRICT JSON only, no markdown fences:
|
|
112
|
+
{"criteria":[{"id":"...","pass":true,"note":"one concrete sentence citing their submission"}],
|
|
113
|
+
"overall":"PASS"|"FAIL",
|
|
114
|
+
"summary":"2-3 sentences: the strongest thing they did and the most important gap",
|
|
115
|
+
"misconception":"the single misunderstanding their submission reveals, or null"}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const HEURISTICS = {
|
|
119
|
+
"runtime-boundary": {
|
|
120
|
+
repair: {
|
|
121
|
+
boundary: (s) => /['"]use client['"]|dynamic\s*\(.*ssr:\s*false/.test(s),
|
|
122
|
+
guards: (s) => /typeof window|in window|window\.SpeechRecognition\s*(\?\?|\|\|)|navigator\.|webkitSpeechRecognition/.test(s),
|
|
123
|
+
unsupported: (s) => /unsupported|not supported|fallback/i.test(s),
|
|
124
|
+
denied: (s) => /denied|permission|catch|onerror|NotAllowedError/i.test(s),
|
|
125
|
+
verify: (s) => /test|verify|check\b|assert|playwright|vitest|jest/i.test(s)
|
|
126
|
+
},
|
|
127
|
+
transfer: {
|
|
128
|
+
boundary: (s) => /client (component|boundary)|['"]use client['"]|isolate/i.test(s),
|
|
129
|
+
guards: (s) => /feature.detect|capabilit|typeof|in navigator|getUserMedia|permissions\.query/i.test(s),
|
|
130
|
+
"failure-states": (s) => /denied|unsupported|fallback|error state|graceful/i.test(s),
|
|
131
|
+
"no-overfit": (s) => /runtime|server|render|boundary|ownership/i.test(s),
|
|
132
|
+
verify: (s) => /test|verify|device|browser matrix|e2e|manual check/i.test(s)
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"secret-boundary": {
|
|
136
|
+
repair: {
|
|
137
|
+
route: (s) => /app\/api|route\.(ts|js)|NextResponse|export (async )?function (POST|GET)/.test(s),
|
|
138
|
+
secret: (s) => !/NEXT_PUBLIC/.test(s) && /process\.env\./.test(s),
|
|
139
|
+
validation: (s) => /validat|typeof|\.length|schema|zod|400|invalid|trim\(/i.test(s),
|
|
140
|
+
"safe-errors": (s) => /try|catch|status\(5|generic|console\.error|safe/i.test(s),
|
|
141
|
+
abuse: (s) => /rate|limit|429|max_tokens|cap|size/i.test(s)
|
|
142
|
+
},
|
|
143
|
+
transfer: {
|
|
144
|
+
secret: (s) => /server|env|secret key.*(server|env)|never.*(client|browser)/i.test(s),
|
|
145
|
+
webhook: (s) => /signature|constructEvent|stripe-signature|verif/i.test(s),
|
|
146
|
+
validation: (s) => /validat|check|amount|schema|before marking/i.test(s),
|
|
147
|
+
idempotency: (s) => /idempoten|replay|duplicate|already processed/i.test(s),
|
|
148
|
+
verify: (s) => /test|verify|stripe cli|webhook.*local|e2e/i.test(s)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export function heuristicReview(moduleId, stage, submission) {
|
|
154
|
+
const rubric = getRubric(moduleId, stage);
|
|
155
|
+
const checks = (HEURISTICS[moduleId] || HEURISTICS["runtime-boundary"])[stage] || {};
|
|
156
|
+
const criteria = rubric.criteria.map((c) => {
|
|
157
|
+
const pass = Boolean(checks[c.id] && checks[c.id](submission));
|
|
158
|
+
return {
|
|
159
|
+
id: c.id,
|
|
160
|
+
pass,
|
|
161
|
+
note: pass
|
|
162
|
+
? `Detected evidence for "${c.name}" (pattern match — real review needs the claude CLI on the server).`
|
|
163
|
+
: `No evidence found for "${c.name}".`
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
criteria,
|
|
168
|
+
overall: computeOverall(rubric, criteria),
|
|
169
|
+
summary: "Heuristic review only: pattern-matching, not understanding. Run with the claude CLI available for a real reviewer.",
|
|
170
|
+
misconception: null,
|
|
171
|
+
reviewer: "heuristic"
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function computeOverall(rubric, criteria) {
|
|
176
|
+
const byId = Object.fromEntries(criteria.map((c) => [c.id, c.pass]));
|
|
177
|
+
const requiredOk = rubric.criteria.filter((c) => c.required).every((c) => byId[c.id]);
|
|
178
|
+
const optionalOk = rubric.criteria.filter((c) => !c.required).some((c) => byId[c.id]);
|
|
179
|
+
return requiredOk && optionalOk ? "PASS" : "FAIL";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function reviewWithClaude(rubric, submission, { timeoutMs = 90000 } = {}) {
|
|
183
|
+
return new Promise((resolvePromise) => {
|
|
184
|
+
const child = execFile(
|
|
185
|
+
"claude",
|
|
186
|
+
["-p", "--model", "sonnet"],
|
|
187
|
+
{ timeout: timeoutMs, maxBuffer: 1024 * 1024 },
|
|
188
|
+
(error, stdout) => {
|
|
189
|
+
if (error) return resolvePromise(null);
|
|
190
|
+
const raw = stdout.trim();
|
|
191
|
+
const start = raw.indexOf("{");
|
|
192
|
+
const end = raw.lastIndexOf("}");
|
|
193
|
+
if (start === -1 || end === -1) return resolvePromise(null);
|
|
194
|
+
try {
|
|
195
|
+
const parsed = JSON.parse(raw.slice(start, end + 1));
|
|
196
|
+
if (!Array.isArray(parsed.criteria) || !parsed.overall) return resolvePromise(null);
|
|
197
|
+
// The model judges criteria; the pass rule stays deterministic.
|
|
198
|
+
parsed.overall = computeOverall(rubric, parsed.criteria);
|
|
199
|
+
parsed.reviewer = "claude";
|
|
200
|
+
resolvePromise(parsed);
|
|
201
|
+
} catch {
|
|
202
|
+
resolvePromise(null);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
);
|
|
206
|
+
child.stdin.write(reviewPrompt(rubric, submission));
|
|
207
|
+
child.stdin.end();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function review(stage, submission, moduleId = "runtime-boundary", inlineRubric = null) {
|
|
212
|
+
// Generated labs carry their own rubric; hand-authored labs look it up by id.
|
|
213
|
+
const rubric = inlineRubric || getRubric(moduleId, stage);
|
|
214
|
+
if (!rubric) throw new Error(`Unknown review stage: ${moduleId}:${stage}`);
|
|
215
|
+
if (!submission || submission.trim().length < 10) {
|
|
216
|
+
return {
|
|
217
|
+
criteria: [],
|
|
218
|
+
overall: "FAIL",
|
|
219
|
+
summary: "Submission is empty. Write the repair (or plan) before asking for review.",
|
|
220
|
+
misconception: null,
|
|
221
|
+
reviewer: "validator"
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const llm = await reviewWithClaude(rubric, submission);
|
|
225
|
+
if (llm) return llm;
|
|
226
|
+
// Heuristic fallback only knows hand-authored patterns. For generated labs with
|
|
227
|
+
// no heuristic, fail honestly rather than fake a pass.
|
|
228
|
+
if (!HEURISTICS[moduleId]) {
|
|
229
|
+
return {
|
|
230
|
+
criteria: rubric.criteria.map((c) => ({ id: c.id, pass: false, note: `Cannot judge "${c.name}" without the claude CLI — generated labs need a real reviewer.` })),
|
|
231
|
+
overall: "FAIL",
|
|
232
|
+
summary: "This is a generated lab; its review needs the claude CLI on the server. Start `replay-labs serve` where claude is available.",
|
|
233
|
+
misconception: null,
|
|
234
|
+
reviewer: "unavailable"
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return heuristicReview(moduleId, stage, submission);
|
|
238
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { extname, join, normalize, resolve } from "node:path";
|
|
4
|
+
import { review } from "./review.js";
|
|
5
|
+
import { bestSessionFrom, discoverSessions, generateInboxHtml, loadDiscoveredSession } from "./discovery.js";
|
|
6
|
+
import { buildSessionBundle, bundleSlug } from "./pipeline.js";
|
|
7
|
+
|
|
8
|
+
const TYPES = {
|
|
9
|
+
".html": "text/html; charset=utf-8",
|
|
10
|
+
".js": "text/javascript; charset=utf-8",
|
|
11
|
+
".css": "text/css; charset=utf-8",
|
|
12
|
+
".json": "application/json; charset=utf-8",
|
|
13
|
+
".md": "text/plain; charset=utf-8",
|
|
14
|
+
".svg": "image/svg+xml",
|
|
15
|
+
".png": "image/png"
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function startServer({ root = process.cwd(), port = 4177, host = "127.0.0.1", homeDir = undefined, artifactRoot = undefined } = {}) {
|
|
19
|
+
const base = resolve(root);
|
|
20
|
+
const labsBase = resolve(artifactRoot || resolve(base, "replay-built"));
|
|
21
|
+
|
|
22
|
+
const server = createServer(async (req, res) => {
|
|
23
|
+
if (req.method === "POST" && req.url === "/api/review") {
|
|
24
|
+
let body = "";
|
|
25
|
+
req.on("data", (chunk) => { body += chunk; if (body.length > 200000) req.destroy(); });
|
|
26
|
+
req.on("end", async () => {
|
|
27
|
+
try {
|
|
28
|
+
const { stage, submission, moduleId, rubric } = JSON.parse(body);
|
|
29
|
+
const inlineRubric = rubric && rubric[stage] ? rubric[stage] : null;
|
|
30
|
+
const result = await review(stage, submission, moduleId, inlineRubric);
|
|
31
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
32
|
+
res.end(JSON.stringify(result));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
35
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (req.method === "GET" && (req.url === "/" || req.url === "" || req.url?.startsWith("/inbox"))) {
|
|
42
|
+
try {
|
|
43
|
+
const sessions = await discoverSessions({ limit: 80, homeDir });
|
|
44
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
45
|
+
res.end(generateInboxHtml(sessions, { interactive: true }));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
json(res, 500, { error: error.message });
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (req.method === "GET" && req.url?.startsWith("/api/sessions")) {
|
|
53
|
+
try {
|
|
54
|
+
const sessions = await discoverSessions({ limit: 80, homeDir });
|
|
55
|
+
json(res, 200, { sessions });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
json(res, 500, { error: error.message });
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (req.method === "POST" && req.url === "/api/choose-lab") {
|
|
63
|
+
readJson(req, async (error, body) => {
|
|
64
|
+
if (error) { json(res, 400, { error: error.message }); return; }
|
|
65
|
+
try {
|
|
66
|
+
const sessions = await discoverSessions({ limit: 300, homeDir });
|
|
67
|
+
const selected = bestSessionFrom(sessions);
|
|
68
|
+
if (!selected) throw new Error("No local Claude/Codex sessions found.");
|
|
69
|
+
const result = await buildFromSession({
|
|
70
|
+
base,
|
|
71
|
+
artifactRoot: labsBase,
|
|
72
|
+
sessionPath: selected.path,
|
|
73
|
+
generate: body?.generate === true || (selected.richLabs === 0 && selected.hasConcreteEvidence),
|
|
74
|
+
title: selected.title
|
|
75
|
+
});
|
|
76
|
+
json(res, 200, { ...result, title: selected.title, reason: selected.reason, sessionPath: selected.path });
|
|
77
|
+
} catch (buildError) {
|
|
78
|
+
json(res, 500, { error: buildError.message });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (req.method === "POST" && req.url === "/api/build-lab") {
|
|
85
|
+
readJson(req, async (error, body) => {
|
|
86
|
+
if (error) { json(res, 400, { error: error.message }); return; }
|
|
87
|
+
try {
|
|
88
|
+
if (!body?.sessionPath) throw new Error("Missing sessionPath.");
|
|
89
|
+
const result = await buildFromSession({
|
|
90
|
+
base,
|
|
91
|
+
artifactRoot: labsBase,
|
|
92
|
+
sessionPath: body.sessionPath,
|
|
93
|
+
generate: body.generate === true
|
|
94
|
+
});
|
|
95
|
+
json(res, 200, result);
|
|
96
|
+
} catch (buildError) {
|
|
97
|
+
json(res, 500, { error: buildError.message });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (req.method === "GET" && req.url === "/api/health") {
|
|
104
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
105
|
+
res.end(JSON.stringify({ ok: true }));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
|
|
110
|
+
const safePath = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
|
|
111
|
+
let filePath = join(base, safePath);
|
|
112
|
+
if (safePath === "/replay-built" || safePath.startsWith("/replay-built/")) {
|
|
113
|
+
filePath = join(labsBase, safePath.replace(/^\/replay-built\/?/, ""));
|
|
114
|
+
}
|
|
115
|
+
if (!filePath.startsWith(base)) {
|
|
116
|
+
if (!filePath.startsWith(labsBase)) {
|
|
117
|
+
res.writeHead(403); res.end("forbidden"); return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const candidates = [filePath];
|
|
121
|
+
for (const candidate of candidates) {
|
|
122
|
+
try {
|
|
123
|
+
const content = await readFile(candidate);
|
|
124
|
+
res.writeHead(200, { "content-type": TYPES[extname(candidate)] || "application/octet-stream" });
|
|
125
|
+
res.end(content);
|
|
126
|
+
return;
|
|
127
|
+
} catch { /* try next candidate */ }
|
|
128
|
+
}
|
|
129
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
130
|
+
res.end(`not found: ${urlPath}`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
server.listen(port, host, () => {
|
|
134
|
+
const address = server.address();
|
|
135
|
+
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
136
|
+
console.log(`Replay Labs: http://${host}:${actualPort}/ (root: ${base})`);
|
|
137
|
+
console.log(`session inbox: http://${host}:${actualPort}/inbox`);
|
|
138
|
+
console.log(`local app data: ${labsBase}`);
|
|
139
|
+
console.log("review endpoint: POST /api/review — uses the claude CLI when available, heuristic fallback otherwise");
|
|
140
|
+
});
|
|
141
|
+
return server;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function buildFromSession({ base, artifactRoot, sessionPath, generate, title }) {
|
|
145
|
+
const loaded = await loadDiscoveredSession(sessionPath);
|
|
146
|
+
const slug = bundleSlug(title || loaded.goal || sessionPath);
|
|
147
|
+
const outDir = resolve(artifactRoot, slug);
|
|
148
|
+
const bundle = await buildSessionBundle({
|
|
149
|
+
goal: loaded.goal,
|
|
150
|
+
diff: loaded.diff,
|
|
151
|
+
transcript: loaded.transcript,
|
|
152
|
+
diffPath: sessionPath,
|
|
153
|
+
transcriptPath: sessionPath,
|
|
154
|
+
outDir,
|
|
155
|
+
generate,
|
|
156
|
+
maxGenerated: 1
|
|
157
|
+
});
|
|
158
|
+
const richCount = bundle.labs.filter((lab) => lab.rich).length;
|
|
159
|
+
const primaryLab = bundle.labs.find((lab) => lab.rich);
|
|
160
|
+
const hasDecisionSignals = bundle.labs.length > 0;
|
|
161
|
+
const href = "/replay-built/" + normalize(bundle.indexPath.slice(artifactRoot.length)).replace(/^[/\\]/, "").replaceAll("\\", "/");
|
|
162
|
+
const baseHref = href.replace(/\/index\.html$/, "");
|
|
163
|
+
return {
|
|
164
|
+
outDir,
|
|
165
|
+
indexPath: bundle.indexPath,
|
|
166
|
+
href,
|
|
167
|
+
primaryLabHref: primaryLab ? `${baseHref}/labs/${primaryLab.module.id}.html` : null,
|
|
168
|
+
labs: bundle.labs.length,
|
|
169
|
+
richLabs: richCount,
|
|
170
|
+
generated: Boolean(generate),
|
|
171
|
+
noReadyLabs: richCount === 0,
|
|
172
|
+
noDecisionSignals: !hasDecisionSignals,
|
|
173
|
+
message: richCount > 0
|
|
174
|
+
? "Lab ready."
|
|
175
|
+
: hasDecisionSignals
|
|
176
|
+
? "Replay Labs found decision signals, but not enough concrete code evidence to build a practice lab."
|
|
177
|
+
: "Replay could not find enough decision evidence in this session."
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readJson(req, callback) {
|
|
182
|
+
let body = "";
|
|
183
|
+
req.on("data", (chunk) => {
|
|
184
|
+
body += chunk;
|
|
185
|
+
if (body.length > 500000) req.destroy();
|
|
186
|
+
});
|
|
187
|
+
req.on("end", () => {
|
|
188
|
+
try {
|
|
189
|
+
callback(null, body ? JSON.parse(body) : {});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
callback(error);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function json(res, status, payload) {
|
|
197
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
198
|
+
res.end(JSON.stringify(payload));
|
|
199
|
+
}
|
package/src/storage.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function appDataDir({ platform = process.platform, env = process.env, homeDir = homedir() } = {}) {
|
|
6
|
+
if (env.REPLAY_HOME) return env.REPLAY_HOME;
|
|
7
|
+
if (platform === "darwin") return join(homeDir, "Library", "Application Support", "Replay Labs");
|
|
8
|
+
if (platform === "win32") return join(env.APPDATA || join(homeDir, "AppData", "Roaming"), "Replay Labs");
|
|
9
|
+
return join(env.XDG_DATA_HOME || join(homeDir, ".local", "share"), "replay-labs");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function appPaths(options = {}) {
|
|
13
|
+
const root = appDataDir(options);
|
|
14
|
+
return {
|
|
15
|
+
root,
|
|
16
|
+
indexDir: join(root, "index"),
|
|
17
|
+
labsDir: join(root, "labs"),
|
|
18
|
+
cacheDir: join(root, "cache"),
|
|
19
|
+
generatedModulesDir: join(root, "cache", "generated-modules"),
|
|
20
|
+
logsDir: join(root, "logs")
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function ensureAppDataDirs(options = {}) {
|
|
25
|
+
const paths = appPaths(options);
|
|
26
|
+
await Promise.all([
|
|
27
|
+
mkdir(paths.indexDir, { recursive: true }),
|
|
28
|
+
mkdir(paths.labsDir, { recursive: true }),
|
|
29
|
+
mkdir(paths.generatedModulesDir, { recursive: true }),
|
|
30
|
+
mkdir(paths.logsDir, { recursive: true })
|
|
31
|
+
]);
|
|
32
|
+
return paths;
|
|
33
|
+
}
|
|
34
|
+
|