@oh-story/dsh 0.1.8 → 0.1.10

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 (360) hide show
  1. package/README.md +14 -14
  2. package/lib/client.js +237 -84
  3. package/lib/client.js.map +3 -3
  4. package/lib/drama/manifest.json +183 -77
  5. package/lib/drama/skills/short-drama/SKILL.md +5 -1
  6. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +140 -9
  7. package/lib/drama/skills/short-drama/scripts/project_tool.py +11 -0
  8. package/lib/drama/skills/short-drama-assets/references/character-and-look.md +28 -0
  9. package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +8 -2
  10. package/lib/drama/skills/short-drama-assets/references/voice-direction.md +28 -0
  11. package/lib/drama/skills/short-drama-edit/SKILL.md +146 -0
  12. package/lib/drama/skills/short-drama-edit/agents/openai.yaml +4 -0
  13. package/lib/drama/skills/short-drama-edit/assets/remotion/README.md +60 -0
  14. package/lib/drama/skills/short-drama-edit/assets/remotion/package.json +15 -0
  15. package/lib/drama/skills/short-drama-edit/assets/remotion/remotion.config.ts +6 -0
  16. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Root.tsx +26 -0
  17. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +88 -0
  18. package/lib/drama/skills/short-drama-edit/assets/remotion/src/font.ts +46 -0
  19. package/lib/drama/skills/short-drama-edit/assets/remotion/src/index.ts +4 -0
  20. package/lib/drama/skills/short-drama-edit/assets/remotion/src/schema.ts +32 -0
  21. package/lib/drama/skills/short-drama-edit/assets/remotion/tsconfig.json +13 -0
  22. package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +43 -0
  23. package/lib/drama/skills/short-drama-edit/references/cut-craft.md +38 -0
  24. package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +29 -0
  25. package/lib/drama/skills/short-drama-edit/references/generated-footage.md +54 -0
  26. package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +60 -0
  27. package/lib/drama/skills/short-drama-edit/references/stage-contract.md +51 -0
  28. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1156 -0
  29. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +520 -0
  30. package/lib/drama/skills/short-drama-image-prompts/SKILL.md +2 -0
  31. package/lib/drama/skills/short-drama-image-prompts/assets/image-prompts.md +5 -0
  32. package/lib/drama/skills/short-drama-image-prompts/assets/lookdev-prompts.md +3 -0
  33. package/lib/drama/skills/short-drama-image-prompts/references/common-recipe.md +11 -0
  34. package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +2 -2
  35. package/lib/drama/skills/short-drama-novel-analyze/references/chapter-extraction.md +4 -1
  36. package/lib/drama/skills/short-drama-produce/SKILL.md +29 -4
  37. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +22 -0
  38. package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +13 -12
  39. package/lib/drama/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
  40. package/lib/drama/skills/short-drama-produce/references/providers/seedance.md +7 -0
  41. package/lib/drama/skills/short-drama-produce/scripts/fixture_adapter.py +18 -1
  42. package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +181 -2
  43. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +199 -3
  44. package/lib/drama/skills/short-drama-review/SKILL.md +2 -0
  45. package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +7 -0
  46. package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +10 -0
  47. package/lib/drama/skills/short-drama-storyboard/SKILL.md +12 -1
  48. package/lib/drama/skills/short-drama-storyboard/assets/keyframe-prompts.md +4 -0
  49. package/lib/drama/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
  50. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +78 -2
  51. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -0
  52. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +12 -5
  53. package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +17 -0
  54. package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +76 -7
  55. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +5 -2
  56. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +21 -0
  57. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +6 -17
  58. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +12 -3
  59. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +61 -5
  60. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +3 -3
  61. package/lib/drama/skills/short-drama-write/SKILL.md +11 -2
  62. package/lib/drama/skills/short-drama-write/references/screenplay-format.md +6 -0
  63. package/lib/drama/skills/short-drama-write/references/stage-contract.md +1 -0
  64. package/lib/index.js +290 -90
  65. package/lib/index.js.map +4 -4
  66. package/lib/novel-to-game/README.md +299 -59
  67. package/lib/novel-to-game/README_ZH.md +299 -56
  68. package/lib/novel-to-game/VERSION +1 -1
  69. package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +3 -1
  70. package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +11 -3
  71. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +397 -8
  72. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +0 -31
  73. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +71 -73
  74. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +78 -89
  75. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +37 -14
  76. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +5 -5
  77. package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -1
  78. package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +11 -16
  79. package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +11 -11
  80. package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +23 -0
  81. package/lib/novel-to-game/manifest.json +77 -87
  82. package/lib/novel-to-game/skills/game-art-direction/SKILL.md +9 -35
  83. package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +27 -106
  84. package/lib/novel-to-game/skills/game-build/SKILL.md +17 -35
  85. package/lib/novel-to-game/skills/game-build/references/blender-asset-workflow.md +24 -0
  86. package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +45 -82
  87. package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +14 -45
  88. package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +30 -65
  89. package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +13 -27
  90. package/lib/novel-to-game/skills/game-concept/SKILL.md +16 -34
  91. package/lib/novel-to-game/skills/game-concept/references/concept-method.md +28 -75
  92. package/lib/novel-to-game/skills/game-qa/SKILL.md +9 -20
  93. package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +10 -6
  94. package/lib/novel-to-game/skills/game-world-design/SKILL.md +19 -32
  95. package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +23 -72
  96. package/lib/novel-to-game/skills/game-world-design/references/numeric-design-method.md +14 -48
  97. package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +36 -103
  98. package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +9 -18
  99. package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +4 -4
  100. package/lib/novel-to-game/skills/novel-to-game/SKILL.md +10 -27
  101. package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +5 -6
  102. package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +6 -11
  103. package/lib/oh-story/manifest.json +419 -254
  104. package/lib/oh-story/roles/chapter-extractor.md +177 -237
  105. package/lib/oh-story/roles/character-designer.md +1 -1
  106. package/lib/oh-story/roles/consistency-checker.md +4 -4
  107. package/lib/oh-story/roles/narrative-writer.md +56 -243
  108. package/lib/oh-story/roles/story-architect.md +6 -6
  109. package/lib/oh-story/roles/story-explorer.md +11 -11
  110. package/lib/oh-story/skills/story/SKILL.md +13 -7
  111. package/lib/oh-story/skills/story/VERSION +1 -1
  112. package/lib/oh-story/skills/story/references/author-memory.md +61 -29
  113. package/lib/oh-story/skills/story/scripts/author_memory_commit.py +934 -170
  114. package/lib/oh-story/skills/story-cover/SKILL.md +10 -1
  115. package/lib/oh-story/skills/story-deslop/SKILL.md +54 -229
  116. package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +29 -56
  117. package/lib/oh-story/skills/story-deslop/references/author-memory.md +61 -29
  118. package/lib/oh-story/skills/story-deslop/references/banned-words.md +3 -1
  119. package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +154 -0
  120. package/lib/oh-story/skills/story-deslop/references/style-resolution.md +27 -0
  121. package/lib/oh-story/skills/story-deslop/scripts/author_memory_commit.py +934 -170
  122. package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +6 -1
  123. package/lib/oh-story/skills/story-deslop/scripts/check-degeneration.js +39 -20
  124. package/lib/oh-story/skills/story-deslop/scripts/normalize-punctuation.js +10 -5
  125. package/lib/oh-story/skills/story-deslop/scripts/style-whitelist.js +39 -0
  126. package/lib/oh-story/skills/story-import/SKILL.md +78 -82
  127. package/lib/oh-story/skills/story-import/references/character-state-reverse.md +1 -1
  128. package/lib/oh-story/skills/story-import/references/format-and-structure.md +2 -2
  129. package/lib/oh-story/skills/story-import/references/state-tracking.md +4 -69
  130. package/lib/oh-story/skills/story-import/references/structure-mapping-long.md +10 -8
  131. package/lib/oh-story/skills/story-import/references/tracking-initialization.md +31 -0
  132. package/lib/oh-story/skills/story-import/references/tracking-transaction.md +9 -34
  133. package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +197 -15
  134. package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +210 -2
  135. package/lib/oh-story/skills/story-import/scripts/wordcount_core.py +3 -0
  136. package/lib/oh-story/skills/story-long-analyze/SKILL.md +91 -258
  137. package/lib/oh-story/skills/story-long-analyze/references/author-facing.md +245 -0
  138. package/lib/oh-story/skills/story-long-analyze/references/deconstruction-notes.md +2 -2
  139. package/lib/oh-story/skills/story-long-analyze/references/inspiration-library.md +105 -0
  140. package/lib/oh-story/skills/story-long-analyze/references/material-decomposition.md +113 -105
  141. package/lib/oh-story/skills/story-long-analyze/references/output-templates.md +169 -125
  142. package/lib/oh-story/skills/story-long-analyze/references/pipeline-ops.md +172 -64
  143. package/lib/oh-story/skills/story-long-analyze/references/semantic-acceptance-fixtures.md +84 -0
  144. package/lib/oh-story/skills/story-long-analyze/references/style-profile-generator.md +42 -132
  145. package/lib/oh-story/skills/story-long-analyze/references/style-profile-protocol.md +5 -5
  146. package/lib/oh-story/skills/story-long-analyze/scripts/build_chapter_index.py +466 -0
  147. package/lib/oh-story/skills/story-long-analyze/scripts/inspect_existing_assets.py +702 -0
  148. package/lib/oh-story/skills/story-long-analyze/scripts/inspiration_index.py +864 -0
  149. package/lib/oh-story/skills/story-long-analyze/scripts/manage_analysis_run.py +1106 -0
  150. package/lib/oh-story/skills/story-long-analyze/scripts/render_relation_chart.py +425 -0
  151. package/lib/oh-story/skills/story-long-scan/SKILL.md +4 -1
  152. package/lib/oh-story/skills/story-long-write/SKILL.md +52 -216
  153. package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +29 -56
  154. package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +72 -12
  155. package/lib/oh-story/skills/story-long-write/references/author-memory.md +61 -29
  156. package/lib/oh-story/skills/story-long-write/references/banned-words.md +3 -1
  157. package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +20 -0
  158. package/lib/oh-story/skills/story-long-write/references/cross-book-recall.md +26 -0
  159. package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +10 -4
  160. package/lib/oh-story/skills/story-long-write/references/emotional-methods.md +2 -2
  161. package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +6 -15
  162. package/lib/oh-story/skills/story-long-write/references/long-format.md +2 -0
  163. package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +2 -0
  164. package/lib/oh-story/skills/story-long-write/references/project-files.md +110 -0
  165. package/lib/oh-story/skills/story-long-write/references/reader-contract-and-progression.md +12 -4
  166. package/lib/oh-story/skills/story-long-write/references/reference-index.md +86 -0
  167. package/lib/oh-story/skills/story-long-write/references/style-combat-face.md +1 -1
  168. package/lib/oh-story/skills/story-long-write/references/style-craft.md +9 -29
  169. package/lib/oh-story/skills/story-long-write/references/style-genre-modules.md +1 -1
  170. package/lib/oh-story/skills/story-long-write/references/style-resolution.md +27 -0
  171. package/lib/oh-story/skills/story-long-write/references/tracking-initialization.md +31 -0
  172. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +9 -34
  173. package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +88 -118
  174. package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +55 -119
  175. package/lib/oh-story/skills/story-long-write/references/workflow-revision.md +15 -11
  176. package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +73 -54
  177. package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +22 -0
  178. package/lib/oh-story/skills/story-long-write/references/writing-craft.md +63 -4
  179. package/lib/oh-story/skills/story-long-write/scripts/author_memory_commit.py +934 -170
  180. package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +531 -0
  181. package/lib/oh-story/skills/story-long-write/scripts/check-ai-patterns.js +6 -1
  182. package/lib/oh-story/skills/story-long-write/scripts/check-degeneration.js +39 -20
  183. package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +197 -15
  184. package/lib/oh-story/skills/story-long-write/scripts/check-outline-copy.js +90 -23
  185. package/lib/oh-story/skills/story-long-write/scripts/normalize-punctuation.js +10 -5
  186. package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +285 -0
  187. package/lib/oh-story/skills/story-long-write/scripts/storyctl.py +58 -0
  188. package/lib/oh-story/skills/story-long-write/scripts/style-whitelist.js +39 -0
  189. package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +210 -2
  190. package/lib/oh-story/skills/story-long-write/scripts/wordcount_core.py +3 -0
  191. package/lib/oh-story/skills/story-review/SKILL.md +80 -87
  192. package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +29 -56
  193. package/lib/oh-story/skills/story-review/references/author-memory.md +61 -29
  194. package/lib/oh-story/skills/story-review/references/banned-words.md +3 -1
  195. package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +10 -4
  196. package/lib/oh-story/skills/story-review/references/review-quality.md +6 -12
  197. package/lib/oh-story/skills/story-review/references/style-resolution.md +27 -0
  198. package/lib/oh-story/skills/story-review/references/tracking-initialization.md +31 -0
  199. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +9 -34
  200. package/lib/oh-story/skills/story-review/scripts/author_memory_commit.py +934 -170
  201. package/lib/oh-story/skills/story-review/scripts/check-ai-patterns.js +6 -1
  202. package/lib/oh-story/skills/story-review/scripts/check-degeneration.js +39 -20
  203. package/lib/oh-story/skills/story-review/scripts/normalize-punctuation.js +10 -5
  204. package/lib/oh-story/skills/story-review/scripts/style-whitelist.js +39 -0
  205. package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +210 -2
  206. package/lib/oh-story/skills/story-review/scripts/wordcount_core.py +3 -0
  207. package/lib/oh-story/skills/story-setup/SKILL.md +58 -43
  208. package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +29 -56
  209. package/lib/oh-story/skills/story-setup/references/agent-references/banned-words.md +3 -1
  210. package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +154 -0
  211. package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +10 -4
  212. package/lib/oh-story/skills/story-setup/references/agent-references/format-and-structure.md +2 -2
  213. package/lib/oh-story/skills/story-setup/references/agent-references/long-emotional-methods.md +2 -2
  214. package/lib/oh-story/skills/story-setup/references/agent-references/long-quality.md +1 -1
  215. package/lib/oh-story/skills/story-setup/references/agent-references/short-genre-formulas.md +1 -1
  216. package/lib/oh-story/skills/story-setup/references/agent-references/style-combat-face.md +1 -1
  217. package/lib/oh-story/skills/story-setup/references/agent-references/style-genre-modules.md +1 -1
  218. package/lib/oh-story/skills/story-setup/references/agent-references/style-resolution.md +27 -0
  219. package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +63 -4
  220. package/lib/oh-story/skills/story-setup/references/diagnostics.md +38 -0
  221. package/lib/oh-story/skills/story-short-analyze/SKILL.md +22 -3
  222. package/lib/oh-story/skills/story-short-analyze/references/analysis-writing-techniques.md +1 -1
  223. package/lib/oh-story/skills/story-short-analyze/references/material-decomposition.md +5 -1
  224. package/lib/oh-story/skills/story-short-analyze/references/source-story-quality.md +2 -2
  225. package/lib/oh-story/skills/story-short-scan/SKILL.md +5 -1
  226. package/lib/oh-story/skills/story-short-write/SKILL.md +15 -324
  227. package/lib/oh-story/skills/story-short-write/references/author-memory.md +61 -29
  228. package/lib/oh-story/skills/story-short-write/references/banned-words.md +3 -1
  229. package/lib/oh-story/skills/story-short-write/references/benchmark-recall.md +60 -0
  230. package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +10 -4
  231. package/lib/oh-story/skills/story-short-write/references/genre-styles//344/270/226/346/203/205/346/211/223/350/204/270.md +2 -2
  232. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/217/214/347/224/267/344/270/273.md +1 -1
  233. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/244/215/344/273/207/346/211/223/350/204/270.md +3 -3
  234. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/256/205/346/226/227/345/256/253/346/226/227.md +3 -3
  235. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/200/273/350/243/201/350/261/252/351/227/250.md +3 -3
  236. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/202/254/347/226/221.md +5 -5
  237. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/260/221/344/277/227/346/200/252/350/260/210.md +4 -4
  238. package/lib/oh-story/skills/story-short-write/references/genre-styles//347/224/234/345/256/240.md +5 -5
  239. package/lib/oh-story/skills/story-short-write/references/genre-styles//350/277/275/345/246/273/347/201/253/350/221/254/345/234/272.md +3 -3
  240. package/lib/oh-story/skills/story-short-write/references/genre-writing-formulas.md +1 -1
  241. package/lib/oh-story/skills/story-short-write/references/genre-writing-techniques.md +1 -1
  242. package/lib/oh-story/skills/story-short-write/references/reference-index.md +55 -0
  243. package/lib/oh-story/skills/story-short-write/references/short-craft.md +12 -14
  244. package/lib/oh-story/skills/story-short-write/references/short-deslop.md +10 -10
  245. package/lib/oh-story/skills/story-short-write/references/short-format.md +3 -1
  246. package/lib/oh-story/skills/story-short-write/references/short-prose-quality.md +2 -2
  247. package/lib/oh-story/skills/story-short-write/references/style-resolution.md +27 -0
  248. package/lib/oh-story/skills/story-short-write/references/workflow-design.md +78 -0
  249. package/lib/oh-story/skills/story-short-write/references/workflow-draft.md +126 -0
  250. package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +32 -0
  251. package/lib/oh-story/skills/story-short-write/references/writing-workflow.md +5 -5
  252. package/lib/oh-story/skills/story-short-write/scripts/author_memory_commit.py +934 -170
  253. package/lib/oh-story/skills/story-short-write/scripts/check-ai-patterns.js +6 -1
  254. package/lib/oh-story/skills/story-short-write/scripts/check-degeneration.js +39 -20
  255. package/lib/oh-story/skills/story-short-write/scripts/check-delivery-contract.js +34 -5
  256. package/lib/oh-story/skills/story-short-write/scripts/check-outline-copy.js +90 -23
  257. package/lib/oh-story/skills/story-short-write/scripts/normalize-punctuation.js +10 -5
  258. package/lib/oh-story/skills/story-short-write/scripts/style-whitelist.js +39 -0
  259. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  260. package/lib/types/client/file-activity.d.ts +19 -1
  261. package/lib/types/client/file-activity.d.ts.map +1 -1
  262. package/lib/types/client/index.d.ts.map +1 -1
  263. package/lib/types/client/production-prompts.d.ts +7 -0
  264. package/lib/types/client/production-prompts.d.ts.map +1 -1
  265. package/lib/types/client/production-runtime.d.ts +33 -0
  266. package/lib/types/client/production-runtime.d.ts.map +1 -1
  267. package/lib/types/drama-adapters.d.ts +5 -3
  268. package/lib/types/drama-adapters.d.ts.map +1 -1
  269. package/lib/types/native-hooks.d.ts +52 -0
  270. package/lib/types/native-hooks.d.ts.map +1 -1
  271. package/lib/types/role-identity.d.ts +17 -0
  272. package/lib/types/role-identity.d.ts.map +1 -0
  273. package/lib/types/role-tool.d.ts.map +1 -1
  274. package/lib/types/skill-provider.d.ts.map +1 -1
  275. package/lib/video-recap/CHANGELOG.md +31 -1
  276. package/lib/video-recap/manifest.json +162 -162
  277. package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +4 -9
  278. package/lib/video-recap/skills/video-assemble/scripts/assemble.py +47 -60
  279. package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +77 -115
  280. package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +41 -40
  281. package/lib/video-recap/skills/video-assemble/scripts/audio_automation.py +22 -32
  282. package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +102 -192
  283. package/lib/video-recap/skills/video-assemble/scripts/jianying_builders.py +14 -35
  284. package/lib/video-recap/skills/video-assemble/scripts/jianying_model.py +14 -12
  285. package/lib/video-recap/skills/video-assemble/scripts/jianying_optional.py +8 -9
  286. package/lib/video-recap/skills/video-assemble/scripts/jianying_schema.py +3 -14
  287. package/lib/video-recap/skills/video-assemble/scripts/jianying_timeline_contract.py +23 -12
  288. package/lib/video-recap/skills/video-assemble/scripts/jianying_tracks.py +1 -4
  289. package/lib/video-recap/skills/video-assemble/scripts/jianying_writer.py +24 -25
  290. package/lib/video-recap/skills/video-assemble/scripts/lib.py +31 -85
  291. package/lib/video-recap/skills/video-assemble/scripts/media.py +100 -144
  292. package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +101 -195
  293. package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +10 -21
  294. package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +90 -169
  295. package/lib/video-recap/skills/video-assemble/scripts/subtitle_core.py +69 -122
  296. package/lib/video-recap/skills/video-assemble/scripts/subtitle_render.py +10 -17
  297. package/lib/video-recap/skills/video-assemble/scripts/timeline.py +33 -48
  298. package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +14 -20
  299. package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +135 -213
  300. package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +49 -57
  301. package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +38 -74
  302. package/lib/video-recap/skills/video-cut/scripts/cut_render.py +46 -95
  303. package/lib/video-recap/skills/video-cut/scripts/lib.py +23 -25
  304. package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +70 -173
  305. package/lib/video-recap/skills/video-cut/scripts/narration_mapping.py +28 -53
  306. package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +162 -322
  307. package/lib/video-recap/skills/video-recap/references/timeline-and-jianying.md +6 -7
  308. package/lib/video-recap/skills/video-recap/scripts/doctor.py +104 -124
  309. package/lib/video-recap/skills/video-recap/scripts/final_qc.py +255 -412
  310. package/lib/video-recap/skills/video-recap/scripts/lib.py +30 -52
  311. package/lib/video-recap/skills/video-recap/scripts/materials.py +97 -120
  312. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_client.py +7 -12
  313. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_evidence.py +46 -97
  314. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_observations.py +18 -36
  315. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_payload.py +29 -47
  316. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_report.py +43 -76
  317. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_runner.py +2 -1
  318. package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +2 -1
  319. package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +2 -2
  320. package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +129 -145
  321. package/lib/video-recap/skills/video-recap/scripts/recap_review.py +27 -20
  322. package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +20 -31
  323. package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +31 -106
  324. package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +43 -113
  325. package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +196 -377
  326. package/lib/video-recap/skills/video-script/scripts/agent_brief.py +1 -1
  327. package/lib/video-recap/skills/video-script/scripts/agent_text.py +63 -172
  328. package/lib/video-recap/skills/video-script/scripts/brief_context.py +80 -135
  329. package/lib/video-recap/skills/video-script/scripts/brief_inputs.py +125 -167
  330. package/lib/video-recap/skills/video-script/scripts/brief_timeline.py +93 -208
  331. package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +20 -46
  332. package/lib/video-recap/skills/video-script/scripts/lib.py +30 -26
  333. package/lib/video-recap/skills/video-script/scripts/narration_lint.py +145 -140
  334. package/lib/video-recap/skills/video-script/scripts/review_response.py +12 -42
  335. package/lib/video-recap/skills/video-script/scripts/review_runner.py +0 -6
  336. package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +39 -100
  337. package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +33 -124
  338. package/lib/video-recap/skills/video-understanding/scripts/agent_brief.py +1 -1
  339. package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +63 -172
  340. package/lib/video-recap/skills/video-understanding/scripts/asr.py +1 -2
  341. package/lib/video-recap/skills/video-understanding/scripts/brief_context.py +80 -135
  342. package/lib/video-recap/skills/video-understanding/scripts/brief_inputs.py +125 -167
  343. package/lib/video-recap/skills/video-understanding/scripts/brief_timeline.py +93 -208
  344. package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +1 -1
  345. package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +20 -46
  346. package/lib/video-recap/skills/video-understanding/scripts/lib.py +31 -26
  347. package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +145 -140
  348. package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +39 -100
  349. package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +33 -124
  350. package/lib/video-recap/skills/video-understanding/scripts/vlm.py +1 -4
  351. package/lib/video-recap/skills/video-voiceover/scripts/dub.py +7 -13
  352. package/lib/video-recap/skills/video-voiceover/scripts/fish_audio.py +12 -30
  353. package/lib/video-recap/skills/video-voiceover/scripts/lib.py +98 -131
  354. package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +17 -28
  355. package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +151 -240
  356. package/package.json +45 -45
  357. package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +0 -59
  358. package/lib/novel-to-game/skills/game-world-design/references/game-writing-craft.md +0 -53
  359. package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +0 -42
  360. package/lib/oh-story/skills/story-long-write/references/state-tracking.md +0 -108
