@lingjingai/scriptctl 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/dist/bin.d.ts +2 -0
- package/dist/bin.js +12 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +433 -0
- package/dist/cli.js.map +1 -0
- package/dist/common.d.ts +124 -0
- package/dist/common.js +337 -0
- package/dist/common.js.map +1 -0
- package/dist/domain/direct-core.d.ts +114 -0
- package/dist/domain/direct-core.js +3040 -0
- package/dist/domain/direct-core.js.map +1 -0
- package/dist/domain/script-core.d.ts +18 -0
- package/dist/domain/script-core.js +1886 -0
- package/dist/domain/script-core.js.map +1 -0
- package/dist/help-text.d.ts +2 -0
- package/dist/help-text.js +399 -0
- package/dist/help-text.js.map +1 -0
- package/dist/infra/converters.d.ts +10 -0
- package/dist/infra/converters.js +560 -0
- package/dist/infra/converters.js.map +1 -0
- package/dist/infra/env.d.ts +2 -0
- package/dist/infra/env.js +81 -0
- package/dist/infra/env.js.map +1 -0
- package/dist/infra/providers.d.ts +25 -0
- package/dist/infra/providers.js +330 -0
- package/dist/infra/providers.js.map +1 -0
- package/dist/infra/script-output-api.d.ts +44 -0
- package/dist/infra/script-output-api.js +160 -0
- package/dist/infra/script-output-api.js.map +1 -0
- package/dist/infra/storage.d.ts +35 -0
- package/dist/infra/storage.js +91 -0
- package/dist/infra/storage.js.map +1 -0
- package/dist/output.d.ts +4 -0
- package/dist/output.js +55 -0
- package/dist/output.js.map +1 -0
- package/dist/usecases/direct.d.ts +13 -0
- package/dist/usecases/direct.js +1679 -0
- package/dist/usecases/direct.js.map +1 -0
- package/dist/usecases/doctor.d.ts +2 -0
- package/dist/usecases/doctor.js +75 -0
- package/dist/usecases/doctor.js.map +1 -0
- package/dist/usecases/script.d.ts +32 -0
- package/dist/usecases/script.js +949 -0
- package/dist/usecases/script.js.map +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,1679 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_CONCURRENCY, DEFAULT_MODEL, DEFAULT_PROVIDER, DIRECT_CONTRACT_VERSION, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, REVIEW_TARGETS, SUPPORTED_EXTS, deletePath, deleteTree, directDir, exists, fmtId, readJson, readText, sha256Text, writeJson, } from "../common.js";
|
|
4
|
+
import { compactBatchResult, compactEpisodeResult, buildBatchPlan, buildEpisodePlan, enrichEpisodePlanTitles, extractBatchWithRecovery, mergeEpisodeResults, normalizeEpisodeResult, normalizeInt, recoverBatchFromSource, uniqueAdd, validateBatchExtractionQuality, validateEpisodeExtractionQuality, _md_push_asset, curateScriptAssets, applyMetadataToScript, } from "../domain/direct-core.js";
|
|
5
|
+
import { validateScript } from "../domain/script-core.js";
|
|
6
|
+
import { makeProvider } from "../infra/providers.js";
|
|
7
|
+
import { makeSourceManifest, prepareSource, } from "../infra/converters.js";
|
|
8
|
+
function strOf(v) {
|
|
9
|
+
if (v === null || v === undefined)
|
|
10
|
+
return "";
|
|
11
|
+
return String(v);
|
|
12
|
+
}
|
|
13
|
+
function isDict(v) {
|
|
14
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
15
|
+
}
|
|
16
|
+
function isList(v) {
|
|
17
|
+
return Array.isArray(v);
|
|
18
|
+
}
|
|
19
|
+
function asList(v) {
|
|
20
|
+
return Array.isArray(v) ? v : [];
|
|
21
|
+
}
|
|
22
|
+
function checkpointTimestamp() {
|
|
23
|
+
const now = new Date();
|
|
24
|
+
return now.toISOString().replace(/\.\d+Z$/, "Z");
|
|
25
|
+
}
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// run_state.json
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
export function updateRunState(workspace, updates) {
|
|
30
|
+
const p = path.join(directDir(workspace), "run_state.json");
|
|
31
|
+
let state = {};
|
|
32
|
+
if (exists(p)) {
|
|
33
|
+
try {
|
|
34
|
+
state = readJson(p);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
state = {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
Object.assign(state, updates);
|
|
41
|
+
state["updated_at"] = checkpointTimestamp();
|
|
42
|
+
writeJson(p, state);
|
|
43
|
+
return state;
|
|
44
|
+
}
|
|
45
|
+
export function readRunState(workspace) {
|
|
46
|
+
const p = path.join(directDir(workspace), "run_state.json");
|
|
47
|
+
if (!exists(p))
|
|
48
|
+
return {};
|
|
49
|
+
try {
|
|
50
|
+
const state = readJson(p);
|
|
51
|
+
return isDict(state) ? state : {};
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function failureSignature(items) {
|
|
58
|
+
if (!isList(items))
|
|
59
|
+
return [];
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const item of items) {
|
|
62
|
+
const s = strOf(item).trim();
|
|
63
|
+
if (s)
|
|
64
|
+
out.push(s);
|
|
65
|
+
}
|
|
66
|
+
out.sort();
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
export function addInspectedTarget(workspace, target) {
|
|
70
|
+
const state = readRunState(workspace);
|
|
71
|
+
const targets = [];
|
|
72
|
+
for (const item of asList(state["inspected_targets"])) {
|
|
73
|
+
const s = strOf(item);
|
|
74
|
+
if (s)
|
|
75
|
+
targets.push(s);
|
|
76
|
+
}
|
|
77
|
+
if (!targets.includes(target))
|
|
78
|
+
targets.push(target);
|
|
79
|
+
const missing = [];
|
|
80
|
+
for (const t of REVIEW_TARGETS) {
|
|
81
|
+
if (!targets.includes(t))
|
|
82
|
+
missing.push(t);
|
|
83
|
+
}
|
|
84
|
+
const reviewStatus = missing.length === 0 ? "reviewed" : "in_progress";
|
|
85
|
+
return updateRunState(workspace, {
|
|
86
|
+
inspected_targets: targets,
|
|
87
|
+
review_status: reviewStatus,
|
|
88
|
+
review_missing: missing,
|
|
89
|
+
last_inspect_target: target,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
export function markPatched(workspace, count) {
|
|
93
|
+
const state = readRunState(workspace);
|
|
94
|
+
const patchCount = Number(state["patch_count"] ?? 0) + count;
|
|
95
|
+
return updateRunState(workspace, {
|
|
96
|
+
patch_count: patchCount,
|
|
97
|
+
review_status: "patched",
|
|
98
|
+
review_missing: [],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
export function markMetadataConfidenceReviewed(workspace, operations) {
|
|
102
|
+
const metadataOps = new Set(["set_worldview", "set_actor_role_type", "set_asset_description"]);
|
|
103
|
+
if (!operations.some((op) => isDict(op) && metadataOps.has(strOf(op["op"]))))
|
|
104
|
+
return;
|
|
105
|
+
const p = path.join(directDir(workspace), "asset_metadata.json");
|
|
106
|
+
if (!exists(p))
|
|
107
|
+
return;
|
|
108
|
+
let metadata;
|
|
109
|
+
try {
|
|
110
|
+
metadata = readJson(p);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!isDict(metadata) || metadata["confidence"] !== "low")
|
|
116
|
+
return;
|
|
117
|
+
metadata["confidence"] = "medium";
|
|
118
|
+
metadata["confidence_reviewed_at"] = checkpointTimestamp();
|
|
119
|
+
writeJson(p, metadata);
|
|
120
|
+
}
|
|
121
|
+
export function reviewBlockers(state) {
|
|
122
|
+
if (Number(state["patch_count"] ?? 0) > 0)
|
|
123
|
+
return [];
|
|
124
|
+
const inspected = new Set();
|
|
125
|
+
for (const item of asList(state["inspected_targets"])) {
|
|
126
|
+
const s = strOf(item);
|
|
127
|
+
if (s)
|
|
128
|
+
inspected.add(s);
|
|
129
|
+
}
|
|
130
|
+
return REVIEW_TARGETS.filter((t) => !inspected.has(t));
|
|
131
|
+
}
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Paths for episode/batch results
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
function pad3(n) {
|
|
136
|
+
return String(n).padStart(3, "0");
|
|
137
|
+
}
|
|
138
|
+
function pad4(n) {
|
|
139
|
+
return String(n).padStart(4, "0");
|
|
140
|
+
}
|
|
141
|
+
function episodeResultPath(dir, ep) {
|
|
142
|
+
return path.join(dir, `ep_${pad3(Number(ep["episode"]))}.json`);
|
|
143
|
+
}
|
|
144
|
+
function episodeErrorPath(dir, ep) {
|
|
145
|
+
return path.join(dir, `ep_${pad3(Number(ep["episode"]))}.error.json`);
|
|
146
|
+
}
|
|
147
|
+
function episodeResultKey(ep) {
|
|
148
|
+
return `ep_${pad3(Number(ep["episode"]))}`;
|
|
149
|
+
}
|
|
150
|
+
function episodeResultsIndexPath(dir) {
|
|
151
|
+
return path.join(dir, "index.json");
|
|
152
|
+
}
|
|
153
|
+
function batchResultKey(batch) {
|
|
154
|
+
const bid = strOf(batch["batch_id"]).trim();
|
|
155
|
+
if (bid)
|
|
156
|
+
return bid;
|
|
157
|
+
return `bat_${pad4(Number(batch["batch_index"] ?? 0))}`;
|
|
158
|
+
}
|
|
159
|
+
function batchResultPath(dir, batch) {
|
|
160
|
+
return path.join(dir, `${batchResultKey(batch)}.json`);
|
|
161
|
+
}
|
|
162
|
+
function batchMarkdownPath(dir, batch) {
|
|
163
|
+
return path.join(dir, `${batchResultKey(batch)}.md`);
|
|
164
|
+
}
|
|
165
|
+
function batchErrorPath(dir, batch) {
|
|
166
|
+
return path.join(dir, `${batchResultKey(batch)}.error.json`);
|
|
167
|
+
}
|
|
168
|
+
function batchResultsIndexPath(dir) {
|
|
169
|
+
return path.join(dir, "index.json");
|
|
170
|
+
}
|
|
171
|
+
function persistBatchResult(dir, batch, result) {
|
|
172
|
+
const rawMd = result["_raw_markdown"];
|
|
173
|
+
delete result["_raw_markdown"];
|
|
174
|
+
writeJson(batchResultPath(dir, batch), compactBatchResult(result));
|
|
175
|
+
const mdPath = batchMarkdownPath(dir, batch);
|
|
176
|
+
if (typeof rawMd === "string" && rawMd) {
|
|
177
|
+
fs.mkdirSync(path.dirname(mdPath), { recursive: true });
|
|
178
|
+
fs.writeFileSync(mdPath, rawMd, "utf-8");
|
|
179
|
+
}
|
|
180
|
+
else if (exists(mdPath)) {
|
|
181
|
+
deletePath(mdPath);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function readBatchResultsIndex(dir) {
|
|
185
|
+
const p = batchResultsIndexPath(dir);
|
|
186
|
+
if (!exists(p))
|
|
187
|
+
return { version: 1, batches: {} };
|
|
188
|
+
let data;
|
|
189
|
+
try {
|
|
190
|
+
data = readJson(p);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return { version: 1, batches: {} };
|
|
194
|
+
}
|
|
195
|
+
if (!isDict(data))
|
|
196
|
+
return { version: 1, batches: {} };
|
|
197
|
+
if (!isDict(data["batches"]))
|
|
198
|
+
data["batches"] = {};
|
|
199
|
+
if (!("version" in data))
|
|
200
|
+
data["version"] = 1;
|
|
201
|
+
return data;
|
|
202
|
+
}
|
|
203
|
+
function writeBatchResultsIndex(dir, index) {
|
|
204
|
+
writeJson(batchResultsIndexPath(dir), index);
|
|
205
|
+
}
|
|
206
|
+
function updateBatchResultMetadata(dir, batch, providerName, model) {
|
|
207
|
+
const index = readBatchResultsIndex(dir);
|
|
208
|
+
const batches = index["batches"] ?? {};
|
|
209
|
+
batches[batchResultKey(batch)] = {
|
|
210
|
+
episode: Number(batch["episode"]),
|
|
211
|
+
part: Number(batch["part"]),
|
|
212
|
+
provider: providerName,
|
|
213
|
+
model,
|
|
214
|
+
extracted_at: checkpointTimestamp(),
|
|
215
|
+
};
|
|
216
|
+
index["batches"] = batches;
|
|
217
|
+
writeBatchResultsIndex(dir, index);
|
|
218
|
+
}
|
|
219
|
+
function removeBatchResultMetadata(dir, batch) {
|
|
220
|
+
const index = readBatchResultsIndex(dir);
|
|
221
|
+
const batches = index["batches"] ?? {};
|
|
222
|
+
const key = batchResultKey(batch);
|
|
223
|
+
if (key in batches) {
|
|
224
|
+
delete batches[key];
|
|
225
|
+
index["batches"] = batches;
|
|
226
|
+
writeBatchResultsIndex(dir, index);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function readEpisodeResultsIndex(dir) {
|
|
230
|
+
const p = episodeResultsIndexPath(dir);
|
|
231
|
+
if (!exists(p))
|
|
232
|
+
return { version: 1, episodes: {} };
|
|
233
|
+
let data;
|
|
234
|
+
try {
|
|
235
|
+
data = readJson(p);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return { version: 1, episodes: {} };
|
|
239
|
+
}
|
|
240
|
+
if (!isDict(data))
|
|
241
|
+
return { version: 1, episodes: {} };
|
|
242
|
+
if (!isDict(data["episodes"]))
|
|
243
|
+
data["episodes"] = {};
|
|
244
|
+
if (!("version" in data))
|
|
245
|
+
data["version"] = 1;
|
|
246
|
+
return data;
|
|
247
|
+
}
|
|
248
|
+
function writeEpisodeResultsIndex(dir, index) {
|
|
249
|
+
writeJson(episodeResultsIndexPath(dir), index);
|
|
250
|
+
}
|
|
251
|
+
function updateEpisodeResultMetadata(dir, ep, providerName, model) {
|
|
252
|
+
const index = readEpisodeResultsIndex(dir);
|
|
253
|
+
const episodes = index["episodes"] ?? {};
|
|
254
|
+
episodes[episodeResultKey(ep)] = {
|
|
255
|
+
provider: providerName,
|
|
256
|
+
model,
|
|
257
|
+
extracted_at: checkpointTimestamp(),
|
|
258
|
+
};
|
|
259
|
+
index["episodes"] = episodes;
|
|
260
|
+
writeEpisodeResultsIndex(dir, index);
|
|
261
|
+
}
|
|
262
|
+
function removeEpisodeResultMetadata(dir, ep) {
|
|
263
|
+
const index = readEpisodeResultsIndex(dir);
|
|
264
|
+
const episodes = index["episodes"] ?? {};
|
|
265
|
+
const key = episodeResultKey(ep);
|
|
266
|
+
if (key in episodes) {
|
|
267
|
+
delete episodes[key];
|
|
268
|
+
index["episodes"] = episodes;
|
|
269
|
+
writeEpisodeResultsIndex(dir, index);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function compactResultHasMultiRefs(data) {
|
|
273
|
+
for (const scene of asList(data["sc"])) {
|
|
274
|
+
if (!isDict(scene))
|
|
275
|
+
continue;
|
|
276
|
+
for (const action of asList(scene["a"])) {
|
|
277
|
+
if (!isDict(action))
|
|
278
|
+
continue;
|
|
279
|
+
const refs = action["r"];
|
|
280
|
+
if (isList(refs) && refs.length > 1)
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
function initCheckpoint(sourceText, plan) {
|
|
287
|
+
const planText = JSON.stringify(plan, sortedReplacer(plan));
|
|
288
|
+
return {
|
|
289
|
+
contract_version: DIRECT_CONTRACT_VERSION,
|
|
290
|
+
source_sha256: sha256Text(sourceText),
|
|
291
|
+
episode_plan_sha256: sha256Text(planText),
|
|
292
|
+
total_episodes: Number(plan["total_episodes"] ?? asList(plan["episodes"]).length),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function initBatchCheckpoint(sourceText, batchPlan) {
|
|
296
|
+
const planText = JSON.stringify(batchPlan, sortedReplacer(batchPlan));
|
|
297
|
+
return {
|
|
298
|
+
contract_version: DIRECT_CONTRACT_VERSION,
|
|
299
|
+
source_sha256: sha256Text(sourceText),
|
|
300
|
+
batch_plan_sha256: sha256Text(planText),
|
|
301
|
+
total_batches: Number(batchPlan["total_batches"] ?? asList(batchPlan["batches"]).length),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function sortedReplacer(_root) {
|
|
305
|
+
// Python's json.dumps(sort_keys=True) sorts keys recursively. Replicate by walking and sorting.
|
|
306
|
+
return function (key, value) {
|
|
307
|
+
if (isDict(value)) {
|
|
308
|
+
const sorted = {};
|
|
309
|
+
for (const k of Object.keys(value).sort())
|
|
310
|
+
sorted[k] = value[k];
|
|
311
|
+
return sorted;
|
|
312
|
+
}
|
|
313
|
+
return value;
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function checkpointSourceMatches(previous, current) {
|
|
317
|
+
if (!previous || Object.keys(previous).length === 0)
|
|
318
|
+
return false;
|
|
319
|
+
const keys = ["contract_version", "source_sha256", "episode_plan_sha256", "total_episodes"];
|
|
320
|
+
return keys.every((k) => previous[k] === current[k]);
|
|
321
|
+
}
|
|
322
|
+
function batchCheckpointMatches(previous, current) {
|
|
323
|
+
if (!previous || Object.keys(previous).length === 0)
|
|
324
|
+
return false;
|
|
325
|
+
const keys = ["contract_version", "source_sha256", "batch_plan_sha256", "total_batches"];
|
|
326
|
+
return keys.every((k) => previous[k] === current[k]);
|
|
327
|
+
}
|
|
328
|
+
function resetInitOutputs(dd) {
|
|
329
|
+
for (const dirname of ["episode_results", "batch_results"]) {
|
|
330
|
+
const target = path.join(dd, dirname);
|
|
331
|
+
if (exists(target))
|
|
332
|
+
deleteTree(target);
|
|
333
|
+
}
|
|
334
|
+
for (const name of ["script.initial.json", "validation.json", "batch_plan.json", "asset_curation.json", "asset_metadata.json"]) {
|
|
335
|
+
const p = path.join(dd, name);
|
|
336
|
+
if (exists(p))
|
|
337
|
+
deletePath(p);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function resetBatchOutputs(dd) {
|
|
341
|
+
const batchResultsDir = path.join(dd, "batch_results");
|
|
342
|
+
if (exists(batchResultsDir))
|
|
343
|
+
deleteTree(batchResultsDir);
|
|
344
|
+
}
|
|
345
|
+
function loadCheckpointedEpisode(sourceText, episodeResultsDir, ep, providerName, model, previousProvider) {
|
|
346
|
+
const p = episodeResultPath(episodeResultsDir, ep);
|
|
347
|
+
if (!exists(p))
|
|
348
|
+
return null;
|
|
349
|
+
let result;
|
|
350
|
+
try {
|
|
351
|
+
const data = readJson(p);
|
|
352
|
+
const metadata = isDict(data["_scriptctl"]) ? data["_scriptctl"] : {};
|
|
353
|
+
const index = readEpisodeResultsIndex(episodeResultsDir);
|
|
354
|
+
let indexEntry = {};
|
|
355
|
+
const eps = index["episodes"];
|
|
356
|
+
if (isDict(eps)) {
|
|
357
|
+
const entry = eps[episodeResultKey(ep)];
|
|
358
|
+
if (isDict(entry))
|
|
359
|
+
indexEntry = entry;
|
|
360
|
+
}
|
|
361
|
+
const resultProvider = strOf(metadata["provider"] || indexEntry["provider"] || previousProvider).trim();
|
|
362
|
+
if (providerName && resultProvider && resultProvider !== providerName) {
|
|
363
|
+
throw new Error(`checkpoint provider mismatch: ${resultProvider} != ${providerName}`);
|
|
364
|
+
}
|
|
365
|
+
result = normalizeEpisodeResult(data, ep);
|
|
366
|
+
validateEpisodeExtractionQuality(sourceText, ep, result);
|
|
367
|
+
if (!("sc" in data) || ["episode", "title", "source_span", "_scriptctl"].some((k) => k in data)) {
|
|
368
|
+
writeJson(p, compactEpisodeResult(result));
|
|
369
|
+
if (providerName && model)
|
|
370
|
+
updateEpisodeResultMetadata(episodeResultsDir, ep, providerName, model);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
try {
|
|
375
|
+
deletePath(p);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// ignore
|
|
379
|
+
}
|
|
380
|
+
removeEpisodeResultMetadata(episodeResultsDir, ep);
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
if (Number(result["episode"] ?? 0) !== Number(ep["episode"]))
|
|
384
|
+
return null;
|
|
385
|
+
if (JSON.stringify(result["source_span"]) !== JSON.stringify(ep["source_span"]))
|
|
386
|
+
return null;
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
function loadCheckpointedBatch(sourceText, batchResultsDir, batch, providerName, model, previousProvider) {
|
|
390
|
+
const p = batchResultPath(batchResultsDir, batch);
|
|
391
|
+
if (!exists(p))
|
|
392
|
+
return null;
|
|
393
|
+
let result;
|
|
394
|
+
try {
|
|
395
|
+
const data = readJson(p);
|
|
396
|
+
const index = readBatchResultsIndex(batchResultsDir);
|
|
397
|
+
let indexEntry = {};
|
|
398
|
+
const batches = index["batches"];
|
|
399
|
+
if (isDict(batches)) {
|
|
400
|
+
const entry = batches[batchResultKey(batch)];
|
|
401
|
+
if (isDict(entry))
|
|
402
|
+
indexEntry = entry;
|
|
403
|
+
}
|
|
404
|
+
const resultProvider = strOf(indexEntry["provider"] || previousProvider).trim();
|
|
405
|
+
if (providerName && resultProvider && resultProvider !== providerName) {
|
|
406
|
+
throw new Error(`checkpoint provider mismatch: ${resultProvider} != ${providerName}`);
|
|
407
|
+
}
|
|
408
|
+
result = normalizeEpisodeResult(data, batch);
|
|
409
|
+
validateBatchExtractionQuality(sourceText, batch, result);
|
|
410
|
+
if (!("sc" in data) || compactResultHasMultiRefs(data) || ["episode", "title", "source_span", "_scriptctl"].some((k) => k in data)) {
|
|
411
|
+
persistBatchResult(batchResultsDir, batch, result);
|
|
412
|
+
if (providerName && model)
|
|
413
|
+
updateBatchResultMetadata(batchResultsDir, batch, providerName, model);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
try {
|
|
418
|
+
deletePath(p);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
// ignore
|
|
422
|
+
}
|
|
423
|
+
removeBatchResultMetadata(batchResultsDir, batch);
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
if (Number(result["episode"] ?? 0) !== Number(batch["episode"]))
|
|
427
|
+
return null;
|
|
428
|
+
if (JSON.stringify(result["source_span"]) !== JSON.stringify(batch["source_span"]))
|
|
429
|
+
return null;
|
|
430
|
+
return result;
|
|
431
|
+
}
|
|
432
|
+
function mergeScene(target, source) {
|
|
433
|
+
if ((target["location_name"] === "" || target["location_name"] === "未知场景" || target["location_name"] === null || target["location_name"] === undefined) &&
|
|
434
|
+
source["location_name"]) {
|
|
435
|
+
target["location_name"] = source["location_name"];
|
|
436
|
+
}
|
|
437
|
+
if (!target["location_state"] && source["location_state"]) {
|
|
438
|
+
target["location_state"] = source["location_state"];
|
|
439
|
+
}
|
|
440
|
+
for (const actor of asList(source["actor_names"])) {
|
|
441
|
+
if (!isList(target["actor_names"]))
|
|
442
|
+
target["actor_names"] = [];
|
|
443
|
+
uniqueAdd(target["actor_names"], actor);
|
|
444
|
+
}
|
|
445
|
+
const targetStates = target["actor_states"] ?? {};
|
|
446
|
+
if (!isDict(target["actor_states"]))
|
|
447
|
+
target["actor_states"] = targetStates;
|
|
448
|
+
if (isDict(source["actor_states"])) {
|
|
449
|
+
for (const [actor, state] of Object.entries(source["actor_states"])) {
|
|
450
|
+
if (state && !targetStates[actor])
|
|
451
|
+
targetStates[actor] = String(state);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
for (const prop of asList(source["prop_names"])) {
|
|
455
|
+
if (!isList(target["prop_names"]))
|
|
456
|
+
target["prop_names"] = [];
|
|
457
|
+
uniqueAdd(target["prop_names"], prop);
|
|
458
|
+
}
|
|
459
|
+
if (!isList(target["actions"]))
|
|
460
|
+
target["actions"] = [];
|
|
461
|
+
target["actions"].push(...asList(source["actions"]));
|
|
462
|
+
}
|
|
463
|
+
function mergeBatchResultsForEpisode(episode, batchResults) {
|
|
464
|
+
const scenes = [];
|
|
465
|
+
const actors = [];
|
|
466
|
+
const locations = [];
|
|
467
|
+
const props = [];
|
|
468
|
+
const sorted = [...batchResults].sort((a, b) => Number(a["_batch_part"] ?? 0) - Number(b["_batch_part"] ?? 0));
|
|
469
|
+
for (const item of sorted) {
|
|
470
|
+
const itemScenes = asList(item["scenes"]);
|
|
471
|
+
if (itemScenes.length === 0)
|
|
472
|
+
continue;
|
|
473
|
+
if (item["_starts_inside_scene"] && scenes.length > 0) {
|
|
474
|
+
mergeScene(scenes[scenes.length - 1], itemScenes[0]);
|
|
475
|
+
scenes.push(...itemScenes.slice(1));
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
scenes.push(...itemScenes);
|
|
479
|
+
}
|
|
480
|
+
for (const asset of asList(item["actors"])) {
|
|
481
|
+
if (isDict(asset))
|
|
482
|
+
_md_push_asset(actors, strOf(asset["name"]), strOf(asset["description"]).trim() || null);
|
|
483
|
+
}
|
|
484
|
+
for (const asset of asList(item["locations"])) {
|
|
485
|
+
if (isDict(asset))
|
|
486
|
+
_md_push_asset(locations, strOf(asset["name"]), strOf(asset["description"]).trim() || null);
|
|
487
|
+
}
|
|
488
|
+
for (const asset of asList(item["props"])) {
|
|
489
|
+
if (isDict(asset))
|
|
490
|
+
_md_push_asset(props, strOf(asset["name"]), strOf(asset["description"]).trim() || null);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const result = {
|
|
494
|
+
episode: Number(episode["episode"]),
|
|
495
|
+
title: episode["title"],
|
|
496
|
+
source_span: episode["source_span"],
|
|
497
|
+
scenes,
|
|
498
|
+
actors,
|
|
499
|
+
locations,
|
|
500
|
+
props,
|
|
501
|
+
};
|
|
502
|
+
return normalizeEpisodeResult(result, episode);
|
|
503
|
+
}
|
|
504
|
+
async function providerExtractAssetCurationLocal(provider, sourceText, script) {
|
|
505
|
+
if (provider.extractAssetCuration) {
|
|
506
|
+
const payload = await provider.extractAssetCuration(sourceText, script);
|
|
507
|
+
return isDict(payload) ? payload : {};
|
|
508
|
+
}
|
|
509
|
+
return {};
|
|
510
|
+
}
|
|
511
|
+
function writeEpisodeFailure(dir, ep, exc) {
|
|
512
|
+
const err = exc;
|
|
513
|
+
const error = {
|
|
514
|
+
episode: Number(ep["episode"]),
|
|
515
|
+
title: ep["title"],
|
|
516
|
+
source_span: ep["source_span"],
|
|
517
|
+
error_type: err?.name || "Error",
|
|
518
|
+
message: (err?.message || err?.name || "Error").slice(0, 500),
|
|
519
|
+
failed_at: checkpointTimestamp(),
|
|
520
|
+
};
|
|
521
|
+
if (exc instanceof CliError) {
|
|
522
|
+
if (exc.required.length > 0)
|
|
523
|
+
error["required"] = exc.required;
|
|
524
|
+
if (exc.received.length > 0)
|
|
525
|
+
error["received"] = exc.received;
|
|
526
|
+
if (exc.nextSteps.length > 0)
|
|
527
|
+
error["next"] = exc.nextSteps;
|
|
528
|
+
}
|
|
529
|
+
const resultPath = episodeResultPath(dir, ep);
|
|
530
|
+
if (exists(resultPath))
|
|
531
|
+
deletePath(resultPath);
|
|
532
|
+
removeEpisodeResultMetadata(dir, ep);
|
|
533
|
+
writeJson(episodeErrorPath(dir, ep), error);
|
|
534
|
+
return error;
|
|
535
|
+
}
|
|
536
|
+
function writeBatchFailure(dir, batch, exc) {
|
|
537
|
+
const err = exc;
|
|
538
|
+
const error = {
|
|
539
|
+
batch_id: batchResultKey(batch),
|
|
540
|
+
episode: Number(batch["episode"]),
|
|
541
|
+
part: Number(batch["part"]),
|
|
542
|
+
source_span: batch["source_span"],
|
|
543
|
+
line_range: batch["line_range"],
|
|
544
|
+
error_type: err?.name || "Error",
|
|
545
|
+
message: (err?.message || err?.name || "Error").slice(0, 500),
|
|
546
|
+
failed_at: checkpointTimestamp(),
|
|
547
|
+
};
|
|
548
|
+
if (exc instanceof CliError) {
|
|
549
|
+
if (exc.required.length > 0)
|
|
550
|
+
error["required"] = exc.required;
|
|
551
|
+
if (exc.received.length > 0)
|
|
552
|
+
error["received"] = exc.received;
|
|
553
|
+
if (exc.nextSteps.length > 0)
|
|
554
|
+
error["next"] = exc.nextSteps;
|
|
555
|
+
}
|
|
556
|
+
const resultPath = batchResultPath(dir, batch);
|
|
557
|
+
if (exists(resultPath))
|
|
558
|
+
deletePath(resultPath);
|
|
559
|
+
removeBatchResultMetadata(dir, batch);
|
|
560
|
+
writeJson(batchErrorPath(dir, batch), error);
|
|
561
|
+
return error;
|
|
562
|
+
}
|
|
563
|
+
function initFailedReport(workspace, opts) {
|
|
564
|
+
const payload = {
|
|
565
|
+
status: "init_failed",
|
|
566
|
+
command: "direct init",
|
|
567
|
+
init_stage: opts.stage,
|
|
568
|
+
last_error: { title: opts.title, received: opts.received, failed_at: checkpointTimestamp() },
|
|
569
|
+
};
|
|
570
|
+
if (opts.updates)
|
|
571
|
+
Object.assign(payload, opts.updates);
|
|
572
|
+
updateRunState(workspace, payload);
|
|
573
|
+
return new CliError(opts.title, opts.title, {
|
|
574
|
+
exitCode: opts.exitCode ?? EXIT_RUNTIME,
|
|
575
|
+
required: opts.required,
|
|
576
|
+
received: opts.received,
|
|
577
|
+
nextSteps: opts.nextSteps,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
// Concurrency limiter (mirrors ThreadPoolExecutor)
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
async function pMapWithConcurrency(items, concurrency, worker) {
|
|
584
|
+
const out = new Array(items.length);
|
|
585
|
+
let cursor = 0;
|
|
586
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
587
|
+
await Promise.all(Array.from({ length: limit }, async () => {
|
|
588
|
+
while (true) {
|
|
589
|
+
const idx = cursor++;
|
|
590
|
+
if (idx >= items.length)
|
|
591
|
+
break;
|
|
592
|
+
try {
|
|
593
|
+
const value = await worker(items[idx], idx);
|
|
594
|
+
out[idx] = { ok: true, value };
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
out[idx] = { ok: false, error, item: items[idx] };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}));
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// command_init
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
export async function commandInit(opts) {
|
|
607
|
+
const sourcePathArg = strOf(opts["source_path"]);
|
|
608
|
+
const source = sourcePathArg.startsWith("~")
|
|
609
|
+
? path.join(process.env.HOME ?? "", sourcePathArg.slice(1))
|
|
610
|
+
: sourcePathArg;
|
|
611
|
+
const workspace = strOf(opts["workspace_path"] || "workspace");
|
|
612
|
+
const providerName = strOf(opts["provider"] || DEFAULT_PROVIDER);
|
|
613
|
+
const model = strOf(opts["model"] || process.env.SCRIPTCTL_ANTHROPIC_MODEL || process.env.ANTHROPIC_MODEL || DEFAULT_MODEL);
|
|
614
|
+
let concurrency;
|
|
615
|
+
try {
|
|
616
|
+
concurrency = parseInt(strOf(opts["concurrency"] || DEFAULT_CONCURRENCY), 10);
|
|
617
|
+
if (Number.isNaN(concurrency))
|
|
618
|
+
throw new Error("nan");
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
throw new CliError("USAGE ERROR: Invalid concurrency", "Invalid concurrency.", {
|
|
622
|
+
exitCode: EXIT_USAGE,
|
|
623
|
+
required: ["--concurrency: positive integer"],
|
|
624
|
+
received: [`--concurrency: ${opts["concurrency"]}`],
|
|
625
|
+
nextSteps: ["Use a positive integer and rerun init."],
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
if (concurrency < 1) {
|
|
629
|
+
throw new CliError("USAGE ERROR: Invalid concurrency", "Invalid concurrency.", {
|
|
630
|
+
exitCode: EXIT_USAGE,
|
|
631
|
+
required: ["--concurrency: positive integer"],
|
|
632
|
+
received: [`--concurrency: ${concurrency}`],
|
|
633
|
+
nextSteps: ["Use a positive integer and rerun init."],
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
const batchMode = strOf(opts["batch_mode"] || DEFAULT_BATCH_MODE).trim();
|
|
637
|
+
if (batchMode !== "episode") {
|
|
638
|
+
throw new CliError("USAGE ERROR: Invalid batch mode", "Invalid batch mode.", {
|
|
639
|
+
exitCode: EXIT_USAGE,
|
|
640
|
+
required: ["--batch-mode: episode"],
|
|
641
|
+
received: [`--batch-mode: ${batchMode}`],
|
|
642
|
+
nextSteps: ["Use --batch-mode episode and rerun init."],
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
const batchValues = {};
|
|
646
|
+
for (const [optName, defaultValue] of [
|
|
647
|
+
["batch_target_lines", DEFAULT_BATCH_TARGET_LINES],
|
|
648
|
+
["batch_max_chars", DEFAULT_BATCH_MAX_CHARS],
|
|
649
|
+
["batch_min_lines", DEFAULT_BATCH_MIN_LINES],
|
|
650
|
+
]) {
|
|
651
|
+
let value;
|
|
652
|
+
try {
|
|
653
|
+
value = parseInt(strOf(opts[optName] || defaultValue), 10);
|
|
654
|
+
if (Number.isNaN(value))
|
|
655
|
+
throw new Error("nan");
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
throw new CliError("USAGE ERROR: Invalid batch option", "Invalid batch option.", {
|
|
659
|
+
exitCode: EXIT_USAGE,
|
|
660
|
+
required: [`--${optName.replace(/_/g, "-")}: positive integer`],
|
|
661
|
+
received: [`--${optName.replace(/_/g, "-")}: ${opts[optName]}`],
|
|
662
|
+
nextSteps: ["Use positive integers for batch options and rerun init."],
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
if (value < 1) {
|
|
666
|
+
throw new CliError("USAGE ERROR: Invalid batch option", "Invalid batch option.", {
|
|
667
|
+
exitCode: EXIT_USAGE,
|
|
668
|
+
required: [`--${optName.replace(/_/g, "-")}: positive integer`],
|
|
669
|
+
received: [`--${optName.replace(/_/g, "-")}: ${value}`],
|
|
670
|
+
nextSteps: ["Use positive integers for batch options and rerun init."],
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
batchValues[optName] = value;
|
|
674
|
+
}
|
|
675
|
+
const batchTargetLines = batchValues["batch_target_lines"];
|
|
676
|
+
const batchMaxChars = batchValues["batch_max_chars"];
|
|
677
|
+
const batchMinLines = Math.min(batchValues["batch_min_lines"], batchTargetLines);
|
|
678
|
+
if (!exists(source) || !fs.statSync(source).isFile()) {
|
|
679
|
+
throw new CliError("INIT BLOCKED: Source file not found", "Source file not found.", {
|
|
680
|
+
exitCode: EXIT_INPUT,
|
|
681
|
+
required: [`--source-path: existing ${SUPPORTED_EXTS} file`],
|
|
682
|
+
received: [`--source-path: ${source}`],
|
|
683
|
+
nextSteps: ["Use an existing file path and rerun init."],
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
const ext = path.extname(source).toLowerCase();
|
|
687
|
+
const supportedExts = new Set([".txt", ".md", ".docx", ".xlsx", ".pdf", ".json"]);
|
|
688
|
+
if (!supportedExts.has(ext)) {
|
|
689
|
+
throw new CliError("INIT BLOCKED: Unsupported source format", "Unsupported source format.", {
|
|
690
|
+
exitCode: EXIT_USAGE,
|
|
691
|
+
required: [`--source-path: ${SUPPORTED_EXTS}`],
|
|
692
|
+
received: [`--source-path: ${path.basename(source)}`],
|
|
693
|
+
nextSteps: ["Use a supported source file and rerun init."],
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
const dd = directDir(workspace);
|
|
697
|
+
fs.mkdirSync(dd, { recursive: true });
|
|
698
|
+
const previousStateBeforeInit = readRunState(workspace);
|
|
699
|
+
updateRunState(workspace, {
|
|
700
|
+
status: "init_running",
|
|
701
|
+
command: "direct init",
|
|
702
|
+
init_stage: "source_prepare",
|
|
703
|
+
provider: providerName,
|
|
704
|
+
model,
|
|
705
|
+
concurrency,
|
|
706
|
+
source_path: path.resolve(source),
|
|
707
|
+
});
|
|
708
|
+
let info;
|
|
709
|
+
try {
|
|
710
|
+
info = await prepareSource(source, workspace);
|
|
711
|
+
}
|
|
712
|
+
catch (exc) {
|
|
713
|
+
if (exc instanceof CliError) {
|
|
714
|
+
updateRunState(workspace, {
|
|
715
|
+
status: "init_failed",
|
|
716
|
+
init_stage: "source_prepare",
|
|
717
|
+
last_error: { title: exc.title, received: exc.received, failed_at: checkpointTimestamp() },
|
|
718
|
+
});
|
|
719
|
+
throw exc;
|
|
720
|
+
}
|
|
721
|
+
const e = exc;
|
|
722
|
+
const receivedError = `${source}: ${e?.name ?? "Error"}${e?.message ? `: ${e.message}` : ""}`;
|
|
723
|
+
updateRunState(workspace, {
|
|
724
|
+
status: "init_failed",
|
|
725
|
+
init_stage: "source_prepare",
|
|
726
|
+
last_error: { title: "INIT BLOCKED: Source preparation failed", received: [receivedError], failed_at: checkpointTimestamp() },
|
|
727
|
+
});
|
|
728
|
+
throw new CliError("INIT BLOCKED: Source preparation failed", "Source preparation failed.", {
|
|
729
|
+
exitCode: EXIT_INPUT,
|
|
730
|
+
required: ["readable source file that can be converted to source.txt"],
|
|
731
|
+
received: [receivedError],
|
|
732
|
+
nextSteps: ["Fix or re-export the source file, then rerun init."],
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
const sourceTextPath = strOf(info["sourceTextPath"]);
|
|
736
|
+
const sourceText = readText(sourceTextPath);
|
|
737
|
+
const manifest = makeSourceManifest(source, sourceTextPath, info);
|
|
738
|
+
updateRunState(workspace, { status: "init_running", init_stage: "episode_plan" });
|
|
739
|
+
let plan;
|
|
740
|
+
try {
|
|
741
|
+
plan = buildEpisodePlan(sourceText);
|
|
742
|
+
}
|
|
743
|
+
catch (exc) {
|
|
744
|
+
const e = exc;
|
|
745
|
+
throw initFailedReport(workspace, {
|
|
746
|
+
title: "INIT FAILED: Episode planning failed",
|
|
747
|
+
stage: "episode_plan",
|
|
748
|
+
required: ["source.txt that can be split into episodes"],
|
|
749
|
+
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
750
|
+
nextSteps: ["Inspect workspace/source.txt, fix the source file, and rerun init."],
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
updateRunState(workspace, { status: "init_running", init_stage: "provider" });
|
|
754
|
+
let provider;
|
|
755
|
+
try {
|
|
756
|
+
provider = makeProvider(providerName, model);
|
|
757
|
+
}
|
|
758
|
+
catch (exc) {
|
|
759
|
+
if (exc instanceof CliError) {
|
|
760
|
+
updateRunState(workspace, {
|
|
761
|
+
status: "init_failed",
|
|
762
|
+
init_stage: "provider",
|
|
763
|
+
last_error: { title: exc.title, received: exc.received, failed_at: checkpointTimestamp() },
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
throw exc;
|
|
767
|
+
}
|
|
768
|
+
updateRunState(workspace, { status: "init_running", init_stage: "episode_titles" });
|
|
769
|
+
try {
|
|
770
|
+
plan = await enrichEpisodePlanTitles(sourceText, plan, provider);
|
|
771
|
+
}
|
|
772
|
+
catch (exc) {
|
|
773
|
+
if (exc instanceof CliError) {
|
|
774
|
+
throw initFailedReport(workspace, {
|
|
775
|
+
title: exc.title,
|
|
776
|
+
stage: "episode_titles",
|
|
777
|
+
exitCode: exc.exitCode,
|
|
778
|
+
required: exc.required.length > 0 ? exc.required : ["episode titles generated from source text"],
|
|
779
|
+
received: exc.received.length > 0 ? exc.received : [String(exc.message).slice(0, 160)],
|
|
780
|
+
nextSteps: exc.nextSteps.length > 0 ? exc.nextSteps : ["Rerun init after checking source episode headers."],
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
const e = exc;
|
|
784
|
+
throw initFailedReport(workspace, {
|
|
785
|
+
title: "INIT FAILED: Episode title planning failed",
|
|
786
|
+
stage: "episode_titles",
|
|
787
|
+
required: ["episode titles generated from source text"],
|
|
788
|
+
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
789
|
+
nextSteps: ["Inspect workspace/source.txt and episode_plan.json, then rerun init."],
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
let batchPlan;
|
|
793
|
+
try {
|
|
794
|
+
batchPlan = buildBatchPlan(sourceText, plan, {
|
|
795
|
+
targetLines: batchTargetLines,
|
|
796
|
+
maxChars: batchMaxChars,
|
|
797
|
+
minLines: batchMinLines,
|
|
798
|
+
mode: batchMode,
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
catch (exc) {
|
|
802
|
+
const e = exc;
|
|
803
|
+
throw initFailedReport(workspace, {
|
|
804
|
+
title: "INIT FAILED: Batch planning failed",
|
|
805
|
+
stage: "batch_plan",
|
|
806
|
+
required: ["episode_plan.json that can be split into natural paragraph batches"],
|
|
807
|
+
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
808
|
+
nextSteps: ["Inspect workspace/source.txt and episode_plan.json, then rerun init."],
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
const checkpoint = initCheckpoint(sourceText, plan);
|
|
812
|
+
const batchCheckpoint = initBatchCheckpoint(sourceText, batchPlan);
|
|
813
|
+
const previousState = previousStateBeforeInit;
|
|
814
|
+
const previousCheckpoint = isDict(previousState["checkpoint"]) ? previousState["checkpoint"] : {};
|
|
815
|
+
const previousBatchCheckpoint = isDict(previousState["batch_checkpoint"]) ? previousState["batch_checkpoint"] : {};
|
|
816
|
+
const checkpointReused = checkpointSourceMatches(previousCheckpoint, checkpoint);
|
|
817
|
+
const batchCheckpointReused = checkpointReused && batchCheckpointMatches(previousBatchCheckpoint, batchCheckpoint);
|
|
818
|
+
if (!checkpointReused)
|
|
819
|
+
resetInitOutputs(dd);
|
|
820
|
+
else if (!batchCheckpointReused)
|
|
821
|
+
resetBatchOutputs(dd);
|
|
822
|
+
writeJson(path.join(dd, "source_manifest.json"), manifest);
|
|
823
|
+
writeJson(path.join(dd, "episode_plan.json"), plan);
|
|
824
|
+
writeJson(path.join(dd, "batch_plan.json"), batchPlan);
|
|
825
|
+
const episodeResultsDir = path.join(dd, "episode_results");
|
|
826
|
+
const batchResultsDir = path.join(dd, "batch_results");
|
|
827
|
+
fs.mkdirSync(episodeResultsDir, { recursive: true });
|
|
828
|
+
fs.mkdirSync(batchResultsDir, { recursive: true });
|
|
829
|
+
updateRunState(workspace, {
|
|
830
|
+
status: "init_running",
|
|
831
|
+
init_stage: "batch_extract",
|
|
832
|
+
checkpoint,
|
|
833
|
+
batch_checkpoint: batchCheckpoint,
|
|
834
|
+
checkpoint_reused: checkpointReused,
|
|
835
|
+
batch_checkpoint_reused: batchCheckpointReused,
|
|
836
|
+
batch_mode: batchMode,
|
|
837
|
+
batch_target_lines: batchTargetLines,
|
|
838
|
+
batch_max_chars: batchMaxChars,
|
|
839
|
+
batch_min_lines: batchMinLines,
|
|
840
|
+
episode_total: asList(plan["episodes"]).length,
|
|
841
|
+
batch_total: asList(batchPlan["batches"]).length,
|
|
842
|
+
});
|
|
843
|
+
const results = [];
|
|
844
|
+
const skipped = [];
|
|
845
|
+
let skippedEpisodeBatchCount = 0;
|
|
846
|
+
const pendingBatches = [];
|
|
847
|
+
const batchesByEpisode = new Map();
|
|
848
|
+
for (const batch of asList(batchPlan["batches"])) {
|
|
849
|
+
const epNum = Number(batch["episode"]);
|
|
850
|
+
if (!batchesByEpisode.has(epNum))
|
|
851
|
+
batchesByEpisode.set(epNum, []);
|
|
852
|
+
batchesByEpisode.get(epNum).push(batch);
|
|
853
|
+
}
|
|
854
|
+
const previousProvider = strOf(previousState["provider"]).trim() || null;
|
|
855
|
+
for (const episode of asList(plan["episodes"])) {
|
|
856
|
+
const cached = checkpointReused
|
|
857
|
+
? loadCheckpointedEpisode(sourceText, episodeResultsDir, episode, providerName, model, previousProvider)
|
|
858
|
+
: null;
|
|
859
|
+
if (cached !== null) {
|
|
860
|
+
results.push(cached);
|
|
861
|
+
skipped.push(Number(episode["episode"]));
|
|
862
|
+
const cachedBatches = batchesByEpisode.get(Number(episode["episode"])) ?? [];
|
|
863
|
+
skippedEpisodeBatchCount += cachedBatches.length;
|
|
864
|
+
for (const cachedBatch of cachedBatches) {
|
|
865
|
+
if (!exists(batchResultPath(batchResultsDir, cachedBatch))) {
|
|
866
|
+
const backfilled = recoverBatchFromSource(sourceText, cachedBatch);
|
|
867
|
+
persistBatchResult(batchResultsDir, cachedBatch, backfilled);
|
|
868
|
+
updateBatchResultMetadata(batchResultsDir, cachedBatch, providerName, model);
|
|
869
|
+
}
|
|
870
|
+
const errorPath = batchErrorPath(batchResultsDir, cachedBatch);
|
|
871
|
+
if (exists(errorPath))
|
|
872
|
+
deletePath(errorPath);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
else {
|
|
876
|
+
pendingBatches.push(...(batchesByEpisode.get(Number(episode["episode"])) ?? []));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
const batchResults = [];
|
|
880
|
+
const skippedBatches = [];
|
|
881
|
+
const pending = [];
|
|
882
|
+
for (const batch of pendingBatches) {
|
|
883
|
+
const cachedBatch = batchCheckpointReused
|
|
884
|
+
? loadCheckpointedBatch(sourceText, batchResultsDir, batch, providerName, model, previousProvider)
|
|
885
|
+
: null;
|
|
886
|
+
if (cachedBatch !== null) {
|
|
887
|
+
cachedBatch["_batch_id"] = batchResultKey(batch);
|
|
888
|
+
cachedBatch["_batch_part"] = Number(batch["part"]);
|
|
889
|
+
cachedBatch["_starts_inside_scene"] = Boolean(batch["starts_inside_scene"]);
|
|
890
|
+
batchResults.push(cachedBatch);
|
|
891
|
+
skippedBatches.push(batchResultKey(batch));
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
pending.push(batch);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
const failures = [];
|
|
898
|
+
const outcomes = await pMapWithConcurrency(pending, concurrency, async (batch) => {
|
|
899
|
+
return await extractBatchWithRecovery(provider, sourceText, batch);
|
|
900
|
+
});
|
|
901
|
+
for (let i = 0; i < outcomes.length; i++) {
|
|
902
|
+
const outcome = outcomes[i];
|
|
903
|
+
const batch = pending[i];
|
|
904
|
+
const errorPath = batchErrorPath(batchResultsDir, batch);
|
|
905
|
+
if (outcome.ok) {
|
|
906
|
+
const result = outcome.value;
|
|
907
|
+
result["_batch_id"] = batchResultKey(batch);
|
|
908
|
+
result["_batch_part"] = Number(batch["part"]);
|
|
909
|
+
result["_starts_inside_scene"] = Boolean(batch["starts_inside_scene"]);
|
|
910
|
+
batchResults.push(result);
|
|
911
|
+
persistBatchResult(batchResultsDir, batch, result);
|
|
912
|
+
updateBatchResultMetadata(batchResultsDir, batch, providerName, model);
|
|
913
|
+
if (exists(errorPath))
|
|
914
|
+
deletePath(errorPath);
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
failures.push(writeBatchFailure(batchResultsDir, batch, outcome.error));
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
|
|
921
|
+
batchResults.sort((a, b) => {
|
|
922
|
+
const ea = Number(a["episode"] ?? 0);
|
|
923
|
+
const eb = Number(b["episode"] ?? 0);
|
|
924
|
+
if (ea !== eb)
|
|
925
|
+
return ea - eb;
|
|
926
|
+
return Number(a["_batch_part"] ?? 0) - Number(b["_batch_part"] ?? 0);
|
|
927
|
+
});
|
|
928
|
+
failures.sort((a, b) => {
|
|
929
|
+
const ea = Number(a["episode"] ?? 0);
|
|
930
|
+
const eb = Number(b["episode"] ?? 0);
|
|
931
|
+
if (ea !== eb)
|
|
932
|
+
return ea - eb;
|
|
933
|
+
return Number(a["part"] ?? 0) - Number(b["part"] ?? 0);
|
|
934
|
+
});
|
|
935
|
+
const completedBatches = skippedEpisodeBatchCount + batchResults.length;
|
|
936
|
+
if (failures.length > 0) {
|
|
937
|
+
const failedEpisodes = [...new Set(failures.map((it) => Number(it["episode"])))].sort((a, b) => a - b);
|
|
938
|
+
const failedBatches = failures.map((it) => strOf(it["batch_id"]));
|
|
939
|
+
const currentFailureSignature = failureSignature(failedBatches);
|
|
940
|
+
const previousFailureSignature = failureSignature(previousState["failed_batches"]);
|
|
941
|
+
const sameFailuresRepeated = checkpointReused &&
|
|
942
|
+
batchCheckpointReused &&
|
|
943
|
+
currentFailureSignature.length > 0 &&
|
|
944
|
+
currentFailureSignature.length === previousFailureSignature.length &&
|
|
945
|
+
currentFailureSignature.every((v, idx) => v === previousFailureSignature[idx]) &&
|
|
946
|
+
["init_incomplete", "init_stalled"].includes(strOf(previousState["status"]));
|
|
947
|
+
const previousFailureStreak = normalizeInt(previousState["failure_streak"], 0);
|
|
948
|
+
const failureStreak = sameFailuresRepeated ? previousFailureStreak + 1 : 1;
|
|
949
|
+
const failureTitle = sameFailuresRepeated
|
|
950
|
+
? "INIT STALLED: Same batches keep failing"
|
|
951
|
+
: "INIT INCOMPLETE: Batch extraction failed";
|
|
952
|
+
const nextSteps = sameFailuresRepeated
|
|
953
|
+
? [
|
|
954
|
+
"Run direct inspect --target issue to read failed batch details.",
|
|
955
|
+
"Do not rerun the same init command again until source, batch options, provider, or failed content has changed.",
|
|
956
|
+
]
|
|
957
|
+
: [
|
|
958
|
+
"Run direct inspect --target issue to review failed batches.",
|
|
959
|
+
"Rerun the same init once if failures look transient; completed checkpoints will be reused.",
|
|
960
|
+
];
|
|
961
|
+
const failedEpisodeSet = new Set(failedEpisodes);
|
|
962
|
+
const skippedSet = new Set(skipped);
|
|
963
|
+
const batchResultsByEpisode = new Map();
|
|
964
|
+
for (const result of batchResults) {
|
|
965
|
+
const ep = Number(result["episode"] ?? 0);
|
|
966
|
+
if (!batchResultsByEpisode.has(ep))
|
|
967
|
+
batchResultsByEpisode.set(ep, []);
|
|
968
|
+
batchResultsByEpisode.get(ep).push(result);
|
|
969
|
+
}
|
|
970
|
+
for (const episode of asList(plan["episodes"])) {
|
|
971
|
+
const episodeNum = Number(episode["episode"]);
|
|
972
|
+
if (skippedSet.has(episodeNum) || failedEpisodeSet.has(episodeNum))
|
|
973
|
+
continue;
|
|
974
|
+
const expectedBatches = (batchesByEpisode.get(episodeNum) ?? []).length;
|
|
975
|
+
if (expectedBatches && (batchResultsByEpisode.get(episodeNum) ?? []).length === expectedBatches) {
|
|
976
|
+
const result = mergeBatchResultsForEpisode(episode, batchResultsByEpisode.get(episodeNum) ?? []);
|
|
977
|
+
validateEpisodeExtractionQuality(sourceText, episode, result);
|
|
978
|
+
results.push(result);
|
|
979
|
+
writeJson(episodeResultPath(episodeResultsDir, episode), compactEpisodeResult(result));
|
|
980
|
+
updateEpisodeResultMetadata(episodeResultsDir, episode, providerName, model);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
updateRunState(workspace, {
|
|
984
|
+
status: sameFailuresRepeated ? "init_stalled" : "init_incomplete",
|
|
985
|
+
init_stage: "batch_extract",
|
|
986
|
+
checkpoint,
|
|
987
|
+
batch_checkpoint: batchCheckpoint,
|
|
988
|
+
episode_total: asList(plan["episodes"]).length,
|
|
989
|
+
episode_completed: results.length,
|
|
990
|
+
episode_reused: skipped.length,
|
|
991
|
+
episode_failed: failedEpisodes.length,
|
|
992
|
+
failed_episodes: failedEpisodes,
|
|
993
|
+
batch_total: asList(batchPlan["batches"]).length,
|
|
994
|
+
batch_completed: completedBatches,
|
|
995
|
+
batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
|
|
996
|
+
batch_failed: failures.length,
|
|
997
|
+
failed_batches: failedBatches,
|
|
998
|
+
failure_signature: currentFailureSignature,
|
|
999
|
+
failure_streak: failureStreak,
|
|
1000
|
+
last_error: { title: failureTitle, failed_at: checkpointTimestamp() },
|
|
1001
|
+
exportable: false,
|
|
1002
|
+
});
|
|
1003
|
+
const issues = failures.slice(0, 5).map((it) => `${it["batch_id"]} episode ${it["episode"]} part ${it["part"]}: ${it["error_type"]} - ${it["message"]}`);
|
|
1004
|
+
const report = {
|
|
1005
|
+
title: failureTitle,
|
|
1006
|
+
result: [
|
|
1007
|
+
`episodes total: ${asList(plan["episodes"]).length}`,
|
|
1008
|
+
`completed: ${results.length}`,
|
|
1009
|
+
`reused: ${skipped.length}`,
|
|
1010
|
+
`failed episodes: ${failedEpisodes.length}`,
|
|
1011
|
+
`batches: ${completedBatches}/${asList(batchPlan["batches"]).length} completed, ${failures.length} failed`,
|
|
1012
|
+
`provider: ${providerName}`,
|
|
1013
|
+
],
|
|
1014
|
+
artifacts: [
|
|
1015
|
+
path.join(workspace, "source.txt"),
|
|
1016
|
+
path.join(dd, "source_manifest.json"),
|
|
1017
|
+
path.join(dd, "episode_plan.json"),
|
|
1018
|
+
path.join(dd, "batch_plan.json"),
|
|
1019
|
+
batchResultsDir,
|
|
1020
|
+
episodeResultsDir,
|
|
1021
|
+
path.join(dd, "run_state.json"),
|
|
1022
|
+
],
|
|
1023
|
+
issues,
|
|
1024
|
+
next: nextSteps,
|
|
1025
|
+
};
|
|
1026
|
+
return [report, EXIT_RUNTIME];
|
|
1027
|
+
}
|
|
1028
|
+
updateRunState(workspace, {
|
|
1029
|
+
status: "init_running",
|
|
1030
|
+
init_stage: "episode_merge",
|
|
1031
|
+
checkpoint,
|
|
1032
|
+
batch_checkpoint: batchCheckpoint,
|
|
1033
|
+
episode_total: asList(plan["episodes"]).length,
|
|
1034
|
+
episode_completed: results.length,
|
|
1035
|
+
episode_reused: skipped.length,
|
|
1036
|
+
episode_failed: 0,
|
|
1037
|
+
failed_episodes: [],
|
|
1038
|
+
batch_total: asList(batchPlan["batches"]).length,
|
|
1039
|
+
batch_completed: completedBatches,
|
|
1040
|
+
batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
|
|
1041
|
+
batch_failed: 0,
|
|
1042
|
+
failed_batches: [],
|
|
1043
|
+
failure_signature: [],
|
|
1044
|
+
failure_streak: 0,
|
|
1045
|
+
last_error: null,
|
|
1046
|
+
});
|
|
1047
|
+
for (const dir of [batchResultsDir, episodeResultsDir]) {
|
|
1048
|
+
if (!exists(dir))
|
|
1049
|
+
continue;
|
|
1050
|
+
for (const name of fs.readdirSync(dir)) {
|
|
1051
|
+
if (name.endsWith(".error.json")) {
|
|
1052
|
+
try {
|
|
1053
|
+
deletePath(path.join(dir, name));
|
|
1054
|
+
}
|
|
1055
|
+
catch {
|
|
1056
|
+
// ignore
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
try {
|
|
1062
|
+
const batchResultsByEpisode = new Map();
|
|
1063
|
+
for (const result of batchResults) {
|
|
1064
|
+
const ep = Number(result["episode"] ?? 0);
|
|
1065
|
+
if (!batchResultsByEpisode.has(ep))
|
|
1066
|
+
batchResultsByEpisode.set(ep, []);
|
|
1067
|
+
batchResultsByEpisode.get(ep).push(result);
|
|
1068
|
+
}
|
|
1069
|
+
const skippedSet = new Set(skipped);
|
|
1070
|
+
for (const episode of asList(plan["episodes"])) {
|
|
1071
|
+
const episodeNum = Number(episode["episode"]);
|
|
1072
|
+
if (skippedSet.has(episodeNum))
|
|
1073
|
+
continue;
|
|
1074
|
+
const result = mergeBatchResultsForEpisode(episode, batchResultsByEpisode.get(episodeNum) ?? []);
|
|
1075
|
+
validateEpisodeExtractionQuality(sourceText, episode, result);
|
|
1076
|
+
results.push(result);
|
|
1077
|
+
writeJson(episodeResultPath(episodeResultsDir, episode), compactEpisodeResult(result));
|
|
1078
|
+
updateEpisodeResultMetadata(episodeResultsDir, episode, providerName, model);
|
|
1079
|
+
const errorPath = episodeErrorPath(episodeResultsDir, episode);
|
|
1080
|
+
if (exists(errorPath))
|
|
1081
|
+
deletePath(errorPath);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
catch (exc) {
|
|
1085
|
+
const e = exc;
|
|
1086
|
+
throw initFailedReport(workspace, {
|
|
1087
|
+
title: "INIT FAILED: Episode merge failed",
|
|
1088
|
+
stage: "episode_merge",
|
|
1089
|
+
required: ["complete batch_results/*.json that can merge into episode_results/*.json"],
|
|
1090
|
+
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
1091
|
+
nextSteps: ["Rerun init; completed batch checkpoints will be reused and episode merge will retry."],
|
|
1092
|
+
updates: { checkpoint, batch_checkpoint: batchCheckpoint, batch_completed: completedBatches },
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
|
|
1096
|
+
let script;
|
|
1097
|
+
try {
|
|
1098
|
+
updateRunState(workspace, { status: "init_running", init_stage: "script_merge", checkpoint, batch_checkpoint: batchCheckpoint });
|
|
1099
|
+
script = mergeEpisodeResults(results, strOf(info["projectName"]) || path.basename(source, path.extname(source)));
|
|
1100
|
+
}
|
|
1101
|
+
catch (exc) {
|
|
1102
|
+
const e = exc;
|
|
1103
|
+
throw initFailedReport(workspace, {
|
|
1104
|
+
title: "INIT FAILED: Merge failed",
|
|
1105
|
+
stage: "script_merge",
|
|
1106
|
+
required: ["complete episode_results/*.json"],
|
|
1107
|
+
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
1108
|
+
nextSteps: ["Rerun init; completed episode extraction checkpoints will be reused and merge will retry."],
|
|
1109
|
+
updates: { checkpoint, batch_checkpoint: batchCheckpoint, episode_completed: results.length },
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
try {
|
|
1113
|
+
updateRunState(workspace, { status: "init_running", init_stage: "asset_curation", checkpoint, batch_checkpoint: batchCheckpoint });
|
|
1114
|
+
const rawCuration = await providerExtractAssetCurationLocal(provider, sourceText, script);
|
|
1115
|
+
const curation = curateScriptAssets(script, rawCuration);
|
|
1116
|
+
writeJson(path.join(dd, "asset_curation.json"), curation);
|
|
1117
|
+
}
|
|
1118
|
+
catch (exc) {
|
|
1119
|
+
if (exc instanceof CliError) {
|
|
1120
|
+
throw initFailedReport(workspace, {
|
|
1121
|
+
title: exc.title,
|
|
1122
|
+
stage: "asset_curation",
|
|
1123
|
+
exitCode: exc.exitCode,
|
|
1124
|
+
required: exc.required.length > 0 ? exc.required : ["asset curation JSON matching final script contract"],
|
|
1125
|
+
received: exc.received.length > 0 ? exc.received : [String(exc.message).slice(0, 160)],
|
|
1126
|
+
nextSteps: exc.nextSteps.length > 0 ? exc.nextSteps : ["Rerun init; extraction checkpoints will be reused and asset curation will retry."],
|
|
1127
|
+
updates: { checkpoint, batch_checkpoint: batchCheckpoint, episode_completed: results.length },
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
const e = exc;
|
|
1131
|
+
throw initFailedReport(workspace, {
|
|
1132
|
+
title: "INIT FAILED: Asset curation failed",
|
|
1133
|
+
stage: "asset_curation",
|
|
1134
|
+
required: ["provider location merge decisions and deterministic asset reuse curation"],
|
|
1135
|
+
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
1136
|
+
nextSteps: ["Rerun init; extraction checkpoints will be reused and asset curation will retry."],
|
|
1137
|
+
updates: { checkpoint, batch_checkpoint: batchCheckpoint, episode_completed: results.length },
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
updateRunState(workspace, { status: "init_running", init_stage: "metadata_extract", checkpoint, batch_checkpoint: batchCheckpoint });
|
|
1142
|
+
let metadata = provider.extractMetadata ? await provider.extractMetadata(sourceText, script) : {};
|
|
1143
|
+
if (!isDict(metadata))
|
|
1144
|
+
metadata = {};
|
|
1145
|
+
writeJson(path.join(dd, "asset_metadata.json"), metadata);
|
|
1146
|
+
applyMetadataToScript(script, metadata);
|
|
1147
|
+
}
|
|
1148
|
+
catch (exc) {
|
|
1149
|
+
if (exc instanceof CliError) {
|
|
1150
|
+
throw initFailedReport(workspace, {
|
|
1151
|
+
title: exc.title,
|
|
1152
|
+
stage: "metadata_extract",
|
|
1153
|
+
exitCode: exc.exitCode,
|
|
1154
|
+
required: exc.required.length > 0 ? exc.required : ["metadata JSON matching final script contract"],
|
|
1155
|
+
received: exc.received.length > 0 ? exc.received : [String(exc.message).slice(0, 160)],
|
|
1156
|
+
nextSteps: exc.nextSteps.length > 0 ? exc.nextSteps : ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
|
|
1157
|
+
updates: { checkpoint, batch_checkpoint: batchCheckpoint, episode_completed: results.length },
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
const e = exc;
|
|
1161
|
+
throw initFailedReport(workspace, {
|
|
1162
|
+
title: "INIT FAILED: Metadata extraction failed",
|
|
1163
|
+
stage: "metadata_extract",
|
|
1164
|
+
required: ["provider metadata for worldview, role_type, and asset descriptions"],
|
|
1165
|
+
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
1166
|
+
nextSteps: ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
|
|
1167
|
+
updates: { checkpoint, batch_checkpoint: batchCheckpoint, episode_completed: results.length },
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
const scriptPath = path.join(dd, "script.initial.json");
|
|
1171
|
+
writeJson(scriptPath, script);
|
|
1172
|
+
updateRunState(workspace, { status: "init_running", init_stage: "validate", checkpoint, batch_checkpoint: batchCheckpoint });
|
|
1173
|
+
let validation;
|
|
1174
|
+
try {
|
|
1175
|
+
validation = validateScript(workspace, scriptPath);
|
|
1176
|
+
}
|
|
1177
|
+
catch (exc) {
|
|
1178
|
+
const e = exc;
|
|
1179
|
+
throw initFailedReport(workspace, {
|
|
1180
|
+
title: "INIT FAILED: Validation failed",
|
|
1181
|
+
stage: "validate",
|
|
1182
|
+
required: ["script.initial.json that can be validated"],
|
|
1183
|
+
received: [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`],
|
|
1184
|
+
nextSteps: ["Rerun init to retry validation, or inspect script.initial.json if the failure persists."],
|
|
1185
|
+
updates: { checkpoint, script_path: scriptPath },
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
const passed = Boolean(validation["passed"]);
|
|
1189
|
+
const status = passed ? "ready_for_agent" : "needs_agent_repair";
|
|
1190
|
+
updateRunState(workspace, {
|
|
1191
|
+
status,
|
|
1192
|
+
command: "direct init",
|
|
1193
|
+
init_stage: "complete",
|
|
1194
|
+
checkpoint,
|
|
1195
|
+
batch_checkpoint: batchCheckpoint,
|
|
1196
|
+
checkpoint_reused: checkpointReused,
|
|
1197
|
+
batch_checkpoint_reused: batchCheckpointReused,
|
|
1198
|
+
provider: providerName,
|
|
1199
|
+
model,
|
|
1200
|
+
concurrency,
|
|
1201
|
+
batch_mode: batchMode,
|
|
1202
|
+
batch_target_lines: batchTargetLines,
|
|
1203
|
+
batch_max_chars: batchMaxChars,
|
|
1204
|
+
batch_min_lines: batchMinLines,
|
|
1205
|
+
source_path: path.resolve(source),
|
|
1206
|
+
script_path: scriptPath,
|
|
1207
|
+
validation_path: path.join(dd, "validation.json"),
|
|
1208
|
+
episode_total: asList(plan["episodes"]).length,
|
|
1209
|
+
episode_completed: results.length,
|
|
1210
|
+
episode_reused: skipped.length,
|
|
1211
|
+
episode_failed: 0,
|
|
1212
|
+
failed_episodes: [],
|
|
1213
|
+
batch_total: asList(batchPlan["batches"]).length,
|
|
1214
|
+
batch_completed: completedBatches,
|
|
1215
|
+
batch_reused: skippedEpisodeBatchCount + skippedBatches.length,
|
|
1216
|
+
batch_failed: 0,
|
|
1217
|
+
failed_batches: [],
|
|
1218
|
+
failure_signature: [],
|
|
1219
|
+
failure_streak: 0,
|
|
1220
|
+
last_error: null,
|
|
1221
|
+
review_status: "pending",
|
|
1222
|
+
review_missing: [...REVIEW_TARGETS],
|
|
1223
|
+
inspected_targets: [],
|
|
1224
|
+
patch_count: 0,
|
|
1225
|
+
exportable: providerName !== "mock",
|
|
1226
|
+
});
|
|
1227
|
+
const title = passed
|
|
1228
|
+
? "INIT COMPLETE: Initial script ready"
|
|
1229
|
+
: "INIT NEEDS AGENT: Initial script written with repair issues";
|
|
1230
|
+
const stats = validation["stats"] ?? {};
|
|
1231
|
+
const report = {
|
|
1232
|
+
title,
|
|
1233
|
+
result: [
|
|
1234
|
+
`episodes: ${stats["episodes"] ?? 0}`,
|
|
1235
|
+
`scenes: ${stats["scenes"] ?? 0}`,
|
|
1236
|
+
`actions: ${stats["actions"] ?? 0}`,
|
|
1237
|
+
`validation: ${passed ? "passed" : "needs repair"}`,
|
|
1238
|
+
`provider: ${providerName}`,
|
|
1239
|
+
`episode checkpoint reused: ${skipped.length}`,
|
|
1240
|
+
`batches: ${completedBatches}/${asList(batchPlan["batches"]).length} completed`,
|
|
1241
|
+
`batch checkpoint reused: ${skippedEpisodeBatchCount + skippedBatches.length}`,
|
|
1242
|
+
"agent_review: pending",
|
|
1243
|
+
],
|
|
1244
|
+
artifacts: [
|
|
1245
|
+
path.join(workspace, "source.txt"),
|
|
1246
|
+
path.join(dd, "source_manifest.json"),
|
|
1247
|
+
path.join(dd, "episode_plan.json"),
|
|
1248
|
+
path.join(dd, "batch_plan.json"),
|
|
1249
|
+
batchResultsDir,
|
|
1250
|
+
episodeResultsDir,
|
|
1251
|
+
path.join(dd, "asset_curation.json"),
|
|
1252
|
+
path.join(dd, "asset_metadata.json"),
|
|
1253
|
+
scriptPath,
|
|
1254
|
+
path.join(dd, "validation.json"),
|
|
1255
|
+
path.join(dd, "run_state.json"),
|
|
1256
|
+
],
|
|
1257
|
+
issues: summarizeIssues(asList(validation["issues"])),
|
|
1258
|
+
next: providerName === "mock"
|
|
1259
|
+
? [
|
|
1260
|
+
"Run inspect for issue, episode, and asset; apply patches if needed; then validate/export.",
|
|
1261
|
+
"Do not export mock-provider results for delivery.",
|
|
1262
|
+
]
|
|
1263
|
+
: ["Run inspect for issue, episode, and asset; apply patches if needed; then validate/export."],
|
|
1264
|
+
};
|
|
1265
|
+
return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
|
|
1266
|
+
}
|
|
1267
|
+
export function summarizeIssues(issues) {
|
|
1268
|
+
if (issues.length === 0)
|
|
1269
|
+
return [];
|
|
1270
|
+
const counts = {};
|
|
1271
|
+
for (const item of issues) {
|
|
1272
|
+
const sev = strOf(item["severity"]);
|
|
1273
|
+
counts[sev] = (counts[sev] ?? 0) + 1;
|
|
1274
|
+
}
|
|
1275
|
+
const parts = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)).map(([sev, c]) => `${sev}: ${c}`);
|
|
1276
|
+
const first = issues[0];
|
|
1277
|
+
return [parts.join("; "), `first: ${first["code"]} - ${first["summary"]}`];
|
|
1278
|
+
}
|
|
1279
|
+
// ---------------------------------------------------------------------------
|
|
1280
|
+
// command_validate
|
|
1281
|
+
// ---------------------------------------------------------------------------
|
|
1282
|
+
export function commandValidate(opts) {
|
|
1283
|
+
const workspace = strOf(opts["workspace_path"] || "workspace");
|
|
1284
|
+
const scriptPath = opts["script_path"] ? strOf(opts["script_path"]) : null;
|
|
1285
|
+
const validation = validateScript(workspace, scriptPath);
|
|
1286
|
+
const stats = validation["stats"] ?? {};
|
|
1287
|
+
const passed = Boolean(validation["passed"]);
|
|
1288
|
+
const report = {
|
|
1289
|
+
title: passed ? "VALIDATE PASSED: Script is ready" : "VALIDATE NEEDS AGENT: Repair issues found",
|
|
1290
|
+
result: [
|
|
1291
|
+
`episodes: ${stats["episodes"] ?? 0}`,
|
|
1292
|
+
`scenes: ${stats["scenes"] ?? 0}`,
|
|
1293
|
+
`actions: ${stats["actions"] ?? 0}`,
|
|
1294
|
+
],
|
|
1295
|
+
artifacts: [path.join(directDir(workspace), "validation.json")],
|
|
1296
|
+
issues: summarizeIssues(asList(validation["issues"])),
|
|
1297
|
+
next: [passed ? "Export script.json." : "Inspect issues and apply structured patches."],
|
|
1298
|
+
};
|
|
1299
|
+
return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
|
|
1300
|
+
}
|
|
1301
|
+
// ---------------------------------------------------------------------------
|
|
1302
|
+
// command_inspect (incl. review rendering)
|
|
1303
|
+
// ---------------------------------------------------------------------------
|
|
1304
|
+
function renderReviewEpisode(sourceText, episodePlan, script, episodeNum) {
|
|
1305
|
+
const epId = fmtId("ep", episodeNum);
|
|
1306
|
+
const scriptEp = asList(script["episodes"]).find((ep) => ep["episode_id"] === epId);
|
|
1307
|
+
const planEp = asList(episodePlan["episodes"]).find((ep) => Number(ep["episode"] ?? 0) === episodeNum);
|
|
1308
|
+
const lines = [];
|
|
1309
|
+
if (!planEp && !scriptEp) {
|
|
1310
|
+
lines.push(`⚠ Episode ${episodeNum} not found in episode_plan.json or script.initial.json.`);
|
|
1311
|
+
const available = [...new Set(asList(episodePlan["episodes"]).map((ep) => Number(ep["episode"] ?? 0)))].sort((a, b) => a - b);
|
|
1312
|
+
if (available.length > 0)
|
|
1313
|
+
lines.push(`Available episodes: ${available.join(", ")}`);
|
|
1314
|
+
return lines;
|
|
1315
|
+
}
|
|
1316
|
+
const title = (scriptEp?.["title"] ?? planEp?.["title"]) || "(无标题)";
|
|
1317
|
+
lines.push("=".repeat(72));
|
|
1318
|
+
lines.push(`EPISODE ${episodeNum} / ${epId} — ${title}`);
|
|
1319
|
+
lines.push("=".repeat(72));
|
|
1320
|
+
lines.push("");
|
|
1321
|
+
lines.push("--- 原文 source.txt ---");
|
|
1322
|
+
if (planEp) {
|
|
1323
|
+
const span = isDict(planEp["source_span"]) ? planEp["source_span"] : {};
|
|
1324
|
+
const start = Number(span["start"] ?? 0);
|
|
1325
|
+
const end = Number(span["end"] ?? sourceText.length);
|
|
1326
|
+
const snippet = sourceText.slice(start, end).replace(/\s+$/, "");
|
|
1327
|
+
lines.push(snippet || "(empty)");
|
|
1328
|
+
}
|
|
1329
|
+
else {
|
|
1330
|
+
lines.push("(no episode_plan entry; source span unavailable)");
|
|
1331
|
+
}
|
|
1332
|
+
lines.push("");
|
|
1333
|
+
lines.push("--- 抽取 script.initial.json ---");
|
|
1334
|
+
if (scriptEp) {
|
|
1335
|
+
const actorIdToName = new Map();
|
|
1336
|
+
for (const a of asList(script["actors"]))
|
|
1337
|
+
actorIdToName.set(strOf(a["actor_id"]), strOf(a["actor_name"]));
|
|
1338
|
+
const speakerIdToName = new Map();
|
|
1339
|
+
for (const s of asList(script["speakers"]))
|
|
1340
|
+
speakerIdToName.set(strOf(s["speaker_id"]), strOf(s["display_name"]));
|
|
1341
|
+
const locationIdToName = new Map();
|
|
1342
|
+
for (const l of asList(script["locations"]))
|
|
1343
|
+
locationIdToName.set(strOf(l["location_id"]), strOf(l["location_name"]));
|
|
1344
|
+
const propIdToName = new Map();
|
|
1345
|
+
for (const p of asList(script["props"]))
|
|
1346
|
+
propIdToName.set(strOf(p["prop_id"]), strOf(p["prop_name"]));
|
|
1347
|
+
for (const scene of asList(scriptEp["scenes"])) {
|
|
1348
|
+
const env = isDict(scene["environment"]) ? scene["environment"] : {};
|
|
1349
|
+
const space = strOf(env["space"]) || "?";
|
|
1350
|
+
const timeOfDay = strOf(env["time"]) || "?";
|
|
1351
|
+
const sceneId = strOf(scene["scene_id"]) || "?";
|
|
1352
|
+
let locName = "";
|
|
1353
|
+
for (const ref of asList(scene["locations"])) {
|
|
1354
|
+
locName = locationIdToName.get(strOf(ref["location_id"])) || locName;
|
|
1355
|
+
break;
|
|
1356
|
+
}
|
|
1357
|
+
const actorNames = [];
|
|
1358
|
+
for (const ref of asList(scene["actors"])) {
|
|
1359
|
+
const n = actorIdToName.get(strOf(ref["actor_id"]));
|
|
1360
|
+
if (n)
|
|
1361
|
+
actorNames.push(n);
|
|
1362
|
+
}
|
|
1363
|
+
const propNames = [];
|
|
1364
|
+
for (const ref of asList(scene["props"])) {
|
|
1365
|
+
const n = propIdToName.get(strOf(ref["prop_id"]));
|
|
1366
|
+
if (n)
|
|
1367
|
+
propNames.push(n);
|
|
1368
|
+
}
|
|
1369
|
+
lines.push("");
|
|
1370
|
+
lines.push(`Scene ${sceneId} [${space} ${timeOfDay}] ${locName || "(未知场景)"}`);
|
|
1371
|
+
if (actorNames.length > 0)
|
|
1372
|
+
lines.push(` Actors: ${actorNames.join(", ")}`);
|
|
1373
|
+
if (propNames.length > 0)
|
|
1374
|
+
lines.push(` Props: ${propNames.join(", ")}`);
|
|
1375
|
+
for (const action of asList(scene["actions"])) {
|
|
1376
|
+
const kind = strOf(action["type"]);
|
|
1377
|
+
const content = strOf(action["content"]).replace(/\n/g, "\n ");
|
|
1378
|
+
let tag;
|
|
1379
|
+
if (kind === "dialogue") {
|
|
1380
|
+
const speaker = actorIdToName.get(strOf(action["actor_id"])) || speakerIdToName.get(strOf(action["speaker_id"])) || strOf(action["speaker"]) || "?";
|
|
1381
|
+
const emotion = strOf(action["emotion"]);
|
|
1382
|
+
tag = `dlg|${speaker}` + (emotion ? `|${emotion}` : "");
|
|
1383
|
+
}
|
|
1384
|
+
else if (kind === "inner_thought") {
|
|
1385
|
+
const speaker = actorIdToName.get(strOf(action["actor_id"])) || speakerIdToName.get(strOf(action["speaker_id"])) || strOf(action["speaker"]) || "?";
|
|
1386
|
+
const emotion = strOf(action["emotion"]);
|
|
1387
|
+
tag = `think|${speaker}` + (emotion ? `|${emotion}` : "");
|
|
1388
|
+
}
|
|
1389
|
+
else {
|
|
1390
|
+
tag = "act";
|
|
1391
|
+
}
|
|
1392
|
+
lines.push(` [${tag}] ${content}`);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
else {
|
|
1397
|
+
lines.push("(script.initial.json contains no entry for this episode)");
|
|
1398
|
+
}
|
|
1399
|
+
if (scriptEp) {
|
|
1400
|
+
const actorIdsInEp = new Set();
|
|
1401
|
+
const locationIdsInEp = new Set();
|
|
1402
|
+
const propIdsInEp = new Set();
|
|
1403
|
+
for (const scene of asList(scriptEp["scenes"])) {
|
|
1404
|
+
for (const ref of asList(scene["actors"])) {
|
|
1405
|
+
if (ref["actor_id"])
|
|
1406
|
+
actorIdsInEp.add(strOf(ref["actor_id"]));
|
|
1407
|
+
}
|
|
1408
|
+
for (const ref of asList(scene["locations"])) {
|
|
1409
|
+
if (ref["location_id"])
|
|
1410
|
+
locationIdsInEp.add(strOf(ref["location_id"]));
|
|
1411
|
+
}
|
|
1412
|
+
for (const ref of asList(scene["props"])) {
|
|
1413
|
+
if (ref["prop_id"])
|
|
1414
|
+
propIdsInEp.add(strOf(ref["prop_id"]));
|
|
1415
|
+
}
|
|
1416
|
+
for (const action of asList(scene["actions"])) {
|
|
1417
|
+
if (action["actor_id"])
|
|
1418
|
+
actorIdsInEp.add(strOf(action["actor_id"]));
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
if (actorIdsInEp.size > 0 || locationIdsInEp.size > 0 || propIdsInEp.size > 0) {
|
|
1422
|
+
lines.push("");
|
|
1423
|
+
lines.push(`--- Episode ${episodeNum} 资产 ---`);
|
|
1424
|
+
}
|
|
1425
|
+
if (actorIdsInEp.size > 0) {
|
|
1426
|
+
lines.push("Actors:");
|
|
1427
|
+
for (const actor of asList(script["actors"])) {
|
|
1428
|
+
if (!actorIdsInEp.has(strOf(actor["actor_id"])))
|
|
1429
|
+
continue;
|
|
1430
|
+
const desc = strOf(actor["description"]).trim() || "(无描述)";
|
|
1431
|
+
const role = strOf(actor["role_type"]) || "?";
|
|
1432
|
+
lines.push(` - ${actor["actor_id"]} ${actor["actor_name"]} [${role}] — ${desc}`);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
if (locationIdsInEp.size > 0) {
|
|
1436
|
+
lines.push("Locations:");
|
|
1437
|
+
for (const loc of asList(script["locations"])) {
|
|
1438
|
+
if (!locationIdsInEp.has(strOf(loc["location_id"])))
|
|
1439
|
+
continue;
|
|
1440
|
+
const desc = strOf(loc["description"]).trim() || "(无描述)";
|
|
1441
|
+
lines.push(` - ${loc["location_id"]} ${loc["location_name"]} — ${desc}`);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
if (propIdsInEp.size > 0) {
|
|
1445
|
+
lines.push("Props:");
|
|
1446
|
+
for (const prop of asList(script["props"])) {
|
|
1447
|
+
if (!propIdsInEp.has(strOf(prop["prop_id"])))
|
|
1448
|
+
continue;
|
|
1449
|
+
const desc = strOf(prop["description"]).trim() || "(无描述)";
|
|
1450
|
+
lines.push(` - ${prop["prop_id"]} ${prop["prop_name"]} — ${desc}`);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
return lines;
|
|
1455
|
+
}
|
|
1456
|
+
function renderAssetCurationSummary(curation) {
|
|
1457
|
+
const summary = isDict(curation["summary"]) ? curation["summary"] : {};
|
|
1458
|
+
const lines = [
|
|
1459
|
+
`curation: ` +
|
|
1460
|
+
`actors ${summary["actors_before"] ?? 0}->${summary["actors_after"] ?? 0} ` +
|
|
1461
|
+
`(removed ${summary["actors_removed"] ?? 0}), ` +
|
|
1462
|
+
`props ${summary["props_before"] ?? 0}->${summary["props_after"] ?? 0} ` +
|
|
1463
|
+
`(removed ${summary["props_removed"] ?? 0}), ` +
|
|
1464
|
+
`locations ${summary["locations_before"] ?? 0}->${summary["locations_after"] ?? 0} ` +
|
|
1465
|
+
`(merged ${summary["locations_merged"] ?? 0})`,
|
|
1466
|
+
];
|
|
1467
|
+
for (const actor of asList(curation["actors"])) {
|
|
1468
|
+
if (!isDict(actor) || actor["decision"] !== "remove")
|
|
1469
|
+
continue;
|
|
1470
|
+
lines.push(`curation actor ${actor["actor_id"]}: remove ${actor["name"] || "-"} (scenes=${actor["scene_count"] ?? 0}) — ${actor["reason"] || "-"}`);
|
|
1471
|
+
}
|
|
1472
|
+
for (const prop of asList(curation["props"])) {
|
|
1473
|
+
if (!isDict(prop) || prop["decision"] !== "remove")
|
|
1474
|
+
continue;
|
|
1475
|
+
lines.push(`curation prop ${prop["prop_id"]}: remove ${prop["name"] || "-"} (scenes=${prop["scene_count"] ?? 0}) — ${prop["reason"] || "-"}`);
|
|
1476
|
+
}
|
|
1477
|
+
for (const loc of asList(curation["locations"])) {
|
|
1478
|
+
if (!isDict(loc) || loc["decision"] !== "merge")
|
|
1479
|
+
continue;
|
|
1480
|
+
lines.push(`curation location ${loc["location_id"]}: merge ${loc["name"] || "-"} -> ${loc["target_location_id"] || "-"} — ${loc["reason"] || "-"}`);
|
|
1481
|
+
}
|
|
1482
|
+
return lines;
|
|
1483
|
+
}
|
|
1484
|
+
export function commandInspect(opts) {
|
|
1485
|
+
const workspace = strOf(opts["workspace_path"] || "workspace");
|
|
1486
|
+
const target = strOf(opts["target"]);
|
|
1487
|
+
const itemId = opts["id"] ? strOf(opts["id"]) : null;
|
|
1488
|
+
const dd = directDir(workspace);
|
|
1489
|
+
const scriptPath = path.join(dd, "script.initial.json");
|
|
1490
|
+
const validationPath = path.join(dd, "validation.json");
|
|
1491
|
+
if (!exists(scriptPath) && target === "asset") {
|
|
1492
|
+
throw new CliError("INSPECT BLOCKED: script.initial.json not found", "script.initial.json not found.", {
|
|
1493
|
+
exitCode: EXIT_INPUT,
|
|
1494
|
+
required: ["workspace/draft/scriptctl/direct/script.initial.json"],
|
|
1495
|
+
received: [scriptPath],
|
|
1496
|
+
nextSteps: ["Run scriptctl direct init first."],
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
const script = exists(scriptPath)
|
|
1500
|
+
? readJson(scriptPath)
|
|
1501
|
+
: { episodes: [], actors: [], locations: [], props: [] };
|
|
1502
|
+
let validation = exists(validationPath) ? readJson(validationPath) : { issues: [] };
|
|
1503
|
+
if (target === "issue" && exists(scriptPath)) {
|
|
1504
|
+
validation = validateScript(workspace, scriptPath, { requireSource: false });
|
|
1505
|
+
}
|
|
1506
|
+
const batchPlanPath = path.join(dd, "batch_plan.json");
|
|
1507
|
+
const batchPlan = exists(batchPlanPath) ? readJson(batchPlanPath) : { batches: [] };
|
|
1508
|
+
const batchCounts = new Map();
|
|
1509
|
+
for (const batch of asList(batchPlan["batches"])) {
|
|
1510
|
+
if (!isDict(batch))
|
|
1511
|
+
continue;
|
|
1512
|
+
const ep = Number(batch["episode"] ?? 0);
|
|
1513
|
+
batchCounts.set(ep, (batchCounts.get(ep) ?? 0) + 1);
|
|
1514
|
+
}
|
|
1515
|
+
const failedByEpisode = new Map();
|
|
1516
|
+
const batchResultsDir = path.join(dd, "batch_results");
|
|
1517
|
+
if (exists(batchResultsDir)) {
|
|
1518
|
+
const files = fs.readdirSync(batchResultsDir).filter((n) => n.endsWith(".error.json")).sort();
|
|
1519
|
+
for (const name of files) {
|
|
1520
|
+
const errorFile = path.join(batchResultsDir, name);
|
|
1521
|
+
try {
|
|
1522
|
+
const error = readJson(errorFile);
|
|
1523
|
+
const episodeNum = Number(error["episode"] ?? 0);
|
|
1524
|
+
if (!failedByEpisode.has(episodeNum))
|
|
1525
|
+
failedByEpisode.set(episodeNum, []);
|
|
1526
|
+
failedByEpisode.get(episodeNum).push(strOf(error["batch_id"]) || name.replace(/\.error\.json$/, ""));
|
|
1527
|
+
}
|
|
1528
|
+
catch {
|
|
1529
|
+
// ignore
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
const lines = [];
|
|
1534
|
+
if (target === "episode") {
|
|
1535
|
+
if (exists(scriptPath)) {
|
|
1536
|
+
for (const ep of asList(script["episodes"])) {
|
|
1537
|
+
if (itemId && itemId !== strOf(ep["episode_id"]))
|
|
1538
|
+
continue;
|
|
1539
|
+
const scenes = asList(ep["scenes"]);
|
|
1540
|
+
const sceneCount = scenes.length;
|
|
1541
|
+
let actionCount = 0;
|
|
1542
|
+
for (const scene of scenes)
|
|
1543
|
+
actionCount += asList(scene["actions"]).length;
|
|
1544
|
+
const rawNum = normalizeInt(strOf(ep["episode_id"]), lines.length + 1);
|
|
1545
|
+
const failed = (failedByEpisode.get(rawNum) ?? []).join(",") || "-";
|
|
1546
|
+
lines.push(`${ep["episode_id"]}: scenes=${sceneCount}, actions=${actionCount}, batches=${batchCounts.get(rawNum) ?? 0}, failed_batches=${failed}, title=${ep["title"] || "-"}`);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
else {
|
|
1550
|
+
const planPath = path.join(dd, "episode_plan.json");
|
|
1551
|
+
const plan = exists(planPath) ? readJson(planPath) : { episodes: [] };
|
|
1552
|
+
for (const ep of asList(plan["episodes"])) {
|
|
1553
|
+
const epId = fmtId("ep", Number(ep["episode"] ?? 0));
|
|
1554
|
+
if (itemId && itemId !== epId && itemId !== strOf(ep["episode"]))
|
|
1555
|
+
continue;
|
|
1556
|
+
const episodeNum = Number(ep["episode"] ?? 0);
|
|
1557
|
+
const failed = (failedByEpisode.get(episodeNum) ?? []).join(",") || "-";
|
|
1558
|
+
lines.push(`${epId}: batches=${batchCounts.get(episodeNum) ?? 0}, failed_batches=${failed}, title=${ep["title"] || "-"}`);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
else if (target === "asset") {
|
|
1563
|
+
const curationPath = path.join(dd, "asset_curation.json");
|
|
1564
|
+
if (!itemId && exists(curationPath)) {
|
|
1565
|
+
const curation = readJson(curationPath);
|
|
1566
|
+
if (isDict(curation))
|
|
1567
|
+
lines.push(...renderAssetCurationSummary(curation));
|
|
1568
|
+
}
|
|
1569
|
+
for (const [key, idKey, nameKey] of [["actors", "actor_id", "actor_name"], ["locations", "location_id", "location_name"], ["props", "prop_id", "prop_name"]]) {
|
|
1570
|
+
for (const asset of asList(script[key])) {
|
|
1571
|
+
if (itemId && itemId !== strOf(asset[idKey]) && itemId !== strOf(asset[nameKey]))
|
|
1572
|
+
continue;
|
|
1573
|
+
const aliases = asList(asset["aliases"]).length;
|
|
1574
|
+
const states = asList(asset["states"]).length;
|
|
1575
|
+
const role = key === "actors" ? `, role_type=${asset["role_type"]}` : "";
|
|
1576
|
+
const description = strOf(asset["description"]).trim() ? "yes" : "missing";
|
|
1577
|
+
const singular = key.slice(0, -1);
|
|
1578
|
+
lines.push(`${singular} ${asset[idKey]}: ${asset[nameKey]} aliases=${aliases}, states=${states}${role}, description=${description}`);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
else if (target === "issue") {
|
|
1583
|
+
for (const errors of failedByEpisode.values()) {
|
|
1584
|
+
for (const batchId of errors) {
|
|
1585
|
+
const errorPath = path.join(batchResultsDir, `${batchId}.error.json`);
|
|
1586
|
+
if (!exists(errorPath))
|
|
1587
|
+
continue;
|
|
1588
|
+
let error;
|
|
1589
|
+
try {
|
|
1590
|
+
error = readJson(errorPath);
|
|
1591
|
+
}
|
|
1592
|
+
catch {
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
if (itemId && itemId !== strOf(error["batch_id"]) && itemId !== strOf(error["episode"]) && itemId !== strOf(error["error_type"]))
|
|
1596
|
+
continue;
|
|
1597
|
+
lines.push(`error BATCH_FAILED: ${error["batch_id"]} episode ${error["episode"]} part ${error["part"]} - ${error["message"]}`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
for (const issue of asList(validation["issues"])) {
|
|
1601
|
+
if (itemId && itemId !== strOf(issue["code"]) && itemId !== strOf(issue["severity"]))
|
|
1602
|
+
continue;
|
|
1603
|
+
const whereParts = [];
|
|
1604
|
+
for (const k of ["episode", "scene", "action_index"]) {
|
|
1605
|
+
if (issue[k] !== null && issue[k] !== undefined)
|
|
1606
|
+
whereParts.push(strOf(issue[k]));
|
|
1607
|
+
}
|
|
1608
|
+
const where = whereParts.join(" ");
|
|
1609
|
+
lines.push(`${issue["severity"]} ${issue["code"]}: ${issue["summary"]}${where ? ` [${where}]` : ""}`);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
else if (target === "review") {
|
|
1613
|
+
const episodeArg = strOf(opts["episode"]).trim();
|
|
1614
|
+
if (!episodeArg) {
|
|
1615
|
+
throw new CliError("INSPECT BLOCKED: --episode required for review", "--episode required for review.", {
|
|
1616
|
+
exitCode: EXIT_USAGE,
|
|
1617
|
+
required: ["--episode <n>[,<n>...]"],
|
|
1618
|
+
received: ["--episode not provided"],
|
|
1619
|
+
nextSteps: ["Pass --episode 1 (single) or --episode 1,15,30 (multiple)."],
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
let episodeFilters;
|
|
1623
|
+
try {
|
|
1624
|
+
episodeFilters = episodeArg.split(",").map((s) => s.trim()).filter((s) => s).map((s) => {
|
|
1625
|
+
const n = parseInt(s, 10);
|
|
1626
|
+
if (Number.isNaN(n))
|
|
1627
|
+
throw new Error("nan");
|
|
1628
|
+
return n;
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
catch {
|
|
1632
|
+
throw new CliError("INSPECT BLOCKED: --episode must be integers", "--episode must be integers.", {
|
|
1633
|
+
exitCode: EXIT_USAGE,
|
|
1634
|
+
required: ["--episode <n>[,<n>...]"],
|
|
1635
|
+
received: [`--episode ${episodeArg}`],
|
|
1636
|
+
nextSteps: ["Pass episode numbers like --episode 1,15,30."],
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
const sourcePath = path.join(workspace, "source.txt");
|
|
1640
|
+
if (!exists(sourcePath)) {
|
|
1641
|
+
throw new CliError("INSPECT BLOCKED: source.txt not found", "source.txt not found.", {
|
|
1642
|
+
exitCode: EXIT_INPUT,
|
|
1643
|
+
required: [sourcePath],
|
|
1644
|
+
received: ["source.txt missing"],
|
|
1645
|
+
nextSteps: ["Run scriptctl direct init first."],
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
const sourceText = readText(sourcePath);
|
|
1649
|
+
const episodePlanPath = path.join(dd, "episode_plan.json");
|
|
1650
|
+
const episodePlan = exists(episodePlanPath) ? readJson(episodePlanPath) : { episodes: [] };
|
|
1651
|
+
for (let idx = 0; idx < episodeFilters.length; idx++) {
|
|
1652
|
+
if (idx > 0) {
|
|
1653
|
+
lines.push("");
|
|
1654
|
+
lines.push("");
|
|
1655
|
+
}
|
|
1656
|
+
lines.push(...renderReviewEpisode(sourceText, episodePlan, script, episodeFilters[idx]));
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
else {
|
|
1660
|
+
throw new CliError("INSPECT BLOCKED: Invalid target", "Invalid inspect target.", {
|
|
1661
|
+
exitCode: EXIT_USAGE,
|
|
1662
|
+
required: ["--target: episode, asset, issue, or review"],
|
|
1663
|
+
received: [`--target: ${target}`],
|
|
1664
|
+
nextSteps: ["Use one of the supported inspect targets."],
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
const state = addInspectedTarget(workspace, target);
|
|
1668
|
+
const missingReview = reviewBlockers(state);
|
|
1669
|
+
const report = {
|
|
1670
|
+
title: `INSPECT: ${target}`,
|
|
1671
|
+
result: lines.length > 0 ? lines : ["No matching items."],
|
|
1672
|
+
next: [
|
|
1673
|
+
"Use inspect details to prepare a structured patch, or continue required review.",
|
|
1674
|
+
missingReview.length === 0 ? "Review complete; run validate/export." : `Still inspect: ${missingReview.join(", ")}.`,
|
|
1675
|
+
],
|
|
1676
|
+
};
|
|
1677
|
+
return [report, EXIT_OK];
|
|
1678
|
+
}
|
|
1679
|
+
//# sourceMappingURL=direct.js.map
|