@cloud411716/fancy-webnovel 1.0.46 → 3.0.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 (271) hide show
  1. package/client.js +369 -0
  2. package/cordis.patch.yml +4 -13
  3. package/docs/douyin-plugin.md +118 -0
  4. package/docs/material-library/README.md +404 -0
  5. package/docs/material-library/import.js +437 -0
  6. package/docs/material-library/prompt.md +370 -0
  7. package/docs/material-library/queries.surql +94 -0
  8. package/docs/material-library/sample.jsonl +14 -0
  9. package/docs/material-library/schema.surql +46 -0
  10. package/docs/mcp-profile.example.yml +24 -0
  11. package/docs/story-graph-design.md +602 -0
  12. package/docs/story-graph-validation.md +81 -0
  13. package/docs/workspace-mcp.md +284 -0
  14. package/index.js +26 -38
  15. package/lib/atomic-write.js +109 -0
  16. package/lib/chapter-order.js +100 -0
  17. package/lib/chinese-num.js +111 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +141 -0
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +104 -0
  23. package/lib/infra.js +594 -0
  24. package/lib/llm-wiki.js +109 -0
  25. package/lib/material-db.js +149 -0
  26. package/lib/path-guard.js +150 -0
  27. package/lib/projects.js +400 -0
  28. package/lib/scraper-utils.js +305 -0
  29. package/lib/surreal-server.js +338 -0
  30. package/lib/topic-decision.js +860 -0
  31. package/lib/topic-fields.js +251 -0
  32. package/lib/topic-schema.js +225 -0
  33. package/lib/workspace.js +501 -0
  34. package/package.json +62 -5
  35. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  36. package/plugins/fancy-analyze/command-input.js +65 -0
  37. package/plugins/fancy-analyze/index.js +2024 -0
  38. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  39. package/plugins/fancy-analyze/schema-analyze.md +627 -0
  40. package/plugins/fancy-analyze/schema-write.md +178 -0
  41. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  42. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  43. package/plugins/fancy-analyze/summary-progress.js +495 -0
  44. package/plugins/fancy-analyze/templates.js +1179 -0
  45. package/plugins/fancy-bootstrap/index.js +313 -0
  46. package/plugins/fancy-bootstrap/scripts/init.js +177 -0
  47. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  48. package/plugins/fancy-bootstrap/templates.js +71 -0
  49. package/plugins/fancy-db/index.js +172 -0
  50. package/plugins/fancy-douyin/deploy.js +134 -0
  51. package/plugins/fancy-douyin/index.js +551 -0
  52. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  53. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  54. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  55. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  56. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  57. package/plugins/fancy-douyin/state.js +197 -0
  58. package/plugins/fancy-douyin/templates.js +127 -0
  59. package/plugins/fancy-export/exporter.js +805 -0
  60. package/plugins/fancy-export/index.js +63 -0
  61. package/plugins/fancy-import/import-core.js +1118 -0
  62. package/plugins/fancy-import/import-driver.js +604 -0
  63. package/plugins/fancy-import/index.js +738 -0
  64. package/plugins/fancy-import/prompts/01-import-prompt.md +131 -0
  65. package/plugins/fancy-import/templates.js +58 -0
  66. package/plugins/fancy-material/exporter.js +153 -0
  67. package/plugins/fancy-material/genres.js +256 -0
  68. package/plugins/fancy-material/index.js +835 -0
  69. package/plugins/fancy-material/inventory.js +143 -0
  70. package/plugins/fancy-material/kinds.js +313 -0
  71. package/plugins/fancy-material/queue.js +55 -0
  72. package/plugins/fancy-material/scripts/query.js +254 -0
  73. package/plugins/fancy-material/templates.js +258 -0
  74. package/plugins/fancy-material/tool-deploy.js +138 -0
  75. package/plugins/fancy-material/validate.js +149 -0
  76. package/plugins/fancy-project/index.js +133 -0
  77. package/plugins/fancy-scan/existing-block.js +100 -0
  78. package/plugins/fancy-scan/index.js +708 -0
  79. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  80. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  81. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  82. package/plugins/fancy-scan/scripts/run-scan.js +667 -0
  83. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +923 -0
  84. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +469 -0
  85. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +629 -0
  86. package/plugins/fancy-scan/templates/blocks.js +155 -0
  87. package/plugins/fancy-scan/templates/platforms.js +372 -0
  88. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  89. package/plugins/fancy-scan/templates.js +694 -0
  90. package/plugins/fancy-scan/tool-deploy.js +335 -0
  91. package/plugins/fancy-scan/wait-turn.js +46 -0
  92. package/plugins/fancy-story-builder/db/README.md +106 -0
  93. package/plugins/fancy-story-builder/db/client.js +228 -0
  94. package/plugins/fancy-story-builder/db/commands.js +145 -0
  95. package/plugins/fancy-story-builder/db/ids.js +39 -0
  96. package/plugins/fancy-story-builder/db/import.js +798 -0
  97. package/plugins/fancy-story-builder/db/project.js +299 -0
  98. package/plugins/fancy-story-builder/db/query.js +361 -0
  99. package/plugins/fancy-story-builder/db/schema.js +417 -0
  100. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  101. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  102. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  103. package/plugins/fancy-story-builder/flesh-output.js +168 -0
  104. package/plugins/fancy-story-builder/index.js +6861 -0
  105. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  106. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +563 -0
  107. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +602 -0
  108. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +539 -0
  109. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +225 -0
  110. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  111. package/plugins/fancy-story-builder/purpose-template.md +83 -0
  112. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  113. package/plugins/fancy-story-builder/references/formulas-skeleton.json +402 -0
  114. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  115. package/plugins/fancy-story-builder/references/glossary.md +594 -0
  116. package/plugins/fancy-story-builder/references/index.md +581 -0
  117. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  118. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  119. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  120. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  121. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  122. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  123. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  124. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  125. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  126. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  127. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  128. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  129. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  130. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  131. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  132. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  133. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  134. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  135. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +181 -0
  136. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  137. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +243 -0
  138. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  139. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +185 -0
  140. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  141. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +187 -0
  142. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  143. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +209 -0
  144. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  145. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  146. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  147. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  148. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +160 -0
  149. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  150. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  151. package/plugins/fancy-story-builder/schema-setting.md +232 -0
  152. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  153. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  154. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  155. package/plugins/fancy-story-builder/scripts/content-quality.js +416 -0
  156. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  157. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  158. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1312 -0
  159. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  160. package/plugins/fancy-story-builder/scripts/graph-utils.js +1484 -0
  161. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  162. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  163. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  164. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  165. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1312 -0
  166. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  167. package/plugins/fancy-story-builder/scripts/semantic-lint.js +381 -0
  168. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  169. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1973 -0
  170. package/plugins/fancy-story-builder/scripts/stage-batches.js +1405 -0
  171. package/plugins/fancy-story-builder/scripts/stage-contract.js +429 -0
  172. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  173. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  174. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  175. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  176. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  177. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  178. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  179. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  180. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  181. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  182. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  183. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  184. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  185. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  186. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  187. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  188. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  189. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  190. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  191. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  192. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  193. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  194. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  195. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  196. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  197. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  198. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  199. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  200. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  201. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  202. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  203. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  204. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  205. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  206. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  207. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  208. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  209. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  210. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  211. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  212. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  213. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  214. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  215. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  216. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  217. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  218. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  219. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  220. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  221. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  222. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  223. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  224. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  225. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  226. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  227. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  228. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  229. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  230. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  231. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  232. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  233. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  234. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  235. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  236. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  237. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +427 -0
  238. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
  239. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
  240. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
  241. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +168 -0
  242. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +184 -0
  243. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
  244. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +131 -0
  245. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +85 -0
  246. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +103 -0
  247. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +68 -0
  248. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +142 -0
  249. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +292 -0
  250. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +266 -0
  251. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +336 -0
  252. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
  253. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +308 -0
  254. package/plugins/fancy-story-builder/templates.js +1446 -0
  255. package/plugins/fancy-story-builder/wiki-references.js +540 -0
  256. package/plugins/fancy-story-builder/writing-driver.js +1388 -0
  257. package/plugins/fancy-topic/index.js +1057 -0
  258. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  259. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  260. package/plugins/fancy-topic/scripts/select-topic.js +317 -0
  261. package/plugins/fancy-topic/templates.js +476 -0
  262. package/plugins/fancy-topic/tool-deploy.js +200 -0
  263. package/commands/bootstrap/index.js +0 -164
  264. package/commands/bootstrap/init-project.js +0 -121
  265. package/commands/scan/index.js +0 -283
  266. package/commands/scan/platforms/fanqie.js +0 -146
  267. package/commands/scan/platforms/jinjiang.js +0 -143
  268. package/commands/scan/platforms/qidian.js +0 -102
  269. package/commands/scan/platforms/qimao.js +0 -127
  270. package/commands/scan/scraper.js +0 -195
  271. package/infra.js +0 -160
