rebuild-novel-memory.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724
  1. import fs from "node:fs/promises";
  2. import path from "node:path";
  3. const projectPath = process.argv[2];
  4. const shouldDeleteFragments = process.argv.includes("--delete-fragments");
  5. if (!projectPath) {
  6. console.error("用法:node scripts/rebuild-novel-memory.mjs <小说目录> [--delete-fragments]");
  7. process.exit(1);
  8. }
  9. const UNSTABLE_TAGS = new Set([
  10. "chapter",
  11. "event",
  12. "secret",
  13. "foreshadowing",
  14. "conflict",
  15. "timeline-point",
  16. "canon-rule",
  17. ]);
  18. const UNCERTAIN_RE = /(可能|也许|似乎|疑似|或许|大概|推测|猜测|尚不确定|未证实)/u;
  19. const SENTENCE_PUNCTUATION_RE = /[,。;:?!“”‘’()《》【】<>]/;
  20. const GENERIC_SUBJECT_RE = /^(?:\d+号.+|短发女人|长发女人|老太太|老头|老人|守卫|村民|灰白制服(?:人员|男人|女人)|两名灰白制服人员)$/u;
  21. const SNAPSHOT_FILE_RE = /^(\d+)\.snapshot\.json$/i;
  22. const OUTPUT_PREFIX_RE = /^(\d+)\./i;
  23. function pageHeader(memoryType, title) {
  24. return [
  25. "---",
  26. "type: structured-memory",
  27. `memory_type: ${memoryType}`,
  28. `title: "${title}"`,
  29. "---",
  30. "",
  31. `# ${title}`,
  32. "",
  33. ].join("\n");
  34. }
  35. function unique(items) {
  36. return Array.from(new Set(items.filter(Boolean)));
  37. }
  38. function chapterLabel(chapterNumber) {
  39. return `第${chapterNumber}章`;
  40. }
  41. function joinChapterList(chapters) {
  42. const ordered = [...new Set(chapters)].sort((a, b) => a - b);
  43. return ordered.length > 0 ? ordered.map(chapterLabel).join("、") : "无";
  44. }
  45. function parseInlineTags(content) {
  46. const match = content.match(/^---\n[\s\S]*?^tags:\s*\[([^\]]*)\]/m);
  47. if (!match) return [];
  48. return match[1]
  49. .split(",")
  50. .map((item) => item.trim().replace(/^["']|["']$/g, ""))
  51. .filter(Boolean);
  52. }
  53. function parseInlineSources(content) {
  54. const match = content.match(/^---\n[\s\S]*?^sources:\s*\[([^\]]*)\]/m);
  55. if (!match) return [];
  56. return match[1]
  57. .split(",")
  58. .map((item) => item.trim().replace(/^["']|["']$/g, ""))
  59. .filter(Boolean);
  60. }
  61. function hasOnlyValidSnapshotSources(content, validSnapshotNumbers) {
  62. const sources = parseInlineSources(content);
  63. if (sources.length === 0) return true;
  64. for (const source of sources) {
  65. const match = source.match(SNAPSHOT_FILE_RE);
  66. if (match?.[1] && !validSnapshotNumbers.has(Number(match[1]))) {
  67. return false;
  68. }
  69. }
  70. return true;
  71. }
  72. function shouldDeleteEntityFile(fileName, content, validSnapshotNumbers) {
  73. const baseName = fileName.replace(/\.md$/i, "");
  74. const tags = parseInlineTags(content);
  75. if (tags.some((tag) => UNSTABLE_TAGS.has(tag))) {
  76. return true;
  77. }
  78. if (!hasOnlyValidSnapshotSources(content, validSnapshotNumbers)) {
  79. return true;
  80. }
  81. if (/[“”"']/u.test(baseName) || /(.+)/u.test(baseName)) {
  82. return true;
  83. }
  84. return baseName.length > 12 && SENTENCE_PUNCTUATION_RE.test(baseName);
  85. }
  86. function parseChangeParts(text) {
  87. const normalized = text.replace(/[::]/, ":");
  88. const index = normalized.indexOf(":");
  89. if (index <= 0) return null;
  90. return {
  91. subject: normalized.slice(0, index).trim(),
  92. detail: normalized.slice(index + 1).trim(),
  93. };
  94. }
  95. function ensureMapEntry(map, key, createValue) {
  96. if (!map.has(key)) {
  97. map.set(key, createValue());
  98. }
  99. return map.get(key);
  100. }
  101. function addAll(targetSet, items) {
  102. for (const item of items) {
  103. if (item) targetSet.add(item);
  104. }
  105. }
  106. function appendCandidateSection(lines, candidates) {
  107. lines.push("## 候选区", "");
  108. if (candidates.length === 0) {
  109. lines.push("暂无候选内容。", "");
  110. return;
  111. }
  112. for (const item of candidates) {
  113. lines.push(`- ${item}`);
  114. }
  115. lines.push("");
  116. }
  117. function isImportantSubject(subject) {
  118. const trimmed = subject.trim();
  119. if (!trimmed) return false;
  120. if (SENTENCE_PUNCTUATION_RE.test(trimmed)) return false;
  121. if (GENERIC_SUBJECT_RE.test(trimmed)) return false;
  122. if (/^(读者|角色|旁白)$/u.test(trimmed)) return false;
  123. if (/(通过|补充|借助|利用|经由)/u.test(trimmed)) return false;
  124. if (/^\d+号/u.test(trimmed)) return false;
  125. return trimmed.length <= 12;
  126. }
  127. function pickStableSubject(text, snapshot) {
  128. const parsed = parseChangeParts(text);
  129. if (parsed?.subject) {
  130. return isImportantSubject(parsed.subject) ? parsed.subject : null;
  131. }
  132. return snapshot.characters?.find((name) => text.includes(name) && isImportantSubject(name)) ?? null;
  133. }
  134. function normalizeForeshadowing(rawText) {
  135. const text = rawText
  136. .trim()
  137. .replace(/^(新增伏笔|推进伏笔|回收伏笔|新增|推进|回收)[::\s-]*/u, "")
  138. .trim();
  139. function compactName(name) {
  140. let next = name.trim();
  141. if (next.length > 18 && next.includes("与")) {
  142. next = next.split("与")[0].trim();
  143. }
  144. if (next.length > 18 && next.includes("、")) {
  145. next = next.split("、")[0].trim();
  146. }
  147. return next.slice(0, 18).trim();
  148. }
  149. const quoted = text.match(/[“"']([^“”"']{1,24})[”"']/u);
  150. if (quoted?.[1]) {
  151. const name = compactName(quoted[1]);
  152. const description = text.replace(quoted[0], "").replace(/^[,。;::、\-\s]+/u, "").trim() || rawText.trim();
  153. return { name, description };
  154. }
  155. const splitByDash = text.split(/\s*[-—]\s*/u).map((item) => item.trim()).filter(Boolean);
  156. if (splitByDash.length >= 2) {
  157. return {
  158. name: compactName(splitByDash[0]),
  159. description: splitByDash.slice(1).join(" - ").trim(),
  160. };
  161. }
  162. const keywordSplit = text.split(/为何|并非|不仅是|存在|成为|将成|将|会|正在|开始|继续|揭示|预示|说明|意味着|指向|却能|不承认/u)
  163. .map((item) => item.trim())
  164. .filter(Boolean);
  165. if (keywordSplit.length >= 2) {
  166. return {
  167. name: compactName(keywordSplit[0]),
  168. description: text.trim(),
  169. };
  170. }
  171. const splitByPunctuation = text.split(/[,。;::?!]/u).map((item) => item.trim()).filter(Boolean);
  172. if (splitByPunctuation.length >= 2) {
  173. return {
  174. name: compactName(splitByPunctuation[0]),
  175. description: text.trim(),
  176. };
  177. }
  178. return {
  179. name: compactName(text),
  180. description: text.trim(),
  181. };
  182. }
  183. function buildChapterSnapshotsPage(snapshots) {
  184. const sections = snapshots.map((snapshot) => [
  185. `## ${chapterLabel(snapshot.chapterNumber)}`,
  186. "",
  187. "### 摘要",
  188. snapshot.summary || "无",
  189. "",
  190. "### 人物状态变化",
  191. ...(snapshot.characterStateChanges.length > 0 ? snapshot.characterStateChanges.map((item) => `- ${item}`) : ["- 无"]),
  192. "",
  193. "### 角色认知变化",
  194. ...(snapshot.knowledgeChanges.length > 0 ? snapshot.knowledgeChanges.map((item) => `- ${item}`) : ["- 无"]),
  195. "",
  196. "### 伏笔变化",
  197. ...(snapshot.foreshadowingChanges.length > 0 ? snapshot.foreshadowingChanges.map((item) => `- ${item}`) : ["- 无"]),
  198. "",
  199. "### 时间线事件",
  200. ...(snapshot.timelineEvents.length > 0 ? snapshot.timelineEvents.map((item) => `- ${item}`) : ["- 无"]),
  201. "",
  202. "### 正式设定",
  203. ...(snapshot.newCanonFacts.length > 0 ? snapshot.newCanonFacts.map((item) => `- ${item}`) : ["- 无"]),
  204. "",
  205. "### 当前冲突",
  206. ...(snapshot.conflicts.length > 0 ? snapshot.conflicts.map((item) => `- ${item}`) : ["- 无"]),
  207. "",
  208. "### 结尾钩子",
  209. snapshot.endingHook || "无",
  210. "",
  211. ].join("\n"));
  212. return `${pageHeader("chapter-snapshots", "章节快照记忆")}${sections.join("\n")}\n`;
  213. }
  214. function buildCharacterCognitionPage(snapshots) {
  215. const characters = new Map();
  216. const readerKnown = new Map();
  217. const candidates = [];
  218. for (const snapshot of snapshots) {
  219. for (const rawChange of snapshot.knowledgeChanges ?? []) {
  220. const change = rawChange.trim();
  221. if (!change) continue;
  222. if (UNCERTAIN_RE.test(change)) {
  223. candidates.push(`${chapterLabel(snapshot.chapterNumber)}:${change}`);
  224. continue;
  225. }
  226. const readerMatch = change.match(/^读者知道[了]?(.+)$/u);
  227. if (readerMatch?.[1]) {
  228. const detail = readerMatch[1].trim();
  229. const entry = ensureMapEntry(readerKnown, detail, () => ({ detail, chapters: new Set() }));
  230. entry.chapters.add(snapshot.chapterNumber);
  231. continue;
  232. }
  233. const doesNotKnowMatch = change.match(/^(.+?)不知道(.+)$/u);
  234. if (doesNotKnowMatch?.[1] && doesNotKnowMatch?.[2]) {
  235. const characterName = doesNotKnowMatch[1].trim();
  236. if (!isImportantSubject(characterName)) continue;
  237. const detail = doesNotKnowMatch[2].trim();
  238. const entry = ensureMapEntry(characters, characterName, () => ({
  239. knows: new Map(),
  240. doesNotKnow: new Map(),
  241. lastUpdatedChapter: snapshot.chapterNumber,
  242. }));
  243. const info = ensureMapEntry(entry.doesNotKnow, detail, () => ({ detail, chapters: new Set() }));
  244. info.chapters.add(snapshot.chapterNumber);
  245. entry.lastUpdatedChapter = Math.max(entry.lastUpdatedChapter, snapshot.chapterNumber);
  246. continue;
  247. }
  248. const knowMatch = change.match(/^(.+?)(知道|得知|察觉到|意识到)(.+)$/u);
  249. if (knowMatch?.[1] && knowMatch?.[3]) {
  250. const characterName = knowMatch[1].trim();
  251. if (!isImportantSubject(characterName)) continue;
  252. const detail = knowMatch[3].trim();
  253. const entry = ensureMapEntry(characters, characterName, () => ({
  254. knows: new Map(),
  255. doesNotKnow: new Map(),
  256. lastUpdatedChapter: snapshot.chapterNumber,
  257. }));
  258. const info = ensureMapEntry(entry.knows, detail, () => ({ detail, chapters: new Set() }));
  259. info.chapters.add(snapshot.chapterNumber);
  260. entry.doesNotKnow.delete(detail);
  261. entry.lastUpdatedChapter = Math.max(entry.lastUpdatedChapter, snapshot.chapterNumber);
  262. }
  263. }
  264. }
  265. const lines = [pageHeader("character-cognition", "角色认知记忆"), "## 当前正式认知", ""];
  266. const sortedCharacters = [...characters.entries()].sort((a, b) => a[0].localeCompare(b[0], "zh-CN"));
  267. if (sortedCharacters.length === 0) {
  268. lines.push("暂无正式认知记录。", "");
  269. } else {
  270. for (const [characterName, entry] of sortedCharacters) {
  271. lines.push(`### ${characterName}`);
  272. const knows = [...entry.knows.values()].sort((a, b) => [...a.chapters][0] - [...b.chapters][0]);
  273. const doesNotKnow = [...entry.doesNotKnow.values()].sort((a, b) => [...a.chapters][0] - [...b.chapters][0]);
  274. if (knows.length > 0) {
  275. lines.push("- 已知:");
  276. for (const item of knows) {
  277. lines.push(` - ${item.detail}(来源:${joinChapterList(item.chapters)})`);
  278. }
  279. }
  280. if (doesNotKnow.length > 0) {
  281. lines.push("- 未知:");
  282. for (const item of doesNotKnow) {
  283. lines.push(` - ${item.detail}(来源:${joinChapterList(item.chapters)})`);
  284. }
  285. }
  286. lines.push(`- 最近更新:${chapterLabel(entry.lastUpdatedChapter)}`, "");
  287. }
  288. }
  289. lines.push("## 读者已知", "");
  290. if (readerKnown.size === 0) {
  291. lines.push("暂无单独记录。", "");
  292. } else {
  293. for (const item of [...readerKnown.values()].sort((a, b) => [...a.chapters][0] - [...b.chapters][0])) {
  294. lines.push(`- ${item.detail}(来源:${joinChapterList(item.chapters)})`);
  295. }
  296. lines.push("");
  297. }
  298. appendCandidateSection(lines, candidates);
  299. return `${lines.join("\n").trimEnd()}\n`;
  300. }
  301. function buildCharacterStatesPage(snapshots) {
  302. const states = new Map();
  303. const candidates = [];
  304. for (const snapshot of snapshots) {
  305. for (const change of snapshot.characterStateChanges ?? []) {
  306. const text = change.trim();
  307. if (!text) continue;
  308. if (UNCERTAIN_RE.test(text)) {
  309. candidates.push(`${chapterLabel(snapshot.chapterNumber)}:${text}`);
  310. continue;
  311. }
  312. const parsed = parseChangeParts(text);
  313. const characterName = pickStableSubject(text, snapshot);
  314. if (!characterName) continue;
  315. const detail = parsed?.detail || text;
  316. states.set(characterName, {
  317. detail,
  318. lastUpdatedChapter: snapshot.chapterNumber,
  319. });
  320. }
  321. }
  322. const lines = [pageHeader("character-states", "人物状态记忆"), "## 当前正式状态", ""];
  323. const sorted = [...states.entries()].sort((a, b) => a[0].localeCompare(b[0], "zh-CN"));
  324. if (sorted.length === 0) {
  325. lines.push("暂无正式状态记录。", "");
  326. } else {
  327. for (const [characterName, state] of sorted) {
  328. lines.push(`### ${characterName}`);
  329. lines.push(`- 当前状态:${state.detail}`);
  330. lines.push(`- 最近更新:${chapterLabel(state.lastUpdatedChapter)}`);
  331. lines.push("");
  332. }
  333. }
  334. appendCandidateSection(lines, candidates);
  335. return `${lines.join("\n").trimEnd()}\n`;
  336. }
  337. function buildForeshadowingPage(snapshots) {
  338. const tracker = new Map();
  339. const candidates = [];
  340. for (const snapshot of snapshots) {
  341. for (const rawChange of snapshot.foreshadowingChanges ?? []) {
  342. const change = rawChange.trim();
  343. if (!change) continue;
  344. if (UNCERTAIN_RE.test(change)) {
  345. candidates.push(`${chapterLabel(snapshot.chapterNumber)}:${change}`);
  346. continue;
  347. }
  348. const normalized = normalizeForeshadowing(change);
  349. if (!normalized.name) continue;
  350. const entry = ensureMapEntry(tracker, normalized.name, () => ({
  351. name: normalized.name,
  352. description: normalized.description,
  353. status: "planted",
  354. plantedChapter: snapshot.chapterNumber,
  355. advancedChapters: new Set(),
  356. resolvedChapter: null,
  357. sources: new Set(),
  358. }));
  359. if (normalized.description && entry.description.length < normalized.description.length) {
  360. entry.description = normalized.description;
  361. }
  362. entry.sources.add(snapshot.chapterNumber);
  363. if (/^(回收伏笔|回收)/u.test(change)) {
  364. entry.status = "resolved";
  365. entry.resolvedChapter = snapshot.chapterNumber;
  366. } else if (/^(推进伏笔|推进)/u.test(change)) {
  367. if (entry.status !== "resolved") {
  368. entry.status = "advanced";
  369. }
  370. entry.advancedChapters.add(snapshot.chapterNumber);
  371. } else {
  372. entry.plantedChapter = Math.min(entry.plantedChapter, snapshot.chapterNumber);
  373. }
  374. }
  375. }
  376. const planted = [];
  377. const advanced = [];
  378. const resolved = [];
  379. for (const entry of tracker.values()) {
  380. if (entry.status === "resolved") {
  381. resolved.push(entry);
  382. } else if (entry.status === "advanced") {
  383. advanced.push(entry);
  384. } else {
  385. planted.push(entry);
  386. }
  387. }
  388. const sortEntries = (items) => items.sort((a, b) => a.plantedChapter - b.plantedChapter || a.name.localeCompare(b.name, "zh-CN"));
  389. sortEntries(planted);
  390. sortEntries(advanced);
  391. sortEntries(resolved);
  392. const lines = [pageHeader("foreshadowing-tracker", "伏笔追踪记忆")];
  393. const sections = [
  394. ["进行中", [...planted, ...advanced]],
  395. ["已回收", resolved],
  396. ];
  397. for (const [title, entries] of sections) {
  398. lines.push(`## ${title}`, "");
  399. if (entries.length === 0) {
  400. lines.push("暂无记录。", "");
  401. continue;
  402. }
  403. for (const entry of entries) {
  404. lines.push(`### ${entry.name}`);
  405. lines.push(`- 状态:${entry.status === "resolved" ? "已回收" : entry.status === "advanced" ? "推进中" : "待推进"}`);
  406. if (entry.description) {
  407. lines.push(`- 说明:${entry.description}`);
  408. }
  409. lines.push(`- 初次出现:${chapterLabel(entry.plantedChapter)}`);
  410. if (entry.advancedChapters.size > 0) {
  411. lines.push(`- 推进章节:${joinChapterList(entry.advancedChapters)}`);
  412. }
  413. if (entry.resolvedChapter) {
  414. lines.push(`- 回收章节:${chapterLabel(entry.resolvedChapter)}`);
  415. }
  416. lines.push(`- 来源回查:${joinChapterList(entry.sources)}`);
  417. lines.push("");
  418. }
  419. }
  420. appendCandidateSection(lines, candidates);
  421. return `${lines.join("\n").trimEnd()}\n`;
  422. }
  423. function buildTimelinePage(snapshots) {
  424. const seen = new Set();
  425. const lines = [pageHeader("timeline", "时间线记忆"), "## 已发生事件", ""];
  426. let count = 0;
  427. const candidates = [];
  428. for (const snapshot of snapshots) {
  429. for (const event of snapshot.timelineEvents ?? []) {
  430. const text = event.trim();
  431. if (!text) continue;
  432. if (UNCERTAIN_RE.test(text)) {
  433. candidates.push(`${chapterLabel(snapshot.chapterNumber)}:${text}`);
  434. continue;
  435. }
  436. const key = `${snapshot.chapterNumber}:${text}`;
  437. if (seen.has(key)) continue;
  438. seen.add(key);
  439. lines.push(`- ${chapterLabel(snapshot.chapterNumber)}:${text}`);
  440. count += 1;
  441. }
  442. }
  443. if (count === 0) {
  444. lines.push("暂无正式时间线记录。", "");
  445. } else {
  446. lines.push("");
  447. }
  448. appendCandidateSection(lines, candidates);
  449. return `${lines.join("\n").trimEnd()}\n`;
  450. }
  451. function buildFactListPage(memoryType, title, sectionTitle, snapshots, getItems) {
  452. const facts = new Map();
  453. const candidates = [];
  454. for (const snapshot of snapshots) {
  455. for (const item of getItems(snapshot) ?? []) {
  456. const text = item.trim();
  457. if (!text) continue;
  458. if (UNCERTAIN_RE.test(text)) {
  459. candidates.push(`${chapterLabel(snapshot.chapterNumber)}:${text}`);
  460. continue;
  461. }
  462. const entry = ensureMapEntry(facts, text, () => ({ text, chapters: new Set() }));
  463. entry.chapters.add(snapshot.chapterNumber);
  464. }
  465. }
  466. const lines = [pageHeader(memoryType, title), `## ${sectionTitle}`, ""];
  467. const sorted = [...facts.values()].sort((a, b) => [...a.chapters][0] - [...b.chapters][0]);
  468. if (sorted.length === 0) {
  469. lines.push("暂无记录。", "");
  470. } else {
  471. for (const entry of sorted) {
  472. lines.push(`- ${entry.text}(来源:${joinChapterList(entry.chapters)})`);
  473. }
  474. lines.push("");
  475. }
  476. appendCandidateSection(lines, candidates);
  477. return `${lines.join("\n").trimEnd()}\n`;
  478. }
  479. async function loadSnapshots(snapshotsDir) {
  480. const dirEntries = await fs.readdir(snapshotsDir, { withFileTypes: true });
  481. const snapshotFiles = dirEntries
  482. .filter((entry) => entry.isFile() && SNAPSHOT_FILE_RE.test(entry.name))
  483. .map((entry) => entry.name)
  484. .sort((a, b) => Number(a.match(SNAPSHOT_FILE_RE)[1]) - Number(b.match(SNAPSHOT_FILE_RE)[1]));
  485. const snapshots = [];
  486. for (const fileName of snapshotFiles) {
  487. const filePath = path.join(snapshotsDir, fileName);
  488. const raw = await fs.readFile(filePath, "utf8");
  489. snapshots.push(JSON.parse(raw));
  490. }
  491. return snapshots;
  492. }
  493. async function readActualChapterNumbers(chaptersDir) {
  494. try {
  495. const dirEntries = await fs.readdir(chaptersDir, { withFileTypes: true });
  496. const numbers = [];
  497. for (const entry of dirEntries) {
  498. if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md")) continue;
  499. const filePath = path.join(chaptersDir, entry.name);
  500. const raw = await fs.readFile(filePath, "utf8");
  501. const match = raw.match(/^chapter_number:\s*['"]?(\d+)['"]?\s*$/m);
  502. if (match?.[1]) {
  503. numbers.push(Number(match[1]));
  504. }
  505. }
  506. return numbers.sort((a, b) => a - b);
  507. } catch {
  508. return [];
  509. }
  510. }
  511. function buildSnapshotValidity(snapshot, actualChapterNumbers) {
  512. if (!snapshot || !Number.isFinite(snapshot.chapterNumber)) {
  513. return { valid: false, reason: "invalid-number" };
  514. }
  515. if (snapshot.chapterNumber <= 0) {
  516. return { valid: false, reason: "non-positive" };
  517. }
  518. if (actualChapterNumbers.length === 0) {
  519. return { valid: true, reason: "accepted" };
  520. }
  521. const maxActual = Math.max(...actualChapterNumbers);
  522. if (snapshot.chapterNumber > maxActual + 5) {
  523. return { valid: false, reason: "far-beyond-current-project" };
  524. }
  525. return { valid: true, reason: "accepted" };
  526. }
  527. async function cleanupInvalidSnapshotArtifacts(snapshotsDir, ingestOutputDir, invalidChapterNumbers) {
  528. const deleted = [];
  529. const invalidSet = new Set(invalidChapterNumbers);
  530. try {
  531. const snapshotEntries = await fs.readdir(snapshotsDir, { withFileTypes: true });
  532. for (const entry of snapshotEntries) {
  533. if (!entry.isFile()) continue;
  534. const match = entry.name.match(SNAPSHOT_FILE_RE);
  535. if (!match?.[1]) continue;
  536. const chapterNumber = Number(match[1]);
  537. if (!invalidSet.has(chapterNumber)) continue;
  538. await fs.unlink(path.join(snapshotsDir, entry.name));
  539. deleted.push(path.join(".novel", "snapshots", entry.name));
  540. }
  541. } catch {}
  542. try {
  543. const outputEntries = await fs.readdir(ingestOutputDir, { withFileTypes: true });
  544. for (const entry of outputEntries) {
  545. if (!entry.isFile()) continue;
  546. const match = entry.name.match(OUTPUT_PREFIX_RE);
  547. if (!match?.[1]) continue;
  548. const chapterNumber = Number(match[1]);
  549. if (!invalidSet.has(chapterNumber)) continue;
  550. await fs.unlink(path.join(ingestOutputDir, entry.name));
  551. deleted.push(path.join(".novel", "chapter-ingest-output", entry.name));
  552. }
  553. } catch {}
  554. return deleted;
  555. }
  556. async function cleanupEntityFragments(entitiesDir, validSnapshotNumbers) {
  557. const dirEntries = await fs.readdir(entitiesDir, { withFileTypes: true });
  558. const deleted = [];
  559. for (const entry of dirEntries) {
  560. if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md")) continue;
  561. const filePath = path.join(entitiesDir, entry.name);
  562. const content = await fs.readFile(filePath, "utf8");
  563. if (!shouldDeleteEntityFile(entry.name, content, validSnapshotNumbers)) continue;
  564. await fs.unlink(filePath);
  565. deleted.push(entry.name);
  566. }
  567. return deleted;
  568. }
  569. async function writeMemoryPages(memoryDir, pages) {
  570. await fs.mkdir(memoryDir, { recursive: true });
  571. for (const [fileName, content] of Object.entries(pages)) {
  572. await fs.writeFile(path.join(memoryDir, fileName), content, "utf8");
  573. }
  574. }
  575. async function main() {
  576. const resolvedProjectPath = path.resolve(projectPath);
  577. const entitiesDir = path.join(resolvedProjectPath, "wiki", "entities");
  578. const memoryDir = path.join(resolvedProjectPath, "wiki", "memory");
  579. const snapshotsDir = path.join(resolvedProjectPath, ".novel", "snapshots");
  580. const ingestOutputDir = path.join(resolvedProjectPath, ".novel", "chapter-ingest-output");
  581. const chaptersDir = path.join(resolvedProjectPath, "wiki", "chapters");
  582. const actualChapterNumbers = await readActualChapterNumbers(chaptersDir);
  583. const allSnapshots = await loadSnapshots(snapshotsDir);
  584. const validSnapshots = [];
  585. const invalidChapterNumbers = new Set();
  586. for (const snapshot of allSnapshots) {
  587. const validity = buildSnapshotValidity(snapshot, actualChapterNumbers);
  588. if (validity.valid) {
  589. validSnapshots.push(snapshot);
  590. } else {
  591. invalidChapterNumbers.add(snapshot.chapterNumber);
  592. }
  593. }
  594. if (shouldDeleteFragments && invalidChapterNumbers.size > 0) {
  595. await cleanupInvalidSnapshotArtifacts(
  596. snapshotsDir,
  597. ingestOutputDir,
  598. [...invalidChapterNumbers],
  599. );
  600. }
  601. const snapshots = validSnapshots;
  602. if (snapshots.length === 0) {
  603. throw new Error(`没有找到可用快照:${snapshotsDir}`);
  604. }
  605. let deleted = [];
  606. if (shouldDeleteFragments) {
  607. const validSnapshotNumbers = new Set(snapshots.map((snapshot) => snapshot.chapterNumber));
  608. deleted = await cleanupEntityFragments(entitiesDir, validSnapshotNumbers);
  609. }
  610. await writeMemoryPages(memoryDir, {
  611. "chapter-snapshots.md": buildChapterSnapshotsPage(snapshots),
  612. "character-cognition.md": buildCharacterCognitionPage(snapshots),
  613. "character-states.md": buildCharacterStatesPage(snapshots),
  614. "foreshadowing-tracker.md": buildForeshadowingPage(snapshots),
  615. "timeline.md": buildTimelinePage(snapshots),
  616. "canon-facts.md": buildFactListPage("canon-facts", "正式设定记忆", "正式事实", snapshots, (snapshot) => snapshot.newCanonFacts),
  617. "conflicts.md": buildFactListPage("conflicts", "冲突追踪记忆", "当前冲突", snapshots, (snapshot) => snapshot.conflicts),
  618. });
  619. const remainingEntities = (await fs.readdir(entitiesDir, { withFileTypes: true }))
  620. .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
  621. .length;
  622. console.log(`已整理项目:${resolvedProjectPath}`);
  623. console.log(`有效快照:${snapshots.length}`);
  624. console.log(`排除异常快照:${invalidChapterNumbers.size}`);
  625. console.log(`实体剩余:${remainingEntities}`);
  626. console.log(`删除碎片实体:${deleted.length}`);
  627. if (deleted.length > 0) {
  628. console.log("已删除示例:");
  629. for (const name of deleted.slice(0, 20)) {
  630. console.log(`- ${name}`);
  631. }
  632. }
  633. }
  634. main().catch((error) => {
  635. console.error(error instanceof Error ? error.message : String(error));
  636. process.exit(1);
  637. });