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