@lingjingai/scriptctl 0.35.0 → 0.36.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.
Files changed (367) hide show
  1. package/README.md +21 -5
  2. package/changes/0.36.0.md +22 -0
  3. package/dist/bin.js +0 -0
  4. package/dist/cli.d.ts +2 -1
  5. package/dist/cli.js +52 -128
  6. package/dist/cli.js.map +1 -1
  7. package/dist/common.d.ts +0 -16
  8. package/dist/common.js +2 -342
  9. package/dist/common.js.map +1 -1
  10. package/dist/domain/ingest/apply-plan.d.ts +23 -0
  11. package/dist/domain/ingest/apply-plan.js +210 -0
  12. package/dist/domain/ingest/apply-plan.js.map +1 -0
  13. package/dist/domain/ingest/assembler.d.ts +57 -0
  14. package/dist/domain/ingest/assembler.js +181 -0
  15. package/dist/domain/ingest/assembler.js.map +1 -0
  16. package/dist/domain/ingest/chunker.d.ts +9 -0
  17. package/dist/domain/ingest/chunker.js +38 -0
  18. package/dist/domain/ingest/chunker.js.map +1 -0
  19. package/dist/domain/ingest/evidence.d.ts +5 -0
  20. package/dist/domain/ingest/evidence.js +66 -0
  21. package/dist/domain/ingest/evidence.js.map +1 -0
  22. package/dist/domain/ingest/govern.d.ts +3 -0
  23. package/dist/domain/ingest/govern.js +127 -0
  24. package/dist/domain/ingest/govern.js.map +1 -0
  25. package/dist/domain/ingest/jsonl-parser.d.ts +6 -0
  26. package/dist/domain/ingest/jsonl-parser.js +187 -0
  27. package/dist/domain/ingest/jsonl-parser.js.map +1 -0
  28. package/dist/domain/ingest/markdown-parser.d.ts +6 -0
  29. package/dist/domain/ingest/markdown-parser.js +186 -0
  30. package/dist/domain/ingest/markdown-parser.js.map +1 -0
  31. package/dist/domain/ingest/records.d.ts +71 -0
  32. package/dist/domain/ingest/records.js +9 -0
  33. package/dist/domain/ingest/records.js.map +1 -0
  34. package/dist/domain/ingest/renumber.d.ts +14 -0
  35. package/dist/domain/ingest/renumber.js +130 -0
  36. package/dist/domain/ingest/renumber.js.map +1 -0
  37. package/dist/domain/ingest/script-v3.d.ts +94 -0
  38. package/dist/domain/ingest/script-v3.js +7 -0
  39. package/dist/domain/ingest/script-v3.js.map +1 -0
  40. package/dist/domain/ingest/state-reconcile.d.ts +11 -0
  41. package/dist/domain/ingest/state-reconcile.js +90 -0
  42. package/dist/domain/ingest/state-reconcile.js.map +1 -0
  43. package/dist/domain/ingest/validation.d.ts +11 -0
  44. package/dist/domain/ingest/validation.js +168 -0
  45. package/dist/domain/ingest/validation.js.map +1 -0
  46. package/dist/domain/ingest/video-aggregate.d.ts +23 -0
  47. package/dist/domain/ingest/video-aggregate.js +89 -0
  48. package/dist/domain/ingest/video-aggregate.js.map +1 -0
  49. package/dist/domain/ingest/video-apply.d.ts +37 -0
  50. package/dist/domain/ingest/video-apply.js +236 -0
  51. package/dist/domain/ingest/video-apply.js.map +1 -0
  52. package/dist/domain/ingest/video-md-parser.d.ts +2 -0
  53. package/dist/domain/ingest/video-md-parser.js +233 -0
  54. package/dist/domain/ingest/video-md-parser.js.map +1 -0
  55. package/dist/domain/ingest/video-records.d.ts +56 -0
  56. package/dist/domain/ingest/video-records.js +9 -0
  57. package/dist/domain/ingest/video-records.js.map +1 -0
  58. package/dist/domain/loom/index.d.ts +2 -1
  59. package/dist/domain/loom/index.js +12 -10
  60. package/dist/domain/loom/index.js.map +1 -1
  61. package/dist/domain/loom/types.d.ts +3 -2
  62. package/dist/domain/script/lookups.d.ts +3 -2
  63. package/dist/domain/script/lookups.js +25 -8
  64. package/dist/domain/script/lookups.js.map +1 -1
  65. package/dist/domain/script/patch/apply.js +2 -2
  66. package/dist/domain/script/patch/apply.js.map +1 -1
  67. package/dist/domain/script/patch/helpers.d.ts +3 -7
  68. package/dist/domain/script/patch/helpers.js +23 -96
  69. package/dist/domain/script/patch/helpers.js.map +1 -1
  70. package/dist/domain/script/patch/ops-action.d.ts +0 -2
  71. package/dist/domain/script/patch/ops-action.js +34 -100
  72. package/dist/domain/script/patch/ops-action.js.map +1 -1
  73. package/dist/domain/script/patch/ops-asset.d.ts +1 -0
  74. package/dist/domain/script/patch/ops-asset.js +102 -155
  75. package/dist/domain/script/patch/ops-asset.js.map +1 -1
  76. package/dist/domain/script/patch/ops-dialogue.d.ts +1 -4
  77. package/dist/domain/script/patch/ops-dialogue.js +43 -220
  78. package/dist/domain/script/patch/ops-dialogue.js.map +1 -1
  79. package/dist/domain/script/patch/ops-extend.js +8 -2
  80. package/dist/domain/script/patch/ops-extend.js.map +1 -1
  81. package/dist/domain/script/patch/ops-meta.js.map +1 -1
  82. package/dist/domain/script/patch/ops-scene.js +26 -39
  83. package/dist/domain/script/patch/ops-scene.js.map +1 -1
  84. package/dist/domain/script/patch/ops-state.d.ts +3 -3
  85. package/dist/domain/script/patch/ops-state.js +33 -49
  86. package/dist/domain/script/patch/ops-state.js.map +1 -1
  87. package/dist/domain/script/patch/registry.js +9 -13
  88. package/dist/domain/script/patch/registry.js.map +1 -1
  89. package/dist/domain/script/refs.js +23 -111
  90. package/dist/domain/script/refs.js.map +1 -1
  91. package/dist/domain/script/scene-refs.d.ts +3 -0
  92. package/dist/domain/script/scene-refs.js +35 -0
  93. package/dist/domain/script/scene-refs.js.map +1 -0
  94. package/dist/domain/script/schema.js +17 -21
  95. package/dist/domain/script/schema.js.map +1 -1
  96. package/dist/domain/script/text-util.d.ts +7 -0
  97. package/dist/domain/script/text-util.js +91 -0
  98. package/dist/domain/script/text-util.js.map +1 -0
  99. package/dist/help-text.js +117 -410
  100. package/dist/help-text.js.map +1 -1
  101. package/dist/infra/converters.js +1 -1
  102. package/dist/infra/converters.js.map +1 -1
  103. package/dist/infra/episode-title.d.ts +4 -0
  104. package/dist/infra/episode-title.js +129 -0
  105. package/dist/infra/episode-title.js.map +1 -0
  106. package/dist/infra/ingest/review-renderer.d.ts +1 -0
  107. package/dist/infra/ingest/review-renderer.js +431 -0
  108. package/dist/infra/ingest/review-renderer.js.map +1 -0
  109. package/dist/infra/ingest/source-resolver.d.ts +21 -0
  110. package/dist/infra/ingest/source-resolver.js +51 -0
  111. package/dist/infra/ingest/source-resolver.js.map +1 -0
  112. package/dist/infra/ingest/text-reader.d.ts +3 -0
  113. package/dist/infra/ingest/text-reader.js +27 -0
  114. package/dist/infra/ingest/text-reader.js.map +1 -0
  115. package/dist/infra/ingest/video-discovery.d.ts +8 -0
  116. package/dist/infra/ingest/video-discovery.js +60 -0
  117. package/dist/infra/ingest/video-discovery.js.map +1 -0
  118. package/dist/infra/ingest/workspace-store.d.ts +18 -0
  119. package/dist/infra/ingest/workspace-store.js +70 -0
  120. package/dist/infra/ingest/workspace-store.js.map +1 -0
  121. package/dist/infra/llm/anthropic-model.d.ts +11 -0
  122. package/dist/infra/llm/anthropic-model.js +109 -0
  123. package/dist/infra/llm/anthropic-model.js.map +1 -0
  124. package/dist/infra/llm/gemini-upload.d.ts +9 -0
  125. package/dist/infra/llm/gemini-upload.js +94 -0
  126. package/dist/infra/llm/gemini-upload.js.map +1 -0
  127. package/dist/infra/llm/gemini-video-model.d.ts +47 -0
  128. package/dist/infra/llm/gemini-video-model.js +167 -0
  129. package/dist/infra/llm/gemini-video-model.js.map +1 -0
  130. package/dist/infra/llm/openai-compatible-model.d.ts +13 -0
  131. package/dist/infra/llm/openai-compatible-model.js +143 -0
  132. package/dist/infra/llm/openai-compatible-model.js.map +1 -0
  133. package/dist/infra/llm/proxy.d.ts +1 -0
  134. package/dist/infra/llm/proxy.js +30 -0
  135. package/dist/infra/llm/proxy.js.map +1 -0
  136. package/dist/infra/llm/retry.d.ts +5 -0
  137. package/dist/infra/llm/retry.js +20 -0
  138. package/dist/infra/llm/retry.js.map +1 -0
  139. package/dist/llm/composition.d.ts +5 -0
  140. package/dist/llm/composition.js +5 -0
  141. package/dist/llm/composition.js.map +1 -0
  142. package/dist/llm/config.d.ts +26 -0
  143. package/dist/llm/config.js +201 -0
  144. package/dist/llm/config.js.map +1 -0
  145. package/dist/llm/registry.d.ts +44 -0
  146. package/dist/llm/registry.js +48 -0
  147. package/dist/llm/registry.js.map +1 -0
  148. package/dist/llm/router.d.ts +23 -0
  149. package/dist/llm/router.js +0 -0
  150. package/dist/llm/router.js.map +1 -0
  151. package/dist/llm/tasks/ingest/asset-curate.d.ts +13 -0
  152. package/dist/llm/tasks/ingest/asset-curate.js +45 -0
  153. package/dist/llm/tasks/ingest/asset-curate.js.map +1 -0
  154. package/dist/llm/tasks/ingest/asset-extract.d.ts +12 -0
  155. package/dist/llm/tasks/ingest/asset-extract.js +51 -0
  156. package/dist/llm/tasks/ingest/asset-extract.js.map +1 -0
  157. package/dist/llm/tasks/ingest/asset-govern.d.ts +22 -0
  158. package/dist/llm/tasks/ingest/asset-govern.js +143 -0
  159. package/dist/llm/tasks/ingest/asset-govern.js.map +1 -0
  160. package/dist/llm/tasks/ingest/mention-resolve.d.ts +9 -0
  161. package/dist/llm/tasks/ingest/mention-resolve.js +39 -0
  162. package/dist/llm/tasks/ingest/mention-resolve.js.map +1 -0
  163. package/dist/llm/tasks/ingest/plot-speaker.d.ts +11 -0
  164. package/dist/llm/tasks/ingest/plot-speaker.js +72 -0
  165. package/dist/llm/tasks/ingest/plot-speaker.js.map +1 -0
  166. package/dist/llm/tasks/ingest/roster-merge.d.ts +46 -0
  167. package/dist/llm/tasks/ingest/roster-merge.js +235 -0
  168. package/dist/llm/tasks/ingest/roster-merge.js.map +1 -0
  169. package/dist/llm/tasks/ingest/state-merge.d.ts +10 -0
  170. package/dist/llm/tasks/ingest/state-merge.js +84 -0
  171. package/dist/llm/tasks/ingest/state-merge.js.map +1 -0
  172. package/dist/llm/tasks/ingest/state-reconcile.d.ts +8 -0
  173. package/dist/llm/tasks/ingest/state-reconcile.js +44 -0
  174. package/dist/llm/tasks/ingest/state-reconcile.js.map +1 -0
  175. package/dist/llm/tasks/ingest/text-normalize.d.ts +10 -0
  176. package/dist/llm/tasks/ingest/text-normalize.js +89 -0
  177. package/dist/llm/tasks/ingest/text-normalize.js.map +1 -0
  178. package/dist/llm/tasks/ingest/video-correct.d.ts +30 -0
  179. package/dist/llm/tasks/ingest/video-correct.js +144 -0
  180. package/dist/llm/tasks/ingest/video-correct.js.map +1 -0
  181. package/dist/llm/tasks/ingest/video-transcribe.d.ts +4 -0
  182. package/dist/llm/tasks/ingest/video-transcribe.js +105 -0
  183. package/dist/llm/tasks/ingest/video-transcribe.js.map +1 -0
  184. package/dist/llm/tasks/schemas.d.ts +5 -0
  185. package/dist/llm/tasks/schemas.js +107 -0
  186. package/dist/llm/tasks/schemas.js.map +1 -0
  187. package/dist/llm/types.d.ts +97 -0
  188. package/dist/llm/types.js +13 -0
  189. package/dist/llm/types.js.map +1 -0
  190. package/dist/usecases/doctor.d.ts +1 -1
  191. package/dist/usecases/doctor.js +36 -9
  192. package/dist/usecases/doctor.js.map +1 -1
  193. package/dist/usecases/ingest/command.d.ts +5 -0
  194. package/dist/usecases/ingest/command.js +57 -0
  195. package/dist/usecases/ingest/command.js.map +1 -0
  196. package/dist/usecases/ingest/fanout.d.ts +10 -0
  197. package/dist/usecases/ingest/fanout.js +23 -0
  198. package/dist/usecases/ingest/fanout.js.map +1 -0
  199. package/dist/usecases/ingest/pass4-mention-resolve.d.ts +11 -0
  200. package/dist/usecases/ingest/pass4-mention-resolve.js +52 -0
  201. package/dist/usecases/ingest/pass4-mention-resolve.js.map +1 -0
  202. package/dist/usecases/ingest/pass5-extract.d.ts +7 -0
  203. package/dist/usecases/ingest/pass5-extract.js +50 -0
  204. package/dist/usecases/ingest/pass5-extract.js.map +1 -0
  205. package/dist/usecases/ingest/pass5-govern.d.ts +13 -0
  206. package/dist/usecases/ingest/pass5-govern.js +155 -0
  207. package/dist/usecases/ingest/pass5-govern.js.map +1 -0
  208. package/dist/usecases/ingest/pipeline.d.ts +28 -0
  209. package/dist/usecases/ingest/pipeline.js +144 -0
  210. package/dist/usecases/ingest/pipeline.js.map +1 -0
  211. package/dist/usecases/ingest/publish-command.d.ts +4 -0
  212. package/dist/usecases/ingest/publish-command.js +77 -0
  213. package/dist/usecases/ingest/publish-command.js.map +1 -0
  214. package/dist/usecases/ingest/status.d.ts +39 -0
  215. package/dist/usecases/ingest/status.js +155 -0
  216. package/dist/usecases/ingest/status.js.map +1 -0
  217. package/dist/usecases/ingest/video-pass3.d.ts +26 -0
  218. package/dist/usecases/ingest/video-pass3.js +0 -0
  219. package/dist/usecases/ingest/video-pass3.js.map +1 -0
  220. package/dist/usecases/ingest/video-pipeline.d.ts +32 -0
  221. package/dist/usecases/ingest/video-pipeline.js +202 -0
  222. package/dist/usecases/ingest/video-pipeline.js.map +1 -0
  223. package/dist/usecases/ingest/view-command.d.ts +4 -0
  224. package/dist/usecases/ingest/view-command.js +51 -0
  225. package/dist/usecases/ingest/view-command.js.map +1 -0
  226. package/dist/usecases/script/actions.js +2 -2
  227. package/dist/usecases/script/actions.js.map +1 -1
  228. package/dist/usecases/script/actor.js.map +1 -1
  229. package/dist/usecases/script/actors.js.map +1 -1
  230. package/dist/usecases/script/add-actor.js.map +1 -1
  231. package/dist/usecases/script/add-episode.js.map +1 -1
  232. package/dist/usecases/script/add-location.js.map +1 -1
  233. package/dist/usecases/script/add-prop.js.map +1 -1
  234. package/dist/usecases/script/alias.js.map +1 -1
  235. package/dist/usecases/script/assets.js.map +1 -1
  236. package/dist/usecases/script/context.js.map +1 -1
  237. package/dist/usecases/script/create.js +3 -6
  238. package/dist/usecases/script/create.js.map +1 -1
  239. package/dist/usecases/script/delete.js.map +1 -1
  240. package/dist/usecases/script/describe.js.map +1 -1
  241. package/dist/usecases/script/dialogue.js +6 -13
  242. package/dist/usecases/script/dialogue.js.map +1 -1
  243. package/dist/usecases/script/do.js +1 -1
  244. package/dist/usecases/script/do.js.map +1 -1
  245. package/dist/usecases/script/episodes.js.map +1 -1
  246. package/dist/usecases/script/{add-speaker.d.ts → importance.d.ts} +1 -1
  247. package/dist/usecases/script/importance.js +17 -0
  248. package/dist/usecases/script/importance.js.map +1 -0
  249. package/dist/usecases/script/insert.js.map +1 -1
  250. package/dist/usecases/script/issues.js.map +1 -1
  251. package/dist/usecases/script/lib.d.ts +8 -6
  252. package/dist/usecases/script/lib.js +81 -97
  253. package/dist/usecases/script/lib.js.map +1 -1
  254. package/dist/usecases/script/locations.js.map +1 -1
  255. package/dist/usecases/script/merge.js.map +1 -1
  256. package/dist/usecases/script/move.js.map +1 -1
  257. package/dist/usecases/script/props.js.map +1 -1
  258. package/dist/usecases/script/refs.js.map +1 -1
  259. package/dist/usecases/script/rename.js.map +1 -1
  260. package/dist/usecases/script/replace.js.map +1 -1
  261. package/dist/usecases/script/role.js.map +1 -1
  262. package/dist/usecases/script/scenes.js +8 -15
  263. package/dist/usecases/script/scenes.js.map +1 -1
  264. package/dist/usecases/script/script-patch.js +1 -1
  265. package/dist/usecases/script/script-patch.js.map +1 -1
  266. package/dist/usecases/script/script-validate.js +1 -1
  267. package/dist/usecases/script/script-validate.js.map +1 -1
  268. package/dist/usecases/script/session.d.ts +4 -4
  269. package/dist/usecases/script/session.js +57 -78
  270. package/dist/usecases/script/session.js.map +1 -1
  271. package/dist/usecases/script/split.js.map +1 -1
  272. package/dist/usecases/script/state-add.js.map +1 -1
  273. package/dist/usecases/script/state-delete.js.map +1 -1
  274. package/dist/usecases/script/state-rename.js.map +1 -1
  275. package/dist/usecases/script/summary.js.map +1 -1
  276. package/dist/usecases/script/synopsis-generate.d.ts +4 -5
  277. package/dist/usecases/script/synopsis-generate.js +30 -46
  278. package/dist/usecases/script/synopsis-generate.js.map +1 -1
  279. package/dist/usecases/script/transition.js.map +1 -1
  280. package/dist/usecases/script/type.js.map +1 -1
  281. package/dist/usecases/script/worldview.js.map +1 -1
  282. package/package.json +22 -17
  283. package/skills/scriptctl/SKILL.md +140 -120
  284. package/skills/scriptctl/references/atomic-write-workflow.md +12 -9
  285. package/skills/scriptctl/references/ingest-workflow.md +85 -0
  286. package/skills/scriptctl/references/state-reference-repair.md +26 -39
  287. package/dist/domain/direct/runner.d.ts +0 -25
  288. package/dist/domain/direct/runner.js +0 -88
  289. package/dist/domain/direct/runner.js.map +0 -1
  290. package/dist/domain/direct/stage.d.ts +0 -108
  291. package/dist/domain/direct/stage.js +0 -134
  292. package/dist/domain/direct/stage.js.map +0 -1
  293. package/dist/domain/direct/stages/asset-curation.d.ts +0 -2
  294. package/dist/domain/direct/stages/asset-curation.js +0 -10
  295. package/dist/domain/direct/stages/asset-curation.js.map +0 -1
  296. package/dist/domain/direct/stages/batch-extract.d.ts +0 -2
  297. package/dist/domain/direct/stages/batch-extract.js +0 -12
  298. package/dist/domain/direct/stages/batch-extract.js.map +0 -1
  299. package/dist/domain/direct/stages/batch-plan.d.ts +0 -2
  300. package/dist/domain/direct/stages/batch-plan.js +0 -9
  301. package/dist/domain/direct/stages/batch-plan.js.map +0 -1
  302. package/dist/domain/direct/stages/episode-merge.d.ts +0 -2
  303. package/dist/domain/direct/stages/episode-merge.js +0 -11
  304. package/dist/domain/direct/stages/episode-merge.js.map +0 -1
  305. package/dist/domain/direct/stages/episode-plan.d.ts +0 -2
  306. package/dist/domain/direct/stages/episode-plan.js +0 -9
  307. package/dist/domain/direct/stages/episode-plan.js.map +0 -1
  308. package/dist/domain/direct/stages/episode-synopsis.d.ts +0 -2
  309. package/dist/domain/direct/stages/episode-synopsis.js +0 -12
  310. package/dist/domain/direct/stages/episode-synopsis.js.map +0 -1
  311. package/dist/domain/direct/stages/episode-titles.d.ts +0 -2
  312. package/dist/domain/direct/stages/episode-titles.js +0 -12
  313. package/dist/domain/direct/stages/episode-titles.js.map +0 -1
  314. package/dist/domain/direct/stages/index.d.ts +0 -19
  315. package/dist/domain/direct/stages/index.js +0 -41
  316. package/dist/domain/direct/stages/index.js.map +0 -1
  317. package/dist/domain/direct/stages/metadata.d.ts +0 -2
  318. package/dist/domain/direct/stages/metadata.js +0 -11
  319. package/dist/domain/direct/stages/metadata.js.map +0 -1
  320. package/dist/domain/direct/stages/script-merge.d.ts +0 -2
  321. package/dist/domain/direct/stages/script-merge.js +0 -15
  322. package/dist/domain/direct/stages/script-merge.js.map +0 -1
  323. package/dist/domain/direct/stages/script-synopsis.d.ts +0 -2
  324. package/dist/domain/direct/stages/script-synopsis.js +0 -12
  325. package/dist/domain/direct/stages/script-synopsis.js.map +0 -1
  326. package/dist/domain/direct/stages/source-prepare.d.ts +0 -2
  327. package/dist/domain/direct/stages/source-prepare.js +0 -9
  328. package/dist/domain/direct/stages/source-prepare.js.map +0 -1
  329. package/dist/domain/direct/stages/state-binding.d.ts +0 -2
  330. package/dist/domain/direct/stages/state-binding.js +0 -9
  331. package/dist/domain/direct/stages/state-binding.js.map +0 -1
  332. package/dist/domain/direct/stages/state-curation.d.ts +0 -2
  333. package/dist/domain/direct/stages/state-curation.js +0 -10
  334. package/dist/domain/direct/stages/state-curation.js.map +0 -1
  335. package/dist/domain/direct/stages/validate.d.ts +0 -2
  336. package/dist/domain/direct/stages/validate.js +0 -10
  337. package/dist/domain/direct/stages/validate.js.map +0 -1
  338. package/dist/domain/direct-core.d.ts +0 -313
  339. package/dist/domain/direct-core.js +0 -8514
  340. package/dist/domain/direct-core.js.map +0 -1
  341. package/dist/domain/script/validate.d.ts +0 -22
  342. package/dist/domain/script/validate.js +0 -997
  343. package/dist/domain/script/validate.js.map +0 -1
  344. package/dist/infra/providers.d.ts +0 -97
  345. package/dist/infra/providers.js +0 -1396
  346. package/dist/infra/providers.js.map +0 -1
  347. package/dist/usecases/direct.d.ts +0 -27
  348. package/dist/usecases/direct.js +0 -4172
  349. package/dist/usecases/direct.js.map +0 -1
  350. package/dist/usecases/parse.d.ts +0 -15
  351. package/dist/usecases/parse.js +0 -429
  352. package/dist/usecases/parse.js.map +0 -1
  353. package/dist/usecases/script/add-speaker.js +0 -22
  354. package/dist/usecases/script/add-speaker.js.map +0 -1
  355. package/dist/usecases/script/export.d.ts +0 -3
  356. package/dist/usecases/script/export.js +0 -111
  357. package/dist/usecases/script/export.js.map +0 -1
  358. package/dist/usecases/script/overlap.d.ts +0 -3
  359. package/dist/usecases/script/overlap.js +0 -21
  360. package/dist/usecases/script/overlap.js.map +0 -1
  361. package/dist/usecases/script/speakers.d.ts +0 -3
  362. package/dist/usecases/script/speakers.js +0 -28
  363. package/dist/usecases/script/speakers.js.map +0 -1
  364. package/dist/usecases/script/state-change.d.ts +0 -3
  365. package/dist/usecases/script/state-change.js +0 -24
  366. package/dist/usecases/script/state-change.js.map +0 -1
  367. package/skills/scriptctl/references/direct-workflow.md +0 -72
