@cloud411716/fancy-webnovel 2.2.0 → 3.0.3

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 (212) hide show
  1. package/README.md +65 -0
  2. package/client.js +201 -15
  3. package/cordis.patch.yml +5 -1
  4. package/docs/douyin-plugin.md +118 -0
  5. package/docs/material-library/README.md +404 -0
  6. package/docs/material-library/import.js +437 -0
  7. package/docs/material-library/prompt.md +370 -0
  8. package/docs/material-library/queries.surql +94 -0
  9. package/docs/material-library/sample.jsonl +14 -0
  10. package/docs/material-library/schema.surql +46 -0
  11. package/docs/mcp-profile.example.yml +24 -0
  12. package/docs/story-graph-design.md +602 -0
  13. package/docs/story-graph-validation.md +81 -0
  14. package/docs/workspace-mcp.md +284 -0
  15. package/index.js +12 -7
  16. package/lib/atomic-write.js +109 -0
  17. package/lib/chapter-order.js +100 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +11 -11
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +31 -31
  23. package/lib/infra.js +29 -4
  24. package/lib/material-db.js +149 -0
  25. package/lib/path-guard.js +150 -0
  26. package/lib/projects.js +400 -0
  27. package/lib/scraper-utils.js +140 -0
  28. package/lib/surreal-server.js +338 -0
  29. package/lib/topic-decision.js +860 -0
  30. package/lib/topic-fields.js +251 -0
  31. package/lib/topic-schema.js +225 -0
  32. package/lib/workspace.js +501 -0
  33. package/package.json +31 -7
  34. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  35. package/plugins/fancy-analyze/index.js +1128 -77
  36. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  37. package/plugins/fancy-analyze/schema-analyze.md +355 -107
  38. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  39. package/plugins/fancy-analyze/summary-progress.js +71 -7
  40. package/plugins/fancy-analyze/templates.js +437 -139
  41. package/plugins/fancy-bootstrap/index.js +276 -131
  42. package/plugins/fancy-bootstrap/scripts/init.js +59 -25
  43. package/plugins/fancy-bootstrap/templates.js +35 -18
  44. package/plugins/fancy-db/index.js +172 -0
  45. package/plugins/fancy-douyin/deploy.js +134 -0
  46. package/plugins/fancy-douyin/index.js +551 -0
  47. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  48. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  49. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  50. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  51. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  52. package/plugins/fancy-douyin/state.js +197 -0
  53. package/plugins/fancy-douyin/templates.js +127 -0
  54. package/plugins/fancy-export/exporter.js +805 -0
  55. package/plugins/fancy-export/index.js +63 -0
  56. package/plugins/fancy-import/import-driver.js +29 -6
  57. package/plugins/fancy-import/index.js +13 -5
  58. package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
  59. package/plugins/fancy-import/templates.js +17 -8
  60. package/plugins/fancy-material/exporter.js +153 -0
  61. package/plugins/fancy-material/genres.js +256 -0
  62. package/plugins/fancy-material/index.js +835 -0
  63. package/plugins/fancy-material/inventory.js +145 -0
  64. package/plugins/fancy-material/kinds.js +313 -0
  65. package/plugins/fancy-material/queue.js +55 -0
  66. package/plugins/fancy-material/scripts/query.js +254 -0
  67. package/plugins/fancy-material/templates.js +258 -0
  68. package/plugins/fancy-material/tool-deploy.js +138 -0
  69. package/plugins/fancy-material/validate.js +149 -0
  70. package/plugins/fancy-project/index.js +133 -0
  71. package/plugins/fancy-scan/existing-block.js +100 -0
  72. package/plugins/fancy-scan/index.js +410 -21
  73. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  74. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  75. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  76. package/plugins/fancy-scan/scripts/run-scan.js +169 -22
  77. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
  78. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
  79. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
  80. package/plugins/fancy-scan/templates/blocks.js +155 -0
  81. package/plugins/fancy-scan/templates/platforms.js +372 -0
  82. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  83. package/plugins/fancy-scan/templates.js +545 -146
  84. package/plugins/fancy-scan/tool-deploy.js +335 -0
  85. package/plugins/fancy-scan/wait-turn.js +46 -0
  86. package/plugins/fancy-story-builder/db/README.md +106 -0
  87. package/plugins/fancy-story-builder/db/client.js +228 -0
  88. package/plugins/fancy-story-builder/db/commands.js +145 -0
  89. package/plugins/fancy-story-builder/db/ids.js +39 -0
  90. package/plugins/fancy-story-builder/db/import.js +798 -0
  91. package/plugins/fancy-story-builder/db/project.js +299 -0
  92. package/plugins/fancy-story-builder/db/query.js +361 -0
  93. package/plugins/fancy-story-builder/db/schema.js +417 -0
  94. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  95. package/plugins/fancy-story-builder/flesh-output.js +19 -6
  96. package/plugins/fancy-story-builder/index.js +4105 -396
  97. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  98. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
  99. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
  100. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
  101. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
  102. package/plugins/fancy-story-builder/purpose-template.md +42 -66
  103. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
  104. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  105. package/plugins/fancy-story-builder/references/glossary.md +15 -6
  106. package/plugins/fancy-story-builder/references/index.md +97 -6
  107. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
  108. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
  109. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
  110. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
  111. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
  112. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
  113. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
  114. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
  115. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
  116. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  117. package/plugins/fancy-story-builder/schema-setting.md +201 -293
  118. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  119. package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
  120. package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
  121. package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
  122. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  123. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
  124. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  125. package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
  126. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
  127. package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
  128. package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
  129. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  130. package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
  131. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  132. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  133. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  134. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  135. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  136. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  137. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  138. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  139. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  140. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  141. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  142. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  143. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  144. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  145. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  146. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  147. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  148. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  149. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  150. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  151. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  152. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  153. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  154. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  155. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  156. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  157. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  158. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  159. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  160. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  161. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  162. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  163. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  164. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  165. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  166. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  167. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  168. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  169. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  170. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  171. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  172. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  173. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  174. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  175. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  176. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  177. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  178. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  179. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  180. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  181. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  182. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  183. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  184. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  185. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  186. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  187. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  188. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  189. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  190. 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 +267 -44
  191. 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 +121 -19
  192. 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 +129 -21
  193. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
  194. 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 +76 -17
  195. 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 +56 -8
  196. 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 +63 -13
  197. 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 +34 -6
  198. 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 +81 -18
  199. 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 +202 -42
  200. 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 +160 -27
  201. 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 +217 -41
  202. 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 +215 -41
  203. package/plugins/fancy-story-builder/templates.js +272 -74
  204. package/plugins/fancy-story-builder/wiki-references.js +429 -93
  205. package/plugins/fancy-story-builder/writing-driver.js +336 -9
  206. package/plugins/fancy-topic/index.js +414 -61
  207. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  208. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  209. package/plugins/fancy-topic/scripts/select-topic.js +106 -63
  210. package/plugins/fancy-topic/templates.js +225 -122
  211. package/plugins/fancy-topic/tool-deploy.js +200 -0
  212. package/lib/safe-rm.js +0 -94
