@zwbigi/ink-xy 0.1.2 → 0.1.4

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 (598) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +2 -2
  3. package/.next/build-manifest.json +2 -2
  4. package/.next/server/app/_global-error.html +1 -1
  5. package/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  7. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/.next/server/app/_not-found.html +1 -1
  13. package/.next/server/app/_not-found.rsc +1 -1
  14. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  15. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  16. package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  17. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  18. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  20. package/.next/server/app/api/inkos/route.js.nft.json +1 -1
  21. package/.next/server/app/api/skills/install/route.js.nft.json +1 -1
  22. package/.next/server/app/api/skills/search/route.js.nft.json +1 -1
  23. package/.next/server/app/index.html +1 -1
  24. package/.next/server/app/index.rsc +1 -1
  25. package/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  26. package/.next/server/app/index.segments/_full.segment.rsc +1 -1
  27. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  28. package/.next/server/app/index.segments/_index.segment.rsc +1 -1
  29. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  30. package/.next/server/app-paths-manifest.json +2 -2
  31. package/.next/server/chunks/162.js +1 -1
  32. package/.next/server/middleware-build-manifest.js +1 -1
  33. package/.next/server/pages/404.html +1 -1
  34. package/.next/server/pages/500.html +1 -1
  35. package/.next/trace +4 -4
  36. package/.next/trace-build +1 -1
  37. package/bin/pi-web.js +4 -1
  38. package/inkos/.env.example +20 -0
  39. package/inkos/.node-version +1 -0
  40. package/inkos/.nvmrc +1 -0
  41. package/inkos/CHANGELOG.md +787 -0
  42. package/inkos/CONTRIBUTING.md +89 -0
  43. package/inkos/LICENSE +661 -0
  44. package/inkos/README.en.md +483 -0
  45. package/inkos/README.ja.md +461 -0
  46. package/inkos/README.md +272 -0
  47. package/inkos/assets/15qun.jpg +0 -0
  48. package/inkos/assets/41777702961_.pic.jpg +0 -0
  49. package/inkos/assets/inkos-short-demo-cover.png +0 -0
  50. package/inkos/assets/inkos-text.svg +40 -0
  51. package/inkos/assets/logo.svg +47 -0
  52. package/inkos/assets/screenshot-chapters.png +0 -0
  53. package/inkos/assets/screenshot-pipeline.png +0 -0
  54. package/inkos/assets/screenshot-state.png +0 -0
  55. package/inkos/assets/screenshot-terminal.png +0 -0
  56. package/inkos/assets/wechat-group-v8.jpg +0 -0
  57. package/inkos/package.json +42 -0
  58. package/inkos/packages/cli/package.json +74 -0
  59. package/inkos/packages/cli/src/__tests__/analytics.test.ts +154 -0
  60. package/inkos/packages/cli/src/__tests__/cli-integration.test.ts +1031 -0
  61. package/inkos/packages/cli/src/__tests__/daemon.test.ts +93 -0
  62. package/inkos/packages/cli/src/__tests__/doctor.test.ts +36 -0
  63. package/inkos/packages/cli/src/__tests__/interact-command.test.ts +142 -0
  64. package/inkos/packages/cli/src/__tests__/interaction-tools.test.ts +107 -0
  65. package/inkos/packages/cli/src/__tests__/llm-overrides.test.ts +25 -0
  66. package/inkos/packages/cli/src/__tests__/localization.test.ts +121 -0
  67. package/inkos/packages/cli/src/__tests__/progress-text.test.ts +92 -0
  68. package/inkos/packages/cli/src/__tests__/project-bootstrap.test.ts +71 -0
  69. package/inkos/packages/cli/src/__tests__/publish-package.test.ts +272 -0
  70. package/inkos/packages/cli/src/__tests__/revision-command.test.ts +82 -0
  71. package/inkos/packages/cli/src/__tests__/runtime-requirements.test.ts +89 -0
  72. package/inkos/packages/cli/src/__tests__/short-fiction-command.test.ts +48 -0
  73. package/inkos/packages/cli/src/__tests__/studio-runtime.test.ts +142 -0
  74. package/inkos/packages/cli/src/__tests__/studio.test.ts +87 -0
  75. package/inkos/packages/cli/src/__tests__/tui-activity-state.test.ts +20 -0
  76. package/inkos/packages/cli/src/__tests__/tui-agent-session.test.ts +213 -0
  77. package/inkos/packages/cli/src/__tests__/tui-chat-depth.test.ts +27 -0
  78. package/inkos/packages/cli/src/__tests__/tui-chat-draft.test.ts +44 -0
  79. package/inkos/packages/cli/src/__tests__/tui-command.test.ts +86 -0
  80. package/inkos/packages/cli/src/__tests__/tui-composer-caret.test.ts +46 -0
  81. package/inkos/packages/cli/src/__tests__/tui-composer-display.test.ts +40 -0
  82. package/inkos/packages/cli/src/__tests__/tui-dashboard.test.tsx +219 -0
  83. package/inkos/packages/cli/src/__tests__/tui-effects-i18n.test.ts +29 -0
  84. package/inkos/packages/cli/src/__tests__/tui-i18n.test.ts +22 -0
  85. package/inkos/packages/cli/src/__tests__/tui-input-chrome.test.ts +10 -0
  86. package/inkos/packages/cli/src/__tests__/tui-input-history.test.ts +40 -0
  87. package/inkos/packages/cli/src/__tests__/tui-layout.test.ts +55 -0
  88. package/inkos/packages/cli/src/__tests__/tui-local-commands.test.ts +47 -0
  89. package/inkos/packages/cli/src/__tests__/tui-session-store.test.ts +81 -0
  90. package/inkos/packages/cli/src/__tests__/tui-setup-i18n.test.ts +31 -0
  91. package/inkos/packages/cli/src/__tests__/tui-slash-autocomplete.test.ts +33 -0
  92. package/inkos/packages/cli/src/commands/agent.ts +65 -0
  93. package/inkos/packages/cli/src/commands/analytics.ts +77 -0
  94. package/inkos/packages/cli/src/commands/audit.ts +52 -0
  95. package/inkos/packages/cli/src/commands/book.ts +260 -0
  96. package/inkos/packages/cli/src/commands/compose.ts +50 -0
  97. package/inkos/packages/cli/src/commands/config.ts +328 -0
  98. package/inkos/packages/cli/src/commands/consolidate.ts +50 -0
  99. package/inkos/packages/cli/src/commands/daemon.ts +121 -0
  100. package/inkos/packages/cli/src/commands/detect.ts +125 -0
  101. package/inkos/packages/cli/src/commands/doctor.ts +391 -0
  102. package/inkos/packages/cli/src/commands/draft.ts +43 -0
  103. package/inkos/packages/cli/src/commands/eval.ts +217 -0
  104. package/inkos/packages/cli/src/commands/export.ts +45 -0
  105. package/inkos/packages/cli/src/commands/fanfic.ts +183 -0
  106. package/inkos/packages/cli/src/commands/genre.ts +160 -0
  107. package/inkos/packages/cli/src/commands/import.ts +158 -0
  108. package/inkos/packages/cli/src/commands/init.ts +47 -0
  109. package/inkos/packages/cli/src/commands/interact.ts +109 -0
  110. package/inkos/packages/cli/src/commands/plan.ts +54 -0
  111. package/inkos/packages/cli/src/commands/radar.ts +60 -0
  112. package/inkos/packages/cli/src/commands/review.ts +253 -0
  113. package/inkos/packages/cli/src/commands/revise.ts +58 -0
  114. package/inkos/packages/cli/src/commands/short-fiction.ts +294 -0
  115. package/inkos/packages/cli/src/commands/status.ts +138 -0
  116. package/inkos/packages/cli/src/commands/studio.ts +194 -0
  117. package/inkos/packages/cli/src/commands/style.ts +99 -0
  118. package/inkos/packages/cli/src/commands/tui.ts +18 -0
  119. package/inkos/packages/cli/src/commands/update.ts +45 -0
  120. package/inkos/packages/cli/src/commands/write.ts +324 -0
  121. package/inkos/packages/cli/src/index.ts +5 -0
  122. package/inkos/packages/cli/src/interaction/tools.ts +49 -0
  123. package/inkos/packages/cli/src/localization.ts +215 -0
  124. package/inkos/packages/cli/src/program.ts +106 -0
  125. package/inkos/packages/cli/src/progress-text.ts +85 -0
  126. package/inkos/packages/cli/src/project-bootstrap.ts +175 -0
  127. package/inkos/packages/cli/src/runtime-requirements.ts +135 -0
  128. package/inkos/packages/cli/src/tui/__tests__/markdown.test.ts +64 -0
  129. package/inkos/packages/cli/src/tui/activity-state.ts +41 -0
  130. package/inkos/packages/cli/src/tui/agent-input.ts +264 -0
  131. package/inkos/packages/cli/src/tui/ansi.ts +72 -0
  132. package/inkos/packages/cli/src/tui/app.ts +130 -0
  133. package/inkos/packages/cli/src/tui/chat-depth.ts +22 -0
  134. package/inkos/packages/cli/src/tui/chat-draft.ts +42 -0
  135. package/inkos/packages/cli/src/tui/composer-caret.ts +22 -0
  136. package/inkos/packages/cli/src/tui/composer-display.ts +26 -0
  137. package/inkos/packages/cli/src/tui/dashboard-model.ts +164 -0
  138. package/inkos/packages/cli/src/tui/dashboard.tsx +544 -0
  139. package/inkos/packages/cli/src/tui/effects.ts +542 -0
  140. package/inkos/packages/cli/src/tui/i18n.ts +278 -0
  141. package/inkos/packages/cli/src/tui/input-history.ts +69 -0
  142. package/inkos/packages/cli/src/tui/local-commands.ts +55 -0
  143. package/inkos/packages/cli/src/tui/markdown.ts +64 -0
  144. package/inkos/packages/cli/src/tui/session-store.ts +6 -0
  145. package/inkos/packages/cli/src/tui/setup.ts +397 -0
  146. package/inkos/packages/cli/src/tui/slash-autocomplete.ts +62 -0
  147. package/inkos/packages/cli/src/tui/theme.ts +17 -0
  148. package/inkos/packages/cli/src/utils.ts +222 -0
  149. package/inkos/packages/cli/tsconfig.json +9 -0
  150. package/inkos/packages/core/genres/cozy.md +43 -0
  151. package/inkos/packages/core/genres/cultivation.md +42 -0
  152. package/inkos/packages/core/genres/dungeon-core.md +40 -0
  153. package/inkos/packages/core/genres/horror.md +51 -0
  154. package/inkos/packages/core/genres/isekai.md +43 -0
  155. package/inkos/packages/core/genres/litrpg.md +43 -0
  156. package/inkos/packages/core/genres/other.md +24 -0
  157. package/inkos/packages/core/genres/progression.md +41 -0
  158. package/inkos/packages/core/genres/romantasy.md +45 -0
  159. package/inkos/packages/core/genres/sci-fi.md +42 -0
  160. package/inkos/packages/core/genres/system-apocalypse.md +40 -0
  161. package/inkos/packages/core/genres/tower-climber.md +41 -0
  162. package/inkos/packages/core/genres/urban.md +53 -0
  163. package/inkos/packages/core/genres/xianxia.md +46 -0
  164. package/inkos/packages/core/genres/xuanhuan.md +64 -0
  165. package/inkos/packages/core/package.json +61 -0
  166. package/inkos/packages/core/src/__tests__/agent-max-tokens-policy.test.ts +29 -0
  167. package/inkos/packages/core/src/__tests__/agent-session.test.ts +866 -0
  168. package/inkos/packages/core/src/__tests__/agent-system-prompt.test.ts +167 -0
  169. package/inkos/packages/core/src/__tests__/agent-tools-params.test.ts +197 -0
  170. package/inkos/packages/core/src/__tests__/agent-tools.test.ts +421 -0
  171. package/inkos/packages/core/src/__tests__/ai-tells.test.ts +90 -0
  172. package/inkos/packages/core/src/__tests__/architect-phase5-consolidated.test.ts +445 -0
  173. package/inkos/packages/core/src/__tests__/architect-phase5.test.ts +455 -0
  174. package/inkos/packages/core/src/__tests__/architect-phase7.test.ts +210 -0
  175. package/inkos/packages/core/src/__tests__/architect.test.ts +859 -0
  176. package/inkos/packages/core/src/__tests__/audit-parse.test.ts +78 -0
  177. package/inkos/packages/core/src/__tests__/book-id.test.ts +26 -0
  178. package/inkos/packages/core/src/__tests__/book-session-store.test.ts +447 -0
  179. package/inkos/packages/core/src/__tests__/book-session.test.ts +113 -0
  180. package/inkos/packages/core/src/__tests__/chapter-analyzer.test.ts +574 -0
  181. package/inkos/packages/core/src/__tests__/chapter-memo-parser.test.ts +247 -0
  182. package/inkos/packages/core/src/__tests__/chapter-persistence.test.ts +198 -0
  183. package/inkos/packages/core/src/__tests__/chapter-review-cycle.test.ts +294 -0
  184. package/inkos/packages/core/src/__tests__/chapter-splitter.test.ts +156 -0
  185. package/inkos/packages/core/src/__tests__/chapter-state-recovery.test.ts +235 -0
  186. package/inkos/packages/core/src/__tests__/chapter-truth-validation.test.ts +253 -0
  187. package/inkos/packages/core/src/__tests__/composer.test.ts +627 -0
  188. package/inkos/packages/core/src/__tests__/config-loader.test.ts +325 -0
  189. package/inkos/packages/core/src/__tests__/config-migration.test.ts +102 -0
  190. package/inkos/packages/core/src/__tests__/consolidator.test.ts +32 -0
  191. package/inkos/packages/core/src/__tests__/context-filter.test.ts +60 -0
  192. package/inkos/packages/core/src/__tests__/context-transform.test.ts +108 -0
  193. package/inkos/packages/core/src/__tests__/continuity.test.ts +391 -0
  194. package/inkos/packages/core/src/__tests__/detection-insights.test.ts +59 -0
  195. package/inkos/packages/core/src/__tests__/detector.test.ts +86 -0
  196. package/inkos/packages/core/src/__tests__/draft-directive-parser.test.ts +386 -0
  197. package/inkos/packages/core/src/__tests__/edit-controller.test.ts +190 -0
  198. package/inkos/packages/core/src/__tests__/effective-llm-config.test.ts +486 -0
  199. package/inkos/packages/core/src/__tests__/fanfic-dimensions.test.ts +58 -0
  200. package/inkos/packages/core/src/__tests__/fanfic-models.test.ts +69 -0
  201. package/inkos/packages/core/src/__tests__/governed-working-set.test.ts +155 -0
  202. package/inkos/packages/core/src/__tests__/hook-arbiter.test.ts +124 -0
  203. package/inkos/packages/core/src/__tests__/hook-governance.test.ts +228 -0
  204. package/inkos/packages/core/src/__tests__/hook-health.test.ts +166 -0
  205. package/inkos/packages/core/src/__tests__/hook-ledger-validator.test.ts +236 -0
  206. package/inkos/packages/core/src/__tests__/hook-promotion.test.ts +192 -0
  207. package/inkos/packages/core/src/__tests__/hook-stale-detection.test.ts +136 -0
  208. package/inkos/packages/core/src/__tests__/index-notify-lazy.test.ts +20 -0
  209. package/inkos/packages/core/src/__tests__/interaction-chat-tokens.test.ts +170 -0
  210. package/inkos/packages/core/src/__tests__/interaction-models.test.ts +155 -0
  211. package/inkos/packages/core/src/__tests__/interaction-nl-router.test.ts +223 -0
  212. package/inkos/packages/core/src/__tests__/interaction-runtime.test.ts +633 -0
  213. package/inkos/packages/core/src/__tests__/interaction-tools.test.ts +343 -0
  214. package/inkos/packages/core/src/__tests__/length-metrics.test.ts +82 -0
  215. package/inkos/packages/core/src/__tests__/length-normalizer.test.ts +331 -0
  216. package/inkos/packages/core/src/__tests__/list-models.test.ts +109 -0
  217. package/inkos/packages/core/src/__tests__/llm-env.test.ts +31 -0
  218. package/inkos/packages/core/src/__tests__/logger.test.ts +175 -0
  219. package/inkos/packages/core/src/__tests__/long-span-fatigue.test.ts +160 -0
  220. package/inkos/packages/core/src/__tests__/memory-retrieval.test.ts +1303 -0
  221. package/inkos/packages/core/src/__tests__/models.test.ts +918 -0
  222. package/inkos/packages/core/src/__tests__/outline-paths.test.ts +97 -0
  223. package/inkos/packages/core/src/__tests__/path-safety.test.ts +22 -0
  224. package/inkos/packages/core/src/__tests__/persisted-governed-plan.test.ts +134 -0
  225. package/inkos/packages/core/src/__tests__/phase5-cleanup.test.ts +393 -0
  226. package/inkos/packages/core/src/__tests__/phase5-hotfix.test.ts +288 -0
  227. package/inkos/packages/core/src/__tests__/phase7-hotfix.test.ts +614 -0
  228. package/inkos/packages/core/src/__tests__/pipeline-agent.test.ts +354 -0
  229. package/inkos/packages/core/src/__tests__/pipeline-runner-memory-sync.test.ts +317 -0
  230. package/inkos/packages/core/src/__tests__/pipeline-runner.test.ts +5200 -0
  231. package/inkos/packages/core/src/__tests__/planner-context.test.ts +137 -0
  232. package/inkos/packages/core/src/__tests__/planner-prompts-ratio.test.ts +11 -0
  233. package/inkos/packages/core/src/__tests__/planner-prompts.test.ts +171 -0
  234. package/inkos/packages/core/src/__tests__/planner.test.ts +362 -0
  235. package/inkos/packages/core/src/__tests__/planning-materials.test.ts +90 -0
  236. package/inkos/packages/core/src/__tests__/polisher.test.ts +189 -0
  237. package/inkos/packages/core/src/__tests__/post-write-validator.test.ts +291 -0
  238. package/inkos/packages/core/src/__tests__/probe.test.ts +77 -0
  239. package/inkos/packages/core/src/__tests__/project-interaction.test.ts +241 -0
  240. package/inkos/packages/core/src/__tests__/provider.test.ts +953 -0
  241. package/inkos/packages/core/src/__tests__/providers-group.test.ts +34 -0
  242. package/inkos/packages/core/src/__tests__/providers-lookup.test.ts +81 -0
  243. package/inkos/packages/core/src/__tests__/providers-schema.test.ts +158 -0
  244. package/inkos/packages/core/src/__tests__/proxy-fetch.test.ts +75 -0
  245. package/inkos/packages/core/src/__tests__/revise-foundation.test.ts +514 -0
  246. package/inkos/packages/core/src/__tests__/reviser.test.ts +859 -0
  247. package/inkos/packages/core/src/__tests__/runtime-state-store.test.ts +388 -0
  248. package/inkos/packages/core/src/__tests__/scheduler.test.ts +123 -0
  249. package/inkos/packages/core/src/__tests__/secrets-migration.test.ts +71 -0
  250. package/inkos/packages/core/src/__tests__/secrets.test.ts +95 -0
  251. package/inkos/packages/core/src/__tests__/sensitive-words.test.ts +88 -0
  252. package/inkos/packages/core/src/__tests__/service-presets-regression.test.ts +73 -0
  253. package/inkos/packages/core/src/__tests__/service-resolver-regression.test.ts +75 -0
  254. package/inkos/packages/core/src/__tests__/service-resolver.test.ts +228 -0
  255. package/inkos/packages/core/src/__tests__/session-transcript-restore.test.ts +1311 -0
  256. package/inkos/packages/core/src/__tests__/session-transcript.test.ts +195 -0
  257. package/inkos/packages/core/src/__tests__/settler-delta-parser.test.ts +133 -0
  258. package/inkos/packages/core/src/__tests__/short-fiction-public.test.ts +241 -0
  259. package/inkos/packages/core/src/__tests__/spot-fix-patches.test.ts +104 -0
  260. package/inkos/packages/core/src/__tests__/state-manager.test.ts +1298 -0
  261. package/inkos/packages/core/src/__tests__/state-projections.test.ts +130 -0
  262. package/inkos/packages/core/src/__tests__/state-reducer.test.ts +372 -0
  263. package/inkos/packages/core/src/__tests__/state-validator-agent.test.ts +165 -0
  264. package/inkos/packages/core/src/__tests__/state-validator.test.ts +122 -0
  265. package/inkos/packages/core/src/__tests__/style-analyzer.test.ts +61 -0
  266. package/inkos/packages/core/src/__tests__/temperature-constraints.test.ts +57 -0
  267. package/inkos/packages/core/src/__tests__/v13-hotfix-round4.test.ts +343 -0
  268. package/inkos/packages/core/src/__tests__/verify-service.test.ts +77 -0
  269. package/inkos/packages/core/src/__tests__/webhook.test.ts +91 -0
  270. package/inkos/packages/core/src/__tests__/writer-parser.test.ts +348 -0
  271. package/inkos/packages/core/src/__tests__/writer-prompts.test.ts +269 -0
  272. package/inkos/packages/core/src/__tests__/writer.test.ts +1360 -0
  273. package/inkos/packages/core/src/agent/agent-session.ts +737 -0
  274. package/inkos/packages/core/src/agent/agent-system-prompt.ts +199 -0
  275. package/inkos/packages/core/src/agent/agent-tools.ts +835 -0
  276. package/inkos/packages/core/src/agent/context-transform.ts +85 -0
  277. package/inkos/packages/core/src/agent/index.ts +14 -0
  278. package/inkos/packages/core/src/agents/ai-tells.ts +161 -0
  279. package/inkos/packages/core/src/agents/architect.ts +1291 -0
  280. package/inkos/packages/core/src/agents/base.ts +100 -0
  281. package/inkos/packages/core/src/agents/chapter-analyzer.ts +634 -0
  282. package/inkos/packages/core/src/agents/composer.ts +469 -0
  283. package/inkos/packages/core/src/agents/consolidator.ts +218 -0
  284. package/inkos/packages/core/src/agents/continuity.ts +824 -0
  285. package/inkos/packages/core/src/agents/detection-insights.ts +72 -0
  286. package/inkos/packages/core/src/agents/detector.ts +224 -0
  287. package/inkos/packages/core/src/agents/en-prompt-sections.ts +129 -0
  288. package/inkos/packages/core/src/agents/fanfic-canon-importer.ts +146 -0
  289. package/inkos/packages/core/src/agents/fanfic-dimensions.ts +87 -0
  290. package/inkos/packages/core/src/agents/fanfic-prompt-sections.ts +109 -0
  291. package/inkos/packages/core/src/agents/foundation-reviewer.ts +204 -0
  292. package/inkos/packages/core/src/agents/length-normalizer.ts +218 -0
  293. package/inkos/packages/core/src/agents/observer-prompts.ts +127 -0
  294. package/inkos/packages/core/src/agents/planner-context.ts +297 -0
  295. package/inkos/packages/core/src/agents/planner-prompts.ts +404 -0
  296. package/inkos/packages/core/src/agents/planner.ts +783 -0
  297. package/inkos/packages/core/src/agents/polisher.ts +153 -0
  298. package/inkos/packages/core/src/agents/post-write-validator.ts +873 -0
  299. package/inkos/packages/core/src/agents/radar-source.ts +123 -0
  300. package/inkos/packages/core/src/agents/radar.ts +120 -0
  301. package/inkos/packages/core/src/agents/reviser.ts +701 -0
  302. package/inkos/packages/core/src/agents/rules-reader.ts +155 -0
  303. package/inkos/packages/core/src/agents/sensitive-words.ts +142 -0
  304. package/inkos/packages/core/src/agents/settler-delta-parser.ts +53 -0
  305. package/inkos/packages/core/src/agents/settler-parser.ts +38 -0
  306. package/inkos/packages/core/src/agents/settler-prompts.ts +230 -0
  307. package/inkos/packages/core/src/agents/short-fiction.ts +429 -0
  308. package/inkos/packages/core/src/agents/state-validator.ts +322 -0
  309. package/inkos/packages/core/src/agents/style-analyzer.ts +93 -0
  310. package/inkos/packages/core/src/agents/writer-parser.ts +178 -0
  311. package/inkos/packages/core/src/agents/writer-prompts.ts +899 -0
  312. package/inkos/packages/core/src/agents/writer.ts +1450 -0
  313. package/inkos/packages/core/src/index.ts +392 -0
  314. package/inkos/packages/core/src/interaction/book-session-store.ts +226 -0
  315. package/inkos/packages/core/src/interaction/draft-directive-parser.ts +266 -0
  316. package/inkos/packages/core/src/interaction/edit-controller.ts +270 -0
  317. package/inkos/packages/core/src/interaction/events.ts +41 -0
  318. package/inkos/packages/core/src/interaction/export-artifact.ts +151 -0
  319. package/inkos/packages/core/src/interaction/intents.ts +63 -0
  320. package/inkos/packages/core/src/interaction/modes.ts +13 -0
  321. package/inkos/packages/core/src/interaction/nl-router.ts +258 -0
  322. package/inkos/packages/core/src/interaction/project-control.ts +150 -0
  323. package/inkos/packages/core/src/interaction/project-session-store.ts +81 -0
  324. package/inkos/packages/core/src/interaction/project-tools.ts +704 -0
  325. package/inkos/packages/core/src/interaction/request-router.ts +5 -0
  326. package/inkos/packages/core/src/interaction/runtime.ts +1167 -0
  327. package/inkos/packages/core/src/interaction/session-transcript-legacy.ts +113 -0
  328. package/inkos/packages/core/src/interaction/session-transcript-restore.ts +607 -0
  329. package/inkos/packages/core/src/interaction/session-transcript-schema.ts +76 -0
  330. package/inkos/packages/core/src/interaction/session-transcript.ts +189 -0
  331. package/inkos/packages/core/src/interaction/session.ts +226 -0
  332. package/inkos/packages/core/src/interaction/truth-authority.ts +45 -0
  333. package/inkos/packages/core/src/llm/config-migration.ts +58 -0
  334. package/inkos/packages/core/src/llm/cover-providers.ts +45 -0
  335. package/inkos/packages/core/src/llm/provider.ts +1331 -0
  336. package/inkos/packages/core/src/llm/providers/endpoints/ai360.ts +42 -0
  337. package/inkos/packages/core/src/llm/providers/endpoints/anthropic.ts +82 -0
  338. package/inkos/packages/core/src/llm/providers/endpoints/astronCodingPlan.ts +30 -0
  339. package/inkos/packages/core/src/llm/providers/endpoints/baichuan.ts +28 -0
  340. package/inkos/packages/core/src/llm/providers/endpoints/bailian.ts +65 -0
  341. package/inkos/packages/core/src/llm/providers/endpoints/bailianCodingPlan.ts +30 -0
  342. package/inkos/packages/core/src/llm/providers/endpoints/custom.ts +22 -0
  343. package/inkos/packages/core/src/llm/providers/endpoints/deepseek.ts +35 -0
  344. package/inkos/packages/core/src/llm/providers/endpoints/giteeai.ts +41 -0
  345. package/inkos/packages/core/src/llm/providers/endpoints/githubCopilot.ts +43 -0
  346. package/inkos/packages/core/src/llm/providers/endpoints/glmCodingPlan.ts +28 -0
  347. package/inkos/packages/core/src/llm/providers/endpoints/google.ts +51 -0
  348. package/inkos/packages/core/src/llm/providers/endpoints/hunyuan.ts +42 -0
  349. package/inkos/packages/core/src/llm/providers/endpoints/infiniai.ts +72 -0
  350. package/inkos/packages/core/src/llm/providers/endpoints/internlm.ts +28 -0
  351. package/inkos/packages/core/src/llm/providers/endpoints/kimiCode.ts +23 -0
  352. package/inkos/packages/core/src/llm/providers/endpoints/kimiCodingPlan.ts +24 -0
  353. package/inkos/packages/core/src/llm/providers/endpoints/kkaiapi.ts +56 -0
  354. package/inkos/packages/core/src/llm/providers/endpoints/longcat.ts +25 -0
  355. package/inkos/packages/core/src/llm/providers/endpoints/minimax.ts +39 -0
  356. package/inkos/packages/core/src/llm/providers/endpoints/minimaxCodingPlan.ts +28 -0
  357. package/inkos/packages/core/src/llm/providers/endpoints/mistral.ts +40 -0
  358. package/inkos/packages/core/src/llm/providers/endpoints/modelscope.ts +30 -0
  359. package/inkos/packages/core/src/llm/providers/endpoints/moonshot.ts +39 -0
  360. package/inkos/packages/core/src/llm/providers/endpoints/newapi.ts +21 -0
  361. package/inkos/packages/core/src/llm/providers/endpoints/ollama.ts +73 -0
  362. package/inkos/packages/core/src/llm/providers/endpoints/openai.ts +77 -0
  363. package/inkos/packages/core/src/llm/providers/endpoints/opencodeCodingPlan.ts +30 -0
  364. package/inkos/packages/core/src/llm/providers/endpoints/openrouter.ts +87 -0
  365. package/inkos/packages/core/src/llm/providers/endpoints/ppio.ts +86 -0
  366. package/inkos/packages/core/src/llm/providers/endpoints/qiniu.ts +32 -0
  367. package/inkos/packages/core/src/llm/providers/endpoints/sensenova.ts +45 -0
  368. package/inkos/packages/core/src/llm/providers/endpoints/siliconcloud.ts +126 -0
  369. package/inkos/packages/core/src/llm/providers/endpoints/spark.ts +33 -0
  370. package/inkos/packages/core/src/llm/providers/endpoints/stepfun.ts +35 -0
  371. package/inkos/packages/core/src/llm/providers/endpoints/tencentcloud.ts +25 -0
  372. package/inkos/packages/core/src/llm/providers/endpoints/volcengine.ts +52 -0
  373. package/inkos/packages/core/src/llm/providers/endpoints/volcengineCodingPlan.ts +42 -0
  374. package/inkos/packages/core/src/llm/providers/endpoints/wenxin.ts +106 -0
  375. package/inkos/packages/core/src/llm/providers/endpoints/xai.ts +34 -0
  376. package/inkos/packages/core/src/llm/providers/endpoints/xiaomimimo.ts +26 -0
  377. package/inkos/packages/core/src/llm/providers/endpoints/zeroone.ts +34 -0
  378. package/inkos/packages/core/src/llm/providers/endpoints/zhipu.ts +61 -0
  379. package/inkos/packages/core/src/llm/providers/index.ts +71 -0
  380. package/inkos/packages/core/src/llm/providers/lookup.ts +70 -0
  381. package/inkos/packages/core/src/llm/providers/probe.ts +35 -0
  382. package/inkos/packages/core/src/llm/providers/types.ts +89 -0
  383. package/inkos/packages/core/src/llm/providers/verify.ts +104 -0
  384. package/inkos/packages/core/src/llm/secrets.ts +77 -0
  385. package/inkos/packages/core/src/llm/service-presets.ts +215 -0
  386. package/inkos/packages/core/src/llm/service-resolver.ts +91 -0
  387. package/inkos/packages/core/src/models/book-rules.ts +126 -0
  388. package/inkos/packages/core/src/models/book.ts +70 -0
  389. package/inkos/packages/core/src/models/chapter.ts +42 -0
  390. package/inkos/packages/core/src/models/detection.ts +25 -0
  391. package/inkos/packages/core/src/models/genre-profile.ts +36 -0
  392. package/inkos/packages/core/src/models/input-governance.ts +99 -0
  393. package/inkos/packages/core/src/models/length-governance.ts +46 -0
  394. package/inkos/packages/core/src/models/project.ts +161 -0
  395. package/inkos/packages/core/src/models/runtime-state.ts +144 -0
  396. package/inkos/packages/core/src/models/state.ts +52 -0
  397. package/inkos/packages/core/src/models/style-profile.ts +15 -0
  398. package/inkos/packages/core/src/notify/dispatcher.ts +96 -0
  399. package/inkos/packages/core/src/notify/feishu.ts +34 -0
  400. package/inkos/packages/core/src/notify/telegram.ts +25 -0
  401. package/inkos/packages/core/src/notify/webhook.ts +58 -0
  402. package/inkos/packages/core/src/notify/wechat-work.ts +22 -0
  403. package/inkos/packages/core/src/pipeline/agent.ts +691 -0
  404. package/inkos/packages/core/src/pipeline/chapter-persistence.ts +79 -0
  405. package/inkos/packages/core/src/pipeline/chapter-review-cycle.ts +324 -0
  406. package/inkos/packages/core/src/pipeline/chapter-state-recovery.ts +236 -0
  407. package/inkos/packages/core/src/pipeline/chapter-truth-validation.ts +145 -0
  408. package/inkos/packages/core/src/pipeline/detection-runner.ts +164 -0
  409. package/inkos/packages/core/src/pipeline/persisted-governed-plan.ts +216 -0
  410. package/inkos/packages/core/src/pipeline/runner.ts +3438 -0
  411. package/inkos/packages/core/src/pipeline/scheduler.ts +411 -0
  412. package/inkos/packages/core/src/pipeline/short-fiction-runner.ts +801 -0
  413. package/inkos/packages/core/src/prompts/index.ts +1 -0
  414. package/inkos/packages/core/src/prompts/short-fiction.ts +273 -0
  415. package/inkos/packages/core/src/state/manager.ts +560 -0
  416. package/inkos/packages/core/src/state/memory-db.ts +359 -0
  417. package/inkos/packages/core/src/state/runtime-state-store.ts +164 -0
  418. package/inkos/packages/core/src/state/state-bootstrap.ts +657 -0
  419. package/inkos/packages/core/src/state/state-projections.ts +255 -0
  420. package/inkos/packages/core/src/state/state-reducer.ts +260 -0
  421. package/inkos/packages/core/src/state/state-validator.ts +117 -0
  422. package/inkos/packages/core/src/utils/analytics.ts +92 -0
  423. package/inkos/packages/core/src/utils/book-id.ts +31 -0
  424. package/inkos/packages/core/src/utils/cadence-policy.ts +46 -0
  425. package/inkos/packages/core/src/utils/chapter-cadence.ts +211 -0
  426. package/inkos/packages/core/src/utils/chapter-memo-parser.ts +157 -0
  427. package/inkos/packages/core/src/utils/chapter-splitter.ts +80 -0
  428. package/inkos/packages/core/src/utils/config-loader.ts +29 -0
  429. package/inkos/packages/core/src/utils/context-assembly.ts +98 -0
  430. package/inkos/packages/core/src/utils/context-filter.ts +190 -0
  431. package/inkos/packages/core/src/utils/effective-llm-config.ts +529 -0
  432. package/inkos/packages/core/src/utils/governed-context.ts +101 -0
  433. package/inkos/packages/core/src/utils/governed-working-set.ts +395 -0
  434. package/inkos/packages/core/src/utils/hook-arbiter.ts +332 -0
  435. package/inkos/packages/core/src/utils/hook-governance.ts +199 -0
  436. package/inkos/packages/core/src/utils/hook-health.ts +189 -0
  437. package/inkos/packages/core/src/utils/hook-ledger-validator.ts +277 -0
  438. package/inkos/packages/core/src/utils/hook-lifecycle.ts +224 -0
  439. package/inkos/packages/core/src/utils/hook-policy.ts +115 -0
  440. package/inkos/packages/core/src/utils/hook-promotion.ts +313 -0
  441. package/inkos/packages/core/src/utils/hook-stale-detection.ts +168 -0
  442. package/inkos/packages/core/src/utils/length-metrics.ts +123 -0
  443. package/inkos/packages/core/src/utils/llm-endpoint-auth.ts +40 -0
  444. package/inkos/packages/core/src/utils/llm-env.ts +74 -0
  445. package/inkos/packages/core/src/utils/logger.ts +123 -0
  446. package/inkos/packages/core/src/utils/long-span-fatigue.ts +545 -0
  447. package/inkos/packages/core/src/utils/memory-retrieval.ts +527 -0
  448. package/inkos/packages/core/src/utils/narrative-control.ts +177 -0
  449. package/inkos/packages/core/src/utils/outline-paths.ts +275 -0
  450. package/inkos/packages/core/src/utils/path-safety.ts +11 -0
  451. package/inkos/packages/core/src/utils/planning-materials.ts +185 -0
  452. package/inkos/packages/core/src/utils/pov-filter.ts +149 -0
  453. package/inkos/packages/core/src/utils/proxy-fetch.ts +44 -0
  454. package/inkos/packages/core/src/utils/runtime-writer.ts +41 -0
  455. package/inkos/packages/core/src/utils/spot-fix-patches.ts +189 -0
  456. package/inkos/packages/core/src/utils/story-markdown.ts +346 -0
  457. package/inkos/packages/core/src/utils/web-search.ts +82 -0
  458. package/inkos/packages/core/src/utils/writing-methodology.ts +164 -0
  459. package/inkos/packages/core/tsconfig.json +8 -0
  460. package/inkos/packages/core/vitest.config.ts +7 -0
  461. package/inkos/packages/studio/components.json +25 -0
  462. package/inkos/packages/studio/index.html +13 -0
  463. package/inkos/packages/studio/package.json +72 -0
  464. package/inkos/packages/studio/postcss.config.js +3 -0
  465. package/inkos/packages/studio/src/App.test.ts +25 -0
  466. package/inkos/packages/studio/src/App.tsx +280 -0
  467. package/inkos/packages/studio/src/api/__tests__/normalize-base-url.test.ts +40 -0
  468. package/inkos/packages/studio/src/api/book-create.test.ts +104 -0
  469. package/inkos/packages/studio/src/api/book-create.ts +94 -0
  470. package/inkos/packages/studio/src/api/errors.ts +17 -0
  471. package/inkos/packages/studio/src/api/index.ts +30 -0
  472. package/inkos/packages/studio/src/api/lib/run-store.ts +177 -0
  473. package/inkos/packages/studio/src/api/lib/sse.ts +50 -0
  474. package/inkos/packages/studio/src/api/phase5-hotfix.test.ts +335 -0
  475. package/inkos/packages/studio/src/api/safety.ts +6 -0
  476. package/inkos/packages/studio/src/api/server.test.ts +3162 -0
  477. package/inkos/packages/studio/src/api/server.ts +3666 -0
  478. package/inkos/packages/studio/src/api/v13-hotfix-round4.test.ts +226 -0
  479. package/inkos/packages/studio/src/app-state.test.ts +8 -0
  480. package/inkos/packages/studio/src/app-state.ts +1 -0
  481. package/inkos/packages/studio/src/components/ConfirmDialog.tsx +95 -0
  482. package/inkos/packages/studio/src/components/ServiceConfigSourceCard.tsx +139 -0
  483. package/inkos/packages/studio/src/components/ServiceQuickLinks.tsx +65 -0
  484. package/inkos/packages/studio/src/components/Sidebar.tsx +652 -0
  485. package/inkos/packages/studio/src/components/ai-elements/code-block.tsx +562 -0
  486. package/inkos/packages/studio/src/components/ai-elements/confirmation.tsx +174 -0
  487. package/inkos/packages/studio/src/components/ai-elements/message.tsx +360 -0
  488. package/inkos/packages/studio/src/components/ai-elements/prompt-input.tsx +1457 -0
  489. package/inkos/packages/studio/src/components/ai-elements/reasoning.tsx +226 -0
  490. package/inkos/packages/studio/src/components/ai-elements/shimmer.tsx +77 -0
  491. package/inkos/packages/studio/src/components/ai-elements/tool.tsx +173 -0
  492. package/inkos/packages/studio/src/components/chat/BookSidebar.tsx +291 -0
  493. package/inkos/packages/studio/src/components/chat/ChatMessage.tsx +39 -0
  494. package/inkos/packages/studio/src/components/chat/QuickActions.tsx +73 -0
  495. package/inkos/packages/studio/src/components/chat/ToolExecutionSteps.tsx +320 -0
  496. package/inkos/packages/studio/src/components/chat/__tests__/ToolExecutionSteps.test.ts +114 -0
  497. package/inkos/packages/studio/src/components/chat-utils.ts +56 -0
  498. package/inkos/packages/studio/src/components/chatbar-state.test.ts +69 -0
  499. package/inkos/packages/studio/src/components/sidebar/ChaptersSection.tsx +66 -0
  500. package/inkos/packages/studio/src/components/sidebar/CharacterSection.tsx +129 -0
  501. package/inkos/packages/studio/src/components/sidebar/FoundationSection.tsx +61 -0
  502. package/inkos/packages/studio/src/components/sidebar/ProgressSection.tsx +124 -0
  503. package/inkos/packages/studio/src/components/sidebar/SidebarCard.tsx +30 -0
  504. package/inkos/packages/studio/src/components/sidebar/SummarySection.tsx +89 -0
  505. package/inkos/packages/studio/src/components/ui/alert.tsx +76 -0
  506. package/inkos/packages/studio/src/components/ui/badge.tsx +52 -0
  507. package/inkos/packages/studio/src/components/ui/button-group.tsx +87 -0
  508. package/inkos/packages/studio/src/components/ui/button.tsx +58 -0
  509. package/inkos/packages/studio/src/components/ui/collapsible.tsx +19 -0
  510. package/inkos/packages/studio/src/components/ui/command.tsx +194 -0
  511. package/inkos/packages/studio/src/components/ui/dialog.tsx +158 -0
  512. package/inkos/packages/studio/src/components/ui/dropdown-menu.tsx +266 -0
  513. package/inkos/packages/studio/src/components/ui/hover-card.tsx +51 -0
  514. package/inkos/packages/studio/src/components/ui/input-group.tsx +158 -0
  515. package/inkos/packages/studio/src/components/ui/input.tsx +20 -0
  516. package/inkos/packages/studio/src/components/ui/select.tsx +199 -0
  517. package/inkos/packages/studio/src/components/ui/separator.tsx +23 -0
  518. package/inkos/packages/studio/src/components/ui/spinner.tsx +10 -0
  519. package/inkos/packages/studio/src/components/ui/textarea.tsx +18 -0
  520. package/inkos/packages/studio/src/components/ui/tooltip.tsx +66 -0
  521. package/inkos/packages/studio/src/constants/service-groups.ts +29 -0
  522. package/inkos/packages/studio/src/hooks/use-api.test.ts +93 -0
  523. package/inkos/packages/studio/src/hooks/use-api.ts +189 -0
  524. package/inkos/packages/studio/src/hooks/use-book-activity.test.ts +129 -0
  525. package/inkos/packages/studio/src/hooks/use-book-activity.ts +180 -0
  526. package/inkos/packages/studio/src/hooks/use-colors.ts +27 -0
  527. package/inkos/packages/studio/src/hooks/use-hash-route.test.ts +101 -0
  528. package/inkos/packages/studio/src/hooks/use-hash-route.ts +89 -0
  529. package/inkos/packages/studio/src/hooks/use-i18n.ts +289 -0
  530. package/inkos/packages/studio/src/hooks/use-session-events.ts +64 -0
  531. package/inkos/packages/studio/src/hooks/use-sse.test.ts +52 -0
  532. package/inkos/packages/studio/src/hooks/use-sse.ts +92 -0
  533. package/inkos/packages/studio/src/hooks/use-theme.test.ts +31 -0
  534. package/inkos/packages/studio/src/hooks/use-theme.ts +75 -0
  535. package/inkos/packages/studio/src/index.css +323 -0
  536. package/inkos/packages/studio/src/lib/error-copy.test.ts +34 -0
  537. package/inkos/packages/studio/src/lib/error-copy.ts +37 -0
  538. package/inkos/packages/studio/src/lib/utils.ts +6 -0
  539. package/inkos/packages/studio/src/main.tsx +10 -0
  540. package/inkos/packages/studio/src/pages/Analytics.tsx +80 -0
  541. package/inkos/packages/studio/src/pages/BookCreate.tsx +895 -0
  542. package/inkos/packages/studio/src/pages/BookDetail.tsx +652 -0
  543. package/inkos/packages/studio/src/pages/ChapterReader.tsx +266 -0
  544. package/inkos/packages/studio/src/pages/ChatPage.tsx +521 -0
  545. package/inkos/packages/studio/src/pages/DaemonControl.tsx +116 -0
  546. package/inkos/packages/studio/src/pages/Dashboard.tsx +379 -0
  547. package/inkos/packages/studio/src/pages/DoctorView.tsx +82 -0
  548. package/inkos/packages/studio/src/pages/GenreManager.tsx +464 -0
  549. package/inkos/packages/studio/src/pages/ImportManager.tsx +216 -0
  550. package/inkos/packages/studio/src/pages/LanguageSelector.tsx +74 -0
  551. package/inkos/packages/studio/src/pages/LogViewer.tsx +82 -0
  552. package/inkos/packages/studio/src/pages/RadarView.tsx +157 -0
  553. package/inkos/packages/studio/src/pages/ServiceDetailPage.tsx +393 -0
  554. package/inkos/packages/studio/src/pages/ServiceListPage.tsx +463 -0
  555. package/inkos/packages/studio/src/pages/StyleManager.tsx +225 -0
  556. package/inkos/packages/studio/src/pages/TruthFiles.tsx +194 -0
  557. package/inkos/packages/studio/src/pages/chat-page-state.test.ts +206 -0
  558. package/inkos/packages/studio/src/pages/chat-page-state.ts +112 -0
  559. package/inkos/packages/studio/src/pages/page-state.test.ts +258 -0
  560. package/inkos/packages/studio/src/pages/service-detail-state.test.ts +294 -0
  561. package/inkos/packages/studio/src/pages/service-detail-state.ts +234 -0
  562. package/inkos/packages/studio/src/pages/style-manager-state.test.ts +22 -0
  563. package/inkos/packages/studio/src/pages/truth-files-state.test.ts +61 -0
  564. package/inkos/packages/studio/src/shared/contracts.ts +143 -0
  565. package/inkos/packages/studio/src/store/chat/__tests__/message-parts.test.ts +172 -0
  566. package/inkos/packages/studio/src/store/chat/index.ts +3 -0
  567. package/inkos/packages/studio/src/store/chat/initialState.ts +8 -0
  568. package/inkos/packages/studio/src/store/chat/message-policy.test.ts +16 -0
  569. package/inkos/packages/studio/src/store/chat/message-policy.ts +5 -0
  570. package/inkos/packages/studio/src/store/chat/parts-builder.ts +187 -0
  571. package/inkos/packages/studio/src/store/chat/selectors.ts +13 -0
  572. package/inkos/packages/studio/src/store/chat/slices/create/action.ts +10 -0
  573. package/inkos/packages/studio/src/store/chat/slices/create/initialState.ts +9 -0
  574. package/inkos/packages/studio/src/store/chat/slices/message/action.ts +417 -0
  575. package/inkos/packages/studio/src/store/chat/slices/message/initialState.ts +10 -0
  576. package/inkos/packages/studio/src/store/chat/slices/message/runtime.test.ts +21 -0
  577. package/inkos/packages/studio/src/store/chat/slices/message/runtime.ts +233 -0
  578. package/inkos/packages/studio/src/store/chat/slices/message/stream-events.ts +272 -0
  579. package/inkos/packages/studio/src/store/chat/store.ts +11 -0
  580. package/inkos/packages/studio/src/store/chat/types.ts +169 -0
  581. package/inkos/packages/studio/src/store/service/index.ts +2 -0
  582. package/inkos/packages/studio/src/store/service/store.ts +123 -0
  583. package/inkos/packages/studio/src/store/service/types.ts +50 -0
  584. package/inkos/packages/studio/tsconfig.json +24 -0
  585. package/inkos/packages/studio/tsconfig.server.json +11 -0
  586. package/inkos/packages/studio/vite.config.ts +34 -0
  587. package/inkos/packages/studio/vitest.config.ts +14 -0
  588. package/inkos/pnpm-lock.yaml +9569 -0
  589. package/inkos/pnpm-workspace.yaml +2 -0
  590. package/inkos/scripts/prepare-package-for-publish.mjs +135 -0
  591. package/inkos/scripts/restore-package-json.mjs +31 -0
  592. package/inkos/scripts/set-package-versions.mjs +74 -0
  593. package/inkos/scripts/verify-no-workspace-protocol.mjs +140 -0
  594. package/inkos/skills/SKILL.md +654 -0
  595. package/inkos/tsconfig.json +19 -0
  596. package/package.json +4 -3
  597. /package/.next/static/{F2hMZMf1IyCVAWpkbtRz7 → -3vIrBZXdQ0rp7Wa3Kz40}/_buildManifest.js +0 -0
  598. /package/.next/static/{F2hMZMf1IyCVAWpkbtRz7 → -3vIrBZXdQ0rp7Wa3Kz40}/_ssgManifest.js +0 -0
