memory-retrieval.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. import { readFile } from "node:fs/promises";
  2. import { join } from "node:path";
  3. import {
  4. ChapterSummariesStateSchema,
  5. CurrentStateStateSchema,
  6. HooksStateSchema,
  7. } from "../models/runtime-state.js";
  8. import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js";
  9. import { bootstrapStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
  10. import {
  11. buildPlannerHookAgenda,
  12. filterActiveHooks,
  13. isFuturePlannedHook,
  14. isHookWithinChapterWindow,
  15. } from "./hook-agenda.js";
  16. import {
  17. parseChapterSummariesMarkdown,
  18. parseCurrentStateFacts,
  19. parsePendingHooksMarkdown,
  20. renderHookSnapshot,
  21. renderSummarySnapshot,
  22. } from "./story-markdown.js";
  23. export {
  24. buildPlannerHookAgenda,
  25. isFuturePlannedHook,
  26. isHookWithinChapterWindow,
  27. } from "./hook-agenda.js";
  28. export {
  29. parseChapterSummariesMarkdown,
  30. parseCurrentStateFacts,
  31. parsePendingHooksMarkdown,
  32. renderHookSnapshot,
  33. renderSummarySnapshot,
  34. } from "./story-markdown.js";
  35. export interface MemorySelection {
  36. readonly summaries: ReadonlyArray<StoredSummary>;
  37. readonly hooks: ReadonlyArray<StoredHook>;
  38. readonly activeHooks: ReadonlyArray<StoredHook>;
  39. readonly facts: ReadonlyArray<Fact>;
  40. readonly volumeSummaries: ReadonlyArray<VolumeSummarySelection>;
  41. readonly dbPath?: string;
  42. }
  43. export interface VolumeSummarySelection {
  44. readonly heading: string;
  45. readonly content: string;
  46. readonly anchor: string;
  47. }
  48. export async function retrieveMemorySelection(params: {
  49. readonly bookDir: string;
  50. readonly chapterNumber: number;
  51. readonly goal: string;
  52. readonly outlineNode?: string;
  53. readonly mustKeep?: ReadonlyArray<string>;
  54. }): Promise<MemorySelection> {
  55. const storyDir = join(params.bookDir, "story");
  56. const stateDir = join(storyDir, "state");
  57. const fallbackChapter = Math.max(0, params.chapterNumber - 1);
  58. await bootstrapStructuredStateFromMarkdown({
  59. bookDir: params.bookDir,
  60. fallbackChapter,
  61. }).catch(() => undefined);
  62. const [
  63. currentStateMarkdown,
  64. volumeSummariesMarkdown,
  65. structuredCurrentState,
  66. structuredHooks,
  67. structuredSummaries,
  68. ] = await Promise.all([
  69. readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
  70. readFile(join(storyDir, "volume_summaries.md"), "utf-8").catch(() => ""),
  71. readStructuredState(join(stateDir, "current_state.json"), CurrentStateStateSchema),
  72. readStructuredState(join(stateDir, "hooks.json"), HooksStateSchema),
  73. readStructuredState(join(stateDir, "chapter_summaries.json"), ChapterSummariesStateSchema),
  74. ]);
  75. const facts = structuredCurrentState?.facts ?? parseCurrentStateFacts(
  76. currentStateMarkdown,
  77. fallbackChapter,
  78. );
  79. const narrativeQueryTerms = extractQueryTerms(
  80. params.goal,
  81. params.outlineNode,
  82. [],
  83. );
  84. const factQueryTerms = extractQueryTerms(
  85. params.goal,
  86. params.outlineNode,
  87. params.mustKeep ?? [],
  88. );
  89. const volumeSummaries = selectRelevantVolumeSummaries(
  90. parseVolumeSummariesMarkdown(volumeSummariesMarkdown),
  91. narrativeQueryTerms,
  92. );
  93. const memoryDb = openMemoryDB(params.bookDir);
  94. if (memoryDb) {
  95. try {
  96. if (memoryDb.getChapterCount() === 0) {
  97. const summaries = structuredSummaries?.rows ?? parseChapterSummariesMarkdown(
  98. await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
  99. );
  100. if (summaries.length > 0) {
  101. memoryDb.replaceSummaries(summaries);
  102. }
  103. }
  104. if (memoryDb.getActiveHooks().length === 0) {
  105. const hooks = structuredHooks?.hooks ?? parsePendingHooksMarkdown(
  106. await readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
  107. );
  108. if (hooks.length > 0) {
  109. memoryDb.replaceHooks(hooks);
  110. }
  111. }
  112. if (memoryDb.getCurrentFacts().length === 0 && facts.length > 0) {
  113. memoryDb.replaceCurrentFacts(facts);
  114. }
  115. const activeHooks = memoryDb.getActiveHooks();
  116. return {
  117. summaries: selectRelevantSummaries(
  118. memoryDb.getSummaries(1, Math.max(1, params.chapterNumber - 1)),
  119. params.chapterNumber,
  120. narrativeQueryTerms,
  121. ),
  122. hooks: selectRelevantHooks(activeHooks, narrativeQueryTerms, params.chapterNumber),
  123. activeHooks,
  124. facts: selectRelevantFacts(memoryDb.getCurrentFacts(), factQueryTerms),
  125. volumeSummaries,
  126. dbPath: join(storyDir, "memory.db"),
  127. };
  128. } finally {
  129. memoryDb.close();
  130. }
  131. }
  132. const [summariesMarkdown, hooksMarkdown] = await Promise.all([
  133. readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
  134. readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
  135. ]);
  136. const summaries = structuredSummaries?.rows ?? parseChapterSummariesMarkdown(summariesMarkdown);
  137. const hooks = structuredHooks?.hooks ?? parsePendingHooksMarkdown(hooksMarkdown);
  138. const activeHooks = filterActiveHooks(hooks);
  139. return {
  140. summaries: selectRelevantSummaries(summaries, params.chapterNumber, narrativeQueryTerms),
  141. hooks: selectRelevantHooks(activeHooks, narrativeQueryTerms, params.chapterNumber),
  142. activeHooks,
  143. facts: selectRelevantFacts(facts, factQueryTerms),
  144. volumeSummaries,
  145. };
  146. }
  147. export function extractQueryTerms(goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray<string>): string[] {
  148. const primaryTerms = uniqueTerms([
  149. ...extractTermsFromText(stripNegativeGuidance(goal)),
  150. ...mustKeep.flatMap((item) => extractTermsFromText(item)),
  151. ]);
  152. if (primaryTerms.length >= 2) {
  153. return primaryTerms.slice(0, 12);
  154. }
  155. return uniqueTerms([
  156. ...primaryTerms,
  157. ...extractTermsFromText(stripNegativeGuidance(outlineNode ?? "")),
  158. ]).slice(0, 12);
  159. }
  160. function openMemoryDB(bookDir: string): MemoryDB | null {
  161. try {
  162. return new MemoryDB(bookDir);
  163. } catch {
  164. return null;
  165. }
  166. }
  167. async function readStructuredState<T>(
  168. path: string,
  169. schema: { parse(value: unknown): T },
  170. ): Promise<T | null> {
  171. try {
  172. const raw = await readFile(path, "utf-8");
  173. return schema.parse(JSON.parse(raw));
  174. } catch {
  175. return null;
  176. }
  177. }
  178. function buildLegacyQueryTerms(goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray<string>): string[] {
  179. const stopWords = new Set([
  180. "bring", "focus", "back", "chapter", "clear", "narrative", "before", "opening",
  181. "track", "the", "with", "from", "that", "this", "into", "still", "cannot",
  182. "current", "state", "advance", "conflict", "story", "keep", "must", "local",
  183. ]);
  184. const source = [goal, outlineNode ?? "", ...mustKeep].join(" ");
  185. const english = source.match(/[a-z]{4,}/gi) ?? [];
  186. const chinese = source.match(/[\u4e00-\u9fff]{2,4}/g) ?? [];
  187. return [...new Set(
  188. [...english, ...chinese]
  189. .map((term) => term.trim())
  190. .filter((term) => term.length >= 2)
  191. .filter((term) => !stopWords.has(term.toLowerCase())),
  192. )].slice(0, 12);
  193. }
  194. function extractTermsFromText(text: string): string[] {
  195. if (!text.trim()) return [];
  196. const stopWords = new Set([
  197. "bring", "focus", "back", "chapter", "clear", "narrative", "before", "opening",
  198. "track", "the", "with", "from", "that", "this", "into", "still", "cannot",
  199. "current", "state", "advance", "conflict", "story", "keep", "must", "local",
  200. "does", "not", "only", "just", "then", "than",
  201. ]);
  202. const normalized = text.replace(/第\d+章/g, " ");
  203. const english = (normalized.match(/[a-z]{4,}/gi) ?? [])
  204. .map((term) => term.trim())
  205. .filter((term) => term.length >= 2)
  206. .filter((term) => !stopWords.has(term.toLowerCase()));
  207. const chineseSegments = normalized.match(/[\u4e00-\u9fff]{2,}/g) ?? [];
  208. const chinese = chineseSegments.flatMap((segment) => extractChineseFocusTerms(segment));
  209. return [...english, ...chinese];
  210. }
  211. function extractChineseFocusTerms(segment: string): string[] {
  212. const stripped = segment
  213. .replace(/^(本章|继续|重新|拉回|回到|推进|优先|围绕|聚焦|坚持|保持|把注意力|注意力|将注意力|请把注意力|先把注意力)+/, "")
  214. .replace(/^(处理|推进|回拉|拉回到)+/, "")
  215. .trim();
  216. const target = stripped.length >= 2 ? stripped : segment;
  217. const terms = new Set<string>();
  218. if (target.length <= 4) {
  219. terms.add(target);
  220. }
  221. for (let size = 2; size <= 4; size += 1) {
  222. if (target.length >= size) {
  223. terms.add(target.slice(-size));
  224. }
  225. }
  226. return [...terms].filter((term) => term.length >= 2);
  227. }
  228. function stripNegativeGuidance(text: string): string {
  229. if (!text) return "";
  230. return text
  231. .replace(/\b(do not|don't|avoid|without|instead of)\b[\s\S]*$/i, " ")
  232. .replace(/(?:不要|不让|别|禁止|避免|但不允许)[\s\S]*$/u, " ")
  233. .trim();
  234. }
  235. function uniqueTerms(terms: ReadonlyArray<string>): string[] {
  236. const result: string[] = [];
  237. const seen = new Set<string>();
  238. for (const term of terms) {
  239. const normalized = term.trim().toLowerCase();
  240. if (!normalized || seen.has(normalized)) continue;
  241. seen.add(normalized);
  242. result.push(term.trim());
  243. }
  244. return result;
  245. }
  246. function parseVolumeSummariesMarkdown(markdown: string): VolumeSummarySelection[] {
  247. if (!markdown.trim()) return [];
  248. const sections = markdown
  249. .split(/^##\s+/m)
  250. .map((section) => section.trim())
  251. .filter(Boolean);
  252. return sections.map((section) => {
  253. const [headingLine, ...bodyLines] = section.split("\n");
  254. const heading = headingLine?.trim() ?? "";
  255. const content = bodyLines.join("\n").trim();
  256. return {
  257. heading,
  258. content,
  259. anchor: slugifyAnchor(heading),
  260. };
  261. }).filter((section) => section.heading.length > 0 && section.content.length > 0);
  262. }
  263. function isUnresolvedHook(status: string): boolean {
  264. return status.trim().length === 0 || /open|待定|推进|active|progressing/i.test(status);
  265. }
  266. function selectRelevantSummaries(
  267. summaries: ReadonlyArray<StoredSummary>,
  268. chapterNumber: number,
  269. queryTerms: ReadonlyArray<string>,
  270. ): StoredSummary[] {
  271. return summaries
  272. .filter((summary) => summary.chapter < chapterNumber)
  273. .map((summary) => ({
  274. summary,
  275. score: scoreSummary(summary, chapterNumber, queryTerms),
  276. matched: matchesAny([
  277. summary.title,
  278. summary.characters,
  279. summary.events,
  280. summary.stateChanges,
  281. summary.hookActivity,
  282. summary.chapterType,
  283. ].join(" "), queryTerms),
  284. }))
  285. .filter((entry) => entry.matched || entry.summary.chapter >= chapterNumber - 3)
  286. .sort((left, right) => right.score - left.score || right.summary.chapter - left.summary.chapter)
  287. .slice(0, 4)
  288. .map((entry) => entry.summary)
  289. .sort((left, right) => left.chapter - right.chapter);
  290. }
  291. function selectRelevantHooks(
  292. hooks: ReadonlyArray<StoredHook>,
  293. queryTerms: ReadonlyArray<string>,
  294. chapterNumber: number,
  295. ): StoredHook[] {
  296. const ranked = hooks
  297. .map((hook) => ({
  298. hook,
  299. score: scoreHook(hook, queryTerms, chapterNumber),
  300. matched: matchesAny(
  301. [hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" "),
  302. queryTerms,
  303. ),
  304. }))
  305. .filter((entry: { hook: StoredHook; score: number; matched: boolean }) =>
  306. entry.matched || isUnresolvedHook(entry.hook.status),
  307. );
  308. const primary = ranked
  309. .filter((entry: { hook: StoredHook; score: number; matched: boolean }) =>
  310. entry.matched || isHookWithinChapterWindow(entry.hook, chapterNumber, 5),
  311. )
  312. .sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter)
  313. .slice(0, 6);
  314. const selectedIds = new Set(primary.map((entry: { hook: StoredHook; score: number; matched: boolean }) => entry.hook.hookId));
  315. const stale = ranked
  316. .filter((entry: { hook: StoredHook; score: number; matched: boolean }) =>
  317. !selectedIds.has(entry.hook.hookId)
  318. && !isFuturePlannedHook(entry.hook, chapterNumber)
  319. && isUnresolvedHook(entry.hook.status),
  320. )
  321. .sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score)
  322. .slice(0, 2);
  323. return [...primary, ...stale].map((entry: { hook: StoredHook; score: number; matched: boolean }) => entry.hook);
  324. }
  325. function selectRelevantFacts(
  326. facts: ReadonlyArray<Fact>,
  327. queryTerms: ReadonlyArray<string>,
  328. ): Fact[] {
  329. const prioritizedPredicates = [
  330. /^(当前冲突|current conflict)$/i,
  331. /^(当前目标|current goal)$/i,
  332. /^(主角状态|protagonist state)$/i,
  333. /^(当前限制|current constraint)$/i,
  334. /^(当前位置|current location)$/i,
  335. /^(当前敌我|current alliances|current relationships)$/i,
  336. ];
  337. return facts
  338. .map((fact) => {
  339. const text = [fact.subject, fact.predicate, fact.object].join(" ");
  340. const priority = prioritizedPredicates.findIndex((pattern) => pattern.test(fact.predicate));
  341. const baseScore = priority === -1 ? 5 : 20 - priority * 2;
  342. const termScore = queryTerms.reduce(
  343. (score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0),
  344. 0,
  345. );
  346. return {
  347. fact,
  348. score: baseScore + termScore,
  349. matched: matchesAny(text, queryTerms),
  350. };
  351. })
  352. .filter((entry) => entry.matched || entry.score >= 14)
  353. .sort((left, right) => right.score - left.score)
  354. .slice(0, 4)
  355. .map((entry) => entry.fact);
  356. }
  357. function selectRelevantVolumeSummaries(
  358. summaries: ReadonlyArray<VolumeSummarySelection>,
  359. queryTerms: ReadonlyArray<string>,
  360. ): VolumeSummarySelection[] {
  361. if (summaries.length === 0) return [];
  362. const ranked = summaries
  363. .map((summary, index) => {
  364. const text = `${summary.heading} ${summary.content}`;
  365. const termScore = queryTerms.reduce(
  366. (score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0),
  367. 0,
  368. );
  369. return {
  370. index,
  371. summary,
  372. score: termScore + index,
  373. matched: matchesAny(text, queryTerms),
  374. };
  375. })
  376. .filter((entry, index, all) => entry.matched || index === all.length - 1)
  377. .sort((left, right) => right.score - left.score)
  378. .slice(0, 2)
  379. .sort((left, right) => left.index - right.index)
  380. .map((entry) => entry.summary);
  381. return ranked;
  382. }
  383. function scoreSummary(summary: StoredSummary, chapterNumber: number, queryTerms: ReadonlyArray<string>): number {
  384. const text = [
  385. summary.title,
  386. summary.characters,
  387. summary.events,
  388. summary.stateChanges,
  389. summary.hookActivity,
  390. summary.chapterType,
  391. ].join(" ");
  392. const age = Math.max(0, chapterNumber - summary.chapter);
  393. const recencyScore = Math.max(0, 12 - age);
  394. const termScore = queryTerms.reduce((score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0), 0);
  395. return recencyScore + termScore;
  396. }
  397. function scoreHook(
  398. hook: StoredHook,
  399. queryTerms: ReadonlyArray<string>,
  400. _chapterNumber: number,
  401. ): number {
  402. const text = [hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" ");
  403. const freshness = Math.max(0, hook.lastAdvancedChapter);
  404. const termScore = queryTerms.reduce((score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0), 0);
  405. return termScore + freshness;
  406. }
  407. function matchesAny(text: string, queryTerms: ReadonlyArray<string>): boolean {
  408. return queryTerms.some((term) => includesTerm(text, term));
  409. }
  410. function includesTerm(text: string, term: string): boolean {
  411. return text.toLowerCase().includes(term.toLowerCase());
  412. }
  413. function slugifyAnchor(value: string): string {
  414. return value
  415. .trim()
  416. .toLowerCase()
  417. .replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
  418. .replace(/^-+|-+$/g, "")
  419. || "volume-summary";
  420. }