@lingjingai/scriptctl 0.76.0 → 0.78.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,662 @@
1
+ // `scriptctl review-view` serves this: the step-by-step path of review runs, from what
2
+ // `scriptctl review` already left in a workspace (.review traces, failure snapshots, NDJSON logs).
3
+ // Read-only: it never calls a model and never writes to the workspace.
4
+ import { spawn } from "node:child_process";
5
+ import { createHash } from "node:crypto";
6
+ import * as fs from "node:fs";
7
+ import * as http from "node:http";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const PAGE = path.join(path.dirname(fileURLToPath(import.meta.url)), "review-view.html");
13
+ const SKIP_DIRS = new Set(["node_modules", ".git", "worktrees", "raw", "ep", "cache", "src", "evidence", "archive"]);
14
+ const VIDEO_TYPES = { ".mp4": "video/mp4", ".m4v": "video/mp4", ".mov": "video/quicktime", ".mkv": "video/x-matroska" };
15
+ const MAX_CACHED_RUNS = 3;
16
+ const SLOW_STEP_MS = 60_000;
17
+ const EVIDENCE_CHARS = 3000;
18
+
19
+ // ---- files ---------------------------------------------------------------------
20
+
21
+ const list = (dir) => { try { return fs.readdirSync(dir); } catch { return []; } };
22
+ const stat = (file) => fs.statSync(file, { throwIfNoEntry: false });
23
+ const mtime = (file) => stat(file)?.mtimeMs ?? null;
24
+ const isDir = (file) => stat(file)?.isDirectory() ?? false;
25
+
26
+ function readJson(file) {
27
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
28
+ }
29
+
30
+ /** A run still in progress can leave a partial last line. */
31
+ function readJsonl(file) {
32
+ const rows = [];
33
+ let text;
34
+ try { text = fs.readFileSync(file, "utf8"); } catch { return rows; }
35
+ for (const line of text.split("\n")) {
36
+ if (!line.startsWith("{")) continue;
37
+ try { rows.push(JSON.parse(line)); } catch { /* logs interleave diagnostics with NDJSON */ }
38
+ }
39
+ return rows;
40
+ }
41
+
42
+ // ---- workspaces ------------------------------------------------------------------
43
+
44
+ function reviewName(dir) {
45
+ const names = list(path.join(dir, ".review")).filter((name) =>
46
+ fs.existsSync(path.join(dir, ".review", name, "trace")) || fs.existsSync(path.join(dir, ".review", name, "global.json")));
47
+ return names.includes("script") ? "script" : names[0] ?? null;
48
+ }
49
+
50
+ function findWorkspaces(root, depth = 5, found = []) {
51
+ if (reviewName(root)) { found.push(root); return found; }
52
+ if (depth > 0) {
53
+ for (const name of list(root)) {
54
+ if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
55
+ const child = path.join(root, name);
56
+ if (isDir(child)) findWorkspaces(child, depth - 1, found);
57
+ }
58
+ }
59
+ return found;
60
+ }
61
+
62
+ const registry = new Map();
63
+ function register(dir) {
64
+ const absolute = path.resolve(dir.replace(/^~(?=\/|$)/, os.homedir()));
65
+ const workspace = reviewName(absolute) ? absolute : reviewName(path.join(absolute, "workspace")) ? path.join(absolute, "workspace") : null;
66
+ if (!workspace) return null;
67
+ const id = createHash("sha1").update(workspace).digest("hex").slice(0, 10);
68
+ registry.set(id, workspace);
69
+ return id;
70
+ }
71
+ function workspaceRows() {
72
+ const rows = [...registry].map(([id, dir]) => {
73
+ const folder = path.basename(dir) === "workspace" ? path.dirname(dir) : dir;
74
+ return { id, name: path.basename(folder), parent: path.basename(path.dirname(folder)), path: dir,
75
+ updated: mtime(path.join(dir, ".review", reviewName(dir), "global.json")) ?? mtime(path.join(dir, ".review")) };
76
+ }).sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0));
77
+ // Sibling experiments often share a folder name; the parent tells them apart in the picker.
78
+ const uses = new Map();
79
+ for (const row of rows) uses.set(row.name, (uses.get(row.name) ?? 0) + 1);
80
+ return rows.map(({ parent, ...row }) => (uses.get(row.name) > 1 ? { ...row, name: `${parent}/${row.name}` } : row));
81
+ }
82
+
83
+ // ---- sources ---------------------------------------------------------------------
84
+
85
+ function scriptIndex(dir, name) {
86
+ const names = new Map();
87
+ let title = null, episodes = 0;
88
+ for (const file of [`${name}.initial.json`, "script.initial.json", `${name}.json`]) {
89
+ const raw = readJson(path.join(dir, file));
90
+ if (!raw) continue;
91
+ title = raw.title ?? title;
92
+ episodes = raw.episodes?.length ?? episodes;
93
+ for (const asset of raw.actors ?? []) names.set(asset.actor_id, { kind: "人物", name: asset.actor_name });
94
+ for (const asset of raw.locations ?? []) names.set(asset.location_id, { kind: "地点", name: asset.location_name });
95
+ for (const asset of raw.props ?? []) names.set(asset.prop_id, { kind: "道具", name: asset.prop_name });
96
+ }
97
+ return { names, title, episodes };
98
+ }
99
+
100
+ function readVideos(dir) {
101
+ const videos = new Map();
102
+ for (const episode of readJson(path.join(dir, "manifest.json"))?.episodes ?? []) {
103
+ if (episode.path && VIDEO_TYPES[path.extname(episode.path).toLowerCase()]) videos.set(episode.episode, episode.path);
104
+ }
105
+ if (videos.size) return videos;
106
+ // Runs without a manifest took the raw folder beside the workspace, in name order.
107
+ const raw = path.join(dir, "..", "raw");
108
+ list(raw).filter((name) => VIDEO_TYPES[path.extname(name).toLowerCase()]).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
109
+ .forEach((name, index) => videos.set(index + 1, path.join(raw, name)));
110
+ return videos;
111
+ }
112
+
113
+ /** One trace file is one agent execution; its name carries the unit and the start time. */
114
+ function readAgentRuns(trace) {
115
+ return list(trace).flatMap((name) => {
116
+ const match = /^ep(\d+)(?::([^-]+))?-(\d+)\.jsonl$/.exec(name);
117
+ if (!match) return [];
118
+ const steps = readJsonl(path.join(trace, name));
119
+ const start = Number(match[3]);
120
+ const last = steps.at(-1)?.at;
121
+ return [{ file: name, episode: Number(match[1]), asset: match[2] ?? null, start, end: last ? Date.parse(last) : start, steps }];
122
+ }).sort((a, b) => a.start - b.start);
123
+ }
124
+
125
+ function readCalls(trace) {
126
+ return readJsonl(path.join(trace, "converge-calls.jsonl")).map((row) => {
127
+ const [, , type, ...target] = row.call.split(":");
128
+ const end = Date.parse(row.at);
129
+ return { ...row, type, target: target.join(":"), start: end - (row.ms ?? 0), end };
130
+ }).sort((a, b) => a.start - b.start);
131
+ }
132
+
133
+ function readFailures(dir) {
134
+ const folder = path.join(dir, "failures");
135
+ return list(folder).flatMap((file) => {
136
+ const raw = readJson(path.join(folder, file));
137
+ if (!raw) return [];
138
+ return [{ file, at: Number(file.split("-")[0]) || null, episode: raw.episode_number ?? 0, asset: raw.asset ?? null,
139
+ provider: raw.provider, model: raw.model, error: raw.error, chain: raw.error_chain ?? [],
140
+ messages: Array.isArray(raw.prompt) ? raw.prompt.length : 0, response: raw.responseBody ? String(raw.responseBody).slice(0, 2000) : null }];
141
+ }).sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
142
+ }
143
+
144
+ const keyOf = (event) => event.episode_number ? `ep${event.episode_number}`
145
+ : event.asset === "asset-escalation" ? "escalation" : event.asset ? `ep0:${event.asset}` : "identity";
146
+
147
+ /** Each log is one `scriptctl review` process; later processes re-emit reused units with empty usage. */
148
+ function readLogs(workspace) {
149
+ const parent = path.dirname(workspace);
150
+ const files = [
151
+ ...list(parent).filter((name) => /^review.*\.ndjson$/.test(name)).map((name) => path.join(parent, name)),
152
+ ...list(path.join(parent, "logs")).filter((name) => /review.*\.(ndjson|stdout\.log)$/.test(name)).map((name) => path.join(parent, "logs", name)),
153
+ ...list(workspace).filter((name) => /^review.*\.ndjson$/.test(name)).map((name) => path.join(workspace, name)),
154
+ ].sort((a, b) => mtime(a) - mtime(b));
155
+ const logs = [], results = [], seen = new Set();
156
+ for (const file of files) {
157
+ const events = readJsonl(file).filter((event) => typeof event.type === "string");
158
+ if (!events.length) continue;
159
+ const name = path.relative(parent, file);
160
+ const done = events.find((event) => event.type === "done");
161
+ logs.push({ file: name, updated: mtime(file), wall_ms: done?.wall_ms ?? null, statistics: done?.statistics ?? null, finished: Boolean(done),
162
+ units: events.filter((event) => event.type === "episode" || event.type === "global").length });
163
+ for (const event of events) {
164
+ if (event.type !== "episode" && event.type !== "global") continue;
165
+ const signature = [keyOf(event), event.wall_ms, event.steps, event.findings?.length].join("|");
166
+ if (seen.has(signature)) continue;
167
+ seen.add(signature);
168
+ results.push({ ...event, key: keyOf(event), log: name });
169
+ }
170
+ }
171
+ return { logs, results };
172
+ }
173
+
174
+ // ---- assembly --------------------------------------------------------------------
175
+
176
+ function agentWindow(usage) {
177
+ let first = Infinity, last = -Infinity;
178
+ for (const row of usage ?? []) {
179
+ if (!row.started_at || !row.call_key?.includes(":agent:")) continue;
180
+ const started = Date.parse(row.started_at);
181
+ first = Math.min(first, started);
182
+ last = Math.max(last, started + (row.ms ?? 0));
183
+ }
184
+ return first === Infinity ? null : [first, last];
185
+ }
186
+
187
+ const closest = (items, distance, limit) => {
188
+ let best = null, bestDistance = limit;
189
+ for (const item of items) {
190
+ const value = distance(item);
191
+ if (value < bestDistance) { best = item; bestDistance = value; }
192
+ }
193
+ return best;
194
+ };
195
+
196
+ /** `ep0-*.jsonl` is written by both the identity agent and the escalation batches. */
197
+ function splitEp0(runs, calls, assetRuns, results, batches) {
198
+ const windows = results.filter((result) => result.key === "escalation").map((result) => agentWindow(result.usage)).filter(Boolean);
199
+ const identityCalls = calls.filter((call) => ["handoff", "episode", "duplicates"].includes(call.type));
200
+ const assetStarts = [...calls.filter((call) => call.type === "asset"), ...assetRuns].map((item) => item.start);
201
+ const escalation = (run) => {
202
+ if (batches.some((batch) => Math.abs(Date.parse(batch.started_at) - run.start) < 5_000)) return true;
203
+ if (windows.some(([first, last]) => run.start >= first - 120_000 && run.start <= last + 5_000)) return true;
204
+ const followsIdentityCalls = identityCalls.some((call) => call.end <= run.start && run.start - call.end < 600_000);
205
+ return !followsIdentityCalls && assetStarts.some((start) => start < run.start);
206
+ };
207
+ return { identity: runs.filter((run) => !escalation(run)), escalation: runs.filter(escalation) };
208
+ }
209
+
210
+ function assignResult(pool, result, create, logTimes) {
211
+ const win = agentWindow(result.usage);
212
+ // A process cannot report work that began after its log stopped growing.
213
+ const finished = logTimes.get(result.log) ?? Infinity;
214
+ const open = pool.filter((unit) => !unit.result && (unit.runs[0]?.start ?? unit.calls[0]?.start ?? -Infinity) <= finished + 5_000);
215
+ let target = win ? closest(open.filter((unit) => unit.runs.length), (unit) => Math.abs(unit.runs[0].start - win[0]), 180_000) : null;
216
+ if (!target && !win) {
217
+ target = result.steps > 0 && !result.usage?.some((row) => row.call_key?.includes(":converge:"))
218
+ ? open.find((unit) => unit.runs.length)
219
+ : open.find((unit) => unit.calls.length) ?? (result.steps > 0 ? open.find((unit) => unit.runs.length) : null);
220
+ }
221
+ if (!target) {
222
+ target = create(result);
223
+ pool.push(target);
224
+ }
225
+ target.result = result;
226
+ if (win) {
227
+ // A batched unit reports once for several trace files.
228
+ for (const other of open) {
229
+ if (other === target || !other.runs.length || other.runs[0].start < win[0] - 120_000 || other.runs[0].start > win[1] + 5_000) continue;
230
+ target.runs.push(...other.runs);
231
+ target.calls.push(...other.calls);
232
+ other.merged = true;
233
+ }
234
+ }
235
+ }
236
+
237
+ function assemble(src) {
238
+ const pools = new Map();
239
+ const unit = (phase, key, fields = {}) => {
240
+ const created = { phase, key, runs: [], calls: [], result: null, ...fields };
241
+ if (!pools.has(key)) pools.set(key, []);
242
+ pools.get(key).push(created);
243
+ return created;
244
+ };
245
+ const episodeRuns = src.runs.filter((run) => run.episode > 0);
246
+ const assetRuns = src.runs.filter((run) => run.episode === 0 && run.asset);
247
+ const ep0 = splitEp0(src.runs.filter((run) => run.episode === 0 && !run.asset), src.calls, assetRuns, src.results, src.batches);
248
+
249
+ for (const run of episodeRuns) unit("episode", `ep${run.episode}`, { episode: run.episode, runs: [run] });
250
+ for (const run of ep0.escalation) unit("escalation", "escalation", { runs: [run] });
251
+
252
+ // A round of identity checks ends with the duplicate scan; its agent takes what is left.
253
+ let round = null;
254
+ const identityEvents = [...src.calls.filter((call) => call.type !== "asset").map((call) => ({ call, start: call.start })),
255
+ ...ep0.identity.map((run) => ({ run, start: run.start }))].sort((a, b) => a.start - b.start);
256
+ for (const { call, run } of identityEvents) {
257
+ const finished = round && (round.runs.length || round.calls.some((item) => item.type === "duplicates"));
258
+ if (call) {
259
+ if (!round || (finished && call.type !== "duplicates")) round = unit("identity", "identity");
260
+ round.calls.push(call);
261
+ } else {
262
+ if (!round || round.runs.length) round = unit("identity", "identity");
263
+ round.runs.push(run);
264
+ }
265
+ }
266
+
267
+ for (const call of src.calls.filter((item) => item.type === "asset")) {
268
+ unit("asset", `ep0:${call.target}`, { asset: call.target, calls: [call] });
269
+ }
270
+ for (const run of assetRuns) {
271
+ const key = `ep0:${run.asset}`;
272
+ const lead = (pools.get(key) ?? []).filter((item) => !item.runs.length && item.calls.at(-1)?.end <= run.start + 1_000
273
+ && run.start - item.calls.at(-1).end < 600_000).at(-1);
274
+ if (lead) lead.runs.push(run);
275
+ else unit("asset", key, { asset: run.asset, runs: [run] });
276
+ }
277
+
278
+ const resultOnly = (result) => ({
279
+ phase: result.key === "identity" ? "identity" : result.key === "escalation" ? "escalation" : result.episode_number ? "episode"
280
+ : result.usage?.some((row) => row.call_key?.includes(":appearance")) || isDir(path.join(src.reviewDir, "trace", "appearance", result.asset ?? "")) ? "deep" : "asset",
281
+ key: result.key, episode: result.episode_number || undefined, asset: result.asset, runs: [], calls: [], result: null,
282
+ });
283
+ const poolOf = (key) => { if (!pools.has(key)) pools.set(key, []); return pools.get(key); };
284
+ const logTimes = new Map(src.logs.map((log) => [log.file, log.updated]));
285
+ for (const result of src.results) assignResult(poolOf(result.key), result, resultOnly, logTimes);
286
+ for (const saved of src.globals) {
287
+ const key = keyOf(saved);
288
+ const pool = poolOf(key);
289
+ const same = pool.find((item) => item.result && item.result.wall_ms === saved.wall_ms && item.result.steps === saved.steps);
290
+ if (same) { same.result.review_basis ??= saved.review_basis; continue; }
291
+ const target = pool.filter((item) => !item.result && !item.merged).at(-1);
292
+ const result = { ...saved, key, log: "global.json" };
293
+ if (target) target.result = result;
294
+ else if (!pool.length) { const created = resultOnly(result); created.result = result; pool.push(created); }
295
+ }
296
+
297
+ const units = [...pools.values()].flat().filter((item) => !item.merged);
298
+ for (const item of units) place(item, src.reviewDir, logTimes);
299
+ units.sort((a, b) => (a.start ?? Infinity) - (b.start ?? Infinity));
300
+ const counts = new Map(), passes = new Map(), previous = new Map();
301
+ for (const item of units) {
302
+ const nth = (counts.get(item.key) ?? 0) + 1;
303
+ counts.set(item.key, nth);
304
+ item.nth = nth;
305
+ item.id = `${item.key}#${nth}`;
306
+ const ran = item.runs.length > 0 || item.calls.length > 0 || (item.result?.usage?.length ?? 0) > 0;
307
+ if (ran) passes.set(item.key, (passes.get(item.key) ?? 0) + 1);
308
+ item.pass = ran ? passes.get(item.key) : 0;
309
+ const before = previous.get(item.key);
310
+ // Running again after a failure is a retry; running again after success is a second review.
311
+ if (ran && before) item.rerun = before.result?.state !== "completed" ? "retry" : before.result?.log === item.result?.log ? "same-process" : "other-process";
312
+ if (ran) previous.set(item.key, item);
313
+ item.label ??= labelOf(item, item.pass, src.names);
314
+ }
315
+ for (const failure of src.failures) {
316
+ const key = failure.episode ? `ep${failure.episode}` : failure.asset ? `ep0:${failure.asset}` : null;
317
+ const candidates = units.filter((item) => key ? item.key === key : ["identity", "escalation"].includes(item.key));
318
+ const owner = closest(candidates.filter((item) => item.start != null), (item) =>
319
+ failure.at >= item.start - 30_000 && failure.at <= item.end + 30_000 ? 0 : Math.min(Math.abs(failure.at - item.start), Math.abs(failure.at - item.end)), 3_600_000);
320
+ failure.unit = owner?.id ?? null;
321
+ (owner?.failures ?? (owner ? (owner.failures = []) : [])).push(failure);
322
+ }
323
+ return units;
324
+ }
325
+
326
+ function place(item, reviewDir, logTimes) {
327
+ const times = [...item.runs, ...item.calls];
328
+ if (times.length) {
329
+ item.start = Math.min(...times.map((time) => time.start));
330
+ item.end = Math.max(...times.map((time) => time.end));
331
+ } else {
332
+ const win = agentWindow(item.result?.usage);
333
+ if (win) { item.start = win[0]; item.end = win[1]; }
334
+ else if (item.phase === "deep") {
335
+ const dir = path.join(reviewDir, "trace", "appearance", item.asset ?? "");
336
+ const stamps = list(dir).map((name) => mtime(path.join(dir, name))).filter(Boolean);
337
+ if (stamps.length) { item.end = Math.max(...stamps); item.start = item.end - (item.result?.wall_ms ?? 0); }
338
+ }
339
+ // Only the log's finish time is known; the page marks this position as an estimate.
340
+ if (item.start == null && logTimes.has(item.result?.log)) {
341
+ item.end = logTimes.get(item.result.log);
342
+ item.start = item.end - (item.result?.wall_ms ?? 0);
343
+ item.approximate = true;
344
+ }
345
+ }
346
+ item.start ??= null;
347
+ item.end ??= item.start;
348
+ }
349
+
350
+ function labelOf(item, pass, names) {
351
+ if (item.phase === "episode") return pass ? `第 ${item.episode} 集 · 第 ${pass} 次` : `第 ${item.episode} 集 · ${item.result?.message ?? "未执行"}`;
352
+ if (item.phase === "identity") return `身份核对 · 第 ${pass || 1} 轮`;
353
+ if (item.phase === "escalation") return `资产疑点复核 · 第 ${pass || 1} 次`;
354
+ const asset = names.get(item.asset);
355
+ return `${asset?.name ?? item.asset}${pass > 1 ? ` · 第 ${pass} 次` : ""}`;
356
+ }
357
+
358
+ function totals(item) {
359
+ const out = { model_calls: 0, input: 0, cached: 0, output: 0, reasoning: 0 };
360
+ const rows = item.result?.usage ?? [];
361
+ if (rows.length) {
362
+ out.model_calls = rows.length;
363
+ for (const row of rows) {
364
+ out.input += row.input_tokens ?? 0; out.cached += row.cached_input_tokens ?? 0;
365
+ out.output += row.output_tokens ?? 0; out.reasoning += row.reasoning_tokens ?? 0;
366
+ }
367
+ return out;
368
+ }
369
+ out.model_calls = item.calls.reduce((sum, call) => sum + (call.attempts ?? 1), 0);
370
+ for (const run of item.runs) for (const step of run.steps) {
371
+ out.model_calls += 1;
372
+ out.input += step.usage?.inputTokens ?? 0; out.cached += step.usage?.cachedInputTokens ?? 0;
373
+ out.output += step.usage?.outputTokens ?? 0; out.reasoning += step.usage?.reasoningTokens ?? 0;
374
+ }
375
+ return out;
376
+ }
377
+
378
+ function outcomeCounts(findings = []) {
379
+ const counts = { fixed: 0, unresolved: 0, unchanged: 0 };
380
+ for (const finding of findings) if (finding.outcome in counts) counts[finding.outcome] += 1;
381
+ return counts;
382
+ }
383
+
384
+ function verdictOf(call) {
385
+ const value = call.value;
386
+ if (!value) return call.error ? "error" : null;
387
+ return value.verdict ?? value.decision ?? (Array.isArray(value.issues) ? `${value.issues.length} 个问题` : null)
388
+ ?? (value.pairs || value.not_objects ? `${(value.pairs?.length ?? 0) + (value.not_objects?.length ?? 0)} 个候选` : null);
389
+ }
390
+
391
+ const assetKind = (item) => ({ act: "人物", loc: "地点", prp: "道具" })[item.asset?.slice(0, 3)] ?? null;
392
+
393
+ function summarize(item) {
394
+ const steps = item.runs.reduce((sum, run) => sum + run.steps.length, 0) || (item.result?.steps ?? 0);
395
+ const media = item.result?.media_calls ?? 0;
396
+ const state = item.state ?? item.result?.state ?? (item.runs.length || item.calls.length ? "unreported" : "unknown");
397
+ return {
398
+ id: item.id, key: item.key, phase: item.phase, label: item.label, episode: item.episode ?? null, asset: item.asset ?? null, pass: item.pass ?? 0,
399
+ kind: item.asset ? assetKind(item) : null, rerun: item.rerun ?? null, approximate: Boolean(item.approximate), traced: item.runs.length > 0,
400
+ start: item.start, end: item.end, wall_ms: item.result?.wall_ms ?? (item.start != null ? item.end - item.start : null),
401
+ state, failure_kind: item.result?.failure_kind ?? null, log: item.result?.log ?? null,
402
+ reused: Boolean(item.result && !item.result.usage?.length && !item.runs.length && !item.calls.length && item.result.findings?.length),
403
+ steps, media_calls: media, ...totals(item), outcomes: outcomeCounts(item.result?.findings),
404
+ failures: item.failures?.length ?? 0,
405
+ verdict: item.calls.length === 1 ? verdictOf(item.calls[0]) : null,
406
+ segments: [
407
+ ...item.calls.map((call) => ({ kind: "call", start: call.start, end: call.end })),
408
+ ...item.runs.map((run) => ({ kind: "agent", start: run.start, end: run.end })),
409
+ ],
410
+ };
411
+ }
412
+
413
+ function loadRun(dir) {
414
+ const name = reviewName(dir);
415
+ const reviewDir = path.join(dir, ".review", name);
416
+ const trace = path.join(reviewDir, "trace");
417
+ const { logs, results } = readLogs(dir);
418
+ const { names, title, episodes } = scriptIndex(dir, name);
419
+ const src = {
420
+ reviewDir, names, logs, results, runs: readAgentRuns(trace), calls: readCalls(trace),
421
+ globals: readJson(path.join(reviewDir, "global.json"))?.units ?? [],
422
+ failures: readFailures(reviewDir), batches: readJsonl(path.join(trace, "asset-escalation-batches.jsonl")),
423
+ };
424
+ const units = assemble(src);
425
+ return { dir, name, reviewDir, title, episodes, logs, units, failures: src.failures, videos: readVideos(dir),
426
+ byId: new Map(units.map((item) => [item.id, item])) };
427
+ }
428
+
429
+ const cache = new Map();
430
+ function signature(dir) {
431
+ const name = reviewName(dir);
432
+ const trace = path.join(dir, ".review", name, "trace");
433
+ const stamps = [mtime(path.join(dir, ".review", name, "global.json")), ...list(trace).map((file) => {
434
+ const info = stat(path.join(trace, file));
435
+ return `${file}:${info?.size}`;
436
+ })];
437
+ return stamps.join("|");
438
+ }
439
+ function runFor(id) {
440
+ const dir = registry.get(id);
441
+ if (!dir) return null;
442
+ const current = signature(dir);
443
+ const cached = cache.get(id);
444
+ if (cached?.signature === current) return cached.run;
445
+ const run = loadRun(dir);
446
+ cache.delete(id);
447
+ cache.set(id, { signature: current, run });
448
+ if (cache.size > MAX_CACHED_RUNS) cache.delete(cache.keys().next().value);
449
+ return run;
450
+ }
451
+
452
+ // ---- detail payloads --------------------------------------------------------------
453
+
454
+ const quiet = new Set(["validate", "issues"]);
455
+
456
+ /** Results may come back in another order when a step ran tools in parallel. */
457
+ function pairCalls(step) {
458
+ const left = [...(step.results ?? [])];
459
+ return (step.calls ?? []).map((call) => {
460
+ const at = left.findIndex((result) => result.tool === call.tool);
461
+ const result = at >= 0 ? left.splice(at, 1)[0] : null;
462
+ return { tool: call.tool, args: call.args, result: result?.result, error: Boolean(result?.error) };
463
+ });
464
+ }
465
+
466
+ function stepPayload(run, step, seen, modelRow) {
467
+ const calls = pairCalls(step).map((call) => {
468
+ const signed = JSON.stringify([call.tool, call.args]);
469
+ const dup = !quiet.has(call.tool) && seen.has(signed);
470
+ seen.add(signed);
471
+ const text = typeof call.result === "string" ? call.result : JSON.stringify(call.result ?? "");
472
+ const failed = call.error || (call.result && typeof call.result === "object" && "error" in call.result && call.result.error) || /^(error|错误|失败)/i.test(text);
473
+ return { ...call, dup, failed: Boolean(failed), budget: /预算已?用完|额度已?用尽/.test(text) };
474
+ });
475
+ const end = Date.parse(step.at);
476
+ return {
477
+ step: step.step, start: end - (step.step_ms ?? 0), end, step_ms: step.step_ms, finish: step.finish,
478
+ model_ms: modelRow?.ms ?? null, route: modelRow ? `${modelRow.provider}/${modelRow.model}` : null,
479
+ input: step.usage?.inputTokens ?? 0, cached: step.usage?.cachedInputTokens ?? 0, output: step.usage?.outputTokens ?? 0, reasoning_tokens: step.usage?.reasoningTokens ?? 0,
480
+ text: step.text ?? null, reasoning: step.reasoning ?? null, calls, slow: (step.step_ms ?? 0) >= SLOW_STEP_MS,
481
+ };
482
+ }
483
+
484
+ function runPayload(run, usage) {
485
+ const rows = (usage ?? []).filter((row) => row.started_at && row.call_key?.includes(":agent:"))
486
+ .filter((row) => { const at = Date.parse(row.started_at); return at >= run.start - 5_000 && at <= run.end + 5_000; });
487
+ const aligned = rows.length === run.steps.length;
488
+ const seen = new Set();
489
+ return { file: run.file, start: run.start, end: run.end,
490
+ steps: run.steps.map((step, index) => stepPayload(run, step, seen, aligned ? rows[index] : null)) };
491
+ }
492
+
493
+ function usageStages(rows = []) {
494
+ const stages = new Map();
495
+ for (const row of rows) {
496
+ const key = (row.call_key ?? "").replace(/:(msuicode|nnyyai|tuzi|ark|packy|litellm|doubao)[^:]*:.*$/, "").replace(/\b(act|loc|prp)[A-Za-z0-9]{4,}\b/g, "$1").replace(/\d+/g, "#");
497
+ const stage = stages.get(key) ?? { key, calls: 0, input: 0, cached: 0, output: 0, reasoning: 0, ms: 0 };
498
+ stage.calls += 1; stage.input += row.input_tokens ?? 0; stage.cached += row.cached_input_tokens ?? 0;
499
+ stage.output += row.output_tokens ?? 0; stage.reasoning += row.reasoning_tokens ?? 0; stage.ms += row.ms ?? 0;
500
+ stages.set(key, stage);
501
+ }
502
+ return [...stages.values()].sort((a, b) => b.input + b.output - a.input - a.output);
503
+ }
504
+
505
+ /** Source audits embed whole frame-by-frame reports as evidence; the page only needs the start of each. */
506
+ function clipFinding(finding) {
507
+ const clip = (text) => typeof text === "string" && text.length > EVIDENCE_CHARS ? `${text.slice(0, EVIDENCE_CHARS)}…<共 ${text.length} 字符>` : text;
508
+ return { ...finding, evidence: finding.evidence?.map((item) => typeof item === "string" ? clip(item) : { ...item, detail: clip(item.detail) }) };
509
+ }
510
+
511
+ function unitPayload(run, item) {
512
+ return {
513
+ ...summarize(item),
514
+ message: item.result?.message ?? null, error: item.result?.error ?? null, retry: item.result?.retry ?? null,
515
+ tool_calls: item.result?.tool_calls ?? null,
516
+ handoffs: (item.result?.findings ?? []).filter((finding) => finding.evidence?.some((evidence) => evidence.label === "逐集上报")).length,
517
+ reviewBasis: item.result?.review_basis ? { handoffs: item.result.review_basis.handoffs?.length ?? 0 } : null,
518
+ calls: item.calls.map((call) => ({ ...call, verdict: verdictOf(call) })),
519
+ runs: item.runs.map((agentRun) => runPayload(agentRun, item.result?.usage)),
520
+ findings: (item.result?.findings ?? []).map(clipFinding),
521
+ usage: usageStages(item.result?.usage),
522
+ failureRows: item.failures ?? [],
523
+ files: item.phase === "deep" ? list(path.join(run.reviewDir, "trace", "appearance", item.asset ?? "")).map((file) =>
524
+ path.posix.join(run.name, "trace", "appearance", item.asset, file)) : [],
525
+ hasVideo: item.episode ? run.videos.has(item.episode) : run.videos.size > 0,
526
+ };
527
+ }
528
+
529
+ function overviewPayload(id, run) {
530
+ const units = run.units.map(summarize);
531
+ return { id, path: run.dir, title: run.title, episodes: run.episodes, logs: run.logs, units,
532
+ videos: [...run.videos.keys()], failures: run.failures.length, unassigned: run.failures.filter((failure) => !failure.unit) };
533
+ }
534
+
535
+ function failurePrompt(run, file) {
536
+ if (path.basename(file) !== file) return null;
537
+ const raw = readJson(path.join(run.reviewDir, "failures", file));
538
+ if (!raw) return null;
539
+ const clip = (text) => text.length > 40_000 ? `${text.slice(0, 40_000)}\n…<共 ${text.length} 字符>` : text;
540
+ const parts = (content) => typeof content === "string" ? [{ type: "text", text: clip(content) }]
541
+ : (content ?? []).map((part) => {
542
+ if (part.type === "text" || part.type === "reasoning") return { type: part.type, text: clip(part.text ?? "") };
543
+ if (part.type === "tool-call") return { type: part.type, text: clip(`${part.toolName} ${JSON.stringify(part.input ?? part.args ?? {})}`) };
544
+ if (part.type === "tool-result") {
545
+ const output = JSON.stringify(part.output ?? part.result ?? "", (key, value) => key === "data" && typeof value === "string" && value.length > 200 ? `<${value.length} chars>` : value);
546
+ return { type: part.type, text: clip(`${part.toolName} ${output}`) };
547
+ }
548
+ return { type: part.type ?? "other", text: `[${part.type ?? "other"}${part.mediaType ? ` ${part.mediaType}` : ""}]` };
549
+ });
550
+ return { error: raw.error, chain: raw.error_chain, provider: raw.provider, model: raw.model, response: raw.responseBody ?? null,
551
+ messages: (Array.isArray(raw.prompt) ? raw.prompt : []).map((message) => ({ role: message.role, parts: parts(message.content) })) };
552
+ }
553
+
554
+ // ---- media -------------------------------------------------------------------------
555
+
556
+ function seconds(at) {
557
+ if (/^\d+(\.\d+)?$/.test(at)) return Number(at);
558
+ const match = /^(?:(\d+):)?(\d{1,3}):(\d{2}(?:\.\d+)?)$/.exec(at);
559
+ return match ? Number(match[1] ?? 0) * 3600 + Number(match[2]) * 60 + Number(match[3]) : null;
560
+ }
561
+
562
+ function serveFrame(res, video, at) {
563
+ const ffmpeg = spawn("ffmpeg", ["-nostdin", "-loglevel", "error", "-ss", String(at), "-i", video, "-frames:v", "1",
564
+ "-vf", "scale=720:-2", "-q:v", "4", "-f", "image2pipe", "-vcodec", "mjpeg", "-"],
565
+ { env: { ...process.env, PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin` }, stdio: ["ignore", "pipe", "ignore"] });
566
+ const chunks = [];
567
+ ffmpeg.stdout.on("data", (chunk) => chunks.push(chunk));
568
+ ffmpeg.on("error", () => { res.writeHead(500); res.end("ffmpeg unavailable"); });
569
+ ffmpeg.on("close", () => {
570
+ if (res.writableEnded) return;
571
+ const image = Buffer.concat(chunks);
572
+ if (!image.length) { res.writeHead(404); res.end("no frame"); return; }
573
+ res.writeHead(200, { "content-type": "image/jpeg", "cache-control": "max-age=3600" });
574
+ res.end(image);
575
+ });
576
+ }
577
+
578
+ function serveVideo(req, res, file) {
579
+ const info = stat(file);
580
+ if (!info) { res.writeHead(404); res.end("video missing"); return; }
581
+ const type = VIDEO_TYPES[path.extname(file).toLowerCase()];
582
+ const range = /bytes=(\d*)-(\d*)/.exec(req.headers.range ?? "");
583
+ if (!range) {
584
+ res.writeHead(200, { "content-type": type, "content-length": info.size, "accept-ranges": "bytes" });
585
+ fs.createReadStream(file).pipe(res);
586
+ return;
587
+ }
588
+ const start = range[1] ? Number(range[1]) : Math.max(0, info.size - Number(range[2]));
589
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), info.size - 1) : info.size - 1;
590
+ if (start > end) { res.writeHead(416, { "content-range": `bytes */${info.size}` }); res.end(); return; }
591
+ res.writeHead(206, { "content-type": type, "content-length": end - start + 1, "accept-ranges": "bytes", "content-range": `bytes ${start}-${end}/${info.size}` });
592
+ fs.createReadStream(file, { start, end }).pipe(res);
593
+ }
594
+
595
+ // ---- server ------------------------------------------------------------------------
596
+
597
+ const json = (res, value, status = 200) => {
598
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
599
+ res.end(JSON.stringify(value));
600
+ };
601
+
602
+ function handle(req, res, options) {
603
+ const url = new URL(req.url, "http://localhost");
604
+ const query = url.searchParams;
605
+ if (url.pathname === "/") {
606
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
607
+ res.end(fs.readFileSync(PAGE));
608
+ return;
609
+ }
610
+ if (url.pathname === "/api/workspaces") return json(res, workspaceRows());
611
+ if (url.pathname === "/api/open") {
612
+ if (!options.allowOpen) return json(res, { error: "只有监听本机地址时才能按路径打开" }, 403);
613
+ const id = register(query.get("path") ?? "");
614
+ return id ? json(res, { id }) : json(res, { error: "这个目录下没有 .review 审核记录" }, 404);
615
+ }
616
+ const run = runFor(query.get("w"));
617
+ if (!run) return json(res, { error: "workspace 不存在" }, 404);
618
+ if (url.pathname === "/api/run") return json(res, overviewPayload(query.get("w"), run));
619
+ if (url.pathname === "/api/unit") {
620
+ const item = run.byId.get(query.get("id"));
621
+ return item ? json(res, unitPayload(run, item)) : json(res, { error: "单元不存在" }, 404);
622
+ }
623
+ if (url.pathname === "/api/failure") {
624
+ const prompt = failurePrompt(run, query.get("f") ?? "");
625
+ return prompt ? json(res, prompt) : json(res, { error: "失败快照不存在" }, 404);
626
+ }
627
+ if (url.pathname === "/api/file") {
628
+ const root = path.join(run.dir, ".review");
629
+ const file = path.resolve(root, query.get("p") ?? "");
630
+ if (!file.startsWith(root + path.sep) || !stat(file)?.isFile()) return json(res, { error: "文件不存在" }, 404);
631
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
632
+ fs.createReadStream(file).pipe(res);
633
+ return;
634
+ }
635
+ const video = run.videos.get(Number(query.get("ep")));
636
+ if (url.pathname === "/api/frame") {
637
+ const at = seconds(query.get("at") ?? "");
638
+ if (!video || at === null) return json(res, { error: "没有对应原片或时间" }, 404);
639
+ return serveFrame(res, video, at);
640
+ }
641
+ if (url.pathname === "/api/video") {
642
+ if (!video) return json(res, { error: "没有对应原片" }, 404);
643
+ return serveVideo(req, res, video);
644
+ }
645
+ return json(res, { error: "not found" }, 404);
646
+ }
647
+
648
+ /** Find every run under the given paths and build the server; the caller decides where it listens. */
649
+ export function createReviewViewServer({ paths, allowOpen = true }) {
650
+ for (const given of paths) {
651
+ const root = path.resolve(given.replace(/^~(?=\/|$)/, os.homedir()));
652
+ for (const dir of register(root) ? [] : findWorkspaces(root)) register(dir);
653
+ }
654
+ const server = http.createServer((req, res) => {
655
+ try { handle(req, res, { allowOpen }); } catch (error) {
656
+ console.error(error);
657
+ if (!res.headersSent) json(res, { error: error instanceof Error ? error.message : String(error) }, 500);
658
+ else res.end();
659
+ }
660
+ });
661
+ return { server, workspaces: workspaceRows() };
662
+ }