@@ -0,0 +1,299 @@
1
+ /**
2
+ * SurrealDB -> stage JSON projection.
3
+ *
4
+ * The generation prompts still speak in stage JSON. This module writes a
5
+ * read-only projection of the database back into `设定/...` before a stage
6
+ * runs, so the prompt builders and validators keep working unchanged.
7
+ *
8
+ * Nothing here is a source of truth: the projection is overwritten on the
9
+ * next command and can be deleted without losing story data.
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { dbErrorMessage, StoryDbError } from './client.js';
15
+
16
+ const STORY = 'story:main';
17
+ const STAGE_DIR = {
18
+ '10-scale': '10-scale',
19
+ '20-macro': '20-macro',
20
+ '30-cast': '30-cast',
21
+ '40-rhythm': '40-rhythm',
22
+ '50-writing': '50-writing',
23
+ };
24
+
25
+ const ORDER = ['10-scale', '20-macro', '30-cast', '40-rhythm', 'outline', '50-writing'];
26
+
27
+ function dirOf(projectRoot, stageId, sub = '') {
28
+ return path.join(projectRoot, '设定', STAGE_DIR[stageId] || stageId, sub);
29
+ }
30
+
31
+ function ensureDir(dir) {
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ }
34
+
35
+ function writeJson(file, value) {
36
+ ensureDir(path.dirname(file));
37
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', 'utf8');
38
+ }
39
+
40
+ function writeText(file, value) {
41
+ ensureDir(path.dirname(file));
42
+ fs.writeFileSync(file, value, 'utf8');
43
+ }
44
+
45
+ function meta(stage, nodeId) {
46
+ return {
47
+ schema_version: '2.1',
48
+ node_id: nodeId,
49
+ stage,
50
+ generated_at: new Date().toISOString(),
51
+ };
52
+ }
53
+
54
+ function payloadOf(row, fallback = {}) {
55
+ return row && row.payload && typeof row.payload === 'object' ? row.payload : fallback;
56
+ }
57
+
58
+ function mapPayload(rows) {
59
+ return rows.map((row) => payloadOf(row, { id: row.entity_id, name: row.name }));
60
+ }
61
+
62
+ async function count(sdb, sql, vars) {
63
+ const rows = await sdb.rows(sql, vars);
64
+ return Number(rows[0] && rows[0].count) || 0;
65
+ }
66
+
67
+ export async function stageHasData(sdb, stageId) {
68
+ switch (stageId) {
69
+ case '10-scale': return (await count(sdb, `SELECT count() FROM scale WHERE story = ${STORY} GROUP ALL;`)) > 0;
70
+ case '20-macro': return (await count(sdb, `SELECT count() FROM stage_doc WHERE story = ${STORY} AND stage = '20-macro' GROUP ALL;`)) > 0;
71
+ case '30-cast': return (await count(sdb, `SELECT count() FROM character WHERE story = ${STORY} GROUP ALL;`)) > 0
72
+ || (await count(sdb, `SELECT count() FROM faction WHERE story = ${STORY} GROUP ALL;`)) > 0;
73
+ case '40-rhythm': return (await count(sdb, `SELECT count() FROM chapter WHERE story = ${STORY} GROUP ALL;`)) > 0;
74
+ case 'outline': return (await count(sdb, `SELECT count() FROM outline WHERE story = ${STORY} GROUP ALL;`)) > 0;
75
+ case '50-writing': return (await count(sdb, `SELECT count() FROM chapter_text WHERE story = ${STORY} GROUP ALL;`)) > 0;
76
+ default: return false;
77
+ }
78
+ }
79
+
80
+ async function projectScale(sdb, projectRoot) {
81
+ const scale = await sdb.one(`SELECT * FROM scale:main;`);
82
+ if (!scale || !scale.payload) return false;
83
+ writeJson(dirOf(projectRoot, '10-scale', 'scale.json'), scale.payload);
84
+ return true;
85
+ }
86
+
87
+ const MACRO_FILES = ['world', 'story', 'volumes', 'lines', 'endings', 'emotion', 'roles'];
88
+
89
+ async function projectMacro(sdb, projectRoot) {
90
+ const rows = await sdb.rows(
91
+ `SELECT * FROM stage_doc WHERE story = ${STORY} AND stage = '20-macro';`,
92
+ );
93
+ if (!rows.length) return false;
94
+ const byName = new Map(rows.map((row) => [row.name, row.payload]));
95
+ let wrote = false;
96
+ for (const name of MACRO_FILES) {
97
+ if (byName.has(name)) {
98
+ writeJson(dirOf(projectRoot, '20-macro', name + '.json'), byName.get(name));
99
+ wrote = true;
100
+ }
101
+ }
102
+ return wrote;
103
+ }
104
+
105
+ async function projectCast(sdb, projectRoot) {
106
+ const characters = await sdb.rows(`SELECT * FROM character WHERE story = ${STORY} ORDER BY entity_id;`);
107
+ if (!characters.length) return false;
108
+
109
+ const files = [
110
+ ['characters', 'character'],
111
+ ['factions', 'faction'],
112
+ ['locations', 'location'],
113
+ ['items', 'item'],
114
+ ['conflicts', 'conflict'],
115
+ ['foreshadows', 'foreshadow'],
116
+ ['climaxes', 'climax'],
117
+ ['payoffs', 'payoff'],
118
+ ];
119
+ for (const [fileKey, table] of files) {
120
+ const rows = await sdb.rows(`SELECT * FROM ${table} WHERE story = ${STORY} ORDER BY entity_id;`);
121
+ writeJson(dirOf(projectRoot, '30-cast', fileKey + '.json'), {
122
+ [fileKey]: mapPayload(rows),
123
+ _meta: meta('30-cast', '30-cast.' + fileKey),
124
+ });
125
+ }
126
+
127
+ const gaps = await sdb.rows(`SELECT * FROM information WHERE story = ${STORY} ORDER BY entity_id;`);
128
+ writeJson(dirOf(projectRoot, '30-cast', 'information.json'), {
129
+ information_gaps: mapPayload(gaps),
130
+ _meta: meta('30-cast', '30-cast.information'),
131
+ });
132
+
133
+ const rels = await sdb.rows(`SELECT * FROM relation WHERE story = ${STORY};`);
134
+ const factionRels = await sdb.rows(`SELECT * FROM faction_relation WHERE story = ${STORY};`);
135
+ const memberships = await sdb.rows(`SELECT * FROM membership WHERE story = ${STORY};`);
136
+ writeJson(dirOf(projectRoot, '30-cast', 'relations.json'), {
137
+ character_relations: rels.map((row) => {
138
+ const p = payloadOf(row);
139
+ return {
140
+ ...p,
141
+ id: p.id || row.id,
142
+ from: p.from || row.from_id,
143
+ to: p.to || row.to_id,
144
+ kind: p.kind || row.kind,
145
+ initial_state: p.initial_state !== undefined ? p.initial_state : row.initial_state,
146
+ changes: p.changes !== undefined ? p.changes : (row.changes || []),
147
+ };
148
+ }),
149
+ faction_relations: factionRels.map((row) => {
150
+ const p = payloadOf(row);
151
+ return {
152
+ ...p,
153
+ id: p.id || row.id,
154
+ from: p.from || row.from_id,
155
+ to: p.to || row.to_id,
156
+ kind: p.kind || row.kind,
157
+ initial_state: p.initial_state !== undefined ? p.initial_state : row.initial_state,
158
+ changes: p.changes !== undefined ? p.changes : (row.changes || []),
159
+ };
160
+ }),
161
+ memberships: memberships.map((row) => {
162
+ const p = payloadOf(row);
163
+ const fromRecord = p.character || String(row.in || '').split(':').pop().replace(/[⟨⟩]/g, '');
164
+ const toRecord = p.faction || String(row.out || '').split(':').pop().replace(/[⟨⟩]/g, '');
165
+ return {
166
+ ...p,
167
+ character: fromRecord,
168
+ faction: toRecord,
169
+ role: p.role || row.role,
170
+ };
171
+ }),
172
+ _meta: meta('30-cast', '30-cast.relations'),
173
+ });
174
+
175
+ return true;
176
+ }
177
+
178
+ async function projectRhythm(sdb, projectRoot, opts = {}) {
179
+ const chapters = await sdb.rows(
180
+ `SELECT * FROM chapter WHERE story = ${STORY} ORDER BY display_no;`,
181
+ );
182
+ if (!chapters.length) return false;
183
+
184
+ if (!opts.outlinesOnly) {
185
+ // Remove stale chunk shards; seedChunksFromChapterMap will split the
186
+ // merged map again for the generation loop.
187
+ const shardDir = dirOf(projectRoot, '40-rhythm', '.chapter-map');
188
+ try { fs.rmSync(shardDir, { recursive: true, force: true }); } catch { /* ignore */ }
189
+ writeJson(dirOf(projectRoot, '40-rhythm', 'chapter_map.json'), {
190
+ _meta: meta('40-rhythm', '40-rhythm.chapter_map'),
191
+ chapters: chapters.map((row) => payloadOf(row)),
192
+ });
193
+ }
194
+
195
+ const outlines = await sdb.rows(
196
+ `SELECT * FROM outline WHERE story = ${STORY} ORDER BY chapter_id;`,
197
+ );
198
+ if (outlines.length) {
199
+ const outDir = dirOf(projectRoot, '40-rhythm', 'outlines');
200
+ ensureDir(outDir);
201
+ const existing = new Set(fs.readdirSync(outDir).filter((name) => name.endsWith('.json')));
202
+ for (const row of outlines) {
203
+ const chapterId = row.chapter_id;
204
+ writeJson(path.join(outDir, chapterId + '.json'), payloadOf(row));
205
+ existing.delete(chapterId + '.json');
206
+ }
207
+ for (const stale of existing) {
208
+ try { fs.unlinkSync(path.join(outDir, stale)); } catch { /* ignore */ }
209
+ }
210
+ }
211
+ return true;
212
+ }
213
+
214
+ async function projectWriting(sdb, projectRoot) {
215
+ const texts = await sdb.rows(
216
+ `SELECT * FROM chapter_text WHERE story = ${STORY} ORDER BY display_no;`,
217
+ );
218
+ if (!texts.length) return false;
219
+ const chapDir = dirOf(projectRoot, '50-writing', 'chapters');
220
+ ensureDir(chapDir);
221
+ const keep = new Set();
222
+ for (const row of texts) {
223
+ const name = '第' + String(row.display_no).padStart(3, '0') + '章.md';
224
+ keep.add(name);
225
+ writeText(path.join(chapDir, name), row.content);
226
+ }
227
+ for (const name of fs.readdirSync(chapDir)) {
228
+ if (name.endsWith('.md') && !keep.has(name)) {
229
+ try { fs.unlinkSync(path.join(chapDir, name)); } catch { /* ignore */ }
230
+ }
231
+ }
232
+
233
+ const ledger = await sdb.rows(`SELECT * FROM ledger_entry WHERE story = ${STORY};`);
234
+ if (ledger.length) {
235
+ writeJson(dirOf(projectRoot, '50-writing', 'ledger.json'), {
236
+ _meta: meta('50-writing', '50-writing.ledger'),
237
+ chapters: ledger.map((row) => payloadOf(row)),
238
+ });
239
+ }
240
+ return true;
241
+ }
242
+
243
+ async function writeStageStatus(sdb, projectRoot, stageId) {
244
+ const state = await sdb.one(
245
+ `SELECT * FROM stage_state WHERE story = ${STORY} AND stage = $stage LIMIT 1;`,
246
+ { stage: stageId },
247
+ );
248
+ if (!state) return;
249
+ const dir = dirOf(projectRoot, stageId);
250
+ if (!fs.existsSync(dir)) return;
251
+ writeJson(path.join(dir, '.stage-status.json'), {
252
+ schema_version: '2.1',
253
+ stage: stageId,
254
+ status: state.status,
255
+ checked_at: state.checked_at,
256
+ files: {},
257
+ errors: state.errors || [],
258
+ warnings: state.warnings || [],
259
+ });
260
+ }
261
+
262
+ /** Project every stage that exists in the DB. Used by `tools export`. */
263
+ export async function projectAll(sdb, projectRoot) {
264
+ const written = {};
265
+ for (const stageId of ORDER) {
266
+ written[stageId] = await projectOne(sdb, stageId, projectRoot);
267
+ }
268
+ return written;
269
+ }
270
+
271
+ /** Project the stages needed by `targetStage`, in dependency order. */
272
+ export async function projectForStage(sdb, targetStage, projectRoot) {
273
+ const idx = ORDER.indexOf(targetStage);
274
+ const stages = idx >= 0 ? ORDER.slice(0, idx + 1) : ORDER;
275
+ const written = {};
276
+ for (const stageId of stages) {
277
+ written[stageId] = await projectOne(sdb, stageId, projectRoot);
278
+ }
279
+ return written;
280
+ }
281
+
282
+ async function projectOne(sdb, stageId, projectRoot) {
283
+ let written = false;
284
+ switch (stageId) {
285
+ case '10-scale': written = await projectScale(sdb, projectRoot); break;
286
+ case '20-macro': written = await projectMacro(sdb, projectRoot); break;
287
+ case '30-cast': written = await projectCast(sdb, projectRoot); break;
288
+ case '40-rhythm': written = await projectRhythm(sdb, projectRoot); break;
289
+ case 'outline': written = await projectRhythm(sdb, projectRoot, { outlinesOnly: true }); break;
290
+ case '50-writing': written = await projectWriting(sdb, projectRoot); break;
291
+ default: written = false;
292
+ }
293
+ if (written && stageId !== 'outline') {
294
+ await writeStageStatus(sdb, projectRoot, stageId);
295
+ }
296
+ return written;
297
+ }
298
+
299
+ export { writeStageStatus };
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Read-only queries used by the `query` and `analyze` commands.
3
+ *
4
+ * Everything here reads only SurrealDB. No stage JSON is consulted, so an
5
+ * agent asking "what does changing this relationship affect?" gets one
6
+ * consistent answer from one store.
7
+ */
8
+
9
+ import { dbErrorMessage, StoryDbError } from './client.js';
10
+
11
+ const STORY = 'story:main';
12
+
13
+ function recordOf(table, id) {
14
+ return `type::record(${JSON.stringify(table)}, ${JSON.stringify(String(id))})`;
15
+ }
16
+
17
+ async function resolveCharacter(sdb, value) {
18
+ const q = String(value || '').trim();
19
+ const rows = await sdb.rows(
20
+ `SELECT * FROM character WHERE story = ${STORY} AND (entity_id = $q OR name = $q) LIMIT 2;`,
21
+ { q },
22
+ );
23
+ if (!rows.length) throw new StoryDbError('QUERY_NOT_FOUND', '未找到人物:' + q);
24
+ return rows[0];
25
+ }
26
+
27
+ async function resolveForeshadow(sdb, value) {
28
+ const q = String(value || '').trim();
29
+ const rows = await sdb.rows(
30
+ `SELECT * FROM foreshadow WHERE story = ${STORY} AND (entity_id = $q OR name = $q) LIMIT 2;`,
31
+ { q },
32
+ );
33
+ if (!rows.length) throw new StoryDbError('QUERY_NOT_FOUND', '未找到伏笔:' + q);
34
+ return rows[0];
35
+ }
36
+
37
+ export async function getStatus(sdb) {
38
+ const tables = [
39
+ 'scale', 'volume', 'act', 'character', 'faction', 'location', 'item',
40
+ 'conflict', 'foreshadow', 'payoff', 'climax', 'information',
41
+ 'relation', 'chapter', 'knowledge_event', 'knowledge_state',
42
+ 'chapter_metric', 'outline', 'chapter_text', 'ledger_entry',
43
+ ];
44
+ const counts = {};
45
+ for (const table of tables) {
46
+ const rows = await sdb.rows(`SELECT count() FROM ${table} WHERE story = ${STORY} GROUP ALL;`);
47
+ counts[table] = Number(rows[0] && rows[0].count) || 0;
48
+ }
49
+ const stages = await sdb.rows(
50
+ `SELECT stage, status, checked_at, array::len(errors) AS error_count, array::len(warnings) AS warning_count
51
+ FROM stage_state WHERE story = ${STORY} ORDER BY stage;`,
52
+ );
53
+ const story = await sdb.one('SELECT * FROM story:main;');
54
+ return {
55
+ story: story || null,
56
+ stages,
57
+ counts,
58
+ };
59
+ }
60
+
61
+ export async function getCharacter(sdb, idOrName) {
62
+ const character = await resolveCharacter(sdb, idOrName);
63
+ const id = character.entity_id;
64
+ const record = recordOf('character', id);
65
+ const [relations, appearances, knowledge, events] = await Promise.all([
66
+ sdb.rows(
67
+ `SELECT from_id, to_id, kind, initial_state, changes
68
+ FROM relation WHERE story = ${STORY} AND (from_id = $id OR to_id = $id);`,
69
+ { id },
70
+ ),
71
+ sdb.rows(
72
+ `SELECT in.display_no AS chapter, in.title AS title, role
73
+ FROM chapter_cast WHERE story = ${STORY} AND out = ${record}
74
+ ORDER BY chapter;`,
75
+ ),
76
+ sdb.rows(
77
+ `SELECT subject_type, subject_id, state
78
+ FROM knowledge_state WHERE story = ${STORY} AND character = ${record}
79
+ ORDER BY subject_type, subject_id;`,
80
+ ),
81
+ sdb.rows(
82
+ `SELECT chapter_no, subject_type, subject_id, change, coverage, knowledge, how
83
+ FROM knowledge_event WHERE story = ${STORY} AND character_id = $id
84
+ ORDER BY chapter_no;`,
85
+ { id },
86
+ ),
87
+ ]);
88
+ return {
89
+ character,
90
+ relations,
91
+ appearances,
92
+ knowledge,
93
+ knowledge_events: events,
94
+ };
95
+ }
96
+
97
+ export async function getForeshadow(sdb, idOrName) {
98
+ const foreshadow = await resolveForeshadow(sdb, idOrName);
99
+ const id = foreshadow.entity_id;
100
+ const record = recordOf('foreshadow', id);
101
+ const [actions, payoffs, knowledge, states] = await Promise.all([
102
+ sdb.rows(
103
+ `SELECT in.display_no AS chapter, in.title AS title, action, detail
104
+ FROM foreshadow_action WHERE story = ${STORY} AND out = ${record}
105
+ ORDER BY chapter;`,
106
+ ),
107
+ sdb.rows(
108
+ `SELECT out.entity_id AS payoff, out.name AS name FROM foreshadow_payoff
109
+ WHERE story = ${STORY} AND in = ${record};`,
110
+ ),
111
+ sdb.rows(
112
+ `SELECT chapter_no, character_id, change, coverage, knowledge, how
113
+ FROM knowledge_event
114
+ WHERE story = ${STORY} AND subject_type = 'foreshadow' AND subject_id = $id
115
+ ORDER BY chapter_no;`,
116
+ { id },
117
+ ),
118
+ sdb.rows(
119
+ `SELECT character.entity_id AS character, state FROM knowledge_state
120
+ WHERE story = ${STORY} AND subject_type = 'foreshadow' AND subject_id = $id
121
+ ORDER BY character;`,
122
+ { id },
123
+ ),
124
+ ]);
125
+ const requiredBy = await sdb.rows(
126
+ `SELECT entity_id, name FROM payoff WHERE story = ${STORY} AND $id IN required_setup;`,
127
+ { id },
128
+ );
129
+ return {
130
+ foreshadow,
131
+ actions,
132
+ linked_payoffs: payoffs,
133
+ required_by_payoffs: requiredBy,
134
+ knowledge_events: knowledge,
135
+ knowledge_states: states,
136
+ };
137
+ }
138
+
139
+ export async function getChapters(sdb, from, to) {
140
+ const start = Number(from) || 1;
141
+ const end = Number(to) || start;
142
+ const chapters = await sdb.rows(
143
+ `SELECT chapter_id, display_no, title, volume, act, pov, word_target,
144
+ emotion_value, hook_type, hook_strength
145
+ FROM chapter WHERE story = ${STORY} AND display_no >= $start AND display_no <= $end
146
+ ORDER BY display_no;`,
147
+ { start, end },
148
+ );
149
+ const metrics = await sdb.rows(
150
+ `SELECT chapter_no, emotion_value, hook_strength, word_target, word_count
151
+ FROM chapter_metric WHERE story = ${STORY} AND chapter_no >= $start AND chapter_no <= $end
152
+ ORDER BY chapter_no;`,
153
+ { start, end },
154
+ );
155
+ const texts = await sdb.rows(
156
+ `SELECT display_no, word_count FROM chapter_text
157
+ WHERE story = ${STORY} AND display_no >= $start AND display_no <= $end
158
+ ORDER BY display_no;`,
159
+ { start, end },
160
+ );
161
+ return { range: { from: start, to: end }, chapters, metrics, texts };
162
+ }
163
+
164
+ export async function getRelations(sdb, idOrName) {
165
+ const character = await resolveCharacter(sdb, idOrName);
166
+ const id = character.entity_id;
167
+ const rows = await sdb.rows(
168
+ `SELECT from_id, to_id, kind, initial_state, changes
169
+ FROM relation WHERE story = ${STORY} AND (from_id = $id OR to_id = $id);`,
170
+ { id },
171
+ );
172
+ return { character: { id, name: character.name }, relations: rows };
173
+ }
174
+
175
+ export async function getKnowledge(sdb, idOrName, chapterNo) {
176
+ const character = await resolveCharacter(sdb, idOrName);
177
+ const id = character.entity_id;
178
+ if (chapterNo === undefined || chapterNo === null) {
179
+ const rows = await sdb.rows(
180
+ `SELECT subject_type, subject_id, state FROM knowledge_state
181
+ WHERE story = ${STORY} AND character = ${recordOf('character', id)}
182
+ ORDER BY subject_type, subject_id;`,
183
+ );
184
+ return { character: { id, name: character.name }, at: 'current', knowledge: rows };
185
+ }
186
+ const chapter = Number(chapterNo);
187
+ const events = await sdb.rows(
188
+ `SELECT chapter_no, subject_type, subject_id, change, coverage, knowledge, how
189
+ FROM knowledge_event
190
+ WHERE story = ${STORY} AND character_id = $id AND chapter_no <= $chapter
191
+ ORDER BY chapter_no, id;`,
192
+ { id, chapter },
193
+ );
194
+ const latest = new Map();
195
+ for (const event of events) {
196
+ latest.set(event.subject_type + ':' + event.subject_id, event);
197
+ }
198
+ return {
199
+ character: { id, name: character.name },
200
+ at: chapter,
201
+ knowledge: [...latest.values()].map((event) => ({
202
+ subject_type: event.subject_type,
203
+ subject_id: event.subject_id,
204
+ state: event.coverage,
205
+ since_chapter: event.chapter_no,
206
+ change: event.change,
207
+ })),
208
+ };
209
+ }
210
+
211
+ export async function analyzeRelationChange(sdb, aRaw, bRaw, opts = {}) {
212
+ const a = await resolveCharacter(sdb, aRaw);
213
+ const b = await resolveCharacter(sdb, bRaw);
214
+ const aId = a.entity_id;
215
+ const bId = b.entity_id;
216
+ const fromChapter = Number(opts.fromChapter) || 1;
217
+ const newKind = opts.newKind || null;
218
+
219
+ const current = await sdb.rows(
220
+ `SELECT from_id, to_id, kind, initial_state, changes FROM relation
221
+ WHERE story = ${STORY}
222
+ AND ((from_id = $a AND to_id = $b) OR (from_id = $b AND to_id = $a));`,
223
+ { a: aId, b: bId },
224
+ );
225
+
226
+ const chapters = await sdb.rows(
227
+ `SELECT display_no, chapter_id, title FROM chapter
228
+ WHERE story = ${STORY} AND display_no >= $from
229
+ AND $a IN ->chapter_cast->character.entity_id
230
+ AND $b IN ->chapter_cast->character.entity_id
231
+ ORDER BY display_no;`,
232
+ { from: fromChapter, a: aId, b: bId },
233
+ );
234
+ const chapterNos = chapters.map((row) => Number(row.display_no));
235
+
236
+ const conflicts = await sdb.rows(
237
+ `SELECT entity_id, name, type FROM conflict
238
+ WHERE story = ${STORY} AND $a IN participants AND $b IN participants;`,
239
+ { a: aId, b: bId },
240
+ );
241
+
242
+ const allActions = await sdb.rows(
243
+ `SELECT out.entity_id AS foreshadow, out.name AS name, in.display_no AS chapter, action, detail
244
+ FROM foreshadow_action WHERE story = ${STORY} AND in.display_no >= $from
245
+ ORDER BY chapter;`,
246
+ { from: fromChapter },
247
+ );
248
+ const foreshadows = allActions.filter((row) => chapterNos.includes(Number(row.chapter)));
249
+
250
+ const knowledge = await sdb.rows(
251
+ `SELECT chapter_no, character_id, subject_type, subject_id, change, coverage
252
+ FROM knowledge_event WHERE story = ${STORY} AND chapter_no >= $from
253
+ AND character_id IN [$a, $b]
254
+ ORDER BY chapter_no;`,
255
+ { from: fromChapter, a: aId, b: bId },
256
+ );
257
+
258
+ const warnings = [];
259
+ if (!current.length) warnings.push('当前没有找到 ' + aId + ' 与 ' + bId + ' 的关系边');
260
+ if (!chapters.length) warnings.push('第 ' + fromChapter + ' 章之后没有两人同时出场的章节');
261
+ if (newKind && current.length && current[0].kind === newKind) warnings.push('新关系与当前关系相同:' + newKind);
262
+ for (const fs of foreshadows) {
263
+ if (fs.action === '回收') warnings.push('伏笔 ' + fs.foreshadow + ' 在第 ' + fs.chapter + ' 章回收,依赖当前关系');
264
+ }
265
+
266
+ return {
267
+ query: 'relation_change',
268
+ characters: [{ id: aId, name: a.name }, { id: bId, name: b.name }],
269
+ current_relation: current[0] || null,
270
+ change: { from_chapter: fromChapter, to_kind: newKind },
271
+ impact: {
272
+ chapters,
273
+ conflicts,
274
+ foreshadows,
275
+ knowledge_events: knowledge,
276
+ },
277
+ warnings,
278
+ };
279
+ }
280
+
281
+ export async function analyzeForeshadowChange(sdb, idOrName, opts = {}) {
282
+ const foreshadow = await resolveForeshadow(sdb, idOrName);
283
+ const id = foreshadow.entity_id;
284
+ const record = recordOf('foreshadow', id);
285
+ const action = opts.action || null;
286
+ const chapter = Number(opts.chapter) || null;
287
+
288
+ const actions = await sdb.rows(
289
+ `SELECT in.display_no AS chapter, in.chapter_id AS chapter_id, action, detail
290
+ FROM foreshadow_action WHERE story = ${STORY} AND out = ${record}
291
+ ORDER BY chapter;`,
292
+ );
293
+ const payoffs = await sdb.rows(
294
+ `SELECT out.entity_id AS payoff, out.name AS name FROM foreshadow_payoff
295
+ WHERE story = ${STORY} AND in = ${record};`,
296
+ );
297
+ const requiredBy = await sdb.rows(
298
+ `SELECT entity_id, name FROM payoff WHERE story = ${STORY} AND $id IN required_setup;`,
299
+ { id },
300
+ );
301
+ const knowledge = await sdb.rows(
302
+ `SELECT chapter_no, character_id, change, coverage
303
+ FROM knowledge_event
304
+ WHERE story = ${STORY} AND subject_type = 'foreshadow' AND subject_id = $id
305
+ ORDER BY chapter_no;`,
306
+ { id },
307
+ );
308
+ const chapters = await sdb.rows(
309
+ `SELECT display_no, chapter_id, title FROM chapter
310
+ WHERE story = ${STORY} AND $id IN ->foreshadow_action->foreshadow.entity_id
311
+ ORDER BY display_no;`,
312
+ { id },
313
+ );
314
+
315
+ const plant = actions.find((row) => row.action === '埋设');
316
+ const recover = actions.find((row) => row.action === '回收');
317
+ const warnings = [];
318
+ if (!plant) warnings.push('这条伏笔尚未埋设记录');
319
+ if (action === '回收') {
320
+ if (recover) warnings.push('这条伏笔已经在第 ' + recover.chapter + ' 章回收');
321
+ if (chapter && plant && Number(plant.chapter) >= chapter) {
322
+ warnings.push('回收章(' + chapter + ')必须晚于埋设章(' + plant.chapter + ')');
323
+ }
324
+ }
325
+ if (payoffs.length + requiredBy.length > 0 && !recover) {
326
+ warnings.push('有 ' + (payoffs.length + requiredBy.length) + ' 个爽点依赖这条伏笔,但尚未回收记录');
327
+ }
328
+
329
+ return {
330
+ query: 'foreshadow_change',
331
+ foreshadow,
332
+ change: { action, chapter },
333
+ timeline: actions,
334
+ linked_payoffs: payoffs,
335
+ required_by_payoffs: requiredBy,
336
+ knowledge_events: knowledge,
337
+ affected_chapters: chapters,
338
+ warnings,
339
+ };
340
+ }
341
+
342
+ const READ_ONLY = /^\s*(SELECT|INFO|RETURN|SHOW|DESCRIBE|EXPLAIN)\b/i;
343
+
344
+ export async function runReadOnlySql(sdb, sql) {
345
+ const text = String(sql || '').trim().replace(/;+\s*$/, '');
346
+ if (!text) throw new StoryDbError('QUERY_EMPTY', '查询语句为空');
347
+ if (!READ_ONLY.test(text)) {
348
+ throw new StoryDbError('QUERY_NOT_READ_ONLY', '只允许 SELECT / INFO / RETURN / SHOW / DESCRIBE / EXPLAIN 查询');
349
+ }
350
+ if (/;\s*(DELETE|CREATE|UPDATE|UPSERT|REMOVE|DEFINE|RELATE|INSERT|ALTER|KILL)\b/i.test(text)) {
351
+ throw new StoryDbError('QUERY_NOT_READ_ONLY', '查询中不能包含写操作');
352
+ }
353
+ try {
354
+ const out = await sdb.query(text + ';');
355
+ return Array.isArray(out) && out.length === 1 ? out[0] : out;
356
+ } catch (err) {
357
+ throw new StoryDbError('QUERY_FAILED', '查询失败:' + dbErrorMessage(err));
358
+ }
359
+ }
360
+
361
+ export { resolveCharacter, resolveForeshadow };