@oh-story/dsh 0.1.9 → 0.1.11

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