@cloud411716/fancy-webnovel 1.0.46 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/client.js +369 -0
  2. package/cordis.patch.yml +4 -13
  3. package/docs/douyin-plugin.md +118 -0
  4. package/docs/material-library/README.md +404 -0
  5. package/docs/material-library/import.js +437 -0
  6. package/docs/material-library/prompt.md +370 -0
  7. package/docs/material-library/queries.surql +94 -0
  8. package/docs/material-library/sample.jsonl +14 -0
  9. package/docs/material-library/schema.surql +46 -0
  10. package/docs/mcp-profile.example.yml +24 -0
  11. package/docs/story-graph-design.md +602 -0
  12. package/docs/story-graph-validation.md +81 -0
  13. package/docs/workspace-mcp.md +284 -0
  14. package/index.js +26 -38
  15. package/lib/atomic-write.js +109 -0
  16. package/lib/chapter-order.js +100 -0
  17. package/lib/chinese-num.js +111 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +141 -0
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +104 -0
  23. package/lib/infra.js +594 -0
  24. package/lib/llm-wiki.js +109 -0
  25. package/lib/material-db.js +149 -0
  26. package/lib/path-guard.js +150 -0
  27. package/lib/projects.js +400 -0
  28. package/lib/scraper-utils.js +305 -0
  29. package/lib/surreal-server.js +338 -0
  30. package/lib/topic-decision.js +860 -0
  31. package/lib/topic-fields.js +251 -0
  32. package/lib/topic-schema.js +225 -0
  33. package/lib/workspace.js +501 -0
  34. package/package.json +62 -5
  35. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  36. package/plugins/fancy-analyze/command-input.js +65 -0
  37. package/plugins/fancy-analyze/index.js +2024 -0
  38. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  39. package/plugins/fancy-analyze/schema-analyze.md +627 -0
  40. package/plugins/fancy-analyze/schema-write.md +178 -0
  41. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  42. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  43. package/plugins/fancy-analyze/summary-progress.js +495 -0
  44. package/plugins/fancy-analyze/templates.js +1179 -0
  45. package/plugins/fancy-bootstrap/index.js +313 -0
  46. package/plugins/fancy-bootstrap/scripts/init.js +177 -0
  47. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  48. package/plugins/fancy-bootstrap/templates.js +71 -0
  49. package/plugins/fancy-db/index.js +172 -0
  50. package/plugins/fancy-douyin/deploy.js +134 -0
  51. package/plugins/fancy-douyin/index.js +551 -0
  52. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  53. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  54. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  55. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  56. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  57. package/plugins/fancy-douyin/state.js +197 -0
  58. package/plugins/fancy-douyin/templates.js +127 -0
  59. package/plugins/fancy-export/exporter.js +805 -0
  60. package/plugins/fancy-export/index.js +63 -0
  61. package/plugins/fancy-import/import-core.js +1118 -0
  62. package/plugins/fancy-import/import-driver.js +604 -0
  63. package/plugins/fancy-import/index.js +738 -0
  64. package/plugins/fancy-import/prompts/01-import-prompt.md +131 -0
  65. package/plugins/fancy-import/templates.js +58 -0
  66. package/plugins/fancy-material/exporter.js +153 -0
  67. package/plugins/fancy-material/genres.js +256 -0
  68. package/plugins/fancy-material/index.js +835 -0
  69. package/plugins/fancy-material/inventory.js +143 -0
  70. package/plugins/fancy-material/kinds.js +313 -0
  71. package/plugins/fancy-material/queue.js +55 -0
  72. package/plugins/fancy-material/scripts/query.js +254 -0
  73. package/plugins/fancy-material/templates.js +258 -0
  74. package/plugins/fancy-material/tool-deploy.js +138 -0
  75. package/plugins/fancy-material/validate.js +149 -0
  76. package/plugins/fancy-project/index.js +133 -0
  77. package/plugins/fancy-scan/existing-block.js +100 -0
  78. package/plugins/fancy-scan/index.js +708 -0
  79. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  80. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  81. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  82. package/plugins/fancy-scan/scripts/run-scan.js +667 -0
  83. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +923 -0
  84. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +469 -0
  85. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +629 -0
  86. package/plugins/fancy-scan/templates/blocks.js +155 -0
  87. package/plugins/fancy-scan/templates/platforms.js +372 -0
  88. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  89. package/plugins/fancy-scan/templates.js +694 -0
  90. package/plugins/fancy-scan/tool-deploy.js +335 -0
  91. package/plugins/fancy-scan/wait-turn.js +46 -0
  92. package/plugins/fancy-story-builder/db/README.md +106 -0
  93. package/plugins/fancy-story-builder/db/client.js +228 -0
  94. package/plugins/fancy-story-builder/db/commands.js +145 -0
  95. package/plugins/fancy-story-builder/db/ids.js +39 -0
  96. package/plugins/fancy-story-builder/db/import.js +798 -0
  97. package/plugins/fancy-story-builder/db/project.js +299 -0
  98. package/plugins/fancy-story-builder/db/query.js +361 -0
  99. package/plugins/fancy-story-builder/db/schema.js +417 -0
  100. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  101. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  102. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  103. package/plugins/fancy-story-builder/flesh-output.js +168 -0
  104. package/plugins/fancy-story-builder/index.js +6861 -0
  105. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  106. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +563 -0
  107. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +602 -0
  108. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +539 -0
  109. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +225 -0
  110. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  111. package/plugins/fancy-story-builder/purpose-template.md +83 -0
  112. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  113. package/plugins/fancy-story-builder/references/formulas-skeleton.json +402 -0
  114. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  115. package/plugins/fancy-story-builder/references/glossary.md +594 -0
  116. package/plugins/fancy-story-builder/references/index.md +581 -0
  117. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  118. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  119. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  120. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  121. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  122. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  123. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  124. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  125. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  126. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  127. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  128. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  129. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  130. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  131. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  132. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  133. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  134. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  135. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +181 -0
  136. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  137. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +243 -0
  138. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  139. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +185 -0
  140. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  141. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +187 -0
  142. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  143. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +209 -0
  144. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  145. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  146. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  147. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  148. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +160 -0
  149. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  150. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  151. package/plugins/fancy-story-builder/schema-setting.md +232 -0
  152. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  153. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  154. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  155. package/plugins/fancy-story-builder/scripts/content-quality.js +416 -0
  156. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  157. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  158. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1312 -0
  159. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  160. package/plugins/fancy-story-builder/scripts/graph-utils.js +1484 -0
  161. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  162. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  163. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  164. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  165. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1312 -0
  166. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  167. package/plugins/fancy-story-builder/scripts/semantic-lint.js +381 -0
  168. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  169. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1973 -0
  170. package/plugins/fancy-story-builder/scripts/stage-batches.js +1405 -0
  171. package/plugins/fancy-story-builder/scripts/stage-contract.js +429 -0
  172. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  173. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  174. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  175. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  176. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  177. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  178. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  179. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  180. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  181. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  182. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  183. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  184. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  185. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  186. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  187. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  188. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  189. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  190. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  191. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  192. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  193. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  194. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  195. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  196. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  197. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  198. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  199. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  200. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  201. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  202. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  203. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  204. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  205. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  206. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  207. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  208. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  209. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  210. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  211. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  212. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  213. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  214. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  215. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  216. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  217. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  218. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  219. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  220. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  221. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  222. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  223. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  224. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  225. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  226. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  227. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  228. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  229. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  230. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  231. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  232. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  233. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  234. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  235. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  236. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  237. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +427 -0
  238. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
  239. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
  240. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
  241. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +168 -0
  242. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +184 -0
  243. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
  244. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +131 -0
  245. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +85 -0
  246. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +103 -0
  247. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +68 -0
  248. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +142 -0
  249. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +292 -0
  250. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +266 -0
  251. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +336 -0
  252. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
  253. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +308 -0
  254. package/plugins/fancy-story-builder/templates.js +1446 -0
  255. package/plugins/fancy-story-builder/wiki-references.js +540 -0
  256. package/plugins/fancy-story-builder/writing-driver.js +1388 -0
  257. package/plugins/fancy-topic/index.js +1057 -0
  258. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  259. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  260. package/plugins/fancy-topic/scripts/select-topic.js +317 -0
  261. package/plugins/fancy-topic/templates.js +476 -0
  262. package/plugins/fancy-topic/tool-deploy.js +200 -0
  263. package/commands/bootstrap/index.js +0 -164
  264. package/commands/bootstrap/init-project.js +0 -121
  265. package/commands/scan/index.js +0 -283
  266. package/commands/scan/platforms/fanqie.js +0 -146
  267. package/commands/scan/platforms/jinjiang.js +0 -143
  268. package/commands/scan/platforms/qidian.js +0 -102
  269. package/commands/scan/platforms/qimao.js +0 -127
  270. package/commands/scan/scraper.js +0 -195
  271. package/infra.js +0 -160