@@ -1,4172 +0,0 @@
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_DIRECT_CONCURRENCY, DEFAULT_MODEL, DEFAULT_PROVIDER, DEFAULT_SUMMARY_GROUP_SIZE, DEFAULT_SUMMARY_REDUCE_THRESHOLD_CHARS, EXIT_INPUT, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, SUPPORTED_EXTS, deletePath, directDir, exists, fmtId, readJson, readText, sha256Text, writeJson, } from "../common.js";
4
- import { compactBatchResult, compactEpisodeResult, expandCompactEpisodeResult, buildBatchPlan, buildEpisodePlan, DEFAULT_TITLE_CHUNK_SIZE, chunkEpisodesNeedingTitles, applyEpisodeTitlesToPlan, cleanSynopsisList, episodeTitleResponseMap, extractBatchWithRecovery, mergeEpisodeResultsWithReport, normalizeEpisodeResult, planSynopsisReduction, readCarriedSynopsis, sortDeep, uniqueAdd, validateEpisodeExtractionQuality, _md_push_asset, applyAssetGroupingDuplicateMerges, applyStateCurationPlan, applyStateBindingPlan, bindDefaultStateRefsInScript, buildAssetCurationAuditContextText, buildAssetCurationContextText, buildAssetCurationRepairContextText, buildAssetGroupingContextText, buildLocationCanonicalizationAuditContextText, buildPrimaryAssetCurationChunks, buildStateCurationChunks, buildStateCurationContextText, groupedAssetCurationChunks, combineLocationCanonicalizationPlan, buildStateBindingContexts, assetCurationAuditSourceKeys, combineAssetCurationExpressionPlans, combinePrimaryAssetCurationPlans, combineAssetCurationPlans, curateScriptAssets, ensureDefaultStates, assetCurationAuditCandidateSourceKeys, locationCanonicalizationCandidateSourceKeys, missingAssetCurationRequiredSourceKeys, normalizeStateBindingPlanShape, parseStateCurationExpressions, parseAssetCurationExpressions, parseAssetCurationExpressionsTolerant, parseAssetGroupingExpressions, assertLocationCanonicalizationPlan, applyMetadataToScript, validateAssetGroupingPlan, stateCurationSourceKeysForRefs, assertStateCurationPlan, } from "../domain/direct-core.js";
5
- import { validateScript } from "../domain/script/validate.js";
6
- import { makeProvider, providerCapabilities } from "../infra/providers.js";
7
- import { remoteScriptOutputStoreFromEnv } from "../infra/script-output-api.js";
8
- import { syncEpisodeCount } from "../infra/script-output-store.js";
9
- import { makeSourceManifest, prepareSource, } from "../infra/converters.js";
10
- import { Workspace, StageIncomplete, emptyRunState, stageRunStateName, } from "../domain/direct/stage.js";
11
- // Circular import: runner.ts → stages/index.ts → each stage file → THIS module
12
- // (the stageXxx impls live here). It's safe because every reference below
13
- // (runStages / runOne / STAGES) is used only inside function bodies that run at
14
- // CALL time (commandInitImpl, commandRun), never at module-load time — so the
15
- // cycle is fully resolved before any of them executes.
16
- import { runStages, runOne } from "../domain/direct/runner.js";
17
- import { STAGES } from "../domain/direct/stages/index.js";
18
- function strOf(v) {
19
- if (v === null || v === undefined)
20
- return "";
21
- return String(v);
22
- }
23
- function legacyDirectInitStage(stage) {
24
- if (stage === "episode_synopsis")
25
- return "episode_merge";
26
- if (stage === "script_synopsis")
27
- return "summary";
28
- return stage;
29
- }
30
- function runStateDirectStage(state) {
31
- const initStage = strOf(state["init_stage"]);
32
- const directStage = strOf(state["direct_stage"]);
33
- if (directStage && legacyDirectInitStage(directStage) === initStage)
34
- return directStage;
35
- return initStage;
36
- }
37
- function isDict(v) {
38
- return typeof v === "object" && v !== null && !Array.isArray(v);
39
- }
40
- function isList(v) {
41
- return Array.isArray(v);
42
- }
43
- function asList(v) {
44
- return Array.isArray(v) ? v : [];
45
- }
46
- function checkpointTimestamp() {
47
- const now = new Date();
48
- return now.toISOString().replace(/\.\d+Z$/, "Z");
49
- }
50
- function parseIntList(v) {
51
- return strOf(v).split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !Number.isNaN(n));
52
- }
53
- function parseStrList(v) {
54
- return strOf(v).split(",").map((s) => s.trim()).filter((s) => s.length > 0);
55
- }
56
- // Parse a positive-integer CLI option (default when unset) with a uniform USAGE
57
- // error. Shared by --concurrency / --summary-threshold-chars / the batch opts.
58
- function parsePositiveIntOption(raw, flag, title, fallback) {
59
- const value = parseInt(strOf(raw || fallback), 10);
60
- if (Number.isNaN(value) || value < 1) {
61
- throw new CliError(title, `${title}.`, {
62
- exitCode: EXIT_USAGE,
63
- required: [`${flag}: positive integer`],
64
- received: [`${flag}: ${raw}`],
65
- nextSteps: ["Use a positive integer and rerun init."],
66
- });
67
- }
68
- return value;
69
- }
70
- // Status/report line for the synopsis stage: skipped, degraded (with episode
71
- // list when known), or null when the overview is complete. One formatter so
72
- // `direct init`'s report and `direct status` never word it differently.
73
- function formatSummaryStatusLine(skipped, degraded, degradedEpisodes) {
74
- if (skipped)
75
- return "summary: skipped (--skip-summary)";
76
- if (!degraded)
77
- return null;
78
- return degradedEpisodes.length > 0
79
- ? `summary: degraded (episodes: ${degradedEpisodes.join(", ")})`
80
- : "summary: degraded (overview may be partial)";
81
- }
82
- // ---------------------------------------------------------------------------
83
- // run_state.json
84
- // ---------------------------------------------------------------------------
85
- export function updateRunState(workspace, updates) {
86
- const p = path.join(directDir(workspace), "run_state.json");
87
- let state = {};
88
- if (exists(p)) {
89
- try {
90
- state = readJson(p);
91
- }
92
- catch {
93
- state = {};
94
- }
95
- }
96
- const directStage = strOf(updates["direct_stage"]);
97
- const initStage = strOf(updates["init_stage"]);
98
- if (directStage)
99
- updates["init_stage"] = legacyDirectInitStage(directStage);
100
- else if (initStage) {
101
- updates["direct_stage"] = initStage;
102
- updates["init_stage"] = legacyDirectInitStage(initStage);
103
- }
104
- Object.assign(state, updates);
105
- state["updated_at"] = checkpointTimestamp();
106
- writeJson(p, state);
107
- return state;
108
- }
109
- export function readRunState(workspace) {
110
- const p = path.join(directDir(workspace), "run_state.json");
111
- if (!exists(p))
112
- return {};
113
- try {
114
- const state = readJson(p);
115
- return isDict(state) ? state : {};
116
- }
117
- catch {
118
- return {};
119
- }
120
- }
121
- function isoFromMs(ms) {
122
- return new Date(ms).toISOString();
123
- }
124
- function durationMs(startedAtMs, finishedAtMs) {
125
- return Math.max(0, Math.round(finishedAtMs - startedAtMs));
126
- }
127
- function timingErrorSummary(error) {
128
- const e = error;
129
- return `${e?.name ?? "Error"}: ${e?.message ?? ""}`.slice(0, 240);
130
- }
131
- function timingSummary(events) {
132
- const stages = {};
133
- const branches = {};
134
- const slowChunks = events
135
- .filter((event) => event.category === "chunk")
136
- .sort((left, right) => right.duration_ms - left.duration_ms)
137
- .slice(0, 20)
138
- .map((event) => ({
139
- name: event.name,
140
- parent: event.parent ?? "",
141
- duration_ms: event.duration_ms,
142
- status: event.status,
143
- }));
144
- for (const event of events) {
145
- if (event.category === "stage")
146
- stages[event.name] = event.duration_ms;
147
- if (event.category === "branch")
148
- branches[event.name] = event.duration_ms;
149
- }
150
- return { stages, branches, slow_chunks: slowChunks };
151
- }
152
- class JsonTimingRecorder {
153
- ws;
154
- workspace;
155
- command = "";
156
- events = [];
157
- activeSpans = [];
158
- counter = 0;
159
- constructor(ws, workspace) {
160
- this.ws = ws;
161
- this.workspace = workspace;
162
- }
163
- reset(command) {
164
- this.command = command;
165
- this.events = [];
166
- this.activeSpans = [];
167
- this.counter = 0;
168
- this.flush();
169
- updateRunState(this.workspace, {
170
- timing_current: null,
171
- timing_current_stage: null,
172
- timing_current_branch: null,
173
- timing_summary: { stages: {}, branches: {}, slow_chunks: [] },
174
- });
175
- }
176
- async span(options, run) {
177
- const startedAtMs = Date.now();
178
- const active = {
179
- name: options.name,
180
- category: options.category,
181
- ...(options.parent ? { parent: options.parent } : {}),
182
- started_at: isoFromMs(startedAtMs),
183
- };
184
- this.activeSpans.push(active);
185
- this.updateTimingCurrent();
186
- try {
187
- const value = await run();
188
- this.removeActiveSpan(active);
189
- this.record({
190
- ...options,
191
- status: "success",
192
- startedAtMs,
193
- finishedAtMs: Date.now(),
194
- });
195
- return value;
196
- }
197
- catch (error) {
198
- this.removeActiveSpan(active);
199
- this.record({
200
- ...options,
201
- status: "error",
202
- startedAtMs,
203
- finishedAtMs: Date.now(),
204
- error: timingErrorSummary(error),
205
- });
206
- throw error;
207
- }
208
- }
209
- removeActiveSpan(active) {
210
- const index = this.activeSpans.lastIndexOf(active);
211
- if (index >= 0)
212
- this.activeSpans.splice(index, 1);
213
- }
214
- activeSpanByCategory(category) {
215
- for (let index = this.activeSpans.length - 1; index >= 0; index -= 1) {
216
- const span = this.activeSpans[index];
217
- if (span.category === category)
218
- return span;
219
- }
220
- return null;
221
- }
222
- currentSpanPayload(span) {
223
- if (!span)
224
- return null;
225
- return {
226
- name: span.name,
227
- category: span.category,
228
- parent: span.parent ?? null,
229
- started_at: span.started_at,
230
- };
231
- }
232
- updateTimingCurrent() {
233
- updateRunState(this.workspace, {
234
- timing_current: this.currentSpanPayload(this.activeSpans[this.activeSpans.length - 1] ?? null),
235
- timing_current_stage: this.currentSpanPayload(this.activeSpanByCategory("stage")),
236
- timing_current_branch: this.currentSpanPayload(this.activeSpanByCategory("branch")),
237
- timing_summary: timingSummary(this.events),
238
- });
239
- }
240
- record(options) {
241
- const event = {
242
- id: `tim_${String(++this.counter).padStart(5, "0")}`,
243
- name: options.name,
244
- category: options.category,
245
- ...(options.parent ? { parent: options.parent } : {}),
246
- status: options.status,
247
- started_at: isoFromMs(options.startedAtMs),
248
- finished_at: isoFromMs(options.finishedAtMs),
249
- duration_ms: durationMs(options.startedAtMs, options.finishedAtMs),
250
- ...(options.metadata ? { metadata: options.metadata } : {}),
251
- ...(options.error ? { error: options.error } : {}),
252
- };
253
- this.events.push(event);
254
- this.flush();
255
- this.updateTimingCurrent();
256
- }
257
- flush() {
258
- this.ws.writeJson("timings.json", {
259
- version: 1,
260
- command: this.command,
261
- updated_at: checkpointTimestamp(),
262
- events: this.events,
263
- summary: timingSummary(this.events),
264
- });
265
- }
266
- }
267
- // ---------------------------------------------------------------------------
268
- // Paths for episode/batch results
269
- // ---------------------------------------------------------------------------
270
- function pad3(n) {
271
- return String(n).padStart(3, "0");
272
- }
273
- function pad4(n) {
274
- return String(n).padStart(4, "0");
275
- }
276
- function episodeResultPath(dir, ep) {
277
- return path.join(dir, `ep_${pad3(Number(ep["episode"]))}.json`);
278
- }
279
- function episodeErrorPath(dir, ep) {
280
- return path.join(dir, `ep_${pad3(Number(ep["episode"]))}.error.json`);
281
- }
282
- function episodeResultKey(ep) {
283
- return `ep_${pad3(Number(ep["episode"]))}`;
284
- }
285
- // Read the synopsis carried on a persisted episode result (compact key `syn`),
286
- // for episode-synopsis reuse. Returns "" when absent/unreadable/legacy (no syn).
287
- function readEpisodeSynopsis(dir, episode) {
288
- const p = episodeResultPath(dir, episode);
289
- if (!exists(p))
290
- return "";
291
- try {
292
- return readCarriedSynopsis(readJson(p));
293
- }
294
- catch {
295
- return "";
296
- }
297
- }
298
- function batchResultKey(batch) {
299
- const bid = strOf(batch["batch_id"]).trim();
300
- if (bid)
301
- return bid;
302
- return `bat_${pad4(Number(batch["batch_index"] ?? 0))}`;
303
- }
304
- function batchResultPath(dir, batch) {
305
- return path.join(dir, `${batchResultKey(batch)}.json`);
306
- }
307
- function batchMarkdownPath(dir, batch) {
308
- return path.join(dir, `${batchResultKey(batch)}.md`);
309
- }
310
- function batchErrorPath(dir, batch) {
311
- return path.join(dir, `${batchResultKey(batch)}.error.json`);
312
- }
313
- function persistBatchResult(dir, batch, result) {
314
- const rawMd = result["_raw_markdown"];
315
- delete result["_raw_markdown"];
316
- writeJson(batchResultPath(dir, batch), compactBatchResult(result));
317
- const mdPath = batchMarkdownPath(dir, batch);
318
- if (typeof rawMd === "string" && rawMd) {
319
- fs.mkdirSync(path.dirname(mdPath), { recursive: true });
320
- fs.writeFileSync(mdPath, rawMd, "utf-8");
321
- }
322
- else if (exists(mdPath)) {
323
- deletePath(mdPath);
324
- }
325
- }
326
- // ---------------------------------------------------------------------------
327
- // Per-batch result files ARE the state. A batch with `<key>.json` is done; with
328
- // `<key>.error.json` it failed; with neither it is pending. Reuse = file
329
- // presence — the agent drives what to (re)run via `direct status` + --episodes/
330
- // --batches selection. No content hashes, no global checkpoint, no destructive
331
- // reset: re-running a unit only ever touches that unit's own files, so the other
332
- // episodes can never be lost.
333
- // ---------------------------------------------------------------------------
334
- // Read a persisted batch result (reused or freshly written) for assembly.
335
- // Returns null if absent or unreadable, in which case the batch counts as
336
- // not-done and will be (re)run.
337
- function readBatchResult(dir, batch) {
338
- const p = batchResultPath(dir, batch);
339
- if (!exists(p))
340
- return null;
341
- try {
342
- const result = normalizeEpisodeResult(readJson(p), batch);
343
- result["_batch_id"] = batchResultKey(batch);
344
- result["_batch_part"] = Number(batch["part"]);
345
- result["_starts_inside_scene"] = Boolean(batch["starts_inside_scene"]);
346
- return result;
347
- }
348
- catch {
349
- return null;
350
- }
351
- }
352
- function isDirectStageName(value) {
353
- switch (value) {
354
- case "source_prepare":
355
- case "episode_plan":
356
- case "batch_plan":
357
- case "episode_titles":
358
- case "batch_extract":
359
- case "episode_merge":
360
- case "episode_synopsis":
361
- case "script_merge":
362
- case "asset_curation":
363
- case "state_curation":
364
- case "state_binding":
365
- case "metadata_extract":
366
- case "script_synopsis":
367
- case "validate":
368
- return true;
369
- default:
370
- return false;
371
- }
372
- }
373
- let progressFrame = 0;
374
- let activeProgressFrame = null;
375
- let progressTimer = null;
376
- let progressBlockLines = 0;
377
- // Single source of truth for a batch's on-disk state, shared by init (what to
378
- // (re)run) and status (what to report) so the two can never disagree. "done"
379
- // requires a result that actually PARSES — a present-but-corrupt result is
380
- // pending, so it self-heals on the next run instead of being silently trusted.
381
- function classifyBatch(dir, batch) {
382
- if (readBatchResult(dir, batch) !== null)
383
- return "done";
384
- if (exists(batchErrorPath(dir, batch)))
385
- return "error";
386
- return "pending";
387
- }
388
- function statusFromBatchState(state) {
389
- return state === "done" ? "success" : state;
390
- }
391
- function stageSnapshot(name, status, completed, total, message) {
392
- const boundedTotal = Math.max(0, total);
393
- const boundedCompleted = Math.max(0, Math.min(completed, boundedTotal));
394
- return {
395
- name,
396
- status,
397
- completed: boundedCompleted,
398
- total: boundedTotal,
399
- pending: status === "skipped" ? 0 : Math.max(0, boundedTotal - boundedCompleted),
400
- warning: status === "warning" ? 1 : 0,
401
- error: status === "error" ? 1 : 0,
402
- message: message ?? "",
403
- };
404
- }
405
- function formatDuration(ms) {
406
- const value = Math.max(0, Math.round(ms));
407
- if (value < 1000)
408
- return `${value}ms`;
409
- const seconds = value / 1000;
410
- if (seconds < 60)
411
- return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
412
- const minutes = Math.floor(seconds / 60);
413
- const rest = Math.round(seconds % 60);
414
- return `${minutes}m${String(rest).padStart(2, "0")}s`;
415
- }
416
- function stageTimingDurations(state) {
417
- const timingSummaryRaw = state["timing_summary"];
418
- const timingSummary = isDict(timingSummaryRaw) ? timingSummaryRaw : {};
419
- const stagesRaw = timingSummary["stages"];
420
- const stages = isDict(stagesRaw) ? stagesRaw : {};
421
- const out = {};
422
- for (const [key, value] of Object.entries(stages)) {
423
- const n = Number(value);
424
- if (!Number.isNaN(n))
425
- out[key] = n;
426
- }
427
- return out;
428
- }
429
- function runningTimingElapsed(state, stageName) {
430
- const stageRaw = state["timing_current_stage"];
431
- const currentRaw = isDict(stageRaw) ? stageRaw : state["timing_current"];
432
- const current = isDict(currentRaw) ? currentRaw : null;
433
- if (!current || strOf(current["category"]) !== "stage" || strOf(current["name"]) !== stageName)
434
- return null;
435
- const startedAt = Date.parse(strOf(current["started_at"]));
436
- if (Number.isNaN(startedAt))
437
- return null;
438
- return Date.now() - startedAt;
439
- }
440
- function applyStageTiming(stage, state, durations) {
441
- const duration = durations[stage.name];
442
- const elapsed = stage.status === "running" ? runningTimingElapsed(state, stage.name) : null;
443
- const messageParts = [stage.message].filter((item) => item);
444
- if (typeof duration === "number")
445
- messageParts.push(`耗时 ${formatDuration(duration)}`);
446
- else if (elapsed !== null)
447
- messageParts.push(`已运行 ${formatDuration(elapsed)}`);
448
- return {
449
- ...stage,
450
- ...(typeof duration === "number" ? { duration_ms: duration } : {}),
451
- ...(elapsed !== null ? { elapsed_ms: elapsed } : {}),
452
- message: messageParts.join("; "),
453
- };
454
- }
455
- function fileStage(name, filePath, runningStage) {
456
- if (exists(filePath))
457
- return stageSnapshot(name, "success", 1, 1);
458
- return stageSnapshot(name, runningStage === name ? "running" : "pending", 0, 1);
459
- }
460
- function orderedFileStage(name, filePath, runningStage, runStatus) {
461
- if (!canTrustStageArtifacts(name, runningStage, runStatus)) {
462
- return stageSnapshot(name, "pending", 0, 1);
463
- }
464
- return fileStage(name, filePath, runningStage ?? "");
465
- }
466
- function canTrustStageArtifacts(name, runningStage, runStatus) {
467
- if (runStatus !== "init_running" || !runningStage)
468
- return true;
469
- return stageOrder(name) <= stageOrder(runningStage);
470
- }
471
- function readJsonFile(filePath, fallback) {
472
- if (!exists(filePath))
473
- return fallback;
474
- return readJson(filePath);
475
- }
476
- function readableJsonFile(filePath) {
477
- if (!exists(filePath))
478
- return false;
479
- try {
480
- readJson(filePath);
481
- return true;
482
- }
483
- catch {
484
- return false;
485
- }
486
- }
487
- function stageUnit(name) {
488
- if (name === "batch_extract")
489
- return "progress";
490
- if (name === "episode_merge" || name === "episode_synopsis" || name === "episode_titles")
491
- return "items";
492
- return "step";
493
- }
494
- function stageLabel(name) {
495
- switch (name) {
496
- case "source_prepare": return "准备素材";
497
- case "episode_plan": return "阅读剧本";
498
- case "batch_plan": return "理解剧情";
499
- case "episode_titles": return "整理内容";
500
- case "batch_extract": return "整理内容";
501
- case "episode_merge": return "生成剧本";
502
- case "episode_synopsis": return "补充信息";
503
- case "script_merge": return "生成剧本";
504
- case "asset_curation": return "补充信息";
505
- case "state_curation": return "补充信息";
506
- case "state_binding": return "补充信息";
507
- case "metadata_extract": return "补充信息";
508
- case "script_synopsis": return "补充信息";
509
- case "validate": return "检查结果";
510
- }
511
- }
512
- function statusText(status) {
513
- switch (status) {
514
- case "success": return "完成";
515
- case "running": return "进行中";
516
- case "pending": return "等待中";
517
- case "warning": return "需留意";
518
- case "error": return "出错";
519
- case "skipped": return "已跳过";
520
- }
521
- }
522
- function statusPriority(status) {
523
- switch (status) {
524
- case "error": return 5;
525
- case "warning": return 4;
526
- case "running": return 3;
527
- case "pending": return 2;
528
- case "skipped": return 1;
529
- default: return 0;
530
- }
531
- }
532
- function userStageRows(stages) {
533
- const byLabel = new Map();
534
- for (const stage of stages) {
535
- const label = stageLabel(stage.name);
536
- byLabel.set(label, [...(byLabel.get(label) ?? []), stage]);
537
- }
538
- const rows = [];
539
- for (const items of byLabel.values()) {
540
- const total = items.reduce((sum, item) => sum + item.total, 0);
541
- const completed = items.reduce((sum, item) => sum + (item.status === "skipped" ? item.total : item.completed), 0);
542
- const status = items.reduce((max, item) => statusPriority(item.status) > statusPriority(max) ? item.status : max, "success");
543
- rows.push({
544
- name: items[0].name,
545
- status,
546
- completed: Math.round(completed),
547
- total,
548
- pending: items.reduce((sum, item) => sum + item.pending, 0),
549
- warning: items.reduce((sum, item) => sum + item.warning, 0),
550
- error: items.reduce((sum, item) => sum + item.error, 0),
551
- message: items.map((item) => item.message).filter(Boolean).join("; "),
552
- });
553
- }
554
- return rows;
555
- }
556
- function stageOrder(name) {
557
- switch (name) {
558
- case "source_prepare": return 0;
559
- case "episode_plan": return 1;
560
- case "episode_titles": return 2;
561
- case "batch_plan": return 3;
562
- case "batch_extract": return 4;
563
- case "episode_merge": return 5;
564
- case "episode_synopsis": return 6;
565
- case "script_merge": return 7;
566
- case "asset_curation": return 8;
567
- case "state_curation": return 9;
568
- case "state_binding": return 10;
569
- case "metadata_extract": return 11;
570
- case "script_synopsis": return 12;
571
- case "validate": return 13;
572
- }
573
- }
574
- function stageRatio(stage) {
575
- if (stage.status === "success" || stage.status === "skipped")
576
- return 1;
577
- if (stage.total <= 0)
578
- return 0;
579
- return Math.max(0, Math.min(1, stage.completed / stage.total));
580
- }
581
- function stageCountText(stage) {
582
- if (stage.status === "skipped")
583
- return "-";
584
- return `${stage.completed}/${stage.total}`;
585
- }
586
- function useProgressColor() {
587
- return Boolean(process.stderr.isTTY) && process.env.NO_COLOR === undefined;
588
- }
589
- function ansi(code, text) {
590
- return useProgressColor() ? `\x1b[${code}m${text}\x1b[0m` : text;
591
- }
592
- function colorStatus(status, text) {
593
- switch (status) {
594
- case "success": return ansi(32, text);
595
- case "error": return ansi(31, text);
596
- case "warning": return ansi(33, text);
597
- case "running": return ansi(36, text);
598
- case "pending": return ansi(34, text);
599
- case "skipped": return ansi(90, text);
600
- }
601
- }
602
- function dim(text) {
603
- return ansi(2, text);
604
- }
605
- function bold(text) {
606
- return ansi(1, text);
607
- }
608
- function progressBar(stage) {
609
- const width = 24;
610
- const ratio = stageRatio(stage);
611
- const filled = Math.round(ratio * width);
612
- const bar = `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
613
- return colorStatus(stage.status, bar);
614
- }
615
- function statusMark(stage) {
616
- if (stage.status === "running") {
617
- const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
618
- const frame = frames[progressFrame % frames.length];
619
- progressFrame += 1;
620
- return ansi(36, frame);
621
- }
622
- if (stage.status === "success")
623
- return ansi(32, "✓");
624
- if (stage.status === "error")
625
- return ansi(31, "✖");
626
- if (stage.status === "warning")
627
- return ansi(33, "!");
628
- if (stage.status === "skipped")
629
- return ansi(90, "-");
630
- return ansi(34, "•");
631
- }
632
- function renderProgressLine(stage) {
633
- const status = colorStatus(stage.status, stage.status.padEnd(7));
634
- const bits = [
635
- statusMark(stage),
636
- bold(stageLabel(stage.name)),
637
- status,
638
- progressBar(stage),
639
- `${stageCountText(stage)} ${stageUnit(stage.name)}`,
640
- ];
641
- if (stage.error > 0)
642
- bits.push(ansi(31, `error ${stage.error}`));
643
- if (stage.warning > 0)
644
- bits.push(ansi(33, `warning ${stage.warning}`));
645
- if (stage.status === "pending")
646
- bits.push(ansi(34, statusText("pending")));
647
- if (stage.message)
648
- bits.push(dim(`- ${stage.message}`));
649
- return bits.join(" ");
650
- }
651
- function renderStageDetail(stage) {
652
- const counts = [stageCountText(stage)];
653
- if (stage.error > 0)
654
- counts.push(ansi(31, `error ${stage.error}`));
655
- if (stage.warning > 0)
656
- counts.push(ansi(33, `warning ${stage.warning}`));
657
- if (stage.status === "pending")
658
- counts.push(ansi(34, statusText("pending")));
659
- return ` ${dim("stage")} ${stageLabel(stage.name)} ${colorStatus(stage.status, statusText(stage.status))} ${counts.join(", ")}`;
660
- }
661
- function renderEpisodeDetail(episode) {
662
- const errors = episode.error_batches.length > 0 ? ansi(31, " has errors") : "";
663
- return ` ${dim("episode")} ${episode.episode_id} ${colorStatus(episode.status, statusText(episode.status))} ${episode.batches_done}/${episode.batches_total}, story ${colorStatus(episode.synopsis_status, statusText(episode.synopsis_status))}${errors}`;
664
- }
665
- function renderBatchDetail(batch) {
666
- const reason = batch.error_code || batch.error_type || batch.message;
667
- return ` ${dim("detail")} ${batch.batch_id} ${colorStatus(batch.status, statusText(batch.status))} ep_${pad3(batch.episode)}${reason ? ` - ${reason}` : ""}`;
668
- }
669
- function capDetails(lines, total, label) {
670
- if (total <= lines.length)
671
- return lines;
672
- return [...lines, ` ${dim(`${total - lines.length} more ${label}`)}`];
673
- }
674
- function renderProgressDetails(snapshot, stageName) {
675
- if (stageName === "batch_extract") {
676
- const episodes = snapshot.episodes.filter((episode) => episode.status !== "success");
677
- return [
678
- ...capDetails(episodes.slice(0, 4).map((episode) => renderEpisodeDetail(episode)), episodes.length, "episodes"),
679
- ];
680
- }
681
- if (stageName === "episode_titles" || stageName === "episode_merge" || stageName === "episode_synopsis") {
682
- const episodes = snapshot.episodes.filter((episode) => episode.status !== "success" || episode.synopsis_status !== "success");
683
- return capDetails(episodes.slice(0, 6).map((episode) => renderEpisodeDetail(episode)), episodes.length, "episodes");
684
- }
685
- const stages = snapshot.stages.filter((stage) => stage.status === "running" || stage.status === "pending" || stage.status === "warning" || stage.status === "error");
686
- return capDetails(stages.slice(0, 5).map((stage) => renderStageDetail(stage)), stages.length, "stages");
687
- }
688
- function renderProgressFrameText(frame) {
689
- return [renderProgressLine(frame.stage), ...frame.details].join("\n");
690
- }
691
- function renderProgressFrame() {
692
- if (activeProgressFrame)
693
- updateProgressBlock(renderProgressFrameText(activeProgressFrame));
694
- }
695
- function progressStageWithStatus(stage, status) {
696
- if (status === "success")
697
- return { ...stage, status, completed: stage.total, pending: 0, warning: 0, error: 0 };
698
- if (status === "warning")
699
- return { ...stage, status, completed: stage.total, pending: 0, warning: Math.max(1, stage.warning), error: 0 };
700
- if (status === "skipped")
701
- return { ...stage, status, completed: stage.total, pending: 0, warning: 0, error: 0 };
702
- if (status === "error")
703
- return { ...stage, status, error: Math.max(1, stage.error) };
704
- return { ...stage, status };
705
- }
706
- function stopProgressTimer() {
707
- if (!progressTimer)
708
- return;
709
- clearInterval(progressTimer);
710
- progressTimer = null;
711
- }
712
- function ensureProgressTimer() {
713
- if (!process.stderr.isTTY || progressTimer !== null)
714
- return;
715
- progressTimer = setInterval(renderProgressFrame, 80);
716
- }
717
- function eraseProgressBlock() {
718
- if (progressBlockLines === 0)
719
- return;
720
- process.stderr.write(`\x1b[${progressBlockLines}A`);
721
- for (let i = 0; i < progressBlockLines; i++) {
722
- process.stderr.write("\x1b[2K");
723
- if (i < progressBlockLines - 1)
724
- process.stderr.write("\x1b[1B");
725
- }
726
- process.stderr.write("\r");
727
- if (progressBlockLines > 1)
728
- process.stderr.write(`\x1b[${progressBlockLines - 1}A`);
729
- progressBlockLines = 0;
730
- }
731
- function updateProgressBlock(text) {
732
- eraseProgressBlock();
733
- process.stderr.write(text + "\n");
734
- progressBlockLines = text.split("\n").length;
735
- }
736
- function persistProgressLine(text) {
737
- eraseProgressBlock();
738
- process.stderr.write(text + "\n");
739
- }
740
- function emitProgressStage(stage, details = []) {
741
- const line = renderProgressLine(stage);
742
- if (!process.stderr.isTTY) {
743
- process.stderr.write(line + "\n");
744
- return;
745
- }
746
- activeProgressFrame = { stage, details: stage.status === "running" ? details : [] };
747
- if (stage.status === "running") {
748
- ensureProgressTimer();
749
- updateProgressBlock(renderProgressFrameText(activeProgressFrame));
750
- return;
751
- }
752
- stopProgressTimer();
753
- persistProgressLine(line);
754
- activeProgressFrame = null;
755
- }
756
- function finishInitProgress() {
757
- stopProgressTimer();
758
- if (process.stderr.isTTY)
759
- eraseProgressBlock();
760
- }
761
- function emitInitProgress(workspace, stageName, fallbackStatus = "running") {
762
- const dd = directDir(workspace);
763
- const episodePlanPath = path.join(dd, "episode_plan.json");
764
- const batchPlanPath = path.join(dd, "batch_plan.json");
765
- if (exists(episodePlanPath) && exists(batchPlanPath)) {
766
- const snapshot = buildDirectStatusSnapshot(workspace);
767
- const stage = snapshot.stages.find((item) => item.name === stageName);
768
- if (stage) {
769
- const renderedStage = fallbackStatus === "running" ? stage : progressStageWithStatus(stage, fallbackStatus);
770
- emitProgressStage(renderedStage, renderProgressDetails(snapshot, stageName));
771
- return;
772
- }
773
- }
774
- emitProgressStage({
775
- name: stageName,
776
- status: fallbackStatus,
777
- completed: fallbackStatus === "success" ? 1 : 0,
778
- total: 1,
779
- pending: fallbackStatus === "running" || fallbackStatus === "pending" ? 1 : 0,
780
- warning: fallbackStatus === "warning" ? 1 : 0,
781
- error: fallbackStatus === "error" ? 1 : 0,
782
- message: "",
783
- });
784
- }
785
- function emitFailedInitProgress(workspace) {
786
- const stage = runStateDirectStage(readRunState(workspace));
787
- if (isDirectStageName(stage))
788
- emitInitProgress(workspace, stage, "error");
789
- }
790
- function buildDirectStatusSnapshot(workspace) {
791
- const dd = directDir(workspace);
792
- const episodePlanPath = path.join(dd, "episode_plan.json");
793
- const batchPlanPath = path.join(dd, "batch_plan.json");
794
- const episodePlan = readJsonFile(episodePlanPath, {});
795
- const batchPlan = readJsonFile(batchPlanPath, {});
796
- const episodes = asList(episodePlan["episodes"]);
797
- const batches = asList(batchPlan["batches"]);
798
- const state = readRunState(workspace);
799
- const rawRunningStage = runStateDirectStage(state);
800
- const runningStage = rawRunningStage && rawRunningStage !== "complete" && isDirectStageName(rawRunningStage) ? rawRunningStage : null;
801
- const runStatus = strOf(state["status"]);
802
- const failedStage = runStatus === "init_failed" ? runningStage : null;
803
- const batchResultsDir = path.join(dd, "batch_results");
804
- const episodeResultsDir = path.join(dd, "episode_results");
805
- const trustEpisodeMergeArtifacts = canTrustStageArtifacts("episode_merge", runningStage, runStatus);
806
- const trustEpisodeSynopsisArtifacts = canTrustStageArtifacts("episode_synopsis", runningStage, runStatus);
807
- const trustStateCurationArtifacts = canTrustStageArtifacts("state_curation", runningStage, runStatus);
808
- const trustStateBindingArtifacts = canTrustStageArtifacts("state_binding", runningStage, runStatus);
809
- const trustMetadataArtifacts = canTrustStageArtifacts("metadata_extract", runningStage, runStatus);
810
- const trustScriptSynopsisArtifacts = canTrustStageArtifacts("script_synopsis", runningStage, runStatus);
811
- const batchesByEpisode = new Map();
812
- const batchDetails = [];
813
- let doneBatches = 0;
814
- let errorBatches = 0;
815
- for (const batch of batches) {
816
- const batchId = batchResultKey(batch);
817
- const status = statusFromBatchState(classifyBatch(batchResultsDir, batch));
818
- if (status === "success")
819
- doneBatches++;
820
- if (status === "error")
821
- errorBatches++;
822
- const detail = {
823
- batch_id: batchId,
824
- episode: Number(batch["episode"]),
825
- part: Number(batch["part"] ?? 0),
826
- status,
827
- };
828
- if (status === "error") {
829
- try {
830
- const err = readJson(batchErrorPath(batchResultsDir, batch));
831
- const errorCode = strOf(err["error_code"]);
832
- const errorType = strOf(err["error_type"]);
833
- const message = strOf(err["message"]);
834
- if (errorCode)
835
- detail.error_code = errorCode;
836
- if (errorType)
837
- detail.error_type = errorType;
838
- if (message)
839
- detail.message = message;
840
- }
841
- catch {
842
- detail.error_type = "Error";
843
- }
844
- }
845
- batchDetails.push(detail);
846
- if (!batchesByEpisode.has(detail.episode))
847
- batchesByEpisode.set(detail.episode, []);
848
- batchesByEpisode.get(detail.episode).push(detail);
849
- }
850
- const pendingBatches = batches.length - doneBatches - errorBatches;
851
- const degradedSynopsisEpisodes = trustEpisodeSynopsisArtifacts
852
- ? new Set(asList(state["summary_degraded_episodes"]).map((n) => Number(n)))
853
- : new Set();
854
- const episodeSnapshots = [];
855
- let completeEpisodes = 0;
856
- let episodeResults = 0;
857
- let episodeSynopses = 0;
858
- for (const episode of episodes) {
859
- const episodeNum = Number(episode["episode"]);
860
- const epBatches = batchesByEpisode.get(episodeNum) ?? [];
861
- const batchesDone = epBatches.filter((b) => b.status === "success").length;
862
- const batchesError = epBatches.filter((b) => b.status === "error").length;
863
- const batchesPending = epBatches.length - batchesDone - batchesError;
864
- const epResultPath = episodeResultPath(episodeResultsDir, episode);
865
- const hasEpisodeResult = trustEpisodeMergeArtifacts && readableJsonFile(epResultPath);
866
- if (hasEpisodeResult)
867
- episodeResults++;
868
- const synopsis = trustEpisodeSynopsisArtifacts ? readEpisodeSynopsis(episodeResultsDir, episode) : null;
869
- if (synopsis)
870
- episodeSynopses++;
871
- const status = batchesError > 0
872
- ? "error"
873
- : epBatches.length > 0 && batchesDone === epBatches.length && hasEpisodeResult
874
- ? "success"
875
- : runningStage === "batch_extract" || runningStage === "episode_merge"
876
- ? "running"
877
- : "pending";
878
- if (status === "success")
879
- completeEpisodes++;
880
- const synopsisStatus = degradedSynopsisEpisodes.has(episodeNum)
881
- ? "warning"
882
- : synopsis
883
- ? "success"
884
- : Boolean(state["summary_skipped"]) && !exists(path.join(dd, "synopsis.json"))
885
- ? "skipped"
886
- : "pending";
887
- episodeSnapshots.push({
888
- episode: episodeNum,
889
- episode_id: episodeResultKey(episode),
890
- status,
891
- batches_done: batchesDone,
892
- batches_total: epBatches.length,
893
- batches_error: batchesError,
894
- batches_pending: batchesPending,
895
- synopsis_status: synopsisStatus,
896
- error_batches: epBatches.filter((b) => b.status === "error").map((b) => b.batch_id),
897
- });
898
- }
899
- const sourceStage = exists(path.join(workspace, "source.txt"))
900
- ? stageSnapshot("source_prepare", "success", 1, 1)
901
- : stageSnapshot("source_prepare", runningStage === "source_prepare" ? "running" : "pending", 0, 1);
902
- const episodePlanStage = episodes.length > 0
903
- ? stageSnapshot("episode_plan", "success", episodes.length, episodes.length)
904
- : stageSnapshot("episode_plan", runningStage === "episode_plan" ? "running" : "pending", 0, 1);
905
- const batchPlanStage = batches.length > 0
906
- ? stageSnapshot("batch_plan", "success", batches.length, batches.length)
907
- : stageSnapshot("batch_plan", runningStage === "batch_plan" ? "running" : "pending", 0, 1);
908
- const titleDeg = isDict(state["title_degraded"]) ? state["title_degraded"] : null;
909
- const episodeTitlesStage = stageSnapshot("episode_titles", episodes.length === 0
910
- ? runningStage === "episode_titles" ? "running" : "pending"
911
- : titleDeg && Number(titleDeg["count"] ?? 0) > 0 ? "warning" : "success", episodes.length > 0 ? episodes.length : 0, episodes.length > 0 ? episodes.length : 1, titleDeg && Number(titleDeg["count"] ?? 0) > 0
912
- ? `deterministic titles: ${asList(titleDeg["episodes"]).join(", ")}`
913
- : "");
914
- const batchStatus = errorBatches > 0
915
- ? "error"
916
- : doneBatches === batches.length && batches.length > 0
917
- ? "success"
918
- : runningStage === "batch_extract"
919
- ? "running"
920
- : "pending";
921
- const batchStage = {
922
- ...stageSnapshot("batch_extract", batchStatus, doneBatches, batches.length),
923
- pending: pendingBatches,
924
- error: errorBatches,
925
- };
926
- const episodeMergeStatus = errorBatches > 0
927
- ? "error"
928
- : episodeResults === episodes.length && episodes.length > 0
929
- ? "success"
930
- : runningStage === "episode_merge"
931
- ? "running"
932
- : "pending";
933
- const episodeMergeStage = stageSnapshot("episode_merge", episodeMergeStatus, episodeResults, episodes.length);
934
- const synopsisWarning = degradedSynopsisEpisodes.size;
935
- const episodeSynopsisStatus = synopsisWarning > 0
936
- ? "warning"
937
- : episodeSynopses === episodes.length && episodes.length > 0
938
- ? "success"
939
- : runningStage === "episode_synopsis"
940
- ? "running"
941
- : "pending";
942
- const episodeSynopsisStage = {
943
- ...stageSnapshot("episode_synopsis", episodeSynopsisStatus, episodeSynopses, episodes.length),
944
- warning: synopsisWarning,
945
- };
946
- const scriptStage = orderedFileStage("script_merge", path.join(dd, "script.initial.json"), runningStage, runStatus);
947
- const curationStage = orderedFileStage("asset_curation", path.join(dd, "asset_curation.json"), runningStage, runStatus);
948
- const stateCurationStage = trustStateCurationArtifacts
949
- ? orderedFileStage("state_curation", path.join(dd, "state_curation.json"), runningStage, runStatus)
950
- : stageSnapshot("state_curation", runningStage === "state_curation" ? "running" : "pending", 0, 1);
951
- const stateBindingStage = trustStateBindingArtifacts
952
- ? orderedFileStage("state_binding", path.join(dd, "state_binding.json"), runningStage, runStatus)
953
- : stageSnapshot("state_binding", runningStage === "state_binding" ? "running" : "pending", 0, 1);
954
- const metadataStage = Boolean(state["metadata_skipped"]) && trustMetadataArtifacts
955
- ? stageSnapshot("metadata_extract", "skipped", 0, 1, "metadata: skipped (--skip-metadata)")
956
- : orderedFileStage("metadata_extract", path.join(dd, "asset_metadata.json"), runningStage, runStatus);
957
- const synopsisPath = path.join(dd, "synopsis.json");
958
- const summaryLine = formatSummaryStatusLine(Boolean(state["summary_skipped"]), Boolean(state["summary_degraded"]), asList(state["summary_degraded_episodes"]).map((n) => Number(n)));
959
- const hasScriptSynopsis = trustScriptSynopsisArtifacts && exists(synopsisPath);
960
- const scriptSynopsisStatus = hasScriptSynopsis
961
- ? Boolean(state["summary_degraded"]) ? "warning" : "success"
962
- : trustScriptSynopsisArtifacts && Boolean(state["summary_skipped"])
963
- ? "skipped"
964
- : runningStage === "script_synopsis"
965
- ? "running"
966
- : "pending";
967
- const scriptSynopsisStage = stageSnapshot("script_synopsis", scriptSynopsisStatus, hasScriptSynopsis ? 1 : 0, 1, summaryLine ?? "");
968
- const validationPath = path.join(dd, "validation.json");
969
- let validationStage = orderedFileStage("validate", validationPath, runningStage, runStatus);
970
- if (exists(validationPath) && !(runStatus === "init_running" && runningStage && stageOrder("validate") > stageOrder(runningStage))) {
971
- const validation = readJson(validationPath);
972
- validationStage = stageSnapshot("validate", Boolean(validation["passed"]) ? "success" : "warning", 1, 1);
973
- }
974
- const timingDurations = stageTimingDurations(state);
975
- const stages = [
976
- sourceStage,
977
- episodePlanStage,
978
- batchPlanStage,
979
- episodeTitlesStage,
980
- batchStage,
981
- episodeMergeStage,
982
- episodeSynopsisStage,
983
- scriptStage,
984
- curationStage,
985
- stateCurationStage,
986
- stateBindingStage,
987
- metadataStage,
988
- scriptSynopsisStage,
989
- validationStage,
990
- ]
991
- .map((stage) => failedStage === stage.name ? { ...stage, status: "error", error: Math.max(1, stage.error) } : stage)
992
- .map((stage) => applyStageTiming(stage, state, timingDurations));
993
- let overallStatus = "pending";
994
- const maxStage = stages.reduce((max, stage) => statusPriority(stage.status) > statusPriority(max) ? stage.status : max, "success");
995
- if (runStatus === "init_failed" || runStatus === "init_incomplete" || maxStage === "error")
996
- overallStatus = "error";
997
- else if (runStatus === "init_running" || maxStage === "running")
998
- overallStatus = "running";
999
- else if (maxStage === "warning")
1000
- overallStatus = "warning";
1001
- else if (stages.filter((s) => s.name !== "script_synopsis").every((s) => s.status === "success" || s.status === "warning" || s.status === "skipped"))
1002
- overallStatus = "success";
1003
- return {
1004
- overall_status: overallStatus,
1005
- running_stage: runningStage,
1006
- stages,
1007
- episodes: episodeSnapshots,
1008
- batches: batchDetails,
1009
- };
1010
- }
1011
- function renderDirectStatusResult(snapshot) {
1012
- const completedEpisodes = snapshot.episodes.filter((episode) => episode.status === "success").length;
1013
- const result = [
1014
- "Overall",
1015
- `overall: ${statusText(snapshot.overall_status)}; current: ${snapshot.running_stage && isDirectStageName(snapshot.running_stage) ? stageLabel(snapshot.running_stage) : "-"}`,
1016
- snapshot.episodes.length > 0 ? `episodes: ${completedEpisodes}/${snapshot.episodes.length} 完成` : "episodes: 等待中",
1017
- "Stages",
1018
- ];
1019
- for (const stage of userStageRows(snapshot.stages)) {
1020
- result.push(`${stageLabel(stage.name)}: ${statusText(stage.status)} ${stageCountText(stage)}${stage.error ? `, error ${stage.error}` : ""}${stage.warning ? `, warning ${stage.warning}` : ""}${stage.status === "pending" ? ", 等待中" : ""}${stage.message ? ` - ${stage.message}` : ""}`);
1021
- }
1022
- result.push("Episodes");
1023
- for (const episode of snapshot.episodes) {
1024
- const errors = episode.error_batches.length > 0 ? `, error ids: ${episode.error_batches.join(",")}` : "";
1025
- result.push(`${episode.episode_id}: ${statusText(episode.status)} ${episode.batches_done}/${episode.batches_total}, story ${statusText(episode.synopsis_status)}${errors}`);
1026
- }
1027
- result.push("Details");
1028
- const noisyBatches = snapshot.batches.filter((batch) => batch.status !== "success");
1029
- if (noisyBatches.length === 0) {
1030
- result.push("no error/warning/pending details");
1031
- }
1032
- else {
1033
- for (const batch of noisyBatches.slice(0, 50)) {
1034
- const reason = batch.error_code || batch.error_type || batch.message;
1035
- result.push(`${batch.batch_id}: ${statusText(batch.status)} ep_${pad3(batch.episode)}${reason ? ` - ${reason}` : ""}`);
1036
- }
1037
- if (noisyBatches.length > 50)
1038
- result.push(`${noisyBatches.length - 50} more non-success details not shown`);
1039
- }
1040
- return result;
1041
- }
1042
- // Delete result/meta/error/markdown files whose unit key is no longer in the
1043
- // current plan (e.g. the source shed an episode). Pure function of the plan —
1044
- // it never inspects hashes, content, or run_state, so it can only remove units
1045
- // the plan no longer references. Also retires the legacy v3 `index.json`.
1046
- function gcOrphanUnits(dir, liveKeys) {
1047
- if (!exists(dir))
1048
- return [];
1049
- const removed = [];
1050
- for (const name of fs.readdirSync(dir)) {
1051
- if (name === "index.json") {
1052
- deletePath(path.join(dir, name));
1053
- removed.push(name);
1054
- continue;
1055
- }
1056
- const key = name.replace(/\.(meta\.json|error\.json|json|md)$/, "");
1057
- if (key === name)
1058
- continue; // not a recognized unit artifact
1059
- if (!liveKeys.has(key)) {
1060
- deletePath(path.join(dir, name));
1061
- removed.push(name);
1062
- }
1063
- }
1064
- return removed;
1065
- }
1066
- function mergeScene(target, source) {
1067
- if ((target["location_name"] === "" || target["location_name"] === "未知场景" || target["location_name"] === null || target["location_name"] === undefined) &&
1068
- source["location_name"]) {
1069
- target["location_name"] = source["location_name"];
1070
- }
1071
- if (!target["location_state"] && source["location_state"]) {
1072
- target["location_state"] = source["location_state"];
1073
- }
1074
- for (const actor of asList(source["actor_names"])) {
1075
- if (!isList(target["actor_names"]))
1076
- target["actor_names"] = [];
1077
- uniqueAdd(target["actor_names"], actor);
1078
- }
1079
- const targetStates = target["actor_states"] ?? {};
1080
- if (!isDict(target["actor_states"]))
1081
- target["actor_states"] = targetStates;
1082
- if (isDict(source["actor_states"])) {
1083
- for (const [actor, state] of Object.entries(source["actor_states"])) {
1084
- if (state && !targetStates[actor])
1085
- targetStates[actor] = String(state);
1086
- }
1087
- }
1088
- for (const prop of asList(source["prop_names"])) {
1089
- if (!isList(target["prop_names"]))
1090
- target["prop_names"] = [];
1091
- uniqueAdd(target["prop_names"], prop);
1092
- }
1093
- if (!isList(target["actions"]))
1094
- target["actions"] = [];
1095
- target["actions"].push(...asList(source["actions"]));
1096
- }
1097
- export function mergeBatchResultsForEpisode(episode, batchResults) {
1098
- const scenes = [];
1099
- const actors = [];
1100
- const locations = [];
1101
- const props = [];
1102
- const speakers = [];
1103
- const stateDefinitions = [];
1104
- // Ordered, non-empty per-fragment synopses for the episode-level reduce. The
1105
- // reduce/passthrough decision (and LLM call) happens in the orchestrator, NOT
1106
- // here — this pure function only collects the beats in _batch_part order.
1107
- const batchSynopses = [];
1108
- const sorted = [...batchResults].sort((a, b) => Number(a["_batch_part"] ?? 0) - Number(b["_batch_part"] ?? 0));
1109
- for (const item of sorted) {
1110
- const synopsis = strOf(item["synopsis"]).trim();
1111
- if (synopsis)
1112
- batchSynopses.push(synopsis);
1113
- const itemScenes = asList(item["scenes"]);
1114
- if (itemScenes.length === 0)
1115
- continue;
1116
- if (item["_starts_inside_scene"] && scenes.length > 0) {
1117
- mergeScene(scenes[scenes.length - 1], itemScenes[0]);
1118
- scenes.push(...itemScenes.slice(1));
1119
- }
1120
- else {
1121
- scenes.push(...itemScenes);
1122
- }
1123
- for (const asset of asList(item["actors"])) {
1124
- if (isDict(asset))
1125
- _md_push_asset(actors, strOf(asset["name"]), strOf(asset["description"]).trim() || null);
1126
- }
1127
- for (const asset of asList(item["locations"])) {
1128
- if (isDict(asset))
1129
- _md_push_asset(locations, strOf(asset["name"]), strOf(asset["description"]).trim() || null);
1130
- }
1131
- for (const asset of asList(item["props"])) {
1132
- if (isDict(asset))
1133
- _md_push_asset(props, strOf(asset["name"]), strOf(asset["description"]).trim() || null);
1134
- }
1135
- for (const speaker of asList(item["speakers"])) {
1136
- if (isDict(speaker))
1137
- speakers.push(speaker);
1138
- }
1139
- for (const stateDef of asList(item["state_definitions"])) {
1140
- if (isDict(stateDef))
1141
- stateDefinitions.push(stateDef);
1142
- }
1143
- }
1144
- const result = {
1145
- episode: Number(episode["episode"]),
1146
- title: episode["title"],
1147
- source_span: episode["source_span"],
1148
- scenes,
1149
- actors,
1150
- locations,
1151
- props,
1152
- speakers,
1153
- state_definitions: stateDefinitions,
1154
- };
1155
- const normalized = normalizeEpisodeResult(result, episode);
1156
- // Carry the ordered fragment synopses on the merged result so the orchestrator
1157
- // can run the episode-level reduce (or passthrough) on them. Not part of the
1158
- // episode schema — stripped before the episode entry is built downstream.
1159
- normalized["batch_synopses"] = batchSynopses;
1160
- return normalized;
1161
- }
1162
- // Record a batch failure: write `<key>.error.json` (which marks the batch as
1163
- // "tried and failed" → sticky on plain resume) and drop any stale result file.
1164
- // The error_code (e.g. PROVIDER_CONTENT_FILTERED) is surfaced so the agent can
1165
- // see WHY in `direct status` / `direct review` and decide whether to re-run.
1166
- //
1167
- // Deliberate content-filter policy split: batch extraction is HARD (sticky
1168
- // error, episode blocked until an explicit re-run) because batch content is the
1169
- // deliverable and cannot be fabricated; episode TITLES are SOFT (degrade to a
1170
- // deterministic title, recorded as title_degraded, never blocking) because a
1171
- // title is non-essential and always has a safe fallback. Same provider signal,
1172
- // two intentional policies — see enrichEpisodeTitlesChunked.
1173
- function writeBatchFailure(dir, batch, exc) {
1174
- const err = exc;
1175
- const error = {
1176
- batch_id: batchResultKey(batch),
1177
- episode: Number(batch["episode"]),
1178
- part: Number(batch["part"]),
1179
- source_span: batch["source_span"],
1180
- line_range: batch["line_range"],
1181
- error_type: err?.name || "Error",
1182
- message: (err?.message || err?.name || "Error").slice(0, 500),
1183
- failed_at: checkpointTimestamp(),
1184
- };
1185
- if (exc instanceof CliError) {
1186
- if (exc.errorCode)
1187
- error["error_code"] = exc.errorCode;
1188
- if (exc.required.length > 0)
1189
- error["required"] = exc.required;
1190
- if (exc.received.length > 0)
1191
- error["received"] = exc.received;
1192
- if (exc.nextSteps.length > 0)
1193
- error["next"] = exc.nextSteps;
1194
- }
1195
- const resultPath = batchResultPath(dir, batch);
1196
- if (exists(resultPath))
1197
- deletePath(resultPath);
1198
- writeJson(batchErrorPath(dir, batch), error);
1199
- return error;
1200
- }
1201
- function initFailedReport(workspace, opts) {
1202
- const lastError = { title: opts.title, received: opts.received, failed_at: checkpointTimestamp() };
1203
- if (opts.errorCode)
1204
- lastError["error_code"] = opts.errorCode;
1205
- const payload = {
1206
- status: "init_failed",
1207
- command: "direct init",
1208
- init_stage: opts.stage,
1209
- last_error: lastError,
1210
- };
1211
- if (opts.updates)
1212
- Object.assign(payload, opts.updates);
1213
- updateRunState(workspace, payload);
1214
- return new CliError(opts.title, opts.title, {
1215
- exitCode: opts.exitCode ?? EXIT_RUNTIME,
1216
- errorCode: opts.errorCode,
1217
- required: opts.required,
1218
- received: opts.received,
1219
- nextSteps: opts.nextSteps,
1220
- });
1221
- }
1222
- // Catch→initFailedReport for the deterministic stages: format the raw exception
1223
- // into a single-line `received` and write the failure run_state, so the cast +
1224
- // `slice(0,160)` + updates boilerplate lives here instead of being hand-copied
1225
- // into every stage's catch block.
1226
- function stageFailure(workspace, exc, opts) {
1227
- const e = exc;
1228
- const received = exc instanceof CliError && exc.received.length > 0
1229
- ? exc.received
1230
- : [`${e?.name ?? "Error"}: ${(e?.message ?? "").slice(0, 160)}`];
1231
- throw initFailedReport(workspace, {
1232
- title: opts.title,
1233
- stage: opts.stage,
1234
- exitCode: exc instanceof CliError ? exc.exitCode : undefined,
1235
- errorCode: exc instanceof CliError ? exc.errorCode : undefined,
1236
- required: opts.required,
1237
- received,
1238
- nextSteps: opts.nextSteps,
1239
- ...(opts.updates ? { updates: opts.updates } : {}),
1240
- });
1241
- }
1242
- // ---------------------------------------------------------------------------
1243
- // Concurrency limiter (mirrors ThreadPoolExecutor)
1244
- // ---------------------------------------------------------------------------
1245
- async function pMapWithConcurrency(items, concurrency, worker) {
1246
- const out = new Array(items.length);
1247
- let cursor = 0;
1248
- const limit = Math.max(1, Math.min(concurrency, items.length));
1249
- await Promise.all(Array.from({ length: limit }, async () => {
1250
- while (true) {
1251
- const idx = cursor++;
1252
- if (idx >= items.length)
1253
- break;
1254
- try {
1255
- const value = await worker(items[idx], idx);
1256
- out[idx] = { ok: true, value };
1257
- }
1258
- catch (error) {
1259
- out[idx] = { ok: false, error, item: items[idx] };
1260
- }
1261
- }
1262
- }));
1263
- return out;
1264
- }
1265
- function unwrapConcurrentOutcomes(outcomes) {
1266
- const failed = outcomes.find((outcome) => !outcome.ok);
1267
- if (failed)
1268
- throw failed.error;
1269
- return outcomes.map((outcome) => outcome.value);
1270
- }
1271
- function providerErrorSummary(error) {
1272
- if (error instanceof CliError && error.errorCode)
1273
- return error.errorCode;
1274
- const e = error;
1275
- const msg = strOf(e?.message).replace(/\s+/g, " ").trim();
1276
- if (/prohibited content|content filter|refused/i.test(msg))
1277
- return "PROVIDER_CONTENT_FILTERED";
1278
- return `${e?.name ?? "Error"}: ${msg}`.slice(0, 160);
1279
- }
1280
- function cloneDict(value) {
1281
- return structuredClone(value);
1282
- }
1283
- // Generate the plan's missing episode titles in bounded, concurrent chunks.
1284
- // Title generation is non-essential (every episode has a deterministic
1285
- // fallback), so a chunk that the provider filters or fails does NOT abort
1286
- // init: its episodes are retried one-by-one to isolate the poisoned excerpt,
1287
- // and whatever still fails degrades to a deterministic title. The plan is
1288
- // mutated in place and persisted by the caller; degradation is recorded to
1289
- // run_state so it's visible without being a hard failure.
1290
- async function enrichEpisodeTitlesChunked(workspace, sourceText, plan, provider, concurrency) {
1291
- const chunks = chunkEpisodesNeedingTitles(plan, DEFAULT_TITLE_CHUNK_SIZE);
1292
- if (chunks.length === 0 || !provider.extractEpisodeTitles) {
1293
- // Nothing to title, or a provider with no title support: deterministic for all.
1294
- return applyEpisodeTitlesToPlan(sourceText, plan, new Map());
1295
- }
1296
- const extract = provider.extractEpisodeTitles.bind(provider);
1297
- const generated = new Map();
1298
- const collect = (outcomes) => {
1299
- for (const o of outcomes) {
1300
- if (o.ok)
1301
- for (const [num, title] of o.value)
1302
- generated.set(num, title);
1303
- }
1304
- };
1305
- // Phase 1: chunked requests.
1306
- const chunkOutcomes = await pMapWithConcurrency(chunks, concurrency, async (chunk) => episodeTitleResponseMap(await extract(sourceText, { episodes: chunk })));
1307
- collect(chunkOutcomes);
1308
- // Phase 2: any episode still WITHOUT a title — whether its chunk threw or the
1309
- // chunk returned 200 but omitted it — gets one isolated single-episode request,
1310
- // so a single poisoned/omitted excerpt can't deny its chunk-mates a real title.
1311
- const missing = chunks.flat().filter((ep) => !generated.has(Number(ep["episode"] ?? 0)));
1312
- let reason;
1313
- if (missing.length > 0) {
1314
- const singleOutcomes = await pMapWithConcurrency(missing, concurrency, async (ep) => episodeTitleResponseMap(await extract(sourceText, { episodes: [ep] })));
1315
- collect(singleOutcomes);
1316
- const firstError = singleOutcomes.find((o) => !o.ok);
1317
- if (firstError)
1318
- reason = providerErrorSummary(firstError.error);
1319
- }
1320
- const degraded = applyEpisodeTitlesToPlan(sourceText, plan, generated);
1321
- if (degraded.length > 0) {
1322
- updateRunState(workspace, {
1323
- title_degraded: {
1324
- count: degraded.length,
1325
- episodes: degraded,
1326
- reason: reason ?? "provider returned no title for these episodes; used deterministic titles",
1327
- },
1328
- });
1329
- }
1330
- return degraded;
1331
- }
1332
- // ---------------------------------------------------------------------------
1333
- // Synopsis reduce helpers (map-reduce overview)
1334
- // ---------------------------------------------------------------------------
1335
- // Stitch an ordered list of synopses into one. ≥2 beats → LLM stitch; falls
1336
- // back to a plain join when the provider has no synopsis method or returns
1337
- // empty. Throws if the provider call throws — callers catch for soft-degrade.
1338
- async function reduceSynopses(provider, synopses, ctx) {
1339
- if (provider.extractEpisodeSynopsis) {
1340
- const out = await provider.extractEpisodeSynopsis(synopses, ctx);
1341
- const stitched = isDict(out) ? strOf(out["synopsis"]).trim() : "";
1342
- if (stitched)
1343
- return stitched;
1344
- }
1345
- return synopses.join(" ");
1346
- }
1347
- // Reduce-1: resolve each merged episode's `synopsis` IN PLACE. ≤1 fragment beat
1348
- // → passthrough. ≥2 beats: if the episode is unchanged this round and has a
1349
- // persisted `syn`, REUSE it verbatim (never overwrite a good prior synopsis —
1350
- // including under --skip-summary); else --skip-summary → join the fresh beats
1351
- // (no LLM); else defer the LLM stitch. The deferred stitches run concurrently;
1352
- // a failed stitch degrades to the FRESH joined beats (never a stale prior, which
1353
- // for a changed episode would describe old content). Returns degraded episode
1354
- // numbers. Reads prior `syn` before the caller overwrites ep_NNN.json.
1355
- async function resolveEpisodeSynopses(provider, results, opts) {
1356
- const stitchPending = [];
1357
- for (const merged of results) {
1358
- const episodeNum = Number(merged["episode"]);
1359
- const beats = cleanSynopsisList(merged["batch_synopses"]);
1360
- delete merged["batch_synopses"];
1361
- if (beats.length <= 1) {
1362
- if (beats[0])
1363
- merged["synopsis"] = beats[0];
1364
- }
1365
- else {
1366
- const unchanged = !opts.resummarize && !opts.changedEpisodes.has(episodeNum);
1367
- const priorSyn = unchanged ? readEpisodeSynopsis(opts.resultsDir, merged) : "";
1368
- if (unchanged && priorSyn)
1369
- merged["synopsis"] = priorSyn;
1370
- else if (opts.skipSummary)
1371
- merged["synopsis"] = beats.join(" ");
1372
- else
1373
- stitchPending.push({ merged, beats });
1374
- }
1375
- }
1376
- const degraded = [];
1377
- await pMapWithConcurrency(stitchPending, opts.concurrency, async (it) => {
1378
- const episodeNum = Number(it.merged["episode"]);
1379
- try {
1380
- const syn = await reduceSynopses(provider, it.beats, { episode: episodeNum, title: it.merged["title"] });
1381
- if (syn)
1382
- it.merged["synopsis"] = syn;
1383
- }
1384
- catch {
1385
- degraded.push(episodeNum);
1386
- it.merged["synopsis"] = it.beats.join(" ");
1387
- }
1388
- });
1389
- return degraded;
1390
- }
1391
- // Single source of truth for the overview field-shaping rules (trim strings,
1392
- // keep only well-formed main_characters). Both the persisted synopsis.json and
1393
- // the in-memory script go through this, so the two can never normalize differently.
1394
- function normalizeOverviewFields(fields) {
1395
- return {
1396
- synopsis: strOf(fields["synopsis"]).trim(),
1397
- theme: strOf(fields["theme"]).trim(),
1398
- logline: strOf(fields["logline"]).trim(),
1399
- style: strOf(fields["style"]).trim(),
1400
- main_characters: asList(fields["main_characters"]).filter(isDict),
1401
- };
1402
- }
1403
- // Assign ALREADY-normalized overview fields onto the script. Callers with raw
1404
- // provider/disk fields must go through applyScriptSynopsisFields instead.
1405
- function assignOverviewFields(script, o) {
1406
- script["synopsis"] = o["synopsis"];
1407
- script["theme"] = o["theme"];
1408
- script["logline"] = o["logline"];
1409
- script["main_characters"] = o["main_characters"];
1410
- // style is produced by the summary layer (a whole-show judgement); only
1411
- // override the merge-stage placeholder when the reduce actually produced one.
1412
- if (o["style"])
1413
- script["style"] = o["style"];
1414
- }
1415
- // Normalize untrusted fields (provider output / a reused synopsis.json) and
1416
- // assign them onto the script.
1417
- function applyScriptSynopsisFields(script, fields) {
1418
- assignOverviewFields(script, normalizeOverviewFields(fields));
1419
- }
1420
- function synopsisGroupSignature(opts) {
1421
- return sha256Text(JSON.stringify(sortDeep({
1422
- title: strOf(opts.title),
1423
- thresholdChars: opts.thresholdChars,
1424
- groupSize: opts.groupSize,
1425
- level: opts.level,
1426
- groupIndex: opts.groupIndex,
1427
- synopses: opts.synopses,
1428
- })));
1429
- }
1430
- function synopsisGroupPartPath(dd, signature) {
1431
- return path.join(dd, "synopsis_parts", `${signature}.json`);
1432
- }
1433
- async function loadOrRunSynopsisGroup(provider, groupSynopses, opts) {
1434
- if (groupSynopses.length <= 1)
1435
- return { value: groupSynopses[0] ?? "", reused: false };
1436
- const signature = synopsisGroupSignature({
1437
- title: opts.title,
1438
- thresholdChars: opts.thresholdChars,
1439
- groupSize: opts.groupSize,
1440
- level: opts.level,
1441
- groupIndex: opts.groupIndex,
1442
- synopses: groupSynopses,
1443
- });
1444
- const partPath = synopsisGroupPartPath(opts.dd, signature);
1445
- if (!opts.resummarize && exists(partPath)) {
1446
- const prior = readJson(partPath);
1447
- if (isDict(prior) && strOf(prior["source_group_signature"]) === signature) {
1448
- const synopsis = strOf(prior["synopsis"]).trim();
1449
- if (synopsis)
1450
- return { value: synopsis, reused: true };
1451
- }
1452
- }
1453
- try {
1454
- const synopsis = await reduceSynopses(provider, [...groupSynopses], { title: opts.title });
1455
- writeJson(partPath, {
1456
- version: 1,
1457
- source_group_signature: signature,
1458
- level: opts.level,
1459
- group_index: opts.groupIndex,
1460
- source_count: groupSynopses.length,
1461
- synopsis,
1462
- updated_at: checkpointTimestamp(),
1463
- });
1464
- return { value: synopsis, reused: false };
1465
- }
1466
- catch (error) {
1467
- return { value: groupSynopses.join(" "), error, reused: false };
1468
- }
1469
- }
1470
- // Whole-script reduce-2: collapse the episode synopses down to a set that fits
1471
- // one final reduce, then produce the top-level overview. Reuses a prior
1472
- // synopsis.json when the episode synopses are unchanged (signature match). Soft
1473
- // per-group: a failed intermediate stitch degrades to its joined beats (the rest
1474
- // keep stitching) rather than wiping the whole overview; only a failed FINAL
1475
- // synthesis falls the overview back to joined beats. Never blocks init; the
1476
- // degrade reason is surfaced for diagnostics.
1477
- async function buildScriptSynopsis(provider, script, opts) {
1478
- const synopsisPath = path.join(opts.dd, "synopsis.json");
1479
- const episodeSynopses = cleanSynopsisList(asList(script["episodes"]).map((ep) => ep["synopsis"]));
1480
- // Nothing to summarize → leave the placeholders empty (not a degrade).
1481
- if (episodeSynopses.length === 0)
1482
- return { degraded: false, reused: false };
1483
- // Signature gates reuse: include every input that changes the overview —
1484
- // the episode synopses AND the title (it is injected into the reduce prompt)
1485
- // AND the reduce shape (threshold/groupSize). Otherwise a rename or a
1486
- // different threshold would silently reuse a stale overview. sortDeep keeps
1487
- // the digest stable under any future key reordering (repo cache-key convention).
1488
- const signature = sha256Text(JSON.stringify(sortDeep({
1489
- title: strOf(script["title"]),
1490
- thresholdChars: opts.thresholdChars,
1491
- groupSize: opts.groupSize,
1492
- episodes: episodeSynopses,
1493
- })));
1494
- // Reuse: a prior synopsis.json with a matching signature and no --resummarize.
1495
- // This runs before the --skip-summary check ON PURPOSE: --skip-summary means
1496
- // "don't spend LLM on summaries", not "discard a summary we already have".
1497
- if (!opts.resummarize && exists(synopsisPath)) {
1498
- try {
1499
- const prior = readJson(synopsisPath);
1500
- if (isDict(prior) && strOf(prior["source_episode_signature"]) === signature) {
1501
- applyScriptSynopsisFields(script, prior);
1502
- return { degraded: false, reused: true };
1503
- }
1504
- }
1505
- catch {
1506
- // fall through to recompute
1507
- }
1508
- }
1509
- // --skip-summary: no reusable overview above → skip all reduce LLM and leave
1510
- // the top-level overview empty (the placeholders from the merge stage stand).
1511
- if (opts.skipSummary)
1512
- return { degraded: false, reused: false };
1513
- let degraded = false;
1514
- let reason = "";
1515
- const noteDegrade = (err) => {
1516
- degraded = true;
1517
- if (!reason)
1518
- reason = providerErrorSummary(err);
1519
- };
1520
- try {
1521
- // Reduce one level at a time until the surviving set fits a single final
1522
- // reduce. planSynopsisReduction guarantees progress (it returns fitsInOnePass
1523
- // whenever it can't form fewer groups than items), so `current` strictly
1524
- // shrinks each non-terminal pass and this terminates without a counter.
1525
- let current = episodeSynopses;
1526
- let level = 1;
1527
- while (true) {
1528
- const plan = planSynopsisReduction(current, opts.thresholdChars, opts.groupSize);
1529
- if (plan.fitsInOnePass)
1530
- break;
1531
- // Groups within a level are independent → stitch them concurrently. A
1532
- // singleton group passes through verbatim (no LLM rewrite), mirroring the
1533
- // reduce-1 passthrough. A group whose stitch FAILS degrades to its joined
1534
- // beats (soft, per-group) — one bad group never wipes the whole overview.
1535
- const currentLevel = level;
1536
- const outcomes = await pMapWithConcurrency(plan.groups, opts.concurrency, async (group, groupIndex) => {
1537
- const groupSynopses = group.map((i) => current[i]).filter((s) => s.trim());
1538
- return loadOrRunSynopsisGroup(provider, groupSynopses, { ...opts, title: script["title"], level: currentLevel, groupIndex });
1539
- });
1540
- const next = [];
1541
- for (const o of outcomes) {
1542
- if (!o.ok) {
1543
- noteDegrade(o.error);
1544
- continue;
1545
- } // unexpected worker throw (worker already soft-handles reduce)
1546
- if (o.value.error)
1547
- noteDegrade(o.value.error);
1548
- if (o.value.value.trim())
1549
- next.push(o.value.value);
1550
- }
1551
- current = next;
1552
- level += 1;
1553
- if (current.length === 0)
1554
- break;
1555
- }
1556
- // Final synthesis. On failure, fall the overview synopsis back to the joined
1557
- // surviving beats rather than wiping everything (theme/logline/etc. stay empty).
1558
- let fields = {};
1559
- if (provider.extractScriptSynopsis) {
1560
- try {
1561
- const out = await provider.extractScriptSynopsis(current, { title: script["title"] });
1562
- if (isDict(out))
1563
- fields = out;
1564
- }
1565
- catch (err) {
1566
- noteDegrade(err);
1567
- }
1568
- }
1569
- const overview = {
1570
- ...normalizeOverviewFields(fields),
1571
- source_episode_signature: signature,
1572
- };
1573
- if (!strOf(overview["synopsis"]))
1574
- overview["synopsis"] = current.join(" "); // fall back to joined beats
1575
- if (!strOf(overview["synopsis"])) {
1576
- degraded = true;
1577
- if (!reason)
1578
- reason = "empty reduce output";
1579
- }
1580
- assignOverviewFields(script, overview); // overview is already normalized — assign directly (no re-normalize)
1581
- writeJson(synopsisPath, overview);
1582
- return { degraded, reused: false, reason: reason || undefined };
1583
- }
1584
- catch (err) {
1585
- // Last-resort soft-degrade (e.g. writeJson failure): never block init.
1586
- noteDegrade(err);
1587
- return { degraded: true, reused: false, reason: reason || undefined };
1588
- }
1589
- }
1590
- // ---------------------------------------------------------------------------
1591
- // command_init
1592
- // ---------------------------------------------------------------------------
1593
- // 转完后把集数写回项目组(首页「我的项目」卡片读的 episode_count)。沙箱里
1594
- // SANDBOX_PROJECT_GROUP_NO/AWB_BASE_URL/access key 齐全时才生效;本地 dev 没绑定
1595
- // 远端项目组就静默跳过。全程 best-effort——没配置、网络或鉴权失败都不影响 init 结果。
1596
- async function syncProjectGroupEpisodeCount(episodeCount) {
1597
- let client;
1598
- try {
1599
- client = remoteScriptOutputStoreFromEnv();
1600
- }
1601
- catch {
1602
- return; // 未绑定远端项目组(非沙箱)→ 不写
1603
- }
1604
- await syncEpisodeCount(client, episodeCount);
1605
- }
1606
- export async function commandInit(opts) {
1607
- const workspace = strOf(opts["workspace_path"] || "workspace");
1608
- try {
1609
- return await commandInitImpl(opts);
1610
- }
1611
- catch (exc) {
1612
- emitFailedInitProgress(workspace);
1613
- throw exc;
1614
- }
1615
- finally {
1616
- finishInitProgress();
1617
- }
1618
- }
1619
- // Parse `direct init` / `direct run` options into the typed DirectRunOptions the
1620
- // stage pipeline consumes. Single source of truth for flag parsing + validation
1621
- // so init and run can't drift on defaults (--concurrency / batch sizing / the
1622
- // selection flags). Throws the same USAGE/INPUT CliErrors the god-command did.
1623
- function parseDirectRunOptions(opts) {
1624
- const sourcePathArg = strOf(opts["source_path"]);
1625
- const source = sourcePathArg.startsWith("~")
1626
- ? path.join(process.env.HOME ?? "", sourcePathArg.slice(1))
1627
- : sourcePathArg;
1628
- const workspace = strOf(opts["workspace_path"] || "workspace");
1629
- const providerName = strOf(opts["provider"] || DEFAULT_PROVIDER);
1630
- const model = strOf(opts["model"] || process.env.SCRIPTCTL_ANTHROPIC_MODEL || DEFAULT_MODEL);
1631
- // Explicit selection: the agent decides what to (re)run. Default is "resume"
1632
- // — only batches never attempted run. --episodes / --batches force-rerun the
1633
- // named units, --retry-errors force-reruns everything currently errored, --all
1634
- // reruns everything. Forced units have their stale result/error deleted first.
1635
- const selEpisodes = new Set(parseIntList(opts["episodes"]));
1636
- const selBatches = new Set(parseStrList(opts["batches"]));
1637
- const retryErrors = Boolean(opts["retry_errors"]);
1638
- const runAll = Boolean(opts["all"]);
1639
- // Synopsis stage controls. --skip-summary skips all summary LLM (episode
1640
- // synopses degrade to joined beats, top-level overview stays empty);
1641
- // --resummarize forces the summary LLM to re-run even when structure is
1642
- // reused (e.g. after a prompt change).
1643
- const skipSummary = Boolean(opts["skip_summary"]);
1644
- const skipMetadata = Boolean(opts["skip_metadata"]);
1645
- const resummarize = Boolean(opts["resummarize"]);
1646
- const summaryThresholdChars = parsePositiveIntOption(opts["summary_threshold_chars"], "--summary-threshold-chars", "USAGE ERROR: Invalid summary threshold", DEFAULT_SUMMARY_REDUCE_THRESHOLD_CHARS);
1647
- const concurrency = parsePositiveIntOption(opts["concurrency"], "--concurrency", "USAGE ERROR: Invalid concurrency", DEFAULT_DIRECT_CONCURRENCY);
1648
- const batchMode = strOf(opts["batch_mode"] || DEFAULT_BATCH_MODE).trim();
1649
- if (batchMode !== "episode") {
1650
- throw new CliError("USAGE ERROR: Invalid batch mode", "Invalid batch mode.", {
1651
- exitCode: EXIT_USAGE,
1652
- required: ["--batch-mode: episode"],
1653
- received: [`--batch-mode: ${batchMode}`],
1654
- nextSteps: ["Use --batch-mode episode and rerun init."],
1655
- });
1656
- }
1657
- const batchValues = {};
1658
- for (const [optName, defaultValue] of [
1659
- ["batch_target_lines", DEFAULT_BATCH_TARGET_LINES],
1660
- ["batch_max_chars", DEFAULT_BATCH_MAX_CHARS],
1661
- ["batch_min_lines", DEFAULT_BATCH_MIN_LINES],
1662
- ]) {
1663
- batchValues[optName] = parsePositiveIntOption(opts[optName], `--${optName.replace(/_/g, "-")}`, "USAGE ERROR: Invalid batch option", defaultValue);
1664
- }
1665
- const batchTargetLines = batchValues["batch_target_lines"];
1666
- const batchMaxChars = batchValues["batch_max_chars"];
1667
- const batchMinLines = Math.min(batchValues["batch_min_lines"], batchTargetLines);
1668
- return {
1669
- source,
1670
- workspace,
1671
- providerName,
1672
- model,
1673
- concurrency,
1674
- batchMode,
1675
- batchTargetLines,
1676
- batchMaxChars,
1677
- batchMinLines,
1678
- selEpisodes,
1679
- selBatches,
1680
- retryErrors,
1681
- runAll,
1682
- skipSummary,
1683
- skipMetadata,
1684
- resummarize,
1685
- summaryThresholdChars,
1686
- };
1687
- }
1688
- // Build the per-stage progress emitter + run_state updater + lazy provider that
1689
- // every stage shares. Extracted so commandInit and commandRun assemble an
1690
- // identical StageContext (same progress rendering, same run_state side effects).
1691
- function buildStageContext(options) {
1692
- const ws = new Workspace(options.workspace);
1693
- let provider = null;
1694
- const timing = new JsonTimingRecorder(ws, options.workspace);
1695
- const ctx = {
1696
- ws,
1697
- options,
1698
- state: emptyRunState(),
1699
- timing,
1700
- provider() {
1701
- if (provider)
1702
- return provider;
1703
- try {
1704
- provider = makeProvider(options.providerName, options.model);
1705
- }
1706
- catch (exc) {
1707
- if (exc instanceof CliError) {
1708
- updateRunState(options.workspace, {
1709
- status: "init_failed",
1710
- init_stage: "episode_titles",
1711
- last_error: { title: exc.title, received: exc.received, failed_at: checkpointTimestamp() },
1712
- });
1713
- }
1714
- throw exc;
1715
- }
1716
- return provider;
1717
- },
1718
- emitProgress(stage, status = "running") {
1719
- emitInitProgress(options.workspace, stageRunStateName(stage), status);
1720
- },
1721
- updateRunState(updates) {
1722
- updateRunState(options.workspace, updates);
1723
- },
1724
- };
1725
- return ctx;
1726
- }
1727
- // Validate --source-path (existence + supported extension) before any stage
1728
- // runs. Kept as the init-only entry guard the god-command performed up front.
1729
- function assertInitSource(source) {
1730
- if (!exists(source) || !fs.statSync(source).isFile()) {
1731
- throw new CliError("INIT BLOCKED: Source file not found", "Source file not found.", {
1732
- exitCode: EXIT_INPUT,
1733
- required: [`--source-path: existing ${SUPPORTED_EXTS} file`],
1734
- received: [`--source-path: ${source}`],
1735
- nextSteps: ["Use an existing file path and rerun init."],
1736
- });
1737
- }
1738
- const ext = path.extname(source).toLowerCase();
1739
- const supportedExts = new Set([".txt", ".md", ".docx", ".xlsx", ".pdf", ".json"]);
1740
- if (!supportedExts.has(ext)) {
1741
- throw new CliError("INIT BLOCKED: Unsupported source format", "Unsupported source format.", {
1742
- exitCode: EXIT_USAGE,
1743
- required: [`--source-path: ${SUPPORTED_EXTS}`],
1744
- received: [`--source-path: ${path.basename(source)}`],
1745
- nextSteps: ["Use a supported source file and rerun init."],
1746
- });
1747
- }
1748
- }
1749
- // Build the final INIT report from the completed run state. Reads the same
1750
- // artifacts/state the god-command's trailing block read, so the report lines,
1751
- // artifacts list and exit code are byte-identical.
1752
- function buildInitReport(ctx) {
1753
- const { ws, options, state } = ctx;
1754
- const dd = ws.dir;
1755
- const validation = state.validation ?? {};
1756
- const passed = Boolean(validation["passed"]);
1757
- const title = passed
1758
- ? "INIT COMPLETE: Initial script ready"
1759
- : "INIT NEEDS AGENT: Initial script written with repair issues";
1760
- const stats = validation["stats"] ?? {};
1761
- const summaryDegraded = state.summaryFullDegraded || state.summaryDegradedEpisodes.length > 0;
1762
- const summaryLine = formatSummaryStatusLine(options.skipSummary, summaryDegraded, state.summaryDegradedEpisodes);
1763
- const normalization = isDict(state.assetNormalization) ? state.assetNormalization : null;
1764
- const normSummary = normalization && isDict(normalization["summary"]) ? normalization["summary"] : {};
1765
- const merges = Number(normSummary["asset_merges"] ?? 0);
1766
- const statesFromNames = Number(normSummary["location_states_from_names"] ?? 0);
1767
- const statesFromTime = Number(normSummary["location_states_from_time"] ?? 0);
1768
- const reviewItems = Number(normSummary["review_items"] ?? 0);
1769
- const norm = merges || statesFromNames || statesFromTime || reviewItems;
1770
- const report = {
1771
- title,
1772
- result: [
1773
- `${stats["episodes"] ?? 0} episodes / ${stats["scenes"] ?? 0} scenes / ${stats["actions"] ?? 0} actions; validation ${passed ? "passed" : "needs repair"}`,
1774
- `provider ${options.providerName}; ${state.mergedResults.length}/${state.totalEpisodes} episodes, ${state.doneBatches}/${state.totalBatches} batches`,
1775
- // Deviation-only lines: omit when at default/clean state.
1776
- ...(norm ? [`normalization: merges=${merges}, states=${statesFromNames + statesFromTime}, review=${reviewItems}`] : []),
1777
- ...(options.skipMetadata ? ["metadata: skipped"] : []),
1778
- ...(state.titleDegraded.length > 0 ? [`titles degraded (deterministic): ${state.titleDegraded.join(", ")}`] : []),
1779
- ...(summaryLine ? [summaryLine] : []),
1780
- ],
1781
- // The produced script is the handle for `direct review` / validate / export;
1782
- // validation.json only matters when there are issues to inspect.
1783
- artifacts: passed
1784
- ? [path.join(dd, "script.initial.json")]
1785
- : [path.join(dd, "script.initial.json"), path.join(dd, "validation.json")],
1786
- issues: summarizeIssues(asList(validation["issues"])),
1787
- // init is the entrypoint of a multi-command pipeline, so the next handoff is
1788
- // genuinely non-obvious — keep it concise.
1789
- next: options.providerName === "mock"
1790
- ? ["Run `direct review`, then validate/export.", "Do not export mock-provider results for delivery."]
1791
- : ["Run `direct review`, then validate/export."],
1792
- };
1793
- return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
1794
- }
1795
- // Build the INIT INCOMPLETE report (some episode still has unextracted batches).
1796
- // Mirrors the god-command's early-return block; EXIT_RUNTIME signals automation.
1797
- function buildIncompleteReport(ctx) {
1798
- const { ws, options, state } = ctx;
1799
- const dd = ws.dir;
1800
- const report = {
1801
- title: "INIT INCOMPLETE: Some episodes have unextracted batches",
1802
- result: [
1803
- `episodes: ${state.mergedResults.length}/${state.totalEpisodes} complete`,
1804
- `incomplete episodes: ${state.incompleteEpisodes.join(", ")}`,
1805
- `content: ${state.doneBatches}/${state.totalBatches} done, ${state.erroredBatches} error`,
1806
- ...(state.titleDegraded.length > 0 ? [`titles degraded (deterministic): ${state.titleDegraded.join(", ")}`] : []),
1807
- `provider: ${options.providerName}`,
1808
- ],
1809
- artifacts: [
1810
- ws.rootPathOf("source.txt"),
1811
- path.join(dd, "episode_plan.json"),
1812
- path.join(dd, "batch_plan.json"),
1813
- path.join(dd, "batch_results"),
1814
- path.join(dd, "episode_results"),
1815
- path.join(dd, "run_state.json"),
1816
- ],
1817
- next: [
1818
- "Run `direct status` to see episode progress.",
1819
- "Re-run one episode with `direct init --episodes <n>`, or all errors with `--retry-errors`.",
1820
- "Errored units are left untouched on a plain rerun; select them to retry.",
1821
- ],
1822
- };
1823
- return [report, EXIT_RUNTIME];
1824
- }
1825
- function initPreFanoutStages() {
1826
- const end = STAGES.findIndex((stage) => stage.name === "state-curation");
1827
- return STAGES.slice(0, end + 1);
1828
- }
1829
- function validateOnlyStages() {
1830
- return STAGES.filter((stage) => stage.name === "validate");
1831
- }
1832
- function updatePostCurationBranch(ctx, name, status) {
1833
- const current = readRunState(ctx.options.workspace);
1834
- const rawBranches = current["post_curation_branches"];
1835
- const branches = isDict(rawBranches) ? { ...rawBranches } : {};
1836
- branches[name] = { status, updated_at: checkpointTimestamp() };
1837
- ctx.updateRunState({ post_curation_branches: branches });
1838
- }
1839
- async function runPostCurationBranch(ctx, name, run) {
1840
- updatePostCurationBranch(ctx, name, "running");
1841
- try {
1842
- const value = await ctx.timing.span({ name, category: "branch", parent: "post_curation" }, run);
1843
- updatePostCurationBranch(ctx, name, "success");
1844
- return value;
1845
- }
1846
- catch (error) {
1847
- updatePostCurationBranch(ctx, name, "error");
1848
- throw error;
1849
- }
1850
- }
1851
- async function runPostCurationFanout(ctx) {
1852
- const { ws, state, options } = ctx;
1853
- const baseScript = ensureScript(ctx);
1854
- ctx.updateRunState({
1855
- status: "init_running",
1856
- init_stage: "state_binding",
1857
- post_curation_parallel: true,
1858
- post_curation_branches: {
1859
- state_binding: { status: "running", updated_at: checkpointTimestamp() },
1860
- metadata_extract: { status: options.skipMetadata ? "skipped" : "running", updated_at: checkpointTimestamp() },
1861
- script_synopsis: { status: options.skipSummary ? "skipped" : "running", updated_at: checkpointTimestamp() },
1862
- },
1863
- });
1864
- ctx.emitProgress("state-binding");
1865
- ctx.emitProgress("metadata", options.skipMetadata ? "skipped" : "running");
1866
- ctx.emitProgress("script-synopsis", options.skipSummary ? "skipped" : "running");
1867
- const stateBindingPromise = runPostCurationBranch(ctx, "state_binding", async () => {
1868
- const script = cloneDict(baseScript);
1869
- const result = await runStateBindingForScript(ctx, script, false);
1870
- return { ...result, script };
1871
- });
1872
- const metadataPromise = runPostCurationBranch(ctx, "metadata_extract", async () => {
1873
- const script = cloneDict(baseScript);
1874
- return runMetadataForScript(ctx, script, false, false);
1875
- });
1876
- const synopsisPromise = runPostCurationBranch(ctx, "script_synopsis", async () => {
1877
- const script = cloneDict(baseScript);
1878
- return runScriptSynopsisForScript(ctx, script, false);
1879
- });
1880
- const settled = await Promise.allSettled([stateBindingPromise, metadataPromise, synopsisPromise]);
1881
- const failed = settled.find((item) => item.status === "rejected");
1882
- if (failed && failed.status === "rejected")
1883
- throw failed.reason;
1884
- const stateBinding = await stateBindingPromise;
1885
- const metadata = await metadataPromise;
1886
- const synopsis = await synopsisPromise;
1887
- await ctx.timing.span({ name: "post_curation.merge", category: "substage", parent: "post_curation" }, () => {
1888
- const finalScript = stateBinding.script;
1889
- if (!metadata.skipped)
1890
- applyMetadataToScript(finalScript, metadata.metadata);
1891
- applyScriptSynopsisFields(finalScript, synopsis.fields);
1892
- ws.writeJson("script.initial.json", finalScript);
1893
- state.script = finalScript;
1894
- state.summaryFullDegraded = synopsis.degraded;
1895
- state.summaryDegradeReason = synopsis.reason;
1896
- });
1897
- state.summaryDegradedEpisodes.sort((a, b) => a - b);
1898
- const summaryDegraded = state.summaryFullDegraded || state.summaryDegradedEpisodes.length > 0;
1899
- ctx.updateRunState({
1900
- post_curation_parallel: false,
1901
- summary_skipped: options.skipSummary,
1902
- summary_degraded: summaryDegraded,
1903
- summary_degraded_episodes: state.summaryDegradedEpisodes,
1904
- summary_degraded_reason: state.summaryDegradeReason || null,
1905
- metadata_skipped: options.skipMetadata,
1906
- });
1907
- ctx.emitProgress("state-binding", "success");
1908
- ctx.emitProgress("metadata", metadata.skipped ? "skipped" : "success");
1909
- ctx.emitProgress("script-synopsis", summaryDegraded ? "warning" : options.skipSummary ? "skipped" : "success");
1910
- }
1911
- async function commandInitImpl(opts) {
1912
- const options = parseDirectRunOptions(opts);
1913
- assertInitSource(options.source);
1914
- const ctx = buildStageContext(options);
1915
- ctx.ws.ensure();
1916
- ctx.timing.reset("direct init");
1917
- // Seed run_state with the init header before the first stage runs (provider /
1918
- // model / source_path / metadata flag) — the god-command set these up front.
1919
- updateRunState(options.workspace, {
1920
- status: "init_running",
1921
- command: "direct init",
1922
- init_stage: "source_prepare",
1923
- provider: options.providerName,
1924
- model: options.model,
1925
- concurrency: options.concurrency,
1926
- source_path: path.resolve(options.source),
1927
- metadata_skipped: options.skipMetadata,
1928
- });
1929
- try {
1930
- await runStages(ctx, initPreFanoutStages(), { force: true, persistScriptAfterStages: false });
1931
- await ctx.timing.span({ name: "post_curation", category: "stage" }, () => runPostCurationFanout(ctx));
1932
- await runStages(ctx, validateOnlyStages(), { force: true, persistScriptAfterStages: false });
1933
- }
1934
- catch (exc) {
1935
- // The resumable "incomplete" stop is not a failure — surface the INCOMPLETE
1936
- // report + EXIT_RUNTIME, exactly as the god-command's early return did.
1937
- if (exc instanceof StageIncomplete)
1938
- return buildIncompleteReport(ctx);
1939
- throw exc;
1940
- }
1941
- // Pipeline complete: finalize run_state (the trailing god-command block).
1942
- const validation = ctx.state.validation ?? {};
1943
- const passed = Boolean(validation["passed"]);
1944
- const status = passed ? "ready_for_agent" : "needs_agent_repair";
1945
- const summaryDegraded = ctx.state.summaryFullDegraded || ctx.state.summaryDegradedEpisodes.length > 0;
1946
- updateRunState(options.workspace, {
1947
- status,
1948
- command: "direct init",
1949
- init_stage: "complete",
1950
- provider: options.providerName,
1951
- model: options.model,
1952
- concurrency: options.concurrency,
1953
- batch_mode: options.batchMode,
1954
- batch_target_lines: options.batchTargetLines,
1955
- batch_max_chars: options.batchMaxChars,
1956
- batch_min_lines: options.batchMinLines,
1957
- source_path: path.resolve(options.source),
1958
- script_path: ctx.ws.pathOf("script.initial.json"),
1959
- validation_path: ctx.ws.pathOf("validation.json"),
1960
- episode_total: ctx.state.totalEpisodes,
1961
- episode_completed: ctx.state.mergedResults.length,
1962
- incomplete_episodes: [],
1963
- batch_total: ctx.state.totalBatches,
1964
- batch_completed: ctx.state.doneBatches,
1965
- batch_reused: ctx.state.reusedBatchCount,
1966
- batch_failed: ctx.state.erroredBatches,
1967
- summary_threshold_chars: options.summaryThresholdChars,
1968
- summary_skipped: options.skipSummary,
1969
- summary_degraded: summaryDegraded,
1970
- summary_degraded_episodes: ctx.state.summaryDegradedEpisodes,
1971
- summary_degraded_reason: ctx.state.summaryDegradeReason || null,
1972
- metadata_skipped: options.skipMetadata,
1973
- last_error: null,
1974
- });
1975
- // 转完(或重跑后集数变了)→ 回填项目组集数。只在完整完成路径写;INCOMPLETE
1976
- // 早返回不写。best-effort,写同样的集数也无妨(重跑覆盖即更新)。
1977
- await syncProjectGroupEpisodeCount(ctx.state.totalEpisodes);
1978
- return buildInitReport(ctx);
1979
- }
1980
- // ---------------------------------------------------------------------------
1981
- // command_run — single-stage runner against an existing workspace. Lets the
1982
- // agent re-run one step (e.g. `direct run metadata`, `direct run episode-synopsis`)
1983
- // without re-driving the whole pipeline. The stage rehydrates the prior
1984
- // artifacts it needs from disk, so it stands alone.
1985
- // ---------------------------------------------------------------------------
1986
- export async function commandRun(opts) {
1987
- const stageName = strOf(asList(opts["_args"])[0] || opts["stage"]).trim();
1988
- if (!stageName) {
1989
- throw new CliError("DIRECT RUN BLOCKED: missing stage", "No stage name given.", {
1990
- exitCode: EXIT_USAGE,
1991
- required: [`one of: ${STAGES.map((s) => s.name).join(", ")}`],
1992
- received: ["<no stage>"],
1993
- nextSteps: ["Pass a stage name, e.g. `direct run metadata`."],
1994
- });
1995
- }
1996
- // Reject an unknown stage up front (both code paths below would otherwise
1997
- // silently no-op the `only`-filtered runStages on a typo).
1998
- if (!STAGES.some((s) => s.name === stageName)) {
1999
- throw new CliError("DIRECT RUN BLOCKED: unknown stage", `Unknown stage "${stageName}".`, {
2000
- exitCode: EXIT_USAGE,
2001
- required: [`one of: ${STAGES.map((s) => s.name).join(", ")}`],
2002
- received: [stageName],
2003
- nextSteps: ["Pass a valid stage name to `direct run`."],
2004
- });
2005
- }
2006
- const options = parseDirectRunOptions(opts);
2007
- const ctx = buildStageContext(options);
2008
- ctx.ws.ensure();
2009
- ctx.timing.reset(`direct run ${stageName}`);
2010
- const force = Boolean(opts["force"]);
2011
- try {
2012
- if (force) {
2013
- // --force re-runs even when the output artifact is already present.
2014
- await runOne(ctx, stageName);
2015
- }
2016
- else {
2017
- // Without --force, honor skip-on-present so `direct run <stage>` is a no-op
2018
- // when the stage's output already exists (mirrors runStages' resume).
2019
- await runStages(ctx, STAGES, { only: new Set([stageName]) });
2020
- }
2021
- }
2022
- catch (exc) {
2023
- if (exc instanceof StageIncomplete) {
2024
- finishInitProgress();
2025
- return buildIncompleteReport(ctx);
2026
- }
2027
- emitFailedInitProgress(options.workspace);
2028
- finishInitProgress();
2029
- throw exc;
2030
- }
2031
- finishInitProgress();
2032
- const report = {
2033
- title: `DIRECT RUN: ${stageName}`,
2034
- result: [
2035
- `stage: ${stageName}`,
2036
- `workspace: ${options.workspace}`,
2037
- "ran one stage; run `direct status` for the full picture.",
2038
- ],
2039
- artifacts: [ctx.ws.pathOf("run_state.json")],
2040
- next: ["Run `direct status` to see updated progress, or `direct run <stage>` for another step."],
2041
- };
2042
- return [report, EXIT_OK];
2043
- }
2044
- // ===========================================================================
2045
- // Per-stage implementations. Each function below is ONE segment of the former
2046
- // commandInitImpl, lifted verbatim onto a StageContext so it can run either as
2047
- // part of the full pipeline (runStages) or on its own (`direct run <stage>`).
2048
- // They call the SAME helpers/pure functions the god-command used — no algorithm
2049
- // is duplicated; only the cross-stage locals moved onto ctx.state. The thin
2050
- // Stage wrappers in src/domain/direct/stages/*.ts just name + order these.
2051
- // ===========================================================================
2052
- // A stage that needs a prior artifact missing from ctx.state (because the run
2053
- // started at a later stage via `direct run`) lazily rehydrates it from disk.
2054
- function ensureEpisodePlan(ctx) {
2055
- if (ctx.state.episodePlan)
2056
- return ctx.state.episodePlan;
2057
- const plan = ctx.ws.readJsonSafe("episode_plan.json");
2058
- if (!plan) {
2059
- throw new CliError("DIRECT RUN BLOCKED: episode_plan.json missing", "episode_plan.json missing.", {
2060
- exitCode: EXIT_INPUT,
2061
- required: ["episode_plan.json (run the episode-plan stage first)"],
2062
- received: ["episode_plan.json missing"],
2063
- nextSteps: ["Run `direct init` or `direct run episode-plan` first."],
2064
- });
2065
- }
2066
- ctx.state.episodePlan = plan;
2067
- return plan;
2068
- }
2069
- function ensureBatchPlan(ctx) {
2070
- if (ctx.state.batchPlan)
2071
- return ctx.state.batchPlan;
2072
- const plan = ctx.ws.readJsonSafe("batch_plan.json");
2073
- if (!plan) {
2074
- throw new CliError("DIRECT RUN BLOCKED: batch_plan.json missing", "batch_plan.json missing.", {
2075
- exitCode: EXIT_INPUT,
2076
- required: ["batch_plan.json (run the batch-plan stage first)"],
2077
- received: ["batch_plan.json missing"],
2078
- nextSteps: ["Run `direct init` or `direct run batch-plan` first."],
2079
- });
2080
- }
2081
- ctx.state.batchPlan = plan;
2082
- return plan;
2083
- }
2084
- function ensureSourceText(ctx) {
2085
- if (ctx.state.sourceText)
2086
- return ctx.state.sourceText;
2087
- const p = ctx.ws.rootPathOf("source.txt");
2088
- if (!exists(p)) {
2089
- throw new CliError("DIRECT RUN BLOCKED: source.txt missing", "source.txt missing.", {
2090
- exitCode: EXIT_INPUT,
2091
- required: ["source.txt (run the source-prepare stage first)"],
2092
- received: ["source.txt missing"],
2093
- nextSteps: ["Run `direct init` or `direct run source-prepare` first."],
2094
- });
2095
- }
2096
- ctx.state.sourceText = readText(p);
2097
- return ctx.state.sourceText;
2098
- }
2099
- function ensureScript(ctx) {
2100
- if (ctx.state.script)
2101
- return ctx.state.script;
2102
- const script = ctx.ws.readJsonSafe("script.initial.json");
2103
- if (!script) {
2104
- throw new CliError("DIRECT RUN BLOCKED: script.initial.json missing", "script.initial.json missing.", {
2105
- exitCode: EXIT_INPUT,
2106
- required: ["script.initial.json (run the script-merge stage first)"],
2107
- received: ["script.initial.json missing"],
2108
- nextSteps: ["Run `direct init` or `direct run script-merge` first."],
2109
- });
2110
- }
2111
- ctx.state.script = script;
2112
- return script;
2113
- }
2114
- // Reassemble ctx.state.mergedResults from episode_results/ when a later stage is
2115
- // run standalone (e.g. `direct run script-merge`). Mirrors the assembly the
2116
- // episode-merge stage does in-memory, but reads the persisted results.
2117
- function ensureMergedResults(ctx) {
2118
- if (ctx.state.mergedResults.length > 0)
2119
- return ctx.state.mergedResults;
2120
- const plan = ensureEpisodePlan(ctx);
2121
- const episodeResultsDir = ctx.ws.pathOf("episode_results");
2122
- const results = [];
2123
- for (const episode of asList(plan["episodes"])) {
2124
- const p = episodeResultPath(episodeResultsDir, episode);
2125
- const data = exists(p) ? readJsonFile(p, {}) : null;
2126
- if (data && isDict(data) && Object.keys(data).length > 0) {
2127
- results.push(expandCompactEpisodeResult(data, episode));
2128
- }
2129
- }
2130
- results.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
2131
- ctx.state.mergedResults = results;
2132
- return results;
2133
- }
2134
- // Recover the project name when a script-producing stage runs standalone (no
2135
- // source-prepare set state.projectName, and `direct run` has no --source-path).
2136
- // Order: in-memory → persisted manifest (source_name minus ext) → existing
2137
- // script title → source basename. Without this, `direct run script-merge` would
2138
- // rebuild the script with an empty title (state.projectName='' && source='').
2139
- function ensureProjectName(ctx) {
2140
- if (ctx.state.projectName)
2141
- return ctx.state.projectName;
2142
- const manifest = ctx.ws.readJsonSafe("source_manifest.json");
2143
- const fromManifest = manifest
2144
- ? strOf(manifest["source_name"]).replace(/\.[^.]+$/, "")
2145
- || (strOf(manifest["source_path"]) ? path.basename(strOf(manifest["source_path"]), path.extname(strOf(manifest["source_path"]))) : "")
2146
- : "";
2147
- const fromScript = strOf(ctx.ws.readJsonSafe("script.initial.json")?.["title"]);
2148
- const fromSource = ctx.options.source ? path.basename(ctx.options.source, path.extname(ctx.options.source)) : "";
2149
- const name = fromManifest || fromScript || fromSource;
2150
- ctx.state.projectName = name;
2151
- return name;
2152
- }
2153
- // --- source-prepare -------------------------------------------------------
2154
- export async function stageSourcePrepare(ctx) {
2155
- const { options, ws, state } = ctx;
2156
- const source = options.source;
2157
- ctx.updateRunState({ status: "init_running", command: "direct init", init_stage: "source_prepare" });
2158
- ctx.emitProgress("source-prepare");
2159
- let info;
2160
- try {
2161
- info = await prepareSource(source, options.workspace);
2162
- }
2163
- catch (exc) {
2164
- if (exc instanceof CliError) {
2165
- ctx.updateRunState({
2166
- status: "init_failed",
2167
- init_stage: "source_prepare",
2168
- last_error: { title: exc.title, received: exc.received, failed_at: checkpointTimestamp() },
2169
- });
2170
- throw exc;
2171
- }
2172
- const e = exc;
2173
- const receivedError = `${source}: ${e?.name ?? "Error"}${e?.message ? `: ${e.message}` : ""}`;
2174
- ctx.updateRunState({
2175
- status: "init_failed",
2176
- init_stage: "source_prepare",
2177
- last_error: { title: "INIT BLOCKED: Source preparation failed", received: [receivedError], failed_at: checkpointTimestamp() },
2178
- });
2179
- throw new CliError("INIT BLOCKED: Source preparation failed", "Source preparation failed.", {
2180
- exitCode: EXIT_INPUT,
2181
- required: ["readable source file that can be converted to source.txt"],
2182
- received: [receivedError],
2183
- nextSteps: ["Fix or re-export the source file, then rerun init."],
2184
- });
2185
- }
2186
- const sourceTextPath = strOf(info["sourceTextPath"]);
2187
- state.sourceText = readText(sourceTextPath);
2188
- state.projectName = strOf(info["projectName"]) || path.basename(source, path.extname(source));
2189
- state.manifest = makeSourceManifest(source, sourceTextPath, info);
2190
- ws.writeJson("source_manifest.json", state.manifest);
2191
- }
2192
- // --- episode-plan ---------------------------------------------------------
2193
- export async function stageEpisodePlan(ctx) {
2194
- const { ws, state, options } = ctx;
2195
- const sourceText = ensureSourceText(ctx);
2196
- ctx.updateRunState({ status: "init_running", init_stage: "episode_plan" });
2197
- ctx.emitProgress("episode-plan");
2198
- let plan;
2199
- try {
2200
- plan = buildEpisodePlan(sourceText);
2201
- }
2202
- catch (exc) {
2203
- stageFailure(options.workspace, exc, {
2204
- title: "INIT FAILED: Episode planning failed",
2205
- stage: "episode_plan",
2206
- required: ["source.txt that can be split into episodes"],
2207
- nextSteps: ["Inspect workspace/source.txt, fix the source file, and rerun init."],
2208
- });
2209
- }
2210
- state.episodePlan = plan;
2211
- ws.writeJson("episode_plan.json", plan);
2212
- }
2213
- // --- episode-titles -------------------------------------------------------
2214
- export async function stageEpisodeTitles(ctx) {
2215
- const { state, options } = ctx;
2216
- const sourceText = ensureSourceText(ctx);
2217
- const plan = ensureEpisodePlan(ctx);
2218
- // Provider capability gate (was inline in commandInitImpl before titles).
2219
- // Checked twice — declared capabilities, then the constructed provider — but
2220
- // both fail the same way, so share one report builder.
2221
- const lacksExtraction = () => initFailedReport(options.workspace, {
2222
- title: "INIT FAILED: provider lacks batch extraction",
2223
- stage: "episode_titles",
2224
- required: ["provider.extractBatch or provider.extractEpisode"],
2225
- received: [`--provider ${options.providerName}`],
2226
- nextSteps: ["Use --provider anthropic or --provider mock for direct init."],
2227
- });
2228
- const capabilities = providerCapabilities(options.providerName);
2229
- if (capabilities && !capabilities.extractBatch && !capabilities.extractEpisode) {
2230
- throw lacksExtraction();
2231
- }
2232
- const provider = ctx.provider();
2233
- if (!provider.extractBatch && !provider.extractEpisode) {
2234
- throw lacksExtraction();
2235
- }
2236
- ctx.updateRunState({ status: "init_running", init_stage: "episode_titles" });
2237
- ctx.emitProgress("episode-titles");
2238
- state.titleDegraded = await enrichEpisodeTitlesChunked(options.workspace, sourceText, plan, provider, options.concurrency);
2239
- // Titles mutate the plan in place — persist it so the artifact reflects them.
2240
- ctx.ws.writeJson("episode_plan.json", plan);
2241
- }
2242
- // --- batch-plan -----------------------------------------------------------
2243
- export async function stageBatchPlan(ctx) {
2244
- const { ws, state, options } = ctx;
2245
- const sourceText = ensureSourceText(ctx);
2246
- const plan = ensureEpisodePlan(ctx);
2247
- let batchPlan;
2248
- try {
2249
- batchPlan = buildBatchPlan(sourceText, plan, {
2250
- targetLines: options.batchTargetLines,
2251
- maxChars: options.batchMaxChars,
2252
- minLines: options.batchMinLines,
2253
- mode: options.batchMode,
2254
- });
2255
- }
2256
- catch (exc) {
2257
- stageFailure(options.workspace, exc, {
2258
- title: "INIT FAILED: Batch planning failed",
2259
- stage: "batch_plan",
2260
- required: ["episode_plan.json that can be split into natural paragraph batches"],
2261
- nextSteps: ["Inspect workspace/source.txt and episode_plan.json, then rerun init."],
2262
- });
2263
- }
2264
- state.batchPlan = batchPlan;
2265
- ws.writeJson("batch_plan.json", batchPlan);
2266
- ctx.emitProgress("batch-plan", "success");
2267
- }
2268
- // --- batch-extract --------------------------------------------------------
2269
- export async function stageBatchExtract(ctx) {
2270
- const { ws, state, options } = ctx;
2271
- const sourceText = ensureSourceText(ctx);
2272
- const plan = ensureEpisodePlan(ctx);
2273
- const batchPlan = ensureBatchPlan(ctx);
2274
- const provider = ctx.provider();
2275
- const episodeResultsDir = ws.mkdir("episode_results");
2276
- const batchResultsDir = ws.mkdir("batch_results");
2277
- // Non-destructive GC for units the current plan no longer references.
2278
- gcOrphanUnits(episodeResultsDir, new Set(asList(plan["episodes"]).map((ep) => episodeResultKey(ep))));
2279
- gcOrphanUnits(batchResultsDir, new Set(asList(batchPlan["batches"]).map((b) => batchResultKey(b))));
2280
- state.totalEpisodes = asList(plan["episodes"]).length;
2281
- state.totalBatches = asList(batchPlan["batches"]).length;
2282
- // Decide which batches to run (force / reuse-done / sticky-error / pending).
2283
- const pending = [];
2284
- let reusedBatchCount = 0;
2285
- let stickyErrorCount = 0;
2286
- for (const batch of asList(batchPlan["batches"])) {
2287
- const epNum = Number(batch["episode"]);
2288
- const key = batchResultKey(batch);
2289
- const errorPath = batchErrorPath(batchResultsDir, batch);
2290
- const forced = options.runAll || options.selEpisodes.has(epNum) || options.selBatches.has(key) || (options.retryErrors && exists(errorPath));
2291
- if (forced) {
2292
- const resultPath = batchResultPath(batchResultsDir, batch);
2293
- if (exists(resultPath))
2294
- deletePath(resultPath);
2295
- if (exists(errorPath))
2296
- deletePath(errorPath);
2297
- pending.push(batch);
2298
- continue;
2299
- }
2300
- switch (classifyBatch(batchResultsDir, batch)) {
2301
- case "done":
2302
- reusedBatchCount++;
2303
- break;
2304
- case "error":
2305
- stickyErrorCount++;
2306
- break;
2307
- default: pending.push(batch);
2308
- }
2309
- }
2310
- state.pending = pending;
2311
- state.reusedBatchCount = reusedBatchCount;
2312
- state.stickyErrorCount = stickyErrorCount;
2313
- state.changedEpisodes = new Set(pending.map((b) => Number(b["episode"])));
2314
- ctx.updateRunState({
2315
- status: "init_running",
2316
- init_stage: "batch_extract",
2317
- batch_mode: options.batchMode,
2318
- batch_target_lines: options.batchTargetLines,
2319
- batch_max_chars: options.batchMaxChars,
2320
- batch_min_lines: options.batchMinLines,
2321
- episode_total: state.totalEpisodes,
2322
- batch_total: state.totalBatches,
2323
- });
2324
- ctx.emitProgress("batch-extract");
2325
- const outcomes = await pMapWithConcurrency(pending, options.concurrency, async (batch) => {
2326
- try {
2327
- const result = await extractBatchWithRecovery(provider, sourceText, batch);
2328
- persistBatchResult(batchResultsDir, batch, result);
2329
- const errorPath = batchErrorPath(batchResultsDir, batch);
2330
- if (exists(errorPath))
2331
- deletePath(errorPath);
2332
- ctx.emitProgress("batch-extract");
2333
- return true;
2334
- }
2335
- catch (error) {
2336
- writeBatchFailure(batchResultsDir, batch, error);
2337
- ctx.emitProgress("batch-extract");
2338
- return false;
2339
- }
2340
- });
2341
- const freshOk = outcomes.filter((o) => o.ok && o.value === true).length;
2342
- const freshFail = outcomes.filter((o) => o.ok && o.value === false).length;
2343
- state.doneBatches = reusedBatchCount + freshOk;
2344
- state.erroredBatches = stickyErrorCount + freshFail;
2345
- }
2346
- // --- episode-merge --------------------------------------------------------
2347
- export async function stageEpisodeMerge(ctx) {
2348
- const { ws, state } = ctx;
2349
- const sourceText = ensureSourceText(ctx);
2350
- const plan = ensureEpisodePlan(ctx);
2351
- const batchPlan = ensureBatchPlan(ctx);
2352
- const episodeResultsDir = ws.mkdir("episode_results");
2353
- const batchResultsDir = ws.mkdir("batch_results");
2354
- // Recompute the bookkeeping a standalone `direct run episode-merge` needs when
2355
- // batch-extract didn't run this invocation.
2356
- if (state.totalEpisodes === 0)
2357
- state.totalEpisodes = asList(plan["episodes"]).length;
2358
- if (state.totalBatches === 0)
2359
- state.totalBatches = asList(batchPlan["batches"]).length;
2360
- const batchesByEpisode = new Map();
2361
- for (const batch of asList(batchPlan["batches"])) {
2362
- const epNum = Number(batch["episode"]);
2363
- if (!batchesByEpisode.has(epNum))
2364
- batchesByEpisode.set(epNum, []);
2365
- batchesByEpisode.get(epNum).push(batch);
2366
- }
2367
- if (state.doneBatches === 0 && state.erroredBatches === 0) {
2368
- let done = 0;
2369
- let errored = 0;
2370
- for (const batch of asList(batchPlan["batches"])) {
2371
- const s = classifyBatch(batchResultsDir, batch);
2372
- if (s === "done")
2373
- done++;
2374
- else if (s === "error")
2375
- errored++;
2376
- }
2377
- state.doneBatches = done;
2378
- state.erroredBatches = errored;
2379
- }
2380
- const results = [];
2381
- const incompleteEpisodes = [];
2382
- ctx.updateRunState({
2383
- status: "init_running",
2384
- init_stage: "episode_merge",
2385
- episode_total: state.totalEpisodes,
2386
- batch_total: state.totalBatches,
2387
- batch_completed: state.doneBatches,
2388
- batch_failed: state.erroredBatches,
2389
- });
2390
- ctx.emitProgress("episode-merge");
2391
- try {
2392
- for (const episode of asList(plan["episodes"])) {
2393
- const episodeNum = Number(episode["episode"]);
2394
- const epBatches = batchesByEpisode.get(episodeNum) ?? [];
2395
- const batchResults = epBatches.map((b) => readBatchResult(batchResultsDir, b)).filter((r) => r !== null);
2396
- if (epBatches.length === 0 || batchResults.length !== epBatches.length) {
2397
- incompleteEpisodes.push(episodeNum);
2398
- const stale = episodeResultPath(episodeResultsDir, episode);
2399
- if (exists(stale))
2400
- deletePath(stale);
2401
- continue;
2402
- }
2403
- const merged = mergeBatchResultsForEpisode(episode, batchResults);
2404
- validateEpisodeExtractionQuality(sourceText, episode, merged);
2405
- results.push(merged);
2406
- }
2407
- }
2408
- catch (exc) {
2409
- stageFailure(ctx.options.workspace, exc, {
2410
- title: "INIT FAILED: Episode merge failed",
2411
- stage: "episode_merge",
2412
- required: ["batch_results/*.json that can merge into episode_results/*.json"],
2413
- nextSteps: ["Rerun init; completed batches are reused and episode merge will retry."],
2414
- });
2415
- }
2416
- state.mergedResults = results;
2417
- state.incompleteEpisodes = incompleteEpisodes;
2418
- ctx.emitProgress("episode-merge", incompleteEpisodes.length > 0 ? "warning" : "success");
2419
- }
2420
- // --- episode-synopsis -----------------------------------------------------
2421
- export async function stageEpisodeSynopsis(ctx) {
2422
- const { ws, state, options } = ctx;
2423
- const provider = ctx.provider();
2424
- const episodeResultsDir = ws.mkdir("episode_results");
2425
- const results = ensureMergedResults(ctx);
2426
- ctx.updateRunState({ status: "init_running", init_stage: "episode_synopsis" });
2427
- ctx.emitProgress("episode-synopsis");
2428
- state.summaryDegradedEpisodes.push(...await resolveEpisodeSynopses(provider, results, {
2429
- resultsDir: episodeResultsDir,
2430
- resummarize: options.resummarize,
2431
- skipSummary: options.skipSummary,
2432
- changedEpisodes: state.changedEpisodes,
2433
- concurrency: options.concurrency,
2434
- }));
2435
- for (const merged of results) {
2436
- writeJson(episodeResultPath(episodeResultsDir, merged), compactEpisodeResult(merged));
2437
- const errPath = episodeErrorPath(episodeResultsDir, merged);
2438
- if (exists(errPath))
2439
- deletePath(errPath);
2440
- }
2441
- ctx.emitProgress("episode-synopsis", state.summaryDegradedEpisodes.length > 0 ? "warning" : "success");
2442
- // Incomplete episodes are a resumable, non-error stop. The full-pipeline
2443
- // runner converts this into the INIT INCOMPLETE report + EXIT_RUNTIME; a
2444
- // standalone `direct run episode-synopsis` surfaces it the same way.
2445
- if (state.incompleteEpisodes.length > 0) {
2446
- state.incompleteEpisodes.sort((a, b) => a - b);
2447
- ctx.updateRunState({
2448
- status: "init_incomplete",
2449
- init_stage: "batch_extract",
2450
- episode_total: state.totalEpisodes,
2451
- episode_completed: state.mergedResults.length,
2452
- incomplete_episodes: state.incompleteEpisodes,
2453
- batch_total: state.totalBatches,
2454
- batch_completed: state.doneBatches,
2455
- batch_failed: state.erroredBatches,
2456
- last_error: { title: "INIT INCOMPLETE: Some episodes have unextracted batches", failed_at: checkpointTimestamp() },
2457
- });
2458
- ctx.emitProgress("batch-extract", "error");
2459
- throw new StageIncomplete();
2460
- }
2461
- state.mergedResults.sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
2462
- }
2463
- // --- script-merge ---------------------------------------------------------
2464
- export async function stageScriptMerge(ctx) {
2465
- const { ws, state, options } = ctx;
2466
- const results = ensureMergedResults(ctx);
2467
- const episodePlan = ensureEpisodePlan(ctx);
2468
- ctx.updateRunState({
2469
- status: "init_running",
2470
- init_stage: "script_merge",
2471
- episode_total: state.totalEpisodes || asList(episodePlan["episodes"]).length,
2472
- episode_completed: results.length,
2473
- incomplete_episodes: [],
2474
- batch_total: state.totalBatches,
2475
- batch_completed: state.doneBatches,
2476
- batch_failed: state.erroredBatches,
2477
- last_error: null,
2478
- });
2479
- ctx.emitProgress("script-merge");
2480
- try {
2481
- const merged = mergeEpisodeResultsWithReport(results, ensureProjectName(ctx));
2482
- state.script = merged.script;
2483
- state.assetNormalization = merged.assetNormalization;
2484
- ws.writeJson("asset_normalization.json", state.assetNormalization);
2485
- }
2486
- catch (exc) {
2487
- stageFailure(options.workspace, exc, {
2488
- title: "INIT FAILED: Merge failed",
2489
- stage: "script_merge",
2490
- required: ["complete episode_results/*.json"],
2491
- nextSteps: ["Rerun init; completed episode extraction checkpoints will be reused and merge will retry."],
2492
- updates: { episode_completed: results.length },
2493
- });
2494
- }
2495
- }
2496
- // --- asset-curation -------------------------------------------------------
2497
- const ASSET_GROUPING_CACHE_VERSION = 3;
2498
- const ASSET_CURATION_CACHE_VERSION = 5;
2499
- function assetCacheIdentity(options) {
2500
- return {
2501
- provider: options.providerName,
2502
- model: options.model,
2503
- };
2504
- }
2505
- function assetCacheArtifactFields(ctx, cacheVersion) {
2506
- return {
2507
- cache_version: cacheVersion,
2508
- ...assetCacheIdentity(ctx.options),
2509
- };
2510
- }
2511
- function assetCurationScriptFingerprint(script, options) {
2512
- return sha256Text(JSON.stringify(sortDeep({
2513
- cache_version: ASSET_CURATION_CACHE_VERSION,
2514
- ...assetCacheIdentity(options),
2515
- script: {
2516
- actors: script["actors"],
2517
- locations: script["locations"],
2518
- props: script["props"],
2519
- speakers: script["speakers"],
2520
- episodes: script["episodes"],
2521
- },
2522
- })));
2523
- }
2524
- function assetCurationPlanFingerprint(scriptFingerprint, plan) {
2525
- return sha256Text(JSON.stringify(sortDeep({
2526
- script_fingerprint: scriptFingerprint,
2527
- decisions: plan["decisions"],
2528
- })));
2529
- }
2530
- function chunkStrings(values, chunkSize) {
2531
- const chunks = [];
2532
- for (let index = 0; index < values.length; index += chunkSize) {
2533
- chunks.push(values.slice(index, index + chunkSize));
2534
- }
2535
- return chunks;
2536
- }
2537
- function curationSourceKey(kind, id) {
2538
- return `${kind}:${id}`;
2539
- }
2540
- function curationDecisionSourceKey(decision) {
2541
- return curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
2542
- }
2543
- function curationSourceKeysForIds(kind, ids) {
2544
- return ids.map((id) => curationSourceKey(kind, id));
2545
- }
2546
- function curationPartSelection(kind, ids) {
2547
- if (kind === "actor")
2548
- return { actor: ids };
2549
- if (kind === "location")
2550
- return { location: ids };
2551
- return { prop: ids };
2552
- }
2553
- function curationIdsFromSourceKeys(kind, ids, sourceKeys) {
2554
- const allowed = new Set(sourceKeys);
2555
- return ids.filter((id) => allowed.has(curationSourceKey(kind, id)));
2556
- }
2557
- function invalidCurationPart(title, received) {
2558
- return new CliError(title, title, {
2559
- exitCode: EXIT_RUNTIME,
2560
- required: ["repairable asset curation expressions"],
2561
- received,
2562
- nextSteps: ["Rerun init; completed extraction checkpoints will be reused and only invalid curation parts will retry."],
2563
- });
2564
- }
2565
- const MAX_CURATION_PART_REPAIR_ATTEMPTS = 2;
2566
- function assetGroupingKindListKey(kind) {
2567
- if (kind === "actor")
2568
- return "actors";
2569
- if (kind === "location")
2570
- return "locations";
2571
- return "props";
2572
- }
2573
- function assetGroupingKindIdKey(kind) {
2574
- if (kind === "actor")
2575
- return "actor_id";
2576
- if (kind === "location")
2577
- return "location_id";
2578
- return "prop_id";
2579
- }
2580
- function assetGroupingKindNameKey(kind) {
2581
- if (kind === "actor")
2582
- return "actor_name";
2583
- if (kind === "location")
2584
- return "location_name";
2585
- return "prop_name";
2586
- }
2587
- function assetGroupingScriptFingerprint(script, options) {
2588
- const compact = {};
2589
- for (const kind of ["actor", "location", "prop"]) {
2590
- const idKey = assetGroupingKindIdKey(kind);
2591
- const nameKey = assetGroupingKindNameKey(kind);
2592
- compact[assetGroupingKindListKey(kind)] = asList(script[assetGroupingKindListKey(kind)])
2593
- .filter(isDict)
2594
- .map((asset) => ({ id: strOf(asset[idKey]), name: strOf(asset[nameKey]) }));
2595
- }
2596
- return sha256Text(JSON.stringify(sortDeep({
2597
- cache_version: ASSET_GROUPING_CACHE_VERSION,
2598
- ...assetCacheIdentity(options),
2599
- assets: compact,
2600
- })));
2601
- }
2602
- function combineAssetGroupingPlans(parts) {
2603
- const groups = parts.flatMap((part) => asList(part["groups"]).filter(isDict));
2604
- const duplicateMergeDecisions = parts.flatMap((part) => asList(part["duplicate_merge_decisions"]).filter(isDict));
2605
- const normalizations = parts.flatMap((part) => asList(part["normalizations"]).filter(isDict));
2606
- const summaries = parts.map((part) => part["summary"]).filter(isDict);
2607
- return {
2608
- version: 1,
2609
- format: "asset-grouping-v1",
2610
- groups,
2611
- duplicate_merge_decisions: duplicateMergeDecisions,
2612
- normalizations,
2613
- parts: parts.map((part) => ({
2614
- kind: part["kind"],
2615
- expression_text: part["expression_text"],
2616
- summary: part["summary"],
2617
- normalizations: part["normalizations"],
2618
- })),
2619
- summary: {
2620
- group_count: groups.length,
2621
- duplicate_merge_count: duplicateMergeDecisions.length,
2622
- normalization_count: normalizations.length,
2623
- parts: summaries,
2624
- },
2625
- };
2626
- }
2627
- async function loadOrRunAssetGroupingPart(ctx, script, groupAssets, kind, fingerprint) {
2628
- const artifactName = `asset_grouping_parts/${kind}.json`;
2629
- const cached = ctx.ws.readJsonSafe(artifactName);
2630
- const cachedPlan = !ctx.state.bypassInternalCaches && isDict(cached) && cached["script_fingerprint"] === fingerprint && cached["parse_status"] === "ok" && isDict(cached["plan"]) ? cached["plan"] : null;
2631
- if (cachedPlan && isList(cachedPlan["groups"]))
2632
- return cachedPlan;
2633
- const startedAtMs = Date.now();
2634
- const idKey = assetGroupingKindIdKey(kind);
2635
- const assetCount = asList(script[assetGroupingKindListKey(kind)])
2636
- .filter(isDict)
2637
- .map((asset) => strOf(asset[idKey]).trim())
2638
- .filter((id) => id).length;
2639
- if (assetCount === 0) {
2640
- const plan = {
2641
- version: 1,
2642
- format: "asset-grouping-v1",
2643
- kind,
2644
- expression_text: "",
2645
- expressions: [],
2646
- groups: [],
2647
- duplicate_merge_decisions: [],
2648
- summary: { input_count: 0, group_count: 0, mentioned_count: 0, omitted_duplicate_count: 0 },
2649
- };
2650
- ctx.ws.writeJson(artifactName, {
2651
- version: 1,
2652
- ...assetCacheArtifactFields(ctx, ASSET_GROUPING_CACHE_VERSION),
2653
- script_fingerprint: fingerprint,
2654
- parse_status: "ok",
2655
- kind,
2656
- context_chars: 0,
2657
- expression_text: "",
2658
- duration_ms: durationMs(startedAtMs, Date.now()),
2659
- plan,
2660
- updated_at: checkpointTimestamp(),
2661
- });
2662
- return plan;
2663
- }
2664
- const contextText = buildAssetGroupingContextText(script, kind);
2665
- let rawText = "";
2666
- try {
2667
- rawText = await ctx.timing.span({ name: `asset_grouping.${kind}`, category: "chunk", parent: "asset_curation.grouping", metadata: { artifact: artifactName } }, () => groupAssets(script, contextText));
2668
- const parsed = parseAssetGroupingExpressions(rawText);
2669
- const plan = validateAssetGroupingPlan(script, kind, parsed);
2670
- ctx.ws.writeJson(artifactName, {
2671
- version: 1,
2672
- ...assetCacheArtifactFields(ctx, ASSET_GROUPING_CACHE_VERSION),
2673
- script_fingerprint: fingerprint,
2674
- parse_status: "ok",
2675
- kind,
2676
- context_chars: contextText.length,
2677
- expression_text: rawText,
2678
- duration_ms: durationMs(startedAtMs, Date.now()),
2679
- plan,
2680
- updated_at: checkpointTimestamp(),
2681
- });
2682
- return plan;
2683
- }
2684
- catch (exc) {
2685
- const e = exc;
2686
- ctx.ws.writeJson(artifactName, {
2687
- version: 1,
2688
- ...assetCacheArtifactFields(ctx, ASSET_GROUPING_CACHE_VERSION),
2689
- script_fingerprint: fingerprint,
2690
- parse_status: "failed",
2691
- kind,
2692
- context_chars: contextText.length,
2693
- expression_text: rawText,
2694
- duration_ms: durationMs(startedAtMs, Date.now()),
2695
- error: `${e?.name ?? "Error"}: ${e?.message ?? ""}`,
2696
- updated_at: checkpointTimestamp(),
2697
- });
2698
- throw exc;
2699
- }
2700
- }
2701
- async function loadOrRunAssetGrouping(ctx, script, groupAssets, fingerprint) {
2702
- ctx.ws.mkdir("asset_grouping_parts");
2703
- const outcomes = await pMapWithConcurrency(["actor", "location", "prop"], ctx.options.concurrency, async (kind) => loadOrRunAssetGroupingPart(ctx, script, groupAssets, kind, fingerprint));
2704
- return combineAssetGroupingPlans(unwrapConcurrentOutcomes(outcomes));
2705
- }
2706
- function curationDecisionSourceKeys(plan) {
2707
- return new Set(asList(plan["decisions"]).filter(isDict).map((decision) => curationDecisionSourceKey(decision)));
2708
- }
2709
- function sourceKeysFromRejectedExpressions(rejected, candidateSourceKeys) {
2710
- const candidateSet = new Set(candidateSourceKeys);
2711
- const keys = [];
2712
- for (const item of rejected) {
2713
- const key = strOf(item["source_key"]);
2714
- if (key && (candidateSet.size === 0 || candidateSet.has(key)))
2715
- uniqueAdd(keys, key);
2716
- }
2717
- return keys;
2718
- }
2719
- function scopedCurationPlan(plan, allowedSourceKeys) {
2720
- const allowed = new Set(allowedSourceKeys);
2721
- const decisions = asList(plan["decisions"]).filter(isDict);
2722
- const acceptedExpressions = asList(plan["accepted_expressions"]);
2723
- const scopedDecisions = [];
2724
- const scopedExpressions = [];
2725
- const ignored = [];
2726
- for (let index = 0; index < decisions.length; index += 1) {
2727
- const decision = decisions[index];
2728
- const sourceKey = curationDecisionSourceKey(decision);
2729
- const expression = acceptedExpressions[index] ?? "";
2730
- if (allowed.has(sourceKey)) {
2731
- scopedDecisions.push(decision);
2732
- if (expression)
2733
- scopedExpressions.push(expression);
2734
- }
2735
- else {
2736
- ignored.push({ source_key: sourceKey, expression, reason: "outside_repair_scope" });
2737
- }
2738
- }
2739
- return {
2740
- plan: {
2741
- ...plan,
2742
- expression_text: formatCurationExpressionText(scopedExpressions),
2743
- expressions: scopedExpressions.length > 0 ? scopedExpressions : ["NOOP # no asset curation changes"],
2744
- accepted_expressions: scopedExpressions,
2745
- decisions: scopedDecisions,
2746
- },
2747
- ignored,
2748
- };
2749
- }
2750
- function missingRequiredSourceKeysForPart(script, candidateSourceKeys, partialPlan) {
2751
- const candidateSet = new Set(candidateSourceKeys);
2752
- return missingAssetCurationRequiredSourceKeys(script, partialPlan).filter((key) => candidateSet.has(key));
2753
- }
2754
- function repairSourceKeysForPart(script, candidateSourceKeys, partialPlan, requireCoverage) {
2755
- const candidateSet = new Set(candidateSourceKeys);
2756
- const accepted = new Set(asList(partialPlan["decisions"]).filter(isDict).map((decision) => curationDecisionSourceKey(decision)));
2757
- const rejected = asList(partialPlan["rejected_expressions"]).filter(isDict);
2758
- const repairKeys = [];
2759
- let hasUnlocatedRejection = false;
2760
- for (const item of rejected) {
2761
- const key = strOf(item["source_key"]);
2762
- if (key && (candidateSet.size === 0 || candidateSet.has(key)))
2763
- uniqueAdd(repairKeys, key);
2764
- else
2765
- hasUnlocatedRejection = true;
2766
- }
2767
- if (hasUnlocatedRejection) {
2768
- for (const key of candidateSourceKeys) {
2769
- if (!accepted.has(key))
2770
- uniqueAdd(repairKeys, key);
2771
- }
2772
- }
2773
- if (requireCoverage) {
2774
- for (const key of missingRequiredSourceKeysForPart(script, candidateSourceKeys, partialPlan)) {
2775
- uniqueAdd(repairKeys, key);
2776
- }
2777
- }
2778
- return repairKeys;
2779
- }
2780
- function mergeRepairedCurationPlan(rawText, partialPlan, repairPlan, repairAttempts) {
2781
- const acceptedExpressions = asList(partialPlan["accepted_expressions"]);
2782
- const repairExpressions = asList(repairPlan["accepted_expressions"]);
2783
- const decisions = [
2784
- ...asList(partialPlan["decisions"]).filter(isDict),
2785
- ...asList(repairPlan["decisions"]).filter(isDict),
2786
- ];
2787
- const expressions = [...acceptedExpressions, ...repairExpressions].filter((expression) => !/^NOOP(?:\s|#|$)/i.test(expression.trim()));
2788
- return {
2789
- version: 3,
2790
- format: "asset-curation-exp-v1",
2791
- expression_text: formatCurationExpressionText(expressions),
2792
- raw_expression_text: rawText,
2793
- accepted_expressions: [...acceptedExpressions, ...repairExpressions],
2794
- rejected_expressions: asList(partialPlan["rejected_expressions"]).filter(isDict),
2795
- repair_attempts: repairAttempts,
2796
- expressions: expressions.length > 0 ? expressions : ["NOOP # no asset curation changes"],
2797
- decisions,
2798
- partial: false,
2799
- };
2800
- }
2801
- function formatCurationExpressionText(expressions) {
2802
- if (expressions.length > 0)
2803
- return expressions.map((expression) => `<exp>${expression}</exp>`).join("\n");
2804
- return "<exp>NOOP # no asset curation changes</exp>";
2805
- }
2806
- function writeFailedCurationPartArtifact(opts) {
2807
- const e = opts.error;
2808
- opts.ctx.ws.writeJson(opts.artifactName, {
2809
- version: 1,
2810
- ...assetCacheArtifactFields(opts.ctx, ASSET_CURATION_CACHE_VERSION),
2811
- script_fingerprint: opts.fingerprint,
2812
- ...opts.metadata,
2813
- parse_status: "failed",
2814
- candidate_source_keys: opts.candidateSourceKeys,
2815
- expression_text: opts.partPlan?.["expression_text"] ?? null,
2816
- raw_expression_text: opts.partPlan?.["raw_expression_text"] ?? opts.expressionText,
2817
- accepted_expressions: opts.partPlan?.["accepted_expressions"] ?? [],
2818
- rejected_expressions: opts.partPlan?.["rejected_expressions"] ?? [],
2819
- repair_attempts: opts.repairAttempts,
2820
- duration_ms: durationMs(opts.startedAtMs, Date.now()),
2821
- error: `${e?.name ?? "Error"}: ${e?.message ?? ""}`,
2822
- plan: opts.partPlan,
2823
- updated_at: checkpointTimestamp(),
2824
- });
2825
- }
2826
- async function loadOrRunCurationPart(ctx, script, artifactName, fingerprint, metadata, candidateSourceKeys, parentTiming, requireCoverage, contextText, run) {
2827
- const cached = ctx.ws.readJsonSafe(artifactName);
2828
- const cachedPlan = !ctx.state.bypassInternalCaches && isDict(cached) && cached["script_fingerprint"] === fingerprint && cached["parse_status"] === "ok" && isDict(cached["plan"]) ? cached["plan"] : null;
2829
- if (cachedPlan && isList(cachedPlan["decisions"]))
2830
- return cachedPlan;
2831
- const startedAtMs = Date.now();
2832
- const baseContextText = contextText(candidateSourceKeys);
2833
- const expressionText = await ctx.timing.span({ name: strOf(metadata["part_id"]) || artifactName, category: "chunk", parent: parentTiming, metadata: { artifact: artifactName } }, () => run(baseContextText));
2834
- let partPlan = parseAssetCurationExpressionsTolerant(expressionText);
2835
- if (!isDict(partPlan) || !isList(partPlan["decisions"])) {
2836
- throw new CliError("INIT FAILED: Provider returned invalid asset curation", "Provider returned invalid asset curation.", {
2837
- exitCode: EXIT_RUNTIME,
2838
- required: ["object with decisions[]"],
2839
- received: [typeof partPlan],
2840
- nextSteps: ["Rerun init; completed extraction checkpoints will be reused and curation will retry."],
2841
- });
2842
- }
2843
- const repairAttempts = [];
2844
- let repairSourceKeys = repairSourceKeysForPart(script, candidateSourceKeys, partPlan, requireCoverage);
2845
- let repairRejectedContext = asList(partPlan["rejected_expressions"]).filter(isDict);
2846
- try {
2847
- for (let attempt = 1; repairSourceKeys.length > 0 && attempt <= MAX_CURATION_PART_REPAIR_ATTEMPTS; attempt += 1) {
2848
- const repairContext = buildAssetCurationRepairContextText({
2849
- baseContextText: contextText(repairSourceKeys),
2850
- repairSourceKeys,
2851
- rejectedExpressions: repairRejectedContext,
2852
- acceptedDecisions: asList(partPlan["decisions"]).filter(isDict),
2853
- });
2854
- const repairText = await ctx.timing.span({ name: `${strOf(metadata["part_id"]) || artifactName}.repair.${pad3(attempt)}`, category: "repair", parent: parentTiming, metadata: { artifact: artifactName, repair_source_keys: repairSourceKeys } }, () => run(repairContext));
2855
- const repairPlan = parseAssetCurationExpressionsTolerant(repairText);
2856
- const repairRejected = asList(repairPlan["rejected_expressions"]).filter(isDict);
2857
- const scopedRepair = scopedCurationPlan(repairPlan, repairSourceKeys);
2858
- repairAttempts.push({
2859
- index: attempt,
2860
- repair_source_keys: repairSourceKeys,
2861
- expression_text: repairText,
2862
- accepted_expressions: scopedRepair.plan["accepted_expressions"],
2863
- ignored_expressions: scopedRepair.ignored,
2864
- rejected_expressions: repairRejected,
2865
- decisions: scopedRepair.plan["decisions"],
2866
- });
2867
- if (repairRejected.length > 0) {
2868
- if (asList(scopedRepair.plan["decisions"]).filter(isDict).length > 0) {
2869
- partPlan = mergeRepairedCurationPlan(expressionText, partPlan, scopedRepair.plan, repairAttempts);
2870
- }
2871
- const missingKeys = requireCoverage ? missingRequiredSourceKeysForPart(script, candidateSourceKeys, partPlan) : [];
2872
- const rejectedKeys = sourceKeysFromRejectedExpressions(repairRejected, repairSourceKeys);
2873
- const nextRepairSourceKeys = [...(rejectedKeys.length > 0 ? rejectedKeys : []), ...missingKeys]
2874
- .filter((key, index, all) => all.indexOf(key) === index);
2875
- if (nextRepairSourceKeys.length === 0) {
2876
- repairSourceKeys = [];
2877
- repairRejectedContext = [];
2878
- continue;
2879
- }
2880
- if (attempt >= MAX_CURATION_PART_REPAIR_ATTEMPTS) {
2881
- throw invalidCurationPart("INIT FAILED: Asset curation repair returned invalid expressions", repairRejected.map((item) => `${strOf(item["source_key"]) || "-"}: ${strOf(item["error"])}`).slice(0, 40));
2882
- }
2883
- repairSourceKeys = nextRepairSourceKeys;
2884
- repairRejectedContext = repairRejected;
2885
- continue;
2886
- }
2887
- const finalKeys = new Set([...curationDecisionSourceKeys(partPlan), ...curationDecisionSourceKeys(scopedRepair.plan)]);
2888
- const unrepaired = repairSourceKeys.filter((key) => !finalKeys.has(key));
2889
- if (unrepaired.length > 0 && attempt >= MAX_CURATION_PART_REPAIR_ATTEMPTS) {
2890
- throw invalidCurationPart("INIT FAILED: Asset curation repair omitted required source decisions", unrepaired.slice(0, 40));
2891
- }
2892
- partPlan = mergeRepairedCurationPlan(expressionText, partPlan, scopedRepair.plan, repairAttempts);
2893
- repairRejectedContext = [];
2894
- const missingKeys = requireCoverage ? missingRequiredSourceKeysForPart(script, candidateSourceKeys, partPlan) : [];
2895
- repairSourceKeys = [...unrepaired, ...missingKeys]
2896
- .filter((key, index, all) => all.indexOf(key) === index);
2897
- }
2898
- if (repairSourceKeys.length > 0) {
2899
- throw invalidCurationPart("INIT FAILED: Asset curation repair omitted required source decisions", repairSourceKeys.slice(0, 40));
2900
- }
2901
- }
2902
- catch (exc) {
2903
- writeFailedCurationPartArtifact({
2904
- ctx,
2905
- artifactName,
2906
- fingerprint,
2907
- metadata,
2908
- candidateSourceKeys,
2909
- expressionText,
2910
- partPlan,
2911
- repairAttempts,
2912
- startedAtMs,
2913
- error: exc,
2914
- });
2915
- throw exc;
2916
- }
2917
- for (const [key, value] of Object.entries(metadata))
2918
- partPlan[key] = value;
2919
- ctx.ws.writeJson(artifactName, {
2920
- version: 1,
2921
- ...assetCacheArtifactFields(ctx, ASSET_CURATION_CACHE_VERSION),
2922
- script_fingerprint: fingerprint,
2923
- ...metadata,
2924
- parse_status: "ok",
2925
- candidate_source_keys: candidateSourceKeys,
2926
- expression_text: partPlan["expression_text"],
2927
- raw_expression_text: partPlan["raw_expression_text"] ?? expressionText,
2928
- accepted_expressions: partPlan["accepted_expressions"],
2929
- rejected_expressions: partPlan["rejected_expressions"],
2930
- repair_attempts: repairAttempts,
2931
- duration_ms: durationMs(startedAtMs, Date.now()),
2932
- plan: partPlan,
2933
- updated_at: checkpointTimestamp(),
2934
- });
2935
- return partPlan;
2936
- }
2937
- async function loadOrRunPrimaryCurationPart(ctx, script, curateAssets, kind, partId, fingerprint, ids) {
2938
- const selection = curationPartSelection(kind, ids);
2939
- return loadOrRunCurationPart(ctx, script, `asset_curation_parts/primary.${partId}.json`, fingerprint, { asset_kind: kind, part_id: partId, selection }, curationSourceKeysForIds(kind, ids), "asset_curation.primary", true, (selectedSourceKeys = curationSourceKeysForIds(kind, ids)) => {
2940
- const selectedIds = curationIdsFromSourceKeys(kind, ids, selectedSourceKeys);
2941
- return buildAssetCurationContextText(script, [kind], curationPartSelection(kind, selectedIds.length > 0 ? selectedIds : ids));
2942
- }, (contextText) => curateAssets(script, contextText));
2943
- }
2944
- function primaryCurationChunks(script, groupingPlan) {
2945
- const chunks = [];
2946
- for (const kind of ["actor", "location", "prop"]) {
2947
- const groupingGroups = groupingPlan
2948
- ? asList(groupingPlan["groups"]).filter((group) => isDict(group) && group["kind"] === kind)
2949
- : [];
2950
- const kindChunks = groupingGroups.length > 0
2951
- ? groupedAssetCurationChunks(script, kind, groupingGroups)
2952
- : buildPrimaryAssetCurationChunks(script, kind);
2953
- for (let index = 0; index < kindChunks.length; index += 1) {
2954
- chunks.push({
2955
- kind,
2956
- partId: `${kind}.${String(index + 1).padStart(3, "0")}`,
2957
- ids: kindChunks[index],
2958
- });
2959
- }
2960
- }
2961
- return chunks;
2962
- }
2963
- async function loadOrRunAuditCurationParts(ctx, script, curateAssets, primaryPlan, fingerprint) {
2964
- const sourceChunks = chunkStrings(assetCurationAuditSourceKeys(script, primaryPlan), 24);
2965
- if (sourceChunks.length === 0) {
2966
- return parseAssetCurationExpressions("<exp>NOOP # no asset curation changes</exp>");
2967
- }
2968
- const outcomes = await pMapWithConcurrency(sourceChunks, ctx.options.concurrency, async (selectedSourceKeys, index) => {
2969
- const partId = `audit.${String(index + 1).padStart(3, "0")}`;
2970
- return loadOrRunCurationPart(ctx, script, `asset_curation_parts/${partId}.json`, fingerprint, { part_id: partId, selected_source_keys: selectedSourceKeys }, selectedSourceKeys, "asset_curation.audit", false, (repairSourceKeys = selectedSourceKeys) => buildAssetCurationAuditContextText(script, primaryPlan, repairSourceKeys), (contextText) => curateAssets(script, contextText));
2971
- });
2972
- const parts = unwrapConcurrentOutcomes(outcomes);
2973
- return combineAssetCurationExpressionPlans(parts);
2974
- }
2975
- async function loadOrRunLocationCanonicalizationParts(ctx, script, curateAssets, plan, fingerprint) {
2976
- const sourceChunks = chunkStrings(locationCanonicalizationCandidateSourceKeys(script, plan), 24);
2977
- if (sourceChunks.length === 0) {
2978
- return parseAssetCurationExpressions("<exp>NOOP # no location canonicalization changes</exp>");
2979
- }
2980
- const outcomes = await pMapWithConcurrency(sourceChunks, ctx.options.concurrency, async (selectedSourceKeys, index) => {
2981
- const partId = `location_canonical.${String(index + 1).padStart(3, "0")}`;
2982
- return loadOrRunCurationPart(ctx, script, `asset_curation_parts/${partId}.json`, fingerprint, { part_id: partId, selected_source_keys: selectedSourceKeys }, selectedSourceKeys, "asset_curation.location_canonical", false, (repairSourceKeys = selectedSourceKeys) => buildLocationCanonicalizationAuditContextText(script, plan, repairSourceKeys), (contextText) => curateAssets(script, contextText));
2983
- });
2984
- const parts = unwrapConcurrentOutcomes(outcomes);
2985
- return combineAssetCurationExpressionPlans(parts);
2986
- }
2987
- export async function stageAssetCuration(ctx) {
2988
- const { ws, state, options } = ctx;
2989
- const script = ensureScript(ctx);
2990
- let groupingPlan = null;
2991
- let groupingApplySummary = null;
2992
- let plan = null;
2993
- let primaryPlan = null;
2994
- let auditPlan = null;
2995
- let locationCanonicalPlan = null;
2996
- let primaryExpressionText = null;
2997
- let primaryPartExpressionTexts = [];
2998
- let auditExpressionText = null;
2999
- let locationCanonicalExpressionText = null;
3000
- try {
3001
- ctx.updateRunState({ status: "init_running", init_stage: "asset_curation" });
3002
- ctx.emitProgress("asset-curation");
3003
- const provider = ctx.provider();
3004
- if (!provider.curateAssets) {
3005
- throw new CliError("INIT FAILED: Provider does not support asset curation", "Provider does not support asset curation.", {
3006
- exitCode: EXIT_RUNTIME,
3007
- required: ["provider.curateAssets(script, contextText)"],
3008
- received: [`provider: ${provider.name || options.providerName}`],
3009
- nextSteps: ["Use a direct provider that supports asset curation."],
3010
- });
3011
- }
3012
- if (!provider.groupAssets) {
3013
- throw new CliError("INIT FAILED: Provider does not support asset grouping", "Provider does not support asset grouping.", {
3014
- exitCode: EXIT_RUNTIME,
3015
- required: ["provider.groupAssets(script, contextText)"],
3016
- received: [`provider: ${provider.name || options.providerName}`],
3017
- nextSteps: ["Use a direct provider that supports asset grouping."],
3018
- });
3019
- }
3020
- const groupAssets = provider.groupAssets.bind(provider);
3021
- const curateAssets = provider.curateAssets.bind(provider);
3022
- const groupingFingerprint = assetGroupingScriptFingerprint(script, options);
3023
- groupingPlan = await ctx.timing.span({ name: "asset_curation.grouping", category: "substage", parent: "asset_curation" }, () => loadOrRunAssetGrouping(ctx, script, groupAssets, groupingFingerprint));
3024
- groupingApplySummary = await ctx.timing.span({ name: "asset_curation.grouping.apply", category: "substage", parent: "asset_curation.grouping" }, () => applyAssetGroupingDuplicateMerges(script, { decisions: groupingPlan["duplicate_merge_decisions"] }));
3025
- Object.assign(groupingPlan, assetCacheArtifactFields(ctx, ASSET_GROUPING_CACHE_VERSION));
3026
- groupingPlan["apply_summary"] = groupingApplySummary;
3027
- groupingPlan["script_fingerprint"] = groupingFingerprint;
3028
- ws.writeJson("asset_grouping.json", groupingPlan);
3029
- const fingerprint = assetCurationScriptFingerprint(script, options);
3030
- const cachedCuration = ws.readJsonSafe("asset_curation.json");
3031
- if (!state.bypassInternalCaches && isDict(cachedCuration) && cachedCuration["script_fingerprint"] === fingerprint && isList(cachedCuration["decisions"])) {
3032
- const curation = curateScriptAssets(script, { decisions: cachedCuration["decisions"] });
3033
- Object.assign(curation, assetCacheArtifactFields(ctx, ASSET_CURATION_CACHE_VERSION));
3034
- curation["script_fingerprint"] = fingerprint;
3035
- curation["grouping"] = groupingPlan;
3036
- curation["grouping_apply_summary"] = groupingApplySummary;
3037
- if (typeof cachedCuration["expression_text"] === "string")
3038
- curation["expression_text"] = cachedCuration["expression_text"];
3039
- if (typeof cachedCuration["primary_expression_text"] === "string")
3040
- curation["primary_expression_text"] = cachedCuration["primary_expression_text"];
3041
- if (typeof cachedCuration["primary_part_expression_text"] === "string")
3042
- curation["primary_part_expression_text"] = cachedCuration["primary_part_expression_text"];
3043
- if (typeof cachedCuration["audit_expression_text"] === "string")
3044
- curation["audit_expression_text"] = cachedCuration["audit_expression_text"];
3045
- if (typeof cachedCuration["location_canonical_expression_text"] === "string")
3046
- curation["location_canonical_expression_text"] = cachedCuration["location_canonical_expression_text"];
3047
- if (isList(cachedCuration["expressions"]))
3048
- curation["expressions"] = cachedCuration["expressions"];
3049
- if (isList(cachedCuration["primary_expressions"]))
3050
- curation["primary_expressions"] = cachedCuration["primary_expressions"];
3051
- if (isList(cachedCuration["primary_parts"]))
3052
- curation["primary_parts"] = cachedCuration["primary_parts"];
3053
- if (isList(cachedCuration["audit_expressions"]))
3054
- curation["audit_expressions"] = cachedCuration["audit_expressions"];
3055
- if (isList(cachedCuration["location_canonical_expressions"]))
3056
- curation["location_canonical_expressions"] = cachedCuration["location_canonical_expressions"];
3057
- if (typeof cachedCuration["format"] === "string")
3058
- curation["format"] = cachedCuration["format"];
3059
- if (isDict(cachedCuration["audit_summary"]))
3060
- curation["audit_summary"] = cachedCuration["audit_summary"];
3061
- ws.writeJson("asset_curation.json", curation);
3062
- ws.delete("asset_curation.failed.json");
3063
- ctx.emitProgress("asset-curation", "success");
3064
- return;
3065
- }
3066
- const primaryChunkSpecs = primaryCurationChunks(script, groupingPlan);
3067
- const primaryOutcomes = await pMapWithConcurrency(primaryChunkSpecs, options.concurrency, async (chunk) => loadOrRunPrimaryCurationPart(ctx, script, curateAssets, chunk.kind, chunk.partId, fingerprint, chunk.ids));
3068
- const primaryParts = unwrapConcurrentOutcomes(primaryOutcomes);
3069
- primaryPartExpressionTexts = primaryParts.map((part) => ({
3070
- asset_kind: part["asset_kind"],
3071
- part_id: part["part_id"],
3072
- expression_text: part["expression_text"],
3073
- }));
3074
- primaryPlan = await ctx.timing.span({ name: "asset_curation.primary.combine", category: "substage", parent: "asset_curation.primary", metadata: { chunks: primaryParts.length } }, () => combinePrimaryAssetCurationPlans(primaryParts));
3075
- primaryExpressionText = typeof primaryPlan["expression_text"] === "string" ? primaryPlan["expression_text"] : "";
3076
- if (!isDict(primaryPlan) || !isList(primaryPlan["decisions"])) {
3077
- throw new CliError("INIT FAILED: Provider returned invalid asset curation", "Provider returned invalid asset curation.", {
3078
- exitCode: EXIT_RUNTIME,
3079
- required: ["object with decisions[]"],
3080
- received: [typeof primaryPlan],
3081
- nextSteps: ["Rerun init; completed extraction checkpoints will be reused and curation will retry."],
3082
- });
3083
- }
3084
- const allowedNewAuditSources = [
3085
- ...missingAssetCurationRequiredSourceKeys(script, primaryPlan),
3086
- ...assetCurationAuditCandidateSourceKeys(script, primaryPlan),
3087
- ];
3088
- auditPlan = await ctx.timing.span({ name: "asset_curation.audit", category: "substage", parent: "asset_curation" }, () => loadOrRunAuditCurationParts(ctx, script, curateAssets, primaryPlan, assetCurationPlanFingerprint(`${fingerprint}:audit-chunk-v1`, primaryPlan)));
3089
- auditExpressionText = typeof auditPlan["expression_text"] === "string" ? auditPlan["expression_text"] : "";
3090
- if (!isDict(auditPlan) || !isList(auditPlan["decisions"])) {
3091
- throw new CliError("INIT FAILED: Provider returned invalid asset curation audit", "Provider returned invalid asset curation audit.", {
3092
- exitCode: EXIT_RUNTIME,
3093
- required: ["object with decisions[]"],
3094
- received: [typeof auditPlan],
3095
- nextSteps: ["Rerun init; completed extraction checkpoints will be reused and curation audit will retry."],
3096
- });
3097
- }
3098
- plan = combineAssetCurationPlans(primaryPlan, auditPlan, allowedNewAuditSources, { salvageInvalidAuditSources: true });
3099
- const allowedNewLocationCanonicalSources = locationCanonicalizationCandidateSourceKeys(script, plan);
3100
- locationCanonicalPlan = await ctx.timing.span({ name: "asset_curation.location_canonical", category: "substage", parent: "asset_curation" }, () => loadOrRunLocationCanonicalizationParts(ctx, script, curateAssets, plan, assetCurationPlanFingerprint(`${fingerprint}:location-canonical-chunk-v2`, plan)));
3101
- locationCanonicalExpressionText = typeof locationCanonicalPlan["expression_text"] === "string" ? locationCanonicalPlan["expression_text"] : "";
3102
- if (!isDict(locationCanonicalPlan) || !isList(locationCanonicalPlan["decisions"])) {
3103
- throw new CliError("INIT FAILED: Provider returned invalid location canonicalization audit", "Provider returned invalid location canonicalization audit.", {
3104
- exitCode: EXIT_RUNTIME,
3105
- required: ["object with decisions[]"],
3106
- received: [typeof locationCanonicalPlan],
3107
- nextSteps: ["Rerun init; completed extraction checkpoints will be reused and location canonicalization will retry."],
3108
- });
3109
- }
3110
- assertLocationCanonicalizationPlan(locationCanonicalPlan);
3111
- plan = combineLocationCanonicalizationPlan(script, plan, locationCanonicalPlan, allowedNewLocationCanonicalSources);
3112
- const curation = await ctx.timing.span({ name: "asset_curation.apply", category: "substage", parent: "asset_curation" }, () => curateScriptAssets(script, plan));
3113
- Object.assign(curation, assetCacheArtifactFields(ctx, ASSET_CURATION_CACHE_VERSION));
3114
- curation["script_fingerprint"] = fingerprint;
3115
- curation["format"] = plan["format"];
3116
- curation["expression_text"] = plan["expression_text"];
3117
- curation["primary_expression_text"] = plan["primary_expression_text"];
3118
- curation["audit_expression_text"] = plan["audit_expression_text"];
3119
- curation["location_canonical_expression_text"] = plan["location_canonical_expression_text"];
3120
- curation["primary_part_expression_text"] = plan["primary_part_expression_text"];
3121
- curation["primary_parts"] = plan["primary_parts"];
3122
- curation["expressions"] = plan["expressions"];
3123
- curation["primary_expressions"] = plan["primary_expressions"];
3124
- curation["audit_expressions"] = plan["audit_expressions"];
3125
- curation["location_canonical_expressions"] = plan["location_canonical_expressions"];
3126
- curation["audit_summary"] = plan["audit_summary"];
3127
- curation["grouping"] = groupingPlan;
3128
- curation["grouping_apply_summary"] = groupingApplySummary;
3129
- ws.writeJson("asset_curation.json", curation);
3130
- ws.delete("asset_curation.failed.json");
3131
- }
3132
- catch (exc) {
3133
- if (plan || primaryPlan || auditPlan || locationCanonicalPlan || primaryExpressionText !== null || auditExpressionText !== null || locationCanonicalExpressionText !== null) {
3134
- const e = exc;
3135
- ws.writeJson("asset_curation.failed.json", {
3136
- version: 3,
3137
- failed_at: checkpointTimestamp(),
3138
- error: `${e?.name ?? "Error"}: ${e?.message ?? ""}`,
3139
- expression_text: plan?.["expression_text"] ?? null,
3140
- primary_expression_text: primaryExpressionText,
3141
- primary_part_expression_texts: primaryPartExpressionTexts,
3142
- audit_expression_text: auditExpressionText,
3143
- location_canonical_expression_text: locationCanonicalExpressionText,
3144
- primary_plan: primaryPlan,
3145
- audit_plan: auditPlan,
3146
- location_canonical_plan: locationCanonicalPlan,
3147
- grouping_plan: groupingPlan,
3148
- grouping_apply_summary: groupingApplySummary,
3149
- plan,
3150
- });
3151
- }
3152
- stageFailure(options.workspace, exc, {
3153
- title: "INIT FAILED: Asset curation failed",
3154
- stage: "asset_curation",
3155
- required: ["a well-formed assembled script for deterministic asset curation"],
3156
- nextSteps: ["Rerun init; completed extraction checkpoints will be reused."],
3157
- updates: { episode_completed: state.mergedResults.length },
3158
- });
3159
- }
3160
- ctx.emitProgress("asset-curation", "success");
3161
- }
3162
- // --- state-curation -------------------------------------------------------
3163
- const STATE_CURATION_CACHE_VERSION = 7;
3164
- function stateCurationScriptFingerprint(script, options) {
3165
- return sha256Text(JSON.stringify(sortDeep({
3166
- cache_version: STATE_CURATION_CACHE_VERSION,
3167
- ...assetCacheIdentity(options),
3168
- script: {
3169
- actors: script["actors"],
3170
- locations: script["locations"],
3171
- props: script["props"],
3172
- episodes: script["episodes"],
3173
- },
3174
- })));
3175
- }
3176
- function stateCurationContextFingerprint(scriptFingerprint, partId, refs) {
3177
- return sha256Text(JSON.stringify(sortDeep({
3178
- script_fingerprint: scriptFingerprint,
3179
- part_id: partId,
3180
- refs,
3181
- })));
3182
- }
3183
- function combineStateCurationPlans(parts) {
3184
- const decisions = parts.flatMap((part) => asList(part.plan["decisions"]).filter(isDict));
3185
- const defaultSelections = parts.flatMap((part) => asList(part.plan["default_selections"]).filter(isDict));
3186
- const expressions = parts.flatMap((part) => asList(part.plan["expressions"]));
3187
- return {
3188
- version: 1,
3189
- format: "state-curation-exp-v1",
3190
- expression_text: expressions.length > 0 ? expressions.map((expression) => `<exp>${expression}</exp>`).join("\n") : "<exp>NOOP # no state curation changes</exp>",
3191
- expressions,
3192
- decisions,
3193
- default_selections: defaultSelections,
3194
- parts: parts.map((part) => ({
3195
- part_id: part.partId,
3196
- refs: part.refs,
3197
- context_fingerprint: part.contextFingerprint,
3198
- context_chars: part.contextChars,
3199
- reused: part.reused,
3200
- decisions: asList(part.plan["decisions"]).length,
3201
- default_selections: asList(part.plan["default_selections"]).length,
3202
- })),
3203
- };
3204
- }
3205
- async function loadOrRunStateCurationPart(ctx, script, curateStates, scriptFingerprint, partId, refs) {
3206
- const artifactName = `state_curation_parts/${partId}.json`;
3207
- const contextFingerprint = stateCurationContextFingerprint(scriptFingerprint, partId, refs);
3208
- const cached = ctx.ws.readJsonSafe(artifactName);
3209
- const cachedPlan = !ctx.state.bypassInternalCaches
3210
- && isDict(cached)
3211
- && cached["context_fingerprint"] === contextFingerprint
3212
- && cached["parse_status"] === "ok"
3213
- && isDict(cached["plan"])
3214
- ? cached["plan"]
3215
- : null;
3216
- if (cachedPlan && isList(cachedPlan["decisions"])) {
3217
- const cachedContextChars = isDict(cached) ? Number(cached["context_chars"] ?? 0) : 0;
3218
- return {
3219
- partId,
3220
- refs: [...refs],
3221
- contextFingerprint,
3222
- contextChars: cachedContextChars,
3223
- plan: cachedPlan,
3224
- reused: true,
3225
- };
3226
- }
3227
- const startedAtMs = Date.now();
3228
- const contextText = buildStateCurationContextText(script, refs);
3229
- const requiredKeys = stateCurationSourceKeysForRefs(script, refs);
3230
- let expressionText = "";
3231
- try {
3232
- expressionText = await ctx.timing.span({ name: `state_curation.${partId}`, category: "chunk", parent: "state_curation.chunks", metadata: { artifact: artifactName } }, () => curateStates(script, contextText));
3233
- const parsed = parseStateCurationExpressions(expressionText);
3234
- const plan = assertStateCurationPlan(script, parsed, requiredKeys);
3235
- ctx.ws.writeJson(artifactName, {
3236
- version: 1,
3237
- ...assetCacheArtifactFields(ctx, STATE_CURATION_CACHE_VERSION),
3238
- context_fingerprint: contextFingerprint,
3239
- script_fingerprint: scriptFingerprint,
3240
- parse_status: "ok",
3241
- part_id: partId,
3242
- refs,
3243
- required_state_ids: requiredKeys,
3244
- context_chars: contextText.length,
3245
- expression_text: expressionText,
3246
- duration_ms: durationMs(startedAtMs, Date.now()),
3247
- plan,
3248
- updated_at: checkpointTimestamp(),
3249
- });
3250
- return {
3251
- partId,
3252
- refs: [...refs],
3253
- contextFingerprint,
3254
- contextChars: contextText.length,
3255
- plan,
3256
- reused: false,
3257
- };
3258
- }
3259
- catch (exc) {
3260
- const e = exc;
3261
- ctx.ws.writeJson(artifactName, {
3262
- version: 1,
3263
- ...assetCacheArtifactFields(ctx, STATE_CURATION_CACHE_VERSION),
3264
- context_fingerprint: contextFingerprint,
3265
- script_fingerprint: scriptFingerprint,
3266
- parse_status: "failed",
3267
- part_id: partId,
3268
- refs,
3269
- required_state_ids: requiredKeys,
3270
- context_chars: contextText.length,
3271
- expression_text: expressionText,
3272
- duration_ms: durationMs(startedAtMs, Date.now()),
3273
- error: `${e?.name ?? "Error"}: ${e?.message ?? ""}`,
3274
- updated_at: checkpointTimestamp(),
3275
- });
3276
- throw exc;
3277
- }
3278
- }
3279
- export async function stageStateCuration(ctx) {
3280
- const { ws, state, options } = ctx;
3281
- const script = ensureScript(ctx);
3282
- let plan = null;
3283
- try {
3284
- ctx.updateRunState({ status: "init_running", init_stage: "state_curation" });
3285
- ctx.emitProgress("state-curation");
3286
- const scriptFingerprint = stateCurationScriptFingerprint(script, options);
3287
- const cached = ws.readJsonSafe("state_curation.json");
3288
- if (!state.bypassInternalCaches && isDict(cached) && cached["script_fingerprint"] === scriptFingerprint && isList(cached["decisions"])) {
3289
- const curation = await ctx.timing.span({ name: "state_curation.apply", category: "substage", parent: "state_curation" }, () => applyStateCurationPlan(script, { decisions: cached["decisions"], default_selections: cached["default_selections"] }));
3290
- curation["script_fingerprint"] = scriptFingerprint;
3291
- curation["cache_version"] = STATE_CURATION_CACHE_VERSION;
3292
- curation["provider"] = options.providerName;
3293
- curation["model"] = options.model;
3294
- if (isList(cached["parts"]))
3295
- curation["parts"] = cached["parts"];
3296
- if (typeof cached["expression_text"] === "string")
3297
- curation["expression_text"] = cached["expression_text"];
3298
- if (isList(cached["expressions"]))
3299
- curation["expressions"] = cached["expressions"];
3300
- if (isList(cached["default_selections"]))
3301
- curation["default_selections"] = cached["default_selections"];
3302
- ws.writeJson("state_curation.json", curation);
3303
- ws.delete("state_curation.failed.json");
3304
- ctx.emitProgress("state-curation", "success");
3305
- return;
3306
- }
3307
- const chunks = buildStateCurationChunks(script);
3308
- if (chunks.length === 0) {
3309
- const curation = applyStateCurationPlan(script, { decisions: [] });
3310
- curation["script_fingerprint"] = scriptFingerprint;
3311
- curation["cache_version"] = STATE_CURATION_CACHE_VERSION;
3312
- curation["provider"] = options.providerName;
3313
- curation["model"] = options.model;
3314
- curation["parts"] = [];
3315
- curation["expression_text"] = "<exp>NOOP # no state curation changes</exp>";
3316
- curation["expressions"] = ["NOOP # no state curation changes"];
3317
- ws.writeJson("state_curation.json", curation);
3318
- ws.delete("state_curation.failed.json");
3319
- ctx.emitProgress("state-curation", "success");
3320
- return;
3321
- }
3322
- const provider = ctx.provider();
3323
- if (!provider.curateStates) {
3324
- throw new CliError("INIT FAILED: Provider does not support state curation", "Provider does not support state curation.", {
3325
- exitCode: EXIT_RUNTIME,
3326
- required: ["provider.curateStates(script, contextText)"],
3327
- received: [`provider: ${provider.name || options.providerName}`],
3328
- nextSteps: ["Use a direct provider that supports state curation."],
3329
- });
3330
- }
3331
- ws.mkdir("state_curation_parts");
3332
- ctx.updateRunState({ state_curation_completed_chunks: 0, state_curation_total_chunks: chunks.length });
3333
- const curateStates = provider.curateStates.bind(provider);
3334
- let completedChunks = 0;
3335
- const outcomes = await pMapWithConcurrency(chunks, options.concurrency, async (chunk) => {
3336
- const part = await loadOrRunStateCurationPart(ctx, script, curateStates, scriptFingerprint, chunk.part_id, chunk.refs);
3337
- completedChunks += 1;
3338
- ctx.updateRunState({ state_curation_completed_chunks: completedChunks, state_curation_total_chunks: chunks.length });
3339
- return part;
3340
- });
3341
- const parts = unwrapConcurrentOutcomes(outcomes).sort((left, right) => left.partId.localeCompare(right.partId));
3342
- plan = combineStateCurationPlans(parts);
3343
- const curation = await ctx.timing.span({ name: "state_curation.apply", category: "substage", parent: "state_curation" }, () => applyStateCurationPlan(script, plan));
3344
- curation["script_fingerprint"] = scriptFingerprint;
3345
- curation["cache_version"] = STATE_CURATION_CACHE_VERSION;
3346
- curation["provider"] = options.providerName;
3347
- curation["model"] = options.model;
3348
- curation["expression_text"] = plan["expression_text"];
3349
- curation["expressions"] = plan["expressions"];
3350
- curation["parts"] = plan["parts"];
3351
- ws.writeJson("state_curation.json", curation);
3352
- ws.delete("state_curation.failed.json");
3353
- }
3354
- catch (exc) {
3355
- const e = exc;
3356
- ws.writeJson("state_curation.failed.json", {
3357
- version: 1,
3358
- failed_at: checkpointTimestamp(),
3359
- error: `${e?.name ?? "Error"}: ${e?.message ?? ""}`,
3360
- plan,
3361
- });
3362
- stageFailure(options.workspace, exc, {
3363
- title: "INIT FAILED: State curation failed",
3364
- stage: "state_curation",
3365
- required: ["provider state curation decisions for the assembled script state catalog"],
3366
- nextSteps: ["Rerun init; completed extraction and asset curation checkpoints will be reused."],
3367
- updates: { episode_completed: state.mergedResults.length },
3368
- });
3369
- }
3370
- ctx.emitProgress("state-curation", "success");
3371
- }
3372
- const STATE_BINDING_CHUNK_CACHE_VERSION = 2;
3373
- function stateBindingChunkFingerprint(options, context) {
3374
- return sha256Text(JSON.stringify(sortDeep({
3375
- cache_version: STATE_BINDING_CHUNK_CACHE_VERSION,
3376
- provider: options.providerName,
3377
- model: options.model,
3378
- context,
3379
- })));
3380
- }
3381
- async function runStateBindingForScript(ctx, script, progress) {
3382
- const { ws, options } = ctx;
3383
- const provider = ctx.provider();
3384
- if (!provider.bindStates) {
3385
- throw new CliError("INIT FAILED: Provider does not support state binding", "Provider does not support state binding.", {
3386
- exitCode: EXIT_RUNTIME,
3387
- required: ["provider.bindStates(script, context)"],
3388
- received: [`provider: ${provider.name || options.providerName}`],
3389
- nextSteps: ["Use a direct provider that supports state binding."],
3390
- });
3391
- }
3392
- const defaultSummary = await ctx.timing.span({ name: "state_binding.default_states", category: "substage", parent: "state_binding" }, () => ensureDefaultStates(script));
3393
- const missingDefaults = Number(defaultSummary["assets_missing_default_state"] ?? 0);
3394
- if (missingDefaults > 0) {
3395
- throw new CliError("DIRECT STATE BINDING BLOCKED: default states missing", "Some assets have states but no asset-scoped `default` state.", {
3396
- exitCode: EXIT_NEEDS_AGENT,
3397
- required: ["each stateful actor/location/prop has exactly one state_id `default`"],
3398
- received: [`assets_missing_default_state: ${missingDefaults}`],
3399
- nextSteps: ["Run `scriptctl direct run state-curation` or edit script.initial.json to select each asset's common visual state as `default`."],
3400
- });
3401
- }
3402
- const defaultBindingSummary = await ctx.timing.span({ name: "state_binding.default_refs", category: "substage", parent: "state_binding" }, () => bindDefaultStateRefsInScript(script));
3403
- const chunks = buildStateBindingContexts(script, 8);
3404
- ws.mkdir("state_binding_chunks");
3405
- if (progress) {
3406
- ctx.updateRunState({ state_binding_completed_chunks: 0, state_binding_total_chunks: chunks.length });
3407
- }
3408
- let chunkPlans = [];
3409
- const bypassChunkCache = ctx.state.bypassInternalCaches;
3410
- try {
3411
- const outcomes = await pMapWithConcurrency(chunks, options.concurrency, async (context, index) => {
3412
- const chunkArtifact = `state_binding_chunks/chunk_${pad3(index + 1)}.json`;
3413
- const contextFingerprint = stateBindingChunkFingerprint(options, context);
3414
- const cachedChunk = ws.readJsonSafe(chunkArtifact);
3415
- const cachedPlanRaw = isDict(cachedChunk) ? cachedChunk["plan"] : null;
3416
- const cachedPlan = !bypassChunkCache
3417
- && isDict(cachedChunk)
3418
- && cachedChunk["context_fingerprint"] === contextFingerprint
3419
- && isDict(cachedPlanRaw)
3420
- ? cachedPlanRaw
3421
- : null;
3422
- const startedAtMs = Date.now();
3423
- const rawPlan = normalizeStateBindingPlanShape(cachedPlan ?? await ctx.timing.span({ name: `state_binding.chunk.${pad3(index + 1)}`, category: "chunk", parent: "state_binding.chunks", metadata: { index } }, () => provider.bindStates(script, context)));
3424
- if (!isDict(rawPlan) || !isList(rawPlan["scenes"])) {
3425
- throw new CliError("INIT FAILED: Provider returned invalid state binding", "Provider returned invalid state binding.", {
3426
- exitCode: EXIT_RUNTIME,
3427
- required: ["object with scenes[]"],
3428
- received: [typeof rawPlan],
3429
- nextSteps: ["Rerun init; completed extraction checkpoints will be reused and state binding will retry."],
3430
- });
3431
- }
3432
- if (!cachedPlan) {
3433
- ws.writeJson(chunkArtifact, {
3434
- version: 1,
3435
- cache_version: STATE_BINDING_CHUNK_CACHE_VERSION,
3436
- index,
3437
- provider: options.providerName,
3438
- model: options.model,
3439
- context_fingerprint: contextFingerprint,
3440
- scene_ids: asList(context["scenes"]).map((scene) => strOf(scene["scene_id"])),
3441
- duration_ms: durationMs(startedAtMs, Date.now()),
3442
- plan: rawPlan,
3443
- updated_at: checkpointTimestamp(),
3444
- });
3445
- }
3446
- if (progress) {
3447
- ctx.updateRunState({ state_binding_completed_chunks: index + 1, state_binding_total_chunks: chunks.length });
3448
- }
3449
- return { index, context, plan: rawPlan, contextFingerprint, reused: Boolean(cachedPlan) };
3450
- });
3451
- chunkPlans = unwrapConcurrentOutcomes(outcomes).sort((left, right) => left.index - right.index);
3452
- }
3453
- catch (exc) {
3454
- const e = exc;
3455
- ws.writeJson("state_binding.failed.json", {
3456
- version: 1,
3457
- failed_at: checkpointTimestamp(),
3458
- chunks_total: chunks.length,
3459
- error: `${e?.name ?? "Error"}: ${e?.message ?? ""}`,
3460
- completed_chunks: [],
3461
- });
3462
- throw exc;
3463
- }
3464
- const chunkReports = [];
3465
- let sceneRefsBound = 0;
3466
- let stateChangesApplied = 0;
3467
- let invalidSceneRefsSkipped = 0;
3468
- let invalidStateChangesSkipped = 0;
3469
- try {
3470
- await ctx.timing.span({ name: "state_binding.apply", category: "substage", parent: "state_binding" }, () => {
3471
- for (const item of chunkPlans) {
3472
- const applied = applyStateBindingPlan(script, item.plan, { invalidStateMode: "skip" });
3473
- sceneRefsBound += Number(applied["scene_refs_bound"] ?? 0);
3474
- stateChangesApplied += Number(applied["state_changes_applied"] ?? 0);
3475
- invalidSceneRefsSkipped += Number(applied["invalid_scene_refs_skipped"] ?? 0);
3476
- invalidStateChangesSkipped += Number(applied["invalid_state_changes_skipped"] ?? 0);
3477
- chunkReports.push({
3478
- index: item.index,
3479
- reused: item.reused,
3480
- context_fingerprint: item.contextFingerprint,
3481
- scene_count: asList(item.context["scenes"]).length,
3482
- scene_refs_bound: applied["scene_refs_bound"],
3483
- state_changes_applied: applied["state_changes_applied"],
3484
- invalid_scene_refs_skipped: applied["invalid_scene_refs_skipped"],
3485
- invalid_state_changes_skipped: applied["invalid_state_changes_skipped"],
3486
- });
3487
- }
3488
- });
3489
- }
3490
- catch (exc) {
3491
- const e = exc;
3492
- ws.writeJson("state_binding.failed.json", {
3493
- version: 1,
3494
- failed_at: checkpointTimestamp(),
3495
- chunks_total: chunks.length,
3496
- error: `${e?.name ?? "Error"}: ${e?.message ?? ""}`,
3497
- completed_chunks: chunkReports,
3498
- });
3499
- throw exc;
3500
- }
3501
- const summary = {
3502
- ...defaultSummary,
3503
- ...defaultBindingSummary,
3504
- llm_scene_refs_bound: sceneRefsBound,
3505
- llm_state_changes_applied: stateChangesApplied,
3506
- invalid_scene_refs_skipped: invalidSceneRefsSkipped,
3507
- invalid_state_changes_skipped: invalidStateChangesSkipped,
3508
- chunks: chunks.length,
3509
- cached_chunks: chunkPlans.filter((chunk) => chunk.reused).length,
3510
- llm_scene_count: chunks.reduce((n, chunk) => n + asList(chunk["scenes"]).length, 0),
3511
- };
3512
- ws.writeJson("state_binding.json", {
3513
- version: 1,
3514
- summary,
3515
- chunks: chunkReports,
3516
- });
3517
- ws.delete("state_binding.failed.json");
3518
- return { summary, chunks: chunkReports };
3519
- }
3520
- export async function stageStateBinding(ctx) {
3521
- const { state, options } = ctx;
3522
- const script = ensureScript(ctx);
3523
- try {
3524
- ctx.updateRunState({ status: "init_running", init_stage: "state_binding" });
3525
- ctx.emitProgress("state-binding");
3526
- await runStateBindingForScript(ctx, script, true);
3527
- }
3528
- catch (exc) {
3529
- stageFailure(options.workspace, exc, {
3530
- title: "INIT FAILED: State binding failed",
3531
- stage: "state_binding",
3532
- required: ["provider state binding decisions for a well-formed assembled script"],
3533
- nextSteps: ["Rerun init; completed extraction and curation checkpoints will be reused."],
3534
- updates: { episode_completed: state.mergedResults.length },
3535
- });
3536
- }
3537
- ctx.emitProgress("state-binding", "success");
3538
- }
3539
- async function runMetadataForScript(ctx, script, applyToScript, progress) {
3540
- const { ws, options } = ctx;
3541
- const sourceText = ensureSourceText(ctx);
3542
- const assetMetadataPath = ws.pathOf("asset_metadata.json");
3543
- if (options.skipMetadata) {
3544
- if (progress)
3545
- ctx.updateRunState({ status: "init_running", init_stage: "metadata_extract", metadata_skipped: true });
3546
- if (exists(assetMetadataPath))
3547
- deletePath(assetMetadataPath);
3548
- return { metadata: {}, skipped: true };
3549
- }
3550
- const provider = ctx.provider();
3551
- let metadata = provider.extractMetadata
3552
- ? await ctx.timing.span({ name: "metadata.extract", category: "substage", parent: "metadata_extract" }, () => provider.extractMetadata(sourceText, script))
3553
- : {};
3554
- if (!isDict(metadata))
3555
- metadata = {};
3556
- ws.writeJson("asset_metadata.json", metadata);
3557
- if (applyToScript) {
3558
- await ctx.timing.span({ name: "metadata.apply", category: "substage", parent: "metadata_extract" }, () => applyMetadataToScript(script, metadata));
3559
- }
3560
- return { metadata, skipped: false };
3561
- }
3562
- export async function stageMetadata(ctx) {
3563
- const { state, options } = ctx;
3564
- const script = ensureScript(ctx);
3565
- try {
3566
- ctx.updateRunState({ status: "init_running", init_stage: "metadata_extract", metadata_skipped: false });
3567
- ctx.emitProgress("metadata");
3568
- const result = await runMetadataForScript(ctx, script, true, true);
3569
- if (result.skipped) {
3570
- ctx.emitProgress("metadata", "skipped");
3571
- return;
3572
- }
3573
- }
3574
- catch (exc) {
3575
- if (exc instanceof CliError) {
3576
- throw initFailedReport(options.workspace, {
3577
- title: exc.title,
3578
- stage: "metadata_extract",
3579
- exitCode: exc.exitCode,
3580
- errorCode: exc.errorCode,
3581
- required: exc.required.length > 0 ? exc.required : ["metadata JSON matching final script contract"],
3582
- received: exc.received.length > 0 ? exc.received : [String(exc.message).slice(0, 160)],
3583
- nextSteps: exc.nextSteps.length > 0 ? exc.nextSteps : ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
3584
- updates: { episode_completed: state.mergedResults.length },
3585
- });
3586
- }
3587
- stageFailure(options.workspace, exc, {
3588
- title: "INIT FAILED: Metadata extraction failed",
3589
- stage: "metadata_extract",
3590
- required: ["provider metadata for worldview, role_type, and asset descriptions"],
3591
- nextSteps: ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
3592
- updates: { episode_completed: state.mergedResults.length },
3593
- });
3594
- }
3595
- ctx.emitProgress("metadata", "success");
3596
- }
3597
- function overviewFieldsFromScript(script) {
3598
- return {
3599
- synopsis: script["synopsis"],
3600
- theme: script["theme"],
3601
- logline: script["logline"],
3602
- style: script["style"],
3603
- main_characters: script["main_characters"],
3604
- };
3605
- }
3606
- async function runScriptSynopsisForScript(ctx, script, persistScript) {
3607
- const { ws, options } = ctx;
3608
- const provider = ctx.provider();
3609
- let degraded = false;
3610
- let reason = "";
3611
- try {
3612
- const summary = await buildScriptSynopsis(provider, script, {
3613
- dd: ws.dir,
3614
- thresholdChars: options.summaryThresholdChars,
3615
- groupSize: DEFAULT_SUMMARY_GROUP_SIZE,
3616
- resummarize: options.resummarize,
3617
- skipSummary: options.skipSummary,
3618
- concurrency: options.concurrency,
3619
- });
3620
- degraded = summary.degraded;
3621
- reason = summary.reason ?? "";
3622
- }
3623
- catch (exc) {
3624
- degraded = true;
3625
- reason = providerErrorSummary(exc);
3626
- }
3627
- if (persistScript)
3628
- ws.writeJson("script.initial.json", script);
3629
- return { degraded, reason, fields: overviewFieldsFromScript(script) };
3630
- }
3631
- export async function stageScriptSynopsis(ctx) {
3632
- const { ws, state, options } = ctx;
3633
- const script = ensureScript(ctx);
3634
- ctx.updateRunState({ status: "init_running", init_stage: "script_synopsis" });
3635
- ctx.emitProgress("script-synopsis");
3636
- const summary = await runScriptSynopsisForScript(ctx, script, true);
3637
- state.summaryFullDegraded = summary.degraded;
3638
- state.summaryDegradeReason = summary.reason;
3639
- state.summaryDegradedEpisodes.sort((a, b) => a - b);
3640
- const summaryDegraded = summary.degraded || state.summaryDegradedEpisodes.length > 0;
3641
- ctx.updateRunState({
3642
- summary_skipped: options.skipSummary,
3643
- summary_degraded: summaryDegraded,
3644
- summary_degraded_episodes: state.summaryDegradedEpisodes,
3645
- summary_degraded_reason: summary.reason || null,
3646
- });
3647
- ctx.emitProgress("script-synopsis", summaryDegraded ? "warning" : options.skipSummary ? "skipped" : "success");
3648
- }
3649
- // --- validate -------------------------------------------------------------
3650
- export async function stageValidate(ctx) {
3651
- const { ws, state, options } = ctx;
3652
- ensureScript(ctx); // ensures script.initial.json exists on disk for validateScript
3653
- const scriptPath = ws.pathOf("script.initial.json");
3654
- ctx.updateRunState({ status: "init_running", init_stage: "validate" });
3655
- ctx.emitProgress("validate");
3656
- let validation;
3657
- try {
3658
- validation = validateScript(options.workspace, scriptPath);
3659
- }
3660
- catch (exc) {
3661
- stageFailure(options.workspace, exc, {
3662
- title: "INIT FAILED: Validation failed",
3663
- stage: "validate",
3664
- required: ["script.initial.json that can be validated"],
3665
- nextSteps: ["Rerun init to retry validation, or inspect script.initial.json if the failure persists."],
3666
- updates: { script_path: scriptPath },
3667
- });
3668
- }
3669
- state.validation = validation;
3670
- const passed = Boolean(validation["passed"]);
3671
- ctx.emitProgress("validate", passed ? "success" : "warning");
3672
- }
3673
- // ---------------------------------------------------------------------------
3674
- // command_status — the agent-facing state table, derived entirely from disk: a
3675
- // batch with `<key>.json` is done, with `<key>.error.json` it failed, with
3676
- // neither it is pending. run_state is just a denormalized cache of this, so the
3677
- // agent can read state, then pick exactly which episodes/batches to (re)run.
3678
- // ---------------------------------------------------------------------------
3679
- export function commandStatus(opts) {
3680
- const workspace = strOf(opts["workspace_path"] || "workspace");
3681
- const dd = directDir(workspace);
3682
- const episodePlanPath = path.join(dd, "episode_plan.json");
3683
- const batchPlanPath = path.join(dd, "batch_plan.json");
3684
- const hasTrace = exists(path.join(workspace, "source.txt")) || Object.keys(readRunState(workspace)).length > 0;
3685
- if (!hasTrace && (!exists(episodePlanPath) || !exists(batchPlanPath))) {
3686
- throw new CliError("STATUS BLOCKED: Plan not found", "Plan not found.", {
3687
- exitCode: EXIT_INPUT,
3688
- required: ["episode_plan.json and batch_plan.json"],
3689
- received: [exists(episodePlanPath) ? "episode_plan.json ok" : "episode_plan.json missing"],
3690
- nextSteps: ["Run scriptctl direct init first."],
3691
- });
3692
- }
3693
- const snapshot = buildDirectStatusSnapshot(workspace);
3694
- const result = renderDirectStatusResult(snapshot);
3695
- const blockingStatuses = new Set(["error", "pending", "running"]);
3696
- const allDone = !snapshot.stages.some((stage) => blockingStatuses.has(stage.status));
3697
- const firstErrorEpisode = snapshot.episodes.find((episode) => episode.status === "error");
3698
- const report = {
3699
- title: "DIRECT STATUS",
3700
- result,
3701
- artifacts: [path.join(dd, "batch_results"), path.join(dd, "episode_results"), path.join(dd, "run_state.json")],
3702
- overall_status: snapshot.overall_status,
3703
- running_stage: snapshot.running_stage,
3704
- stages: snapshot.stages,
3705
- episodes: snapshot.episodes,
3706
- batches: snapshot.batches,
3707
- next: firstErrorEpisode
3708
- ? [`Re-run an errored episode with: direct init --episodes ${firstErrorEpisode.episode} (or --retry-errors).`]
3709
- : allDone
3710
- ? ["All episodes complete."]
3711
- : ["Pending work remains — re-run: direct init (resume) or --episodes <N>."],
3712
- };
3713
- // Non-zero when the workspace is not fully extracted, so automation can gate
3714
- // on the exit code instead of scraping stdout.
3715
- return [report, allDone ? EXIT_OK : EXIT_NEEDS_AGENT];
3716
- }
3717
- export function summarizeIssues(issues) {
3718
- if (issues.length === 0)
3719
- return [];
3720
- const counts = {};
3721
- for (const item of issues) {
3722
- const sev = strOf(item["severity"]);
3723
- counts[sev] = (counts[sev] ?? 0) + 1;
3724
- }
3725
- const parts = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)).map(([sev, c]) => `${sev}: ${c}`);
3726
- const first = issues[0];
3727
- return [parts.join("; "), `first: ${first["code"]} - ${first["summary"]}`];
3728
- }
3729
- // ---------------------------------------------------------------------------
3730
- // command_validate
3731
- // ---------------------------------------------------------------------------
3732
- export function commandValidate(opts) {
3733
- const workspace = strOf(opts["workspace_path"] || "workspace");
3734
- const scriptPath = opts["script_path"] ? strOf(opts["script_path"]) : null;
3735
- const validation = validateScript(workspace, scriptPath);
3736
- const stats = validation["stats"] ?? {};
3737
- const passed = Boolean(validation["passed"]);
3738
- const report = {
3739
- title: passed ? "VALIDATE PASSED: Script is ready" : "VALIDATE NEEDS AGENT: Repair issues found",
3740
- result: [`${stats["episodes"] ?? 0} episodes / ${stats["scenes"] ?? 0} scenes / ${stats["actions"] ?? 0} actions`],
3741
- artifacts: [path.join(directDir(workspace), "validation.json")],
3742
- issues: summarizeIssues(asList(validation["issues"])),
3743
- // Success needs no guidance; only repair gets a next step.
3744
- next: passed ? undefined : ["Inspect issues and apply structured patches."],
3745
- };
3746
- return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
3747
- }
3748
- // ---------------------------------------------------------------------------
3749
- // command_inspect (incl. review rendering)
3750
- // ---------------------------------------------------------------------------
3751
- function renderReviewEpisode(sourceText, episodePlan, script, episodeNum) {
3752
- const epId = fmtId("ep", episodeNum);
3753
- const scriptEp = asList(script["episodes"]).find((ep) => ep["episode_id"] === epId);
3754
- const planEp = asList(episodePlan["episodes"]).find((ep) => Number(ep["episode"] ?? 0) === episodeNum);
3755
- const lines = [];
3756
- if (!planEp && !scriptEp) {
3757
- lines.push(`⚠ Episode ${episodeNum} not found in episode_plan.json or script.initial.json.`);
3758
- const available = [...new Set(asList(episodePlan["episodes"]).map((ep) => Number(ep["episode"] ?? 0)))].sort((a, b) => a - b);
3759
- if (available.length > 0)
3760
- lines.push(`Available episodes: ${available.join(", ")}`);
3761
- return lines;
3762
- }
3763
- const title = (scriptEp?.["title"] ?? planEp?.["title"]) || "(无标题)";
3764
- lines.push("=".repeat(72));
3765
- lines.push(`EPISODE ${episodeNum} / ${epId} — ${title}`);
3766
- lines.push("=".repeat(72));
3767
- lines.push("");
3768
- lines.push("--- 原文 source.txt ---");
3769
- if (planEp) {
3770
- const span = isDict(planEp["source_span"]) ? planEp["source_span"] : {};
3771
- const start = Number(span["start"] ?? 0);
3772
- const end = Number(span["end"] ?? sourceText.length);
3773
- const snippet = sourceText.slice(start, end).replace(/\s+$/, "");
3774
- lines.push(snippet || "(empty)");
3775
- }
3776
- else {
3777
- lines.push("(no episode_plan entry; source span unavailable)");
3778
- }
3779
- lines.push("");
3780
- lines.push("--- 抽取 script.initial.json ---");
3781
- if (scriptEp) {
3782
- const actorIdToName = new Map();
3783
- for (const a of asList(script["actors"]))
3784
- actorIdToName.set(strOf(a["actor_id"]), strOf(a["actor_name"]));
3785
- const speakerIdToName = new Map();
3786
- for (const s of asList(script["speakers"]))
3787
- speakerIdToName.set(strOf(s["speaker_id"]), strOf(s["display_name"]));
3788
- const locationIdToName = new Map();
3789
- for (const l of asList(script["locations"]))
3790
- locationIdToName.set(strOf(l["location_id"]), strOf(l["location_name"]));
3791
- const propIdToName = new Map();
3792
- for (const p of asList(script["props"]))
3793
- propIdToName.set(strOf(p["prop_id"]), strOf(p["prop_name"]));
3794
- for (const scene of asList(scriptEp["scenes"])) {
3795
- const env = isDict(scene["environment"]) ? scene["environment"] : {};
3796
- const space = strOf(env["space"]) || "?";
3797
- const timeOfDay = strOf(env["time"]) || "?";
3798
- const sceneId = strOf(scene["scene_id"]) || "?";
3799
- let locName = "";
3800
- for (const ref of asList(scene["locations"])) {
3801
- locName = locationIdToName.get(strOf(ref["location_id"])) || locName;
3802
- break;
3803
- }
3804
- const actorNames = [];
3805
- for (const ref of asList(scene["actors"])) {
3806
- const n = actorIdToName.get(strOf(ref["actor_id"]));
3807
- if (n)
3808
- actorNames.push(n);
3809
- }
3810
- const propNames = [];
3811
- for (const ref of asList(scene["props"])) {
3812
- const n = propIdToName.get(strOf(ref["prop_id"]));
3813
- if (n)
3814
- propNames.push(n);
3815
- }
3816
- lines.push("");
3817
- lines.push(`Scene ${sceneId} [${space} ${timeOfDay}] ${locName || "(未知场景)"}`);
3818
- if (actorNames.length > 0)
3819
- lines.push(` Actors: ${actorNames.join(", ")}`);
3820
- if (propNames.length > 0)
3821
- lines.push(` Props: ${propNames.join(", ")}`);
3822
- for (const action of asList(scene["actions"])) {
3823
- const kind = strOf(action["type"]);
3824
- const content = strOf(action["content"]).replace(/\n/g, "\n ");
3825
- let tag;
3826
- if (kind === "dialogue") {
3827
- const speaker = actorIdToName.get(strOf(action["actor_id"])) || speakerIdToName.get(strOf(action["speaker_id"])) || strOf(action["speaker"]) || "?";
3828
- const emotion = strOf(action["emotion"]);
3829
- tag = `dlg|${speaker}` + (emotion ? `|${emotion}` : "");
3830
- }
3831
- else if (kind === "inner_thought") {
3832
- const speaker = actorIdToName.get(strOf(action["actor_id"])) || speakerIdToName.get(strOf(action["speaker_id"])) || strOf(action["speaker"]) || "?";
3833
- const emotion = strOf(action["emotion"]);
3834
- tag = `think|${speaker}` + (emotion ? `|${emotion}` : "");
3835
- }
3836
- else {
3837
- tag = "act";
3838
- }
3839
- lines.push(` [${tag}] ${content}`);
3840
- }
3841
- }
3842
- }
3843
- else {
3844
- lines.push("(script.initial.json contains no entry for this episode)");
3845
- }
3846
- if (scriptEp) {
3847
- const actorIdsInEp = new Set();
3848
- const locationIdsInEp = new Set();
3849
- const propIdsInEp = new Set();
3850
- for (const scene of asList(scriptEp["scenes"])) {
3851
- for (const ref of asList(scene["actors"])) {
3852
- if (ref["actor_id"])
3853
- actorIdsInEp.add(strOf(ref["actor_id"]));
3854
- }
3855
- for (const ref of asList(scene["locations"])) {
3856
- if (ref["location_id"])
3857
- locationIdsInEp.add(strOf(ref["location_id"]));
3858
- }
3859
- for (const ref of asList(scene["props"])) {
3860
- if (ref["prop_id"])
3861
- propIdsInEp.add(strOf(ref["prop_id"]));
3862
- }
3863
- for (const action of asList(scene["actions"])) {
3864
- if (action["actor_id"])
3865
- actorIdsInEp.add(strOf(action["actor_id"]));
3866
- }
3867
- }
3868
- if (actorIdsInEp.size > 0 || locationIdsInEp.size > 0 || propIdsInEp.size > 0) {
3869
- lines.push("");
3870
- lines.push(`--- Episode ${episodeNum} 资产 ---`);
3871
- }
3872
- if (actorIdsInEp.size > 0) {
3873
- lines.push("Actors:");
3874
- for (const actor of asList(script["actors"])) {
3875
- if (!actorIdsInEp.has(strOf(actor["actor_id"])))
3876
- continue;
3877
- const desc = strOf(actor["description"]).trim() || "(无描述)";
3878
- const role = strOf(actor["role_type"]) || "?";
3879
- lines.push(` - ${actor["actor_id"]} ${actor["actor_name"]} [${role}] — ${desc}`);
3880
- }
3881
- }
3882
- if (locationIdsInEp.size > 0) {
3883
- lines.push("Locations:");
3884
- for (const loc of asList(script["locations"])) {
3885
- if (!locationIdsInEp.has(strOf(loc["location_id"])))
3886
- continue;
3887
- const desc = strOf(loc["description"]).trim() || "(无描述)";
3888
- lines.push(` - ${loc["location_id"]} ${loc["location_name"]} — ${desc}`);
3889
- }
3890
- }
3891
- if (propIdsInEp.size > 0) {
3892
- lines.push("Props:");
3893
- for (const prop of asList(script["props"])) {
3894
- if (!propIdsInEp.has(strOf(prop["prop_id"])))
3895
- continue;
3896
- const desc = strOf(prop["description"]).trim() || "(无描述)";
3897
- lines.push(` - ${prop["prop_id"]} ${prop["prop_name"]} — ${desc}`);
3898
- }
3899
- }
3900
- }
3901
- return lines;
3902
- }
3903
- function renderAssetCurationSummary(curation) {
3904
- const summary = isDict(curation["summary"]) ? curation["summary"] : {};
3905
- const lines = [
3906
- `curation: ` +
3907
- `actors ${summary["actors_before"] ?? 0}->${summary["actors_after"] ?? 0} ` +
3908
- `(removed ${summary["actors_removed"] ?? 0}), ` +
3909
- `props ${summary["props_before"] ?? 0}->${summary["props_after"] ?? 0} ` +
3910
- `(removed ${summary["props_removed"] ?? 0}), ` +
3911
- `locations ${summary["locations_before"] ?? 0}->${summary["locations_after"] ?? 0} ` +
3912
- `(merged ${summary["locations_merged"] ?? 0})`,
3913
- ];
3914
- const coverage = isDict(summary["coverage"]) ? summary["coverage"] : {};
3915
- const required = isDict(coverage["required"]) ? coverage["required"] : {};
3916
- const auditSummary = isDict(curation["audit_summary"]) ? curation["audit_summary"] : {};
3917
- if (summary["explicit_keep_count"] !== undefined || auditSummary["audit_decisions"] !== undefined || required["prop"] !== undefined) {
3918
- lines.push(`curation coverage: ` +
3919
- `keep ${summary["explicit_keep_count"] ?? 0}, ` +
3920
- `implicit ${summary["implicit_keep_count"] ?? 0}, ` +
3921
- `audit ${auditSummary["audit_decisions"] ?? 0}, ` +
3922
- `required actor/location/prop ${required["actor"] ?? 0}/${required["location"] ?? 0}/${required["prop"] ?? 0}`);
3923
- }
3924
- for (const decision of asList(curation["decisions"])) {
3925
- if (!isDict(decision))
3926
- continue;
3927
- const action = strOf(decision["decision"]);
3928
- if (!action || action === "keep")
3929
- continue;
3930
- const kind = strOf(decision["kind"]);
3931
- const sourceId = strOf(decision["source_id"]);
3932
- const targetKind = strOf(decision["target_kind"]);
3933
- const targetId = strOf(decision["target_id"]);
3934
- const newName = strOf(decision["new_name"]);
3935
- const stateName = strOf(decision["state_name"]);
3936
- const speakerKind = strOf(decision["speaker_kind"]);
3937
- const target = targetId
3938
- ? ` -> ${targetKind || kind}:${targetId}`
3939
- : newName
3940
- ? ` -> ${newName}`
3941
- : stateName
3942
- ? ` -> state:${stateName}`
3943
- : speakerKind
3944
- ? ` -> speaker:${speakerKind}`
3945
- : "";
3946
- lines.push(`curation ${kind}:${sourceId} ${action}${target} — ${decision["reason"] || "-"}`);
3947
- }
3948
- for (const actor of asList(curation["actors"])) {
3949
- if (!isDict(actor) || actor["decision"] !== "remove")
3950
- continue;
3951
- lines.push(`curation actor ${actor["actor_id"]}: remove ${actor["name"] || "-"} (scenes=${actor["scene_count"] ?? 0}) — ${actor["reason"] || "-"}`);
3952
- }
3953
- for (const prop of asList(curation["props"])) {
3954
- if (!isDict(prop) || prop["decision"] !== "remove")
3955
- continue;
3956
- lines.push(`curation prop ${prop["prop_id"]}: remove ${prop["name"] || "-"} (scenes=${prop["scene_count"] ?? 0}) — ${prop["reason"] || "-"}`);
3957
- }
3958
- for (const loc of asList(curation["locations"])) {
3959
- if (!isDict(loc) || loc["decision"] !== "merge")
3960
- continue;
3961
- lines.push(`curation location ${loc["location_id"]}: merge ${loc["name"] || "-"} -> ${loc["target_location_id"] || "-"} — ${loc["reason"] || "-"}`);
3962
- }
3963
- return lines;
3964
- }
3965
- function renderStateBindingSummary(binding) {
3966
- const summary = isDict(binding["summary"]) ? binding["summary"] : {};
3967
- return [
3968
- `state-binding: ` +
3969
- `default-states synthesized ${summary["default_states_synthesized"] ?? 0}, ` +
3970
- `llm refs ${summary["llm_scene_refs_bound"] ?? 0}, ` +
3971
- `default refs ${summary["default_refs_bound"] ?? 0}, ` +
3972
- `unresolved ${summary["unresolved_stateful_refs"] ?? 0}, ` +
3973
- `state_changes ${summary["llm_state_changes_applied"] ?? 0}, ` +
3974
- `chunks ${summary["chunks"] ?? 0}`,
3975
- ];
3976
- }
3977
- function renderStateCurationSummary(curation) {
3978
- const summary = isDict(curation["summary"]) ? curation["summary"] : {};
3979
- const decisionSummary = isDict(summary["decisions"]) ? summary["decisions"] : {};
3980
- const lines = [
3981
- `state-curation: ` +
3982
- `states ${summary["states_before"] ?? 0}->${summary["states_after"] ?? 0} ` +
3983
- `(removed ${summary["states_removed"] ?? 0}), ` +
3984
- `actor/location/prop ${summary["actor_states_after"] ?? 0}/${summary["location_states_after"] ?? 0}/${summary["prop_states_after"] ?? 0}, ` +
3985
- `merge ${decisionSummary["merge"] ?? 0}, drop ${decisionSummary["drop"] ?? 0}, action ${decisionSummary["action_desc"] ?? 0}, rename ${decisionSummary["rename"] ?? 0}, ` +
3986
- `default-id ${summary["default_state_ids_rewritten"] ?? 0}, synthesized ${summary["default_states_synthesized"] ?? 0}`,
3987
- ];
3988
- for (const decision of asList(curation["decisions"])) {
3989
- if (!isDict(decision))
3990
- continue;
3991
- const action = strOf(decision["decision"]);
3992
- if (!action || action === "keep")
3993
- continue;
3994
- const kind = strOf(decision["kind"]);
3995
- const assetId = strOf(decision["asset_id"]);
3996
- const stateId = strOf(decision["state_id"]);
3997
- const target = action === "merge"
3998
- ? ` -> ${decision["target_state_id"] || "-"}`
3999
- : action === "rename"
4000
- ? ` -> ${decision["new_name"] || "-"}`
4001
- : "";
4002
- lines.push(`state-curation ${kind}:${assetId}/${stateId} ${action}${target} — ${decision["reason"] || "-"}`);
4003
- }
4004
- return lines;
4005
- }
4006
- function renderAssetNormalizationSummary(normalization) {
4007
- const summary = isDict(normalization["summary"]) ? normalization["summary"] : {};
4008
- const lines = [
4009
- `normalization: ` +
4010
- `merges ${summary["asset_merges"] ?? 0}, ` +
4011
- `location-states ${(Number(summary["location_states_from_names"] ?? 0) + Number(summary["location_states_from_time"] ?? 0))}, ` +
4012
- `review ${summary["review_items"] ?? 0}`,
4013
- ];
4014
- for (const decision of asList(normalization["decisions"])) {
4015
- if (!isDict(decision))
4016
- continue;
4017
- const action = strOf(decision["action"]);
4018
- if (action !== "canonical_merge" && action !== "location_state_from_name" && action !== "location_state_from_time")
4019
- continue;
4020
- lines.push(`normalization ${action}: ${decision["from"] || "-"} -> ${decision["into"] || "-"}${decision["state"] ? ` state=${decision["state"]}` : ""} — ${decision["reason"] || "-"}`);
4021
- }
4022
- return lines;
4023
- }
4024
- // Collect batch extraction failures (batch_results/<key>.error.json) keyed by
4025
- // episode. Shared by both review views — these are direct-stage failures the
4026
- // final-script `issues` query can't see.
4027
- function collectBatchFailures(batchResultsDir) {
4028
- const byEpisode = new Map();
4029
- if (!exists(batchResultsDir))
4030
- return byEpisode;
4031
- const files = fs.readdirSync(batchResultsDir).filter((n) => n.endsWith(".error.json")).sort();
4032
- for (const name of files) {
4033
- try {
4034
- const error = readJson(path.join(batchResultsDir, name));
4035
- const episodeNum = Number(error["episode"] ?? 0);
4036
- if (!byEpisode.has(episodeNum))
4037
- byEpisode.set(episodeNum, []);
4038
- byEpisode.get(episodeNum).push(error);
4039
- }
4040
- catch {
4041
- // ignore unreadable error file
4042
- }
4043
- }
4044
- return byEpisode;
4045
- }
4046
- function parseEpisodeArg(episodeArg) {
4047
- try {
4048
- return episodeArg.split(",").map((s) => s.trim()).filter((s) => s).map((s) => {
4049
- const n = parseInt(s, 10);
4050
- if (Number.isNaN(n))
4051
- throw new Error("nan");
4052
- return n;
4053
- });
4054
- }
4055
- catch {
4056
- throw new CliError("REVIEW BLOCKED: --episode must be integers", "--episode must be integers.", {
4057
- exitCode: EXIT_USAGE,
4058
- required: ["--episode <n>[,<n>...]"],
4059
- received: [`--episode ${episodeArg}`],
4060
- nextSteps: ["Pass episode numbers like --episode 1,15,30."],
4061
- });
4062
- }
4063
- }
4064
- // ---------------------------------------------------------------------------
4065
- // command_review — the direct-stage proofreading view. Read-only; no run_state
4066
- // side effects. Two shapes:
4067
- // * `direct review` workspace diagnostics: curation decisions +
4068
- // issues (validation + batch extraction errors)
4069
- // * `direct review --episode N` per-episode source.txt ↔ script.initial.json
4070
- // side-by-side alignment (renderReviewEpisode)
4071
- // Generic per-episode / per-asset listings live on the top-level `episodes` /
4072
- // `assets` queries (use `--draft` to point them at script.initial.json).
4073
- // ---------------------------------------------------------------------------
4074
- export function commandReview(opts) {
4075
- const workspace = strOf(opts["workspace_path"] || "workspace");
4076
- const dd = directDir(workspace);
4077
- const scriptPath = path.join(dd, "script.initial.json");
4078
- const batchResultsDir = path.join(dd, "batch_results");
4079
- const episodeArg = strOf(opts["episode"]).trim();
4080
- // --episode: source ↔ extract alignment for the named episodes.
4081
- if (episodeArg) {
4082
- const episodeFilters = parseEpisodeArg(episodeArg);
4083
- const sourcePath = path.join(workspace, "source.txt");
4084
- if (!exists(sourcePath)) {
4085
- throw new CliError("REVIEW BLOCKED: source.txt not found", "source.txt not found.", {
4086
- exitCode: EXIT_INPUT,
4087
- required: [sourcePath],
4088
- received: ["source.txt missing"],
4089
- nextSteps: ["Run scriptctl direct init first."],
4090
- });
4091
- }
4092
- const script = exists(scriptPath)
4093
- ? readJson(scriptPath)
4094
- : { episodes: [], actors: [], locations: [], props: [] };
4095
- const sourceText = readText(sourcePath);
4096
- const episodePlanPath = path.join(dd, "episode_plan.json");
4097
- const episodePlan = exists(episodePlanPath) ? readJson(episodePlanPath) : { episodes: [] };
4098
- const lines = [];
4099
- for (let idx = 0; idx < episodeFilters.length; idx++) {
4100
- if (idx > 0) {
4101
- lines.push("");
4102
- lines.push("");
4103
- }
4104
- lines.push(...renderReviewEpisode(sourceText, episodePlan, script, episodeFilters[idx]));
4105
- }
4106
- const report = {
4107
- title: `DIRECT REVIEW: episode ${episodeFilters.join(",")}`,
4108
- result: lines.length > 0 ? lines : ["No matching items."],
4109
- artifacts: [path.join(workspace, "source.txt"), path.join(dd, "episode_plan.json"), scriptPath],
4110
- next: ["Edit with `--draft` (e.g. `scriptctl replace … --draft`) if the extraction is wrong, then validate/export."],
4111
- };
4112
- return [report, EXIT_OK];
4113
- }
4114
- // No --episode: workspace-level diagnostics. Curation decisions + every issue
4115
- // (validation issues recomputed live, plus batch extraction failures).
4116
- const lines = [];
4117
- const curationPath = path.join(dd, "asset_curation.json");
4118
- const stateCurationPath = path.join(dd, "state_curation.json");
4119
- const stateBindingPath = path.join(dd, "state_binding.json");
4120
- const normalizationPath = path.join(dd, "asset_normalization.json");
4121
- if (exists(normalizationPath)) {
4122
- const normalization = readJson(normalizationPath);
4123
- if (isDict(normalization))
4124
- lines.push(...renderAssetNormalizationSummary(normalization));
4125
- }
4126
- if (exists(curationPath)) {
4127
- const curation = readJson(curationPath);
4128
- if (isDict(curation))
4129
- lines.push(...renderAssetCurationSummary(curation));
4130
- }
4131
- if (exists(stateCurationPath)) {
4132
- const curation = readJson(stateCurationPath);
4133
- if (isDict(curation))
4134
- lines.push(...renderStateCurationSummary(curation));
4135
- }
4136
- if (exists(stateBindingPath)) {
4137
- const binding = readJson(stateBindingPath);
4138
- if (isDict(binding))
4139
- lines.push(...renderStateBindingSummary(binding));
4140
- }
4141
- const batchFailures = collectBatchFailures(batchResultsDir);
4142
- for (const errors of batchFailures.values()) {
4143
- for (const error of errors) {
4144
- lines.push(`error BATCH_FAILED: ${error["batch_id"]} episode ${error["episode"]} part ${error["part"]} - ${error["message"]}`);
4145
- }
4146
- }
4147
- // persist:false keeps review read-only — it must not overwrite validation.json
4148
- // (the help/comment promise "no state side effects").
4149
- const validation = exists(scriptPath)
4150
- ? validateScript(workspace, scriptPath, { requireSource: false, persist: false })
4151
- : { issues: [] };
4152
- for (const issue of asList(validation["issues"])) {
4153
- const whereParts = [];
4154
- for (const k of ["episode", "scene", "action_index"]) {
4155
- if (issue[k] !== null && issue[k] !== undefined)
4156
- whereParts.push(strOf(issue[k]));
4157
- }
4158
- const where = whereParts.join(" ");
4159
- lines.push(`${issue["severity"]} ${issue["code"]}: ${issue["summary"]}${where ? ` [${where}]` : ""}`);
4160
- }
4161
- const report = {
4162
- title: "DIRECT REVIEW",
4163
- result: lines.length > 0 ? lines : ["No issues, curation decisions, or batch failures to review."],
4164
- artifacts: [scriptPath, normalizationPath, path.join(dd, "asset_grouping.json"), path.join(dd, "asset_curation.json"), stateCurationPath, stateBindingPath, batchResultsDir],
4165
- next: [
4166
- "Run `direct review --episode <n>` for source↔extract alignment.",
4167
- "Edit with `--draft` (e.g. `scriptctl rename … --draft`), then validate/export.",
4168
- ],
4169
- };
4170
- return [report, EXIT_OK];
4171
- }
4172
- //# sourceMappingURL=direct.js.map