@@ -0,0 +1,1106 @@
1
+ #!/usr/bin/env python3
2
+ """Plan, commit, split, and recover long-analysis batches.
3
+
4
+ Runtime state lives only in the managed block of ``_progress.md``. Plans are
5
+ printed as JSON and are never persisted. Batch caches are complete recovery
6
+ evidence, not a second state database. An existing chapter summary is never
7
+ overwritten: to redo a chapter, delete its summary and plan again.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import codecs
14
+ import csv
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import re
19
+ import sys
20
+ import tempfile
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
23
+
24
+ from inspect_existing_assets import inspect
25
+
26
+
27
+ STATE_START = "<!-- story-long-analyze:runtime-state:start -->"
28
+ STATE_END = "<!-- story-long-analyze:runtime-state:end -->"
29
+ CACHE_START = "<!-- story-long-analyze:cache:start -->"
30
+ CACHE_END = "<!-- story-long-analyze:cache:end -->"
31
+ MODEL_START = "<!-- MODEL_OUTPUT_START -->"
32
+ MODEL_END = "<!-- MODEL_OUTPUT_END -->"
33
+ CHAPTER_TOKEN_RE = re.compile(r"<!--\s*CHAPTER_(START|END):(\d+)\s*-->")
34
+ CHAPTER_BLOCK_RE = re.compile(
35
+ r"<!--\s*CHAPTER_START:(\d+)\s*-->\s*(.*?)\s*<!--\s*CHAPTER_END:\1\s*-->", re.DOTALL
36
+ )
37
+ BATCH_ID_RE = re.compile(r"^(RAW|REUSE)-(\d+)-(\d+)$")
38
+ COMPACT_FIELDS = (
39
+ "概要", "因果", "关键行动", "局面结果", "涉及人物", "信息变化", "状态变化",
40
+ "三维节奏", "章尾钩子", "证据",
41
+ )
42
+ POINT_HEADER_RE = re.compile(r"^P(\d+)\s+\*\*(.+?)\*\*\s*[::]\s*(.+)$")
43
+ POINT_TAG_RE = re.compile(r"^主题标签\s*[::]?\s*([^||]*?)\s*[||]\s*基调\s*[::]?\s*(.*?)\s*$")
44
+ THEMES = ("爱情", "亲情", "友情", "权力", "金钱", "成长", "复仇", "悬念", "搞笑", "热血", "日常", "其他")
45
+ TONES = ("紧张", "轻松", "悲伤", "热血", "爽", "甜", "温馨", "恐怖", "压抑", "其他")
46
+ POINT_TYPES = ("转折点", "信息揭示", "冲突", "解决", "铺垫", "行动", "对话", "状态变化")
47
+ THEME_ALIASES = {"恋爱": "爱情", "权谋": "权力", "政治": "权力", "幽默": "搞笑"}
48
+ TONE_ALIASES = {"悲痛": "悲伤", "伤感": "悲伤", "悲愤": "悲伤", "痛快": "爽", "解气": "爽", "惊悚": "恐怖",
49
+ "恐惧": "恐怖", "危险": "紧张", "危急": "紧张", "绝望": "压抑", "无力": "压抑", "释然": "轻松"}
50
+ POINT_ALIASES = {"揭示": "信息揭示", "转折": "转折点", "变化": "状态变化", "动作": "行动",
51
+ "交谈": "对话", "化解": "解决"}
52
+ MIN_PLOT_POINTS = 10
53
+ MAX_PLOT_POINTS = 30
54
+ MAX_CHAPTERS = 3
55
+ MAX_CHARS = 25_000
56
+ # Stage 3-6 each run once over complete Stage 2 output; their rows decide 最终状态.
57
+ FINAL_STAGES = ("stage3", "stage4", "stage5", "stage6")
58
+ LEGACY_FINAL_RE = re.compile(r"(?m)^([ \t]*(?:[-*][ \t]*)?最终状态[ \t]*[::][ \t]*)([A-Za-z0-9_]+)")
59
+
60
+
61
+ class RunError(ValueError):
62
+ def __init__(self, code: str, detail: str, author_message: Optional[str] = None) -> None:
63
+ super().__init__(detail)
64
+ self.code = code
65
+ self.detail = detail
66
+ self.author_message = author_message
67
+
68
+
69
+ def sha256(data: bytes) -> str:
70
+ return hashlib.sha256(data).hexdigest()
71
+
72
+
73
+ def normalized(text: str) -> str:
74
+ return text.replace("\r\n", "\n").replace("\r", "\n")
75
+
76
+
77
+ def atomic_write(path: Path, data: bytes) -> None:
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ fd, temporary = tempfile.mkstemp(prefix=".%s." % path.name, suffix=".tmp", dir=str(path.parent))
80
+ try:
81
+ with os.fdopen(fd, "wb") as handle:
82
+ handle.write(data)
83
+ handle.flush()
84
+ os.fsync(handle.fileno())
85
+ os.replace(temporary, str(path))
86
+ except BaseException:
87
+ try:
88
+ os.unlink(temporary)
89
+ except OSError:
90
+ pass
91
+ raise
92
+
93
+
94
+ def require_root(root: Path) -> Path:
95
+ root = root.resolve()
96
+ if not root.exists():
97
+ raise RunError("root_not_found", str(root))
98
+ if not root.is_dir():
99
+ raise RunError("root_is_not_directory", str(root))
100
+ try:
101
+ next(root.iterdir(), None)
102
+ except OSError as exc:
103
+ raise RunError("root_unreadable", str(exc)) from exc
104
+ return root
105
+
106
+
107
+ def read_index(root: Path, index_arg: Optional[Path] = None) -> List[Dict[str, Any]]:
108
+ path = (index_arg.resolve() if index_arg else root / "chapter_index.csv")
109
+ if not path.is_file():
110
+ raise RunError("chapter_index_required", str(path))
111
+ rows = [] # type: List[Dict[str, Any]]
112
+ try:
113
+ with path.open("r", encoding="utf-8-sig", newline="") as handle:
114
+ reader = csv.DictReader(handle)
115
+ required = {"chapter", "start_line", "end_line", "char_count", "source_locator", "chapter_sha256"}
116
+ missing = required - set(reader.fieldnames or ())
117
+ if missing:
118
+ raise RunError("chapter_index_invalid", "missing columns: %s" % ", ".join(sorted(missing)))
119
+ for raw in reader:
120
+ row = dict(raw)
121
+ try:
122
+ row["chapter"] = int(row["chapter"])
123
+ row["start_line"] = int(row["start_line"])
124
+ row["end_line"] = int(row["end_line"])
125
+ row["char_count"] = int(row["char_count"])
126
+ except (TypeError, ValueError) as exc:
127
+ raise RunError("chapter_index_invalid", "numeric column invalid") from exc
128
+ if not re.fullmatch(r"[0-9a-f]{64}", str(row["chapter_sha256"])):
129
+ raise RunError("chapter_index_invalid", "chapter_sha256 invalid")
130
+ rows.append(row)
131
+ except (OSError, UnicodeError, csv.Error) as exc:
132
+ raise RunError("chapter_index_unreadable", str(exc)) from exc
133
+ if [row["chapter"] for row in rows] != list(range(1, len(rows) + 1)):
134
+ raise RunError("chapter_index_invalid", "chapter ids must be continuous from 1")
135
+ return rows
136
+
137
+
138
+ def range_sha256(rows: Sequence[Dict[str, Any]], start: int, end: int) -> str:
139
+ selected = [row for row in rows if start <= row["chapter"] <= end]
140
+ if [row["chapter"] for row in selected] != list(range(start, end + 1)):
141
+ raise RunError("range_not_in_index", "%s-%s" % (start, end))
142
+ payload = "range-v1\n" + "".join(
143
+ "%s:%s\n" % (row["chapter"], row["chapter_sha256"]) for row in selected
144
+ )
145
+ return sha256(payload.encode("ascii"))
146
+
147
+
148
+ def decode_progress(raw: bytes) -> Tuple[str, bool, str]:
149
+ bom = raw.startswith(codecs.BOM_UTF8)
150
+ text = raw.decode("utf-8-sig")
151
+ newline = "\r\n" if "\r\n" in text else "\n"
152
+ return text, bom, newline
153
+
154
+
155
+ def empty_state() -> Dict[str, Any]:
156
+ return {"batches": {}, "stages": {}}
157
+
158
+
159
+ def table_cells(line: str) -> List[str]:
160
+ return [cell.strip() for cell in line.strip().strip("|").split("|")]
161
+
162
+
163
+ def load_state(progress: Path) -> Tuple[Dict[str, Any], bytes]:
164
+ raw = progress.read_bytes() if progress.is_file() else b""
165
+ text, _, _ = decode_progress(raw)
166
+ state = empty_state()
167
+ start_count = text.count(STATE_START)
168
+ end_count = text.count(STATE_END)
169
+ if start_count != end_count or start_count > 1:
170
+ raise RunError(
171
+ "progress_state_block_invalid",
172
+ "expected zero or one complete managed state block; found start=%s end=%s"
173
+ % (start_count, end_count),
174
+ )
175
+ match = re.search(re.escape(STATE_START) + r"(.*?)" + re.escape(STATE_END), text, re.DOTALL)
176
+ if not match:
177
+ return state, raw
178
+ section = None
179
+ for line in match.group(1).splitlines():
180
+ if line.strip() == "### 批次状态":
181
+ section = "batches"
182
+ continue
183
+ if line.strip() == "### 阶段状态":
184
+ section = "stages"
185
+ continue
186
+ if not line.lstrip().startswith("|") or set(line.replace("|", "").replace("-", "").replace(":", "").strip()) == set():
187
+ continue
188
+ cells = table_cells(line)
189
+ if section == "batches" and cells and cells[0] not in {"批次ID", "---"} and len(cells) >= 7:
190
+ try:
191
+ start, end = [int(value) for value in cells[1].split("-", 1)]
192
+ except (ValueError, IndexError):
193
+ continue
194
+ state["batches"][cells[0]] = {
195
+ "batch_id": cells[0], "start": start, "end": end, "input_kind": cells[2],
196
+ "range_sha256": cells[3], "status": cells[4],
197
+ "parent": "" if cells[5] == "-" else cells[5],
198
+ "cache": "" if cells[6] == "-" else cells[6],
199
+ }
200
+ elif section == "stages" and cells and cells[0] not in {"阶段", "---"} and len(cells) >= 3:
201
+ state["stages"][cells[0]] = {"status": cells[1], "output": "" if cells[2] == "-" else cells[2]}
202
+ return state, raw
203
+
204
+
205
+ def final_status(stages: Dict[str, Dict[str, str]]) -> str:
206
+ """Value for the ``最终状态`` line that session hooks read (Stage 3-6 rows)."""
207
+ statuses = [stages.get(stage, {}).get("status") for stage in FINAL_STAGES]
208
+ if all(status in {"completed", "completed_with_errors"} for status in statuses):
209
+ return "completed_with_errors" if "completed_with_errors" in statuses else "completed"
210
+ return "pending"
211
+
212
+
213
+ def render_state(state: Dict[str, Any], newline: str, final_line: bool = True) -> str:
214
+ lines = [STATE_START, "## 长篇拆文运行状态", ""]
215
+ if final_line and state["stages"]:
216
+ lines.extend(["- 最终状态:%s" % final_status(state["stages"]), ""])
217
+ lines.extend(["### 批次状态",
218
+ "| 批次ID | 章节范围 | 输入 | 原文范围hash | 状态 | 父批次 | 缓存 |",
219
+ "|---|---|---|---|---|---|---|"])
220
+ batches = list(state["batches"].values())
221
+ batches.sort(key=lambda row: (row["start"], row["end"], row["batch_id"]))
222
+ for row in batches:
223
+ lines.append("| %s | %s-%s | %s | %s | %s | %s | %s |" % (
224
+ row["batch_id"], row["start"], row["end"], row["input_kind"],
225
+ row["range_sha256"], row["status"], row.get("parent") or "-", row.get("cache") or "-"))
226
+ lines.extend(["", "### 阶段状态", "| 阶段 | 状态 | 产物 |", "|---|---|---|"])
227
+ for stage in sorted(state["stages"]):
228
+ row = state["stages"][stage]
229
+ lines.append("| %s | %s | %s |" % (stage, row["status"], row.get("output") or "-"))
230
+ lines.append(STATE_END)
231
+ return newline.join(lines)
232
+
233
+
234
+ def write_state(progress: Path, state: Dict[str, Any]) -> bool:
235
+ raw = progress.read_bytes() if progress.is_file() else b""
236
+ text, bom, newline = decode_progress(raw)
237
+ pattern = re.compile(re.escape(STATE_START) + r".*?" + re.escape(STATE_END), re.DOTALL)
238
+ # Hooks read the first 最终状态 in the file. A legacy project keeps its own
239
+ # line as that single value; the runtime only promotes it once Stage 3-6
240
+ # are all complete and never demotes it.
241
+ existing = pattern.search(text)
242
+ head = text[:existing.start()] if existing else text
243
+ legacy = LEGACY_FINAL_RE.search(head)
244
+ status = final_status(state["stages"])
245
+ if legacy and status != "pending" and legacy.group(2) != status:
246
+ head = head[:legacy.start(2)] + status + head[legacy.end(2):]
247
+ text = head + (text[existing.start():] if existing else "")
248
+ block = render_state(state, newline, final_line=legacy is None)
249
+ if pattern.search(text):
250
+ updated = pattern.sub(lambda _: block, text, count=1)
251
+ else:
252
+ if not text:
253
+ text = "# 深度拆解进度" + newline + "- schema_version: 2" + newline
254
+ separator = "" if text.endswith(newline + newline) else (newline if text.endswith(newline) else newline + newline)
255
+ updated = text + separator + block + newline
256
+ data = ((codecs.BOM_UTF8 if bom else b"") + updated.encode("utf-8"))
257
+ if data == raw:
258
+ return False
259
+ atomic_write(progress, data)
260
+ return True
261
+
262
+
263
+ def summary_path(root: Path, chapter: int) -> Path:
264
+ return root / "章节" / ("第%s章_摘要.md" % chapter)
265
+
266
+
267
+ def cache_complete(path: Path) -> bool:
268
+ try:
269
+ text = normalized(path.read_text(encoding="utf-8-sig"))
270
+ except (OSError, UnicodeError):
271
+ return False
272
+ return text.rstrip().endswith(CACHE_END) and text.count(MODEL_START) == 1 and text.count(MODEL_END) == 1
273
+
274
+
275
+ def cache_path(root: Path, batch_id: str) -> Path:
276
+ return root / "_analysis_cache" / ("批次-%s.md" % batch_id)
277
+
278
+
279
+ def completed_batch(root: Path, row: Dict[str, Any], rows: Optional[Sequence[Dict[str, Any]]]) -> bool:
280
+ if row.get("status") not in {"completed", "success"} or not all(summary_path(root, chapter).is_file() for chapter in range(row["start"], row["end"] + 1)):
281
+ return False
282
+ path = root / row.get("cache", "") if row.get("cache") else cache_path(root, row["batch_id"])
283
+ if not cache_complete(path):
284
+ return False
285
+ try:
286
+ metadata = parse_cache(path)
287
+ except (OSError, UnicodeError, RunError):
288
+ return False
289
+ if (metadata.get("batch_id"), metadata.get("start"), metadata.get("end")) != (
290
+ row["batch_id"], row["start"], row["end"]
291
+ ):
292
+ return False
293
+ if metadata.get("range_sha256") != row.get("range_sha256"):
294
+ return False
295
+ if row["input_kind"] == "raw-original":
296
+ return rows is not None and row.get("range_sha256") == range_sha256(rows, row["start"], row["end"])
297
+ return True
298
+
299
+
300
+ def compact_ranges(chapters: Iterable[int]) -> List[Tuple[int, int]]:
301
+ values = sorted(set(chapters))
302
+ if not values:
303
+ return []
304
+ result = []
305
+ start = previous = values[0]
306
+ for value in values[1:]:
307
+ if value == previous + 1:
308
+ previous = value
309
+ else:
310
+ result.append((start, previous))
311
+ start = previous = value
312
+ result.append((start, previous))
313
+ return result
314
+
315
+
316
+ def chunk_range(start: int, end: int, index_by_chapter: Optional[Dict[int, Dict[str, Any]]]) -> List[Tuple[int, int]]:
317
+ result = []
318
+ current_start = start
319
+ count = 0
320
+ chars = 0
321
+ previous = start - 1
322
+ for chapter in range(start, end + 1):
323
+ chapter_chars = index_by_chapter[chapter]["char_count"] if index_by_chapter else 0
324
+ if count and (count + 1 > MAX_CHAPTERS or chars + chapter_chars > MAX_CHARS):
325
+ result.append((current_start, previous))
326
+ current_start = chapter
327
+ count = 0
328
+ chars = 0
329
+ count += 1
330
+ chars += chapter_chars
331
+ previous = chapter
332
+ result.append((current_start, previous))
333
+ return result
334
+
335
+
336
+ def invalid_batch_targets(root: Path, rows: Optional[Sequence[Dict[str, Any]]],
337
+ state: Dict[str, Any]) -> Tuple[Set[int], Set[int]]:
338
+ """Chapters of recorded batches that no longer verify (cache lost or source changed)."""
339
+ raw = set()
340
+ reuse = set()
341
+ for batch in state["batches"].values():
342
+ chapters = set(range(batch["start"], batch["end"] + 1))
343
+ # Split children stay owed until committed, even when summaries exist.
344
+ pending_child = batch.get("parent") and batch.get("status") == "planned"
345
+ if not pending_child and (
346
+ batch.get("status") not in {"completed", "success"} or completed_batch(root, batch, rows)
347
+ ):
348
+ continue
349
+ if batch.get("input_kind") == "raw-original":
350
+ raw.update(chapters)
351
+ elif batch.get("input_kind") == "existing-results":
352
+ reuse.update(chapters)
353
+ return raw, reuse
354
+
355
+
356
+ def add_recoverable_caches(root: Path, state: Dict[str, Any],
357
+ rows: Optional[Sequence[Dict[str, Any]]]) -> Tuple[List[Dict[str, Any]], Set[int]]:
358
+ """Use complete caches in this read-only plan without persisting recovery."""
359
+ cache_dir = root / "_analysis_cache"
360
+ recoverable = []
361
+ covered = set() # type: Set[int]
362
+ for path in sorted(cache_dir.glob("批次-*.md")) if cache_dir.is_dir() else []:
363
+ try:
364
+ metadata = parse_cache(path)
365
+ batch_id = metadata["batch_id"]
366
+ input_kind, start, end = parse_batch_id(batch_id)
367
+ if metadata["input_kind"] != input_kind:
368
+ continue
369
+ current_hash = metadata["range_sha256"]
370
+ if input_kind == "raw-original":
371
+ if rows is None or current_hash != range_sha256(rows, start, end):
372
+ continue
373
+ records = parse_model_output(metadata["model_output"], start, end, input_kind)
374
+ missing = [chapter for chapter in range(start, end + 1) if not summary_path(root, chapter).is_file()]
375
+ if missing and not all(chapter in records for chapter in missing):
376
+ continue
377
+ covered.update(range(start, end + 1))
378
+ current = state["batches"].get(batch_id)
379
+ needs_state_repair = current is None or not completed_batch(root, current, rows)
380
+ if missing or needs_state_repair:
381
+ recoverable.append({"batch_id": batch_id, "cache": path.relative_to(root).as_posix(),
382
+ "missing_summary_chapters": missing,
383
+ "state_repair_required": needs_state_repair})
384
+ if missing:
385
+ continue
386
+ recovered = {
387
+ "batch_id": batch_id, "start": start, "end": end, "input_kind": input_kind,
388
+ "range_sha256": current_hash, "status": "completed", "parent": "",
389
+ "cache": path.relative_to(root).as_posix(),
390
+ }
391
+ if current is None or not completed_batch(root, current, rows):
392
+ state["batches"][batch_id] = recovered
393
+ except (OSError, UnicodeError, RunError, ValueError):
394
+ continue
395
+ return recoverable, covered
396
+
397
+
398
+ def plan_command(args: argparse.Namespace) -> Dict[str, Any]:
399
+ root = require_root(args.root)
400
+ report = inspect(root, args.expected_chapters)
401
+ state, _ = load_state(root / "_progress.md")
402
+ index_rows = None # type: Optional[List[Dict[str, Any]]]
403
+ index_path = args.index.resolve() if args.index else root / "chapter_index.csv"
404
+ if index_path.is_file():
405
+ index_rows = read_index(root, index_path)
406
+ recoverable_caches, cache_covered = add_recoverable_caches(root, state, index_rows)
407
+ expected = report.get("expected_chapters") or (len(index_rows) if index_rows else None)
408
+ if not expected:
409
+ raise RunError("expected_chapters_unknown", "build the chapter index or pass --expected-chapters")
410
+ semantic = set(report["completed_semantic_chapters"])
411
+ summaries = set(report["completed_summary_chapters"])
412
+ all_chapters = set(range(1, int(expected) + 1))
413
+ invalid_raw, invalid_reuse = invalid_batch_targets(root, index_rows, state)
414
+
415
+ raw_targets = (all_chapters - semantic) | invalid_raw
416
+ if args.intent == "enhance":
417
+ reuse_targets = (semantic - raw_targets) | invalid_reuse
418
+ else:
419
+ reuse_targets = ((semantic - summaries) | invalid_reuse) - raw_targets
420
+ raw_targets -= cache_covered
421
+ reuse_targets -= cache_covered
422
+ if raw_targets and index_rows is None:
423
+ raise RunError("chapter_index_required", "raw-original work remains")
424
+ if index_rows is not None:
425
+ absent = raw_targets - {row["chapter"] for row in index_rows}
426
+ if absent:
427
+ raise RunError("range_not_in_index", ",".join(map(str, sorted(absent))))
428
+ mapping_blocked = set(report.get("chapter_mapping_blocked_chapters", []))
429
+ if mapping_blocked & (raw_targets | reuse_targets):
430
+ raise RunError(
431
+ "chapter_mapping_ambiguous",
432
+ "; ".join(report.get("chapter_mapping_conflicts", [])) or "legacy chapter identity is unresolved",
433
+ report.get("chapter_mapping_author_message"),
434
+ )
435
+
436
+ selected = [] # type: List[Dict[str, Any]]
437
+ index_by_chapter = {row["chapter"]: row for row in index_rows or []}
438
+ preferred_paths = report["chapter_sources"]["preferred_paths"]
439
+ for kind, targets in (("raw-original", raw_targets), ("existing-results", reuse_targets)):
440
+ for range_start, range_end in compact_ranges(targets):
441
+ for start, end in chunk_range(range_start, range_end, index_by_chapter if kind == "raw-original" else None):
442
+ selected.append({"input_kind": kind, "start": start, "end": end})
443
+
444
+ # Persisted split children replace any recombined parent range on later plans.
445
+ split_children = [
446
+ row for row in state["batches"].values()
447
+ if row.get("parent") and row.get("status") != "superseded"
448
+ ]
449
+ for child in split_children:
450
+ for item in list(selected):
451
+ if item["input_kind"] == child["input_kind"] and item["start"] <= child["start"] and child["end"] <= item["end"]:
452
+ selected.remove(item)
453
+ if item["start"] < child["start"]:
454
+ selected.append({"input_kind": item["input_kind"], "start": item["start"], "end": child["start"] - 1})
455
+ selected.append({"input_kind": child["input_kind"], "start": child["start"], "end": child["end"]})
456
+ if child["end"] < item["end"]:
457
+ selected.append({"input_kind": item["input_kind"], "start": child["end"] + 1, "end": item["end"]})
458
+ break
459
+
460
+ batches = []
461
+ raw_reads = 0
462
+ result_reads = 0
463
+ for item in sorted(selected, key=lambda value: (value["start"], value["end"], value["input_kind"])):
464
+ prefix = "RAW" if item["input_kind"] == "raw-original" else "REUSE"
465
+ batch_id = "%s-%s-%s" % (prefix, item["start"], item["end"])
466
+ current_range_hash = range_sha256(index_rows, item["start"], item["end"]) if item["input_kind"] == "raw-original" else "existing-results"
467
+ prior = state["batches"].get(batch_id)
468
+ if prior and completed_batch(root, prior, index_rows):
469
+ continue
470
+ if item["input_kind"] == "raw-original":
471
+ sources = [index_by_chapter[chapter]["source_locator"] for chapter in range(item["start"], item["end"] + 1)]
472
+ raw_reads += len(sources)
473
+ else:
474
+ sources = sorted(set(preferred_paths.get(str(chapter), "") for chapter in range(item["start"], item["end"] + 1)) - {""})
475
+ result_reads += len(sources)
476
+ batches.append({
477
+ "batch_id": batch_id, "chapter_range": [item["start"], item["end"]],
478
+ "input_kind": item["input_kind"], "range_sha256": current_range_hash,
479
+ "source_files": sources, "cache": "_analysis_cache/批次-%s.md" % batch_id,
480
+ "chapter_chars": [index_by_chapter[chapter]["char_count"] for chapter in range(item["start"], item["end"] + 1)]
481
+ if item["input_kind"] == "raw-original" else [],
482
+ })
483
+ required_stages = list(report.get("stage_repairs", []))
484
+ if batches or recoverable_caches:
485
+ required_stages = sorted(set(required_stages + ["stage2"]))
486
+ summary_gaps = sorted(all_chapters - summaries)
487
+ payload = {
488
+ "ok": True, "root": str(root), "intent": args.intent,
489
+ "classification": report["classification"], "recommended_path": report["recommended_path"],
490
+ "mixed_sources": report["mixed_sources"], "batches": batches,
491
+ "remaining_batches": len(batches),
492
+ "recoverable_caches": recoverable_caches,
493
+ "summary_gaps": summary_gaps,
494
+ "read_counts": {"raw_chapters": raw_reads, "existing_result_files": result_reads},
495
+ "required_stages": required_stages,
496
+ "state_written": False,
497
+ }
498
+ if args.next is not None:
499
+ if args.next < 0:
500
+ raise RunError("invalid_next", str(args.next))
501
+ # Dispatch one batch at a time without re-reading the whole remaining plan;
502
+ # counts above still describe everything that is left.
503
+ payload["batches"] = batches[:args.next]
504
+ payload["summary_gaps"] = ["%s-%s" % pair if pair[0] != pair[1] else str(pair[0])
505
+ for pair in compact_ranges(summary_gaps)]
506
+ return payload
507
+
508
+
509
+ def parse_batch_id(batch_id: str) -> Tuple[str, int, int]:
510
+ match = BATCH_ID_RE.fullmatch(batch_id)
511
+ if not match:
512
+ raise RunError("invalid_batch_id", batch_id)
513
+ start, end = int(match.group(2)), int(match.group(3))
514
+ if start < 1 or end < start:
515
+ raise RunError("invalid_batch_range", batch_id)
516
+ return ("raw-original" if match.group(1) == "RAW" else "existing-results", start, end)
517
+
518
+
519
+ def compact_field(body: str, name: str) -> str:
520
+ match = re.search(r"(?m)^\*\*%s\*\*\s*[::]\s*(\S.*)$" % re.escape(name), body)
521
+ if not match:
522
+ raise RunError("chapter_schema_incomplete", "missing field: %s" % name)
523
+ value = match.group(1).strip()
524
+ if "{" in value or "}" in value:
525
+ raise RunError("template_placeholder", name)
526
+ return value
527
+
528
+
529
+ def map_enum(value: str, allowed: Sequence[str], aliases: Dict[str, str], default: str = "其他") -> str:
530
+ """Map one enum value; with several listed values, the first that maps wins."""
531
+ for token in [value.strip()] + re.split(r"[、/,,;;\s]+", value.strip()):
532
+ if token in allowed:
533
+ return token
534
+ if token in aliases:
535
+ return aliases[token]
536
+ for item in allowed:
537
+ if item != "其他" and token.startswith(item):
538
+ return item
539
+ return default
540
+
541
+
542
+ def parse_plot_points(body: str, chapter: int, minimum: int) -> List[Dict[str, Any]]:
543
+ """Parse the repeated ``P{n}`` blocks that follow ``**情节点**:``."""
544
+ match = re.search(r"(?m)^\*\*情节点\*\*\s*[::]\s*$", body)
545
+ if not match:
546
+ raise RunError("chapter_schema_incomplete", "chapter %s missing field: 情节点" % chapter)
547
+ points = [] # type: List[Dict[str, Any]]
548
+ for line in body[match.end():].split("\n"):
549
+ stripped = line.strip()
550
+ header = POINT_HEADER_RE.match(stripped)
551
+ if header:
552
+ segments = [item.strip() for item in re.split(r"[||]", header.group(3))]
553
+ # Tolerate the tag written inline at the end of the P line.
554
+ inline = POINT_TAG_RE.match(" | ".join(segments[-2:])) if len(segments) >= 3 else None
555
+ rest = segments[1:-2] if inline else segments[1:]
556
+ if not segments[0].startswith("类型") or not rest or not rest[0]:
557
+ raise RunError("plot_point_invalid", "chapter %s P%s needs 类型 and 白描" % (chapter, header.group(1)))
558
+ points.append({"number": int(header.group(1)), "title": header.group(2).strip(),
559
+ "type": segments[0][2:].lstrip(" ::"), "rest": rest, "quote": [],
560
+ "tag": (inline.group(1), inline.group(2)) if inline else None})
561
+ continue
562
+ if not stripped or stripped == "---":
563
+ continue
564
+ if not points or points[-1]["tag"] is not None:
565
+ raise RunError("plot_point_invalid", "chapter %s: unexpected line %r" % (chapter, stripped[:40]))
566
+ tag = POINT_TAG_RE.match(stripped)
567
+ if tag:
568
+ points[-1]["tag"] = (tag.group(1), tag.group(2))
569
+ else:
570
+ points[-1]["quote"].append(stripped)
571
+ if [point["number"] for point in points] != list(range(1, len(points) + 1)):
572
+ raise RunError("plot_point_invalid", "chapter %s: points must be numbered P1..Pn" % chapter)
573
+ if not minimum <= len(points) <= MAX_PLOT_POINTS:
574
+ raise RunError("plot_point_count", "chapter %s has %s points; expected %s-%s"
575
+ % (chapter, len(points), minimum, MAX_PLOT_POINTS))
576
+ for point in points:
577
+ if point["tag"] is None:
578
+ raise RunError("plot_point_invalid", "chapter %s P%s missing 主题标签/基调 line" % (chapter, point["number"]))
579
+ text = "%s %s %s" % (point["title"], " ".join(point["rest"]), " ".join(point["quote"]))
580
+ if "{" in text or "}" in text:
581
+ raise RunError("template_placeholder", "chapter %s P%s" % (chapter, point["number"]))
582
+ return points
583
+
584
+
585
+ def parse_model_output(text: str, start: int, end: int, input_kind: str) -> Dict[int, Dict[str, Any]]:
586
+ text = normalized(text)
587
+ if "BATCH_ERROR:" in text:
588
+ raise RunError("extractor_reported_error", "model returned BATCH_ERROR")
589
+ if any(marker in text for marker in (CACHE_START, CACHE_END, MODEL_START, MODEL_END)):
590
+ raise RunError("reserved_marker_in_output", "model output contains a runtime cache marker")
591
+ expected_tokens = [token for chapter in range(start, end + 1) for token in (("START", chapter), ("END", chapter))]
592
+ actual_tokens = [(match.group(1), int(match.group(2))) for match in CHAPTER_TOKEN_RE.finditer(text)]
593
+ if input_kind == "raw-original" and actual_tokens != expected_tokens:
594
+ raise RunError("chapter_marker_mismatch", "expected %s; received %s" % (expected_tokens, actual_tokens))
595
+ if actual_tokens and actual_tokens != expected_tokens:
596
+ raise RunError("chapter_marker_mismatch", "expected %s; received %s" % (expected_tokens, actual_tokens))
597
+ # Projections rebuilt from old results may hold fewer beats than a fresh reading.
598
+ minimum = MIN_PLOT_POINTS if input_kind == "raw-original" else 1
599
+ records = {} # type: Dict[int, Dict[str, Any]]
600
+ for match in CHAPTER_BLOCK_RE.finditer(text):
601
+ chapter = int(match.group(1))
602
+ body = match.group(2).strip()
603
+ if not re.search(r"(?m)^##\s+第%s章(?:\s+.*)?$" % chapter, body):
604
+ raise RunError("chapter_schema_incomplete", "chapter %s heading missing" % chapter)
605
+ fields = {name: compact_field(body, name) for name in COMPACT_FIELDS} # type: Dict[str, Any]
606
+ fields["情节点"] = parse_plot_points(body, chapter, minimum)
607
+ records[chapter] = fields
608
+ if input_kind == "raw-original" and set(records) != set(range(start, end + 1)):
609
+ raise RunError("chapter_block_missing", "%s-%s" % (start, end))
610
+ if records and set(records) != set(range(start, end + 1)):
611
+ raise RunError("chapter_block_missing", "partial reuse projection is not allowed")
612
+ if not records and input_kind == "existing-results":
613
+ marker = re.search(r"<!--\s*REUSED_CHAPTERS:(\d+)-(\d+)\s*-->", text)
614
+ if not marker or (int(marker.group(1)), int(marker.group(2))) != (start, end):
615
+ raise RunError("reused_range_mismatch", "%s-%s" % (start, end))
616
+ if text.count("<!-- BATCH_OBSERVATIONS_START -->") != 1 or text.count("<!-- BATCH_OBSERVATIONS_END -->") != 1:
617
+ raise RunError("batch_marker_mismatch", "one complete cross-chapter observation block is required")
618
+ observation_start = text.index("<!-- BATCH_OBSERVATIONS_START -->")
619
+ observation_end = text.index("<!-- BATCH_OBSERVATIONS_END -->")
620
+ last_source_marker = max((match.end() for match in CHAPTER_TOKEN_RE.finditer(text)), default=0)
621
+ reused_marker = re.search(r"<!--\s*REUSED_CHAPTERS:\d+-\d+\s*-->", text)
622
+ if reused_marker:
623
+ last_source_marker = max(last_source_marker, reused_marker.end())
624
+ if observation_start < last_source_marker or observation_end <= observation_start:
625
+ raise RunError("batch_marker_order", "cross-chapter observations must follow source coverage")
626
+ return records
627
+
628
+
629
+ def render_plot_point(point: Dict[str, Any]) -> str:
630
+ point_type = map_enum(point["type"], POINT_TYPES, POINT_ALIASES, default="行动")
631
+ theme = map_enum(point["tag"][0], THEMES, THEME_ALIASES)
632
+ tone = map_enum(point["tag"][1], TONES, TONE_ALIASES)
633
+ lines = ["P%s **%s**:%s" % (point["number"], point["title"], " | ".join(["类型" + point_type] + point["rest"]))]
634
+ lines.extend(point["quote"])
635
+ lines.extend(["", "主题标签%s | 基调:%s" % (theme, tone)])
636
+ return "\n".join(lines)
637
+
638
+
639
+ def render_summary(chapter: int, fields: Dict[str, Any], source_kind: str,
640
+ chapter_hash: str, batch_id: str) -> bytes:
641
+ text = (
642
+ "<!-- story-long-analyze:projection runtime=single-state-v1 source=%s chapter_sha256=%s batch=%s -->\n"
643
+ "## 第%s章\n\n**概要**:%s\n\n**关键事件**:\n1. %s\n\n"
644
+ "**因果**:%s\n\n**局面结果**:%s\n\n**涉及**:%s\n\n"
645
+ "**信息变化**:%s\n\n**状态变化**:%s\n\n**三维节奏**:%s\n\n"
646
+ "**章尾钩子**:%s\n\n**证据**:%s\n\n**情节点**:\n\n%s\n"
647
+ ) % (
648
+ source_kind, chapter_hash, batch_id, chapter, fields["概要"], fields["关键行动"],
649
+ fields["因果"], fields["局面结果"], fields["涉及人物"], fields["信息变化"],
650
+ fields["状态变化"], fields["三维节奏"], fields["章尾钩子"], fields["证据"],
651
+ "\n\n---\n\n".join(render_plot_point(point) for point in fields["情节点"]),
652
+ )
653
+ return text.encode("utf-8")
654
+
655
+
656
+ def render_cache(batch_id: str, start: int, end: int, input_kind: str,
657
+ range_hash: str, source_files: Sequence[str], model_output: str,
658
+ projection_schema: str = "compact-v3") -> bytes:
659
+ text = (
660
+ "%s\n# 批次 %s\n- batch_id: %s\n- chapters: %s-%s\n- input_kind: %s\n"
661
+ "- range_sha256: %s\n- projection_schema: %s\n"
662
+ "- source_files: %s\n%s\n%s\n%s\n%s\n"
663
+ ) % (CACHE_START, batch_id, batch_id, start, end, input_kind, range_hash, projection_schema,
664
+ json.dumps(list(source_files), ensure_ascii=False), MODEL_START,
665
+ normalized(model_output).strip(), MODEL_END, CACHE_END)
666
+ return text.encode("utf-8")
667
+
668
+
669
+ def parse_cache(path: Path) -> Dict[str, Any]:
670
+ text = normalized(path.read_text(encoding="utf-8-sig"))
671
+ if not text.rstrip().endswith(CACHE_END):
672
+ raise RunError("cache_incomplete", str(path))
673
+ metadata = {} # type: Dict[str, Any]
674
+ for key in ("batch_id", "chapters", "input_kind", "range_sha256", "projection_schema"):
675
+ match = re.search(r"(?m)^- %s:\s*(.+)$" % key, text)
676
+ if not match:
677
+ raise RunError("cache_invalid", "missing %s" % key)
678
+ metadata[key] = match.group(1).strip()
679
+ try:
680
+ start, end = [int(value) for value in metadata["chapters"].split("-", 1)]
681
+ except ValueError:
682
+ raise RunError("cache_invalid", "chapters: %s" % metadata["chapters"])
683
+ model_match = re.search(re.escape(MODEL_START) + r"\n(.*?)\n" + re.escape(MODEL_END), text, re.DOTALL)
684
+ if not model_match:
685
+ raise RunError("cache_invalid", "model output markers missing")
686
+ metadata.update({"start": start, "end": end, "model_output": model_match.group(1)})
687
+ return metadata
688
+
689
+
690
+ def source_files_from_args(values: Optional[Sequence[str]]) -> List[str]:
691
+ return list(values or [])
692
+
693
+
694
+ def commit_from_cache(root: Path, metadata: Dict[str, Any], cache: Path,
695
+ index_rows: Optional[Sequence[Dict[str, Any]]]) -> Dict[str, Any]:
696
+ batch_id = metadata["batch_id"]
697
+ input_kind, id_start, id_end = parse_batch_id(batch_id)
698
+ start, end = metadata["start"], metadata["end"]
699
+ if (start, end, input_kind) != (id_start, id_end, metadata["input_kind"]):
700
+ raise RunError("cache_invalid", "batch metadata mismatch")
701
+ if input_kind == "raw-original":
702
+ if index_rows is None:
703
+ raise RunError("chapter_index_required", batch_id)
704
+ current_hash = range_sha256(index_rows, start, end)
705
+ if metadata["range_sha256"] != current_hash:
706
+ raise RunError("range_hash_mismatch", batch_id)
707
+ else:
708
+ current_hash = metadata["range_sha256"]
709
+ records = parse_model_output(metadata["model_output"], start, end, input_kind)
710
+ hashes = {row["chapter"]: row["chapter_sha256"] for row in index_rows or []}
711
+ created = []
712
+ kept = []
713
+ for chapter, fields in sorted(records.items()):
714
+ path = summary_path(root, chapter)
715
+ if path.exists():
716
+ kept.append(chapter)
717
+ continue
718
+ chapter_hash = hashes.get(chapter, "0" * 64)
719
+ atomic_write(path, render_summary(chapter, fields, input_kind, chapter_hash, batch_id))
720
+ created.append(path.relative_to(root).as_posix())
721
+ failure_after = os.environ.get("STORY_ANALYZE_FAIL_AFTER_SUMMARIES")
722
+ if failure_after and len(created) >= int(failure_after):
723
+ raise OSError("injected_failure_after_%s_summaries" % failure_after)
724
+ missing = [chapter for chapter in range(start, end + 1) if not summary_path(root, chapter).is_file()]
725
+ if missing:
726
+ raise RunError("summary_projection_missing", ",".join(map(str, missing)))
727
+ state, _ = load_state(root / "_progress.md")
728
+ state["batches"][batch_id] = {
729
+ "batch_id": batch_id, "start": start, "end": end, "input_kind": input_kind,
730
+ "range_sha256": current_hash, "status": "completed", "parent": "",
731
+ "cache": cache.relative_to(root).as_posix(),
732
+ }
733
+ changed = write_state(root / "_progress.md", state)
734
+ return {"batch_id": batch_id, "created_summaries": created,
735
+ "kept_existing_summary_chapters": kept, "progress_updated": changed}
736
+
737
+
738
+ def commit_command(args: argparse.Namespace) -> Dict[str, Any]:
739
+ root = require_root(args.root)
740
+ input_kind, start, end = parse_batch_id(args.batch_id)
741
+ if end - start + 1 > MAX_CHAPTERS:
742
+ raise RunError("batch_too_large", "%s exceeds %s chapters" % (args.batch_id, MAX_CHAPTERS))
743
+ text = args.input.read_text(encoding="utf-8-sig")
744
+ records = parse_model_output(text, start, end, input_kind)
745
+ index_rows = read_index(root, args.index) if input_kind == "raw-original" or (args.index or root / "chapter_index.csv").is_file() else None
746
+ if input_kind == "raw-original" and end > start:
747
+ total_chars = sum(row["char_count"] for row in index_rows if start <= row["chapter"] <= end)
748
+ if total_chars > MAX_CHARS:
749
+ raise RunError("batch_too_large", "%s exceeds %s characters" % (args.batch_id, MAX_CHARS))
750
+ current_hash = range_sha256(index_rows, start, end) if input_kind == "raw-original" else "existing-results"
751
+ if input_kind == "raw-original" and not args.range_sha256:
752
+ raise RunError("range_hash_required", "pass the value printed by plan")
753
+ if args.range_sha256 and args.range_sha256 != current_hash:
754
+ raise RunError("range_hash_mismatch", args.batch_id)
755
+ # Parsing above validates the whole result before the first write.
756
+ path = cache_path(root, args.batch_id)
757
+ data = render_cache(args.batch_id, start, end, input_kind, current_hash,
758
+ source_files_from_args(args.source_file), text)
759
+ if not path.is_file() or path.read_bytes() != data:
760
+ if path.is_file():
761
+ old_data = path.read_bytes()
762
+ history = root / "_analysis_cache" / "legacy" / (
763
+ "%s.%s.md" % (path.stem, sha256(old_data)[:12])
764
+ )
765
+ if not history.exists():
766
+ atomic_write(history, old_data)
767
+ atomic_write(path, data)
768
+ if os.environ.get("STORY_ANALYZE_FAIL_AFTER_CACHE") == "1":
769
+ raise OSError("injected_failure_after_cache")
770
+ metadata = parse_cache(path)
771
+ result = commit_from_cache(root, metadata, path, index_rows)
772
+ result.update({"ok": True, "cache": path.relative_to(root).as_posix(), "validated_chapters": sorted(records)})
773
+ return result
774
+
775
+
776
+ def repair_command(args: argparse.Namespace) -> Dict[str, Any]:
777
+ root = require_root(args.root)
778
+ index_rows = read_index(root, args.index) if (args.index or root / "chapter_index.csv").is_file() else None
779
+ paths = [cache_path(root, args.batch_id)] if args.batch_id else sorted((root / "_analysis_cache").glob("批次-*.md"))
780
+ state, _ = load_state(root / "_progress.md")
781
+ repaired = []
782
+ skipped = []
783
+ errors = []
784
+ for path in paths:
785
+ # A cache replaced by a split or by other committed batches is history, not a repair target.
786
+ batch_id = path.stem[len("批次-"):]
787
+ if BATCH_ID_RE.fullmatch(batch_id):
788
+ _, start, end = parse_batch_id(batch_id)
789
+ covered = set()
790
+ for other in state["batches"].values():
791
+ if other["batch_id"] != batch_id and completed_batch(root, other, index_rows):
792
+ covered.update(range(other["start"], other["end"] + 1))
793
+ if state["batches"].get(batch_id, {}).get("status") == "superseded" or set(range(start, end + 1)) <= covered:
794
+ skipped.append({"cache": path.relative_to(root).as_posix(), "reason": "superseded"})
795
+ continue
796
+ try:
797
+ metadata = parse_cache(path)
798
+ repaired.append(commit_from_cache(root, metadata, path, index_rows))
799
+ except (OSError, UnicodeError, RunError) as exc:
800
+ errors.append({"cache": path.as_posix(), "error": getattr(exc, "code", str(exc)), "detail": str(exc)})
801
+ return {"ok": not errors, "repaired": repaired, "skipped": skipped, "errors": errors}
802
+
803
+
804
+ def split_command(args: argparse.Namespace) -> Dict[str, Any]:
805
+ root = require_root(args.root)
806
+ input_kind, start, end = parse_batch_id(args.batch_id)
807
+ if start >= end:
808
+ raise RunError("batch_not_splittable", args.batch_id)
809
+ index_rows = read_index(root, args.index) if input_kind == "raw-original" else None
810
+ split_at = args.at
811
+ if split_at is None:
812
+ if index_rows:
813
+ counts = {row["chapter"]: row["char_count"] for row in index_rows}
814
+ total = sum(counts[chapter] for chapter in range(start, end + 1))
815
+ running = 0
816
+ split_at = start
817
+ for chapter in range(start, end):
818
+ running += counts[chapter]
819
+ split_at = chapter
820
+ if running >= total / 2:
821
+ break
822
+ else:
823
+ split_at = (start + end) // 2
824
+ if split_at < start or split_at >= end:
825
+ raise RunError("invalid_split_point", str(split_at))
826
+ state, _ = load_state(root / "_progress.md")
827
+ parent_hash = range_sha256(index_rows, start, end) if index_rows else "existing-results"
828
+ state["batches"][args.batch_id] = {
829
+ "batch_id": args.batch_id, "start": start, "end": end, "input_kind": input_kind,
830
+ "range_sha256": parent_hash, "status": "superseded", "parent": "", "cache": "",
831
+ }
832
+ prefix = "RAW" if input_kind == "raw-original" else "REUSE"
833
+ children = []
834
+ for child_start, child_end in ((start, split_at), (split_at + 1, end)):
835
+ child_id = "%s-%s-%s" % (prefix, child_start, child_end)
836
+ child_hash = range_sha256(index_rows, child_start, child_end) if index_rows else "existing-results"
837
+ existing = state["batches"].get(child_id)
838
+ if not existing or existing.get("status") not in {"completed", "success"}:
839
+ state["batches"][child_id] = {
840
+ "batch_id": child_id, "start": child_start, "end": child_end,
841
+ "input_kind": input_kind, "range_sha256": child_hash, "status": "planned",
842
+ "parent": args.batch_id, "cache": "",
843
+ }
844
+ children.append(child_id)
845
+ write_state(root / "_progress.md", state)
846
+ return {"ok": True, "parent": args.batch_id, "status": "superseded", "children": children}
847
+
848
+
849
+ def stage_required_outputs(root: Path, stage: str) -> List[str]:
850
+ if stage == "stage1":
851
+ expected = inspect(root, None).get("expected_chapters") or 3
852
+ required = ["章节/第%s章_深度拆解.md" % chapter for chapter in range(1, min(3, int(expected)) + 1)]
853
+ required.append("快速预览.md")
854
+ return required
855
+ if stage == "stage2":
856
+ expected = inspect(root, None).get("expected_chapters")
857
+ if not expected:
858
+ raise RunError("expected_chapters_unknown", "Stage 2 completion requires a known chapter count")
859
+ return ["章节/第%s章_摘要.md" % chapter for chapter in range(1, int(expected) + 1)]
860
+ if stage == "stage3":
861
+ return ["剧情/情绪模块.md", "剧情/节奏.md"]
862
+ if stage == "stage4":
863
+ character_files = sorted(path for path in (root / "角色").glob("*.md") if path.is_file() and path.stat().st_size)
864
+ setting_files = sorted(path for path in (root / "设定").rglob("*.md") if path.is_file() and path.stat().st_size)
865
+ missing = []
866
+ if not character_files:
867
+ missing.append("角色/*.md")
868
+ if not setting_files:
869
+ missing.append("设定/**/*.md")
870
+ if missing:
871
+ raise RunError("stage_output_missing", ",".join(missing))
872
+ return [character_files[0].relative_to(root).as_posix(), setting_files[0].relative_to(root).as_posix()]
873
+ if stage == "stage5":
874
+ return ["拆文报告.md"]
875
+ if stage == "stage6":
876
+ return ["文风.md"]
877
+ raise RunError("stage_unknown", stage)
878
+
879
+
880
+ def mark_stage_command(args: argparse.Namespace) -> Dict[str, Any]:
881
+ root = require_root(args.root)
882
+ if args.prepare:
883
+ if args.stage != "stage5":
884
+ raise RunError("prepare_stage_unsupported", args.stage)
885
+ report = root / "拆文报告.md"
886
+ backup = root / "_analysis_cache" / "legacy" / "拆文报告.md"
887
+ created = False
888
+ selected_backup = backup
889
+ if report.is_file():
890
+ report_data = report.read_bytes()
891
+ if backup.exists() and backup.read_bytes() != report_data:
892
+ selected_backup = backup.with_name("拆文报告.%s.md" % sha256(report_data)[:12])
893
+ if not selected_backup.exists():
894
+ atomic_write(selected_backup, report_data)
895
+ created = True
896
+ return {"ok": True, "stage": args.stage, "prepared": True,
897
+ "legacy_report_backup": selected_backup.relative_to(root).as_posix() if selected_backup.is_file() else None,
898
+ "backup_created": created, "progress_updated": False}
899
+ required_outputs = stage_required_outputs(root, args.stage) if args.status == "completed" else []
900
+ missing_required = [
901
+ name for name in required_outputs
902
+ if not (root / name).is_file() or (root / name).stat().st_size == 0
903
+ ]
904
+ if missing_required:
905
+ raise RunError("stage_output_missing", ",".join(missing_required))
906
+ output = args.output
907
+ relative_output = ""
908
+ if output:
909
+ path = output if output.is_absolute() else root / output
910
+ if args.status == "completed" and (not path.is_file() or path.stat().st_size == 0):
911
+ raise RunError("stage_output_missing", str(path))
912
+ try:
913
+ relative_output = path.resolve().relative_to(root).as_posix()
914
+ except ValueError:
915
+ raise RunError("stage_output_outside_root", str(path))
916
+ elif required_outputs:
917
+ relative_output = ";".join(required_outputs)
918
+ state, _ = load_state(root / "_progress.md")
919
+ state["stages"][args.stage] = {"status": args.status, "output": relative_output}
920
+ changed = write_state(root / "_progress.md", state)
921
+ return {"ok": True, "stage": args.stage, "status": args.status, "progress_updated": changed}
922
+
923
+
924
+ OBS_START = "<!-- BATCH_OBSERVATIONS_START -->"
925
+ OBS_END = "<!-- BATCH_OBSERVATIONS_END -->"
926
+ # Labels as projected by render_summary (legacy summaries use the same labels).
927
+ SUMMARY_FIELDS = ("概要", "关键事件", "因果", "局面结果", "涉及", "信息变化", "状态变化",
928
+ "三维节奏", "章尾钩子", "证据")
929
+
930
+
931
+ def parse_chapter_window(value: Optional[str]) -> Optional[Tuple[int, int]]:
932
+ if not value:
933
+ return None
934
+ match = re.fullmatch(r"(\d+)(?:-(\d+))?", value.strip())
935
+ if not match:
936
+ raise RunError("invalid_chapter_window", value)
937
+ start = int(match.group(1))
938
+ end = int(match.group(2) or start)
939
+ if start < 1 or end < start:
940
+ raise RunError("invalid_chapter_window", value)
941
+ return start, end
942
+
943
+
944
+ def digest_caches(root: Path) -> List[Tuple[int, int, str, Path]]:
945
+ """Complete caches of committed batches, falling back to every complete cache on disk."""
946
+ state, _ = load_state(root / "_progress.md")
947
+ chosen = {} # type: Dict[str, Tuple[int, int, str, Path]]
948
+ for row in state["batches"].values():
949
+ if row.get("status") not in {"completed", "success"}:
950
+ continue
951
+ path = root / row["cache"] if row.get("cache") else cache_path(root, row["batch_id"])
952
+ if cache_complete(path):
953
+ chosen[row["batch_id"]] = (row["start"], row["end"], row["batch_id"], path)
954
+ if not chosen:
955
+ for path in sorted((root / "_analysis_cache").glob("批次-*.md")):
956
+ try:
957
+ _, start, end = parse_batch_id(path.stem[len("批次-"):])
958
+ except RunError:
959
+ continue
960
+ if cache_complete(path):
961
+ chosen[path.stem] = (start, end, path.stem[len("批次-"):], path)
962
+ return sorted(chosen.values())
963
+
964
+
965
+ def summary_field(text: str, name: str) -> str:
966
+ if name == "关键事件":
967
+ match = re.search(r"(?ms)^\*\*关键事件\*\*\s*[::]\s*\n(.*?)(?=^\*\*|\Z)", text)
968
+ return " ".join(line.strip() for line in match.group(1).splitlines() if line.strip()) if match else ""
969
+ match = re.search(r"(?m)^\*\*%s\*\*\s*[::]\s*(\S.*)$" % re.escape(name), text)
970
+ return match.group(1).strip() if match else ""
971
+
972
+
973
+ def summary_points(text: str, mode: str) -> List[str]:
974
+ lines = text.split("\n")
975
+ points = [] # type: List[str]
976
+ for index, line in enumerate(lines):
977
+ header = POINT_HEADER_RE.match(line.strip())
978
+ if not header:
979
+ continue
980
+ segments = [item.strip() for item in re.split(r"[||]", header.group(3))]
981
+ tone = ""
982
+ for follow in lines[index + 1:index + 8]:
983
+ tag = POINT_TAG_RE.match(follow.strip())
984
+ if tag:
985
+ tone = tag.group(2)
986
+ break
987
+ if POINT_HEADER_RE.match(follow.strip()):
988
+ break
989
+ point_type = segments[0][2:].lstrip(" ::") if segments[0].startswith("类型") else segments[0]
990
+ if mode == "brief":
991
+ points.append("P%s %s|%s|%s" % (header.group(1), header.group(2).strip(), point_type, tone or "—"))
992
+ else:
993
+ points.append("%s |基调:%s" % (line.strip(), tone or "—"))
994
+ return points
995
+
996
+
997
+ def digest_command(args: argparse.Namespace) -> Dict[str, Any]:
998
+ root = require_root(args.root)
999
+ window = parse_chapter_window(args.chapters)
1000
+ parts = [] # type: List[str]
1001
+ if args.part == "observations":
1002
+ for start, end, batch_id, path in digest_caches(root):
1003
+ if window and (end < window[0] or start > window[1]):
1004
+ continue
1005
+ model = parse_cache(path)["model_output"]
1006
+ if OBS_START not in model or OBS_END not in model:
1007
+ continue
1008
+ body = model.split(OBS_START, 1)[1].split(OBS_END, 1)[0].strip()
1009
+ body = re.sub(r"(?m)^##\s*跨章观察\s*\n", "", body).strip()
1010
+ parts.append("## %s(第%s-%s章)\n\n%s" % (batch_id, start, end, body))
1011
+ else:
1012
+ fields = [item.strip() for item in (args.fields or "").split(",") if item.strip()]
1013
+ unknown = [item for item in fields if item not in SUMMARY_FIELDS]
1014
+ if unknown:
1015
+ raise RunError("unknown_summary_field", ",".join(unknown))
1016
+ if not fields and args.points == "none":
1017
+ raise RunError("digest_empty_request", "pass --fields and/or --points")
1018
+ chapters = sorted(
1019
+ int(match.group(1)) for match in
1020
+ (re.fullmatch(r"第(\d+)章_摘要", path.stem) for path in (root / "章节").glob("第*章_摘要.md"))
1021
+ if match
1022
+ )
1023
+ for chapter in chapters:
1024
+ if window and not window[0] <= chapter <= window[1]:
1025
+ continue
1026
+ text = normalized(summary_path(root, chapter).read_text(encoding="utf-8-sig"))
1027
+ lines = ["### 第%s章" % chapter]
1028
+ lines.extend("- %s:%s" % (name, summary_field(text, name) or "未提供") for name in fields)
1029
+ if args.points != "none":
1030
+ lines.extend(summary_points(text, args.points))
1031
+ parts.append("\n".join(lines))
1032
+ if not parts:
1033
+ raise RunError("digest_nothing_found", "no committed batch caches or summaries in range")
1034
+ return {"ok": True, "text": "\n\n".join(parts) + "\n"}
1035
+
1036
+
1037
+ def build_parser() -> argparse.ArgumentParser:
1038
+ parser = argparse.ArgumentParser(description=__doc__)
1039
+ sub = parser.add_subparsers(dest="command", required=True)
1040
+ plan = sub.add_parser("plan", help="print a read-only in-memory plan")
1041
+ plan.add_argument("--root", required=True, type=Path)
1042
+ plan.add_argument("--index", type=Path)
1043
+ plan.add_argument("--expected-chapters", type=int)
1044
+ plan.add_argument("--intent", choices=("continue", "enhance"), default="continue")
1045
+ plan.add_argument("--next", type=int, metavar="N",
1046
+ help="only print the first N batches (0 = counts only); remaining_batches stays total")
1047
+ plan.set_defaults(handler=plan_command)
1048
+ commit = sub.add_parser("commit", help="validate and atomically commit one batch")
1049
+ commit.add_argument("--root", required=True, type=Path)
1050
+ commit.add_argument("--input", required=True, type=Path)
1051
+ commit.add_argument("--batch-id", required=True)
1052
+ commit.add_argument("--range-sha256")
1053
+ commit.add_argument("--index", type=Path)
1054
+ commit.add_argument("--source-file", action="append")
1055
+ commit.set_defaults(handler=commit_command)
1056
+ split = sub.add_parser("split", help="persist a failed batch split")
1057
+ split.add_argument("--root", required=True, type=Path)
1058
+ split.add_argument("--batch-id", required=True)
1059
+ split.add_argument("--at", type=int)
1060
+ split.add_argument("--index", type=Path)
1061
+ split.set_defaults(handler=split_command)
1062
+ repair = sub.add_parser("repair-progress", help="recover missing projections/state from complete caches")
1063
+ repair.add_argument("--root", required=True, type=Path)
1064
+ repair.add_argument("--batch-id")
1065
+ repair.add_argument("--index", type=Path)
1066
+ repair.set_defaults(handler=repair_command)
1067
+ stage = sub.add_parser("mark-stage", help="mark a stage after its output is present")
1068
+ stage.add_argument("--root", required=True, type=Path)
1069
+ stage.add_argument("--stage", required=True, choices=("stage1", "stage2", "stage3", "stage4", "stage5", "stage6"))
1070
+ stage.add_argument("--status", choices=("completed", "completed_with_errors"), default="completed")
1071
+ stage.add_argument("--output", type=Path)
1072
+ stage.add_argument("--prepare", action="store_true", help="before Stage 5, preserve the existing report")
1073
+ stage.set_defaults(handler=mark_stage_command)
1074
+ digest = sub.add_parser("digest", help="print Stage 3-5 reading material without whole-cache reads")
1075
+ digest.add_argument("--root", required=True, type=Path)
1076
+ digest.add_argument("--part", required=True, choices=("observations", "chapters"))
1077
+ digest.add_argument("--chapters", help="chapter window such as 1-40")
1078
+ digest.add_argument("--fields", help="comma-separated summary fields, e.g. 三维节奏,涉及,状态变化")
1079
+ digest.add_argument("--points", choices=("none", "brief", "full"), default="none")
1080
+ digest.set_defaults(handler=digest_command)
1081
+ return parser
1082
+
1083
+
1084
+ def main() -> int:
1085
+ for stream in (sys.stdout, sys.stderr):
1086
+ if hasattr(stream, "reconfigure"):
1087
+ stream.reconfigure(encoding="utf-8")
1088
+ parser = build_parser()
1089
+ args = parser.parse_args()
1090
+ try:
1091
+ payload = args.handler(args)
1092
+ if args.command == "digest":
1093
+ sys.stdout.write(payload["text"])
1094
+ return 0
1095
+ print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
1096
+ return 0 if payload.get("ok", True) else 2
1097
+ except (OSError, UnicodeError, RunError) as exc:
1098
+ payload = {"ok": False, "error": getattr(exc, "code", "io_error"), "detail": str(exc)}
1099
+ if getattr(exc, "author_message", None):
1100
+ payload["author_message"] = exc.author_message
1101
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1102
+ return 2
1103
+
1104
+
1105
+ if __name__ == "__main__":
1106
+ raise SystemExit(main())