package/lib/infra.js ADDED
@@ -0,0 +1,594 @@
1
+ /**
2
+ * infra.js — DSH Plugin Core Infrastructure
3
+ *
4
+ * Provides:
5
+ * notify(session, kind, text) — return success/error result for TUI toast + Web left-panel
6
+ * llmFollowup(invocation, prompt) — hand a prompt to the LLM as a user message
7
+ * broadcastResult(invocation, name, result) — 显式把命令结果播报给 LLM(默认不调用)
8
+ * askUserQuestion(ctx, invocation, questions) — ask the human, with DSH's error taxonomy mapped
9
+ * spawnScript(cmd, args, opts) — spawn child process, parse stdout JSON, retain stderr
10
+ *
11
+ * All fancy-* plugins import from this file.
12
+ *
13
+ * ── 铁律一:插件不碰会话 ──────────────────────────────────────────
14
+ *
15
+ * **插件自己不往会话里写任何事件**,也不为了"点亮对话视图"去唤醒 agent。
16
+ *
17
+ * 通知类消息(初始化成功、参数不正确、校验失败……)全部是**本地行为**:
18
+ * notify() → command/done → TUI toast + Web 左侧命令面板
19
+ * (dsh-commands 的 settle → channel.notify)。
20
+ * **一条通知都不进模型请求。**
21
+ *
22
+ * 用户原话(2026-10-26):
23
+ * 「我希望fancy-webnovel的通知类消息,例如初始化成功,参数不正确等通知
24
+ * 都是"本地"的行为,不触发LLM,而不是像现在这样即便发送个空字符串都会触发llm」
25
+ *
26
+ * 这条铁律不是保守,是**有硬约束支撑的**:
27
+ *
28
+ * · 插件自己 session.append 会抢走 transcript 的第一条(surface 头),
29
+ * 于是 agent 之后提交系统提示词时抛
30
+ * "system/message requires a protected first surface head"
31
+ * → **会话读不回来**(dsh-session-format-v2-to-v3/lib/index.js:432);
32
+ * · 而第一条必须是系统提示词(见下面"铁律二"),
33
+ * 插件写进去的东西会逼着系统提示词降级成 user 轮;
34
+ * · 插件伪造 agent 的回合也不行:回合号必须严格递增,插件占掉 lastTurn+1
35
+ * 就会让 agent 下一回合的 turn/start 被判 "does not open expected turn"
36
+ * (agent 在 session/create 时就构造,计数器已固化)。
37
+ *
38
+ * 详见 tests/cases/conversation-view.test.js。
39
+ *
40
+ * ── 铁律二:插件要跟模型说话,只有一条路 ────────────────────────────
41
+ *
42
+ * llmFollowup() → agent.followup()。由 agent loop 提交时,系统提示词排在前面、
43
+ * 插件的提示词排在后面(dsh-agent-loop:1024-1028)。
44
+ *
45
+ * ## 这里有一条不能破的顺序依赖
46
+ *
47
+ * **系统提示词必须是 transcript 的第一条消息。**
48
+ *
49
+ * pi-ai 的 splitSystemPrompt(dsh-llm-pi-ai/lib/index.js:1210-1214)
50
+ * **只认第一条**:首条不是 system 就不提取,真正的系统提示词会掉进
51
+ * `role === "system"` 分支(:1242-1248)被**转成一条 user 消息** ——
52
+ * 这就是"系统提示词从 system 轮降级成 user 轮"。
53
+ *
54
+ * 插件自己 session.append 会破坏它(插件的消息抢走第一条),
55
+ * 所以必须走 agent.followup:agent loop 先提交 system/message
56
+ * (dsh-agent-loop/lib/index.js:1024-1026),再提交插件的 user/message
57
+ * (:1028),顺序由 agent 保证。
58
+ *
59
+ * ⚠️ 改动投递顺序前先看这段:一旦系统提示词不再排第一,模型会在
60
+ * "降级后的提示词"下运行,而且**没有任何报错**。
61
+ *
62
+ * 不要自己 session.append('user/message', ...) 往请求里塞东西 —— 插件写进
63
+ * 会话的每一条消息都会进入下一次模型请求,而且会破坏 DSH「surface 头 =
64
+ * 系统提示词」的不变量(会话读不回来)。
65
+ *
66
+ * ## llmFollowup 的唯一判空规则
67
+ *
68
+ * 正文必须 **trim 后长度 > 0**。这条规则覆盖了所有会判空的路径:
69
+ * pi-ai 的 userContent 按 length 判(dsh-llm-pi-ai/lib/index.js:1144),
70
+ * DSH host 入口的 hasPromptContent 按 trim 判
71
+ * (dsh-api-session-controller/lib/index.js:548)。
72
+ * trim 后长度 > 0 的正文两条路都过 → **content 不可能被判成空**,
73
+ * provider 也就不会 400("message content cannot be empty")。
74
+ *
75
+ * 没有特例口子。空正文、纯空白正文一律拒绝投递 —— 空 content 到线就是
76
+ * provider 400(实测 2026-10-26,agnes-2.5-flash)。
77
+ *
78
+ * 命令结果本身不走模型:左侧命令面板已经是完整结果,没必要再让模型转述
79
+ * 一遍 —— 实测 2026-10-25:模型收到"命令失败"播报后自己去跑 which / ls /
80
+ * npm list 排查插件,既烧额度又不可控。
81
+ *
82
+ * 确实需要把结果交给模型时,显式调用 broadcastResult(invocation, 插件名, result)。
83
+ */
84
+
85
+ import { spawn } from 'child_process';
86
+ import { randomUUID } from 'crypto';
87
+ import { join, resolve } from 'path';
88
+
89
+ // --------------------------------------------------------------------------
90
+ // ANSI escape cleanup
91
+ // --------------------------------------------------------------------------
92
+
93
+ /**
94
+ * Strip ANSI escape sequences from a string (defensive, prevents TTY leak).
95
+ * Handles CSI (e.g. \x1b[31m), OSC (e.g. \x1b]...\x07) and charset selection.
96
+ */
97
+ export function stripAnsi(str) {
98
+ return String(str)
99
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') // CSI: \x1b[...<final>
100
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC: \x1b]...\x07 or \x1b\\
101
+ .replace(/\x1b[()][AB012]/g, '') // Charset selection
102
+ .replace(/\x1b[=>NOMc78]/g, ''); // Single-char controls
103
+ }
104
+
105
+ // --------------------------------------------------------------------------
106
+ // notify — return error result propagated through dsh-commands settleThrown()
107
+ // --------------------------------------------------------------------------
108
+
109
+ /**
110
+ * @param {object} _session - DSH Session object (kept for API compatibility; unused)
111
+ * @param {'error'|'success'} kind
112
+ * @param {string} text
113
+ * @returns {{ kind: 'error'|'success', text: string }} Result propagated through
114
+ * dsh-commands settleThrown() → command/done → channel.notify() (TUI toast)
115
+ * and command/done (Web left-panel with real commandId prefix)
116
+ */
117
+ export function notify(_session, kind, text) {
118
+ return { kind, text: stripAnsi(text) };
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // askUserQuestion — 向用户提问,统一处理 DSH 的错误码
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * DSH 的 UserQuestionError 码全集(权威来源:@deepseek-ai/dsh-user-questions
127
+ * 的 README 与 lib/types/index.js):
128
+ * EMPTY_QUESTIONS / BAD_INTENT / NO_PROVIDER / ASK_ABORTED /
129
+ * CALLER_NOT_LIVE / DELEGATED_CALLER
130
+ *
131
+ * 此前各插件自己写 try/catch,只认其中 3 个码,其余落到 `throw err`
132
+ * 重新抛出 —— 命令会带着未捕获异常结束,用户看到的是原始错误而不是
133
+ * 可读提示。CALLER_NOT_LIVE(会话已被 dispose)与 DELEGATED_CALLER
134
+ * (在子代理里运行)都是实际会遇到的场景。
135
+ */
136
+ export function classifyUserQuestionError(err) {
137
+ const code = err && typeof err.code === 'string' ? err.code : '';
138
+ const message = err && err.message ? err.message : String(err || '未知错误');
139
+ if (code === 'ASK_ABORTED' || code === 'ASK_CANCELLED') {
140
+ return { type: 'cancelled', message: null };
141
+ }
142
+ if (code === 'NO_PROVIDER') {
143
+ return { type: 'no_provider', message: null };
144
+ }
145
+ if (code === 'CALLER_NOT_LIVE' || code === 'DELEGATED_CALLER' || code === 'ASK_MISSING_AGENT') {
146
+ return {
147
+ type: 'ask_failed',
148
+ message: '当前会话已不处于可交互状态(可能已被关闭,或该命令运行在子代理里)。' +
149
+ '请回到主会话重新执行本命令。'
150
+ };
151
+ }
152
+ if (code === 'EMPTY_QUESTIONS' || code === 'BAD_INTENT') {
153
+ return { type: 'ask_failed', message: '提问内容不被 DSH 接受(' + code + '):' + message };
154
+ }
155
+ return { type: 'ask_failed', message: message };
156
+ }
157
+
158
+ /**
159
+ * 找出提问内容里**不能安全 JSON 化**的值。
160
+ *
161
+ * DSH 的 api gateway 在远程(web UI 连远端 host)模式下会校验提问请求
162
+ * 必须是"lossless JSON":属性值不允许是 undefined —— JSON.stringify 会
163
+ * 悄悄丢掉 undefined,但网关按**属性存在性**判定,所以
164
+ * `{ label: '番茄', description: undefined }` 会被整个请求被拒:
165
+ * "api gateway: Remote event request is not lossless JSON data"
166
+ *
167
+ * 这个错误看不出是哪个字段的问题,只能靠猜。这里在本地先扫一遍,
168
+ * 把位置定位到具体路径,让错误信息直接可执行。
169
+ *
170
+ * 只检查"一定会出问题"的值(undefined / function / symbol / bigint /
171
+ * NaN / Infinity),不做原型等更严格的判定 —— 避免误报。
172
+ */
173
+ function findNonJsonValues(value, path = '', out = [], seen = new Set()) {
174
+ if (value === null) return out;
175
+ const t = typeof value;
176
+ if (t === 'string' || t === 'boolean') return out;
177
+ if (t === 'number') {
178
+ if (!Number.isFinite(value)) out.push(`${path} = ${value}`);
179
+ return out;
180
+ }
181
+ if (t === 'undefined' || t === 'function' || t === 'symbol' || t === 'bigint') {
182
+ out.push(`${path} = ${t}`);
183
+ return out;
184
+ }
185
+ if (t !== 'object') return out;
186
+ if (seen.has(value)) return out; // 循环引用不重复报
187
+ seen.add(value);
188
+ try {
189
+ if (Array.isArray(value)) {
190
+ for (let i = 0; i < value.length; i++) {
191
+ findNonJsonValues(value[i], `${path}[${i}]`, out, seen);
192
+ }
193
+ return out;
194
+ }
195
+ for (const key of Object.keys(value)) {
196
+ findNonJsonValues(value[key], `${path}.${key}`, out, seen);
197
+ }
198
+ return out;
199
+ } finally {
200
+ seen.delete(value);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * 问用户一个问题,**永不抛异常**。
206
+ *
207
+ * @returns {Promise<{ok: boolean, answer: object|null, failure: {type: string, message: string|null}|null}>}
208
+ * failure.type 可直接作为插件 notify 的 type:
209
+ * 'cancelled' / 'no_provider' / 'ask_failed'
210
+ */
211
+ export async function askUserQuestion(ctx, invocation, questions) {
212
+ // 送进 DSH 之前先本地预检:远程模式下这类问题会让网关直接拒掉整个请求,
213
+ // 而且报出来的错误完全指不到字段("Remote event request is not lossless
214
+ // JSON data")。本地拦住才能给出可执行的提示。
215
+ const bad = findNonJsonValues(questions, 'questions');
216
+ if (bad.length) {
217
+ return {
218
+ ok: false,
219
+ answer: null,
220
+ failure: {
221
+ type: 'ask_failed',
222
+ message: '提问内容含有无法 JSON 化的值,DSH 会拒收整个请求:' + bad.join('、') +
223
+ '。可选字段请直接省略该键,不要传 undefined。',
224
+ },
225
+ };
226
+ }
227
+ try {
228
+ const answer = await ctx.userQuestions.ask({
229
+ questions,
230
+ agent: invocation.agent,
231
+ signal: invocation.signal,
232
+ });
233
+ return { ok: true, answer, failure: null };
234
+ } catch (err) {
235
+ return { ok: false, answer: null, failure: classifyUserQuestionError(err) };
236
+ }
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // llmFollowup — 发送消息给 LLM,让 LLM 在同一 session 继续处理
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /**
244
+ * 构造一条 DSH 的 UserMessage。
245
+ *
246
+ * 单独导出(而不是把字面量埋在 llmFollowup 里):用例要断言的是**消息对象的
247
+ * 字段**,直接调用真函数比对,比去源码里刮字符串可靠得多 —— 源码刮取会
248
+ * 被任何一次重构打断,而且只能看到"写没写",看不到"对不对"。
249
+ *
250
+ * @param {string} text - 提示词正文(调用方保证非空字符串;正文还必须
251
+ * trim 不掉 —— 见 llmFollowup 的"唯一判空规则")
252
+ * @returns {{id: string, role: string, content: Array, source: object}}
253
+ */
254
+ export function buildUserMessage(text) {
255
+ return {
256
+ // DSH 的 Message.id 契约是"跨所有表示边界保持的稳定身份"。
257
+ // 此前用 Date.now(),同一毫秒内两次投递会产生相同 id;当前各次投递
258
+ // 间隔远超 1ms 所以没暴露,但并发投递就会撞 id。
259
+ id: 'llm-followup-' + randomUUID(),
260
+ role: 'user',
261
+ content: [{ type: 'text', text }],
262
+ source: { kind: 'user' },
263
+ };
264
+ }
265
+
266
+ /**
267
+ * 给 LLM 投递一条 user 消息。
268
+ *
269
+ * ## 唯一判空规则(没有特例)
270
+ *
271
+ * 正文必须 **trim 后长度 > 0**。
272
+ *
273
+ * 这条规则覆盖了所有会判空的路径:
274
+ * · **按 length 判**:pi-ai 适配器丢掉空文本块
275
+ * (dsh-llm-pi-ai/lib/index.js:1144 `if (block.text.length > 0)`),
276
+ * 消息内容变成空数组 → provider 400 "message content cannot be empty"。
277
+ * · **按 trim 判**:DSH 自己的 host 入口
278
+ * (dsh-api-session-controller/lib/index.js:548
279
+ * `part.text.trim().length > 0`)—— 纯空白正文在那条路上直接被拒。
280
+ *
281
+ * trim 后长度 > 0 的正文,两条路都过 → **content 不可能被判成空**。
282
+ * 普通空格只过得了第一条(实测:走 session/prompt 被拒,accepted=None)。
283
+ *
284
+ * **没有特例口子。** 曾经为了"点亮对话视图"开过一个 allowEmptyBody 开关
285
+ * (允许投递零长度正文),2026-10-26 连同整个点亮机制一起删掉了 ——
286
+ * 通知类消息必须是纯本地行为,不该为了点亮视图去唤醒模型。
287
+ *
288
+ * @param {object} invocation - DSH 命令调用对象
289
+ * @param {string|object|null|undefined} content - 提示词正文
290
+ * @returns {{ok: boolean, error: string|null}}
291
+ */
292
+ /**
293
+ * 给所有插件提示词追加统一的临时文件规则。
294
+ *
295
+ * 绝对路径来自会话 cwd(工作区目录),不依赖宿主进程的 process.cwd()。
296
+ */
297
+ export function tempDirInstruction(invocation) {
298
+ const cwd = invocation && invocation.agent && invocation.agent.session
299
+ && invocation.agent.session.header
300
+ && invocation.agent.session.header.cwd;
301
+ if (typeof cwd !== 'string' || !cwd.trim()) return '';
302
+ const tempDir = join(resolve(cwd), '.tmp');
303
+ return [
304
+ '',
305
+ '【临时文件规则】',
306
+ '如果需要创建临时文件,必须创建在以下绝对路径目录中:',
307
+ tempDir,
308
+ '创建目录可用:mkdir -p ' + tempDir,
309
+ '不要把临时文件直接放在工作区目录、/tmp 或其他位置。',
310
+ '临时文件创建后不允许删除(任务结束也不要清理);需要重写时直接覆盖同一个文件。',
311
+ ].join('\n');
312
+ }
313
+
314
+ export function llmFollowup(invocation, content, options) {
315
+ let prompt;
316
+ try {
317
+ if (typeof content === 'string') prompt = content;
318
+ else if (content === null || content === undefined) prompt = '';
319
+ else prompt = JSON.stringify(content);
320
+ } catch (e) {
321
+ const reason = '提示词无法序列化:' + (e && e.message ? e.message : String(e));
322
+ console.error('[llmFollowup]', reason);
323
+ return { ok: false, error: reason };
324
+ }
325
+ if (typeof prompt !== 'string' || prompt.length === 0) {
326
+ const reason = '提示词为空字符串,拒绝投递(空消息会被 provider 拒收:message content cannot be empty)';
327
+ console.error('[llmFollowup]', reason);
328
+ return { ok: false, error: reason };
329
+ }
330
+ if (prompt.trim().length === 0) {
331
+ const reason = '提示词为空,拒绝投递(空消息会被 provider 拒收:message content cannot be empty)';
332
+ console.error('[llmFollowup]', reason);
333
+ return { ok: false, error: reason };
334
+ }
335
+ try {
336
+ const appendTempRule = !options || options.appendTempRule !== false;
337
+ const finalPrompt = appendTempRule ? prompt + tempDirInstruction(invocation) : prompt;
338
+ const message = buildUserMessage(finalPrompt);
339
+ const agent = invocation.agent;
340
+ // DSH 对两者的语义区分得很明确:
341
+ // followup() — "排一个普通的后续 turn 并唤醒驱动器,
342
+ // 该项成为它自己 turn 的唯一普通消息"(正是这里要的)
343
+ // send() — "路由到 inbox 边界,wakeup 只是'可能唤醒'"
344
+ // 优先用语义更准的 followup;旧运行时没有该方法时退回 send。
345
+ if (agent && typeof agent.followup === 'function') {
346
+ agent.followup(message);
347
+ } else {
348
+ agent.send(message, 'next-turn', true);
349
+ }
350
+ // 记账:本次命令已经派过模型消息。
351
+ // broadcastResult 据此判断"要不要播报"——派过活就不播,
352
+ // 那条真实提示词本身就把对话视图激活了。
353
+ markDispatched(invocation);
354
+ // 注意:这里的 ok 只代表"消息已入队",不代表模型已处理。
355
+ // DSH 的 send/followup 都返回 void,真正的模型调用在后续 agent loop 里
356
+ // 异步发生(其 whenIdle 文档亦明确:"does not identify the settlement
357
+ // of any particular message")。
358
+ return { ok: true, error: null };
359
+ } catch (e) {
360
+ // 记录并把失败结果返回给调用方:此前这里只 console.error,
361
+ // 导致调用方的 try/catch 永远捕获不到失败,会把"投递失败"当成"阶段已开始"。
362
+ console.error('[llmFollowup error]', e);
363
+ return { ok: false, error: e && e.message ? e.message : String(e) };
364
+ }
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // 命令结果播报 —— 显式调用才会给 LLM 发消息
369
+ // ---------------------------------------------------------------------------
370
+
371
+ /**
372
+ * 本次命令已经派过模型消息的记账。
373
+ *
374
+ * invocation 是 DSH 冻结的对象(dsh-commands/lib/index.js:369
375
+ * `Object.freeze({commandId, agent, rawInput, attachments, signal})`),
376
+ * 挂不上去标记,只能用 WeakSet 记账 —— 也不能用普通 Map:命令结束就没人
377
+ * 再看这个 invocation,不回收会漏内存。
378
+ */
379
+ const dispatchedInvocations = new WeakSet();
380
+
381
+ /** 记账:本次命令已派过模型消息。 */
382
+ export function markDispatched(invocation) {
383
+ if (invocation && (typeof invocation === 'object' || typeof invocation === 'function')) {
384
+ dispatchedInvocations.add(invocation);
385
+ }
386
+ }
387
+
388
+ /** 本次命令是否派过模型消息。 */
389
+ export function hasDispatched(invocation) {
390
+ return Boolean(invocation) && dispatchedInvocations.has(invocation);
391
+ }
392
+
393
+ /** 结果播报正文的最大长度(超了截断;完整内容仍在命令面板里)。 */
394
+ const BROADCAST_MAX = 1500;
395
+
396
+ /**
397
+ * 构造"命令结果播报"的文本。
398
+ *
399
+ * **默认不会发**:命令结果已经在左侧命令面板里完整呈现(DSH 的 command/done),
400
+ * 再给 LLM 发一遍是重复劳动,而且实测有害 —— 收到播报的模型会自己去
401
+ * "帮忙"排查(tt5 实测 2026-10-25:模型收到"命令失败"后跑了 which / ls /
402
+ * npm list 去查插件,既烧额度又不可控)。
403
+ *
404
+ * 所以现在只有插件**显式调用** broadcastResult() 时才会真的发。
405
+ * 派活的命令(骨架 / 血肉 / 节奏 / 写作 / 去味 / 导入)本来就会发真实提示词,
406
+ * 那条 user 消息已经激活了对话视图,不需要额外播报。
407
+ *
408
+ * 措辞上有两条硬要求:
409
+ * ① **永远声明"不是任务指令"**:模型收到结果通报就自己去干活 = 假成功。
410
+ * (哪怕命令返回的是 kind='success',正文也可能写着"未完成"——
411
+ * 插件的约定是"命令本身正常执行了",成败在正文里。)
412
+ * ② **不替插件宣布成功**:只说"命令已结束",成败由正文说了算。
413
+ */
414
+ export function buildBroadcastText(pluginName, result) {
415
+ const failed = Boolean(result && result.kind === 'error');
416
+ const raw = result && typeof result.text === 'string' ? result.text.trim() : '';
417
+ const head = `【${pluginName}】命令${failed ? '失败' : '已结束'}。`;
418
+ const tail = failed
419
+ ? '\n\n(命令结果播报:请如实转达失败原因并给出下一步建议;这是结果通报,不是任务指令,'
420
+ + '不要开始执行任何任务。)'
421
+ : '\n\n(命令结果播报:请如实转达上面的结果;这是结果通报,不是任务指令,'
422
+ + '不要开始执行任何任务。用户没有后续指令时不必展开。)';
423
+ let body = raw;
424
+ if (body.length > BROADCAST_MAX) {
425
+ body = body.slice(0, BROADCAST_MAX) + '\n…(已截断,完整内容见命令面板)';
426
+ }
427
+ return head + (body ? '\n\n' + body : '') + tail;
428
+ }
429
+
430
+ /**
431
+ * 显式把命令结果播报给 LLM(**默认不调用**)。
432
+ *
433
+ * 只有插件确实需要"让模型知道这次命令的结果"时才用 ——
434
+ * 例如插件跑完一个阶段后要把结论交给模型继续处理。
435
+ * 不需要就别调:左侧命令面板已经完整呈现了结果。
436
+ *
437
+ * 已派过活时不播报:那条真实提示词已经把对话视图激活了,
438
+ * 再补一条只会打断正在跑的阶段。
439
+ *
440
+ * @param {object} invocation - DSH 命令调用对象
441
+ * @param {string} pluginName - 插件名(写进播报,让模型知道是谁的结果)
442
+ * @param {{kind: string, text?: string}} result - 命令结果(notify 的返回值)
443
+ * @returns {Promise<{activated: boolean, reason: string, error?: string}>}
444
+ */
445
+ export async function broadcastResult(invocation, pluginName, result) {
446
+ if (hasDispatched(invocation)) {
447
+ return { activated: false, reason: 'already_dispatched' };
448
+ }
449
+ const text = buildBroadcastText(pluginName, result);
450
+ const follow = await llmFollowup(invocation, text, { appendTempRule: false });
451
+ return follow.ok === true
452
+ ? { activated: true, reason: 'activated' }
453
+ : { activated: false, reason: 'dispatch_failed', error: follow.error };
454
+ }
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // spawnScript — spawn 子进程,stdout 只收集 JSON,stderr 单独收集并保留
458
+ // ---------------------------------------------------------------------------
459
+
460
+ /**
461
+ * @typedef {Object} SpawnResult
462
+ * @property {boolean} ok
463
+ * @property {string} [output] - 完整 stdout
464
+ * @property {string} [error] - 错误描述(stderr 或 exit code)
465
+ * @property {string} [stderr] - 原始 stderr(成功时也保留)
466
+ * @property {number} [code] - exit code
467
+ */
468
+
469
+ /**
470
+ * 终止子进程及其所在的进程组。
471
+ *
472
+ * spawnScript 以 detached:true 起子进程,proc.pid 即新进程组的组长,
473
+ * process.kill(-pid, sig) 会把整组一起收掉 —— 其中包含 playwright 拉起的
474
+ * chromium。此前只调 proc.kill() 杀直接子进程,chromium 会变成孤儿进程,
475
+ * 继续占 CPU/内存并往工作区目录写盘。
476
+ *
477
+ * Windows 的 process.kill 不支持负 pid,此处退化为只杀直接子进程
478
+ * (与改动前行为一致,不劣化)。
479
+ *
480
+ * 导出供调用方使用:被 detached 出去的孙进程不在调用方的进程组里,
481
+ * 调用方自己被 SIGTERM 时必须显式转发,否则孙进程会变成孤儿。
482
+ */
483
+ export function killProcessTree(proc, signal = 'SIGTERM') {
484
+ if (proc.pid) {
485
+ try { process.kill(-proc.pid, signal); } catch (_) { /* 非 POSIX,或进程已退出 */ }
486
+ }
487
+ try { proc.kill(signal); } catch (_) {}
488
+ }
489
+
490
+ /**
491
+ * @param {string} cmd
492
+ * @param {string[]} args
493
+ * @param {Object} [opts]
494
+ * @param {number} [opts.timeout=30000] - 毫秒超时(手动实现,因为 spawn 的 timeout 选项无效)
495
+ * @param {string} [opts.cwd]
496
+ * @param {Object} [opts.env]
497
+ * @param {Function} [opts.onSpawn] - 子进程创建后的回调,参数为 ChildProcess。
498
+ * 用于让调用方自己接管信号转发(被 detached 出去的孙进程不在调用方进程组内)。
499
+ * @returns {Promise<SpawnResult>}
500
+ */
501
+ export function spawnScript(cmd, args, { timeout = 30000, cwd, env, onSpawn } = {}) {
502
+ return new Promise((resolve) => {
503
+ let proc;
504
+ try {
505
+ // detached:true —— 子进程成为独立进程组的组长,超时时可整组清理。
506
+ proc = spawn(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
507
+ } catch (spawnErr) {
508
+ resolve({ ok: false, output: '', error: spawnErr.message, stderr: '' });
509
+ return;
510
+ }
511
+
512
+ if (typeof onSpawn === 'function') {
513
+ try { onSpawn(proc); } catch (_) { /* 调用方的钩子异常不应影响主流程 */ }
514
+ }
515
+
516
+ const outParts = [], errParts = [];
517
+ proc.stdout.on('data', (d) => { outParts.push(d.toString()); });
518
+ proc.stderr.on('data', (d) => { errParts.push(d.toString()); });
519
+
520
+ let timedOut = false;
521
+ const timer = setTimeout(() => {
522
+ timedOut = true;
523
+ killProcessTree(proc, 'SIGTERM');
524
+ // 5s 兜底 SIGKILL(防止 child process 拒收 SIGTERM)
525
+ const killTimer = setTimeout(() => {
526
+ killProcessTree(proc, 'SIGKILL');
527
+ }, 5000);
528
+ killTimer.unref();
529
+ }, timeout);
530
+ timer.unref();
531
+
532
+ proc.on('close', (code) => {
533
+ clearTimeout(timer);
534
+ const stdout = outParts.join('');
535
+ const stderr = errParts.join('');
536
+
537
+ // 优先尝试解析 stdout 为 JSON
538
+ try {
539
+ const parsed = JSON.parse(stdout);
540
+ // 判定优先级:
541
+ // 0) 超时 → 一律失败(最高优先级,见下)
542
+ // 1) explicit ok === true → 成功
543
+ // 2) explicit ok === false → 失败
544
+ // 3) 没有 ok 字段 → 看 written > 0(fanqie/jinjiang/qimao receipt),
545
+ // 或看顶层 status === 'success'(bootstrap receipt 风格)
546
+ //
547
+ // 超时必须排在 parsed.ok 之前:子进程完全可能在挂死之前
548
+ // 已经往 stdout 写过一条 {"ok":true}(例如先写 receipt 再卡住),
549
+ // 此时若让 parsed.ok 胜出,超时会被判成"成功"——
550
+ // 调用方只判 ok 就会拿到假成功。
551
+ let okFlag;
552
+ if (timedOut) {
553
+ okFlag = false;
554
+ } else if (parsed.ok === true) {
555
+ okFlag = true;
556
+ } else if (parsed.ok === false) {
557
+ okFlag = false;
558
+ } else {
559
+ const hasWritten = Number.isInteger(parsed.written) && parsed.written > 0;
560
+ okFlag = hasWritten || parsed.status === 'success';
561
+ }
562
+ resolve({
563
+ ok: okFlag,
564
+ output: stdout,
565
+ // 超时信息优先于子进程自报的错误 —— 超时才是真正的失败原因,
566
+ // 子进程的错误信息只是它被 SIGTERM 打断时的表象。
567
+ error: timedOut
568
+ ? `timeout after ${timeout}ms`
569
+ : (parsed.error
570
+ ? (typeof parsed.error === 'object'
571
+ ? (parsed.error.message || JSON.stringify(parsed.error))
572
+ : String(parsed.error))
573
+ : undefined),
574
+ stderr,
575
+ code,
576
+ });
577
+ return;
578
+ } catch (_) {
579
+ // stdout 不是 JSON — fallback 到 stderr / exit code
580
+ resolve({
581
+ ok: false,
582
+ output: stdout,
583
+ error: stderr.trim() || (timedOut ? `timeout after ${timeout}ms` : `exit ${code}`),
584
+ stderr,
585
+ code,
586
+ });
587
+ }
588
+ });
589
+ proc.on('error', (err) => {
590
+ clearTimeout(timer);
591
+ resolve({ ok: false, output: '', error: err.message, stderr: errParts.join(''), code: -1 });
592
+ });
593
+ });
594
+ }