@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
@@ -0,0 +1,730 @@
1
+ /**
2
+ * fancy-import plugin entry
3
+ *
4
+ * 把一部**已经写好的旧稿**转成 fancy-story-builder 的四阶段产物,
5
+ * 让旧稿能接着用正���流程继续做(补设定、排节奏、去 AI 味)。
6
+ *
7
+ * 流程(插件驱动,脚本当裁判):
8
+ * 1. 扫描导入源(目录或单文件)→ 切章(保留原标题)→ 统计实际规模
9
+ * 2. 正文按 story-builder 格式落盘:设定/raw/sources/写作/第NNN章.md
10
+ * 3. 逐轮派活让模型反推:骨架 → 血肉 → 节奏 → 台账
11
+ * 4. 脚本按旧稿实际规模**反推 quota**,缺失项按公式补齐 → 写 框架.json
12
+ * 5. 每轮产物都跑 story-builder 自己的校验器;不过就带着错误重投
13
+ * 6. 生成导入报告(含待补清单)
14
+ *
15
+ * 为什么配额由脚本算而不是让模型填:
16
+ * 模型的"估计"和校验器的口径对不上,导入完就会校验失败;
17
+ * 而且 quota 必须与实体条数一致(骨架校验器按 QUOTA_MISMATCH 核对),
18
+ * 模型填的数和实际写出来的实体数必然对不上。
19
+ *
20
+ * 依赖:
21
+ * - fancy-bootstrap(初始化项目)
22
+ * - fancy-story-builder(四阶段产物格式与校验器)
23
+ */
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ import { llmFollowup, notify, spawnScript } from '#infra';
29
+ import { readDeployedConfig, ensureSetupId } from '#story-builder/index.js';
30
+ import { phaseScriptFiles } from '#story-builder/deploy-manifest.js';
31
+ import { makeWaitForTurn } from '#story-builder/writing-driver.js';
32
+
33
+ import {
34
+ scanSource,
35
+ reverseQuota,
36
+ actualCountsByQuota,
37
+ buildVolumeRatio,
38
+ buildChapterFile,
39
+ chapterFileName
40
+ } from './import-core.js';
41
+ import {
42
+ importDirs,
43
+ runImportStage,
44
+ renderImportReport,
45
+ writeImportReport,
46
+ pickExcerpts,
47
+ IMPORT_ROUNDS
48
+ } from './import-driver.js';
49
+ import { importTemplates } from './templates.js';
50
+
51
+ export const inject = ['commands', 'userQuestions'];
52
+
53
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
54
+
55
+ /** story-builder 插件目录(导入要借它的模板与校验器) */
56
+ const STORY_BUILDER_DIR = path.resolve(__dirname, '..', 'fancy-story-builder');
57
+
58
+ /** 导入阶段要用的脚本(按各自阶段的入口推导依赖闭包) */
59
+ const IMPORT_SCRIPT_PHASES = ['skeleton', 'flesh', 'rhythm'];
60
+
61
+ /** 导入阶段要用的参考文件 */
62
+ const IMPORT_REFERENCE_FILES = ['glossary.md', 'formulas-skeleton.json', 'formulas-rhythm.json'];
63
+
64
+ /** 导入阶段要用的模板分类 */
65
+ const IMPORT_TEMPLATE_DIRS = ['骨架', '血肉', '节奏'];
66
+
67
+ function getProjectRoot(session) {
68
+ // 不能回退到 process.cwd():那是宿主进程的工作目录,不是用户项目目录。
69
+ const cwd = session.header && session.header.cwd;
70
+ return cwd || null;
71
+ }
72
+
73
+ function copyDirRecursive(srcDir, destDir) {
74
+ try {
75
+ if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return false;
76
+ } catch {
77
+ return false;
78
+ }
79
+ try {
80
+ fs.mkdirSync(destDir, { recursive: true });
81
+ } catch {
82
+ return false;
83
+ }
84
+ let allOk = true;
85
+ let entries;
86
+ try {
87
+ entries = fs.readdirSync(srcDir);
88
+ } catch {
89
+ return false;
90
+ }
91
+ for (const f of entries) {
92
+ const src = path.join(srcDir, f);
93
+ const dest = path.join(destDir, f);
94
+ let st;
95
+ try {
96
+ st = fs.statSync(src);
97
+ } catch {
98
+ allOk = false;
99
+ continue;
100
+ }
101
+ if (st.isDirectory()) {
102
+ if (!copyDirRecursive(src, dest)) allOk = false;
103
+ } else if (st.isFile()) {
104
+ try {
105
+ fs.copyFileSync(src, dest);
106
+ } catch {
107
+ allOk = false;
108
+ }
109
+ }
110
+ }
111
+ return allOk;
112
+ }
113
+
114
+ /**
115
+ * 把导入阶段要用的脚本 / 参考 / 模板部署到项目里。
116
+ *
117
+ * 清单**不是手写的**:从各阶段入口校验脚本的 import 闭包推出来
118
+ * (见 story-builder/deploy-manifest.js)。少拷一个文件,
119
+ * 校验器在运行时才报 ERR_MODULE_NOT_FOUND —— 部署阶段看不出问题。
120
+ *
121
+ * @returns {{ok:boolean, missing:string[], err:string|null}}
122
+ */
123
+ export function deployImportFiles(projectRoot) {
124
+ const missing = [];
125
+ const refsDir = path.join(projectRoot, 'references');
126
+ const tplRoot = path.join(projectRoot, '模板');
127
+
128
+ try {
129
+ fs.mkdirSync(refsDir, { recursive: true });
130
+ } catch (e) {
131
+ return { ok: false, missing, err: 'references/ 目录建不出来:' + e.message };
132
+ }
133
+
134
+ // 1. 校验脚本(按 import 闭包)
135
+ const files = new Set();
136
+ for (const phase of IMPORT_SCRIPT_PHASES) {
137
+ const manifest = phaseScriptFiles(
138
+ phase,
139
+ path.join(STORY_BUILDER_DIR, 'scripts'),
140
+ path.join(STORY_BUILDER_DIR, 'prompts'),
141
+ path.join(STORY_BUILDER_DIR, 'references')
142
+ );
143
+ if (!manifest) continue;
144
+ for (const m of manifest.missing) missing.push(m);
145
+ for (const f of manifest.files) files.add(f);
146
+ }
147
+ for (const f of files) {
148
+ const src = path.join(STORY_BUILDER_DIR, 'scripts', f);
149
+ if (!fs.existsSync(src)) {
150
+ missing.push(f + '(清单里有,磁盘上没有)');
151
+ continue;
152
+ }
153
+ try {
154
+ fs.copyFileSync(src, path.join(refsDir, f));
155
+ } catch {
156
+ missing.push(f + '(拷贝失败)');
157
+ }
158
+ }
159
+
160
+ // 2. 参考文件(词表 / 公式)
161
+ for (const f of IMPORT_REFERENCE_FILES) {
162
+ const src = path.join(STORY_BUILDER_DIR, 'references', f);
163
+ if (!fs.existsSync(src)) {
164
+ missing.push(f + '(references 里没有)');
165
+ continue;
166
+ }
167
+ try {
168
+ fs.copyFileSync(src, path.join(refsDir, f));
169
+ } catch {
170
+ missing.push(f + '(拷贝失败)');
171
+ }
172
+ }
173
+
174
+ // 3. 模板
175
+ for (const d of IMPORT_TEMPLATE_DIRS) {
176
+ if (!copyDirRecursive(path.join(STORY_BUILDER_DIR, 'templates', d), path.join(tplRoot, d))) {
177
+ missing.push('模板/' + d);
178
+ }
179
+ }
180
+
181
+ return { ok: missing.length === 0, missing, err: null };
182
+ }
183
+
184
+ /**
185
+ * 把扫描结果落盘:正文按 story-builder 格式,设定资料单独放一份。
186
+ *
187
+ * 覆盖已有正文前**必须先备份**(2026-10-27 修):
188
+ * 用户跑过写作/去味之后再导入一次,旧实现直接用原始旧稿覆盖
189
+ * `设定/raw/sources/写作/第NNN章.md` —— 去味成果全部丢失且不可恢复
190
+ * (`.revisions/` 只覆盖去味阶段的备份,不覆盖导入)。
191
+ * 现在覆盖前先复制到 `写作/.revisions/import-<时间戳>/`,
192
+ * **备份不成功就不覆盖**(宁可这一章导入失败,也不能丢用户已有的正文)。
193
+ *
194
+ * @returns {{written:number, skipped:number, errors:string[], overwritten:string[], backupDir:string}}
195
+ */
196
+ export function writeScannedChapters(dirs, scan) {
197
+ const written = [];
198
+ const errors = [];
199
+ const overwritten = [];
200
+ let backupDir = '';
201
+
202
+ try {
203
+ fs.mkdirSync(dirs.writing, { recursive: true });
204
+ } catch (e) {
205
+ return { written: 0, skipped: 0, errors: ['写作目录建不出来:' + e.message], overwritten, backupDir };
206
+ }
207
+
208
+ for (const ch of scan.chapters) {
209
+ const p = path.join(dirs.writing, chapterFileName(ch.ch));
210
+
211
+ // 覆盖前先备份
212
+ if (fs.existsSync(p)) {
213
+ if (!backupDir) {
214
+ backupDir = path.join(dirs.writing, '.revisions', 'import-' + importStamp());
215
+ }
216
+ try {
217
+ fs.mkdirSync(backupDir, { recursive: true });
218
+ fs.copyFileSync(p, path.join(backupDir, path.basename(p)));
219
+ overwritten.push(path.basename(p));
220
+ } catch (e) {
221
+ errors.push('备份不了 ' + path.basename(p) + '(不覆盖它):' + e.message);
222
+ continue;
223
+ }
224
+ }
225
+
226
+ try {
227
+ fs.writeFileSync(p, buildChapterFile(ch, ch.ch), 'utf-8');
228
+ written.push(p);
229
+ } catch (e) {
230
+ errors.push('第 ' + ch.ch + ' 章写不下去:' + e.message);
231
+ }
232
+ }
233
+
234
+ // 设定资料:原样放一份,供模型反推时读
235
+ if (scan.settings.length) {
236
+ try {
237
+ for (const s of scan.settings) {
238
+ // s.name 现在是**相对路径**(可能带子目录),要先把父目录建出来
239
+ const target = path.join(dirs.material, s.name);
240
+ fs.mkdirSync(path.dirname(target), { recursive: true });
241
+ fs.writeFileSync(target, s.text, 'utf-8');
242
+ }
243
+ } catch (e) {
244
+ errors.push('设定资料落盘失败:' + e.message);
245
+ }
246
+ }
247
+
248
+ return { written: written.length, skipped: scan.chapters.length - written.length, errors, overwritten, backupDir };
249
+ }
250
+
251
+ /**
252
+ * 备份目录用的时间戳(可读 + 同一秒内不撞车)。
253
+ * @returns {string}
254
+ */
255
+ function importStamp() {
256
+ const d = new Date();
257
+ const p = (n, w) => String(n).padStart(w, '0');
258
+ return d.getFullYear() + '-' + p(d.getMonth() + 1, 2) + '-' + p(d.getDate(), 2) +
259
+ 'T' + p(d.getHours(), 2) + '-' + p(d.getMinutes(), 2) + '-' + p(d.getSeconds(), 2) +
260
+ '-' + p(d.getMilliseconds(), 3);
261
+ }
262
+
263
+ /**
264
+ * 导入阶段的命令入口。
265
+ */
266
+ export async function apply(ctx) {
267
+ ctx.commands.register({
268
+ name: 'fancy-import',
269
+ description: '📥 旧稿导入 ( usage: /fancy-import <旧稿目录或文件> )',
270
+ input: {
271
+ hint: '旧稿目录或文件'
272
+ },
273
+ handler: async (invocation) => {
274
+ const session = invocation.agent.session;
275
+ const { signal } = invocation;
276
+
277
+ const projectRoot = getProjectRoot(session);
278
+ if (!projectRoot) {
279
+ return notify(session, 'error', importTemplates.notify({
280
+ type: 'no_project_root',
281
+ err: '会话未提供 cwd'
282
+ }));
283
+ }
284
+
285
+ if (!fs.existsSync(path.join(projectRoot, '.fancy-deployed'))) {
286
+ return notify(session, 'error', importTemplates.notify({ type: 'not_initialized' }));
287
+ }
288
+
289
+ const deployedRes = readDeployedConfig(projectRoot);
290
+ if (!deployedRes.ok || !deployedRes.config) {
291
+ return notify(session, 'error', importTemplates.notify({
292
+ type: deployedRes.reason === 'not_found' ? 'not_initialized' : 'no_source',
293
+ err: '.fancy-deployed 读不到(' + (deployedRes.reason || '未知') + ')'
294
+ }));
295
+ }
296
+ const deployedConfig = deployedRes.config;
297
+
298
+ // 1. 导入源
299
+ const rawInput = (invocation.rawInput || '').trim();
300
+ let sourcePath = rawInput;
301
+ if (!sourcePath) {
302
+ // 没给路径:默认找项目里的 旧稿/ 目录
303
+ const fallback = path.join(projectRoot, '旧稿');
304
+ if (fs.existsSync(fallback) && fs.statSync(fallback).isDirectory()) {
305
+ sourcePath = fallback;
306
+ } else {
307
+ return notify(session, 'error', importTemplates.notify({ type: 'no_source_arg' }));
308
+ }
309
+ }
310
+ if (!path.isAbsolute(sourcePath)) {
311
+ sourcePath = path.resolve(projectRoot, sourcePath);
312
+ }
313
+
314
+ // 2. 扫描(确定性:切章保标题 + 统计实际规模)
315
+ const scan = scanSource(sourcePath);
316
+ if (!scan.ok) {
317
+ return notify(session, 'error', importTemplates.notify({ type: 'no_source', err: scan.error }));
318
+ }
319
+
320
+ // 3. 部署导入要用的脚本 / 模板
321
+ const deploy = deployImportFiles(projectRoot);
322
+ if (!deploy.ok) {
323
+ return notify(session, 'error', importTemplates.notify({
324
+ type: 'deploy_failed',
325
+ err: deploy.missing.join('、')
326
+ }));
327
+ }
328
+
329
+ // 4. 设定项目(导入的产物要落在「设定」目录下才会被 ingest 纳管)
330
+ const llmWikiUrl = (deployedConfig.llm_wiki_url || '').replace(/\/$/, '');
331
+ const llmWikiToken = deployedConfig.llm_wiki_token || '';
332
+ const setupResult = await ensureSetupId(projectRoot, deployedConfig, llmWikiUrl, llmWikiToken, signal);
333
+ if (!setupResult.id) {
334
+ return notify(session, 'error', importTemplates.notify({
335
+ type: 'setup_id_unavailable',
336
+ err: setupResult.reason
337
+ }));
338
+ }
339
+
340
+ const dirs = importDirs(projectRoot);
341
+
342
+ // 5. 正文落盘
343
+ const writeRes = writeScannedChapters(dirs, scan);
344
+ if (writeRes.errors.length) {
345
+ return notify(session, 'error', importTemplates.notify({
346
+ type: 'no_source',
347
+ err: writeRes.errors.join(';')
348
+ }));
349
+ }
350
+
351
+ // 6. 公式(算配额用)
352
+ let formulas = {};
353
+ try {
354
+ formulas = JSON.parse(
355
+ fs.readFileSync(path.join(projectRoot, 'references', 'formulas-skeleton.json'), 'utf-8')
356
+ ).formulas || {};
357
+ } catch (e) {
358
+ return notify(session, 'error', importTemplates.notify({
359
+ type: 'deploy_failed',
360
+ err: 'formulas-skeleton.json 读不出来:' + e.message
361
+ }));
362
+ }
363
+
364
+ // 7. 逐轮反推
365
+ const progressNotes = [];
366
+ const contract = readImportContract();
367
+ const waitForTurn = makeImportWait(invocation);
368
+
369
+ // 配额是算出来的,实体轮之后才有值 —— 关系/血肉/节奏轮都要照它写
370
+ const promptContext = {
371
+ chapterCount: scan.chapters.length,
372
+ settingCount: scan.settings.length,
373
+ excerpts: pickExcerpts(scan.chapters),
374
+ quota: null
375
+ };
376
+
377
+ const report = await runImportStage({
378
+ projectRoot,
379
+ dirs,
380
+ contract,
381
+ context: promptContext,
382
+ sendPrompt: async (prompt) => llmFollowup(invocation, prompt),
383
+ waitIdle: async (files) => waitForTurn(files),
384
+ validateFile: async (file, round, f) => {
385
+ if (!round.validator || !f.tpl) return { ok: true, errors: [] };
386
+ const script = path.join(projectRoot, 'references', round.validator);
387
+ const tpl = path.join(projectRoot, '模板', round.templateDir, f.tpl);
388
+ const r = await spawnScript('node', [
389
+ script,
390
+ '--input=' + file,
391
+ '--template=' + tpl,
392
+ '--file=' + f.type
393
+ ], { timeout: 120000, cwd: projectRoot });
394
+ if (!r.ok) {
395
+ return { ok: false, errors: [{ code: 'VALIDATOR_CRASH', path: file, message: '校验脚本异常:' + (r.error || r.stderr || '未知错误'), hint: null }] };
396
+ }
397
+ try {
398
+ const parsed = JSON.parse(r.output);
399
+ return { ok: parsed.status === 0, errors: parsed.errors || [] };
400
+ } catch (e) {
401
+ return { ok: false, errors: [{ code: 'VALIDATOR_OUTPUT_INVALID', path: file, message: '校验脚本输出不是合法 JSON:' + e.message, hint: null }] };
402
+ }
403
+ },
404
+ onProgress: (msg) => { progressNotes.push(msg); },
405
+ onRound: (r) => {
406
+ // 实体轮结束后按实际条数算出配额,写 框架.json,
407
+ // 后面的关系/血肉/节奏轮都照这份配额写。
408
+ if (r.id !== 'entities') return;
409
+ const q = writeFramework(dirs, scan, formulas);
410
+ promptContext.quota = q.quota;
411
+ }
412
+ });
413
+
414
+ // 8. 兜底重算一次配额(即便实体轮没通过,也要给出配额与报告)
415
+ const quotaInfo = writeFramework(dirs, scan, formulas);
416
+
417
+ // 9. 框架.json 自己也要过一遍校验器(它是导入的核心产物)
418
+ const frameworkErrors = await validateFramework(projectRoot, dirs);
419
+
420
+ // 10. 报告
421
+ const gaps = frameworkErrors.map(
422
+ (e) => '[框架.json] [' + e.code + '] ' + (e.path || '(无路径)') + ':' + e.message +
423
+ (e.hint ? '(' + e.hint + ')' : '')
424
+ );
425
+ const infeasible = rhythmInfeasibleReason(scan.stats.chapterCount, quotaInfo.quota.branch_count);
426
+ if (infeasible) gaps.push(infeasible);
427
+
428
+ // 10.1 **阻塞项**:这些不是"建议补全",是"产物不可用"。
429
+ //
430
+ // 旧实现把它们全塞进 gaps,而成功判定只看 report.failed ——
431
+ // 于是"框架校验不过 / 框架没写出来 / 校验器没部署 / 节奏无解"
432
+ // 全都只写进报告,通知仍然是"✅ 导入完成"(2026-10-27 核实)。
433
+ // 产物在下游骨架阶段必然失败,用户却被告知成功。
434
+ const blocking = [];
435
+ if (!quotaInfo.ok) {
436
+ blocking.push('框架.json 写入失败:' + (quotaInfo.error || '未知错误'));
437
+ }
438
+ for (const e of frameworkErrors) {
439
+ blocking.push('[' + e.code + '] ' + (e.path || '(无路径)') + ':' + e.message);
440
+ }
441
+ if (infeasible) blocking.push(infeasible);
442
+
443
+ const reportMd = renderImportReport({
444
+ scan,
445
+ report,
446
+ quotaDecisions: quotaInfo.decisions,
447
+ gaps,
448
+ overwritten: writeRes.overwritten,
449
+ backupDir: writeRes.backupDir
450
+ });
451
+ writeImportReport(dirs, reportMd);
452
+
453
+ const progressSummary = progressNotes.length
454
+ ? '导入进度:' + progressNotes.slice(-3).join(';')
455
+ : '';
456
+
457
+ // 覆盖提示必须说清楚:用户可能已经跑过写作/去味,
458
+ // 这次导入把旧稿原样盖回去了(原文已备份,但成果确实被覆盖)。
459
+ const overwriteNote = writeRes.overwritten.length
460
+ ? '⚠️ 覆盖了 ' + writeRes.overwritten.length + ' 个已有章节' +
461
+ '(原文已备份到 `' + writeRes.backupDir + '`)。'
462
+ : '';
463
+
464
+ if (report.failed.length || blocking.length) {
465
+ const detail = blocking.length
466
+ ? '**阻塞项(产物当前不可用)**:\n' + blocking.map((b) => '- ' + b).join('\n')
467
+ : '';
468
+ return notify(session, 'error', importTemplates.notify({
469
+ type: 'import_incomplete',
470
+ failed: report.failed.length,
471
+ extra: [progressSummary, overwriteNote, detail].filter(Boolean).join('\n\n')
472
+ }));
473
+ }
474
+
475
+ return notify(session, 'success', importTemplates.notify({
476
+ type: 'import_done',
477
+ chapters: scan.stats.chapterCount,
478
+ rounds: report.rounds.length,
479
+ extra: [progressSummary, overwriteNote].filter(Boolean).join('\n\n')
480
+ }));
481
+ }
482
+ });
483
+ }
484
+
485
+ // ============================================================
486
+ // 辅助
487
+ // ============================================================
488
+
489
+ /**
490
+ * 读导入执行须知(prompts/01-import-prompt.md)。
491
+ */
492
+ function readImportContract() {
493
+ try {
494
+ return fs.readFileSync(path.join(__dirname, 'prompts', '01-import-prompt.md'), 'utf-8');
495
+ } catch {
496
+ return '';
497
+ }
498
+ }
499
+
500
+ /**
501
+ * 等 LLM 跑完一轮。
502
+ *
503
+ * 用 story-builder 的 makeWaitForTurn —— 轮询 agent.status 的那套逻辑
504
+ * 两边一样,自己抄一份必然漂移。
505
+ */
506
+ function makeImportWait(invocation) {
507
+ return makeWaitForTurn(invocation, { timeoutMs: 45 * 60 * 1000 });
508
+ }
509
+
510
+ /**
511
+ * 按旧稿实际规模反推 quota,写 骨架/框架.json。
512
+ *
513
+ * @returns {{quota:Object, decisions:Array, path:string}}
514
+ */
515
+ export function writeFramework(dirs, scan, formulas) {
516
+ // 实体.json 是骨架轮产出的;读不到就按"没有实体"处理(配额会全部走公式)
517
+ let entityData = null;
518
+ try {
519
+ entityData = JSON.parse(fs.readFileSync(path.join(dirs.skeleton, '实体.json'), 'utf-8'));
520
+ } catch {
521
+ entityData = null;
522
+ }
523
+
524
+ const actualCounts = actualCountsByQuota(entityData);
525
+ const { quota, decisions, derivations } = reverseQuota({
526
+ targetChapters: scan.stats.chapterCount,
527
+ volumeCount: scan.stats.volumeCount || null,
528
+ actualCounts,
529
+ formulas
530
+ });
531
+
532
+ const capacity = {
533
+ target_chapters: scan.stats.chapterCount,
534
+ target_words: scan.stats.totalWords,
535
+ avg_words_per_chapter: scan.stats.avgWords
536
+ };
537
+
538
+ // 输入快照:书名 / 题材 / 核心冲突这些脚本算不出来(那是书的自我认知),
539
+ // 由第一轮反推产出;读不到就留空 —— 空值会被骨架校验器如实拦下。
540
+ const snapshot = readInputSnapshot(dirs);
541
+
542
+ // 卷比例:按旧稿的实际分卷(没分卷信息就均分)
543
+ const perVolume = chaptersPerVolume(scan);
544
+ const volumeRatio = buildVolumeRatio(quota.volume_count || 0, perVolume.counts, {
545
+ total: (scan.chapters || []).length,
546
+ hasVolumeInfo: perVolume.hasVolumeInfo
547
+ });
548
+
549
+ const framework = {
550
+ capacity,
551
+ input_snapshot: snapshot,
552
+ quota,
553
+ volume_ratio: volumeRatio,
554
+ derivations
555
+ };
556
+
557
+ let written = '';
558
+ let writeError = null;
559
+ try {
560
+ fs.mkdirSync(dirs.skeleton, { recursive: true });
561
+ written = path.join(dirs.skeleton, '框架.json');
562
+ fs.writeFileSync(written, JSON.stringify(framework, null, 2), 'utf-8');
563
+ } catch (e) {
564
+ // 写不出来必须**如实报**,不能悄悄吞掉 —— 旧实现吞掉之后
565
+ // validateFramework 因为"文件不存在"直接返回 [],于是导入报成功,
566
+ // 而框架.json 根本没生成(2026-10-27 核实)。
567
+ written = '';
568
+ writeError = e && e.message ? e.message : String(e);
569
+ }
570
+
571
+ return { quota, decisions, derivations, snapshot, path: written, ok: !writeError, error: writeError };
572
+ }
573
+
574
+ /**
575
+ * 读模型反推出的输入快照。
576
+ * @param {Object} dirs
577
+ * @returns {Object}
578
+ */
579
+ function readInputSnapshot(dirs) {
580
+ const empty = {
581
+ book_name: '',
582
+ genre: '',
583
+ core_summary: '',
584
+ core_conflict: '',
585
+ hard_constraints: [],
586
+ anti_tropes: []
587
+ };
588
+ let raw;
589
+ try {
590
+ raw = JSON.parse(fs.readFileSync(path.join(dirs.material, '输入快照.json'), 'utf-8'));
591
+ } catch {
592
+ return empty;
593
+ }
594
+ if (!raw || typeof raw !== 'object') return empty;
595
+ const arr = (v) => (Array.isArray(v) ? v.map((x) => String(x)).filter(Boolean) : []);
596
+ return {
597
+ book_name: typeof raw.book_name === 'string' ? raw.book_name : '',
598
+ genre: typeof raw.genre === 'string' ? raw.genre : '',
599
+ core_summary: typeof raw.core_summary === 'string' ? raw.core_summary : '',
600
+ core_conflict: typeof raw.core_conflict === 'string' ? raw.core_conflict : '',
601
+ hard_constraints: arr(raw.hard_constraints),
602
+ anti_tropes: arr(raw.anti_tropes)
603
+ };
604
+ }
605
+
606
+ /**
607
+ * 旧稿每卷实际写了多少章(按卷序)。
608
+ *
609
+ * ## 旧实现错在哪
610
+ *
611
+ * 旧代码是"每遇到一个带 volume 的章就 counts.push(1)" —— 那不是按卷分桶,
612
+ * 是把 600 章变成 600 个桶,再截断到 V 个,结果还是 [1,1,1,…];
613
+ * 而 scanSource 又没把 volume 写进 chapter,`ch.volume` 永远是 undefined,
614
+ * 于是全部落进"第 0 卷"分支 → counts = [总章数]。
615
+ * 注释写着"优先用旧稿的实际分卷,没分卷信息才均分",实现恰好相反。
616
+ *
617
+ * 现在按**卷**分桶:
618
+ * - 有卷信息的章 → 按卷归桶(卷的标识用卷标题行,取不到就用卷号);
619
+ * - 卷标题之前那些没卷信息的章 → 归入第一卷(旧稿里它们确实排在最前面);
620
+ * - 旧稿**完全**没有卷信息 → 返回 hasVolumeInfo=false,调用方据此均分。
621
+ *
622
+ * @param {Object} scan
623
+ * @returns {{counts:number[], hasVolumeInfo:boolean}}
624
+ */
625
+ function chaptersPerVolume(scan) {
626
+ const chapters = scan.chapters || [];
627
+ const counts = [];
628
+ const bucketOf = new Map();
629
+ let loose = 0;
630
+
631
+ for (const ch of chapters) {
632
+ const v = ch.volume || null;
633
+ if (!v) {
634
+ loose += 1;
635
+ continue;
636
+ }
637
+ const key = v.heading || (v.no !== null && v.no !== undefined ? 'no:' + v.no : 'title:' + (v.title || ''));
638
+ let idx = bucketOf.get(key);
639
+ if (idx === undefined) {
640
+ idx = counts.length;
641
+ counts.push(0);
642
+ bucketOf.set(key, idx);
643
+ }
644
+ counts[idx] += 1;
645
+ }
646
+
647
+ if (!counts.length) {
648
+ // 旧稿完全没有卷信息
649
+ return { counts: loose ? [loose] : [], hasVolumeInfo: false };
650
+ }
651
+ if (loose) counts[0] += loose;
652
+ return { counts, hasVolumeInfo: true };
653
+ }
654
+
655
+ /**
656
+ * 校验插件生成的 框架.json。
657
+ *
658
+ * 框架.json 是导入的核心产物(配额、卷比例、推导全在里面),
659
+ * 它必须和别的东西一样过校验器 —— 不然导入"成功"了,
660
+ * 下游骨架阶段一跑就炸。
661
+ *
662
+ * @returns {Promise<Array>} 错误列表
663
+ */
664
+ export async function validateFramework(projectRoot, dirs) {
665
+ const file = path.join(dirs.skeleton, '框架.json');
666
+ if (!fs.existsSync(file)) {
667
+ // 文件不存在 ≠ 校验通过。旧实现这里 return [],于是"框架没写出来"
668
+ // 被当成"没问题",导入照样报成功(2026-10-27 核实)。
669
+ return [{
670
+ code: 'FRAMEWORK_MISSING',
671
+ path: file,
672
+ message: '框架.json 没有生成(写入失败,或实体轮没有产出实体清单)',
673
+ hint: '检查磁盘空间/目录权限,以及导入报告里的实体轮结果'
674
+ }];
675
+ }
676
+ const script = path.join(projectRoot, 'references', 'skeleton-validate.js');
677
+ const tpl = path.join(projectRoot, '模板', '骨架', '框架模板.json');
678
+ if (!fs.existsSync(script)) {
679
+ // 校验器缺失是**部署失败**,不是"跳过" —— 跳过后框架根本没被校验过。
680
+ return [{ code: 'VALIDATOR_MISSING', path: script, message: '骨架校验器没部署,无法校验框架.json', hint: '重新部署插件后再导入' }];
681
+ }
682
+ const r = await spawnScript('node', [
683
+ script,
684
+ '--input=' + file,
685
+ '--template=' + tpl,
686
+ '--file=框架'
687
+ ], { timeout: 120000, cwd: projectRoot });
688
+ if (!r.ok) {
689
+ return [{ code: 'VALIDATOR_CRASH', path: file, message: '校验脚本异常:' + (r.error || r.stderr || '未知错误'), hint: null }];
690
+ }
691
+ try {
692
+ const parsed = JSON.parse(r.output);
693
+ return parsed.errors || [];
694
+ } catch (e) {
695
+ return [{ code: 'VALIDATOR_OUTPUT_INVALID', path: file, message: '校验脚本输出不是合法 JSON:' + e.message, hint: null }];
696
+ }
697
+ }
698
+
699
+ /**
700
+ * 支线可行性:骨架校验器要求 BC*3 <= T*0.5 且 BC*30 >= T*0.3。
701
+ *
702
+ * T 太小的时候这个条件**无解**(T=3 时 BC 至少要 <=0.5、>=0.03,
703
+ * 没有整数满足)。这不是导入的 bug,是旧稿太短撑不起骨架阶段的规模要求。
704
+ * 如实说出来,别装作导入成功了。
705
+ *
706
+ * 关于"即 T >= 6":那句话是错的(2026-10-27 核实)——
707
+ * 可行条件是 **BC <= floor(ceil(0.5T)/3) 且 BC >= ceil(floor(0.3T)/30)**,
708
+ * 与 T 无关地还要求 BC 本身落在某个区间里。T=6 时只有 BC=1 可行,
709
+ * BC=2、BC=3 都无解。所以这里直接给出**可行的 BC 区间**,让用户照着调。
710
+ *
711
+ * @param {number} T 旧稿章数
712
+ * @param {number} BC 支线配额
713
+ * @returns {string|null} 有问题返回说明,否则 null
714
+ */
715
+ export function rhythmInfeasibleReason(T, BC) {
716
+ if (typeof T !== 'number' || typeof BC !== 'number') return null;
717
+ const lower = Math.max(BC * 3, Math.floor(T * 0.3));
718
+ const upper = Math.min(BC * 30, Math.ceil(T * 0.5));
719
+ if (lower <= upper) return null;
720
+ // 可行的 BC 区间:把 lower/upper 两边对 BC 解出来
721
+ const bcLower = Math.max(1, Math.ceil(Math.floor(T * 0.3) / 30));
722
+ const bcUpper = Math.floor(Math.ceil(T * 0.5) / 3);
723
+ const feasible = bcUpper >= bcLower
724
+ ? '可行的支线数区间是 [' + bcLower + ', ' + bcUpper + '](BC*3 <= T*0.5 且 BC*30 >= T*0.3)'
725
+ : '当前 T 下没有任何支线数可行(需要 ceil(floor(0.3T)/30) <= BC <= floor(ceil(0.5T)/3))';
726
+ return '旧稿只有 ' + T + ' 章,达不到骨架阶段的规模要求:' +
727
+ '支线配额 ' + BC + ' 要求区间 [' + lower + ', ' + upper + '] 无解' +
728
+ '(' + feasible + ')。' +
729
+ '建议把支线数改到上面的可行区间,或改用 /fancy-story-builder 从骨架重新起稿。';
730
+ }