@cloud411716/fancy-webnovel 1.0.45 → 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 -160
  127. package/commands/bootstrap/init-project.js +0 -121
  128. package/commands/scan/index.js +0 -275
  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,417 @@
1
+ /**
2
+ * stage-contract.js v1.0
3
+ * 产物契约的分节抽取 + 骨架相关关系筛选。
4
+ *
5
+ * 为什么需要分节:
6
+ * 血肉提示词原本 38.5KB,是一份"LLM 自己开车"的执行手册。
7
+ * 改成插件驱动之后,每轮只需要带:总纲 + 任务输入 + 铁律 + 本文件的字段契约
8
+ * + 质量正反例 + (重试时)错误码速查。按小节抽取能让每轮提示词降到
9
+ * 十几 KB,同时保留全部质量约束。
10
+ *
11
+ * 契约文件里的分节标记形如:<!-- contract:file:角色 -->
12
+ */
13
+
14
+ // 分节标记:<!-- contract:名字 -->
15
+ // 名字允许中文(例如 file:角色),所以用"非空白非尖括号"而不是 ASCII 类。
16
+ const MARKER_RE = /<!--\s*contract:([^\s>]+)\s*-->/g;
17
+
18
+ /**
19
+ * 把契约文件切成小节。
20
+ *
21
+ * @param {string} content 契约文件全文
22
+ * @returns {Object} { head, order, sections }
23
+ */
24
+ export function splitContractSections(content) {
25
+ const text = typeof content === 'string' ? content : '';
26
+ const order = [];
27
+ const sections = {};
28
+ let head = '';
29
+ let current = null;
30
+ let buffer = '';
31
+ let lastIndex = 0;
32
+
33
+ const re = new RegExp(MARKER_RE.source, 'g');
34
+ let m;
35
+ while ((m = re.exec(text)) !== null) {
36
+ const chunk = text.slice(lastIndex, m.index);
37
+ if (current === null) head += chunk;
38
+ else buffer += chunk;
39
+ if (current !== null) {
40
+ sections[current] = buffer.trim();
41
+ buffer = '';
42
+ }
43
+ current = m[1];
44
+ order.push(current);
45
+ lastIndex = re.lastIndex;
46
+ }
47
+ const tail = text.slice(lastIndex);
48
+ if (current === null) head += tail;
49
+ else {
50
+ buffer += tail;
51
+ sections[current] = buffer.trim();
52
+ }
53
+
54
+ return { head: head.trim(), order: order, sections: sections };
55
+ }
56
+
57
+ /**
58
+ * 按名字取若干小节并拼成一段提示词。
59
+ *
60
+ * @param {string} content 契约文件全文
61
+ * @param {string[]} names 小节名,按给定顺序拼接
62
+ * @returns {Object} { text, missing }
63
+ */
64
+ export function pickContractSections(content, names) {
65
+ const split = splitContractSections(content);
66
+ const parts = [];
67
+ const missing = [];
68
+ for (const name of names || []) {
69
+ const sec = split.sections[name];
70
+ if (sec === undefined || sec === '') {
71
+ missing.push(name);
72
+ continue;
73
+ }
74
+ parts.push(sec);
75
+ }
76
+ return { text: parts.join('\n\n---\n\n'), missing: missing };
77
+ }
78
+
79
+ // ============================================================
80
+ // 骨架关系筛选
81
+ // ============================================================
82
+
83
+ /**
84
+ * 每张关系表用哪些字段装 id。
85
+ * match —— 命中任一字段即算"相关"
86
+ * anchor —— 有 anchor 时,必须命中 anchor 里的某个字段才算相关
87
+ * (用于关系方向:character_relations 的 from 是关系发起方,
88
+ * 只有 from 落在本批里,血肉才需要写这条关系)
89
+ */
90
+ const RELATION_TABLES = {
91
+ character_relations: { match: ['from', 'to'], anchor: ['from'] },
92
+ faction_membership: { match: ['character', 'faction'], anchor: null },
93
+ faction_relations: { match: ['from', 'to'], anchor: ['from'] },
94
+ conflict_participants: { match: ['conflict', 'protagonist_side', 'main_antagonist', 'secondary_antagonist', 'third_party'], anchor: null },
95
+ branch_protagonist: { match: ['branch', 'character'], anchor: null },
96
+ item_owner: { match: ['item', 'owner_id'], anchor: null },
97
+ location_conflict: { match: ['location', 'conflicts'], anchor: null },
98
+ ending_character: { match: ['ending', 'character'], anchor: null },
99
+ main_line_branch_relation: { match: ['main_line', 'branch'], anchor: null }
100
+ };
101
+
102
+ /**
103
+ * 从一条关系记录里抽出所有 id 形态的值(字符串或字符串数组)。
104
+ */
105
+ function relationIds(record, fields) {
106
+ const out = [];
107
+ for (const f of fields) {
108
+ const v = record[f];
109
+ if (typeof v === 'string' && v) out.push(v);
110
+ else if (Array.isArray(v)) {
111
+ for (const x of v) if (typeof x === 'string' && x) out.push(x);
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * 筛出与本批实体相关的关系,并渲染成提示词可读的清单。
119
+ *
120
+ * @param {Object} relations 骨架 关系.json 的内容
121
+ * @param {Object} idToName id -> name 映射(来自骨架 实体.json)
122
+ * @param {string[]} ids 本批涉及的 id 集合
123
+ * @returns {Object} { lines: string[], count: number }
124
+ */
125
+ export function collectRelevantRelations(relations, idToName, ids) {
126
+ const idSet = new Set(ids || []);
127
+ const names = idToName || {};
128
+ const lines = [];
129
+
130
+ if (!relations || typeof relations !== 'object') return { lines: lines, count: 0 };
131
+
132
+ for (const table of Object.keys(RELATION_TABLES)) {
133
+ const spec = RELATION_TABLES[table];
134
+ if (!spec) continue;
135
+ const rows = relations[table];
136
+ if (!Array.isArray(rows)) continue;
137
+
138
+ const hits = [];
139
+ for (const row of rows) {
140
+ if (!row || typeof row !== 'object') continue;
141
+ const matchIds = relationIds(row, spec.match);
142
+ const matched = matchIds.filter((x) => idSet.has(x));
143
+ if (matched.length === 0) continue;
144
+ if (spec.anchor) {
145
+ const anchorIds = relationIds(row, spec.anchor);
146
+ const anchorHit = anchorIds.some((x) => idSet.has(x));
147
+ if (!anchorHit) continue;
148
+ }
149
+ hits.push({ row: row, ids: matchIds });
150
+ }
151
+
152
+ if (hits.length === 0) continue;
153
+
154
+ lines.push('**' + table + '**(' + hits.length + ' 条)');
155
+ for (const hit of hits) {
156
+ const parts = [];
157
+ for (const key of Object.keys(hit.row)) {
158
+ const v = hit.row[key];
159
+ if (typeof v === 'string') {
160
+ parts.push(key + '=' + v + nameSuffix(v, names));
161
+ } else if (Array.isArray(v)) {
162
+ parts.push(key + '=[' + v.map((x) => (typeof x === 'string' ? x + nameSuffix(x, names) : String(x))).join(', ') + ']');
163
+ } else if (v !== null && typeof v === 'object') {
164
+ parts.push(key + '=' + JSON.stringify(v));
165
+ } else {
166
+ parts.push(key + '=' + String(v));
167
+ }
168
+ }
169
+ // 表名写在行内,模型逐行读时不用回头对照上一行的标题
170
+ lines.push('- [' + table + '] ' + parts.join(';'));
171
+ }
172
+ }
173
+
174
+ return { lines: lines, count: lines.length };
175
+ }
176
+
177
+ function nameSuffix(id, names) {
178
+ if (!names || !names[id]) return '';
179
+ return '(' + names[id] + ')';
180
+ }
181
+
182
+ /**
183
+ * 从骨架 实体.json 里建立 id -> name 映射。
184
+ */
185
+ export function buildIdToName(skeletonEntities) {
186
+ const map = {};
187
+ if (!skeletonEntities || typeof skeletonEntities !== 'object') return map;
188
+ for (const key of Object.keys(skeletonEntities)) {
189
+ const list = skeletonEntities[key];
190
+ if (!Array.isArray(list)) continue;
191
+ for (const e of list) {
192
+ if (!e || typeof e !== 'object') continue;
193
+ if (e.id && e.name) map[e.id] = e.name;
194
+ }
195
+ }
196
+ return map;
197
+ }
198
+
199
+ // ============================================================
200
+ // 节奏阶段:本批章号的真源信息
201
+ // ============================================================
202
+
203
+ /**
204
+ * 把"本批章号在时序映射里的对应信息"渲染成提示词可读的行。
205
+ *
206
+ * 为什么必须给:节奏阶段的章节号唯一真源是 `时序映射.json`,
207
+ * 而 `章节细纲.json` 的 hook_head / hook_tail 必须与 hook_timeline
208
+ * 中该章的钩子对齐(校验器按章比对)。模型自己编章号 / 编钩子
209
+ * 必然对不上,只能把真源原样喂给它。
210
+ *
211
+ * @param {Object} opts
212
+ * @param {Object} opts.timeline 已合并的 时序映射.json
213
+ * @param {number[]} opts.chapters 本批要写的章号
214
+ * @param {Object} [opts.idToName] id -> name(骨架实体)
215
+ * @param {Object} [opts.climaxTypes] climax_id -> type(区分"大高潮")
216
+ * @returns {Object} { lines: string[], count: number }
217
+ */
218
+ export function buildRhythmChapterRefs(opts) {
219
+ const o = opts || {};
220
+ const timeline = o.timeline && typeof o.timeline === 'object' ? o.timeline : {};
221
+ const chapters = Array.isArray(o.chapters) ? o.chapters : [];
222
+ const names = o.idToName || {};
223
+ const climaxTypes = o.climaxTypes || {};
224
+ const lines = [];
225
+
226
+ const hookByCh = new Map();
227
+ for (const h of (timeline.hook_timeline || [])) {
228
+ if (h && typeof h.ch === 'number') hookByCh.set(h.ch, h);
229
+ }
230
+ const climaxByCh = new Map();
231
+ for (const c of (timeline.climax_timeline || [])) {
232
+ if (c && typeof c.chapter === 'number') {
233
+ if (!climaxByCh.has(c.chapter)) climaxByCh.set(c.chapter, []);
234
+ climaxByCh.get(c.chapter).push(c);
235
+ }
236
+ }
237
+ const endingByCh = new Map();
238
+ for (const e of (timeline.ending_timeline || [])) {
239
+ if (e && typeof e.chapter === 'number') {
240
+ if (!endingByCh.has(e.chapter)) endingByCh.set(e.chapter, []);
241
+ endingByCh.get(e.chapter).push(e);
242
+ }
243
+ }
244
+ const fsByCh = new Map();
245
+ for (const f of (timeline.foreshadow_timeline || [])) {
246
+ if (!f || typeof f !== 'object') continue;
247
+ for (const [ch, kind] of [[f.buried_chapter, '埋'], [f.recovered_chapter, '收']]) {
248
+ if (typeof ch !== 'number') continue;
249
+ if (!fsByCh.has(ch)) fsByCh.set(ch, []);
250
+ fsByCh.get(ch).push(kind + ' ' + f.fs_id + nameOf(f.fs_id, names));
251
+ }
252
+ }
253
+ const mainNodesByCh = new Map();
254
+ for (const m of (timeline.main_line_timeline || [])) {
255
+ for (const n of (m && m.key_nodes) || []) {
256
+ if (!n || typeof n.chapter !== 'number') continue;
257
+ if (!mainNodesByCh.has(n.chapter)) mainNodesByCh.set(n.chapter, []);
258
+ mainNodesByCh.get(n.chapter).push({ main_id: m.main_id, event_ref: n.event_ref });
259
+ }
260
+ }
261
+ const branchByCh = new Map();
262
+ for (const b of (timeline.branch_timeline || [])) {
263
+ if (!b || typeof b !== 'object') continue;
264
+ for (const [ch, kind] of [[b.start, '起'], [b.turning, '转'], [b.resolution, '收']]) {
265
+ if (typeof ch !== 'number') continue;
266
+ if (!branchByCh.has(ch)) branchByCh.set(ch, []);
267
+ branchByCh.get(ch).push(kind + ' ' + b.branch_id + nameOf(b.branch_id, names));
268
+ }
269
+ }
270
+
271
+ for (const ch of chapters) {
272
+ const parts = [];
273
+ const hook = hookByCh.get(ch);
274
+ if (hook) {
275
+ parts.push('钩子 头=' + str(hook.hook_head) + ' 尾=' + str(hook.hook_tail) + ' 强度=' + str(hook.hook_strength));
276
+ }
277
+ for (const c of (climaxByCh.get(ch) || [])) {
278
+ const type = climaxTypes[c.climax_id] ? '(' + climaxTypes[c.climax_id] + ')' : '';
279
+ parts.push('高潮 ' + c.climax_id + nameOf(c.climax_id, names) + type);
280
+ }
281
+ for (const e of (endingByCh.get(ch) || [])) {
282
+ parts.push('结局 ' + e.ending_id + nameOf(e.ending_id, names));
283
+ }
284
+ for (const n of (mainNodesByCh.get(ch) || [])) {
285
+ parts.push('主线节点 ' + n.main_id + nameOf(n.main_id, names) + ':' + str(n.event_ref));
286
+ }
287
+ for (const b of (branchByCh.get(ch) || [])) {
288
+ parts.push('支线 ' + b);
289
+ }
290
+ for (const f of (fsByCh.get(ch) || [])) {
291
+ parts.push('伏笔 ' + f);
292
+ }
293
+ if (!parts.length) {
294
+ parts.push('(时序映射里本章没有钩子/高潮/结局/主线节点记录)');
295
+ }
296
+ lines.push('- 第 ' + ch + ' 章:' + parts.join(';'));
297
+ }
298
+
299
+ return { lines: lines, count: lines.length };
300
+ }
301
+
302
+ function str(v) {
303
+ if (v === null || v === undefined) return '';
304
+ return String(v);
305
+ }
306
+
307
+ function nameOf(id, names) {
308
+ if (!id || !names || !names[id]) return '';
309
+ return '(' + names[id] + ')';
310
+ }
311
+
312
+ /**
313
+ * 渲染"上一批收尾",让模型接着上一批往下写。
314
+ *
315
+ * 章节是按时间推进的:第 N 章要承接第 N-1 章。模型看不到上一批的产物
316
+ * (分片是各自独立的文件),不给收尾就会写成互不相关的片段。
317
+ *
318
+ * @param {Object} opts
319
+ * @param {Object} [opts.shard] 上一批的分片内容(已解析的 JSON)
320
+ * @param {string[]} [opts.arrayKeys] 分片里的数组名(按优先级)
321
+ * @returns {string|null} 收尾描述;取不到返回 null
322
+ */
323
+ export function buildRhythmPrevTail(opts) {
324
+ const o = opts || {};
325
+ const shard = o.shard;
326
+ if (!shard || typeof shard !== 'object') return null;
327
+
328
+ // 收尾只挑"承上启下"用得上的字段
329
+ const FIELDS = [
330
+ ['ch', '章'],
331
+ ['volume', '卷'],
332
+ ['act', '幕'],
333
+ ['scene_focus', '场景重点'],
334
+ ['function_main', '主功能'],
335
+ ['function_aux', '辅功能'],
336
+ ['hook_head', '钩子头'],
337
+ ['hook_tail', '钩子尾'],
338
+ ['hook_strength', '钩子强度']
339
+ ];
340
+
341
+ let last = null;
342
+ for (const key of o.arrayKeys || []) {
343
+ const arr = shard[key];
344
+ if (Array.isArray(arr) && arr.length) {
345
+ last = arr[arr.length - 1];
346
+ break;
347
+ }
348
+ }
349
+ if (!last || typeof last !== 'object') return null;
350
+
351
+ const parts = [];
352
+ for (const [k, label] of FIELDS) {
353
+ if (last[k] === undefined || last[k] === null || last[k] === '') continue;
354
+ parts.push(label + '=' + String(last[k]));
355
+ }
356
+ if (!parts.length) return null;
357
+ return '上一批最后一条:' + parts.join(',');
358
+ }
359
+
360
+ /**
361
+ * 渲染"卷与幕 + 卷章数配额"。
362
+ *
363
+ * 为什么必须给:`章节规划.json` 的每一章都要填 `volume` 与 `act`,
364
+ * 而这两个值是**受约束的枚举**:
365
+ * - volume 必须是骨架 `层级.json` 里的 V001 / V002 …
366
+ * - act 必须是该卷 `层级.json` 的 acts 里出现过的幕序号
367
+ * (校验器 RHYTHM_ACT_INVALID 逐条查)
368
+ * - 每卷章数要接近 `框架.json` 的 volume_ratio(RHYTHM_VOLUME_DIST)
369
+ * 模型看不到这三张表就只能瞎编,编出来的卷号 100% 触发 RHYTHM_REF_MISSING。
370
+ *
371
+ * @param {Object} opts
372
+ * @param {Object} opts.hierarchy 骨架 层级.json
373
+ * @param {Object} opts.framework 骨架 框架.json
374
+ * @returns {Object} { lines: string[], count: number }
375
+ */
376
+ export function buildRhythmSkeletonOutline(opts) {
377
+ const o = opts || {};
378
+ const hierarchy = o.hierarchy && typeof o.hierarchy === 'object' ? o.hierarchy : {};
379
+ const framework = o.framework && typeof o.framework === 'object' ? o.framework : {};
380
+ const lines = [];
381
+
382
+ // 卷 → 该卷的幕序号集合
383
+ const actsByVolume = new Map();
384
+ for (const a of (hierarchy.acts || [])) {
385
+ if (!a || !a.volume || typeof a.act_index !== 'number') continue;
386
+ if (!actsByVolume.has(a.volume)) actsByVolume.set(a.volume, new Set());
387
+ actsByVolume.get(a.volume).add(a.act_index);
388
+ }
389
+
390
+ const volumes = Array.isArray(hierarchy.volumes) ? hierarchy.volumes : [];
391
+ if (volumes.length) {
392
+ lines.push('卷与幕(骨架 层级.json,volume 只能取这里的 id,act 只能取该卷的幕序号):');
393
+ for (const v of volumes) {
394
+ if (!v || !v.id) continue;
395
+ const acts = actsByVolume.get(v.id);
396
+ const actText = acts && acts.size ? [...acts].sort((a, b) => a - b).join('、') : '(骨架未定义)';
397
+ lines.push('- ' + v.id + (v.name ? '(' + v.name + ')' : '') + ':幕 ' + actText);
398
+ }
399
+ }
400
+
401
+ const ratios = Array.isArray(framework.volume_ratio) ? framework.volume_ratio : [];
402
+ if (ratios.length) {
403
+ const T = framework.capacity && typeof framework.capacity.target_chapters === 'number'
404
+ ? framework.capacity.target_chapters
405
+ : null;
406
+ lines.push('');
407
+ lines.push('卷章数配额(骨架 框架.json volume_ratio' + (T ? ',T=' + T : '') + ',实际章数与之相差超过 1 章会报 warning):');
408
+ for (const r of ratios) {
409
+ if (!r || !r.volume_id) continue;
410
+ const pct = typeof r.chapter_ratio === 'number' ? Math.round(r.chapter_ratio * 100) + '%' : String(r.chapter_ratio);
411
+ const approx = T ? '(约 ' + Math.round(T * r.chapter_ratio) + ' 章)' : '';
412
+ lines.push('- ' + r.volume_id + ':' + pct + approx);
413
+ }
414
+ }
415
+
416
+ return { lines: lines, count: lines.length };
417
+ }
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * upstream-query.js —— 上游数据按需查询(只读)
4
+ *
5
+ * 用途:摘要(summarizeStage)给出的是"全量 id + 名称"总览,
6
+ * 但逐章细纲、单个角色档案这类明细体量大,无法塞进 prompt。
7
+ * 本脚本让 Agent 按需精确取出单条/单章,避免"为了看一条而读整个文件"。
8
+ *
9
+ * 用法(在项目根目录下执行):
10
+ * node references/upstream-query.js --stage=节奏 --file=章节细纲 --ch=12
11
+ * node references/upstream-query.js --stage=节奏 --file=章节规划 --ch=12
12
+ * node references/upstream-query.js --stage=血肉 --file=角色 --id=C001
13
+ * node references/upstream-query.js --stage=骨架 --file=实体 --id=FS042
14
+ * node references/upstream-query.js --stage=骨架 --file=实体 --ids=FS042,FS043
15
+ * node references/upstream-query.js --stage=节奏 --file=章节细纲 --list
16
+ *
17
+ * 输出:stdout 为 JSON
18
+ * { "status": 0, "stage": "节奏", "file": "章节细纲", "matched": 1, "items": [ ... ] }
19
+ * status: 0=命中 1=未命中 2=参数/读取错误
20
+ */
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'url';
24
+
25
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+
27
+ // 各阶段的文件名白名单(防止路径穿越)
28
+ const STAGE_FILES = {
29
+ 骨架: ['框架', '实体', '关系', '层级'],
30
+ 血肉: ['角色', '冲突', '势力', '剧情', '地点', '结局', '世界观规则', '关键道具'],
31
+ 节奏: ['章节规划', '时序映射', '节奏参数', '章节细纲'],
32
+ };
33
+
34
+ function emit(status, payload) {
35
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
36
+ process.exitCode = status;
37
+ }
38
+
39
+ function parseArgs(argv) {
40
+ const args = {};
41
+ for (const a of argv.slice(2)) {
42
+ const m = a.match(/^--([\w-]+)(?:=(.*))?$/);
43
+ if (m) args[m[1]] = m[2] === undefined ? true : m[2];
44
+ }
45
+ return args;
46
+ }
47
+
48
+ /**
49
+ * 找项目根:向上找带 `.fancy-deployed` 标记的目录。
50
+ *
51
+ * 旧实现是 `path.join(__dirname, '..', ...)` —— 部署后 __dirname 是
52
+ * `<项目根>/references`,`..` 正好是项目根;但从插件目录直接跑
53
+ * (开发/测试/模型手动 node plugins/.../upstream-query.js)时,
54
+ * `..` 是**插件根**,会指向 plugins/fancy-story-builder/设定/...,
55
+ * 报"文件不存在"。
56
+ *
57
+ * @returns {string|null} 项目根;找不到返回 null
58
+ */
59
+ function findProjectRoot(startDir) {
60
+ let dir = startDir;
61
+ for (let i = 0; i < 8; i++) {
62
+ try {
63
+ if (fs.existsSync(path.join(dir, '.fancy-deployed'))) return dir;
64
+ } catch {
65
+ // 读不到就继续往上
66
+ }
67
+ const parent = path.dirname(dir);
68
+ if (parent === dir) break;
69
+ dir = parent;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /** 阶段目录:<项目根>/设定/raw/sources/<阶段> */
75
+ function stageDirOf(stage, projectRoot) {
76
+ // 显式指定的项目根优先;否则向上找 .fancy-deployed;都没有就退回旧行为
77
+ const root = projectRoot || findProjectRoot(__dirname) || path.join(__dirname, '..');
78
+ return path.join(root, '设定', 'raw', 'sources', stage);
79
+ }
80
+
81
+ function readJson(p) {
82
+ try {
83
+ return { data: JSON.parse(fs.readFileSync(p, 'utf-8')) };
84
+ } catch (e) {
85
+ return { error: e.message };
86
+ }
87
+ }
88
+
89
+ /** 在 JSON 里找出所有"数组型字段",并把每个元素当作一条记录 */
90
+ function collectRecords(data) {
91
+ const records = [];
92
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return records;
93
+ for (const [key, val] of Object.entries(data)) {
94
+ if (!Array.isArray(val)) continue;
95
+ for (const it of val) {
96
+ if (it && typeof it === 'object') records.push({ group: key, item: it });
97
+ }
98
+ }
99
+ return records;
100
+ }
101
+
102
+ /** 记录的身份:id 或 ch(与摘要规则一致) */
103
+ function recordId(item) {
104
+ if (item.id !== undefined && item.id !== null && item.id !== '') return String(item.id);
105
+ if (item.ch !== undefined && item.ch !== null) return String(item.ch);
106
+ return '';
107
+ }
108
+
109
+ function main() {
110
+ const args = parseArgs(process.argv);
111
+
112
+ const stage = args.stage;
113
+ const file = args.file;
114
+ // --project-root 显式指定项目根(没指定就向上找 .fancy-deployed)
115
+ const projectRoot = typeof args['project-root'] === 'string' ? args['project-root'] : null;
116
+
117
+ if (!stage || !STAGE_FILES[stage]) {
118
+ emit(2, {
119
+ status: 2,
120
+ error: `--stage 必须是 ${Object.keys(STAGE_FILES).join(' / ')} 之一`,
121
+ available_stages: Object.keys(STAGE_FILES),
122
+ });
123
+ return;
124
+ }
125
+ if (!file || !STAGE_FILES[stage].includes(file)) {
126
+ emit(2, {
127
+ status: 2,
128
+ error: `--file 必须是 ${stage} 阶段的 ${STAGE_FILES[stage].join(' / ')} 之一`,
129
+ available_files: STAGE_FILES[stage],
130
+ });
131
+ return;
132
+ }
133
+
134
+ const filePath = path.join(stageDirOf(stage, projectRoot), file + '.json');
135
+ if (!fs.existsSync(filePath)) {
136
+ emit(2, { status: 2, error: `文件不存在: ${filePath}`, hint: '请先完成该阶段' });
137
+ return;
138
+ }
139
+ const r = readJson(filePath);
140
+ if (r.error) {
141
+ emit(2, { status: 2, error: 'JSON 解析失败: ' + r.error, path: filePath });
142
+ return;
143
+ }
144
+
145
+ const records = collectRecords(r.data);
146
+
147
+ // --list:列出该文件里所有记录的分组与身份
148
+ if (args.list) {
149
+ const groups = {};
150
+ for (const rec of records) {
151
+ if (!groups[rec.group]) groups[rec.group] = [];
152
+ groups[rec.group].push(recordId(rec.item));
153
+ }
154
+ emit(0, { status: 0, stage, file, path: filePath, groups });
155
+ return;
156
+ }
157
+
158
+ const want = [];
159
+ if (args.ch !== undefined) want.push(String(args.ch));
160
+ if (args.id !== undefined) want.push(String(args.id));
161
+ if (args.ids !== undefined) {
162
+ for (const s of String(args.ids).split(',')) if (s.trim()) want.push(s.trim());
163
+ }
164
+
165
+ if (want.length === 0) {
166
+ emit(2, {
167
+ status: 2,
168
+ error: '需要 --ch / --id / --ids 之一,或用 --list 查看可用记录',
169
+ hint: '例如: --ch=12 或 --id=C001 或 --ids=FS042,FS043',
170
+ });
171
+ return;
172
+ }
173
+
174
+ const wantSet = new Set(want);
175
+ const matched = records.filter(rec => wantSet.has(recordId(rec.item)));
176
+
177
+ if (matched.length === 0) {
178
+ emit(1, {
179
+ status: 1,
180
+ stage, file, path: filePath,
181
+ matched: 0,
182
+ wanted: want,
183
+ items: [],
184
+ hint: '未找到匹配的记录。可用 --list 查看该文件的全部记录分组',
185
+ });
186
+ return;
187
+ }
188
+
189
+ emit(0, {
190
+ status: 0,
191
+ stage, file, path: filePath,
192
+ matched: matched.length,
193
+ items: matched.map(rec => rec.item),
194
+ groups: matched.map(rec => rec.group),
195
+ });
196
+ }
197
+
198
+ main();