@@ -0,0 +1,3438 @@
1
+ import type { LLMClient, OnStreamProgress } from "../llm/provider.js";
2
+ import { chatCompletion, createLLMClient } from "../llm/provider.js";
3
+ import type { Logger } from "../utils/logger.js";
4
+ import type { BookConfig, FanficMode } from "../models/book.js";
5
+ import type { ChapterMeta } from "../models/chapter.js";
6
+ import type { NotifyChannel, LLMConfig, AgentLLMOverride, InputGovernanceMode } from "../models/project.js";
7
+ import type { GenreProfile } from "../models/genre-profile.js";
8
+ import { ArchitectAgent, type ArchitectOutput } from "../agents/architect.js";
9
+ import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js";
10
+ import { PlannerAgent, type PlanChapterOutput } from "../agents/planner.js";
11
+ import { composeGovernedChapter, type ComposeChapterOutput } from "../agents/composer.js";
12
+ import { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "../agents/writer.js";
13
+ import { LengthNormalizerAgent } from "../agents/length-normalizer.js";
14
+ import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
15
+ import { ContinuityAuditor } from "../agents/continuity.js";
16
+ import { ReviserAgent, DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js";
17
+ import { StateValidatorAgent, type ValidationResult, type ValidationWarning } from "../agents/state-validator.js";
18
+ import { RadarAgent } from "../agents/radar.js";
19
+ import type { RadarSource } from "../agents/radar-source.js";
20
+ import { readGenreProfile } from "../agents/rules-reader.js";
21
+ import { analyzeAITells } from "../agents/ai-tells.js";
22
+ import { analyzeSensitiveWords } from "../agents/sensitive-words.js";
23
+ import { StateManager } from "../state/manager.js";
24
+ import { MemoryDB, type Fact } from "../state/memory-db.js";
25
+ import { dispatchNotification, dispatchWebhookEvent } from "../notify/dispatcher.js";
26
+ import type { WebhookEvent } from "../notify/webhook.js";
27
+ import type { AgentContext } from "../agents/base.js";
28
+ import type { AuditResult, AuditIssue } from "../agents/continuity.js";
29
+ import type { RadarResult } from "../agents/radar.js";
30
+ import type { LengthSpec, LengthTelemetry } from "../models/length-governance.js";
31
+ import type { ChapterMemo, ContextPackage, RuleStack } from "../models/input-governance.js";
32
+ import { buildLengthSpec, countChapterLength, formatLengthCount, isOutsideHardRange, resolveLengthCountingMode, type LengthLanguage } from "../utils/length-metrics.js";
33
+ import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js";
34
+ import { buildWritingMethodologySection } from "../utils/writing-methodology.js";
35
+ import {
36
+ isNewLayoutBook,
37
+ readCharacterContext,
38
+ readStoryFrame,
39
+ readVolumeMap,
40
+ } from "../utils/outline-paths.js";
41
+ import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state/runtime-state-store.js";
42
+ import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
43
+ import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/promises";
44
+ import { join } from "node:path";
45
+ import {
46
+ parseStateDegradedReviewNote,
47
+ resolveStateDegradedBaseStatus,
48
+ retrySettlementAfterValidationFailure,
49
+ } from "./chapter-state-recovery.js";
50
+ import { persistChapterArtifacts } from "./chapter-persistence.js";
51
+ import { runChapterReviewCycle } from "./chapter-review-cycle.js";
52
+ import { validateChapterTruthPersistence } from "./chapter-truth-validation.js";
53
+ import { loadPersistedPlan, relativeToBookDir, savePersistedPlan } from "./persisted-governed-plan.js";
54
+
55
+ const SEQUENCE_LEVEL_CATEGORIES = new Set([
56
+ "Pacing Monotony", "节奏单调",
57
+ "Mood Monotony", "情绪单调",
58
+ "Title Collapse", "标题重复",
59
+ "Title Clustering", "标题聚集",
60
+ "Opening Pattern Repetition", "开头同构",
61
+ "Ending Pattern Repetition", "结尾同构",
62
+ ]);
63
+
64
+ function isSequenceLevelCategory(category: string): boolean {
65
+ return SEQUENCE_LEVEL_CATEGORIES.has(category);
66
+ }
67
+
68
+ interface ImportFoundationSourceOptions {
69
+ readonly maxFullTextChars?: number;
70
+ readonly chapterExcerptChars?: number;
71
+ readonly titleCatalogChars?: number;
72
+ readonly edgeChapterCount?: number;
73
+ readonly middleAnchorCount?: number;
74
+ }
75
+
76
+ const DEFAULT_IMPORT_FOUNDATION_MAX_FULL_TEXT_CHARS = 80_000;
77
+ const DEFAULT_IMPORT_CHAPTER_EXCERPT_CHARS = 6_000;
78
+ const DEFAULT_IMPORT_TITLE_CATALOG_CHARS = 24_000;
79
+ const DEFAULT_IMPORT_EDGE_CHAPTER_COUNT = 4;
80
+ const DEFAULT_IMPORT_MIDDLE_ANCHOR_COUNT = 8;
81
+
82
+ function formatImportedChapter(
83
+ chapter: { readonly title: string; readonly content: string },
84
+ index: number,
85
+ language: LengthLanguage,
86
+ content = chapter.content,
87
+ ): string {
88
+ return language === "en"
89
+ ? `Chapter ${index + 1}: ${chapter.title}\n\n${content}`
90
+ : `第${index + 1}章 ${chapter.title}\n\n${content}`;
91
+ }
92
+
93
+ function estimateImportFullTextLength(
94
+ chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>,
95
+ ): number {
96
+ return chapters.reduce((total, chapter) => total + chapter.title.length + chapter.content.length + 24, 0);
97
+ }
98
+
99
+ function excerptHeadTail(text: string, maxChars: number, language: LengthLanguage): string {
100
+ const clean = text.trim();
101
+ if (clean.length <= maxChars) return clean;
102
+ const headChars = Math.max(200, Math.floor(maxChars * 0.6));
103
+ const tailChars = Math.max(200, maxChars - headChars);
104
+ const omitted = clean.length - headChars - tailChars;
105
+ const marker = language === "en"
106
+ ? `\n\n[... ${omitted} chars omitted for import-context budget ...]\n\n`
107
+ : `\n\n【中间省略 ${omitted} 字,用于控制导入上下文预算】\n\n`;
108
+ return `${clean.slice(0, headChars).trimEnd()}${marker}${clean.slice(-tailChars).trimStart()}`;
109
+ }
110
+
111
+ function pickImportAnchorIndexes(
112
+ chapterCount: number,
113
+ edgeChapterCount: number,
114
+ middleAnchorCount: number,
115
+ ): ReadonlyArray<number> {
116
+ const selected = new Set<number>();
117
+ for (let i = 0; i < Math.min(edgeChapterCount, chapterCount); i++) selected.add(i);
118
+ for (let i = Math.max(0, chapterCount - edgeChapterCount); i < chapterCount; i++) selected.add(i);
119
+
120
+ const middleStart = Math.min(edgeChapterCount, chapterCount);
121
+ const middleEnd = Math.max(middleStart, chapterCount - edgeChapterCount);
122
+ const middleSize = middleEnd - middleStart;
123
+ const anchors = Math.min(middleAnchorCount, middleSize);
124
+ for (let i = 0; i < anchors; i++) {
125
+ const offset = Math.floor(((i + 1) * middleSize) / (anchors + 1));
126
+ selected.add(Math.min(chapterCount - 1, middleStart + offset));
127
+ }
128
+
129
+ return [...selected].sort((a, b) => a - b);
130
+ }
131
+
132
+ function buildTitleCatalog(
133
+ chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>,
134
+ language: LengthLanguage,
135
+ maxChars: number,
136
+ ): string {
137
+ const lines = chapters.map((chapter, index) =>
138
+ language === "en"
139
+ ? `- Chapter ${index + 1}: ${chapter.title} (${chapter.content.length} chars)`
140
+ : `- 第${index + 1}章:${chapter.title}(${chapter.content.length}字)`,
141
+ );
142
+ const joined = lines.join("\n");
143
+ if (joined.length <= maxChars) return joined;
144
+
145
+ const headBudget = Math.floor(maxChars * 0.55);
146
+ const tailBudget = maxChars - headBudget;
147
+ const head: string[] = [];
148
+ const tail: string[] = [];
149
+ let headChars = 0;
150
+ let tailChars = 0;
151
+ for (const line of lines) {
152
+ if (headChars + line.length + 1 > headBudget) break;
153
+ head.push(line);
154
+ headChars += line.length + 1;
155
+ }
156
+ for (let i = lines.length - 1; i >= 0; i--) {
157
+ const line = lines[i]!;
158
+ if (tailChars + line.length + 1 > tailBudget) break;
159
+ tail.unshift(line);
160
+ tailChars += line.length + 1;
161
+ }
162
+ const omitted = lines.length - head.length - tail.length;
163
+ const marker = language === "en"
164
+ ? `- ... ${omitted} chapter titles omitted ...`
165
+ : `- ……中间 ${omitted} 个章节标题省略……`;
166
+ return [...head, marker, ...tail].join("\n");
167
+ }
168
+
169
+ export function buildImportFoundationSource(
170
+ chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>,
171
+ language: LengthLanguage,
172
+ options: ImportFoundationSourceOptions = {},
173
+ ): string {
174
+ const maxFullTextChars = options.maxFullTextChars ?? DEFAULT_IMPORT_FOUNDATION_MAX_FULL_TEXT_CHARS;
175
+ const chapterExcerptChars = options.chapterExcerptChars ?? DEFAULT_IMPORT_CHAPTER_EXCERPT_CHARS;
176
+ const titleCatalogChars = options.titleCatalogChars ?? DEFAULT_IMPORT_TITLE_CATALOG_CHARS;
177
+ const edgeChapterCount = options.edgeChapterCount ?? DEFAULT_IMPORT_EDGE_CHAPTER_COUNT;
178
+ const middleAnchorCount = options.middleAnchorCount ?? DEFAULT_IMPORT_MIDDLE_ANCHOR_COUNT;
179
+
180
+ if (estimateImportFullTextLength(chapters) <= maxFullTextChars) {
181
+ return chapters.map((chapter, index) => formatImportedChapter(chapter, index, language)).join("\n\n---\n\n");
182
+ }
183
+
184
+ const anchorIndexes = pickImportAnchorIndexes(chapters.length, edgeChapterCount, middleAnchorCount);
185
+ const header = language === "en"
186
+ ? [
187
+ "## Import foundation source package",
188
+ "",
189
+ `The imported book has ${chapters.length} chapters. To avoid overflowing the LLM context, this package keeps the opening chapters, ending/continuation point, selected middle anchors, and a capped title catalog. Full chapters will still be replayed sequentially after foundation generation to rebuild truth files.`,
190
+ ].join("\n")
191
+ : [
192
+ "## 导入基础设定压缩资料包",
193
+ "",
194
+ `本次导入共 ${chapters.length} 章。为避免超出 LLM 上下文,这里保留开篇、结尾续写点、少量中段锚点和标题目录;完整章节将在后续顺序回放中逐章分析并沉淀 truth files。`,
195
+ ].join("\n");
196
+ const catalogTitle = language === "en" ? "## Capped chapter title catalog" : "## 章节标题目录(截断)";
197
+ const anchorsTitle = language === "en" ? "## Source excerpts for architecture" : "## 用于反推基础设定的正文摘录";
198
+ const anchorText = anchorIndexes
199
+ .map((index) => {
200
+ const chapter = chapters[index]!;
201
+ return formatImportedChapter(
202
+ chapter,
203
+ index,
204
+ language,
205
+ excerptHeadTail(chapter.content, chapterExcerptChars, language),
206
+ );
207
+ })
208
+ .join("\n\n---\n\n");
209
+
210
+ return [
211
+ header,
212
+ "",
213
+ catalogTitle,
214
+ buildTitleCatalog(chapters, language, titleCatalogChars),
215
+ "",
216
+ anchorsTitle,
217
+ anchorText,
218
+ ].join("\n");
219
+ }
220
+
221
+ export interface PipelineConfig {
222
+ readonly client: LLMClient;
223
+ readonly model: string;
224
+ readonly projectRoot: string;
225
+ readonly defaultLLMConfig?: LLMConfig;
226
+ readonly foundationReviewRetries?: number;
227
+ readonly writingReviewRetries?: number;
228
+ readonly notifyChannels?: ReadonlyArray<NotifyChannel>;
229
+ readonly radarSources?: ReadonlyArray<RadarSource>;
230
+ readonly externalContext?: string;
231
+ readonly modelOverrides?: Record<string, string | AgentLLMOverride>;
232
+ readonly inputGovernanceMode?: InputGovernanceMode;
233
+ readonly logger?: Logger;
234
+ readonly onStreamProgress?: OnStreamProgress;
235
+ }
236
+
237
+ export interface TokenUsageSummary {
238
+ readonly promptTokens: number;
239
+ readonly completionTokens: number;
240
+ readonly totalTokens: number;
241
+ }
242
+
243
+ export interface ChapterPipelineResult {
244
+ readonly chapterNumber: number;
245
+ readonly title: string;
246
+ readonly wordCount: number;
247
+ readonly auditResult: AuditResult;
248
+ readonly revised: boolean;
249
+ readonly status: "ready-for-review" | "audit-failed" | "state-degraded";
250
+ readonly lengthWarnings?: ReadonlyArray<string>;
251
+ readonly lengthTelemetry?: LengthTelemetry;
252
+ readonly tokenUsage?: TokenUsageSummary;
253
+ }
254
+
255
+ // Atomic operation results
256
+ export interface DraftResult {
257
+ readonly chapterNumber: number;
258
+ readonly title: string;
259
+ readonly wordCount: number;
260
+ readonly filePath: string;
261
+ readonly lengthWarnings?: ReadonlyArray<string>;
262
+ readonly lengthTelemetry?: LengthTelemetry;
263
+ readonly tokenUsage?: TokenUsageSummary;
264
+ }
265
+
266
+ export interface PlanChapterResult {
267
+ readonly bookId: string;
268
+ readonly chapterNumber: number;
269
+ readonly intentPath: string;
270
+ readonly goal: string;
271
+ readonly conflicts: ReadonlyArray<string>;
272
+ }
273
+
274
+ export interface ComposeChapterResult extends PlanChapterResult {
275
+ readonly contextPath: string;
276
+ readonly ruleStackPath: string;
277
+ readonly tracePath: string;
278
+ }
279
+
280
+ export interface ReviseResult {
281
+ readonly chapterNumber: number;
282
+ readonly wordCount: number;
283
+ readonly fixedIssues: ReadonlyArray<string>;
284
+ readonly applied: boolean;
285
+ readonly status: "unchanged" | "ready-for-review" | "audit-failed";
286
+ readonly skippedReason?: string;
287
+ readonly lengthWarnings?: ReadonlyArray<string>;
288
+ readonly lengthTelemetry?: LengthTelemetry;
289
+ }
290
+
291
+ export interface TruthFiles {
292
+ readonly currentState: string;
293
+ readonly particleLedger: string;
294
+ readonly pendingHooks: string;
295
+ readonly storyBible: string;
296
+ readonly volumeOutline: string;
297
+ readonly bookRules: string;
298
+ }
299
+
300
+ export interface BookStatusInfo {
301
+ readonly bookId: string;
302
+ readonly title: string;
303
+ readonly genre: string;
304
+ readonly platform: string;
305
+ readonly status: string;
306
+ readonly chaptersWritten: number;
307
+ readonly totalWords: number;
308
+ readonly nextChapter: number;
309
+ readonly chapters: ReadonlyArray<ChapterMeta>;
310
+ }
311
+
312
+ interface MergedAuditEvaluation {
313
+ readonly auditResult: AuditResult;
314
+ readonly aiTellCount: number;
315
+ readonly blockingCount: number;
316
+ readonly criticalCount: number;
317
+ readonly revisionBlockingIssues: ReadonlyArray<AuditIssue>;
318
+ }
319
+
320
+ export interface ImportChaptersInput {
321
+ readonly bookId: string;
322
+ readonly chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>;
323
+ readonly resumeFrom?: number;
324
+ /** "continuation" (default) = pick up where the text left off, no new spacetime.
325
+ * "series" = shared universe but independent new story, requires new spacetime. */
326
+ readonly importMode?: "continuation" | "series";
327
+ }
328
+
329
+ export interface ImportChaptersResult {
330
+ readonly bookId: string;
331
+ readonly importedCount: number;
332
+ readonly totalWords: number;
333
+ readonly nextChapter: number;
334
+ }
335
+
336
+ export interface InitBookOptions {
337
+ readonly externalContext?: string;
338
+ readonly authorIntent?: string;
339
+ readonly currentFocus?: string;
340
+ }
341
+
342
+ export class PipelineRunner {
343
+ private readonly state: StateManager;
344
+ private readonly config: PipelineConfig;
345
+ private readonly agentClients = new Map<string, LLMClient>();
346
+ private memoryIndexFallbackWarned = false;
347
+
348
+ constructor(config: PipelineConfig) {
349
+ this.config = config;
350
+ this.state = new StateManager(config.projectRoot);
351
+ }
352
+
353
+ private localize(language: LengthLanguage, messages: { zh: string; en: string }): string {
354
+ return language === "en" ? messages.en : messages.zh;
355
+ }
356
+
357
+ private async resolveBookLanguage(
358
+ book: Pick<BookConfig, "genre" | "language">,
359
+ ): Promise<LengthLanguage> {
360
+ if (book.language) {
361
+ return book.language;
362
+ }
363
+
364
+ try {
365
+ const { profile } = await this.loadGenreProfile(book.genre);
366
+ return profile.language;
367
+ } catch {
368
+ return "zh";
369
+ }
370
+ }
371
+
372
+ private async resolveBookLanguageById(bookId: string): Promise<LengthLanguage> {
373
+ try {
374
+ const book = await this.state.loadBookConfig(bookId);
375
+ return await this.resolveBookLanguage(book);
376
+ } catch {
377
+ return "zh";
378
+ }
379
+ }
380
+
381
+ private languageFromLengthSpec(lengthSpec: Pick<LengthSpec, "countingMode">): LengthLanguage {
382
+ return lengthSpec.countingMode === "en_words" ? "en" : "zh";
383
+ }
384
+
385
+ private logStage(language: LengthLanguage, message: { zh: string; en: string }): void {
386
+ this.config.logger?.info(
387
+ `${this.localize(language, { zh: "阶段:", en: "Stage: " })}${this.localize(language, message)}`,
388
+ );
389
+ }
390
+
391
+ private logInfo(language: LengthLanguage, message: { zh: string; en: string }): void {
392
+ this.config.logger?.info(this.localize(language, message));
393
+ }
394
+
395
+ private logWarn(language: LengthLanguage, message: { zh: string; en: string }): void {
396
+ this.config.logger?.warn(this.localize(language, message));
397
+ }
398
+
399
+ private async tryGenerateStyleGuide(
400
+ bookId: string,
401
+ referenceText: string,
402
+ sourceName: string | undefined,
403
+ language?: LengthLanguage,
404
+ ): Promise<void> {
405
+ try {
406
+ await this.generateStyleGuide(bookId, referenceText, sourceName);
407
+ } catch (error) {
408
+ const resolvedLanguage = language ?? await this.resolveBookLanguageById(bookId);
409
+ const detail = error instanceof Error ? error.message : String(error);
410
+ this.logWarn(resolvedLanguage, {
411
+ zh: `风格指纹提取失败,已跳过:${detail}`,
412
+ en: `Style fingerprint extraction failed and was skipped: ${detail}`,
413
+ });
414
+ }
415
+ }
416
+
417
+ private async generateAndReviewFoundation(params: {
418
+ readonly generate: (reviewFeedback?: string) => Promise<ArchitectOutput>;
419
+ readonly reviewer: FoundationReviewerAgent;
420
+ readonly mode: "original" | "fanfic" | "series";
421
+ readonly sourceCanon?: string;
422
+ readonly styleGuide?: string;
423
+ readonly language: "zh" | "en";
424
+ readonly stageLanguage: LengthLanguage;
425
+ readonly maxRetries?: number;
426
+ }): Promise<ArchitectOutput> {
427
+ const maxRetries = params.maxRetries ?? this.config.foundationReviewRetries ?? 2;
428
+ let foundation = await params.generate();
429
+
430
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
431
+ this.logStage(params.stageLanguage, {
432
+ zh: `审核基础设定(第${attempt + 1}轮)`,
433
+ en: `reviewing foundation (round ${attempt + 1})`,
434
+ });
435
+
436
+ const review = await params.reviewer.review({
437
+ foundation,
438
+ mode: params.mode,
439
+ sourceCanon: params.sourceCanon,
440
+ styleGuide: params.styleGuide,
441
+ language: params.language,
442
+ });
443
+
444
+ this.config.logger?.info(
445
+ `Foundation review: ${review.totalScore}/100 ${review.passed ? "PASSED" : "REJECTED"}`,
446
+ );
447
+ for (const dim of review.dimensions) {
448
+ this.config.logger?.info(` [${dim.score}] ${dim.name.slice(0, 40)}`);
449
+ }
450
+
451
+ if (review.passed) {
452
+ return foundation;
453
+ }
454
+
455
+ this.logWarn(params.stageLanguage, {
456
+ zh: `基础设定未通过审核(${review.totalScore}分),正在重新生成...`,
457
+ en: `Foundation rejected (${review.totalScore}/100), regenerating...`,
458
+ });
459
+
460
+ foundation = await params.generate(this.buildFoundationReviewFeedback(review, params.language));
461
+ }
462
+
463
+ // Final review
464
+ const finalReview = await params.reviewer.review({
465
+ foundation,
466
+ mode: params.mode,
467
+ sourceCanon: params.sourceCanon,
468
+ styleGuide: params.styleGuide,
469
+ language: params.language,
470
+ });
471
+ this.config.logger?.info(
472
+ `Foundation final review: ${finalReview.totalScore}/100 ${finalReview.passed ? "PASSED" : "ACCEPTED (max retries)"}`,
473
+ );
474
+
475
+ return foundation;
476
+ }
477
+
478
+ private buildFoundationReviewFeedback(
479
+ review: {
480
+ readonly dimensions: ReadonlyArray<{
481
+ readonly name: string;
482
+ readonly score: number;
483
+ readonly feedback: string;
484
+ }>;
485
+ readonly overallFeedback: string;
486
+ },
487
+ language: "zh" | "en",
488
+ ): string {
489
+ const dimensionLines = review.dimensions
490
+ .map((dimension) => (
491
+ language === "en"
492
+ ? `- ${dimension.name} [${dimension.score}]: ${dimension.feedback}`
493
+ : `- ${dimension.name}(${dimension.score}分):${dimension.feedback}`
494
+ ))
495
+ .join("\n");
496
+
497
+ return language === "en"
498
+ ? [
499
+ "## Overall Feedback",
500
+ review.overallFeedback,
501
+ "",
502
+ "## Dimension Notes",
503
+ dimensionLines || "- none",
504
+ ].join("\n")
505
+ : [
506
+ "## 总评",
507
+ review.overallFeedback,
508
+ "",
509
+ "## 分项问题",
510
+ dimensionLines || "- 无",
511
+ ].join("\n");
512
+ }
513
+
514
+ private agentCtx(bookId?: string): AgentContext {
515
+ return {
516
+ client: this.config.client,
517
+ model: this.config.model,
518
+ projectRoot: this.config.projectRoot,
519
+ bookId,
520
+ logger: this.config.logger,
521
+ onStreamProgress: this.config.onStreamProgress,
522
+ };
523
+ }
524
+
525
+ private resolveOverride(agentName: string): { model: string; client: LLMClient } {
526
+ const override = this.config.modelOverrides?.[agentName];
527
+ if (!override) {
528
+ return { model: this.config.model, client: this.config.client };
529
+ }
530
+ if (typeof override === "string") {
531
+ return { model: override, client: this.config.client };
532
+ }
533
+ // Full override — needs its own client if baseUrl differs
534
+ if (!override.baseUrl) {
535
+ return { model: override.model, client: this.config.client };
536
+ }
537
+ const base = this.config.defaultLLMConfig;
538
+ const provider = override.provider ?? base?.provider ?? "custom";
539
+ const apiKeySource = override.apiKeyEnv
540
+ ? `env:${override.apiKeyEnv}`
541
+ : `base:${base?.apiKey ?? ""}`;
542
+ const stream = override.stream ?? base?.stream ?? true;
543
+ const apiFormat = base?.apiFormat ?? "chat";
544
+ const cacheKey = [
545
+ provider,
546
+ override.baseUrl,
547
+ apiKeySource,
548
+ `stream:${stream}`,
549
+ `format:${apiFormat}`,
550
+ ].join("|");
551
+ let client = this.agentClients.get(cacheKey);
552
+ if (!client) {
553
+ const apiKey = override.apiKeyEnv
554
+ ? process.env[override.apiKeyEnv] ?? ""
555
+ : base?.apiKey ?? "";
556
+ client = createLLMClient({
557
+ provider,
558
+ service: base?.service ?? "custom",
559
+ configSource: base?.configSource ?? "env",
560
+ baseUrl: override.baseUrl,
561
+ apiKey,
562
+ model: override.model,
563
+ temperature: base?.temperature ?? 0.7,
564
+ thinkingBudget: base?.thinkingBudget ?? 0,
565
+ apiFormat,
566
+ stream,
567
+ });
568
+ this.agentClients.set(cacheKey, client);
569
+ }
570
+ return { model: override.model, client };
571
+ }
572
+
573
+ private agentCtxFor(agent: string, bookId?: string): AgentContext {
574
+ const { model, client } = this.resolveOverride(agent);
575
+ return {
576
+ client,
577
+ model,
578
+ projectRoot: this.config.projectRoot,
579
+ bookId,
580
+ logger: this.config.logger?.child(agent),
581
+ onStreamProgress: this.config.onStreamProgress,
582
+ };
583
+ }
584
+
585
+ public createAgentContext(agent: string, bookId?: string): AgentContext {
586
+ return this.agentCtxFor(agent, bookId);
587
+ }
588
+
589
+ private async pathExists(path: string): Promise<boolean> {
590
+ try {
591
+ await stat(path);
592
+ return true;
593
+ } catch {
594
+ return false;
595
+ }
596
+ }
597
+
598
+ private async loadGenreProfile(genre: string): Promise<{ profile: GenreProfile }> {
599
+ const parsed = await readGenreProfile(this.config.projectRoot, genre);
600
+ return { profile: parsed.profile };
601
+ }
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // Atomic operations (composable by OpenClaw or agent mode)
605
+ // ---------------------------------------------------------------------------
606
+
607
+ async runRadar(): Promise<RadarResult> {
608
+ const radar = new RadarAgent(this.agentCtxFor("radar"), this.config.radarSources);
609
+ return radar.scan();
610
+ }
611
+
612
+ async initBook(book: BookConfig, options: InitBookOptions = {}): Promise<void> {
613
+ const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
614
+ const bookDir = this.state.bookDir(book.id);
615
+ const stagingBookDir = join(
616
+ this.state.booksDir,
617
+ `.tmp-book-create-${book.id}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
618
+ );
619
+ const stageLanguage = await this.resolveBookLanguage(book);
620
+ const effectiveExternalContext = options.externalContext ?? this.config.externalContext;
621
+
622
+ this.logStage(stageLanguage, { zh: "生成基础设定", en: "generating foundation" });
623
+ const { profile: gp } = await this.loadGenreProfile(book.genre);
624
+ const reviewer = new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", book.id));
625
+ const resolvedLanguage = (book.language ?? gp.language) === "en" ? "en" as const : "zh" as const;
626
+ const foundation = await this.generateAndReviewFoundation({
627
+ generate: (reviewFeedback) => architect.generateFoundation(
628
+ book,
629
+ effectiveExternalContext,
630
+ reviewFeedback,
631
+ ),
632
+ reviewer,
633
+ mode: "original",
634
+ language: resolvedLanguage,
635
+ stageLanguage,
636
+ });
637
+ try {
638
+ this.logStage(stageLanguage, { zh: "保存书籍配置", en: "saving book config" });
639
+ await this.state.saveBookConfigAt(stagingBookDir, book);
640
+
641
+ this.logStage(stageLanguage, { zh: "写入基础设定文件", en: "writing foundation files" });
642
+ await architect.writeFoundationFiles(
643
+ stagingBookDir,
644
+ foundation,
645
+ gp.numericalSystem,
646
+ book.language ?? gp.language,
647
+ );
648
+
649
+ if (effectiveExternalContext && effectiveExternalContext.trim().length > 0) {
650
+ const storyDir = join(stagingBookDir, "story");
651
+ await mkdir(storyDir, { recursive: true });
652
+ await writeFile(join(storyDir, "brief.md"), effectiveExternalContext, "utf-8");
653
+ }
654
+
655
+ this.logStage(stageLanguage, { zh: "初始化控制文档", en: "initializing control documents" });
656
+ await this.state.ensureControlDocumentsAt(
657
+ stagingBookDir,
658
+ book.language ?? gp.language,
659
+ options.authorIntent ?? effectiveExternalContext,
660
+ );
661
+ if (options.currentFocus?.trim()) {
662
+ await writeFile(
663
+ join(stagingBookDir, "story", "current_focus.md"),
664
+ options.currentFocus.trimEnd() + "\n",
665
+ "utf-8",
666
+ );
667
+ }
668
+
669
+ await this.state.saveChapterIndexAt(stagingBookDir, []);
670
+
671
+ this.logStage(stageLanguage, { zh: "创建初始快照", en: "creating initial snapshot" });
672
+ await this.state.snapshotStateAt(stagingBookDir, 0);
673
+
674
+ if (await this.pathExists(bookDir)) {
675
+ if (await this.state.isCompleteBookDirectory(bookDir)) {
676
+ throw new Error(`Book "${book.id}" already exists at books/${book.id}/. Use a different title or delete the existing book first.`);
677
+ }
678
+ await rm(bookDir, { recursive: true, force: true });
679
+ }
680
+
681
+ await rename(stagingBookDir, bookDir);
682
+ } catch (error) {
683
+ await rm(stagingBookDir, { recursive: true, force: true }).catch(() => undefined);
684
+ throw error;
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Revise an existing book foundation without touching runtime chapter state.
690
+ *
691
+ * Legacy books read the flat foundation files as source. Phase 5+ books read
692
+ * the authoritative outline/ and roles/ files instead of the compatibility
693
+ * shims, otherwise large role/story details can be lost during rewrite.
694
+ */
695
+ async reviseFoundation(bookId: string, feedback: string): Promise<void> {
696
+ const bookDir = this.state.bookDir(bookId);
697
+ const storyDir = join(bookDir, "story");
698
+ const isPhase5 = await isNewLayoutBook(bookDir);
699
+
700
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
701
+ const backupTag = isPhase5 ? "phase5" : "phase4";
702
+ const backupDir = join(storyDir, `.backup-${backupTag}-${timestamp}`);
703
+ await mkdir(backupDir, { recursive: true });
704
+
705
+ const flatFiles = ["story_bible.md", "volume_outline.md", "book_rules.md", "character_matrix.md"];
706
+ for (const fileName of flatFiles) {
707
+ try {
708
+ const content = await readFile(join(storyDir, fileName), "utf-8");
709
+ await writeFile(join(backupDir, fileName), content, "utf-8");
710
+ } catch {
711
+ // Missing legacy shim files are fine for partially migrated books.
712
+ }
713
+ }
714
+
715
+ if (isPhase5) {
716
+ await this.copyDirShallow(join(storyDir, "outline"), join(backupDir, "outline"));
717
+ await this.copyDirRecursive(join(storyDir, "roles"), join(backupDir, "roles"));
718
+ }
719
+
720
+ const book = await this.state.loadBookConfig(bookId);
721
+ let oldStoryBible: string;
722
+ let oldVolumeOutline: string;
723
+ let oldBookRules: string;
724
+ let oldCharacterMatrix: string;
725
+
726
+ if (isPhase5) {
727
+ [oldStoryBible, oldVolumeOutline, oldCharacterMatrix] = await Promise.all([
728
+ readStoryFrame(bookDir),
729
+ readVolumeMap(bookDir),
730
+ readCharacterContext(bookDir),
731
+ ]);
732
+ oldBookRules = await readFile(join(storyDir, "book_rules.md"), "utf-8").catch(() => "");
733
+ } else {
734
+ [oldStoryBible, oldVolumeOutline, oldBookRules, oldCharacterMatrix] = await Promise.all([
735
+ readFile(join(storyDir, "story_bible.md"), "utf-8").catch(() => ""),
736
+ readFile(join(storyDir, "volume_outline.md"), "utf-8").catch(() => ""),
737
+ readFile(join(storyDir, "book_rules.md"), "utf-8").catch(() => ""),
738
+ readFile(join(storyDir, "character_matrix.md"), "utf-8").catch(() => ""),
739
+ ]);
740
+ }
741
+
742
+ const architect = new ArchitectAgent(this.agentCtxFor("architect", bookId));
743
+ const foundation = await architect.generateFoundation(book, undefined, undefined, {
744
+ reviseFrom: {
745
+ storyBible: oldStoryBible,
746
+ volumeOutline: oldVolumeOutline,
747
+ bookRules: oldBookRules,
748
+ characterMatrix: oldCharacterMatrix,
749
+ userFeedback: feedback,
750
+ },
751
+ });
752
+
753
+ const reviewer = new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", bookId));
754
+ const resolvedLanguage = (book.language ?? "zh") === "en" ? "en" as const : "zh" as const;
755
+ try {
756
+ const review = await reviewer.review({
757
+ foundation,
758
+ mode: "original",
759
+ language: resolvedLanguage,
760
+ } as Parameters<FoundationReviewerAgent["review"]>[0]);
761
+ if (!review.passed) {
762
+ this.config.logger?.warn?.(
763
+ `[reviseFoundation] Foundation review did not pass; accepting rewrite. Feedback: ${review.overallFeedback ?? ""}`,
764
+ );
765
+ }
766
+ } catch (error) {
767
+ this.config.logger?.warn?.(
768
+ `[reviseFoundation] Foundation review failed and was skipped: ${error instanceof Error ? error.message : String(error)}`,
769
+ );
770
+ }
771
+
772
+ const outlineDir = join(storyDir, "outline");
773
+ await mkdir(outlineDir, { recursive: true });
774
+ await mkdir(join(storyDir, "roles", "主要角色"), { recursive: true });
775
+ await mkdir(join(storyDir, "roles", "次要角色"), { recursive: true });
776
+
777
+ const { profile: gp } = await this.loadGenreProfile(book.genre);
778
+ await architect.writeFoundationFiles(
779
+ bookDir,
780
+ foundation,
781
+ gp.numericalSystem,
782
+ book.language ?? gp.language,
783
+ "revise",
784
+ );
785
+ }
786
+
787
+ private async copyDirShallow(src: string, dest: string): Promise<void> {
788
+ try {
789
+ await mkdir(dest, { recursive: true });
790
+ const entries = await readdir(src);
791
+ await Promise.all(entries.map(async (entry) => {
792
+ try {
793
+ const content = await readFile(join(src, entry), "utf-8");
794
+ await writeFile(join(dest, entry), content, "utf-8");
795
+ } catch {
796
+ // Skip unreadable files.
797
+ }
798
+ }));
799
+ } catch {
800
+ // Source directory does not exist.
801
+ }
802
+ }
803
+
804
+ private async copyDirRecursive(src: string, dest: string): Promise<void> {
805
+ try {
806
+ await mkdir(dest, { recursive: true });
807
+ const entries = await readdir(src, { withFileTypes: true });
808
+ for (const entry of entries) {
809
+ const srcPath = join(src, entry.name);
810
+ const destPath = join(dest, entry.name);
811
+ if (entry.isDirectory()) {
812
+ await this.copyDirRecursive(srcPath, destPath);
813
+ } else if (entry.isFile()) {
814
+ try {
815
+ const content = await readFile(srcPath, "utf-8");
816
+ await writeFile(destPath, content, "utf-8");
817
+ } catch {
818
+ // Skip unreadable files.
819
+ }
820
+ }
821
+ }
822
+ } catch {
823
+ // Source directory does not exist.
824
+ }
825
+ }
826
+
827
+ /** Import external source material and generate fanfic_canon.md */
828
+ async importFanficCanon(
829
+ bookId: string,
830
+ sourceText: string,
831
+ sourceName: string,
832
+ fanficMode: FanficMode,
833
+ ): Promise<string> {
834
+ const { FanficCanonImporter } = await import("../agents/fanfic-canon-importer.js");
835
+ const importer = new FanficCanonImporter(this.agentCtxFor("fanfic-canon-importer", bookId));
836
+ const result = await importer.importFromText(sourceText, sourceName, fanficMode);
837
+
838
+ const bookDir = this.state.bookDir(bookId);
839
+ const storyDir = join(bookDir, "story");
840
+ await mkdir(storyDir, { recursive: true });
841
+ await writeFile(join(storyDir, "fanfic_canon.md"), result.fullDocument, "utf-8");
842
+
843
+ return result.fullDocument;
844
+ }
845
+
846
+ /** One-step fanfic book creation: create book + import canon + generate foundation */
847
+ async initFanficBook(
848
+ book: BookConfig,
849
+ sourceText: string,
850
+ sourceName: string,
851
+ fanficMode: FanficMode,
852
+ ): Promise<void> {
853
+ const bookDir = this.state.bookDir(book.id);
854
+ const stageLanguage = await this.resolveBookLanguage(book);
855
+
856
+ this.logStage(stageLanguage, { zh: "保存书籍配置", en: "saving book config" });
857
+ await this.state.saveBookConfig(book.id, book);
858
+
859
+ // Step 1: Import source material → fanfic_canon.md
860
+ this.logStage(stageLanguage, { zh: "导入同人正典", en: "importing fanfic canon" });
861
+ const fanficCanon = await this.importFanficCanon(book.id, sourceText, sourceName, fanficMode);
862
+
863
+ // Step 2: Generate foundation with review loop
864
+ const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
865
+ const reviewer = new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", book.id));
866
+ this.logStage(stageLanguage, { zh: "生成同人基础设定", en: "generating fanfic foundation" });
867
+ const { profile: gp } = await this.loadGenreProfile(book.genre);
868
+ const resolvedLanguage = (book.language ?? gp.language) === "en" ? "en" as const : "zh" as const;
869
+ const foundation = await this.generateAndReviewFoundation({
870
+ generate: (reviewFeedback) => architect.generateFanficFoundation(
871
+ book,
872
+ fanficCanon,
873
+ fanficMode,
874
+ reviewFeedback,
875
+ ),
876
+ reviewer,
877
+ mode: "fanfic",
878
+ sourceCanon: fanficCanon,
879
+ language: resolvedLanguage,
880
+ stageLanguage,
881
+ });
882
+ this.logStage(stageLanguage, { zh: "写入基础设定文件", en: "writing foundation files" });
883
+ await architect.writeFoundationFiles(
884
+ bookDir,
885
+ foundation,
886
+ gp.numericalSystem,
887
+ book.language ?? gp.language,
888
+ );
889
+ this.logStage(stageLanguage, { zh: "初始化控制文档", en: "initializing control documents" });
890
+ await this.state.ensureControlDocuments(book.id, this.config.externalContext);
891
+
892
+ // Step 3: Generate style guide from source material
893
+ if (sourceText.length >= 500) {
894
+ this.logStage(stageLanguage, { zh: "提取原作风格指纹", en: "extracting source style fingerprint" });
895
+ await this.tryGenerateStyleGuide(book.id, sourceText, sourceName, stageLanguage);
896
+ }
897
+
898
+ // Step 4: Initialize chapters directory + snapshot
899
+ this.logStage(stageLanguage, { zh: "创建初始快照", en: "creating initial snapshot" });
900
+ await mkdir(join(bookDir, "chapters"), { recursive: true });
901
+ await this.state.saveChapterIndex(book.id, []);
902
+ await this.state.snapshotState(book.id, 0);
903
+ }
904
+
905
+ /** Write a single draft chapter. Saves chapter file + truth files + index + snapshot. */
906
+ async writeDraft(bookId: string, context?: string, wordCount?: number): Promise<DraftResult> {
907
+ const releaseLock = await this.state.acquireBookLock(bookId);
908
+ try {
909
+ await this.state.ensureControlDocuments(bookId);
910
+ const book = await this.state.loadBookConfig(bookId);
911
+ const bookDir = this.state.bookDir(bookId);
912
+ const chapterNumber = await this.state.getNextChapterNumber(bookId);
913
+ const stageLanguage = await this.resolveBookLanguage(book);
914
+ this.logStage(stageLanguage, { zh: "准备章节输入", en: "preparing chapter inputs" });
915
+ const writeInput = await this.prepareWriteInput(
916
+ book,
917
+ bookDir,
918
+ chapterNumber,
919
+ context ?? this.config.externalContext,
920
+ );
921
+
922
+ const { profile: gp } = await this.loadGenreProfile(book.genre);
923
+ const lengthSpec = buildLengthSpec(
924
+ wordCount ?? book.chapterWordCount,
925
+ book.language ?? gp.language,
926
+ );
927
+
928
+ const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
929
+ this.logStage(stageLanguage, { zh: "撰写章节草稿", en: "writing chapter draft" });
930
+ const output = await writer.writeChapter({
931
+ book,
932
+ bookDir,
933
+ chapterNumber,
934
+ ...writeInput,
935
+ lengthSpec,
936
+ ...(wordCount ? { wordCountOverride: wordCount } : {}),
937
+ });
938
+ const writerCount = countChapterLength(output.content, lengthSpec.countingMode);
939
+ let totalUsage: TokenUsageSummary = output.tokenUsage ?? {
940
+ promptTokens: 0,
941
+ completionTokens: 0,
942
+ totalTokens: 0,
943
+ };
944
+ const normalizedDraft = await this.normalizeDraftLengthIfNeeded({
945
+ bookId,
946
+ chapterNumber,
947
+ chapterContent: output.content,
948
+ lengthSpec,
949
+ chapterIntent: writeInput.chapterIntent,
950
+ });
951
+ totalUsage = PipelineRunner.addUsage(totalUsage, normalizedDraft.tokenUsage);
952
+ const draftOutput: WriteChapterOutput = {
953
+ ...output,
954
+ content: normalizedDraft.content,
955
+ wordCount: normalizedDraft.wordCount,
956
+ tokenUsage: totalUsage,
957
+ };
958
+ const lengthWarnings = this.buildLengthWarnings(
959
+ chapterNumber,
960
+ draftOutput.wordCount,
961
+ lengthSpec,
962
+ );
963
+ const lengthTelemetry = this.buildLengthTelemetry({
964
+ lengthSpec,
965
+ writerCount,
966
+ postWriterNormalizeCount: normalizedDraft.wordCount,
967
+ postReviseCount: 0,
968
+ finalCount: draftOutput.wordCount,
969
+ normalizeApplied: normalizedDraft.applied,
970
+ lengthWarning: lengthWarnings.length > 0,
971
+ });
972
+ this.logLengthWarnings(lengthWarnings);
973
+
974
+ // Save chapter file
975
+ const chaptersDir = join(bookDir, "chapters");
976
+ const paddedNum = String(chapterNumber).padStart(4, "0");
977
+ const sanitized = draftOutput.title.replace(/[/\\?%*:|"<>]/g, "").replace(/\s+/g, "_").slice(0, 50);
978
+ const filename = `${paddedNum}_${sanitized}.md`;
979
+ const filePath = join(chaptersDir, filename);
980
+
981
+ const resolvedLang = book.language ?? gp.language;
982
+ const heading = resolvedLang === "en"
983
+ ? `# Chapter ${chapterNumber}: ${draftOutput.title}`
984
+ : `# 第${chapterNumber}章 ${draftOutput.title}`;
985
+ await writeFile(filePath, `${heading}\n\n${draftOutput.content}`, "utf-8");
986
+
987
+ // Save truth files
988
+ this.logStage(stageLanguage, { zh: "落盘草稿与真相文件", en: "persisting draft and truth files" });
989
+ await writer.saveChapter(bookDir, draftOutput, gp.numericalSystem, resolvedLang);
990
+ await writer.saveNewTruthFiles(bookDir, draftOutput, resolvedLang);
991
+ await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, draftOutput);
992
+ await this.syncNarrativeMemoryIndex(bookId);
993
+
994
+ // Update index
995
+ const existingIndex = await this.state.loadChapterIndex(bookId);
996
+ const now = new Date().toISOString();
997
+ const newEntry: ChapterMeta = {
998
+ number: chapterNumber,
999
+ title: draftOutput.title,
1000
+ status: "drafted",
1001
+ wordCount: draftOutput.wordCount,
1002
+ createdAt: now,
1003
+ updatedAt: now,
1004
+ auditIssues: [],
1005
+ lengthWarnings,
1006
+ lengthTelemetry,
1007
+ ...(draftOutput.tokenUsage ? { tokenUsage: draftOutput.tokenUsage } : {}),
1008
+ };
1009
+ const existingIdx = existingIndex.findIndex((e) => e.number === chapterNumber);
1010
+ const updatedIndex = existingIdx >= 0
1011
+ ? existingIndex.map((e, i) => i === existingIdx ? newEntry : e)
1012
+ : [...existingIndex, newEntry];
1013
+ await this.state.saveChapterIndex(bookId, updatedIndex);
1014
+ await this.markBookActiveIfNeeded(bookId);
1015
+
1016
+ // Snapshot
1017
+ this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" });
1018
+ await this.state.snapshotState(bookId, chapterNumber);
1019
+ await this.syncCurrentStateFactHistory(bookId, chapterNumber);
1020
+
1021
+ await this.emitWebhook("chapter-complete", bookId, chapterNumber, {
1022
+ title: draftOutput.title,
1023
+ wordCount: draftOutput.wordCount,
1024
+ });
1025
+
1026
+ return {
1027
+ chapterNumber,
1028
+ title: draftOutput.title,
1029
+ wordCount: draftOutput.wordCount,
1030
+ filePath,
1031
+ lengthWarnings,
1032
+ lengthTelemetry,
1033
+ tokenUsage: draftOutput.tokenUsage,
1034
+ };
1035
+ } finally {
1036
+ await releaseLock();
1037
+ }
1038
+ }
1039
+
1040
+ async planChapter(bookId: string, context?: string): Promise<PlanChapterResult> {
1041
+ await this.state.ensureControlDocuments(bookId);
1042
+ const book = await this.state.loadBookConfig(bookId);
1043
+ const bookDir = this.state.bookDir(bookId);
1044
+ const chapterNumber = await this.state.getNextChapterNumber(bookId);
1045
+ const stageLanguage = await this.resolveBookLanguage(book);
1046
+ this.logStage(stageLanguage, { zh: "规划下一章意图", en: "planning next chapter intent" });
1047
+ const { plan } = await this.createGovernedArtifacts(
1048
+ book,
1049
+ bookDir,
1050
+ chapterNumber,
1051
+ context ?? this.config.externalContext,
1052
+ { reuseExistingIntentWhenContextMissing: false },
1053
+ );
1054
+
1055
+ return {
1056
+ bookId,
1057
+ chapterNumber,
1058
+ intentPath: relativeToBookDir(bookDir, plan.runtimePath),
1059
+ goal: plan.intent.goal,
1060
+ conflicts: [],
1061
+ };
1062
+ }
1063
+
1064
+ async composeChapter(bookId: string, context?: string): Promise<ComposeChapterResult> {
1065
+ await this.state.ensureControlDocuments(bookId);
1066
+ const book = await this.state.loadBookConfig(bookId);
1067
+ const bookDir = this.state.bookDir(bookId);
1068
+ const chapterNumber = await this.state.getNextChapterNumber(bookId);
1069
+ const stageLanguage = await this.resolveBookLanguage(book);
1070
+ this.logStage(stageLanguage, { zh: "组装章节运行时上下文", en: "composing chapter runtime context" });
1071
+ const { plan, composed } = await this.createGovernedArtifacts(
1072
+ book,
1073
+ bookDir,
1074
+ chapterNumber,
1075
+ context ?? this.config.externalContext,
1076
+ { reuseExistingIntentWhenContextMissing: true },
1077
+ );
1078
+
1079
+ return {
1080
+ bookId,
1081
+ chapterNumber,
1082
+ intentPath: relativeToBookDir(bookDir, plan.runtimePath),
1083
+ goal: plan.intent.goal,
1084
+ conflicts: [],
1085
+ contextPath: relativeToBookDir(bookDir, composed.contextPath),
1086
+ ruleStackPath: relativeToBookDir(bookDir, composed.ruleStackPath),
1087
+ tracePath: relativeToBookDir(bookDir, composed.tracePath),
1088
+ };
1089
+ }
1090
+
1091
+ /** Audit the latest (or specified) chapter. Read-only, no lock needed. */
1092
+ async auditDraft(bookId: string, chapterNumber?: number): Promise<AuditResult & { readonly chapterNumber: number }> {
1093
+ const book = await this.state.loadBookConfig(bookId);
1094
+ const bookDir = this.state.bookDir(bookId);
1095
+ const targetChapter = chapterNumber ?? (await this.state.getNextChapterNumber(bookId)) - 1;
1096
+ if (targetChapter < 1) {
1097
+ throw new Error(`No chapters to audit for "${bookId}"`);
1098
+ }
1099
+
1100
+ const content = await this.readChapterContent(bookDir, targetChapter);
1101
+ const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
1102
+ const { profile: gp } = await this.loadGenreProfile(book.genre);
1103
+ const language = book.language ?? gp.language;
1104
+ this.logStage(language, {
1105
+ zh: `审计第${targetChapter}章`,
1106
+ en: `auditing chapter ${targetChapter}`,
1107
+ });
1108
+ const evaluation = await this.evaluateMergedAudit({
1109
+ auditor,
1110
+ book,
1111
+ bookDir,
1112
+ chapterContent: content,
1113
+ chapterNumber: targetChapter,
1114
+ language,
1115
+ });
1116
+ const result = evaluation.auditResult;
1117
+
1118
+ // Update index with audit result
1119
+ const index = await this.state.loadChapterIndex(bookId);
1120
+ const updated = index.map((ch) =>
1121
+ ch.number === targetChapter
1122
+ ? {
1123
+ ...ch,
1124
+ status: (result.passed ? "ready-for-review" : "audit-failed") as ChapterMeta["status"],
1125
+ updatedAt: new Date().toISOString(),
1126
+ auditIssues: result.issues.map((i) => `[${i.severity}] ${i.description}`),
1127
+ }
1128
+ : ch,
1129
+ );
1130
+ await this.state.saveChapterIndex(bookId, updated);
1131
+ const latestChapter = index.length > 0 ? Math.max(...index.map((chapter) => chapter.number)) : targetChapter;
1132
+ if (targetChapter === latestChapter) {
1133
+ await this.persistAuditDriftGuidance({
1134
+ bookDir,
1135
+ chapterNumber: targetChapter,
1136
+ issues: result.issues.filter((issue) => issue.severity === "critical" || issue.severity === "warning"),
1137
+ language,
1138
+ }).catch(() => undefined);
1139
+ }
1140
+
1141
+ await this.emitWebhook(
1142
+ result.passed ? "audit-passed" : "audit-failed",
1143
+ bookId,
1144
+ targetChapter,
1145
+ { summary: result.summary, issueCount: result.issues.length },
1146
+ );
1147
+
1148
+ return { ...result, chapterNumber: targetChapter };
1149
+ }
1150
+
1151
+ /** Revise the latest (or specified) chapter based on audit issues. */
1152
+ async reviseDraft(bookId: string, chapterNumber?: number, mode: ReviseMode = DEFAULT_REVISE_MODE): Promise<ReviseResult> {
1153
+ const releaseLock = await this.state.acquireBookLock(bookId);
1154
+ try {
1155
+ const book = await this.state.loadBookConfig(bookId);
1156
+ const bookDir = this.state.bookDir(bookId);
1157
+ const targetChapter = chapterNumber ?? (await this.state.getNextChapterNumber(bookId)) - 1;
1158
+ if (targetChapter < 1) {
1159
+ throw new Error(`No chapters to revise for "${bookId}"`);
1160
+ }
1161
+
1162
+ const stageLanguage = await this.resolveBookLanguage(book);
1163
+ // Read the current audit issues from index
1164
+ this.logStage(stageLanguage, {
1165
+ zh: `加载第${targetChapter}章修订上下文`,
1166
+ en: `loading revision context for chapter ${targetChapter}`,
1167
+ });
1168
+ const index = await this.state.loadChapterIndex(bookId);
1169
+ const chapterMeta = index.find((ch) => ch.number === targetChapter);
1170
+ if (!chapterMeta) {
1171
+ throw new Error(`Chapter ${targetChapter} not found in index`);
1172
+ }
1173
+
1174
+ // Re-audit to get structured issues (index only stores strings)
1175
+ const content = await this.readChapterContent(bookDir, targetChapter);
1176
+ const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
1177
+ const { profile: gp } = await this.loadGenreProfile(book.genre);
1178
+ const language = book.language ?? gp.language;
1179
+ const countingMode = resolveLengthCountingMode(language);
1180
+ const reviseControlInput = (this.config.inputGovernanceMode ?? "v2") === "legacy"
1181
+ ? undefined
1182
+ : await this.createGovernedArtifacts(
1183
+ book,
1184
+ bookDir,
1185
+ targetChapter,
1186
+ this.config.externalContext,
1187
+ { reuseExistingIntentWhenContextMissing: true },
1188
+ );
1189
+ const preRevision = await this.evaluateMergedAudit({
1190
+ auditor,
1191
+ book,
1192
+ bookDir,
1193
+ chapterContent: content,
1194
+ chapterNumber: targetChapter,
1195
+ language,
1196
+ auditOptions: reviseControlInput
1197
+ ? {
1198
+ chapterIntent: reviseControlInput.plan.intentMarkdown,
1199
+ chapterMemo: reviseControlInput.plan.memo,
1200
+ contextPackage: reviseControlInput.composed.contextPackage,
1201
+ ruleStack: reviseControlInput.composed.ruleStack,
1202
+ }
1203
+ : undefined,
1204
+ });
1205
+
1206
+ if (preRevision.blockingCount === 0 && preRevision.aiTellCount === 0) {
1207
+ return {
1208
+ chapterNumber: targetChapter,
1209
+ wordCount: countChapterLength(content, countingMode),
1210
+ fixedIssues: [],
1211
+ applied: false,
1212
+ status: "unchanged",
1213
+ skippedReason: "No warning, critical, or AI-tell issues to fix.",
1214
+ };
1215
+ }
1216
+
1217
+ const chapterLengthTarget = chapterMeta.lengthTelemetry?.target ?? book.chapterWordCount;
1218
+ const lengthLanguage = chapterMeta.lengthTelemetry?.countingMode === "en_words"
1219
+ ? "en"
1220
+ : language;
1221
+ const lengthSpec = buildLengthSpec(
1222
+ chapterLengthTarget,
1223
+ lengthLanguage,
1224
+ );
1225
+
1226
+ const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
1227
+ this.logStage(stageLanguage, {
1228
+ zh: `修订第${targetChapter}章`,
1229
+ en: `revising chapter ${targetChapter}`,
1230
+ });
1231
+ const reviseOutput = await reviser.reviseChapter(
1232
+ bookDir,
1233
+ content,
1234
+ targetChapter,
1235
+ preRevision.auditResult.issues,
1236
+ mode,
1237
+ book.genre,
1238
+ reviseControlInput
1239
+ ? {
1240
+ chapterIntent: reviseControlInput.plan.intentMarkdown,
1241
+ chapterMemo: reviseControlInput.plan.memo,
1242
+ chapterIntentData: reviseControlInput.plan.intent,
1243
+ contextPackage: reviseControlInput.composed.contextPackage,
1244
+ ruleStack: reviseControlInput.composed.ruleStack,
1245
+ lengthSpec,
1246
+ }
1247
+ : { lengthSpec },
1248
+ );
1249
+
1250
+ if (reviseOutput.revisedContent.length === 0) {
1251
+ throw new Error("Reviser returned empty content");
1252
+ }
1253
+ const normalizedRevision = await this.normalizeDraftLengthIfNeeded({
1254
+ bookId,
1255
+ chapterNumber: targetChapter,
1256
+ chapterContent: reviseOutput.revisedContent,
1257
+ lengthSpec,
1258
+ });
1259
+ const postRevision = await this.evaluateMergedAudit({
1260
+ auditor,
1261
+ book,
1262
+ bookDir,
1263
+ chapterContent: normalizedRevision.content,
1264
+ chapterNumber: targetChapter,
1265
+ language,
1266
+ auditOptions: reviseControlInput
1267
+ ? {
1268
+ temperature: 0,
1269
+ chapterIntent: reviseControlInput.plan.intentMarkdown,
1270
+ chapterMemo: reviseControlInput.plan.memo,
1271
+ contextPackage: reviseControlInput.composed.contextPackage,
1272
+ ruleStack: reviseControlInput.composed.ruleStack,
1273
+ truthFileOverrides: {
1274
+ currentState: reviseOutput.updatedState !== "(状态卡未更新)" ? reviseOutput.updatedState : undefined,
1275
+ ledger: reviseOutput.updatedLedger !== "(账本未更新)" ? reviseOutput.updatedLedger : undefined,
1276
+ hooks: reviseOutput.updatedHooks !== "(伏笔池未更新)" ? reviseOutput.updatedHooks : undefined,
1277
+ },
1278
+ }
1279
+ : {
1280
+ temperature: 0,
1281
+ truthFileOverrides: {
1282
+ currentState: reviseOutput.updatedState !== "(状态卡未更新)" ? reviseOutput.updatedState : undefined,
1283
+ ledger: reviseOutput.updatedLedger !== "(账本未更新)" ? reviseOutput.updatedLedger : undefined,
1284
+ hooks: reviseOutput.updatedHooks !== "(伏笔池未更新)" ? reviseOutput.updatedHooks : undefined,
1285
+ },
1286
+ },
1287
+ });
1288
+ const effectivePostRevision = this.restoreActionableAuditIfLost(
1289
+ preRevision,
1290
+ postRevision,
1291
+ );
1292
+ const revisionBaseCount = countChapterLength(content, lengthSpec.countingMode);
1293
+ const lengthWarnings = this.buildLengthWarnings(
1294
+ targetChapter,
1295
+ normalizedRevision.wordCount,
1296
+ lengthSpec,
1297
+ );
1298
+ const lengthTelemetry = this.buildLengthTelemetry({
1299
+ lengthSpec,
1300
+ writerCount: revisionBaseCount,
1301
+ postWriterNormalizeCount: 0,
1302
+ postReviseCount: normalizedRevision.wordCount,
1303
+ finalCount: normalizedRevision.wordCount,
1304
+ normalizeApplied: normalizedRevision.applied,
1305
+ lengthWarning: lengthWarnings.length > 0,
1306
+ });
1307
+
1308
+ const improvedBlocking = effectivePostRevision.blockingCount < preRevision.blockingCount;
1309
+ const improvedAITells = effectivePostRevision.aiTellCount < preRevision.aiTellCount;
1310
+ const blockingDidNotWorsen = effectivePostRevision.blockingCount <= preRevision.blockingCount;
1311
+ const criticalDidNotWorsen = effectivePostRevision.criticalCount <= preRevision.criticalCount;
1312
+ const aiDidNotWorsen = effectivePostRevision.aiTellCount <= preRevision.aiTellCount;
1313
+ const shouldApplyRevision = blockingDidNotWorsen
1314
+ && criticalDidNotWorsen
1315
+ && aiDidNotWorsen
1316
+ && (improvedBlocking || improvedAITells);
1317
+
1318
+ if (!shouldApplyRevision) {
1319
+ return {
1320
+ chapterNumber: targetChapter,
1321
+ wordCount: revisionBaseCount,
1322
+ fixedIssues: [],
1323
+ applied: false,
1324
+ status: "unchanged",
1325
+ skippedReason: "Manual revision did not improve merged audit or AI-tell metrics; kept original chapter.",
1326
+ };
1327
+ }
1328
+ this.logLengthWarnings(lengthWarnings);
1329
+
1330
+ // Save revised chapter file
1331
+ this.logStage(stageLanguage, {
1332
+ zh: `落盘第${targetChapter}章修订结果`,
1333
+ en: `persisting revision for chapter ${targetChapter}`,
1334
+ });
1335
+ const chaptersDir = join(bookDir, "chapters");
1336
+ const files = await readdir(chaptersDir);
1337
+ const paddedNum = String(targetChapter).padStart(4, "0");
1338
+ const existingFile = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
1339
+ if (!existingFile) {
1340
+ throw new Error(`Chapter ${targetChapter} file not found in ${chaptersDir} (expected filename starting with ${paddedNum})`);
1341
+ }
1342
+ const reviseLang = book.language ?? gp.language;
1343
+ const reviseHeading = reviseLang === "en"
1344
+ ? `# Chapter ${targetChapter}: ${chapterMeta.title}`
1345
+ : `# 第${targetChapter}章 ${chapterMeta.title}`;
1346
+ await writeFile(
1347
+ join(chaptersDir, existingFile),
1348
+ `${reviseHeading}\n\n${normalizedRevision.content}`,
1349
+ "utf-8",
1350
+ );
1351
+
1352
+ // Update truth files
1353
+ const storyDir = join(bookDir, "story");
1354
+ if (reviseOutput.updatedState !== "(状态卡未更新)") {
1355
+ await writeFile(join(storyDir, "current_state.md"), reviseOutput.updatedState, "utf-8");
1356
+ }
1357
+ if (gp.numericalSystem && reviseOutput.updatedLedger && reviseOutput.updatedLedger !== "(账本未更新)") {
1358
+ await writeFile(join(storyDir, "particle_ledger.md"), reviseOutput.updatedLedger, "utf-8");
1359
+ }
1360
+ if (reviseOutput.updatedHooks !== "(伏笔池未更新)") {
1361
+ await writeFile(join(storyDir, "pending_hooks.md"), reviseOutput.updatedHooks, "utf-8");
1362
+ }
1363
+ await this.syncLegacyStructuredStateFromMarkdown(bookDir, targetChapter);
1364
+
1365
+ // Update index
1366
+ const updatedIndex = index.map((ch) =>
1367
+ ch.number === targetChapter
1368
+ ? {
1369
+ ...ch,
1370
+ status: (effectivePostRevision.auditResult.passed ? "ready-for-review" : "audit-failed") as ChapterMeta["status"],
1371
+ wordCount: normalizedRevision.wordCount,
1372
+ updatedAt: new Date().toISOString(),
1373
+ auditIssues: effectivePostRevision.auditResult.issues.map((i) => `[${i.severity}] ${i.description}`),
1374
+ lengthWarnings,
1375
+ lengthTelemetry,
1376
+ }
1377
+ : ch,
1378
+ );
1379
+ await this.state.saveChapterIndex(bookId, updatedIndex);
1380
+ const latestChapter = index.length > 0 ? Math.max(...index.map((chapter) => chapter.number)) : targetChapter;
1381
+ if (targetChapter === latestChapter) {
1382
+ await this.persistAuditDriftGuidance({
1383
+ bookDir,
1384
+ chapterNumber: targetChapter,
1385
+ issues: effectivePostRevision.auditResult.issues.filter(
1386
+ (issue) => issue.severity === "critical" || issue.severity === "warning",
1387
+ ),
1388
+ language,
1389
+ }).catch(() => undefined);
1390
+ }
1391
+
1392
+ // Re-snapshot
1393
+ this.logStage(stageLanguage, {
1394
+ zh: `更新第${targetChapter}章索引与快照`,
1395
+ en: `updating chapter index and snapshots for chapter ${targetChapter}`,
1396
+ });
1397
+ await this.state.snapshotState(bookId, targetChapter);
1398
+ await this.syncNarrativeMemoryIndex(bookId);
1399
+ await this.syncCurrentStateFactHistory(bookId, targetChapter);
1400
+
1401
+ await this.emitWebhook("revision-complete", bookId, targetChapter, {
1402
+ wordCount: normalizedRevision.wordCount,
1403
+ fixedCount: reviseOutput.fixedIssues.length,
1404
+ });
1405
+
1406
+ return {
1407
+ chapterNumber: targetChapter,
1408
+ wordCount: normalizedRevision.wordCount,
1409
+ fixedIssues: reviseOutput.fixedIssues,
1410
+ applied: true,
1411
+ status: effectivePostRevision.auditResult.passed ? "ready-for-review" : "audit-failed",
1412
+ lengthWarnings,
1413
+ lengthTelemetry,
1414
+ };
1415
+ } finally {
1416
+ await releaseLock();
1417
+ }
1418
+ }
1419
+
1420
+ /** Read all truth files for a book. */
1421
+ async readTruthFiles(bookId: string): Promise<TruthFiles> {
1422
+ const bookDir = this.state.bookDir(bookId);
1423
+ const storyDir = join(bookDir, "story");
1424
+ const readSafe = async (path: string): Promise<string> => {
1425
+ try {
1426
+ return await readFile(path, "utf-8");
1427
+ } catch {
1428
+ return "(文件不存在)";
1429
+ }
1430
+ };
1431
+
1432
+ // Phase 5: prefer the new prose outline files; fall back to legacy paths.
1433
+ const readOutline = async (newRel: string, legacyRel: string): Promise<string> => {
1434
+ const preferred = await readSafe(join(storyDir, newRel));
1435
+ if (preferred.trim() && preferred !== "(文件不存在)") return preferred;
1436
+ return readSafe(join(storyDir, legacyRel));
1437
+ };
1438
+
1439
+ const [currentState, particleLedger, pendingHooks, storyBible, volumeOutline, bookRules] =
1440
+ await Promise.all([
1441
+ readSafe(join(storyDir, "current_state.md")),
1442
+ readSafe(join(storyDir, "particle_ledger.md")),
1443
+ readSafe(join(storyDir, "pending_hooks.md")),
1444
+ readOutline("outline/story_frame.md", "story_bible.md"),
1445
+ readOutline("outline/volume_map.md", "volume_outline.md"),
1446
+ readSafe(join(storyDir, "book_rules.md")),
1447
+ ]);
1448
+
1449
+ return { currentState, particleLedger, pendingHooks, storyBible, volumeOutline, bookRules };
1450
+ }
1451
+
1452
+ /** Get book status overview. */
1453
+ async getBookStatus(bookId: string): Promise<BookStatusInfo> {
1454
+ const book = await this.state.loadBookConfig(bookId);
1455
+ const chapters = await this.state.loadChapterIndex(bookId);
1456
+ const nextChapter = await this.state.getNextChapterNumber(bookId);
1457
+ const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
1458
+
1459
+ return {
1460
+ bookId,
1461
+ title: book.title,
1462
+ genre: book.genre,
1463
+ platform: book.platform,
1464
+ status: book.status,
1465
+ chaptersWritten: chapters.length,
1466
+ totalWords,
1467
+ nextChapter,
1468
+ chapters: [...chapters],
1469
+ };
1470
+ }
1471
+
1472
+ // ---------------------------------------------------------------------------
1473
+ // Full pipeline (convenience — runs draft + audit + revise in one shot)
1474
+ // ---------------------------------------------------------------------------
1475
+
1476
+ async writeNextChapter(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
1477
+ const releaseLock = await this.state.acquireBookLock(bookId);
1478
+ try {
1479
+ return await this._writeNextChapterLocked(bookId, wordCount, temperatureOverride, this.config.externalContext);
1480
+ } finally {
1481
+ await releaseLock();
1482
+ }
1483
+ }
1484
+
1485
+ async repairChapterState(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
1486
+ const releaseLock = await this.state.acquireBookLock(bookId);
1487
+ try {
1488
+ return await this._repairChapterStateLocked(bookId, chapterNumber);
1489
+ } finally {
1490
+ await releaseLock();
1491
+ }
1492
+ }
1493
+
1494
+ async resyncChapterArtifacts(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
1495
+ const releaseLock = await this.state.acquireBookLock(bookId);
1496
+ try {
1497
+ return await this._resyncChapterArtifactsLocked(bookId, chapterNumber);
1498
+ } finally {
1499
+ await releaseLock();
1500
+ }
1501
+ }
1502
+
1503
+ private async _writeNextChapterLocked(
1504
+ bookId: string,
1505
+ wordCount?: number,
1506
+ temperatureOverride?: number,
1507
+ externalContext?: string,
1508
+ ): Promise<ChapterPipelineResult> {
1509
+ await this.state.ensureControlDocuments(bookId);
1510
+ const book = await this.state.loadBookConfig(bookId);
1511
+ const bookDir = this.state.bookDir(bookId);
1512
+ await this.assertNoPendingStateRepair(bookId);
1513
+ const chapterNumber = await this.state.getNextChapterNumber(bookId);
1514
+ const stageLanguage = await this.resolveBookLanguage(book);
1515
+ this.logStage(stageLanguage, { zh: "准备章节输入", en: "preparing chapter inputs" });
1516
+ const writeInput = await this.prepareWriteInput(
1517
+ book,
1518
+ bookDir,
1519
+ chapterNumber,
1520
+ externalContext,
1521
+ );
1522
+ const reducedControlInput = writeInput.chapterIntent && writeInput.contextPackage && writeInput.ruleStack
1523
+ ? {
1524
+ chapterIntent: writeInput.chapterIntent,
1525
+ chapterMemo: writeInput.chapterMemo,
1526
+ chapterIntentData: writeInput.chapterIntentData,
1527
+ contextPackage: writeInput.contextPackage,
1528
+ ruleStack: writeInput.ruleStack,
1529
+ }
1530
+ : undefined;
1531
+ const { profile: gp } = await this.loadGenreProfile(book.genre);
1532
+ const pipelineLang = book.language ?? gp.language;
1533
+ const lengthSpec = buildLengthSpec(
1534
+ wordCount ?? book.chapterWordCount,
1535
+ pipelineLang,
1536
+ );
1537
+ const {
1538
+ normalizePostWriteSurface,
1539
+ validatePostWrite: postWriteValidate,
1540
+ } = await import("../agents/post-write-validator.js");
1541
+ const { validateHookLedger } = await import("../utils/hook-ledger-validator.js");
1542
+ const { readBookRules } = await import("../agents/rules-reader.js");
1543
+ const parsedBookRules = (await readBookRules(bookDir))?.rules ?? null;
1544
+
1545
+ // 1. Write chapter
1546
+ const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
1547
+ this.logStage(stageLanguage, { zh: "撰写章节草稿", en: "writing chapter draft" });
1548
+ const output = await writer.writeChapter({
1549
+ book,
1550
+ bookDir,
1551
+ chapterNumber,
1552
+ ...writeInput,
1553
+ lengthSpec,
1554
+ ...(wordCount ? { wordCountOverride: wordCount } : {}),
1555
+ ...(temperatureOverride ? { temperatureOverride } : {}),
1556
+ });
1557
+ const writerCount = countChapterLength(output.content, lengthSpec.countingMode);
1558
+
1559
+ // Token usage accumulator
1560
+ let totalUsage: TokenUsageSummary = output.tokenUsage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1561
+ const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
1562
+ const reviewResult = await runChapterReviewCycle({
1563
+ book: { genre: book.genre },
1564
+ bookDir,
1565
+ chapterNumber,
1566
+ initialOutput: output,
1567
+ reducedControlInput,
1568
+ lengthSpec,
1569
+ initialUsage: totalUsage,
1570
+ createReviser: () => new ReviserAgent(this.agentCtxFor("reviser", bookId)),
1571
+ auditor,
1572
+ normalizeDraftLengthIfNeeded: (chapterContent) => this.normalizeDraftLengthIfNeeded({
1573
+ bookId,
1574
+ chapterNumber,
1575
+ chapterContent,
1576
+ lengthSpec,
1577
+ chapterIntent: writeInput.chapterIntent,
1578
+ }),
1579
+ normalizePostWriteSurface: (chapterContent) =>
1580
+ normalizePostWriteSurface(chapterContent, pipelineLang),
1581
+ assertChapterContentNotEmpty: (content, stage) =>
1582
+ this.assertChapterContentNotEmpty(content, chapterNumber, stage),
1583
+ addUsage: PipelineRunner.addUsage,
1584
+ analyzeAITells: (content) => analyzeAITells(content, pipelineLang),
1585
+ analyzeSensitiveWords: (content) => analyzeSensitiveWords(content, undefined, pipelineLang),
1586
+ runPostWriteChecks: (content) => {
1587
+ const baseIssues = postWriteValidate(content, gp, parsedBookRules, pipelineLang)
1588
+ .filter((v) => v.severity === "error")
1589
+ .map((v) => ({
1590
+ severity: "critical" as const,
1591
+ category: v.rule,
1592
+ description: v.description,
1593
+ suggestion: v.suggestion,
1594
+ }));
1595
+ // Phase 9-3: verify the draft acts on every hook the memo committed to.
1596
+ const memoBody = writeInput.chapterMemo?.body ?? "";
1597
+ const ledgerIssues = memoBody
1598
+ ? validateHookLedger(memoBody, content)
1599
+ : [];
1600
+ return [...baseIssues, ...ledgerIssues];
1601
+ },
1602
+ maxReviewIterations: this.config.writingReviewRetries,
1603
+ logWarn: (message) => this.logWarn(pipelineLang, message),
1604
+ logStage: (message) => this.logStage(stageLanguage, message),
1605
+ });
1606
+ totalUsage = reviewResult.totalUsage;
1607
+ let finalContent = reviewResult.finalContent;
1608
+ let finalWordCount = reviewResult.finalWordCount;
1609
+ let revised = reviewResult.revised;
1610
+ let auditResult = reviewResult.auditResult;
1611
+ const postReviseCount = reviewResult.postReviseCount;
1612
+ const normalizeApplied = reviewResult.normalizeApplied;
1613
+
1614
+ // 3b. Lightweight per-chapter promotion pass — check if any hooks should
1615
+ // be promoted based on advanced_count derived from chapter_summaries.
1616
+ // Runs BEFORE persistence so the reviewer of the NEXT chapter sees the
1617
+ // updated ledger. No LLM calls — pure ledger parse + threshold check.
1618
+ {
1619
+ const { rerunPromotionPass } = await import("../utils/hook-promotion.js");
1620
+ const { parsePendingHooksMarkdown, renderHookSnapshot } = await import("../utils/story-markdown.js");
1621
+ const promotionStoryDir = join(bookDir, "story");
1622
+ const ledgerPath = join(promotionStoryDir, "pending_hooks.md");
1623
+ const ledgerRaw = await readFile(ledgerPath, "utf-8").catch(() => "");
1624
+ if (ledgerRaw.trim()) {
1625
+ const hooks = parsePendingHooksMarkdown(ledgerRaw);
1626
+ if (hooks.length > 0) {
1627
+ const summariesRaw = await readFile(join(promotionStoryDir, "chapter_summaries.md"), "utf-8").catch(() => "");
1628
+ const promotionResult = rerunPromotionPass(hooks, summariesRaw);
1629
+ if (promotionResult.updated) {
1630
+ const ledgerLang: "zh" | "en" = /[\u4e00-\u9fff]/.test(ledgerRaw) ? "zh" : "en";
1631
+ await writeFile(ledgerPath, renderHookSnapshot([...promotionResult.hooks], ledgerLang), "utf-8");
1632
+ this.config.logger?.info(`[promotion] ${promotionResult.flippedCount} hook(s) promoted after chapter ${chapterNumber}`);
1633
+ }
1634
+ }
1635
+ }
1636
+ }
1637
+
1638
+ // 4. Save the final chapter and truth files from a single persistence source
1639
+ this.logStage(stageLanguage, { zh: "落盘最终章节", en: "persisting final chapter" });
1640
+ this.logStage(stageLanguage, { zh: "生成最终真相文件", en: "rebuilding final truth files" });
1641
+ const chapterIndexBeforePersist = await this.state.loadChapterIndex(bookId);
1642
+ const { resolveDuplicateTitle } = await import("../agents/post-write-validator.js");
1643
+ const initialTitleResolution = resolveDuplicateTitle(
1644
+ output.title,
1645
+ chapterIndexBeforePersist.map((chapter) => chapter.title),
1646
+ pipelineLang,
1647
+ { content: finalContent },
1648
+ );
1649
+ let persistenceOutput = await this.buildPersistenceOutput(
1650
+ bookId,
1651
+ book,
1652
+ bookDir,
1653
+ chapterNumber,
1654
+ initialTitleResolution.title === output.title
1655
+ ? output
1656
+ : { ...output, title: initialTitleResolution.title },
1657
+ finalContent,
1658
+ lengthSpec.countingMode,
1659
+ reducedControlInput,
1660
+ );
1661
+ const finalTitleResolution = resolveDuplicateTitle(
1662
+ persistenceOutput.title,
1663
+ chapterIndexBeforePersist.map((chapter) => chapter.title),
1664
+ pipelineLang,
1665
+ { content: finalContent },
1666
+ );
1667
+ if (finalTitleResolution.title !== persistenceOutput.title) {
1668
+ persistenceOutput = {
1669
+ ...persistenceOutput,
1670
+ title: finalTitleResolution.title,
1671
+ };
1672
+ }
1673
+ if (persistenceOutput.title !== output.title) {
1674
+ const description = pipelineLang === "en"
1675
+ ? `Chapter title "${output.title}" was auto-adjusted to "${persistenceOutput.title}".`
1676
+ : `章节标题"${output.title}"已自动调整为"${persistenceOutput.title}"。`;
1677
+ this.config.logger?.warn(`[title] ${description}`);
1678
+ auditResult = {
1679
+ ...auditResult,
1680
+ issues: [...auditResult.issues, {
1681
+ severity: "warning",
1682
+ category: "title-dedup",
1683
+ description,
1684
+ suggestion: pipelineLang === "en"
1685
+ ? "If the auto-renamed title is weak, revise the chapter title manually."
1686
+ : "如果自动改名不理想,可以在后续手动修订章节标题。",
1687
+ }],
1688
+ };
1689
+ }
1690
+ const longSpanFatigue = await analyzeLongSpanFatigue({
1691
+ bookDir,
1692
+ chapterNumber,
1693
+ chapterContent: finalContent,
1694
+ chapterSummary: persistenceOutput.chapterSummary,
1695
+ language: pipelineLang,
1696
+ });
1697
+ auditResult = {
1698
+ ...auditResult,
1699
+ issues: [
1700
+ ...auditResult.issues,
1701
+ ...longSpanFatigue.issues,
1702
+ ...(persistenceOutput.hookHealthIssues ?? []),
1703
+ ],
1704
+ };
1705
+ finalWordCount = persistenceOutput.wordCount;
1706
+ const lengthWarnings = this.buildLengthWarnings(
1707
+ chapterNumber,
1708
+ finalWordCount,
1709
+ lengthSpec,
1710
+ );
1711
+ const lengthTelemetry = this.buildLengthTelemetry({
1712
+ lengthSpec,
1713
+ writerCount,
1714
+ postWriterNormalizeCount: reviewResult.preAuditNormalizedWordCount,
1715
+ postReviseCount,
1716
+ finalCount: finalWordCount,
1717
+ normalizeApplied,
1718
+ lengthWarning: lengthWarnings.length > 0,
1719
+ });
1720
+ this.logLengthWarnings(lengthWarnings);
1721
+
1722
+ // 4.1 Validate settler output before writing
1723
+ this.logStage(stageLanguage, { zh: "校验真相文件变更", en: "validating truth file updates" });
1724
+ const storyDir = join(bookDir, "story");
1725
+ const [oldState, oldHooks, oldLedger, authorityStoryFrame, authorityBookRules, authorityChapterSummaries] = await Promise.all([
1726
+ readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
1727
+ readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
1728
+ readFile(join(storyDir, "particle_ledger.md"), "utf-8").catch(() => ""),
1729
+ readStoryFrame(bookDir).catch(() => ""),
1730
+ readFile(join(storyDir, "book_rules.md"), "utf-8").catch(() => ""),
1731
+ readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
1732
+ ]);
1733
+ const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
1734
+ const truthValidation = await validateChapterTruthPersistence({
1735
+ writer,
1736
+ validator,
1737
+ book,
1738
+ bookDir,
1739
+ chapterNumber,
1740
+ title: persistenceOutput.title,
1741
+ content: finalContent,
1742
+ persistenceOutput,
1743
+ auditResult,
1744
+ previousTruth: {
1745
+ oldState,
1746
+ oldHooks,
1747
+ oldLedger,
1748
+ },
1749
+ authorityContext: {
1750
+ storyFrame: authorityStoryFrame,
1751
+ bookRules: authorityBookRules,
1752
+ chapterSummaries: authorityChapterSummaries,
1753
+ },
1754
+ reducedControlInput,
1755
+ language: pipelineLang,
1756
+ logWarn: (message) => this.logWarn(pipelineLang, message),
1757
+ logger: this.config.logger,
1758
+ });
1759
+ let chapterStatus: ChapterPipelineResult["status"] | null = truthValidation.chapterStatus;
1760
+ let degradedIssues: ReadonlyArray<AuditIssue> = truthValidation.degradedIssues;
1761
+ persistenceOutput = truthValidation.persistenceOutput;
1762
+ auditResult = truthValidation.auditResult;
1763
+
1764
+ // 4.2 Final paragraph shape check on persisted content (post-normalize, post-revise)
1765
+ {
1766
+ const {
1767
+ detectParagraphLengthDrift,
1768
+ detectParagraphShapeWarnings,
1769
+ } = await import("../agents/post-write-validator.js");
1770
+ const chapDir = join(bookDir, "chapters");
1771
+ const recentFiles = (await readdir(chapDir).catch(() => [] as string[]))
1772
+ .filter((f) => f.endsWith(".md") && /^\d{4}/.test(f))
1773
+ .sort()
1774
+ .slice(-5);
1775
+ const recentContent = (await Promise.all(
1776
+ recentFiles.map((f) => readFile(join(chapDir, f), "utf-8").catch(() => "")),
1777
+ )).join("\n\n");
1778
+ const paragraphIssues = [
1779
+ ...detectParagraphShapeWarnings(finalContent, pipelineLang),
1780
+ ...detectParagraphLengthDrift(finalContent, recentContent, pipelineLang),
1781
+ ];
1782
+ if (paragraphIssues.length > 0) {
1783
+ for (const issue of paragraphIssues) {
1784
+ this.config.logger?.warn(`[paragraph] ${issue.description}`);
1785
+ }
1786
+ auditResult = {
1787
+ ...auditResult,
1788
+ issues: [...auditResult.issues, ...paragraphIssues.map((v) => ({
1789
+ severity: v.severity as "warning",
1790
+ category: "paragraph-shape",
1791
+ description: v.description,
1792
+ suggestion: v.suggestion,
1793
+ }))],
1794
+ };
1795
+ }
1796
+ }
1797
+
1798
+ const resolvedStatus = chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed");
1799
+ await persistChapterArtifacts({
1800
+ chapterNumber,
1801
+ chapterTitle: persistenceOutput.title,
1802
+ status: resolvedStatus,
1803
+ auditResult,
1804
+ finalWordCount,
1805
+ lengthWarnings,
1806
+ lengthTelemetry,
1807
+ degradedIssues,
1808
+ tokenUsage: totalUsage,
1809
+ loadChapterIndex: () => this.state.loadChapterIndex(bookId),
1810
+ saveChapter: () => writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang),
1811
+ saveTruthFiles: async () => {
1812
+ await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang);
1813
+ await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput);
1814
+ this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" });
1815
+ await this.syncNarrativeMemoryIndex(bookId);
1816
+ },
1817
+ saveChapterIndex: (index) => this.state.saveChapterIndex(bookId, index),
1818
+ markBookActiveIfNeeded: () => this.markBookActiveIfNeeded(bookId),
1819
+ persistAuditDriftGuidance: (issues) => this.persistAuditDriftGuidance({
1820
+ bookDir,
1821
+ chapterNumber,
1822
+ issues,
1823
+ language: stageLanguage,
1824
+ }).catch(() => undefined),
1825
+ snapshotState: () => this.state.snapshotState(bookId, chapterNumber),
1826
+ syncCurrentStateFactHistory: () => this.syncCurrentStateFactHistory(bookId, chapterNumber),
1827
+ logSnapshotStage: () =>
1828
+ this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" }),
1829
+ });
1830
+
1831
+ // 6. Send notification
1832
+ if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
1833
+ const statusEmoji = resolvedStatus === "state-degraded"
1834
+ ? "🧯"
1835
+ : auditResult.passed ? "✅" : "⚠️";
1836
+ const chapterLength = formatLengthCount(finalWordCount, lengthSpec.countingMode);
1837
+ await dispatchNotification(this.config.notifyChannels, {
1838
+ title: `${statusEmoji} ${book.title} 第${chapterNumber}章`,
1839
+ body: [
1840
+ `**${persistenceOutput.title}** | ${chapterLength}`,
1841
+ revised ? "📝 已自动修正" : "",
1842
+ resolvedStatus === "state-degraded"
1843
+ ? "状态结算: 已降级保存,需先修复 state 再继续"
1844
+ : `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`,
1845
+ ...auditResult.issues
1846
+ .filter((i) => i.severity !== "info")
1847
+ .map((i) => `- [${i.severity}] ${i.description}`),
1848
+ ]
1849
+ .filter(Boolean)
1850
+ .join("\n"),
1851
+ });
1852
+ }
1853
+
1854
+ await this.emitWebhook("pipeline-complete", bookId, chapterNumber, {
1855
+ title: persistenceOutput.title,
1856
+ wordCount: finalWordCount,
1857
+ passed: auditResult.passed,
1858
+ revised,
1859
+ status: resolvedStatus,
1860
+ });
1861
+
1862
+ return {
1863
+ chapterNumber,
1864
+ title: persistenceOutput.title,
1865
+ wordCount: finalWordCount,
1866
+ auditResult,
1867
+ revised,
1868
+ status: resolvedStatus,
1869
+ lengthWarnings,
1870
+ lengthTelemetry,
1871
+ tokenUsage: totalUsage,
1872
+ };
1873
+ }
1874
+
1875
+ private async _repairChapterStateLocked(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
1876
+ const book = await this.state.loadBookConfig(bookId);
1877
+ const bookDir = this.state.bookDir(bookId);
1878
+ const stageLanguage = await this.resolveBookLanguage(book);
1879
+ const index = [...(await this.state.loadChapterIndex(bookId))];
1880
+ if (index.length === 0) {
1881
+ throw new Error(`Book "${bookId}" has no persisted chapters to repair.`);
1882
+ }
1883
+
1884
+ const targetChapter = chapterNumber ?? index[index.length - 1]!.number;
1885
+ const targetIndex = index.findIndex((chapter) => chapter.number === targetChapter);
1886
+ if (targetIndex < 0) {
1887
+ throw new Error(`Chapter ${targetChapter} not found in "${bookId}".`);
1888
+ }
1889
+ const targetMeta = index[targetIndex]!;
1890
+ const latestChapter = Math.max(...index.map((chapter) => chapter.number));
1891
+ if (targetMeta.status !== "state-degraded") {
1892
+ throw new Error(`Chapter ${targetChapter} is not state-degraded.`);
1893
+ }
1894
+ if (targetChapter !== latestChapter) {
1895
+ throw new Error(`Only the latest state-degraded chapter can be repaired safely (latest is ${latestChapter}).`);
1896
+ }
1897
+
1898
+ this.logStage(stageLanguage, { zh: "修复章节状态结算", en: "repairing chapter state settlement" });
1899
+ const { profile: gp } = await this.loadGenreProfile(book.genre);
1900
+ const pipelineLang = book.language ?? gp.language;
1901
+ const content = await this.readChapterContent(bookDir, targetChapter);
1902
+ const storyDir = join(bookDir, "story");
1903
+ const [oldState, oldHooks] = await Promise.all([
1904
+ readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
1905
+ readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
1906
+ ]);
1907
+
1908
+ const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
1909
+ let repairedOutput = await writer.settleChapterState({
1910
+ book,
1911
+ bookDir,
1912
+ chapterNumber: targetChapter,
1913
+ title: targetMeta.title,
1914
+ content,
1915
+ allowReapply: true,
1916
+ });
1917
+ const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
1918
+ let validation = await validator.validate(
1919
+ content,
1920
+ targetChapter,
1921
+ oldState,
1922
+ repairedOutput.updatedState,
1923
+ oldHooks,
1924
+ repairedOutput.updatedHooks,
1925
+ pipelineLang,
1926
+ );
1927
+
1928
+ if (!validation.passed) {
1929
+ const recovery = await retrySettlementAfterValidationFailure({
1930
+ writer,
1931
+ validator,
1932
+ book,
1933
+ bookDir,
1934
+ chapterNumber: targetChapter,
1935
+ title: targetMeta.title,
1936
+ content,
1937
+ oldState,
1938
+ oldHooks,
1939
+ originalValidation: validation,
1940
+ language: pipelineLang,
1941
+ logWarn: (message) => this.logWarn(pipelineLang, message),
1942
+ logger: this.config.logger,
1943
+ });
1944
+ if (recovery.kind !== "recovered") {
1945
+ throw new Error(
1946
+ recovery.issues[0]?.description
1947
+ ?? `State repair still failed for chapter ${targetChapter}.`,
1948
+ );
1949
+ }
1950
+ repairedOutput = recovery.output;
1951
+ validation = recovery.validation;
1952
+ }
1953
+
1954
+ if (!validation.passed) {
1955
+ throw new Error(`State repair still failed for chapter ${targetChapter}.`);
1956
+ }
1957
+
1958
+ await writer.saveChapter(bookDir, repairedOutput, gp.numericalSystem, pipelineLang);
1959
+ await writer.saveNewTruthFiles(bookDir, repairedOutput, pipelineLang);
1960
+ await this.syncLegacyStructuredStateFromMarkdown(bookDir, targetChapter, repairedOutput);
1961
+ await this.syncNarrativeMemoryIndex(bookId);
1962
+ await this.state.snapshotState(bookId, targetChapter);
1963
+ await this.syncCurrentStateFactHistory(bookId, targetChapter);
1964
+
1965
+ const baseStatus = resolveStateDegradedBaseStatus(targetMeta);
1966
+ const degradedMetadata = parseStateDegradedReviewNote(targetMeta.reviewNote);
1967
+ const injectedIssues = new Set(degradedMetadata?.injectedIssues ?? []);
1968
+ index[targetIndex] = {
1969
+ ...targetMeta,
1970
+ status: baseStatus,
1971
+ updatedAt: new Date().toISOString(),
1972
+ auditIssues: targetMeta.auditIssues.filter((issue) => !injectedIssues.has(issue)),
1973
+ reviewNote: undefined,
1974
+ };
1975
+ await this.state.saveChapterIndex(bookId, index);
1976
+
1977
+ const repairedPassesAudit = baseStatus !== "audit-failed";
1978
+ return {
1979
+ chapterNumber: targetChapter,
1980
+ title: targetMeta.title,
1981
+ wordCount: targetMeta.wordCount,
1982
+ auditResult: {
1983
+ passed: repairedPassesAudit,
1984
+ issues: [],
1985
+ summary: repairedPassesAudit ? "state repaired" : "state repaired but chapter still needs review",
1986
+ },
1987
+ revised: false,
1988
+ status: baseStatus,
1989
+ lengthWarnings: targetMeta.lengthWarnings,
1990
+ lengthTelemetry: targetMeta.lengthTelemetry,
1991
+ tokenUsage: targetMeta.tokenUsage,
1992
+ };
1993
+ }
1994
+
1995
+ private async _resyncChapterArtifactsLocked(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
1996
+ const book = await this.state.loadBookConfig(bookId);
1997
+ const bookDir = this.state.bookDir(bookId);
1998
+ const stageLanguage = await this.resolveBookLanguage(book);
1999
+ const index = [...(await this.state.loadChapterIndex(bookId))];
2000
+ if (index.length === 0) {
2001
+ throw new Error(`Book "${bookId}" has no persisted chapters to sync.`);
2002
+ }
2003
+
2004
+ const targetChapter = chapterNumber ?? index[index.length - 1]!.number;
2005
+ const targetIndex = index.findIndex((chapter) => chapter.number === targetChapter);
2006
+ if (targetIndex < 0) {
2007
+ throw new Error(`Chapter ${targetChapter} not found in "${bookId}".`);
2008
+ }
2009
+
2010
+ const targetMeta = index[targetIndex]!;
2011
+ const latestChapter = Math.max(...index.map((chapter) => chapter.number));
2012
+ if (targetChapter !== latestChapter) {
2013
+ throw new Error(`Only the latest persisted chapter can be synced safely (latest is ${latestChapter}).`);
2014
+ }
2015
+
2016
+ this.logStage(stageLanguage, { zh: "根据已编辑正文同步真相文件与索引", en: "syncing truth files and indexes from edited chapter body" });
2017
+ const { profile: gp } = await this.loadGenreProfile(book.genre);
2018
+ const pipelineLang = book.language ?? gp.language;
2019
+ const content = await this.readChapterContent(bookDir, targetChapter);
2020
+ const storyDir = join(bookDir, "story");
2021
+ const [oldState, oldHooks] = await Promise.all([
2022
+ readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
2023
+ readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
2024
+ ]);
2025
+
2026
+ const reducedControlInput = (this.config.inputGovernanceMode ?? "v2") === "legacy"
2027
+ ? undefined
2028
+ : await this.createGovernedArtifacts(
2029
+ book,
2030
+ bookDir,
2031
+ targetChapter,
2032
+ this.config.externalContext,
2033
+ { reuseExistingIntentWhenContextMissing: true },
2034
+ );
2035
+
2036
+ const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
2037
+ let syncedOutput = await writer.settleChapterState({
2038
+ book,
2039
+ bookDir,
2040
+ chapterNumber: targetChapter,
2041
+ title: targetMeta.title,
2042
+ content,
2043
+ chapterIntent: reducedControlInput?.plan.intentMarkdown,
2044
+ contextPackage: reducedControlInput?.composed.contextPackage,
2045
+ ruleStack: reducedControlInput?.composed.ruleStack,
2046
+ allowReapply: true,
2047
+ });
2048
+ const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
2049
+ let validation = await validator.validate(
2050
+ content,
2051
+ targetChapter,
2052
+ oldState,
2053
+ syncedOutput.updatedState,
2054
+ oldHooks,
2055
+ syncedOutput.updatedHooks,
2056
+ pipelineLang,
2057
+ );
2058
+
2059
+ if (!validation.passed) {
2060
+ const recovery = await retrySettlementAfterValidationFailure({
2061
+ writer,
2062
+ validator,
2063
+ book,
2064
+ bookDir,
2065
+ chapterNumber: targetChapter,
2066
+ title: targetMeta.title,
2067
+ content,
2068
+ reducedControlInput: reducedControlInput
2069
+ ? {
2070
+ chapterIntent: reducedControlInput.plan.intentMarkdown,
2071
+ contextPackage: reducedControlInput.composed.contextPackage,
2072
+ ruleStack: reducedControlInput.composed.ruleStack,
2073
+ }
2074
+ : undefined,
2075
+ oldState,
2076
+ oldHooks,
2077
+ originalValidation: validation,
2078
+ language: pipelineLang,
2079
+ logWarn: (message) => this.logWarn(pipelineLang, message),
2080
+ logger: this.config.logger,
2081
+ });
2082
+ if (recovery.kind !== "recovered") {
2083
+ throw new Error(
2084
+ recovery.issues[0]?.description
2085
+ ?? `Chapter sync still failed for chapter ${targetChapter}.`,
2086
+ );
2087
+ }
2088
+ syncedOutput = recovery.output;
2089
+ validation = recovery.validation;
2090
+ }
2091
+
2092
+ if (!validation.passed) {
2093
+ throw new Error(`Chapter sync still failed for chapter ${targetChapter}.`);
2094
+ }
2095
+
2096
+ await writer.saveChapter(bookDir, syncedOutput, gp.numericalSystem, pipelineLang);
2097
+ await writer.saveNewTruthFiles(bookDir, syncedOutput, pipelineLang);
2098
+ await this.syncLegacyStructuredStateFromMarkdown(bookDir, targetChapter, syncedOutput);
2099
+ await this.syncNarrativeMemoryIndex(bookId);
2100
+ await this.state.snapshotState(bookId, targetChapter);
2101
+ await this.syncCurrentStateFactHistory(bookId, targetChapter);
2102
+
2103
+ const finalStatus: "ready-for-review" | "audit-failed" = targetMeta.status === "state-degraded"
2104
+ ? resolveStateDegradedBaseStatus(targetMeta)
2105
+ : "ready-for-review";
2106
+
2107
+ if (targetMeta.status === "state-degraded") {
2108
+ const degradedMetadata = parseStateDegradedReviewNote(targetMeta.reviewNote);
2109
+ const injectedIssues = new Set(degradedMetadata?.injectedIssues ?? []);
2110
+ index[targetIndex] = {
2111
+ ...targetMeta,
2112
+ status: finalStatus,
2113
+ updatedAt: new Date().toISOString(),
2114
+ auditIssues: targetMeta.auditIssues.filter((issue) => !injectedIssues.has(issue)),
2115
+ reviewNote: undefined,
2116
+ };
2117
+ } else {
2118
+ index[targetIndex] = {
2119
+ ...targetMeta,
2120
+ status: "ready-for-review",
2121
+ updatedAt: new Date().toISOString(),
2122
+ };
2123
+ }
2124
+ await this.state.saveChapterIndex(bookId, index);
2125
+ return {
2126
+ chapterNumber: targetChapter,
2127
+ title: targetMeta.title,
2128
+ wordCount: targetMeta.wordCount,
2129
+ auditResult: {
2130
+ passed: finalStatus !== "audit-failed",
2131
+ issues: [],
2132
+ summary: finalStatus === "audit-failed"
2133
+ ? "chapter truth/state resynced from edited body, but chapter still needs audit fixes"
2134
+ : "chapter truth/state resynced from edited body",
2135
+ },
2136
+ revised: false,
2137
+ status: finalStatus,
2138
+ lengthWarnings: targetMeta.lengthWarnings,
2139
+ lengthTelemetry: targetMeta.lengthTelemetry,
2140
+ tokenUsage: targetMeta.tokenUsage,
2141
+ };
2142
+ }
2143
+
2144
+ // ---------------------------------------------------------------------------
2145
+ // Import operations (style imitation + canon for spinoff)
2146
+ // ---------------------------------------------------------------------------
2147
+
2148
+ /**
2149
+ * Generate a qualitative style guide from reference text via LLM.
2150
+ * Also saves the statistical style_profile.json.
2151
+ */
2152
+ async generateStyleGuide(bookId: string, referenceText: string, sourceName?: string): Promise<string> {
2153
+ const sample = referenceText.trim();
2154
+ if (!sample) {
2155
+ throw new Error("Reference text is required for style extraction.");
2156
+ }
2157
+
2158
+ const { analyzeStyle } = await import("../agents/style-analyzer.js");
2159
+ const bookDir = this.state.bookDir(bookId);
2160
+ const storyDir = join(bookDir, "story");
2161
+ await mkdir(storyDir, { recursive: true });
2162
+
2163
+ // Statistical fingerprint
2164
+ const profile = analyzeStyle(sample, sourceName);
2165
+ await writeFile(join(storyDir, "style_profile.json"), JSON.stringify(profile, null, 2), "utf-8");
2166
+
2167
+ const book = await this.state.loadBookConfig(bookId);
2168
+ const { profile: gp } = await this.loadGenreProfile(book.genre);
2169
+ const lang = (book.language ?? gp.language) === "en" ? "en" as const : "zh" as const;
2170
+
2171
+ let qualitativeGuide: string;
2172
+ if (sample.length < 500) {
2173
+ qualitativeGuide = this.buildDeterministicStyleGuide(profile, {
2174
+ language: lang,
2175
+ reason: lang === "en"
2176
+ ? `The sample is short (${sample.length} chars), so this guide uses the statistical fingerprint instead of LLM qualitative extraction.`
2177
+ : `样本文本较短(${sample.length}字),本次先使用统计指纹生成文风指南,不强行调用 LLM 做定性拆解。`,
2178
+ });
2179
+ } else {
2180
+ try {
2181
+ // LLM qualitative extraction
2182
+ const response = await chatCompletion(this.config.client, this.config.model, [
2183
+ {
2184
+ role: "system",
2185
+ content: `你是一位文学风格分析专家。分析参考文本的写作风格,提取可供模仿的定性特征。
2186
+
2187
+ 输出格式(Markdown):
2188
+ ## 叙事声音与语气
2189
+ (冷峻/热烈/讽刺/温情/...,附1-2个原文例句)
2190
+
2191
+ ## 对话风格
2192
+ (角色说话的共性特征:句子长短、口头禅倾向、方言痕迹、对话节奏)
2193
+
2194
+ ## 场景描写特征
2195
+ (五感偏好、意象选择、描写密度、环境与情绪的关联方式)
2196
+
2197
+ ## 转折与衔接手法
2198
+ (场景如何切换、时间跳跃的处理方式、段落间的过渡特征)
2199
+
2200
+ ## 节奏特征
2201
+ (长短句分布、段落长度偏好、高潮/舒缓的交替方式)
2202
+
2203
+ ## 词汇偏好
2204
+ (高频特色用词、比喻/修辞倾向、口语化程度)
2205
+
2206
+ ## 情绪表达方式
2207
+ (直白抒情 vs 动作外化、内心独白的频率和风格)
2208
+
2209
+ ## 独特习惯
2210
+ (任何值得模仿的个人写作习惯)
2211
+
2212
+ 分析必须基于原文实际特征,不要泛泛而谈。每个部分用1-2个原文例句佐证。`,
2213
+ },
2214
+ {
2215
+ role: "user",
2216
+ content: `分析以下参考文本的写作风格:\n\n${sample.slice(0, 20000)}`,
2217
+ },
2218
+ ], { temperature: 0.3 });
2219
+ qualitativeGuide = response.content.trim()
2220
+ ? response.content
2221
+ : this.buildDeterministicStyleGuide(profile, {
2222
+ language: lang,
2223
+ reason: lang === "en"
2224
+ ? "The LLM returned empty style analysis; using the statistical fingerprint fallback."
2225
+ : "LLM 未返回有效文风分析,本次使用统计指纹兜底生成文风指南。",
2226
+ });
2227
+ } catch (error) {
2228
+ qualitativeGuide = this.buildDeterministicStyleGuide(profile, {
2229
+ language: lang,
2230
+ reason: lang === "en"
2231
+ ? `LLM qualitative extraction failed: ${error instanceof Error ? error.message : String(error)}. Using the statistical fingerprint fallback.`
2232
+ : `LLM 定性拆解失败:${error instanceof Error ? error.message : String(error)}。本次使用统计指纹兜底生成文风指南。`,
2233
+ });
2234
+ }
2235
+ }
2236
+
2237
+ const craftMethodology = buildWritingMethodologySection(lang);
2238
+ const fullStyleGuide = `${qualitativeGuide}\n\n${craftMethodology}`;
2239
+ await writeFile(join(storyDir, "style_guide.md"), fullStyleGuide, "utf-8");
2240
+ return fullStyleGuide;
2241
+ }
2242
+
2243
+ private buildDeterministicStyleGuide(
2244
+ profile: {
2245
+ readonly avgSentenceLength: number;
2246
+ readonly sentenceLengthStdDev: number;
2247
+ readonly avgParagraphLength: number;
2248
+ readonly vocabularyDiversity: number;
2249
+ readonly topPatterns: ReadonlyArray<string>;
2250
+ readonly rhetoricalFeatures: ReadonlyArray<string>;
2251
+ readonly sourceName?: string;
2252
+ },
2253
+ options: { readonly language: "zh" | "en"; readonly reason: string },
2254
+ ): string {
2255
+ if (options.language === "en") {
2256
+ return [
2257
+ "# Style Guide",
2258
+ "",
2259
+ `> ${options.reason}`,
2260
+ "",
2261
+ "## Statistical Fingerprint",
2262
+ `- Source: ${profile.sourceName ?? "unknown"}`,
2263
+ `- Average sentence length: ${profile.avgSentenceLength}`,
2264
+ `- Sentence length variance: ${profile.sentenceLengthStdDev}`,
2265
+ `- Average paragraph length: ${profile.avgParagraphLength}`,
2266
+ `- Vocabulary diversity: ${Math.round(profile.vocabularyDiversity * 100)}%`,
2267
+ profile.topPatterns.length > 0 ? `- Repeated openings: ${profile.topPatterns.join(", ")}` : "- Repeated openings: none obvious in this sample",
2268
+ profile.rhetoricalFeatures.length > 0 ? `- Rhetorical features: ${profile.rhetoricalFeatures.join(", ")}` : "- Rhetorical features: none obvious in this sample",
2269
+ "",
2270
+ "## How To Use",
2271
+ "- Treat this as a lightweight style fingerprint, not a full imitation bible.",
2272
+ "- Keep sentence and paragraph rhythm close to the sample when drafting.",
2273
+ "- If this guide feels too thin, import a longer excerpt later; the file will be replaced.",
2274
+ ].join("\n");
2275
+ }
2276
+
2277
+ return [
2278
+ "# 文风指南",
2279
+ "",
2280
+ `> ${options.reason}`,
2281
+ "",
2282
+ "## 统计风格指纹",
2283
+ `- 来源:${profile.sourceName ?? "unknown"}`,
2284
+ `- 平均句长:${profile.avgSentenceLength}`,
2285
+ `- 句长波动:${profile.sentenceLengthStdDev}`,
2286
+ `- 平均段落长度:${profile.avgParagraphLength}`,
2287
+ `- 词汇多样性:${Math.round(profile.vocabularyDiversity * 100)}%`,
2288
+ profile.topPatterns.length > 0 ? `- 高频句首/模式:${profile.topPatterns.join("、")}` : "- 高频句首/模式:样本内不明显",
2289
+ profile.rhetoricalFeatures.length > 0 ? `- 修辞特征:${profile.rhetoricalFeatures.join("、")}` : "- 修辞特征:样本内不明显",
2290
+ "",
2291
+ "## 使用方式",
2292
+ "- 这是一份轻量文风指纹,不是完整仿写圣经。",
2293
+ "- 后续写作优先参考句长、段落长度、节奏波动和可见修辞。",
2294
+ "- 如果想得到更稳定的定性拆解,后续可以导入更长片段覆盖本文件。",
2295
+ ].join("\n");
2296
+ }
2297
+
2298
+ /**
2299
+ * Import canon from parent book for spinoff writing.
2300
+ * Reads parent's truth files, uses LLM to generate parent_canon.md in target book.
2301
+ */
2302
+ async importCanon(targetBookId: string, parentBookId: string): Promise<string> {
2303
+ // Validate both books exist
2304
+ const bookIds = await this.state.listBooks();
2305
+ if (!bookIds.includes(parentBookId)) {
2306
+ throw new Error(`Parent book "${parentBookId}" not found. Available: ${bookIds.join(", ") || "(none)"}`);
2307
+ }
2308
+ if (!bookIds.includes(targetBookId)) {
2309
+ throw new Error(`Target book "${targetBookId}" not found. Available: ${bookIds.join(", ") || "(none)"}`);
2310
+ }
2311
+
2312
+ const parentDir = this.state.bookDir(parentBookId);
2313
+ const targetDir = this.state.bookDir(targetBookId);
2314
+ const storyDir = join(targetDir, "story");
2315
+ await mkdir(storyDir, { recursive: true });
2316
+
2317
+ const readSafe = async (path: string): Promise<string> => {
2318
+ try { return await readFile(path, "utf-8"); } catch { return "(无)"; }
2319
+ };
2320
+
2321
+ const parentBook = await this.state.loadBookConfig(parentBookId);
2322
+
2323
+ // Phase 5: parent book may be on the new prose layout; prefer outline/.
2324
+ const readParentOutline = async (newRel: string, legacyRel: string): Promise<string> => {
2325
+ const preferred = await readSafe(join(parentDir, "story", newRel));
2326
+ if (preferred.trim() && preferred !== "(无)") return preferred;
2327
+ return readSafe(join(parentDir, "story", legacyRel));
2328
+ };
2329
+
2330
+ const [storyBible, currentState, ledger, hooks, summaries, subplots, emotions, matrix] =
2331
+ await Promise.all([
2332
+ readParentOutline("outline/story_frame.md", "story_bible.md"),
2333
+ readSafe(join(parentDir, "story/current_state.md")),
2334
+ readSafe(join(parentDir, "story/particle_ledger.md")),
2335
+ readSafe(join(parentDir, "story/pending_hooks.md")),
2336
+ readSafe(join(parentDir, "story/chapter_summaries.md")),
2337
+ readSafe(join(parentDir, "story/subplot_board.md")),
2338
+ readSafe(join(parentDir, "story/emotional_arcs.md")),
2339
+ readSafe(join(parentDir, "story/character_matrix.md")),
2340
+ ]);
2341
+
2342
+ const response = await chatCompletion(this.config.client, this.config.model, [
2343
+ {
2344
+ role: "system",
2345
+ content: `你是一位网络小说架构师。基于正传的全部设定和状态文件,生成一份完整的"正传正典参照"文档,供番外写作和审计使用。
2346
+
2347
+ 输出格式(Markdown):
2348
+ # 正传正典(《{正传书名}》)
2349
+
2350
+ ## 世界规则(完整,来自正传设定)
2351
+ (力量体系、地理设定、阵营关系、核心规则——完整复制,不压缩)
2352
+
2353
+ ## 正典约束(不可违反的事实)
2354
+ | 约束ID | 类型 | 约束内容 | 严重性 |
2355
+ |---|---|---|---|
2356
+ | C01 | 人物存亡 | ... | critical |
2357
+ (列出所有硬性约束:谁活着、谁死了、什么事件已经发生、什么规则不可违反)
2358
+
2359
+ ## 角色快照
2360
+ | 角色 | 当前状态 | 性格底色 | 对话特征 | 已知信息 | 未知信息 |
2361
+ |---|---|---|---|---|---|
2362
+ (从状态卡和角色矩阵中提取每个重要角色的完整快照)
2363
+
2364
+ ## 角色双态处理原则
2365
+ - 未来会变强的角色:写潜力暗示
2366
+ - 未来会黑化的角色:写微小裂痕
2367
+ - 未来会死的角色:写导致死亡的性格底色
2368
+
2369
+ ## 关键事件时间线
2370
+ | 章节 | 事件 | 涉及角色 | 对番外的约束 |
2371
+ |---|---|---|---|
2372
+ (从章节摘要中提取关键事件)
2373
+
2374
+ ## 伏笔状态
2375
+ | Hook ID | 类型 | 状态 | 内容 | 预期回收 |
2376
+ |---|---|---|---|---|
2377
+
2378
+ ## 资源账本快照
2379
+ (当前资源状态)
2380
+
2381
+ ---
2382
+ meta:
2383
+ parentBookId: "{parentBookId}"
2384
+ parentTitle: "{正传书名}"
2385
+ generatedAt: "{ISO timestamp}"
2386
+
2387
+ 要求:
2388
+ 1. 世界规则完整复制,不压缩——准确性优先
2389
+ 2. 正典约束必须穷尽,遗漏会导致番外与正传矛盾
2390
+ 3. 角色快照必须包含信息边界(已知/未知),防止番外中角色引用不该知道的信息`,
2391
+ },
2392
+ {
2393
+ role: "user",
2394
+ content: `正传书名:${parentBook.title}
2395
+ 正传ID:${parentBookId}
2396
+
2397
+ ## 正传世界设定
2398
+ ${storyBible}
2399
+
2400
+ ## 正传当前状态卡
2401
+ ${currentState}
2402
+
2403
+ ## 正传资源账本
2404
+ ${ledger}
2405
+
2406
+ ## 正传伏笔池
2407
+ ${hooks}
2408
+
2409
+ ## 正传章节摘要
2410
+ ${summaries}
2411
+
2412
+ ## 正传支线进度
2413
+ ${subplots}
2414
+
2415
+ ## 正传情感弧线
2416
+ ${emotions}
2417
+
2418
+ ## 正传角色矩阵
2419
+ ${matrix}`,
2420
+ },
2421
+ ], { temperature: 0.3 });
2422
+
2423
+ // Append deterministic meta block (LLM may hallucinate timestamps)
2424
+ const metaBlock = [
2425
+ "",
2426
+ "---",
2427
+ "meta:",
2428
+ ` parentBookId: "${parentBookId}"`,
2429
+ ` parentTitle: "${parentBook.title}"`,
2430
+ ` generatedAt: "${new Date().toISOString()}"`,
2431
+ ].join("\n");
2432
+ const canon = response.content + metaBlock;
2433
+
2434
+ await writeFile(join(storyDir, "parent_canon.md"), canon, "utf-8");
2435
+
2436
+ // Also generate style guide from parent's chapter text if available
2437
+ const parentChaptersDir = join(parentDir, "chapters");
2438
+ const parentChapterText = await this.readParentChapterSample(parentChaptersDir);
2439
+ if (parentChapterText.length >= 500) {
2440
+ await this.tryGenerateStyleGuide(targetBookId, parentChapterText, parentBook.title);
2441
+ }
2442
+
2443
+ return canon;
2444
+ }
2445
+
2446
+ private async readParentChapterSample(chaptersDir: string): Promise<string> {
2447
+ try {
2448
+ const entries = await readdir(chaptersDir);
2449
+ const mdFiles = entries
2450
+ .filter((file) => file.endsWith(".md"))
2451
+ .sort()
2452
+ .slice(0, 5);
2453
+ const chunks: string[] = [];
2454
+ let totalLength = 0;
2455
+ for (const file of mdFiles) {
2456
+ if (totalLength >= 20000) break;
2457
+ const content = await readFile(join(chaptersDir, file), "utf-8");
2458
+ chunks.push(content);
2459
+ totalLength += content.length;
2460
+ }
2461
+ return chunks.join("\n\n---\n\n");
2462
+ } catch {
2463
+ return "";
2464
+ }
2465
+ }
2466
+
2467
+ // ---------------------------------------------------------------------------
2468
+ // Chapter import (for continuation writing from existing chapters)
2469
+ // ---------------------------------------------------------------------------
2470
+
2471
+ /**
2472
+ * Import existing chapters into a book. Reverse-engineers all truth files
2473
+ * via sequential replay so the Writer and Auditor can continue naturally.
2474
+ *
2475
+ * Step 1: Generate foundation (story_frame, volume_map, book_rules) from all chapters.
2476
+ * Step 2: Sequentially replay each chapter through ChapterAnalyzer to build truth files.
2477
+ */
2478
+ async importChapters(input: ImportChaptersInput): Promise<ImportChaptersResult> {
2479
+ const releaseLock = await this.state.acquireBookLock(input.bookId);
2480
+ try {
2481
+ const book = await this.state.loadBookConfig(input.bookId);
2482
+ const bookDir = this.state.bookDir(input.bookId);
2483
+ const { profile: gp } = await this.loadGenreProfile(book.genre);
2484
+ const resolvedLanguage = book.language ?? gp.language;
2485
+
2486
+ const startFrom = input.resumeFrom ?? 1;
2487
+
2488
+ const log = this.config.logger?.child("import");
2489
+
2490
+ // Step 1: Generate foundation on first run (not on resume)
2491
+ if (startFrom === 1) {
2492
+ log?.info(this.localize(resolvedLanguage, {
2493
+ zh: `步骤 1:从 ${input.chapters.length} 章生成基础设定...`,
2494
+ en: `Step 1: Generating foundation from ${input.chapters.length} chapters...`,
2495
+ }));
2496
+ const foundationSource = buildImportFoundationSource(input.chapters, resolvedLanguage);
2497
+
2498
+ const architect = new ArchitectAgent(this.agentCtxFor("architect", input.bookId));
2499
+ const isSeries = input.importMode === "series";
2500
+ const foundation = isSeries
2501
+ ? await this.generateAndReviewFoundation({
2502
+ generate: (reviewFeedback) => architect.generateFoundationFromImport(book, foundationSource, undefined, reviewFeedback, { importMode: "series" }),
2503
+ reviewer: new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", input.bookId)),
2504
+ mode: "series",
2505
+ language: resolvedLanguage === "en" ? "en" : "zh",
2506
+ stageLanguage: resolvedLanguage,
2507
+ })
2508
+ : await architect.generateFoundationFromImport(book, foundationSource);
2509
+ await architect.writeFoundationFiles(
2510
+ bookDir,
2511
+ foundation,
2512
+ gp.numericalSystem,
2513
+ resolvedLanguage,
2514
+ );
2515
+ await this.resetImportReplayTruthFiles(bookDir, resolvedLanguage);
2516
+ await this.state.saveChapterIndex(input.bookId, []);
2517
+ await this.state.snapshotState(input.bookId, 0);
2518
+
2519
+ // Generate style guide from imported chapters
2520
+ if (foundationSource.length >= 500) {
2521
+ log?.info(this.localize(resolvedLanguage, {
2522
+ zh: "提取原文风格指纹...",
2523
+ en: "Extracting source style fingerprint...",
2524
+ }));
2525
+ await this.tryGenerateStyleGuide(input.bookId, foundationSource, book.title, resolvedLanguage);
2526
+ }
2527
+
2528
+ log?.info(this.localize(resolvedLanguage, {
2529
+ zh: "基础设定已生成。",
2530
+ en: "Foundation generated.",
2531
+ }));
2532
+ }
2533
+
2534
+ // Step 2: Sequential replay
2535
+ log?.info(this.localize(resolvedLanguage, {
2536
+ zh: `步骤 2:从第 ${startFrom} 章开始顺序回放...`,
2537
+ en: `Step 2: Sequential replay from chapter ${startFrom}...`,
2538
+ }));
2539
+ const analyzer = new ChapterAnalyzerAgent(this.agentCtxFor("chapter-analyzer", input.bookId));
2540
+ const writer = new WriterAgent(this.agentCtxFor("writer", input.bookId));
2541
+ const countingMode = resolveLengthCountingMode(book.language ?? gp.language);
2542
+ let totalWords = 0;
2543
+ let importedCount = 0;
2544
+
2545
+ for (let i = startFrom - 1; i < input.chapters.length; i++) {
2546
+ const ch = input.chapters[i]!;
2547
+ const chapterNumber = i + 1;
2548
+ const governedInput = await this.prepareWriteInput(book, bookDir, chapterNumber);
2549
+
2550
+ log?.info(this.localize(resolvedLanguage, {
2551
+ zh: `分析章节 ${chapterNumber}/${input.chapters.length}:${ch.title}...`,
2552
+ en: `Analyzing chapter ${chapterNumber}/${input.chapters.length}: ${ch.title}...`,
2553
+ }));
2554
+
2555
+ // Analyze chapter to get truth file updates
2556
+ const output = await analyzer.analyzeChapter({
2557
+ book,
2558
+ bookDir,
2559
+ chapterNumber,
2560
+ chapterContent: ch.content,
2561
+ chapterTitle: ch.title,
2562
+ chapterIntent: governedInput.chapterIntent,
2563
+ contextPackage: governedInput.contextPackage,
2564
+ ruleStack: governedInput.ruleStack,
2565
+ });
2566
+
2567
+ // Save chapter file + core truth files (state, ledger, hooks)
2568
+ await writer.saveChapter(bookDir, {
2569
+ ...output,
2570
+ postWriteErrors: [],
2571
+ postWriteWarnings: [],
2572
+ }, gp.numericalSystem, resolvedLanguage);
2573
+
2574
+ // Save extended truth files (summaries, subplots, emotional arcs, character matrix)
2575
+ await writer.saveNewTruthFiles(bookDir, {
2576
+ ...output,
2577
+ postWriteErrors: [],
2578
+ postWriteWarnings: [],
2579
+ }, resolvedLanguage);
2580
+ await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, output);
2581
+ await this.syncNarrativeMemoryIndex(input.bookId);
2582
+
2583
+ // Update chapter index
2584
+ const existingIndex = await this.state.loadChapterIndex(input.bookId);
2585
+ const now = new Date().toISOString();
2586
+ const chapterWordCount = countChapterLength(ch.content, countingMode);
2587
+ const newEntry: ChapterMeta = {
2588
+ number: chapterNumber,
2589
+ title: output.title,
2590
+ status: "imported",
2591
+ wordCount: chapterWordCount,
2592
+ createdAt: now,
2593
+ updatedAt: now,
2594
+ auditIssues: [],
2595
+ lengthWarnings: [],
2596
+ };
2597
+ // Replace if exists (resume case), otherwise append
2598
+ const existingIdx = existingIndex.findIndex((e) => e.number === chapterNumber);
2599
+ const updatedIndex = existingIdx >= 0
2600
+ ? existingIndex.map((e, idx) => idx === existingIdx ? newEntry : e)
2601
+ : [...existingIndex, newEntry];
2602
+ await this.state.saveChapterIndex(input.bookId, updatedIndex);
2603
+
2604
+ // Snapshot state after each chapter for rollback + resume support
2605
+ await this.state.snapshotState(input.bookId, chapterNumber);
2606
+
2607
+ importedCount++;
2608
+ totalWords += chapterWordCount;
2609
+ }
2610
+
2611
+ if (input.chapters.length > 0) {
2612
+ await this.markBookActiveIfNeeded(input.bookId);
2613
+ await this.syncCurrentStateFactHistory(input.bookId, input.chapters.length);
2614
+ }
2615
+
2616
+ const nextChapter = input.chapters.length + 1;
2617
+ log?.info(this.localize(resolvedLanguage, {
2618
+ zh: `完成。已导入 ${importedCount} 章,共 ${formatLengthCount(totalWords, countingMode)}。下一章:${nextChapter}`,
2619
+ en: `Done. ${importedCount} chapters imported, ${formatLengthCount(totalWords, countingMode)}. Next chapter: ${nextChapter}`,
2620
+ }));
2621
+
2622
+ return {
2623
+ bookId: input.bookId,
2624
+ importedCount,
2625
+ totalWords,
2626
+ nextChapter,
2627
+ };
2628
+ } finally {
2629
+ await releaseLock();
2630
+ }
2631
+ }
2632
+
2633
+ private static addUsage(
2634
+ a: TokenUsageSummary,
2635
+ b?: { readonly promptTokens: number; readonly completionTokens: number; readonly totalTokens: number },
2636
+ ): TokenUsageSummary {
2637
+ if (!b) return a;
2638
+ return {
2639
+ promptTokens: a.promptTokens + b.promptTokens,
2640
+ completionTokens: a.completionTokens + b.completionTokens,
2641
+ totalTokens: a.totalTokens + b.totalTokens,
2642
+ };
2643
+ }
2644
+
2645
+ private async buildPersistenceOutput(
2646
+ bookId: string,
2647
+ book: BookConfig,
2648
+ bookDir: string,
2649
+ chapterNumber: number,
2650
+ output: WriteChapterOutput,
2651
+ finalContent: string,
2652
+ countingMode: Parameters<typeof countChapterLength>[1],
2653
+ reducedControlInput?: {
2654
+ chapterIntent: string;
2655
+ contextPackage: ContextPackage;
2656
+ ruleStack: RuleStack;
2657
+ },
2658
+ ): Promise<WriteChapterOutput> {
2659
+ if (finalContent === output.content) {
2660
+ return output;
2661
+ }
2662
+
2663
+ const analyzer = new ChapterAnalyzerAgent(this.agentCtxFor("chapter-analyzer", bookId));
2664
+ const analyzed = await analyzer.analyzeChapter({
2665
+ book,
2666
+ bookDir,
2667
+ chapterNumber,
2668
+ chapterContent: finalContent,
2669
+ chapterTitle: output.title,
2670
+ chapterIntent: reducedControlInput?.chapterIntent,
2671
+ contextPackage: reducedControlInput?.contextPackage,
2672
+ ruleStack: reducedControlInput?.ruleStack,
2673
+ });
2674
+
2675
+ return {
2676
+ ...analyzed,
2677
+ content: finalContent,
2678
+ wordCount: countChapterLength(finalContent, countingMode),
2679
+ postWriteErrors: [],
2680
+ postWriteWarnings: [],
2681
+ hookHealthIssues: output.hookHealthIssues,
2682
+ tokenUsage: output.tokenUsage,
2683
+ };
2684
+ }
2685
+
2686
+ private async assertNoPendingStateRepair(bookId: string): Promise<void> {
2687
+ const existingIndex = await this.state.loadChapterIndex(bookId);
2688
+ const latestChapter = [...existingIndex].sort((left, right) => right.number - left.number)[0];
2689
+ if (latestChapter?.status !== "state-degraded") {
2690
+ return;
2691
+ }
2692
+
2693
+ throw new Error(
2694
+ `Latest chapter ${latestChapter.number} is state-degraded. Repair state or rewrite that chapter before continuing.`,
2695
+ );
2696
+ }
2697
+
2698
+ // ---------------------------------------------------------------------------
2699
+ // Helpers
2700
+ // ---------------------------------------------------------------------------
2701
+
2702
+ private async prepareWriteInput(
2703
+ book: BookConfig,
2704
+ bookDir: string,
2705
+ chapterNumber: number,
2706
+ externalContext?: string,
2707
+ ): Promise<Pick<WriteChapterInput, "externalContext" | "chapterIntent" | "chapterMemo" | "chapterIntentData" | "contextPackage" | "ruleStack">> {
2708
+ if ((this.config.inputGovernanceMode ?? "v2") === "legacy") {
2709
+ return { externalContext };
2710
+ }
2711
+
2712
+ const { plan, composed } = await this.createGovernedArtifacts(
2713
+ book,
2714
+ bookDir,
2715
+ chapterNumber,
2716
+ externalContext,
2717
+ { reuseExistingIntentWhenContextMissing: true },
2718
+ );
2719
+
2720
+ return {
2721
+ externalContext,
2722
+ chapterIntent: plan.intentMarkdown,
2723
+ chapterMemo: plan.memo,
2724
+ chapterIntentData: plan.intent,
2725
+ contextPackage: composed.contextPackage,
2726
+ ruleStack: composed.ruleStack,
2727
+ };
2728
+ }
2729
+
2730
+ private async resetImportReplayTruthFiles(
2731
+ bookDir: string,
2732
+ language: LengthLanguage,
2733
+ ): Promise<void> {
2734
+ const storyDir = join(bookDir, "story");
2735
+
2736
+ await Promise.all([
2737
+ writeFile(
2738
+ join(storyDir, "current_state.md"),
2739
+ this.buildImportReplayStateSeed(language),
2740
+ "utf-8",
2741
+ ),
2742
+ writeFile(
2743
+ join(storyDir, "pending_hooks.md"),
2744
+ this.buildImportReplayHooksSeed(language),
2745
+ "utf-8",
2746
+ ),
2747
+ rm(join(storyDir, "chapter_summaries.md"), { force: true }),
2748
+ rm(join(storyDir, "subplot_board.md"), { force: true }),
2749
+ rm(join(storyDir, "emotional_arcs.md"), { force: true }),
2750
+ rm(join(storyDir, "character_matrix.md"), { force: true }),
2751
+ rm(join(storyDir, "volume_summaries.md"), { force: true }),
2752
+ rm(join(storyDir, "particle_ledger.md"), { force: true }),
2753
+ rm(join(storyDir, "memory.db"), { force: true }),
2754
+ rm(join(storyDir, "memory.db-shm"), { force: true }),
2755
+ rm(join(storyDir, "memory.db-wal"), { force: true }),
2756
+ rm(join(storyDir, "state"), { recursive: true, force: true }),
2757
+ rm(join(storyDir, "snapshots"), { recursive: true, force: true }),
2758
+ ]);
2759
+ }
2760
+
2761
+ private buildImportReplayStateSeed(language: LengthLanguage): string {
2762
+ if (language === "en") {
2763
+ return [
2764
+ "# Current State",
2765
+ "",
2766
+ "| Field | Value |",
2767
+ "| --- | --- |",
2768
+ "| Current Chapter | 0 |",
2769
+ "| Current Location | (not set) |",
2770
+ "| Protagonist State | (not set) |",
2771
+ "| Current Goal | (not set) |",
2772
+ "| Current Constraint | (not set) |",
2773
+ "| Current Alliances | (not set) |",
2774
+ "| Current Conflict | (not set) |",
2775
+ "",
2776
+ ].join("\n");
2777
+ }
2778
+
2779
+ return [
2780
+ "# 当前状态",
2781
+ "",
2782
+ "| 字段 | 值 |",
2783
+ "| --- | --- |",
2784
+ "| 当前章节 | 0 |",
2785
+ "| 当前位置 | (未设定) |",
2786
+ "| 主角状态 | (未设定) |",
2787
+ "| 当前目标 | (未设定) |",
2788
+ "| 当前限制 | (未设定) |",
2789
+ "| 当前敌我 | (未设定) |",
2790
+ "| 当前冲突 | (未设定) |",
2791
+ "",
2792
+ ].join("\n");
2793
+ }
2794
+
2795
+ private buildImportReplayHooksSeed(language: LengthLanguage): string {
2796
+ if (language === "en") {
2797
+ return [
2798
+ "# Pending Hooks",
2799
+ "",
2800
+ "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
2801
+ "| --- | --- | --- | --- | --- | --- | --- |",
2802
+ "",
2803
+ ].join("\n");
2804
+ }
2805
+
2806
+ return [
2807
+ "# 伏笔池",
2808
+ "",
2809
+ "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
2810
+ "| --- | --- | --- | --- | --- | --- | --- |",
2811
+ "",
2812
+ ].join("\n");
2813
+ }
2814
+
2815
+ private async normalizeDraftLengthIfNeeded(params: {
2816
+ bookId: string;
2817
+ chapterNumber: number;
2818
+ chapterContent: string;
2819
+ lengthSpec: LengthSpec;
2820
+ chapterIntent?: string;
2821
+ }): Promise<{
2822
+ content: string;
2823
+ wordCount: number;
2824
+ applied: boolean;
2825
+ tokenUsage?: TokenUsageSummary;
2826
+ }> {
2827
+ const writerCount = countChapterLength(
2828
+ params.chapterContent,
2829
+ params.lengthSpec.countingMode,
2830
+ );
2831
+ if (!isOutsideHardRange(writerCount, params.lengthSpec)) {
2832
+ return {
2833
+ content: params.chapterContent,
2834
+ wordCount: writerCount,
2835
+ applied: false,
2836
+ };
2837
+ }
2838
+
2839
+ const normalizer = new LengthNormalizerAgent(
2840
+ this.agentCtxFor("length-normalizer", params.bookId),
2841
+ );
2842
+ const normalized = await normalizer.normalizeChapter({
2843
+ chapterContent: params.chapterContent,
2844
+ lengthSpec: params.lengthSpec,
2845
+ chapterIntent: params.chapterIntent,
2846
+ });
2847
+
2848
+ // Safety net: if normalizer output is less than 25% of original, it was too destructive.
2849
+ // Reject and keep original content.
2850
+ if (normalized.finalCount < writerCount * 0.25) {
2851
+ this.logWarn(this.languageFromLengthSpec(params.lengthSpec), {
2852
+ zh: `字数归一化被拒绝:第${params.chapterNumber}章 ${writerCount} -> ${normalized.finalCount}(砍了${Math.round((1 - normalized.finalCount / writerCount) * 100)}%,超过安全阈值)`,
2853
+ en: `Length normalization rejected for chapter ${params.chapterNumber}: ${writerCount} -> ${normalized.finalCount} (cut ${Math.round((1 - normalized.finalCount / writerCount) * 100)}%, exceeds safety threshold)`,
2854
+ });
2855
+ return {
2856
+ content: params.chapterContent,
2857
+ wordCount: writerCount,
2858
+ applied: false,
2859
+ };
2860
+ }
2861
+
2862
+ this.logInfo(this.languageFromLengthSpec(params.lengthSpec), {
2863
+ zh: `审计前字数归一化:第${params.chapterNumber}章 ${writerCount} -> ${normalized.finalCount}`,
2864
+ en: `Length normalization before audit for chapter ${params.chapterNumber}: ${writerCount} -> ${normalized.finalCount}`,
2865
+ });
2866
+
2867
+ return {
2868
+ content: normalized.normalizedContent,
2869
+ wordCount: normalized.finalCount,
2870
+ applied: normalized.applied,
2871
+ tokenUsage: normalized.tokenUsage,
2872
+ };
2873
+ }
2874
+
2875
+ private assertChapterContentNotEmpty(content: string, chapterNumber: number, stage: string): void {
2876
+ if (content.trim().length > 0) return;
2877
+ throw new Error(`Chapter ${chapterNumber} has empty chapter content after ${stage}`);
2878
+ }
2879
+
2880
+ private async syncCurrentStateFactHistory(bookId: string, uptoChapter: number): Promise<void> {
2881
+ const bookDir = this.state.bookDir(bookId);
2882
+ try {
2883
+ await this.rebuildCurrentStateFactHistory(bookDir, uptoChapter);
2884
+ } catch (error) {
2885
+ if (this.isMemoryIndexUnavailableError(error)) {
2886
+ if (this.canOpenMemoryIndex(bookDir)) {
2887
+ try {
2888
+ await this.rebuildCurrentStateFactHistory(bookDir, uptoChapter);
2889
+ return;
2890
+ } catch (retryError) {
2891
+ error = retryError;
2892
+ }
2893
+ } else {
2894
+ if (!this.memoryIndexFallbackWarned) {
2895
+ this.memoryIndexFallbackWarned = true;
2896
+ this.logWarn(await this.resolveBookLanguageById(bookId), {
2897
+ zh: "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。",
2898
+ en: "SQLite memory index unavailable on this Node runtime; continuing with markdown fallback.",
2899
+ });
2900
+ await this.logMemoryIndexDebugInfo(bookId, error);
2901
+ }
2902
+ return;
2903
+ }
2904
+ }
2905
+ this.logWarn(await this.resolveBookLanguageById(bookId), {
2906
+ zh: `状态事实同步已跳过:${String(error)}`,
2907
+ en: `State fact sync skipped: ${String(error)}`,
2908
+ });
2909
+ }
2910
+ }
2911
+
2912
+ private async syncLegacyStructuredStateFromMarkdown(
2913
+ bookDir: string,
2914
+ chapterNumber: number,
2915
+ output?: {
2916
+ readonly runtimeStateDelta?: WriteChapterOutput["runtimeStateDelta"];
2917
+ readonly runtimeStateSnapshot?: WriteChapterOutput["runtimeStateSnapshot"];
2918
+ },
2919
+ ): Promise<void> {
2920
+ if (output?.runtimeStateDelta || output?.runtimeStateSnapshot) {
2921
+ return;
2922
+ }
2923
+
2924
+ await rewriteStructuredStateFromMarkdown({
2925
+ bookDir,
2926
+ fallbackChapter: chapterNumber,
2927
+ });
2928
+ }
2929
+
2930
+ private async syncNarrativeMemoryIndex(bookId: string): Promise<void> {
2931
+ const bookDir = this.state.bookDir(bookId);
2932
+ try {
2933
+ await this.rebuildNarrativeMemoryIndex(bookDir);
2934
+ } catch (error) {
2935
+ if (this.isMemoryIndexUnavailableError(error)) {
2936
+ if (this.canOpenMemoryIndex(bookDir)) {
2937
+ try {
2938
+ await this.rebuildNarrativeMemoryIndex(bookDir);
2939
+ return;
2940
+ } catch (retryError) {
2941
+ error = retryError;
2942
+ }
2943
+ } else {
2944
+ if (!this.memoryIndexFallbackWarned) {
2945
+ this.memoryIndexFallbackWarned = true;
2946
+ this.logWarn(await this.resolveBookLanguageById(bookId), {
2947
+ zh: "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。",
2948
+ en: "SQLite memory index unavailable on this Node runtime; continuing with markdown fallback.",
2949
+ });
2950
+ await this.logMemoryIndexDebugInfo(bookId, error);
2951
+ }
2952
+ return;
2953
+ }
2954
+ }
2955
+ this.logWarn(await this.resolveBookLanguageById(bookId), {
2956
+ zh: `叙事记忆同步已跳过:${String(error)}`,
2957
+ en: `Narrative memory sync skipped: ${String(error)}`,
2958
+ });
2959
+ }
2960
+ }
2961
+
2962
+ private async rebuildCurrentStateFactHistory(bookDir: string, uptoChapter: number): Promise<void> {
2963
+ const memoryDb = await this.withMemoryIndexRetry(async () => {
2964
+ const db = new MemoryDB(bookDir);
2965
+ try {
2966
+ db.resetFacts();
2967
+
2968
+ const activeFacts = new Map<string, { id: number; object: string }>();
2969
+
2970
+ for (let chapter = 0; chapter <= uptoChapter; chapter++) {
2971
+ const snapshotFacts = await loadSnapshotCurrentStateFacts(bookDir, chapter);
2972
+ if (snapshotFacts.length === 0) continue;
2973
+ const nextFacts = new Map<string, Omit<Fact, "id">>();
2974
+
2975
+ for (const fact of snapshotFacts) {
2976
+ nextFacts.set(this.factKey(fact), {
2977
+ subject: fact.subject,
2978
+ predicate: fact.predicate,
2979
+ object: fact.object,
2980
+ validFromChapter: chapter,
2981
+ validUntilChapter: null,
2982
+ sourceChapter: chapter,
2983
+ });
2984
+ }
2985
+
2986
+ for (const [key, previous] of activeFacts.entries()) {
2987
+ const next = nextFacts.get(key);
2988
+ if (!next || next.object !== previous.object) {
2989
+ db.invalidateFact(previous.id, chapter);
2990
+ activeFacts.delete(key);
2991
+ }
2992
+ }
2993
+
2994
+ for (const [key, fact] of nextFacts.entries()) {
2995
+ if (activeFacts.has(key)) continue;
2996
+ const id = db.addFact(fact);
2997
+ activeFacts.set(key, { id, object: fact.object });
2998
+ }
2999
+ }
3000
+
3001
+ return db;
3002
+ } catch (error) {
3003
+ db.close();
3004
+ throw error;
3005
+ }
3006
+ });
3007
+
3008
+ try {
3009
+ // No-op: keep the db open only for the duration of the rebuild.
3010
+ } finally {
3011
+ memoryDb.close();
3012
+ }
3013
+ }
3014
+
3015
+ private async rebuildNarrativeMemoryIndex(bookDir: string): Promise<void> {
3016
+ const memorySeed = await loadNarrativeMemorySeed(bookDir);
3017
+
3018
+ const memoryDb = await this.withMemoryIndexRetry(() => {
3019
+ const db = new MemoryDB(bookDir);
3020
+ try {
3021
+ db.replaceSummaries(memorySeed.summaries);
3022
+ db.replaceHooks(memorySeed.hooks);
3023
+ return db;
3024
+ } catch (error) {
3025
+ db.close();
3026
+ throw error;
3027
+ }
3028
+ });
3029
+
3030
+ try {
3031
+ // No-op: keep the db open only for the duration of the rebuild.
3032
+ } finally {
3033
+ memoryDb.close();
3034
+ }
3035
+ }
3036
+
3037
+ private canOpenMemoryIndex(bookDir: string): boolean {
3038
+ let memoryDb: MemoryDB | null = null;
3039
+ try {
3040
+ memoryDb = new MemoryDB(bookDir);
3041
+ return true;
3042
+ } catch {
3043
+ return false;
3044
+ } finally {
3045
+ memoryDb?.close();
3046
+ }
3047
+ }
3048
+
3049
+ private async logMemoryIndexDebugInfo(bookId: string, error: unknown): Promise<void> {
3050
+ if (process.env.INKOS_DEBUG_SQLITE_MEMORY !== "1") {
3051
+ return;
3052
+ }
3053
+
3054
+ const code = typeof error === "object" && error !== null && "code" in error
3055
+ ? String((error as { code?: unknown }).code ?? "")
3056
+ : "";
3057
+ const message = error instanceof Error
3058
+ ? error.message
3059
+ : String(error);
3060
+
3061
+ this.logWarn(await this.resolveBookLanguageById(bookId), {
3062
+ zh: `SQLite 记忆索引调试:node=${process.version}; execArgv=${JSON.stringify(process.execArgv)}; code=${code || "(none)"}; message=${message}`,
3063
+ en: `SQLite memory debug: node=${process.version}; execArgv=${JSON.stringify(process.execArgv)}; code=${code || "(none)"}; message=${message}`,
3064
+ });
3065
+ }
3066
+
3067
+ private async withMemoryIndexRetry<T>(operation: () => Promise<T> | T): Promise<T> {
3068
+ const retryDelaysMs = [0, 25, 75];
3069
+ let lastError: unknown;
3070
+
3071
+ for (let attempt = 0; attempt < retryDelaysMs.length; attempt += 1) {
3072
+ try {
3073
+ return await operation();
3074
+ } catch (error) {
3075
+ lastError = error;
3076
+ if (!this.isMemoryIndexBusyError(error) || attempt === retryDelaysMs.length - 1) {
3077
+ throw error;
3078
+ }
3079
+ await new Promise((resolve) => setTimeout(resolve, retryDelaysMs[attempt + 1]!));
3080
+ }
3081
+ }
3082
+
3083
+ throw lastError;
3084
+ }
3085
+
3086
+ private isMemoryIndexUnavailableError(error: unknown): boolean {
3087
+ if (!error) return false;
3088
+
3089
+ const code = typeof error === "object" && error !== null && "code" in error
3090
+ ? String((error as { code?: unknown }).code ?? "")
3091
+ : "";
3092
+ const message = error instanceof Error
3093
+ ? error.message
3094
+ : String(error);
3095
+ const normalizedMessage = message.trim();
3096
+
3097
+ return /^No such built-in module:\s*node:sqlite$/i.test(normalizedMessage)
3098
+ || /^Cannot find module ['"]node:sqlite['"]$/i.test(normalizedMessage)
3099
+ || (code === "ERR_UNKNOWN_BUILTIN_MODULE" && /\bnode:sqlite\b/i.test(normalizedMessage));
3100
+ }
3101
+
3102
+ private isMemoryIndexBusyError(error: unknown): boolean {
3103
+ if (!error) return false;
3104
+
3105
+ const code = typeof error === "object" && error !== null && "code" in error
3106
+ ? String((error as { code?: unknown }).code ?? "")
3107
+ : "";
3108
+ const message = error instanceof Error
3109
+ ? error.message
3110
+ : String(error);
3111
+
3112
+ return code === "SQLITE_BUSY"
3113
+ || code === "SQLITE_LOCKED"
3114
+ || /\bSQLITE_BUSY\b/i.test(message)
3115
+ || /\bSQLITE_LOCKED\b/i.test(message)
3116
+ || /database is locked/i.test(message)
3117
+ || /database is busy/i.test(message);
3118
+ }
3119
+
3120
+ private factKey(fact: Pick<Fact, "subject" | "predicate">): string {
3121
+ return `${fact.subject}::${fact.predicate}`;
3122
+ }
3123
+
3124
+ private buildLengthWarnings(
3125
+ chapterNumber: number,
3126
+ finalCount: number,
3127
+ lengthSpec: LengthSpec,
3128
+ ): string[] {
3129
+ if (!isOutsideHardRange(finalCount, lengthSpec)) {
3130
+ return [];
3131
+ }
3132
+ return [
3133
+ this.localize(this.languageFromLengthSpec(lengthSpec), {
3134
+ zh: `第${chapterNumber}章经过一次字数归一化后仍超出硬区间(${lengthSpec.hardMin}-${lengthSpec.hardMax},实际 ${finalCount})。`,
3135
+ en: `Chapter ${chapterNumber} remains outside hard range (${lengthSpec.hardMin}-${lengthSpec.hardMax}, actual ${finalCount}) after a single normalization pass.`,
3136
+ }),
3137
+ ];
3138
+ }
3139
+
3140
+ private buildLengthTelemetry(params: {
3141
+ lengthSpec: LengthSpec;
3142
+ writerCount: number;
3143
+ postWriterNormalizeCount: number;
3144
+ postReviseCount: number;
3145
+ finalCount: number;
3146
+ normalizeApplied: boolean;
3147
+ lengthWarning: boolean;
3148
+ }): LengthTelemetry {
3149
+ return {
3150
+ target: params.lengthSpec.target,
3151
+ softMin: params.lengthSpec.softMin,
3152
+ softMax: params.lengthSpec.softMax,
3153
+ hardMin: params.lengthSpec.hardMin,
3154
+ hardMax: params.lengthSpec.hardMax,
3155
+ countingMode: params.lengthSpec.countingMode,
3156
+ writerCount: params.writerCount,
3157
+ postWriterNormalizeCount: params.postWriterNormalizeCount,
3158
+ postReviseCount: params.postReviseCount,
3159
+ finalCount: params.finalCount,
3160
+ normalizeApplied: params.normalizeApplied,
3161
+ lengthWarning: params.lengthWarning,
3162
+ };
3163
+ }
3164
+
3165
+ private async persistAuditDriftGuidance(params: {
3166
+ readonly bookDir: string;
3167
+ readonly chapterNumber: number;
3168
+ readonly issues: ReadonlyArray<AuditIssue>;
3169
+ readonly language: LengthLanguage;
3170
+ }): Promise<void> {
3171
+ const storyDir = join(params.bookDir, "story");
3172
+ const driftPath = join(storyDir, "audit_drift.md");
3173
+ const statePath = join(storyDir, "current_state.md");
3174
+ const currentState = await readFile(statePath, "utf-8").catch(() => "");
3175
+ const sanitizedState = this.stripAuditDriftCorrectionBlock(currentState).trimEnd();
3176
+
3177
+ if (sanitizedState !== currentState) {
3178
+ await writeFile(statePath, sanitizedState, "utf-8");
3179
+ }
3180
+
3181
+ if (params.issues.length === 0) {
3182
+ await rm(driftPath, { force: true }).catch(() => undefined);
3183
+ return;
3184
+ }
3185
+
3186
+ const block = [
3187
+ this.localize(params.language, {
3188
+ zh: "# 审计纠偏",
3189
+ en: "# Audit Drift",
3190
+ }),
3191
+ "",
3192
+ this.localize(params.language, {
3193
+ zh: "## 审计纠偏(自动生成,下一章写作前参照)",
3194
+ en: "## Audit Drift Correction",
3195
+ }),
3196
+ "",
3197
+ this.localize(params.language, {
3198
+ zh: `> 第${params.chapterNumber}章审计发现以下问题,下一章写作时必须避免:`,
3199
+ en: `> Chapter ${params.chapterNumber} audit found the following issues to avoid in the next chapter:`,
3200
+ }),
3201
+ ...params.issues.map((issue) => `> - [${issue.severity}] ${issue.category}: ${issue.description}`),
3202
+ "",
3203
+ ].join("\n");
3204
+
3205
+ await writeFile(driftPath, block, "utf-8");
3206
+ }
3207
+
3208
+ private stripAuditDriftCorrectionBlock(currentState: string): string {
3209
+ const headers = [
3210
+ "## 审计纠偏(自动生成,下一章写作前参照)",
3211
+ "## Audit Drift Correction",
3212
+ "# 审计纠偏",
3213
+ "# Audit Drift",
3214
+ ];
3215
+
3216
+ let cutIndex = -1;
3217
+ for (const header of headers) {
3218
+ const index = currentState.indexOf(header);
3219
+ if (index >= 0 && (cutIndex < 0 || index < cutIndex)) {
3220
+ cutIndex = index;
3221
+ }
3222
+ }
3223
+
3224
+ if (cutIndex < 0) {
3225
+ return currentState;
3226
+ }
3227
+
3228
+ return currentState.slice(0, cutIndex).trimEnd();
3229
+ }
3230
+
3231
+ private logLengthWarnings(lengthWarnings: ReadonlyArray<string>): void {
3232
+ for (const warning of lengthWarnings) {
3233
+ this.config.logger?.warn(warning);
3234
+ }
3235
+ }
3236
+
3237
+ private restoreLostAuditIssues(previous: AuditResult, next: AuditResult): AuditResult {
3238
+ if (next.passed || next.issues.length > 0 || previous.issues.length === 0) {
3239
+ return next;
3240
+ }
3241
+
3242
+ return {
3243
+ ...next,
3244
+ issues: previous.issues,
3245
+ summary: next.summary || previous.summary,
3246
+ };
3247
+ }
3248
+
3249
+ private restoreActionableAuditIfLost(
3250
+ previous: {
3251
+ auditResult: AuditResult;
3252
+ aiTellCount: number;
3253
+ blockingCount: number;
3254
+ criticalCount: number;
3255
+ revisionBlockingIssues: ReadonlyArray<AuditIssue>;
3256
+ },
3257
+ next: {
3258
+ auditResult: AuditResult;
3259
+ aiTellCount: number;
3260
+ blockingCount: number;
3261
+ criticalCount: number;
3262
+ revisionBlockingIssues: ReadonlyArray<AuditIssue>;
3263
+ },
3264
+ ): MergedAuditEvaluation {
3265
+ const auditResult = this.restoreLostAuditIssues(previous.auditResult, next.auditResult);
3266
+ if (auditResult === next.auditResult) {
3267
+ return next;
3268
+ }
3269
+
3270
+ return {
3271
+ ...next,
3272
+ auditResult,
3273
+ revisionBlockingIssues: previous.revisionBlockingIssues,
3274
+ blockingCount: previous.blockingCount,
3275
+ criticalCount: previous.criticalCount,
3276
+ };
3277
+ }
3278
+
3279
+ private async evaluateMergedAudit(params: {
3280
+ auditor: ContinuityAuditor;
3281
+ book: BookConfig;
3282
+ bookDir: string;
3283
+ chapterContent: string;
3284
+ chapterNumber: number;
3285
+ language: LengthLanguage;
3286
+ auditOptions?: {
3287
+ temperature?: number;
3288
+ chapterIntent?: string;
3289
+ chapterMemo?: ChapterMemo;
3290
+ contextPackage?: ContextPackage;
3291
+ ruleStack?: RuleStack;
3292
+ truthFileOverrides?: {
3293
+ currentState?: string;
3294
+ ledger?: string;
3295
+ hooks?: string;
3296
+ };
3297
+ };
3298
+ }): Promise<MergedAuditEvaluation> {
3299
+ const llmAudit = await params.auditor.auditChapter(
3300
+ params.bookDir,
3301
+ params.chapterContent,
3302
+ params.chapterNumber,
3303
+ params.book.genre,
3304
+ params.auditOptions,
3305
+ );
3306
+ const aiTells = analyzeAITells(params.chapterContent, params.language);
3307
+ const sensitiveResult = analyzeSensitiveWords(params.chapterContent, undefined, params.language);
3308
+ const longSpanFatigue = await analyzeLongSpanFatigue({
3309
+ bookDir: params.bookDir,
3310
+ chapterNumber: params.chapterNumber,
3311
+ chapterContent: params.chapterContent,
3312
+ language: params.language,
3313
+ });
3314
+ const hasBlockedWords = sensitiveResult.found.some((f) => f.severity === "block");
3315
+ const issues: ReadonlyArray<AuditIssue> = [
3316
+ ...llmAudit.issues,
3317
+ ...aiTells.issues,
3318
+ ...sensitiveResult.issues,
3319
+ ...longSpanFatigue.issues,
3320
+ ];
3321
+ // revisionBlockingIssues excludes long-span-fatigue issues by
3322
+ // construction (not by category name) so that an LLM-reported issue
3323
+ // sharing a category label with a long-span issue is still counted.
3324
+ const revisionBlockingIssues: ReadonlyArray<AuditIssue> = [
3325
+ ...llmAudit.issues,
3326
+ ...aiTells.issues,
3327
+ ...sensitiveResult.issues,
3328
+ ];
3329
+
3330
+ return {
3331
+ auditResult: {
3332
+ passed: hasBlockedWords ? false : llmAudit.passed,
3333
+ issues,
3334
+ summary: llmAudit.summary,
3335
+ tokenUsage: llmAudit.tokenUsage,
3336
+ },
3337
+ aiTellCount: aiTells.issues.length,
3338
+ blockingCount: revisionBlockingIssues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length,
3339
+ criticalCount: revisionBlockingIssues.filter((issue) => issue.severity === "critical").length,
3340
+ revisionBlockingIssues,
3341
+ };
3342
+ }
3343
+
3344
+ private async markBookActiveIfNeeded(bookId: string): Promise<void> {
3345
+ const book = await this.state.loadBookConfig(bookId);
3346
+ if (book.status !== "outlining") return;
3347
+
3348
+ await this.state.saveBookConfig(bookId, {
3349
+ ...book,
3350
+ status: "active",
3351
+ updatedAt: new Date().toISOString(),
3352
+ });
3353
+ }
3354
+
3355
+ private async createGovernedArtifacts(
3356
+ book: BookConfig,
3357
+ bookDir: string,
3358
+ chapterNumber: number,
3359
+ externalContext?: string,
3360
+ options?: {
3361
+ readonly reuseExistingIntentWhenContextMissing?: boolean;
3362
+ },
3363
+ ): Promise<{
3364
+ plan: PlanChapterOutput;
3365
+ composed: ComposeChapterOutput;
3366
+ }> {
3367
+ const plan = await this.resolveGovernedPlan(book, bookDir, chapterNumber, externalContext, options);
3368
+ const composed = await composeGovernedChapter({
3369
+ book,
3370
+ bookDir,
3371
+ chapterNumber,
3372
+ plan,
3373
+ });
3374
+
3375
+ return { plan, composed };
3376
+ }
3377
+
3378
+ private async resolveGovernedPlan(
3379
+ book: BookConfig,
3380
+ bookDir: string,
3381
+ chapterNumber: number,
3382
+ externalContext?: string,
3383
+ options?: {
3384
+ readonly reuseExistingIntentWhenContextMissing?: boolean;
3385
+ },
3386
+ ): Promise<PlanChapterOutput> {
3387
+ if (
3388
+ options?.reuseExistingIntentWhenContextMissing &&
3389
+ (!externalContext || externalContext.trim().length === 0)
3390
+ ) {
3391
+ const persisted = await loadPersistedPlan(bookDir, chapterNumber);
3392
+ if (persisted) return persisted;
3393
+ }
3394
+
3395
+ const planner = new PlannerAgent(this.agentCtxFor("planner", book.id));
3396
+ const plan = await planner.planChapter({
3397
+ book,
3398
+ bookDir,
3399
+ chapterNumber,
3400
+ externalContext,
3401
+ });
3402
+ // Persist in the new memo format so subsequent compose/write phases can
3403
+ // skip the planner LLM call when no new context is supplied.
3404
+ await savePersistedPlan(bookDir, plan);
3405
+ return plan;
3406
+ }
3407
+
3408
+ private async emitWebhook(
3409
+ event: WebhookEvent,
3410
+ bookId: string,
3411
+ chapterNumber?: number,
3412
+ data?: Record<string, unknown>,
3413
+ ): Promise<void> {
3414
+ if (!this.config.notifyChannels || this.config.notifyChannels.length === 0) return;
3415
+ await dispatchWebhookEvent(this.config.notifyChannels, {
3416
+ event,
3417
+ bookId,
3418
+ chapterNumber,
3419
+ timestamp: new Date().toISOString(),
3420
+ data,
3421
+ });
3422
+ }
3423
+
3424
+ private async readChapterContent(bookDir: string, chapterNumber: number): Promise<string> {
3425
+ const chaptersDir = join(bookDir, "chapters");
3426
+ const files = await readdir(chaptersDir);
3427
+ const paddedNum = String(chapterNumber).padStart(4, "0");
3428
+ const chapterFile = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
3429
+ if (!chapterFile) {
3430
+ throw new Error(`Chapter ${chapterNumber} file not found in ${chaptersDir}`);
3431
+ }
3432
+ const raw = await readFile(join(chaptersDir, chapterFile), "utf-8");
3433
+ // Strip the title line
3434
+ const lines = raw.split("\n");
3435
+ const contentStart = lines.findIndex((l, i) => i > 0 && l.trim().length > 0);
3436
+ return contentStart >= 0 ? lines.slice(contentStart).join("\n") : raw;
3437
+ }
3438
+ }