@@ -0,0 +1,1068 @@
1
+ /**
2
+ * v2/engine.js —— 2.0 插件驱动:插件当司机,LLM 只按指令写文件。
3
+ *
4
+ * 这里不直接依赖 DSH:sendPrompt / waitIdle 由命令处理器注入。
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import {
9
+ V2_STAGE_BY_ID,
10
+ asArray,
11
+ ensureDir,
12
+ nowIso,
13
+ readJson,
14
+ readText,
15
+ stageDir,
16
+ stageFile,
17
+ writeJsonAtomic,
18
+ writeTextAtomic,
19
+ } from './core.js';
20
+ import { buildChapterMapPrompt, buildOutlinePrompt, buildWritingPrompt } from './prompts.js';
21
+
22
+ export const FILE_MAX_ATTEMPTS = 3;
23
+ // 单次提示词超过这个长度时,先停下并报告,避免把模型/宿主拖进上下文压缩死循环。
24
+ export const MAX_PROMPT_CHARS = 180000;
25
+
26
+ function isValidJsonFile(file) {
27
+ try {
28
+ JSON.parse(fs.readFileSync(file, 'utf-8'));
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ function pad3(n) {
36
+ return String(n).padStart(3, '0');
37
+ }
38
+
39
+ function pickFields(obj, fields) {
40
+ if (!obj || typeof obj !== 'object') return obj;
41
+ const out = {};
42
+ for (const field of fields) {
43
+ if (obj[field] !== undefined) out[field] = obj[field];
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function compactRows(rows, fields, truncate = {}) {
49
+ return asArray(rows).map((row) => {
50
+ const out = pickFields(row, fields);
51
+ for (const [field, max] of Object.entries(truncate)) {
52
+ if (typeof out[field] === 'string') out[field] = out[field].slice(0, max);
53
+ }
54
+ return out;
55
+ });
56
+ }
57
+
58
+ /**
59
+ * 阶段摘要只给“跨文件引用需要的最小信息”,不把前几个文件全文再塞一遍。
60
+ * 30-cast 的 characters/foreshadows/payoffs 等文件很容易到几十 KB;
61
+ * 如果把全文级联进最后一个 information.json 提示词,单次请求会超过模型上下文。
62
+ */
63
+ const CAST_DIGEST_FILES = {
64
+ 'characters.json': [],
65
+ 'factions.json': [],
66
+ 'locations.json': ['factions.json'],
67
+ 'items.json': ['characters.json', 'factions.json', 'locations.json'],
68
+ 'conflicts.json': ['characters.json', 'factions.json', 'locations.json', 'items.json'],
69
+ 'foreshadows.json': ['characters.json', 'conflicts.json'],
70
+ 'climaxes.json': ['characters.json', 'factions.json', 'conflicts.json', 'foreshadows.json'],
71
+ 'payoffs.json': ['foreshadows.json', 'climaxes.json', 'conflicts.json'],
72
+ 'relations.json': ['characters.json', 'factions.json'],
73
+ 'information.json': ['characters.json', 'conflicts.json'],
74
+ };
75
+
76
+ function compactCastDigest(name, data) {
77
+ if (name === 'characters.json') {
78
+ return { characters: compactRows(data.characters, ['id', 'name', 'type', 'faction', 'role_slot']) };
79
+ }
80
+ if (name === 'factions.json') return { factions: compactRows(data.factions, ['id', 'name', 'type']) };
81
+ if (name === 'locations.json') return { locations: compactRows(data.locations, ['id', 'name', 'type']) };
82
+ if (name === 'items.json') return { items: compactRows(data.items, ['id', 'name', 'category', 'owner']) };
83
+ if (name === 'conflicts.json') {
84
+ return { conflicts: compactRows(data.conflicts, ['id', 'name', 'type', 'participants']) };
85
+ }
86
+ if (name === 'foreshadows.json') {
87
+ return { foreshadows: compactRows(data.foreshadows, ['id', 'name', 'type', 'scope']) };
88
+ }
89
+ if (name === 'climaxes.json') {
90
+ return { climaxes: compactRows(data.climaxes, ['id', 'name', 'type', 'participants']) };
91
+ }
92
+ if (name === 'payoffs.json') {
93
+ return { payoffs: compactRows(data.payoffs, ['id', 'name', 'type', 'scale', 'required_setup']) };
94
+ }
95
+ if (name === 'relations.json') {
96
+ return {
97
+ character_relations: compactRows(data.character_relations, ['id', 'from', 'to', 'kind']),
98
+ faction_relations: compactRows(data.faction_relations, ['id', 'from', 'to', 'kind']),
99
+ memberships: compactRows(data.memberships, ['id', 'character', 'faction', 'position', 'kind']),
100
+ };
101
+ }
102
+ if (name === 'information.json') {
103
+ return {
104
+ information_gaps: compactRows(data.information_gaps, ['id', 'conflict', 'info_name', 'who_knows', 'who_does_not_know']),
105
+ };
106
+ }
107
+ return data;
108
+ }
109
+
110
+ export function stageDigest(projectRoot, stageId, exclude = [], targetFile = '') {
111
+ const dir = stageDir(projectRoot, stageId);
112
+ let names = [];
113
+ try {
114
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.json') && n !== 'bundle.json' && !exclude.includes(n)).sort();
115
+ } catch {
116
+ names = [];
117
+ }
118
+ const out = [];
119
+ const allowed = stageId === '30-cast' && targetFile && CAST_DIGEST_FILES[targetFile]
120
+ ? new Set(CAST_DIGEST_FILES[targetFile])
121
+ : null;
122
+ for (const name of names) {
123
+ if (allowed && !allowed.has(name)) continue;
124
+ const data = readJson(path.join(dir, name), null);
125
+ if (!data) continue;
126
+ out.push('### ' + name);
127
+ out.push('```json');
128
+ const digest = stageId === '30-cast' ? compactCastDigest(name, data) : data;
129
+ out.push(JSON.stringify(digest));
130
+ out.push('```');
131
+ }
132
+ return out.join('\n');
133
+ }
134
+
135
+ export function snapshotFiles(files) {
136
+ const out = {};
137
+ for (const file of files || []) {
138
+ out[file] = readText(file, null);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ export function filesChangedSince(files, before) {
144
+ if (!before || typeof before !== 'object') return true;
145
+ return (files || []).some((file) => readText(file, null) !== before[file]);
146
+ }
147
+
148
+ function parseJsonFile(file) {
149
+ const text = readText(file, null);
150
+ if (text === null) return { ok: false, error: '文件没有落盘' };
151
+ try {
152
+ return { ok: true, data: JSON.parse(text) };
153
+ } catch (e) {
154
+ return { ok: false, error: 'JSON 解析失败:' + e.message };
155
+ }
156
+ }
157
+
158
+ function failureList(errors) {
159
+ return asArray(errors).map((e) => ({
160
+ code: e.code || 'VALIDATION',
161
+ path: e.path || '',
162
+ message: e.message || e.detail || '',
163
+ expected: e.expected,
164
+ actual: e.actual,
165
+ hint: e.hint,
166
+ }));
167
+ }
168
+
169
+ /**
170
+ * 生成一个 JSON 文件(多次尝试)。
171
+ */
172
+ export async function generateJsonFile(opts) {
173
+ const o = opts || {};
174
+ const target = o.target;
175
+ const relative = o.relative || path.basename(target);
176
+ let failures = asArray(o.failures);
177
+ let lastError = null;
178
+ for (let attempt = 1; attempt <= (o.maxAttempts || FILE_MAX_ATTEMPTS); attempt += 1) {
179
+ const before = snapshotFiles([target]);
180
+ const prompt = o.buildPrompt({ attempt, failures });
181
+ if (prompt.length > MAX_PROMPT_CHARS) {
182
+ const error = '提示词过长(' + prompt.length + ' 字符,上限 ' + MAX_PROMPT_CHARS + '),已停止投递,避免触发上下文压缩死循环';
183
+ if (o.onProgress) o.onProgress(relative + ':' + error);
184
+ return {
185
+ ok: false,
186
+ file: relative,
187
+ errors: [{ code: 'PROMPT_TOO_LARGE', path: relative, message: error, actual: prompt.length, expected: MAX_PROMPT_CHARS }],
188
+ error,
189
+ attempts: attempt,
190
+ };
191
+ }
192
+ const sent = await o.sendPrompt(prompt);
193
+ if (!sent || sent.ok !== true) {
194
+ failures = [{ code: 'PROMPT_DELIVERY_FAILED', message: (sent && sent.error) || '提示词投递失败' }];
195
+ lastError = failures[0].message;
196
+ if (o.onProgress) o.onProgress(relative + ':提示词投递失败,准备重试');
197
+ continue;
198
+ }
199
+ const waited = await o.waitIdle([target], before);
200
+ const noChange = waited && waited.code === 'NO_CHANGE';
201
+ if (!waited || (waited.ok !== true && !noChange)) {
202
+ lastError = (waited && waited.reason) || '等待 LLM 完成失败';
203
+ failures = [{ code: (waited && waited.code) || 'NO_OUTPUT', path: relative, message: lastError }];
204
+ if (waited && ['TIMEOUT', 'ABORTED', 'NO_AGENT'].includes(waited.code)) {
205
+ return { ok: false, file: relative, errors: failures, error: lastError, attempts: attempt };
206
+ }
207
+ if (o.onProgress) o.onProgress(relative + ':' + lastError + ',准备重试');
208
+ continue;
209
+ }
210
+ const parsed = parseJsonFile(target);
211
+ if (!parsed.ok) {
212
+ lastError = parsed.error;
213
+ failures = [{ code: 'INVALID_JSON', path: relative, message: parsed.error }];
214
+ if (o.onProgress) o.onProgress(relative + ':' + parsed.error + ',准备重试');
215
+ continue;
216
+ }
217
+ if (typeof o.validateFile === 'function') {
218
+ const report = await o.validateFile(parsed.data, target);
219
+ if (report && report.ok === true) return { ok: true, file: relative, data: parsed.data, attempts: attempt };
220
+ if (report && report.ok === false) {
221
+ // 明确返回“不通过”但没有 errors 时不能当成通过,否则会静默放过坏文件。
222
+ const reportErrors = Array.isArray(report.errors) && report.errors.length
223
+ ? report.errors
224
+ : [{ code: 'VALIDATION_FAILED', path: relative, message: report.error || '校验未通过但没有返回错误明细' }];
225
+ failures = failureList(reportErrors);
226
+ lastError = failures[0].message;
227
+ if (o.onProgress) o.onProgress(relative + ':校验未通过(' + failures.length + ' 个错误),准备重试');
228
+ continue;
229
+ }
230
+ // 没有 ok 也没有 errors 的旧校验器返回值,保持兼容,视为通过。
231
+ return { ok: true, file: relative, data: parsed.data, attempts: attempt };
232
+ }
233
+ return { ok: true, file: relative, data: parsed.data, attempts: attempt };
234
+ }
235
+ return { ok: false, file: relative, errors: failures, error: lastError || '生成失败', attempts: o.maxAttempts || FILE_MAX_ATTEMPTS };
236
+ }
237
+
238
+ /**
239
+ * 按阶段文件清单生成缺失文件,再跑整阶段校验;失败时把错误归因到文件并重试。
240
+ */
241
+ export async function generateStageFiles(opts) {
242
+ const o = opts || {};
243
+ const { projectRoot, stageId } = o;
244
+ const files = asArray(o.files);
245
+ const reports = [];
246
+ let failures = [];
247
+ for (let round = 1; round <= (o.maxRounds || 2); round += 1) {
248
+ for (const spec of files) {
249
+ const target = stageFile(projectRoot, stageId, spec.file);
250
+ if (fs.existsSync(target) && !o.force && isValidJsonFile(target)) continue;
251
+ const extraParts = [];
252
+ if (o.includeStageDigest) {
253
+ const digest = stageDigest(projectRoot, stageId, [path.basename(spec.file)], path.basename(spec.file));
254
+ if (digest) {
255
+ extraParts.push('## 本阶段已生成的文件(必须与它们保持一致)');
256
+ extraParts.push('');
257
+ extraParts.push(digest);
258
+ }
259
+ }
260
+ if (spec.extra) extraParts.push(spec.extra);
261
+ const result = await generateJsonFile({
262
+ target,
263
+ relative: spec.file,
264
+ failures,
265
+ sendPrompt: o.sendPrompt,
266
+ waitIdle: o.waitIdle,
267
+ onProgress: o.onProgress,
268
+ maxAttempts: o.maxAttempts,
269
+ buildPrompt: ({ attempt, failures: f }) => o.buildPrompt({
270
+ stageId,
271
+ file: spec.file,
272
+ templateFile: spec.templateFile,
273
+ attempt,
274
+ failures: f,
275
+ extra: extraParts.join('\n'),
276
+ }),
277
+ });
278
+ reports.push(result);
279
+ if (!result.ok) {
280
+ // 单文件都写不出来,后面的文件没有继续生成的意义
281
+ return { ok: false, files: reports, errors: asArray(result.errors), error: result.error };
282
+ }
283
+ }
284
+ if (typeof o.validateStage !== 'function') return { ok: true, files: reports, errors: [] };
285
+ const report = await o.validateStage();
286
+ if (report && report.ok) return { ok: true, files: reports, errors: [], report };
287
+ failures = failureList(report && report.errors);
288
+ if (!failures.length) return { ok: false, files: reports, errors: [], error: '阶段校验失败但没有返回错误' };
289
+ if (o.onProgress) o.onProgress(stageId + ':整阶段校验未通过(' + failures.length + ' 个错误),准备按文件回改');
290
+ // 下一轮强制重生成被错误点名的文件
291
+ o.force = false;
292
+ const named = new Set();
293
+ for (const e of failures) {
294
+ const p = String(e.path || '');
295
+ for (const spec of files) {
296
+ if (p.startsWith(spec.file.replace(/\.json$/, '')) || p.includes(spec.file)) named.add(spec.file);
297
+ }
298
+ }
299
+ if (!named.size) return { ok: false, files: reports, errors: failures, error: '无法把校验错误归因到具体文件' };
300
+ for (const spec of files) {
301
+ if (named.has(spec.file)) {
302
+ const target = stageFile(projectRoot, stageId, spec.file);
303
+ try {
304
+ if (fs.existsSync(target)) fs.unlinkSync(target);
305
+ } catch {
306
+ // 删不掉就覆盖
307
+ }
308
+ }
309
+ }
310
+ }
311
+ const final = typeof o.validateStage === 'function' ? await o.validateStage() : { ok: true, errors: [] };
312
+ return { ok: Boolean(final && final.ok), files: reports, errors: failureList(final && final.errors), error: final && final.ok ? null : '阶段校验未通过', report: final };
313
+ }
314
+
315
+ function normalizeChapterShard(data, volume, startCh, endCh, scale) {
316
+ const raw = Array.isArray(data) ? data : asArray(data && data.chapters);
317
+ const acts = asArray(scale && scale.structure && scale.structure.acts);
318
+ const avg = Number(scale && scale.capacity && scale.capacity.avg_words_per_chapter) || 3000;
319
+ const chapters = [];
320
+ for (let i = 0; i < raw.length; i += 1) {
321
+ const row = raw[i] || {};
322
+ const displayNo = startCh + i;
323
+ if (displayNo > endCh) break;
324
+ const act = acts.find((a) => Number(a.chapter_start) <= displayNo && Number(a.chapter_end) >= displayNo);
325
+ chapters.push({
326
+ ...row,
327
+ chapter_id: row.chapter_id || ('CH' + pad3(displayNo)),
328
+ display_no: displayNo,
329
+ title: row.title || ('第 ' + displayNo + ' 章'),
330
+ volume: volume.volume_id,
331
+ act: act ? act.act_id : (row.act || ''),
332
+ word_target: Number(row.word_target) > 0 ? Number(row.word_target) : avg,
333
+ });
334
+ }
335
+ return { chapters };
336
+ }
337
+
338
+ /**
339
+ * 生成章节地图:按卷分片 → 合并 → 校验 → 按卷回改。
340
+ */
341
+ export const CHAPTER_MAP_CHUNK_SIZE = 20;
342
+
343
+ function chunkRanges(start, end, size = CHAPTER_MAP_CHUNK_SIZE) {
344
+ const out = [];
345
+ let s = Number(start);
346
+ const e = Number(end);
347
+ let i = 0;
348
+ while (s <= e) {
349
+ const chunkEnd = Math.min(e, s + size - 1);
350
+ i += 1;
351
+ out.push({ index: i, start: s, end: chunkEnd });
352
+ s = chunkEnd + 1;
353
+ }
354
+ return out;
355
+ }
356
+
357
+ function readVolumeChapters(shardDir, volumeId) {
358
+ // chunk 文件才是真源;V001.json 只是合并缓存。
359
+ // 先读 chunk,避免返工某个 chunk 后仍读到旧的整卷缓存。
360
+ let names = [];
361
+ try {
362
+ names = fs.readdirSync(shardDir)
363
+ .filter((n) => /^V\d{3}-\d{2}\.json$/.test(n) && n.startsWith(volumeId + '-'))
364
+ .sort();
365
+ } catch {
366
+ names = [];
367
+ }
368
+ const out = [];
369
+ for (const name of names) {
370
+ const data = readJson(path.join(shardDir, name), null);
371
+ if (data && Array.isArray(data.chapters)) out.push(...data.chapters);
372
+ }
373
+ if (out.length) return out;
374
+ const full = readJson(path.join(shardDir, volumeId + '.json'), null);
375
+ return full && Array.isArray(full.chapters) ? full.chapters : [];
376
+ }
377
+
378
+ function chapterMapProgress(shardDir, volumes, size = CHAPTER_MAP_CHUNK_SIZE) {
379
+ const completed = [];
380
+ const pending = [];
381
+ for (const v of volumes) {
382
+ const ranges = chunkRanges(v.chapter_start, v.chapter_end, size);
383
+ const missing = ranges.filter((r) => {
384
+ const name = v.volume_id + '-' + String(r.index).padStart(2, '0') + '.json';
385
+ return !isValidJsonFile(path.join(shardDir, name));
386
+ });
387
+ if (missing.length) pending.push(v.volume_id);
388
+ else completed.push(v.volume_id);
389
+ }
390
+ let chunkFiles = [];
391
+ try { chunkFiles = fs.readdirSync(shardDir).filter((n) => /^V\d{3}-\d{2}\.json$/.test(n)).sort(); } catch { chunkFiles = []; }
392
+ return { completed, pending, chunks: chunkFiles.length, chunkFiles };
393
+ }
394
+
395
+ /**
396
+ * word_target 是数字目标,不是剧情内容;合并后按目标总字数做一次确定性
397
+ * 配平,避免 LLM 每章多写 3% 就把整张地图判为失败并触发无意义重生成。
398
+ */
399
+ function balanceWordTargets(chapters, targetWords) {
400
+ const target = Number(targetWords);
401
+ if (!Number.isFinite(target) || target <= 0) return;
402
+ const rows = asArray(chapters).filter((c) => Number.isInteger(Number(c.word_target)) && Number(c.word_target) > 0);
403
+ if (!rows.length || rows.length !== asArray(chapters).length) return;
404
+ const sum = rows.reduce((total, c) => total + Number(c.word_target), 0);
405
+ if (sum === target) return;
406
+
407
+ // 按目标总字数等比缩放,但取整到 50 字,避免配平后出现 2122、2323
408
+ // 这类明显不是作者设定的数字;最后用 50 字为单位把误差摊平。
409
+ const unit = 50;
410
+ const ratio = target / sum;
411
+ for (const c of rows) {
412
+ c.word_target = Math.max(unit, Math.round((Number(c.word_target) * ratio) / unit) * unit);
413
+ }
414
+ let diff = target - rows.reduce((total, c) => total + Number(c.word_target), 0);
415
+ let i = 0;
416
+ let guard = 0;
417
+ while (diff !== 0 && guard < rows.length * 8) {
418
+ const c = rows[i % rows.length];
419
+ const step = diff > 0 ? unit : -unit;
420
+ if (diff < 0 && c.word_target <= unit) {
421
+ i += 1;
422
+ guard += 1;
423
+ continue;
424
+ }
425
+ c.word_target += step;
426
+ diff -= step;
427
+ i += 1;
428
+ guard += 1;
429
+ }
430
+ if (diff !== 0) {
431
+ const last = rows[rows.length - 1];
432
+ last.word_target = Math.max(1, Number(last.word_target) + diff);
433
+ }
434
+ }
435
+
436
+ function chunkRangeForName(volumes, chunkName, size) {
437
+ const m = /^(V\d{3})-(\d{2})$/.exec(String(chunkName || ''));
438
+ if (!m) return null;
439
+ const volume = volumes.find((v) => v.volume_id === m[1]);
440
+ if (!volume) return null;
441
+ const index = Number(m[2]);
442
+ const start = Number(volume.chapter_start) + (index - 1) * size;
443
+ if (!index || start > Number(volume.chapter_end)) return null;
444
+ return {
445
+ volumeId: volume.volume_id,
446
+ index,
447
+ start,
448
+ end: Math.min(Number(volume.chapter_end), start + size - 1),
449
+ };
450
+ }
451
+
452
+ /**
453
+ * 从旧的 chapter_map.json 抢救分片。
454
+ *
455
+ * 旧版本先写最终地图再校验,校验失败后又删掉全部分片;此时最终地图里
456
+ * 其实保留了上一轮的完整内容。非 --force 重跑时,把缺失的 chunk 用它
457
+ * 补回来,避免用户为了一次校验失败重写整本书。
458
+ */
459
+ function seedChunksFromChapterMap(dir, shardDir, volumes, size) {
460
+ const map = readJson(path.join(dir, 'chapter_map.json'), null);
461
+ if (!map || !Array.isArray(map.chapters) || !map.chapters.length) return;
462
+ const byDisplay = new Map();
463
+ for (const c of map.chapters) {
464
+ const n = Number(c && c.display_no);
465
+ if (Number.isFinite(n)) byDisplay.set(n, c);
466
+ }
467
+ for (const volume of volumes) {
468
+ for (const range of chunkRanges(volume.chapter_start, volume.chapter_end, size)) {
469
+ const name = volume.volume_id + '-' + String(range.index).padStart(2, '0');
470
+ const target = path.join(shardDir, name + '.json');
471
+ if (isValidJsonFile(target)) continue;
472
+ const rows = [];
473
+ for (let n = range.start; n <= range.end; n += 1) {
474
+ const row = byDisplay.get(n);
475
+ if (!row) break;
476
+ rows.push(row);
477
+ }
478
+ if (rows.length === range.end - range.start + 1) {
479
+ writeJsonAtomic(target, { chapters: rows });
480
+ }
481
+ }
482
+ }
483
+ }
484
+
485
+ function chunkForDisplay(volumes, displayNo, size) {
486
+ const n = Number(displayNo);
487
+ if (!Number.isFinite(n)) return '';
488
+ for (const v of volumes) {
489
+ if (n >= Number(v.chapter_start) && n <= Number(v.chapter_end)) {
490
+ const index = Math.floor((n - Number(v.chapter_start)) / size) + 1;
491
+ return v.volume_id + '-' + String(index).padStart(2, '0');
492
+ }
493
+ }
494
+ return '';
495
+ }
496
+
497
+ function cnNumber(text) {
498
+ const s = String(text || '').trim();
499
+ if (/^\d+$/.test(s)) return Number(s);
500
+ const digit = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
501
+ if (s === '十') return 10;
502
+ let m = /^十([一二三四五六七八九])$/.exec(s);
503
+ if (m) return 10 + digit[m[1]];
504
+ m = /^([一二三四五六七八九])十([一二三四五六七八九])?$/.exec(s);
505
+ if (m) return digit[m[1]] * 10 + (m[2] ? digit[m[2]] : 0);
506
+ return digit[s] || 0;
507
+ }
508
+
509
+ function volumeFromText(text, volumes) {
510
+ const s = String(text || '');
511
+ let m = /V(\d{3})/i.exec(s);
512
+ if (m) {
513
+ const id = 'V' + m[1];
514
+ const hit = volumes.find((v) => v.volume_id === id);
515
+ if (hit) return hit;
516
+ }
517
+ m = /第\s*([一二三四五六七八九十两\d]+)\s*卷/.exec(s);
518
+ if (m) {
519
+ const n = cnNumber(m[1]);
520
+ const ordered = [...volumes].sort((a, b) => Number(a.chapter_start) - Number(b.chapter_start));
521
+ if (n >= 1 && n <= ordered.length) return ordered[n - 1];
522
+ }
523
+ return null;
524
+ }
525
+
526
+ function referenceDisplays(chapters, kind, id) {
527
+ const out = [];
528
+ for (const c of asArray(chapters)) {
529
+ const display = Number(c.display_no);
530
+ if (!Number.isFinite(display)) continue;
531
+ let hit = false;
532
+ if (kind === 'foreshadow') hit = asArray(c.foreshadow_actions).some((a) => a && a.id === id);
533
+ else if (kind === 'payoff') hit = asArray(c.payoff_actions).some((a) => a && a.id === id);
534
+ else if (kind === 'info') hit = asArray(c.information_gaps).some((a) => a && a.id === id);
535
+ else if (kind === 'main') hit = asArray(c.why && c.why.main_line_nodes).some((x) => x === id);
536
+ else if (kind === 'branch') hit = asArray(c.why && c.why.branch_nodes).some((x) => String(x).startsWith(id));
537
+ else if (kind === 'conflict') hit = asArray(c.why && c.why.conflict).some((x) => x === id);
538
+ else if (kind === 'ending') hit = asArray(c.ending_actions).some((a) => a && a.id === id);
539
+ if (hit) out.push(display);
540
+ }
541
+ return out;
542
+ }
543
+
544
+ /**
545
+ * 给“没有章节落点”的实体猜一个修复落点。
546
+ *
547
+ * 只用于生成返工目标,不直接改地图;优先用实体自带的卷/幕/回收要求,
548
+ * 其次用它在章节地图里已有的最早/最晚落点。
549
+ */
550
+ function entityVolumeHint(kind, id, ctx) {
551
+ const { chapters, volumes, cast, macro } = ctx;
552
+ const refs = referenceDisplays(chapters, kind, id);
553
+ const first = refs.length ? refs[0] : null;
554
+ const last = refs.length ? refs[refs.length - 1] : null;
555
+ if (kind === 'main') {
556
+ const node = asArray(macro && macro.lines && macro.lines.main_line && macro.lines.main_line.nodes)
557
+ .find((n) => n && n.id === id);
558
+ const volume = node && node.volume ? volumes.find((v) => v.volume_id === node.volume) : null;
559
+ if (volume) return { displayNo: Number(volume.chapter_start), reason: '主线节点所在卷的开头' };
560
+ }
561
+ if (kind === 'branch') {
562
+ const branch = asArray(macro && macro.lines && macro.lines.branches).find((x) => x && x.id === id);
563
+ const volume = branch && branch.start ? volumeFromText(branch.start, volumes) : null;
564
+ if (volume) return { displayNo: Number(volume.chapter_start), reason: '支线开始卷的开头' };
565
+ }
566
+ if (kind === 'foreshadow') {
567
+ const row = asArray(cast && cast.foreshadows && cast.foreshadows.foreshadows).find((x) => x && x.id === id);
568
+ const text = [row && row.setup, row && row.recovery_requirement, row && row.payoff_target].filter(Boolean).join(' ');
569
+ const volume = volumeFromText(text, volumes);
570
+ if (volume) {
571
+ const late = /后段|末|结束|结尾/.test(text);
572
+ return {
573
+ displayNo: late ? Number(volume.chapter_end) : Number(volume.chapter_start),
574
+ reason: late ? '伏笔回收要求指向该卷后段' : '伏笔回收要求指向该卷开头',
575
+ };
576
+ }
577
+ }
578
+ if (kind === 'payoff') {
579
+ const row = asArray(cast && cast.payoffs && cast.payoffs.payoffs).find((x) => x && x.id === id);
580
+ for (const setupId of asArray(row && row.required_setup)) {
581
+ if (/^FS\d+/.test(String(setupId))) {
582
+ const fsRow = asArray(cast && cast.foreshadows && cast.foreshadows.foreshadows)
583
+ .find((x) => x && x.id === setupId);
584
+ const text = [fsRow && fsRow.setup, fsRow && fsRow.recovery_requirement, fsRow && fsRow.payoff_target].filter(Boolean).join(' ');
585
+ const volume = volumeFromText(text, volumes);
586
+ if (volume) return { displayNo: Number(volume.chapter_end), reason: '爽点依赖的伏笔落在该卷,优先放到卷末' };
587
+ const setupRefs = referenceDisplays(chapters, 'foreshadow', setupId);
588
+ if (setupRefs.length) return { displayNo: setupRefs[setupRefs.length - 1], reason: '爽点依赖的伏笔最后一次出现处' };
589
+ }
590
+ if (/^CL\d+/.test(String(setupId))) {
591
+ const cl = asArray(cast && cast.climaxes && cast.climaxes.climaxes).find((x) => x && x.id === setupId);
592
+ const volume = cl && cl.volume ? volumes.find((v) => v.volume_id === cl.volume) : null;
593
+ if (volume) return { displayNo: Number(volume.chapter_end), reason: '爽点依赖的高潮所在卷的卷末' };
594
+ }
595
+ }
596
+ const ownVolume = volumeFromText([row && row.setup, row && row.payoff].filter(Boolean).join(' '), volumes);
597
+ if (ownVolume) return { displayNo: Number(ownVolume.chapter_end), reason: '爽点文本指向该卷' };
598
+ }
599
+ if (kind === 'info') {
600
+ const row = asArray(cast && cast.information && cast.information.information_gaps).find((x) => x && x.id === id);
601
+ const conflictRefs = row && row.conflict ? referenceDisplays(chapters, 'conflict', row.conflict) : [];
602
+ if (conflictRefs.length) return { displayNo: conflictRefs[0], reason: '信息差依附的冲突首次出现处' };
603
+ const conflict = asArray(cast && cast.conflicts && cast.conflicts.conflicts).find((x) => x && x.id === (row && row.conflict));
604
+ const volume = volumeFromText([conflict && conflict.description, conflict && conflict.stakes].filter(Boolean).join(' '), volumes);
605
+ if (volume) return { displayNo: Number(volume.chapter_start), reason: '信息差依附的冲突所在卷' };
606
+ }
607
+ if (kind === 'conflict') {
608
+ const row = asArray(cast && cast.conflicts && cast.conflicts.conflicts).find((x) => x && x.id === id);
609
+ const volume = volumeFromText([row && row.description, row && row.stakes].filter(Boolean).join(' '), volumes);
610
+ if (volume) return { displayNo: Number(volume.chapter_start), reason: '冲突文本指向该卷' };
611
+ }
612
+ if (first !== null) return { displayNo: first, reason: '实体在章节地图里已有落点' };
613
+ if (last !== null) return { displayNo: last, reason: '实体在章节地图里已有落点' };
614
+ return null;
615
+ }
616
+
617
+ function parseMissingIds(message, actual) {
618
+ if (Array.isArray(actual) && actual.length) return actual.map(String);
619
+ const s = String(message || '');
620
+ const idx = s.lastIndexOf(':');
621
+ if (idx < 0) return [];
622
+ return s.slice(idx + 1)
623
+ .split('、')
624
+ .map((x) => x.replace(/….*$/, '').trim())
625
+ .filter((x) => /^[A-Z]{1,4}\d{2,}/.test(x));
626
+ }
627
+
628
+ /**
629
+ * 把整张地图的校验错误映射到“该返工哪个 chunk”。
630
+ * 无法定位到任何 chunk 的错误保留在 failures 里,交给调用方报给用户;
631
+ * 绝不能因为一条全局错误就删掉所有分片。
632
+ */
633
+ function planChapterMapRepairs(failures, ctx) {
634
+ const plan = new Map();
635
+ const add = (chunk, failure) => {
636
+ if (!chunk) return;
637
+ if (!plan.has(chunk)) plan.set(chunk, []);
638
+ plan.get(chunk).push(failure);
639
+ };
640
+ for (const e of asArray(failures)) {
641
+ const p = String(e.path || '');
642
+ const m = /^chapter_map\.chapters\[(\d+)\]/.exec(p);
643
+ if (m) {
644
+ const idx = Number(m[1]);
645
+ const chapter = ctx.chapters[idx];
646
+ const display = Number(chapter && chapter.display_no !== undefined ? chapter.display_no : idx + 1);
647
+ const chunk = chunkForDisplay(ctx.volumes, display, ctx.size);
648
+ const range = chunkRangeForName(ctx.volumes, chunk, ctx.size);
649
+ const local = range ? display - range.start : idx;
650
+ add(chunk, {
651
+ ...e,
652
+ path: 'chapters[' + local + ']' + p.slice(m[0].length),
653
+ message: '第 ' + display + ' 章:' + (e.message || ''),
654
+ });
655
+ continue;
656
+ }
657
+ if (e.code === 'RHYTHM_CHAPTER_GAP') {
658
+ const gap = /缺少第\s*(\d+)\s*章/.exec(e.message || '');
659
+ if (gap) add(chunkForDisplay(ctx.volumes, Number(gap[1]), ctx.size), e);
660
+ continue;
661
+ }
662
+ if (e.code === 'RHYTHM_FORESHADOW_ORDER') {
663
+ const id = (/:\s*(FS\d{3})/.exec(e.message || '') || [])[1];
664
+ const hint = id ? entityVolumeHint('foreshadow', id, ctx) : null;
665
+ const target = hint ? hint.displayNo : null;
666
+ add(chunkForDisplay(ctx.volumes, target, ctx.size), {
667
+ ...e,
668
+ message: (e.message || '') + (hint
669
+ ? ';本 chunk 必须在回收之前补一次 `埋设`(推荐落点:第 ' + target + ' 章附近)'
670
+ : ';本 chunk 必须补一次 `埋设`'),
671
+ });
672
+ continue;
673
+ }
674
+ if (['RHYTHM_MAIN_COVERAGE', 'RHYTHM_BRANCH_COVERAGE', 'RHYTHM_CONFLICT_COVERAGE',
675
+ 'RHYTHM_FORESHADOW_COVERAGE', 'RHYTHM_PAYOFF_COVERAGE', 'RHYTHM_INFO_COVERAGE',
676
+ 'RHYTHM_ENDING_COVERAGE'].includes(e.code)) {
677
+ const kind = e.code.includes('PAYOFF') ? 'payoff'
678
+ : e.code.includes('FORESHADOW') ? 'foreshadow'
679
+ : e.code.includes('INFO') ? 'info'
680
+ : e.code.includes('MAIN') ? 'main'
681
+ : e.code.includes('BRANCH') ? 'branch'
682
+ : e.code.includes('ENDING') ? 'ending' : 'conflict';
683
+ for (const id of parseMissingIds(e.message, e.actual)) {
684
+ const hint = entityVolumeHint(kind, id, ctx);
685
+ if (!hint) continue;
686
+ add(chunkForDisplay(ctx.volumes, hint.displayNo, ctx.size), {
687
+ ...e,
688
+ message: (e.message || '') + ';本 chunk 必须为 ' + id + ' 补一次落点(推荐:第 ' + hint.displayNo + ' 章附近,' + hint.reason + ')',
689
+ });
690
+ }
691
+ }
692
+ }
693
+ return plan;
694
+ }
695
+
696
+
697
+ /**
698
+ * 生成章节地图:按卷再按 chunk(默认 20 章)拆分。
699
+ * 单个 100 章卷会一次生成 20 章左右,避免一个 turn 输出 200KB+ 导致超时。
700
+ */
701
+ function writeChapterMapFinal(dir, merged) {
702
+ const chapterMap = {
703
+ _meta: {
704
+ schema_version: '2.1',
705
+ node_id: '40-rhythm.chapter_map',
706
+ stage: '40-rhythm',
707
+ generated_at: nowIso(),
708
+ },
709
+ chapters: merged,
710
+ };
711
+ const index = {
712
+ _meta: { schema_version: '2.1', node_id: '40-rhythm.index', stage: '40-rhythm' },
713
+ chapters: merged.map((c) => ({
714
+ chapter_id: c.chapter_id,
715
+ display_no: c.display_no,
716
+ title: c.title,
717
+ volume: c.volume,
718
+ act: c.act,
719
+ path: 'outlines/' + c.chapter_id + '.json',
720
+ status: c.status || 'draft',
721
+ })),
722
+ };
723
+ writeJsonAtomic(path.join(dir, 'chapter_map.json'), chapterMap);
724
+ writeJsonAtomic(path.join(dir, 'index.json'), index);
725
+ return chapterMap;
726
+ }
727
+
728
+ /**
729
+ * 生成章节地图:按卷再按 chunk(默认 20 章)拆分。
730
+ * 单个 100 章卷会一次生成 20 章左右,避免一个 turn 输出 200KB+ 导致超时。
731
+ *
732
+ * 关键顺序:先在内存合并候选地图并校验,通过后才写 chapter_map.json。
733
+ * 校验失败时只返工错误能定位到的 chunk,不删除任何合法分片。
734
+ */
735
+ export async function generateChapterMap(opts) {
736
+ const o = opts || {};
737
+ const { projectRoot, scale } = o;
738
+ const dir = stageDir(projectRoot, '40-rhythm');
739
+ const shardDir = path.join(dir, '.chapter-map');
740
+ ensureDir(shardDir);
741
+ const volumes = asArray(scale && scale.structure && scale.structure.volumes);
742
+ const chunkSize = Number(o.chunkSize) > 0 ? Number(o.chunkSize) : CHAPTER_MAP_CHUNK_SIZE;
743
+ if (!o.force) seedChunksFromChapterMap(dir, shardDir, volumes, chunkSize);
744
+ const targetWords = scale && scale.capacity && scale.capacity.target_words;
745
+ const macro = readJson(path.join(dir, '_context', 'macro.json'), null);
746
+ const cast = readJson(path.join(dir, '_context', 'cast.json'), null);
747
+ const reports = [];
748
+ let failures = [];
749
+ const progressNow = () => chapterMapProgress(shardDir, volumes, chunkSize);
750
+ const failed = (error) => ({
751
+ ok: false,
752
+ files: reports,
753
+ errors: failures,
754
+ error,
755
+ progress: progressNow(),
756
+ });
757
+
758
+ for (let round = 1; round <= 2; round += 1) {
759
+ // 只有首轮 --force 才重做全部 chunk;返工轮只补缺失/无效 chunk。
760
+ const forceThisRound = Boolean(o.force) && round === 1;
761
+ for (const volume of volumes) {
762
+ const fullTarget = path.join(shardDir, volume.volume_id + '.json');
763
+ const ranges = chunkRanges(volume.chapter_start, volume.chapter_end, chunkSize);
764
+ for (const range of ranges) {
765
+ const shardName = volume.volume_id + '-' + String(range.index).padStart(2, '0');
766
+ const target = path.join(shardDir, shardName + '.json');
767
+ if (!forceThisRound && isValidJsonFile(target)) continue;
768
+ let previous = readVolumeChapters(shardDir, volume.volume_id)
769
+ .filter((c) => Number(c.display_no) < range.start)
770
+ .slice(-2);
771
+ if (!previous.length) {
772
+ const vi = volumes.findIndex((v) => v.volume_id === volume.volume_id);
773
+ const prevVolume = vi > 0 ? volumes[vi - 1] : null;
774
+ if (prevVolume) previous = readVolumeChapters(shardDir, prevVolume.volume_id).slice(-2);
775
+ }
776
+ const existing = previous
777
+ .map((c) => '第 ' + c.display_no + ' 章:' + (c.title || '') + ' / ' + JSON.stringify(c.how || {}))
778
+ .join('\n');
779
+ const result = await generateJsonFile({
780
+ target,
781
+ relative: '.chapter-map/' + shardName + '.json',
782
+ sendPrompt: o.sendPrompt,
783
+ waitIdle: o.waitIdle,
784
+ onProgress: o.onProgress,
785
+ failures: [],
786
+ buildPrompt: ({ attempt, failures: f }) => buildChapterMapPrompt({
787
+ projectRoot,
788
+ volumes: shardName,
789
+ volumeId: volume.volume_id,
790
+ startCh: range.start,
791
+ endCh: range.end,
792
+ attempt,
793
+ failures: f,
794
+ existing,
795
+ }),
796
+ });
797
+ reports.push(result);
798
+ if (!result.ok) {
799
+ return {
800
+ ok: false,
801
+ files: reports,
802
+ errors: asArray(result.errors),
803
+ error: result.error,
804
+ progress: progressNow(),
805
+ };
806
+ }
807
+ }
808
+ // 把本卷所有 chunk 合并成整卷缓存,兼容人工检查和旧逻辑。
809
+ const chapters = readVolumeChapters(shardDir, volume.volume_id);
810
+ if (chapters.length) writeJsonAtomic(fullTarget, { chapters });
811
+ }
812
+
813
+ // 合并候选地图:先从 chunk 读,绝不把旧 chapter_map.json 当输入。
814
+ const merged = [];
815
+ for (const volume of volumes) {
816
+ const normalized = normalizeChapterShard(
817
+ { chapters: readVolumeChapters(shardDir, volume.volume_id) },
818
+ volume,
819
+ volume.chapter_start,
820
+ volume.chapter_end,
821
+ scale,
822
+ );
823
+ merged.push(...normalized.chapters);
824
+ }
825
+ merged.sort((a, b) => a.display_no - b.display_no);
826
+ balanceWordTargets(merged, targetWords);
827
+ const chapterMap = {
828
+ _meta: {
829
+ schema_version: '2.1',
830
+ node_id: '40-rhythm.chapter_map',
831
+ stage: '40-rhythm',
832
+ generated_at: nowIso(),
833
+ },
834
+ chapters: merged,
835
+ };
836
+
837
+ if (typeof o.validateMap !== 'function') {
838
+ writeChapterMapFinal(dir, merged);
839
+ return { ok: true, files: reports, errors: [], chapterMap, progress: progressNow() };
840
+ }
841
+ const report = await o.validateMap(chapterMap);
842
+ if (report && report.ok) {
843
+ writeChapterMapFinal(dir, merged);
844
+ return { ok: true, files: reports, errors: [], chapterMap, report, progress: progressNow() };
845
+ }
846
+ failures = failureList(report && report.errors);
847
+ if (!failures.length) {
848
+ return { ok: false, files: reports, errors: [], error: '章节地图校验失败但没有错误', progress: progressNow() };
849
+ }
850
+ if (round >= 2) break;
851
+
852
+ const plan = planChapterMapRepairs(failures, {
853
+ chapters: merged,
854
+ volumes,
855
+ cast,
856
+ macro,
857
+ size: chunkSize,
858
+ });
859
+ if (!plan.size) {
860
+ return failed('章节地图校验未通过,且错误无法定位到具体 chunk;所有分片已保留,chapter_map.json 未写入。');
861
+ }
862
+ if (o.onProgress) {
863
+ o.onProgress('40-rhythm:章节地图校验未通过(' + failures.length + ' 个错误),只返工 ' + plan.size + ' 个 chunk:' + [...plan.keys()].join('、'));
864
+ }
865
+ for (const [chunk, chunkFailures] of plan) {
866
+ const range = chunkRangeForName(volumes, chunk, chunkSize);
867
+ const target = path.join(shardDir, chunk + '.json');
868
+ const current = readJson(target, null);
869
+ if (!range || !current || !Array.isArray(current.chapters)) continue;
870
+ const result = await generateJsonFile({
871
+ target,
872
+ relative: '.chapter-map/' + chunk + '.json',
873
+ sendPrompt: o.sendPrompt,
874
+ waitIdle: o.waitIdle,
875
+ onProgress: o.onProgress,
876
+ failures: chunkFailures,
877
+ buildPrompt: ({ attempt, failures: f }) => buildChapterMapPrompt({
878
+ projectRoot,
879
+ volumes: chunk,
880
+ volumeId: range.volumeId,
881
+ startCh: range.start,
882
+ endCh: range.end,
883
+ attempt,
884
+ failures: f,
885
+ existing: '',
886
+ currentChunk: current,
887
+ }),
888
+ });
889
+ reports.push(result);
890
+ if (!result.ok) {
891
+ return {
892
+ ok: false,
893
+ files: reports,
894
+ errors: asArray(result.errors),
895
+ error: result.error,
896
+ progress: progressNow(),
897
+ };
898
+ }
899
+ }
900
+ // 返工后刷新受影响卷的整卷缓存。
901
+ const touched = new Set([...plan.keys()].map((name) => name.split('-')[0]));
902
+ for (const volumeId of touched) {
903
+ const volume = volumes.find((v) => v.volume_id === volumeId);
904
+ if (!volume) continue;
905
+ const chapters = readVolumeChapters(shardDir, volume.volume_id);
906
+ if (chapters.length) writeJsonAtomic(path.join(shardDir, volume.volume_id + '.json'), { chapters });
907
+ }
908
+ }
909
+ return failed('章节地图校验未通过;分片已保留,chapter_map.json 未更新。');
910
+ }
911
+
912
+
913
+ /** 找出需要生成细纲的章节。 */
914
+ export function pendingOutlineChapters(projectRoot, chapterMap, opts = {}) {
915
+ const dir = stageDir(projectRoot, '40-rhythm');
916
+ const only = opts.chapterIds ? new Set(opts.chapterIds.map((x) => String(x))) : null;
917
+ const out = [];
918
+ for (const c of asArray(chapterMap && chapterMap.chapters)) {
919
+ if (!c || c.status === 'archived') continue;
920
+ const id = c.chapter_id;
921
+ if (!id) continue;
922
+ if (only && !only.has(id) && !only.has(String(c.display_no))) continue;
923
+ const file = path.join(dir, 'outlines', id + '.json');
924
+ if (opts.force || !isValidJsonFile(file)) out.push(c);
925
+ }
926
+ return out;
927
+ }
928
+
929
+ /**
930
+ * 逐章生成细纲。
931
+ */
932
+ export async function generateOutlines(opts) {
933
+ const o = opts || {};
934
+ const { projectRoot, chapterMap, validateOutline } = o;
935
+ const dir = stageDir(projectRoot, '40-rhythm');
936
+ ensureDir(path.join(dir, 'outlines'));
937
+ const pending = pendingOutlineChapters(projectRoot, chapterMap, o);
938
+ const reports = [];
939
+ const chapters = asArray(chapterMap && chapterMap.chapters);
940
+ for (const c of pending) {
941
+ const target = path.join(dir, 'outlines', c.chapter_id + '.json');
942
+ const idx = chapters.findIndex((x) => x.chapter_id === c.chapter_id);
943
+ const prev = idx > 0 ? chapters[idx - 1] : null;
944
+ const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null;
945
+ const prevSummary = prev ? ('第 ' + prev.display_no + ' 章《' + prev.title + '》\n' + JSON.stringify(prev.how || {}, null, 2)) : '';
946
+ const nextSummary = next ? ('第 ' + next.display_no + ' 章《' + next.title + '》\n' + JSON.stringify(next.how || {}, null, 2)) : '';
947
+ const result = await generateJsonFile({
948
+ target,
949
+ relative: 'outlines/' + c.chapter_id + '.json',
950
+ sendPrompt: o.sendPrompt,
951
+ waitIdle: o.waitIdle,
952
+ onProgress: o.onProgress,
953
+ validateFile: async (data) => {
954
+ if (typeof validateOutline !== 'function') return { ok: true, errors: [] };
955
+ return validateOutline(data, c);
956
+ },
957
+ buildPrompt: ({ attempt, failures }) => buildOutlinePrompt({
958
+ projectRoot,
959
+ chapter: c,
960
+ chapterMap: c,
961
+ previous: prevSummary,
962
+ next: nextSummary,
963
+ macroDigest: o.macroDigest,
964
+ castDigest: o.castDigest,
965
+ attempt,
966
+ failures,
967
+ }),
968
+ });
969
+ reports.push(result);
970
+ if (!result.ok) return { ok: false, files: reports, errors: asArray(result.errors), error: result.error, failedChapter: c.chapter_id };
971
+ }
972
+ return { ok: true, files: reports, errors: [] };
973
+ }
974
+
975
+ /** 找出缺正文的章节。 */
976
+ export function pendingWritingChapters(projectRoot, chapterMap, opts = {}) {
977
+ const dir = path.join(stageDir(projectRoot, '50-writing'), 'chapters');
978
+ const only = opts.chapterIds ? new Set(opts.chapterIds.map((x) => String(x))) : null;
979
+ const out = [];
980
+ for (const c of asArray(chapterMap && chapterMap.chapters)) {
981
+ if (!c || c.status === 'archived') continue;
982
+ if (only && !only.has(c.chapter_id) && !only.has(String(c.display_no))) continue;
983
+ const file = path.join(dir, chapterFileName(c.display_no));
984
+ if (opts.force || !fs.existsSync(file)) out.push(c);
985
+ }
986
+ return out;
987
+ }
988
+
989
+ export function chapterFileName(displayNo) {
990
+ return '第' + String(displayNo).padStart(3, '0') + '章.md';
991
+ }
992
+
993
+ /**
994
+ * 按批生成正文。
995
+ */
996
+ export async function generateWriting(opts) {
997
+ const o = opts || {};
998
+ const { projectRoot, chapterMap, outlines, dossiers, validateChapters } = o;
999
+ const dir = stageDir(projectRoot, '50-writing');
1000
+ ensureDir(path.join(dir, 'chapters'));
1001
+ const pending = pendingWritingChapters(projectRoot, chapterMap, o).sort((a, b) => a.display_no - b.display_no);
1002
+ const batchSize = o.batchSize || 4;
1003
+ const reports = [];
1004
+ for (let i = 0; i < pending.length; i += batchSize) {
1005
+ const batch = pending.slice(i, i + batchSize);
1006
+ const chapters = batch.map((c) => c.display_no);
1007
+ const files = batch.map((c) => path.join(dir, 'chapters', chapterFileName(c.display_no)));
1008
+ const ledgerFile = path.join(dir, 'ledger.json');
1009
+ const allFiles = [...files, ledgerFile];
1010
+ let failures = [];
1011
+ let ok = false;
1012
+ let prevTail = o.prevTail || '';
1013
+ // 每批都从磁盘重新取“上一章结尾”,不能整轮复用同一段前情。
1014
+ const allChapters = asArray(chapterMap && chapterMap.chapters);
1015
+ const firstNo = Math.min(...batch.map((c) => Number(c.display_no)));
1016
+ for (let n = firstNo - 1; n >= 1; n -= 1) {
1017
+ const c = allChapters.find((x) => Number(x.display_no) === n);
1018
+ if (!c) continue;
1019
+ const text = readText(path.join(dir, 'chapters', chapterFileName(n)), null);
1020
+ if (text !== null && text.trim()) {
1021
+ prevTail = text.slice(-500);
1022
+ break;
1023
+ }
1024
+ }
1025
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
1026
+ const before = snapshotFiles(allFiles);
1027
+ const prompt = buildWritingPrompt({
1028
+ projectRoot,
1029
+ chapters,
1030
+ outlines,
1031
+ dossiers,
1032
+ prevTail,
1033
+ ledger: readJson(ledgerFile, { chapters: [] }),
1034
+ attempt,
1035
+ failures,
1036
+ });
1037
+ const sent = await o.sendPrompt(prompt);
1038
+ if (!sent || sent.ok !== true) {
1039
+ failures = [{ code: 'PROMPT_DELIVERY_FAILED', message: (sent && sent.error) || '提示词投递失败' }];
1040
+ continue;
1041
+ }
1042
+ const waited = await o.waitIdle(allFiles, before);
1043
+ const noChange = waited && waited.code === 'NO_CHANGE';
1044
+ if (!waited || (waited.ok !== true && !noChange)) {
1045
+ if (waited && ['TIMEOUT', 'ABORTED', 'NO_AGENT'].includes(waited.code)) {
1046
+ reports.push({ ok: false, chapters, error: waited.reason, errors: [{ code: waited.code, message: waited.reason }] });
1047
+ return { ok: false, reports, errors: reports.flatMap((r) => r.errors || []), error: waited.reason };
1048
+ }
1049
+ failures = [{ code: (waited && waited.code) || 'NO_OUTPUT', message: (waited && waited.reason) || '等待 LLM 完成失败' }];
1050
+ continue;
1051
+ }
1052
+ const report = typeof validateChapters === 'function'
1053
+ ? await validateChapters(chapters)
1054
+ : { ok: true, errors: [] };
1055
+ if (report && report.ok) {
1056
+ reports.push({ ok: true, chapters });
1057
+ ok = true;
1058
+ break;
1059
+ }
1060
+ failures = failureList(report && report.errors);
1061
+ }
1062
+ if (!ok) {
1063
+ reports.push({ ok: false, chapters, errors: failures });
1064
+ return { ok: false, reports, errors: failures, error: '正文批次校验未通过' };
1065
+ }
1066
+ }
1067
+ return { ok: true, reports, errors: [] };
1068
+ }