Просмотр исходного кода

feat: improve governed memory retrieval

Ma 7 месяцев назад
Родитель
Сommit
3a3db03f5d
30 измененных файлов с 2016 добавлено и 78 удалено
  1. 41 0
      packages/cli/src/__tests__/cli-integration.test.ts
  2. 4 2
      packages/cli/src/commands/review.ts
  3. 4 2
      packages/cli/src/commands/status.ts
  4. 104 0
      packages/core/src/__tests__/chapter-analyzer.test.ts
  5. 115 4
      packages/core/src/__tests__/composer.test.ts
  6. 20 0
      packages/core/src/__tests__/context-filter.test.ts
  7. 131 0
      packages/core/src/__tests__/continuity.test.ts
  8. 54 0
      packages/core/src/__tests__/length-normalizer.test.ts
  9. 64 0
      packages/core/src/__tests__/memory-retrieval.test.ts
  10. 312 0
      packages/core/src/__tests__/pipeline-runner.test.ts
  11. 58 0
      packages/core/src/__tests__/planner.test.ts
  12. 125 1
      packages/core/src/__tests__/reviser.test.ts
  13. 163 0
      packages/core/src/__tests__/writer.test.ts
  14. 3 1
      packages/core/src/agents/chapter-analyzer.ts
  15. 46 7
      packages/core/src/agents/composer.ts
  16. 14 6
      packages/core/src/agents/continuity.ts
  17. 43 16
      packages/core/src/agents/length-normalizer.ts
  18. 20 8
      packages/core/src/agents/planner.ts
  19. 11 6
      packages/core/src/agents/reviser.ts
  20. 5 1
      packages/core/src/agents/settler-prompts.ts
  21. 12 1
      packages/core/src/agents/writer.ts
  22. 1 1
      packages/core/src/index.ts
  23. 77 7
      packages/core/src/pipeline/runner.ts
  24. 146 8
      packages/core/src/state/memory-db.ts
  25. 1 1
      packages/core/src/utils/context-filter.ts
  26. 33 0
      packages/core/src/utils/governed-context.ts
  27. 14 1
      packages/core/src/utils/length-metrics.ts
  28. 378 0
      packages/core/src/utils/memory-retrieval.ts
  29. 9 3
      scripts/prepare-package-for-publish.mjs
  30. 8 2
      scripts/restore-package-json.mjs

+ 41 - 0
packages/cli/src/__tests__/cli-integration.test.ts

@@ -171,6 +171,47 @@ describe("CLI integration", () => {
       const { exitCode, stderr } = runStderr(["status", "nonexistent"]);
       expect(exitCode).not.toBe(0);
     });
+
+    it("shows English chapter counts in words for chapter rows", async () => {
+      const bookDir = join(projectDir, "books", "english-status");
+      await mkdir(join(bookDir, "chapters"), { recursive: true });
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: "english-status",
+          title: "English Status Book",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 10,
+          chapterWordCount: 2200,
+          language: "en",
+          createdAt: "2026-03-22T00:00:00.000Z",
+          updatedAt: "2026-03-22T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      );
+      await writeFile(
+        join(bookDir, "chapters", "index.json"),
+        JSON.stringify([
+          {
+            number: 1,
+            title: "A Quiet Sky",
+            status: "ready-for-review",
+            wordCount: 7,
+            createdAt: "2026-03-22T00:00:00.000Z",
+            updatedAt: "2026-03-22T00:00:00.000Z",
+            auditIssues: [],
+            lengthWarnings: [],
+          },
+        ], null, 2),
+        "utf-8",
+      );
+
+      const output = run(["status", "english-status", "--chapters"]);
+      expect(output).toContain('Ch.1 "A Quiet Sky" | 7 words | ready-for-review');
+      expect(output).not.toContain("7字");
+    });
   });
 
   describe("inkos doctor", () => {

+ 4 - 2
packages/cli/src/commands/review.ts

@@ -1,5 +1,5 @@
 import { Command } from "commander";
-import { StateManager } from "@actalk/inkos-core";
+import { StateManager, formatLengthCount, readGenreProfile, resolveLengthCountingMode } from "@actalk/inkos-core";
 import { findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const reviewCommand = new Command("review")
@@ -36,6 +36,8 @@ reviewCommand
         if (pending.length === 0) continue;
 
         const book = await state.loadBookConfig(id);
+        const { profile: genreProfile } = await readGenreProfile(root, book.genre);
+        const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
 
         if (!opts.json) {
           log(`\n${book.title} (${id}):`);
@@ -52,7 +54,7 @@ reviewCommand
           });
           if (!opts.json) {
             log(
-              `  Ch.${ch.number} "${ch.title}" | ${ch.wordCount}字 | ${ch.status}`,
+              `  Ch.${ch.number} "${ch.title}" | ${formatLengthCount(ch.wordCount, countingMode)} | ${ch.status}`,
             );
             if (ch.auditIssues.length > 0) {
               for (const issue of ch.auditIssues) {

+ 4 - 2
packages/cli/src/commands/status.ts

@@ -1,5 +1,5 @@
 import { Command } from "commander";
-import { StateManager } from "@actalk/inkos-core";
+import { StateManager, formatLengthCount, readGenreProfile, resolveLengthCountingMode } from "@actalk/inkos-core";
 import { findProjectRoot, log, logError } from "../utils.js";
 
 export const statusCommand = new Command("status")
@@ -33,6 +33,8 @@ export const statusCommand = new Command("status")
         const book = await state.loadBookConfig(id);
         const index = await state.loadChapterIndex(id);
         const nextChapter = await state.getNextChapterNumber(id);
+        const { profile: genreProfile } = await readGenreProfile(root, book.genre);
+        const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
 
         const approved = index.filter((ch) => ch.status === "approved").length;
         const pending = index.filter(
@@ -80,7 +82,7 @@ export const statusCommand = new Command("status")
             log("");
             for (const ch of index) {
               const icon = ch.status === "approved" ? "+" : ch.status === "audit-failed" ? "!" : "~";
-              log(`    [${icon}] Ch.${ch.number} "${ch.title}" | ${ch.wordCount}字 | ${ch.status}`);
+              log(`    [${icon}] Ch.${ch.number} "${ch.title}" | ${formatLengthCount(ch.wordCount, countingMode)} | ${ch.status}`);
               if (ch.status === "audit-failed" && ch.auditIssues.length > 0) {
                 const criticals = ch.auditIssues.filter((i: string) => i.startsWith("[critical]"));
                 const warnings = ch.auditIssues.filter((i: string) => i.startsWith("[warning]"));

+ 104 - 0
packages/core/src/__tests__/chapter-analyzer.test.ts

@@ -0,0 +1,104 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
+import type { BookConfig } from "../models/book.js";
+import { countChapterLength } from "../utils/length-metrics.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+describe("ChapterAnalyzerAgent", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("counts English chapter content using words instead of characters", async () => {
+    const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-"));
+    const englishContent = "He looked at the sky and waited.";
+    const agent = new ChapterAnalyzerAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "english-book",
+      title: "English Book",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 10,
+      chapterWordCount: 2200,
+      language: "en",
+      createdAt: "2026-03-22T00:00:00.000Z",
+      updatedAt: "2026-03-22T00:00:00.000Z",
+    };
+
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== CHAPTER_TITLE ===",
+          "A Quiet Sky",
+          "",
+          "=== CHAPTER_CONTENT ===",
+          englishContent,
+          "",
+          "=== PRE_WRITE_CHECK ===",
+          "",
+          "=== POST_SETTLEMENT ===",
+          "",
+          "=== UPDATED_STATE ===",
+          "| Field | Value |",
+          "| --- | --- |",
+          "| Chapter | 1 |",
+          "",
+          "=== UPDATED_LEDGER ===",
+          "",
+          "=== UPDATED_HOOKS ===",
+          "| hook_id | status |",
+          "| --- | --- |",
+          "| h1 | open |",
+          "",
+          "=== CHAPTER_SUMMARY ===",
+          "| 1 | A Quiet Sky |",
+          "",
+          "=== UPDATED_SUBPLOTS ===",
+          "",
+          "=== UPDATED_EMOTIONAL_ARCS ===",
+          "",
+          "=== UPDATED_CHARACTER_MATRIX ===",
+          "",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    try {
+      const output = await agent.analyzeChapter({
+        book,
+        bookDir,
+        chapterNumber: 1,
+        chapterContent: englishContent,
+      });
+
+      expect(output.wordCount).toBe(countChapterLength(englishContent, "en_words"));
+      expect(output.wordCount).toBe(7);
+    } finally {
+      await rm(bookDir, { recursive: true, force: true });
+    }
+  });
+});

+ 115 - 4
packages/core/src/__tests__/composer.test.ts

@@ -1,5 +1,5 @@
 import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import type { BookConfig } from "../models/book.js";
@@ -93,14 +93,15 @@ describe("ComposerAgent", () => {
       plan,
     });
 
-    expect(result.contextPackage.selectedContext.map((entry) => entry.source)).toEqual([
+    const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
+    expect(selectedSources.slice(0, 4)).toEqual([
       "story/current_focus.md",
       "story/current_state.md",
       "story/story_bible.md",
       "story/volume_outline.md",
-      "story/pending_hooks.md",
     ]);
-    expect(result.contextPackage.selectedContext.map((entry) => entry.source)).not.toContain("story/style_guide.md");
+    expect(selectedSources.some((source) => source.startsWith("story/pending_hooks.md"))).toBe(true);
+    expect(selectedSources).not.toContain("story/style_guide.md");
     await expect(readFile(result.contextPath, "utf-8")).resolves.toContain("current_focus.md");
   });
 
@@ -145,4 +146,114 @@ describe("ComposerAgent", () => {
     expect(result.trace.notes).toContain("allow local outline deferral");
     await expect(readFile(result.tracePath, "utf-8")).resolves.toContain("allow local outline deferral");
   });
+
+  it("retrieves summary and hook evidence chunks instead of whole long memory files", async () => {
+    await Promise.all([
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "# Pending Hooks",
+          "",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
+          "| mentor-oath | 8 | relationship | open | 9 | 11 | Mentor oath debt with Lin Yue |",
+          "| old-seal | 3 | artifact | resolved | 3 | 3 | Jade seal already recovered |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "# Chapter Summaries",
+          "",
+          "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
+          "| 7 | Broken Letter | Lin Yue | A torn letter mentions the mentor | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
+          "| 8 | River Camp | Lin Yue, Mentor Witness | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
+          "| 9 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    const longRangePlan: PlanChapterOutput = {
+      ...plan,
+      intent: {
+        ...plan.intent,
+        chapter: 10,
+        goal: "Bring the focus back to the mentor oath conflict.",
+        outlineNode: "Track the merchant guild trail.",
+      },
+    };
+
+    const composer = new ComposerAgent({
+      client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await composer.composeChapter({
+      book,
+      bookDir,
+      chapterNumber: 10,
+      plan: longRangePlan,
+    });
+
+    const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
+    expect(selectedSources).toContain("story/pending_hooks.md#mentor-oath");
+    expect(selectedSources).toContain("story/chapter_summaries.md#9");
+    expect(selectedSources).not.toContain("story/pending_hooks.md");
+    await expect(stat(join(storyDir, "memory.db"))).resolves.toBeTruthy();
+  });
+
+  it("adds current-state fact evidence retrieved from sqlite-backed memory", async () => {
+    await writeFile(
+      join(storyDir, "current_state.md"),
+      [
+        "# Current State",
+        "",
+        "| Field | Value |",
+        "| --- | --- |",
+        "| Current Chapter | 9 |",
+        "| Current Location | Ashen ferry crossing |",
+        "| Protagonist State | Lin Yue hides the broken oath token and the old wound has reopened. |",
+        "| Current Goal | Find the vanished mentor before the guild covers its tracks. |",
+        "| Current Conflict | Mentor debt with the vanished teacher blocks every choice. |",
+        "",
+      ].join("\n"),
+      "utf-8",
+    );
+
+    const composer = new ComposerAgent({
+      client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await composer.composeChapter({
+      book,
+      bookDir,
+      chapterNumber: 10,
+      plan: {
+        ...plan,
+        intent: {
+          ...plan.intent,
+          chapter: 10,
+          goal: "Bring the focus back to the vanished mentor conflict.",
+        },
+      },
+    });
+
+    const factEntry = result.contextPackage.selectedContext.find((entry) =>
+      entry.source === "story/current_state.md#current-conflict",
+    );
+
+    expect(factEntry).toBeDefined();
+    expect(factEntry?.excerpt).toContain("Current Conflict");
+    expect(factEntry?.excerpt).toContain("Mentor debt with the vanished teacher");
+  });
 });

+ 20 - 0
packages/core/src/__tests__/context-filter.test.ts

@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { filterSummaries } from "../utils/context-filter.js";
+
+describe("context-filter", () => {
+  it("filters old chapter summary rows even when titles start with 'Chapter'", () => {
+    const summaries = [
+      "# Chapter Summaries",
+      "",
+      "| 1 | Chapter 1 | Lin Yue | Old event | state-1 | side-quest-1 | tense | drama |",
+      "| 97 | Chapter 97 | Lin Yue | Recent event | state-97 | side-quest-97 | tense | drama |",
+      "| 100 | Chapter 100 | Lin Yue | Latest event | state-100 | mentor-oath advanced | tense | drama |",
+    ].join("\n");
+
+    const filtered = filterSummaries(summaries, 101);
+
+    expect(filtered).not.toContain("| 1 | Chapter 1 |");
+    expect(filtered).toContain("| 97 | Chapter 97 |");
+    expect(filtered).toContain("| 100 | Chapter 100 |");
+  });
+});

+ 131 - 0
packages/core/src/__tests__/continuity.test.ts

@@ -0,0 +1,131 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { ContinuityAuditor } from "../agents/continuity.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+describe("ContinuityAuditor", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("uses selected summary and hook evidence instead of full long-history markdown in governed mode", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-auditor-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "# Pending Hooks",
+          "",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
+          "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "# Chapter Summaries",
+          "",
+          "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
+          "| 99 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(join(storyDir, "subplot_board.md"), "# 支线进度板\n", "utf-8"),
+      writeFile(join(storyDir, "emotional_arcs.md"), "# 情感弧线\n", "utf-8"),
+      writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nTrack the merchant guild trail.\n", "utf-8"),
+      writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
+    ]);
+
+    const auditor = new ContinuityAuditor({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const chatSpy = vi.spyOn(ContinuityAuditor.prototype as never, "chat" as never).mockResolvedValue({
+      content: JSON.stringify({
+        passed: true,
+        issues: [],
+        summary: "ok",
+      }),
+      usage: ZERO_USAGE,
+    });
+
+    try {
+      await auditor.auditChapter(
+        bookDir,
+        "Chapter body.",
+        100,
+        "xuanhuan",
+        {
+          chapterIntent: "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor oath conflict.\n",
+          contextPackage: {
+            chapter: 100,
+            selectedContext: [
+              {
+                source: "story/chapter_summaries.md#99",
+                reason: "Relevant episodic memory.",
+                excerpt: "Trial Echo | Mentor left without explanation | mentor-oath advanced",
+              },
+              {
+                source: "story/pending_hooks.md#mentor-oath",
+                reason: "Carry forward unresolved hook.",
+                excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
+              },
+            ],
+          },
+          ruleStack: {
+            layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
+            sections: {
+              hard: ["current_state"],
+              soft: ["current_focus"],
+              diagnostic: ["continuity_audit"],
+            },
+            overrideEdges: [],
+            activeOverrides: [],
+          },
+        },
+      );
+
+      const messages = chatSpy.mock.calls[0]?.[0] as
+        | ReadonlyArray<{ content: string }>
+        | undefined;
+      const userPrompt = messages?.[1]?.content ?? "";
+
+      expect(userPrompt).toContain("story/chapter_summaries.md#99");
+      expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath");
+      expect(userPrompt).not.toContain("| 1 | Guild Trail |");
+      expect(userPrompt).not.toContain("guild-route | 1 | mystery");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+});

+ 54 - 0
packages/core/src/__tests__/length-normalizer.test.ts

@@ -194,4 +194,58 @@ describe("LengthNormalizerAgent", () => {
     expect(result.normalizedContent).toBe(draft);
     expect(result.finalCount).toBe(countChapterLength(draft, "zh_chars"));
   });
+
+  it("preserves legitimate Chinese prose that starts with '我先'", async () => {
+    const agent = createAgent();
+    const prose = "我先回去了,明天再说。\n风从窗缝里灌进来。";
+    const chatSpy = vi.spyOn(BaseAgent.prototype as never, "chat").mockResolvedValue({
+      content: prose,
+      usage: ZERO_USAGE,
+    });
+    const lengthSpec = LengthSpecSchema.parse({
+      target: 220,
+      softMin: 190,
+      softMax: 250,
+      hardMin: 160,
+      hardMax: 280,
+      countingMode: "zh_chars",
+      normalizeMode: "compress",
+    });
+    const draft = "原文。".repeat(80);
+
+    const result = await agent.normalizeChapter({
+      chapterContent: draft,
+      lengthSpec,
+    });
+
+    expect(chatSpy).toHaveBeenCalledTimes(1);
+    expect(result.normalizedContent).toBe(prose);
+  });
+
+  it("preserves legitimate English prose that starts with 'I will'", async () => {
+    const agent = createAgent();
+    const prose = "I will wait here until dawn.\nThe shutters rattled in the wind.";
+    const chatSpy = vi.spyOn(BaseAgent.prototype as never, "chat").mockResolvedValue({
+      content: prose,
+      usage: ZERO_USAGE,
+    });
+    const lengthSpec = LengthSpecSchema.parse({
+      target: 220,
+      softMin: 190,
+      softMax: 250,
+      hardMin: 160,
+      hardMax: 280,
+      countingMode: "en_words",
+      normalizeMode: "compress",
+    });
+    const draft = "Original text. ".repeat(80);
+
+    const result = await agent.normalizeChapter({
+      chapterContent: draft,
+      lengthSpec,
+    });
+
+    expect(chatSpy).toHaveBeenCalledTimes(1);
+    expect(result.normalizedContent).toBe(prose);
+  });
 });

+ 64 - 0
packages/core/src/__tests__/memory-retrieval.test.ts

@@ -0,0 +1,64 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { retrieveMemorySelection } from "../utils/memory-retrieval.js";
+
+describe("retrieveMemorySelection", () => {
+  let root = "";
+
+  afterEach(async () => {
+    if (root) {
+      await rm(root, { recursive: true, force: true });
+      root = "";
+    }
+  });
+
+  it("indexes current state facts into sqlite-backed memory selection", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_state.md"),
+        [
+          "# Current State",
+          "",
+          "| Field | Value |",
+          "| --- | --- |",
+          "| Current Chapter | 9 |",
+          "| Current Location | Ashen ferry crossing |",
+          "| Protagonist State | Lin Yue hides the broken oath token and the old wound has reopened. |",
+          "| Current Goal | Find the vanished mentor before the guild covers its tracks. |",
+          "| Current Conflict | Mentor debt with the vanished teacher blocks every choice. |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+    ]);
+
+    const result = await retrieveMemorySelection({
+      bookDir,
+      chapterNumber: 10,
+      goal: "Bring the focus back to the vanished mentor conflict.",
+      mustKeep: ["Lin Yue hides the broken oath token and the old wound has reopened."],
+    });
+
+    expect(result.facts.length).toBeGreaterThan(0);
+    expect(result.facts).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          predicate: "Current Conflict",
+          object: "Mentor debt with the vanished teacher blocks every choice.",
+          validFromChapter: 9,
+          sourceChapter: 9,
+        }),
+      ]),
+    );
+    expect(result.dbPath).toContain("memory.db");
+  });
+});

+ 312 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -14,6 +14,7 @@ import { ReviserAgent, type ReviseOutput } from "../agents/reviser.js";
 import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
+import { MemoryDB } from "../state/memory-db.js";
 import { countChapterLength } from "../utils/length-metrics.js";
 
 const ZERO_USAGE = {
@@ -89,6 +90,29 @@ function createAnalyzedOutput(overrides: Partial<WriteChapterOutput> = {}): Writ
   });
 }
 
+function createStateCard(params: {
+  readonly chapter: number;
+  readonly location: string;
+  readonly protagonistState: string;
+  readonly goal: string;
+  readonly conflict: string;
+}): string {
+  return [
+    "# Current State",
+    "",
+    "| Field | Value |",
+    "| --- | --- |",
+    `| Current Chapter | ${params.chapter} |`,
+    `| Current Location | ${params.location} |`,
+    `| Protagonist State | ${params.protagonistState} |`,
+    `| Current Goal | ${params.goal} |`,
+    "| Current Constraint | The city gates are watched. |",
+    "| Current Alliances | Mentor allies are scattered. |",
+    `| Current Conflict | ${params.conflict} |`,
+    "",
+  ].join("\n");
+}
+
 async function createRunnerFixture(
   configOverrides: Partial<ConstructorParameters<typeof PipelineRunner>[0]> = {},
 ): Promise<{
@@ -348,6 +372,65 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("syncs current-state facts into memory.db after drafting a chapter", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const chapterOneState = createStateCard({
+      chapter: 1,
+      location: "Ashen ferry crossing",
+      protagonistState: "Lin Yue hides the broken oath token.",
+      goal: "Find the vanished mentor before dawn.",
+      conflict: "Mentor debt blocks every choice.",
+    });
+
+    await Promise.all([
+      writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+      writeFile(
+        join(state.bookDir(bookId), "story", "current_state.md"),
+        createStateCard({
+          chapter: 0,
+          location: "Shrine outskirts",
+          protagonistState: "Lin Yue begins with the oath token hidden.",
+          goal: "Reach the trial city.",
+          conflict: "The trial deadline is closing in.",
+        }),
+        "utf-8",
+      ),
+    ]);
+    await state.snapshotState(bookId, 0);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Draft body.",
+        wordCount: "Draft body.".length,
+        updatedState: chapterOneState,
+        updatedHooks: "# Pending Hooks\n",
+      }),
+    );
+
+    try {
+      await runner.writeDraft(bookId);
+
+      const memoryDb = new MemoryDB(state.bookDir(bookId));
+      try {
+        expect(memoryDb.getCurrentFacts()).toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({
+              predicate: "Current Conflict",
+              object: "Mentor debt blocks every choice.",
+              validFromChapter: 1,
+              sourceChapter: 1,
+            }),
+          ]),
+        );
+      } finally {
+        memoryDb.close();
+      }
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("routes writeNextChapter through planner and composer in v2 mode", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture({
       inputGovernanceMode: "v2",
@@ -905,4 +988,233 @@ describe("PipelineRunner", () => {
 
     await rm(root, { recursive: true, force: true });
   });
+
+  it("rebuilds fact history from imported chapter snapshots", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+
+    vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
+      storyBible: "# Story Bible\n",
+      volumeOutline: "# Volume Outline\n",
+      bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
+      currentState: createStateCard({
+        chapter: 0,
+        location: "Shrine outskirts",
+        protagonistState: "Lin Yue begins with the oath token hidden.",
+        goal: "Reach the trial city.",
+        conflict: "The trial deadline is closing in.",
+      }),
+      pendingHooks: "# Pending Hooks\n",
+    });
+
+    vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter")
+      .mockResolvedValueOnce(createAnalyzedOutput({
+        chapterNumber: 1,
+        title: "One",
+        content: "One body.",
+        wordCount: "One body.".length,
+        updatedState: createStateCard({
+          chapter: 1,
+          location: "Ashen ferry crossing",
+          protagonistState: "Lin Yue still hides the oath token.",
+          goal: "Find the vanished mentor.",
+          conflict: "The mentor debt is still personal.",
+        }),
+        updatedHooks: "# Pending Hooks\n",
+      }))
+      .mockResolvedValueOnce(createAnalyzedOutput({
+        chapterNumber: 2,
+        title: "Two",
+        content: "Two body.",
+        wordCount: "Two body.".length,
+        updatedState: createStateCard({
+          chapter: 2,
+          location: "North watchtower",
+          protagonistState: "Lin Yue finally shows the oath token.",
+          goal: "Reach the watchtower before the guild.",
+          conflict: "The merchant guild now contests the mentor trail.",
+        }),
+        updatedHooks: "# Pending Hooks\n",
+      }));
+
+    try {
+      await runner.importChapters({
+        bookId,
+        chapters: [
+          { title: "One", content: "One body." },
+          { title: "Two", content: "Two body." },
+        ],
+      });
+
+      const memoryDb = new MemoryDB(state.bookDir(bookId));
+      try {
+        const chapterOneFacts = memoryDb.getFactsAt("protagonist", 1);
+        const currentFacts = memoryDb.getCurrentFacts();
+
+        expect(chapterOneFacts).toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({
+              predicate: "Current Conflict",
+              object: "The mentor debt is still personal.",
+            }),
+          ]),
+        );
+        expect(currentFacts).toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({
+              predicate: "Current Conflict",
+              object: "The merchant guild now contests the mentor trail.",
+              validFromChapter: 2,
+              sourceChapter: 2,
+            }),
+          ]),
+        );
+      } finally {
+        memoryDb.close();
+      }
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("tracks imported English chapters using word counts instead of characters", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const englishBook = {
+      ...(await state.loadBookConfig(bookId)),
+      genre: "other",
+      language: "en" as const,
+    };
+    const now = "2026-03-19T00:00:00.000Z";
+
+    await state.saveBookConfig(bookId, englishBook);
+    await state.saveChapterIndex(bookId, [
+      {
+        number: 1,
+        title: "Prelude",
+        status: "imported",
+        wordCount: 3,
+        createdAt: now,
+        updatedAt: now,
+        auditIssues: [],
+        lengthWarnings: [],
+      },
+      {
+        number: 2,
+        title: "Crossroads",
+        status: "imported",
+        wordCount: 2,
+        createdAt: now,
+        updatedAt: now,
+        auditIssues: [],
+        lengthWarnings: [],
+      },
+    ]);
+
+    vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockImplementation(async (input) =>
+      createAnalyzedOutput({
+        chapterNumber: input.chapterNumber,
+        title: input.chapterTitle ?? `Chapter ${input.chapterNumber}`,
+        content: input.chapterContent,
+        wordCount: countChapterLength(input.chapterContent, "en_words"),
+      }),
+    );
+    vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
+    vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
+
+    const result = await runner.importChapters({
+      bookId,
+      resumeFrom: 3,
+      chapters: [
+        { title: "Prelude", content: "One two three" },
+        { title: "Crossroads", content: "Four five" },
+        { title: "The Watchtower", content: "The storm kept rolling west" },
+        { title: "Aftermath", content: "Lanterns dimmed before dawn broke" },
+      ],
+    });
+
+    const chapterIndex = await state.loadChapterIndex(bookId);
+    const chapterThree = chapterIndex.find((entry) => entry.number === 3);
+    const chapterFour = chapterIndex.find((entry) => entry.number === 4);
+
+    expect(result.importedCount).toBe(2);
+    expect(result.totalWords).toBe(10);
+    expect(chapterThree?.wordCount).toBe(5);
+    expect(chapterFour?.wordCount).toBe(5);
+
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("rebuilds current facts from the revised chapter snapshot", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    const oldState = createStateCard({
+      chapter: 1,
+      location: "Ashen ferry crossing",
+      protagonistState: "Lin Yue still hides the oath token.",
+      goal: "Find the vanished mentor.",
+      conflict: "The mentor debt is still personal.",
+    });
+    const revisedState = createStateCard({
+      chapter: 1,
+      location: "Ashen ferry crossing",
+      protagonistState: "Lin Yue no longer hides the oath token.",
+      goal: "Confront the vanished mentor.",
+      conflict: "The oath token is public now, forcing the confrontation.",
+    });
+
+    await Promise.all([
+      writeFile(join(chaptersDir, "0001_Test_Chapter.md"), "# 第1章 Test Chapter\n\nOriginal body.", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), oldState, "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+    ]);
+    await state.saveChapterIndex(bookId, [{
+      number: 1,
+      title: "Test Chapter",
+      status: "audit-failed",
+      wordCount: "Original body.".length,
+      createdAt: "2026-03-19T00:00:00.000Z",
+      updatedAt: "2026-03-19T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    }]);
+    await state.snapshotState(bookId, 1);
+
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: false,
+        issues: [CRITICAL_ISSUE],
+        summary: "needs revision",
+      }),
+    );
+    vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: "Revised body.",
+        wordCount: "Revised body.".length,
+        updatedState: revisedState,
+        updatedHooks: "# Pending Hooks\n",
+      }),
+    );
+
+    try {
+      await runner.reviseDraft(bookId, 1);
+
+      const memoryDb = new MemoryDB(state.bookDir(bookId));
+      try {
+        expect(memoryDb.getCurrentFacts()).toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({
+              predicate: "Current Conflict",
+              object: "The oath token is public now, forcing the confrontation.",
+              validFromChapter: 1,
+              sourceChapter: 1,
+            }),
+          ]),
+        );
+      } finally {
+        memoryDb.close();
+      }
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
 });

+ 58 - 0
packages/core/src/__tests__/planner.test.ts

@@ -132,4 +132,62 @@ describe("PlannerAgent", () => {
     expect(result.intent.conflicts[0]?.type).toBe("outline_vs_request");
     await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("outline_vs_request");
   });
+
+  it("writes compact memory snapshots instead of inlining the full history", async () => {
+    await Promise.all([
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "# Pending Hooks",
+          "",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
+          "| mentor-oath | 8 | relationship | open | 9 | 11 | Mentor oath debt with Lin Yue |",
+          "| old-seal | 3 | artifact | resolved | 3 | 3 | Jade seal already recovered |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "# Chapter Summaries",
+          "",
+          "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
+          "| 2 | City Watch | Patrols sweep the market | Search widens | None | guild-route advanced | urgent | investigation |",
+          "| 3 | Seal Vault | Lin Yue finds the seal vault | The jade seal returns | Seal secured | old-seal resolved | solemn | reveal |",
+          "| 4 | Empty Road | The group loses the convoy | Doubts grow | Travel fatigue | none | grim | travel |",
+          "| 5 | Burned Shrine | Shrine clues point nowhere | Friction rises | Lin Yue distrusts allies | none | bitter | setback |",
+          "| 6 | Quiet Ledger | Merchant records stay hidden | No breakthrough | Cash runs thin | none | weary | transition |",
+          "| 7 | Broken Letter | A torn letter mentions the mentor | Suspicion returns | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
+          "| 8 | River Camp | Lin Yue meets old witnesses | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
+          "| 9 | Trial Echo | The trial fallout resurfaces | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
+          "| 10 | Locked Gate | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | Oath token is still hidden | mentor-oath advanced | focused | decision |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 11,
+      externalContext: "Bring the focus back to the mentor oath conflict with Lin Yue.",
+    });
+
+    const intentMarkdown = await readFile(result.runtimePath, "utf-8");
+    expect(intentMarkdown).toContain("mentor-oath");
+    expect(intentMarkdown).toContain("| 10 | Locked Gate |");
+    expect(intentMarkdown).not.toContain("| 1 | Guild Trail |");
+    expect(intentMarkdown).not.toContain("| old-seal | 3 | artifact | resolved |");
+  });
 });

+ 125 - 1
packages/core/src/__tests__/reviser.test.ts

@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, it, vi } from "vitest";
-import { mkdtemp, mkdir, rm } from "node:fs/promises";
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { ReviserAgent } from "../agents/reviser.js";
@@ -88,4 +88,128 @@ describe("ReviserAgent", () => {
       await rm(root, { recursive: true, force: true });
     }
   });
+
+  it("uses selected summary and hook evidence instead of full long-history markdown in governed mode", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-reviser-governed-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "# Pending Hooks",
+          "",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
+          "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "# Chapter Summaries",
+          "",
+          "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
+          "| 99 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nTrack the merchant guild trail.\n", "utf-8"),
+      writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
+      writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
+      writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
+    ]);
+
+    const agent = new ReviserAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const chatSpy = vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: [
+        "=== FIXED_ISSUES ===",
+        "- repaired",
+        "",
+        "=== REVISED_CONTENT ===",
+        "修订后的正文。",
+        "",
+        "=== UPDATED_STATE ===",
+        "状态卡",
+        "",
+        "=== UPDATED_HOOKS ===",
+        "伏笔池",
+      ].join("\n"),
+      usage: ZERO_USAGE,
+    });
+
+    try {
+      await agent.reviseChapter(
+        bookDir,
+        "原始正文。",
+        100,
+        [CRITICAL_ISSUE],
+        "spot-fix",
+        "xuanhuan",
+        {
+          chapterIntent: "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor oath conflict.\n",
+          contextPackage: {
+            chapter: 100,
+            selectedContext: [
+              {
+                source: "story/chapter_summaries.md#99",
+                reason: "Relevant episodic memory.",
+                excerpt: "Trial Echo | Mentor left without explanation | mentor-oath advanced",
+              },
+              {
+                source: "story/pending_hooks.md#mentor-oath",
+                reason: "Carry forward unresolved hook.",
+                excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
+              },
+            ],
+          },
+          ruleStack: {
+            layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
+            sections: {
+              hard: ["current_state"],
+              soft: ["current_focus"],
+              diagnostic: ["continuity_audit"],
+            },
+            overrideEdges: [],
+            activeOverrides: [],
+          },
+          lengthSpec: buildLengthSpec(220, "zh"),
+        },
+      );
+
+      const messages = chatSpy.mock.calls[0]?.[0] as
+        | ReadonlyArray<{ content: string }>
+        | undefined;
+      const userPrompt = messages?.[1]?.content ?? "";
+
+      expect(userPrompt).toContain("story/chapter_summaries.md#99");
+      expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath");
+      expect(userPrompt).not.toContain("| 1 | Guild Trail |");
+      expect(userPrompt).not.toContain("guild-route | 1 | mystery");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
 });

+ 163 - 0
packages/core/src/__tests__/writer.test.ts

@@ -0,0 +1,163 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { WriterAgent } from "../agents/writer.js";
+import { buildLengthSpec } from "../utils/length-metrics.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+describe("WriterAgent", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("uses compact summary context plus selected long-range evidence during governed settlement", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-writer-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nTrack the merchant guild trail.\n", "utf-8"),
+      writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), [
+        "# Pending Hooks",
+        "",
+        "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+        "| --- | --- | --- | --- | --- | --- | --- |",
+        "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
+        "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
+      ].join("\n"), "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), [
+        "# Chapter Summaries",
+        "",
+        "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
+        "| 97 | Shrine Ash | Lin Yue | The old shrine proves empty | Frustration rises | none | bitter | setback |",
+        "| 98 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
+        "| 99 | Locked Gate | Lin Yue | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | mentor-oath advanced | focused | decision |",
+      ].join("\n"), "utf-8"),
+      writeFile(join(storyDir, "subplot_board.md"), "# 支线进度板\n", "utf-8"),
+      writeFile(join(storyDir, "emotional_arcs.md"), "# 情感弧线\n", "utf-8"),
+      writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
+    ]);
+
+    const agent = new WriterAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
+      .mockResolvedValueOnce({
+        content: [
+          "=== CHAPTER_TITLE ===",
+          "A Decision",
+          "",
+          "=== CHAPTER_CONTENT ===",
+          "Lin Yue turned away from the guild trail and chose the mentor debt.",
+          "",
+          "=== PRE_WRITE_CHECK ===",
+          "- ok",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: "=== OBSERVATIONS ===\n- observed",
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: [
+          "=== POST_SETTLEMENT ===",
+          "| 伏笔变动 | mentor-oath 推进 | 同步更新伏笔池 |",
+          "",
+          "=== UPDATED_STATE ===",
+          "状态卡",
+          "",
+          "=== UPDATED_HOOKS ===",
+          "伏笔池",
+          "",
+          "=== CHAPTER_SUMMARY ===",
+          "| 100 | A Decision | Lin Yue | Chooses the mentor debt | Focus narrowed | mentor-oath advanced | tense | decision |",
+          "",
+          "=== UPDATED_SUBPLOTS ===",
+          "支线板",
+          "",
+          "=== UPDATED_EMOTIONAL_ARCS ===",
+          "情感弧线",
+          "",
+          "=== UPDATED_CHARACTER_MATRIX ===",
+          "角色矩阵",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    try {
+      await agent.writeChapter({
+        book: {
+          id: "writer-book",
+          title: "Writer Book",
+          platform: "tomato",
+          genre: "xuanhuan",
+          status: "active",
+          targetChapters: 120,
+          chapterWordCount: 2200,
+          createdAt: "2026-03-23T00:00:00.000Z",
+          updatedAt: "2026-03-23T00:00:00.000Z",
+        },
+        bookDir,
+        chapterNumber: 100,
+        chapterIntent: "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor oath conflict.\n",
+        contextPackage: {
+          chapter: 100,
+          selectedContext: [
+            {
+              source: "story/chapter_summaries.md#99",
+              reason: "Relevant episodic memory.",
+              excerpt: "Locked Gate | Lin Yue chooses the mentor line over the guild line | mentor-oath advanced",
+            },
+            {
+              source: "story/pending_hooks.md#mentor-oath",
+              reason: "Carry forward unresolved hook.",
+              excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
+            },
+          ],
+        },
+        ruleStack: {
+          layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
+          sections: {
+            hard: ["current_state"],
+            soft: ["current_focus"],
+            diagnostic: ["continuity_audit"],
+          },
+          overrideEdges: [],
+          activeOverrides: [],
+        },
+        lengthSpec: buildLengthSpec(220, "zh"),
+      });
+
+      const settlePrompt = (chatSpy.mock.calls[2]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
+      expect(settlePrompt).toContain("story/chapter_summaries.md#99");
+      expect(settlePrompt).toContain("| 99 | Locked Gate |");
+      expect(settlePrompt).not.toContain("| 1 | Guild Trail |");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+});

+ 3 - 1
packages/core/src/agents/chapter-analyzer.ts

@@ -3,6 +3,7 @@ import type { BookConfig } from "../models/book.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { parseWriterOutput, type ParsedWriterOutput } from "./writer-parser.js";
+import { resolveLengthCountingMode } from "../utils/length-metrics.js";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -71,7 +72,8 @@ export class ChapterAnalyzerAgent extends BaseAgent {
       { maxTokens: 16384, temperature: 0.3 },
     );
 
-    const output = parseWriterOutput(chapterNumber, response.content, genreProfile);
+    const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
+    const output = parseWriterOutput(chapterNumber, response.content, genreProfile, countingMode);
 
     // If LLM didn't return a title, use the one from input or derive from chapter number
     if (output.title === `第${chapterNumber}章` && chapterTitle) {

+ 46 - 7
packages/core/src/agents/composer.ts

@@ -1,5 +1,5 @@
 import { readFile, writeFile, mkdir } from "node:fs/promises";
-import { join } from "node:path";
+import { dirname, join } from "node:path";
 import yaml from "js-yaml";
 import { BaseAgent } from "./base.js";
 import type { BookConfig } from "../models/book.js";
@@ -12,6 +12,7 @@ import {
   type RuleStack,
 } from "../models/input-governance.js";
 import type { PlanChapterOutput } from "./planner.js";
+import { retrieveMemorySelection } from "../utils/memory-retrieval.js";
 
 export interface ComposeChapterInput {
   readonly book: BookConfig;
@@ -120,14 +121,43 @@ export class ComposerAgent extends BaseAgent {
         "Anchor the default planning node for this chapter.",
         plan.intent.outlineNode ? [plan.intent.outlineNode] : [],
       ),
-      this.maybeContextSource(
-        storyDir,
-        "pending_hooks.md",
-        "Carry forward unresolved hooks that match the chapter focus.",
-      ),
     ]);
 
-    return entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
+    const planningAnchor = plan.intent.conflicts.length > 0 ? undefined : plan.intent.outlineNode;
+    const memorySelection = await retrieveMemorySelection({
+      bookDir: dirname(storyDir),
+      chapterNumber: plan.intent.chapter,
+      goal: plan.intent.goal,
+      outlineNode: planningAnchor,
+      mustKeep: plan.intent.mustKeep,
+    });
+
+    const summaryEntries = memorySelection.summaries.map((summary) => ({
+      source: `story/chapter_summaries.md#${summary.chapter}`,
+      reason: "Relevant episodic memory retrieved for the current chapter goal.",
+      excerpt: [summary.title, summary.events, summary.stateChanges, summary.hookActivity]
+        .filter(Boolean)
+        .join(" | "),
+    }));
+    const factEntries = memorySelection.facts.map((fact) => ({
+      source: `story/current_state.md#${this.toFactAnchor(fact.predicate)}`,
+      reason: "Relevant current-state fact retrieved for the current chapter goal.",
+      excerpt: `${fact.predicate} | ${fact.object}`,
+    }));
+    const hookEntries = memorySelection.hooks.map((hook) => ({
+      source: `story/pending_hooks.md#${hook.hookId}`,
+      reason: "Carry forward unresolved hooks that match the chapter focus.",
+      excerpt: [hook.type, hook.status, hook.expectedPayoff, hook.notes]
+        .filter(Boolean)
+        .join(" | "),
+    }));
+
+    return [
+      ...entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
+      ...factEntries,
+      ...summaryEntries,
+      ...hookEntries,
+    ];
   }
 
   private async maybeContextSource(
@@ -158,6 +188,15 @@ export class ComposerAgent extends BaseAgent {
       .find((line) => line.length > 0 && !line.startsWith("#"));
   }
 
+  private toFactAnchor(predicate: string): string {
+    return predicate
+      .trim()
+      .toLowerCase()
+      .replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
+      .replace(/^-+|-+$/g, "")
+      || "fact";
+  }
+
   private async readFileOrDefault(path: string): Promise<string> {
     try {
       return await readFile(path, "utf-8");

+ 14 - 6
packages/core/src/agents/continuity.ts

@@ -7,6 +7,7 @@ import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions.js";
 import { readFile, readdir } from "node:fs/promises";
 import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
+import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
 import { join } from "node:path";
 
 export interface AuditResult {
@@ -317,6 +318,14 @@ ${dimList}
     const filteredSummaries = filterSummaries(chapterSummaries, chapterNumber);
     const filteredHooks = filterHooks(hooks);
 
+    const governedMemoryBlocks = options?.contextPackage
+      ? buildGovernedMemoryEvidenceBlocks(options.contextPackage)
+      : undefined;
+
+    const hooksBlock = governedMemoryBlocks?.hooksBlock
+      ?? (filteredHooks !== "(文件不存在)"
+        ? `\n## 伏笔池\n${filteredHooks}\n`
+        : "");
     const subplotBlock = filteredSubplots !== "(文件不存在)"
       ? `\n## 支线进度板\n${filteredSubplots}\n`
       : "";
@@ -326,9 +335,10 @@ ${dimList}
     const matrixBlock = filteredMatrix !== "(文件不存在)"
       ? `\n## 角色交互矩阵\n${filteredMatrix}\n`
       : "";
-    const summariesBlock = filteredSummaries !== "(文件不存在)"
-      ? `\n## 章节摘要(用于节奏检查)\n${filteredSummaries}\n`
-      : "";
+    const summariesBlock = governedMemoryBlocks?.summariesBlock
+      ?? (filteredSummaries !== "(文件不存在)"
+        ? `\n## 章节摘要(用于节奏检查)\n${filteredSummaries}\n`
+        : "");
 
     const canonBlock = hasParentCanon
       ? `\n## 正传正典参照(番外审查专用)\n${parentCanon}\n`
@@ -357,9 +367,7 @@ ${dimList}
 ## 当前状态卡
 ${currentState}
 ${ledgerBlock}
-## 伏笔池
-${filteredHooks}
-${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${reducedControlBlock || outlineBlock}${prevChapterBlock}${styleGuideBlock}
+${hooksBlock}${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${reducedControlBlock || outlineBlock}${prevChapterBlock}${styleGuideBlock}
 
 ## 待审章节内容
 ${chapterContent}`;

+ 43 - 16
packages/core/src/agents/length-normalizer.ts

@@ -137,8 +137,8 @@ ${input.chapterContent}`;
     const fenced = this.extractFirstFencedBlock(trimmed);
     if (fenced) return fenced;
 
-    if (this.looksLikeResponseWrapper(trimmed)) {
-      const stripped = this.stripCommonWrappers(trimmed);
+    const stripped = this.stripCommonWrappers(trimmed);
+    if (stripped !== undefined) {
       return stripped || fallbackContent;
     }
 
@@ -152,21 +152,48 @@ ${input.chapterContent}`;
     return body ? body : undefined;
   }
 
-  private looksLikeResponseWrapper(content: string): boolean {
-    return /```/.test(content) || /^(我先|下面是|以下是|Here is|I will)/i.test(content);
+  private stripCommonWrappers(content: string): string | undefined {
+    const lines = content.split("\n");
+    let removedAny = false;
+    const keptLines: string[] = [];
+
+    for (const rawLine of lines) {
+      const trimmed = rawLine.trim();
+      if (this.isWrapperLine(trimmed)) {
+        removedAny = true;
+        continue;
+      }
+      keptLines.push(rawLine);
+    }
+
+    if (!removedAny) {
+      return undefined;
+    }
+
+    return keptLines.join("\n").trim();
   }
 
-  private stripCommonWrappers(content: string): string {
-    return content
-      .split("\n")
-      .map((line) => line.trim())
-      .filter((line) =>
-        line.length > 0 &&
-        !/^```/.test(line) &&
-        !/^(我先|下面是|以下是|Here is|I will)/i.test(line) &&
-        !/^#+\s*(说明|解释|注释|analysis|analysis note)/i.test(line),
-      )
-      .join("\n")
-      .trim();
+  private isWrapperLine(line: string): boolean {
+    if (!line) return false;
+    if (/^```/.test(line)) return true;
+    if (/^#+\s*(说明|解释|注释|analysis|analysis note)\b/i.test(line)) return true;
+
+    if (/^(下面是|以下是).*(正文|章节|压缩|扩写|修正|修改|调整|改写|润色|结果|内容|输出|版本)/i.test(line)) {
+      return true;
+    }
+
+    if (/^我先.*(压缩|扩写|修正|修改|调整|改写|润色|处理).*(正文|章节)?/i.test(line)) {
+      return true;
+    }
+
+    if (/^(here(?:'s| is)|below is).*(chapter|draft|content|rewrite|revised|compressed|expanded|normalized|adjusted|output|version|result)/i.test(line)) {
+      return true;
+    }
+
+    if (/^i(?:'ll| will)\s+(rewrite|revise|reword|compress|expand|normalize|adjust|shorten|lengthen|trim|fix)\b/i.test(line)) {
+      return true;
+    }
+
+    return false;
   }
 }

+ 20 - 8
packages/core/src/agents/planner.ts

@@ -4,6 +4,7 @@ import { BaseAgent } from "./base.js";
 import type { BookConfig } from "../models/book.js";
 import { parseBookRules } from "../models/book-rules.js";
 import { ChapterIntentSchema, type ChapterConflict, type ChapterIntent } from "../models/input-governance.js";
+import { renderHookSnapshot, renderSummarySnapshot, retrieveMemorySelection } from "../utils/memory-retrieval.js";
 
 export interface PlanChapterInput {
   readonly book: BookConfig;
@@ -36,8 +37,6 @@ export class PlannerAgent extends BaseAgent {
       volumeOutline: join(storyDir, "volume_outline.md"),
       bookRules: join(storyDir, "book_rules.md"),
       currentState: join(storyDir, "current_state.md"),
-      pendingHooks: join(storyDir, "pending_hooks.md"),
-      chapterSummaries: join(storyDir, "chapter_summaries.md"),
     } as const;
 
     const [
@@ -47,8 +46,6 @@ export class PlannerAgent extends BaseAgent {
       volumeOutline,
       bookRulesRaw,
       currentState,
-      pendingHooks,
-      chapterSummaries,
     ] = await Promise.all([
       this.readFileOrDefault(sourcePaths.authorIntent),
       this.readFileOrDefault(sourcePaths.currentFocus),
@@ -56,8 +53,6 @@ export class PlannerAgent extends BaseAgent {
       this.readFileOrDefault(sourcePaths.volumeOutline),
       this.readFileOrDefault(sourcePaths.bookRules),
       this.readFileOrDefault(sourcePaths.currentState),
-      this.readFileOrDefault(sourcePaths.pendingHooks),
-      this.readFileOrDefault(sourcePaths.chapterSummaries),
     ]);
 
     const goal = this.deriveGoal(input.externalContext, currentFocus, authorIntent, input.chapterNumber);
@@ -67,6 +62,14 @@ export class PlannerAgent extends BaseAgent {
     const mustAvoid = this.collectMustAvoid(currentFocus, parsedRules.rules.prohibitions);
     const styleEmphasis = this.collectStyleEmphasis(authorIntent, currentFocus);
     const conflicts = this.collectConflicts(input.externalContext, outlineNode, volumeOutline);
+    const planningAnchor = conflicts.length > 0 ? undefined : outlineNode;
+    const memorySelection = await retrieveMemorySelection({
+      bookDir: input.bookDir,
+      chapterNumber: input.chapterNumber,
+      goal,
+      outlineNode: planningAnchor,
+      mustKeep,
+    });
 
     const intent = ChapterIntentSchema.parse({
       chapter: input.chapterNumber,
@@ -79,13 +82,22 @@ export class PlannerAgent extends BaseAgent {
     });
 
     const runtimePath = join(runtimeDir, `chapter-${String(input.chapterNumber).padStart(4, "0")}.intent.md`);
-    const intentMarkdown = this.renderIntentMarkdown(intent, pendingHooks, chapterSummaries);
+    const intentMarkdown = this.renderIntentMarkdown(
+      intent,
+      renderHookSnapshot(memorySelection.hooks),
+      renderSummarySnapshot(memorySelection.summaries),
+    );
     await writeFile(runtimePath, intentMarkdown, "utf-8");
 
     return {
       intent,
       intentMarkdown,
-      plannerInputs: Object.values(sourcePaths),
+      plannerInputs: [
+        ...Object.values(sourcePaths),
+        join(storyDir, "pending_hooks.md"),
+        join(storyDir, "chapter_summaries.md"),
+        ...(memorySelection.dbPath ? [memorySelection.dbPath] : []),
+      ],
       runtimePath,
     };
   }

+ 11 - 6
packages/core/src/agents/reviser.ts

@@ -6,6 +6,7 @@ import type { AuditIssue } from "./continuity.js";
 import type { ContextPackage, RuleStack } from "../models/input-governance.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { countChapterLength } from "../utils/length-metrics.js";
+import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -137,6 +138,11 @@ ${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账
     const ledgerBlock = gp.numericalSystem
       ? `\n## 资源账本\n${ledger}`
       : "";
+    const governedMemoryBlocks = options?.contextPackage
+      ? buildGovernedMemoryEvidenceBlocks(options.contextPackage)
+      : undefined;
+    const hooksBlock = governedMemoryBlocks?.hooksBlock
+      ?? `\n## 伏笔池\n${hooks}\n`;
     const outlineBlock = volumeOutline !== "(文件不存在)"
       ? `\n## 卷纲\n${volumeOutline}\n`
       : "";
@@ -146,9 +152,10 @@ ${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账
     const matrixBlock = characterMatrix !== "(文件不存在)"
       ? `\n## 角色交互矩阵\n${characterMatrix}\n`
       : "";
-    const summariesBlock = chapterSummaries !== "(文件不存在)"
-      ? `\n## 章节摘要\n${chapterSummaries}\n`
-      : "";
+    const summariesBlock = governedMemoryBlocks?.summariesBlock
+      ?? (chapterSummaries !== "(文件不存在)"
+        ? `\n## 章节摘要\n${chapterSummaries}\n`
+        : "");
 
     const hasParentCanon = parentCanon !== "(文件不存在)";
     const hasFanficCanon = fanficCanon !== "(文件不存在)";
@@ -178,9 +185,7 @@ ${issueList}
 ## 当前状态卡
 ${currentState}
 ${ledgerBlock}
-## 伏笔池
-${hooks}
-${reducedControlBlock || outlineBlock}${bibleBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${styleGuideBlock}${lengthGuidanceBlock}
+${hooksBlock}${reducedControlBlock || outlineBlock}${bibleBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${styleGuideBlock}${lengthGuidanceBlock}
 
 ## 待修正章节
 ${chapterContent}`;

+ 5 - 1
packages/core/src/agents/settler-prompts.ts

@@ -143,6 +143,7 @@ export function buildSettlerUserPrompt(params: {
   readonly characterMatrix: string;
   readonly volumeOutline: string;
   readonly observations?: string;
+  readonly selectedEvidenceBlock?: string;
 }): string {
   const ledgerBlock = params.ledger
     ? `\n## 当前资源账本\n${params.ledger}\n`
@@ -167,6 +168,9 @@ export function buildSettlerUserPrompt(params: {
   const observationsBlock = params.observations
     ? `\n## 观察日志(由 Observer 提取,包含本章所有事实变化)\n${params.observations}\n\n基于以上观察日志和正文,更新所有追踪文件。确保观察日志中的每一项变化都反映在对应的文件中。\n`
     : "";
+  const selectedEvidenceBlock = params.selectedEvidenceBlock
+    ? `\n## 已选长程证据\n${params.selectedEvidenceBlock}\n`
+    : "";
 
   return `请分析第${params.chapterNumber}章「${params.title}」的正文,更新所有追踪文件。
 ${observationsBlock}
@@ -179,7 +183,7 @@ ${params.currentState}
 ${ledgerBlock}
 ## 当前伏笔池
 ${params.hooks}
-${summariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}
+${selectedEvidenceBlock}${summariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}
 ## 卷纲
 ${params.volumeOutline}
 

+ 12 - 1
packages/core/src/agents/writer.ts

@@ -13,6 +13,7 @@ import type { ChapterTrace, ContextPackage, RuleStack } from "../models/input-go
 import type { LengthSpec } from "../models/length-governance.js";
 import { buildLengthSpec } from "../utils/length-metrics.js";
 import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
+import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
 import { extractPOVFromOutline, filterMatrixByPOV, filterHooksByPOV } from "../utils/pov-filter.js";
 import { parseCreativeOutput } from "./writer-parser.js";
 import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
@@ -106,6 +107,9 @@ export class WriterAgent extends BaseAgent {
     const resolvedLanguage = book.language ?? genreProfile.language;
     const targetWords = input.lengthSpec?.target ?? input.wordCountOverride ?? book.chapterWordCount;
     const resolvedLengthSpec = input.lengthSpec ?? buildLengthSpec(targetWords, resolvedLanguage);
+    const governedMemoryBlocks = input.contextPackage
+      ? buildGovernedMemoryEvidenceBlocks(input.contextPackage)
+      : undefined;
 
     // Build fanfic context if fanfic_canon.md exists
     const fanficContext: FanficContext | undefined = hasFanficCanon && bookRules?.fanficMode
@@ -204,11 +208,16 @@ export class WriterAgent extends BaseAgent {
       currentState,
       ledger: genreProfile.numericalSystem ? ledger : "",
       hooks,
-      chapterSummaries,
+      chapterSummaries: input.contextPackage ? filterSummaries(chapterSummaries, chapterNumber) : chapterSummaries,
       subplotBoard,
       emotionalArcs,
       characterMatrix,
       volumeOutline,
+      selectedEvidenceBlock: governedMemoryBlocks
+        ? [governedMemoryBlocks.hooksBlock, governedMemoryBlocks.summariesBlock]
+          .filter(Boolean)
+          .join("\n")
+        : undefined,
     });
     const settlement = settleResult.settlement;
     const settleUsage = settleResult.usage;
@@ -279,6 +288,7 @@ export class WriterAgent extends BaseAgent {
     readonly emotionalArcs: string;
     readonly characterMatrix: string;
     readonly volumeOutline: string;
+    readonly selectedEvidenceBlock?: string;
   }): Promise<{ settlement: ReturnType<typeof parseSettlementOutput>; usage: TokenUsage }> {
     // Phase 2a: Observer — extract all facts from the chapter
     const resolvedLang = params.book.language ?? params.genreProfile.language;
@@ -314,6 +324,7 @@ export class WriterAgent extends BaseAgent {
       characterMatrix: params.characterMatrix,
       volumeOutline: params.volumeOutline,
       observations,
+      selectedEvidenceBlock: params.selectedEvidenceBlock,
     });
 
     // Settler outputs all truth files — scale with content size

+ 1 - 1
packages/core/src/index.ts

@@ -71,7 +71,7 @@ export { ConsolidatorAgent } from "./agents/consolidator.js";
 export { MemoryDB, type Fact, type StoredSummary } from "./state/memory-db.js";
 export { StateValidatorAgent } from "./agents/state-validator.js";
 export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js";
-export { countChapterLength, buildLengthSpec, isOutsideSoftRange, isOutsideHardRange, chooseNormalizeMode, type LengthLanguage } from "./utils/length-metrics.js";
+export { countChapterLength, resolveLengthCountingMode, formatLengthCount, buildLengthSpec, isOutsideSoftRange, isOutsideHardRange, chooseNormalizeMode, type LengthLanguage } from "./utils/length-metrics.js";
 export { createLogger, createStderrSink, createJsonLineSink, nullSink, type Logger, type LogSink, type LogLevel, type LogEntry } from "./utils/logger.js";
 export { loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH } from "./utils/config-loader.js";
 export { computeAnalytics, type AnalyticsData, type TokenStats } from "./utils/analytics.js";

+ 77 - 7
packages/core/src/pipeline/runner.ts

@@ -20,13 +20,15 @@ import { readGenreProfile } from "../agents/rules-reader.js";
 import { analyzeAITells } from "../agents/ai-tells.js";
 import { analyzeSensitiveWords } from "../agents/sensitive-words.js";
 import { StateManager } from "../state/manager.js";
+import { MemoryDB, type Fact } from "../state/memory-db.js";
 import { dispatchNotification, dispatchWebhookEvent } from "../notify/dispatcher.js";
 import type { WebhookEvent } from "../notify/webhook.js";
 import type { AgentContext } from "../agents/base.js";
 import type { AuditResult, AuditIssue } from "../agents/continuity.js";
 import type { RadarResult } from "../agents/radar.js";
 import type { LengthSpec, LengthTelemetry } from "../models/length-governance.js";
-import { buildLengthSpec, countChapterLength, isOutsideHardRange, isOutsideSoftRange } from "../utils/length-metrics.js";
+import { buildLengthSpec, countChapterLength, formatLengthCount, isOutsideHardRange, isOutsideSoftRange, resolveLengthCountingMode } from "../utils/length-metrics.js";
+import { parseCurrentStateFacts } from "../utils/memory-retrieval.js";
 import { readFile, readdir, writeFile, mkdir } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -390,6 +392,7 @@ export class PipelineRunner {
 
       // Snapshot
       await this.state.snapshotState(bookId, chapterNumber);
+      await this.syncCurrentStateFactHistory(bookId, chapterNumber);
 
       await this.emitWebhook("chapter-complete", bookId, chapterNumber, {
         title: draftOutput.title,
@@ -530,12 +533,17 @@ export class PipelineRunner {
       const content = await this.readChapterContent(bookDir, targetChapter);
       const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
       const auditResult = await auditor.auditChapter(bookDir, content, targetChapter, book.genre);
+      const { profile: gp } = await this.loadGenreProfile(book.genre);
+      const countingMode = resolveLengthCountingMode(book.language ?? gp.language);
 
       if (auditResult.passed && auditResult.issues.filter(i => i.severity === "warning" || i.severity === "critical").length === 0) {
-        return { chapterNumber: targetChapter, wordCount: content.length, fixedIssues: [] };
+        return {
+          chapterNumber: targetChapter,
+          wordCount: countChapterLength(content, countingMode),
+          fixedIssues: [],
+        };
       }
 
-      const { profile: gp } = await this.loadGenreProfile(book.genre);
       const lengthSpec = buildLengthSpec(
         book.chapterWordCount,
         book.language ?? gp.language,
@@ -625,6 +633,7 @@ export class PipelineRunner {
 
       // Re-snapshot
       await this.state.snapshotState(bookId, targetChapter);
+      await this.syncCurrentStateFactHistory(bookId, targetChapter);
 
       await this.emitWebhook("revision-complete", bookId, targetChapter, {
         wordCount: normalizedRevision.wordCount,
@@ -985,14 +994,16 @@ export class PipelineRunner {
 
     // 5.6 Snapshot state for rollback support
     await this.state.snapshotState(bookId, chapterNumber);
+    await this.syncCurrentStateFactHistory(bookId, chapterNumber);
 
     // 6. Send notification
     if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
       const statusEmoji = auditResult.passed ? "✅" : "⚠️";
+      const chapterLength = formatLengthCount(finalWordCount, lengthSpec.countingMode);
       await dispatchNotification(this.config.notifyChannels, {
         title: `${statusEmoji} ${book.title} 第${chapterNumber}章`,
         body: [
-          `**${persistenceOutput.title}** | ${finalWordCount}字`,
+          `**${persistenceOutput.title}** | ${chapterLength}`,
           revised ? "📝 已自动修正" : "",
           `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`,
           ...auditResult.issues
@@ -1262,6 +1273,7 @@ ${matrix}`,
       log?.info(`Step 2: Sequential replay from chapter ${startFrom}...`);
       const analyzer = new ChapterAnalyzerAgent(this.agentCtxFor("chapter-analyzer", input.bookId));
       const writer = new WriterAgent(this.agentCtxFor("writer", input.bookId));
+      const countingMode = resolveLengthCountingMode(book.language ?? gp.language);
       let totalWords = 0;
       let importedCount = 0;
 
@@ -1297,11 +1309,12 @@ ${matrix}`,
         // Update chapter index
         const existingIndex = await this.state.loadChapterIndex(input.bookId);
         const now = new Date().toISOString();
+        const chapterWordCount = countChapterLength(ch.content, countingMode);
         const newEntry: ChapterMeta = {
           number: chapterNumber,
           title: output.title,
           status: "imported",
-          wordCount: ch.content.length,
+          wordCount: chapterWordCount,
           createdAt: now,
           updatedAt: now,
           auditIssues: [],
@@ -1318,11 +1331,15 @@ ${matrix}`,
         await this.state.snapshotState(input.bookId, chapterNumber);
 
         importedCount++;
-        totalWords += ch.content.length;
+        totalWords += chapterWordCount;
+      }
+
+      if (input.chapters.length > 0) {
+        await this.syncCurrentStateFactHistory(input.bookId, input.chapters.length);
       }
 
       const nextChapter = input.chapters.length + 1;
-      log?.info(`Done. ${importedCount} chapters imported, ${totalWords} chars. Next chapter: ${nextChapter}`);
+      log?.info(`Done. ${importedCount} chapters imported, ${formatLengthCount(totalWords, countingMode)}. Next chapter: ${nextChapter}`);
 
       return {
         bookId: input.bookId,
@@ -1450,6 +1467,59 @@ ${matrix}`,
     };
   }
 
+  private async syncCurrentStateFactHistory(bookId: string, uptoChapter: number): Promise<void> {
+    let memoryDb: MemoryDB | null = null;
+    try {
+      const bookDir = this.state.bookDir(bookId);
+      memoryDb = new MemoryDB(bookDir);
+      memoryDb.resetFacts();
+
+      const activeFacts = new Map<string, { id: number; object: string }>();
+
+      for (let chapter = 0; chapter <= uptoChapter; chapter++) {
+        const snapshotPath = join(bookDir, "story", "snapshots", String(chapter), "current_state.md");
+        const markdown = await readFile(snapshotPath, "utf-8").catch(() => "");
+        if (!markdown) continue;
+
+        const snapshotFacts = parseCurrentStateFacts(markdown, chapter);
+        const nextFacts = new Map<string, Omit<Fact, "id">>();
+
+        for (const fact of snapshotFacts) {
+          nextFacts.set(this.factKey(fact), {
+            subject: fact.subject,
+            predicate: fact.predicate,
+            object: fact.object,
+            validFromChapter: chapter,
+            validUntilChapter: null,
+            sourceChapter: chapter,
+          });
+        }
+
+        for (const [key, previous] of activeFacts.entries()) {
+          const next = nextFacts.get(key);
+          if (!next || next.object !== previous.object) {
+            memoryDb.invalidateFact(previous.id, chapter);
+            activeFacts.delete(key);
+          }
+        }
+
+        for (const [key, fact] of nextFacts.entries()) {
+          if (activeFacts.has(key)) continue;
+          const id = memoryDb.addFact(fact);
+          activeFacts.set(key, { id, object: fact.object });
+        }
+      }
+    } catch (error) {
+      this.config.logger?.warn(`State fact sync skipped: ${String(error)}`);
+    } finally {
+      memoryDb?.close();
+    }
+  }
+
+  private factKey(fact: Pick<Fact, "subject" | "predicate">): string {
+    return `${fact.subject}::${fact.predicate}`;
+  }
+
   private buildLengthWarnings(
     chapterNumber: number,
     finalCount: number,

+ 146 - 8
packages/core/src/state/memory-db.ts

@@ -11,6 +11,16 @@
 
 import { join } from "node:path";
 
+const FACT_SELECT_COLUMNS = `
+  id,
+  subject,
+  predicate,
+  object,
+  valid_from_chapter AS validFromChapter,
+  valid_until_chapter AS validUntilChapter,
+  source_chapter AS sourceChapter
+`;
+
 export interface Fact {
   readonly id?: number;
   readonly subject: string;
@@ -32,6 +42,16 @@ export interface StoredSummary {
   readonly chapterType: string;
 }
 
+export interface StoredHook {
+  readonly hookId: string;
+  readonly startChapter: number;
+  readonly type: string;
+  readonly status: string;
+  readonly lastAdvancedChapter: number;
+  readonly expectedPayoff: string;
+  readonly notes: string;
+}
+
 export class MemoryDB {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   private db: any;
@@ -70,9 +90,21 @@ export class MemoryDB {
         chapter_type TEXT NOT NULL DEFAULT ''
       );
 
+      CREATE TABLE IF NOT EXISTS hooks (
+        hook_id TEXT PRIMARY KEY,
+        start_chapter INTEGER NOT NULL DEFAULT 0,
+        type TEXT NOT NULL DEFAULT '',
+        status TEXT NOT NULL DEFAULT 'open',
+        last_advanced_chapter INTEGER NOT NULL DEFAULT 0,
+        expected_payoff TEXT NOT NULL DEFAULT '',
+        notes TEXT NOT NULL DEFAULT ''
+      );
+
       CREATE INDEX IF NOT EXISTS idx_facts_subject ON facts(subject);
       CREATE INDEX IF NOT EXISTS idx_facts_valid ON facts(valid_from_chapter, valid_until_chapter);
       CREATE INDEX IF NOT EXISTS idx_facts_source ON facts(source_chapter);
+      CREATE INDEX IF NOT EXISTS idx_hooks_status ON hooks(status);
+      CREATE INDEX IF NOT EXISTS idx_hooks_last_advanced ON hooks(last_advanced_chapter);
     `);
   }
 
@@ -103,14 +135,18 @@ export class MemoryDB {
   /** Get all currently valid facts (valid_until is null). */
   getCurrentFacts(): ReadonlyArray<Fact> {
     return this.db.prepare(
-      "SELECT * FROM facts WHERE valid_until_chapter IS NULL ORDER BY subject, predicate",
+      `SELECT ${FACT_SELECT_COLUMNS}
+       FROM facts
+       WHERE valid_until_chapter IS NULL
+       ORDER BY subject, predicate`,
     ).all() as unknown as Fact[];
   }
 
   /** Get facts about a specific subject that are valid at a given chapter. */
   getFactsAt(subject: string, chapter: number): ReadonlyArray<Fact> {
     return this.db.prepare(
-      `SELECT * FROM facts
+      `SELECT ${FACT_SELECT_COLUMNS}
+       FROM facts
        WHERE subject = ? AND valid_from_chapter <= ?
        AND (valid_until_chapter IS NULL OR valid_until_chapter > ?)
        ORDER BY predicate`,
@@ -120,14 +156,20 @@ export class MemoryDB {
   /** Get all facts about a subject (including historical). */
   getFactHistory(subject: string): ReadonlyArray<Fact> {
     return this.db.prepare(
-      "SELECT * FROM facts WHERE subject = ? ORDER BY valid_from_chapter",
+      `SELECT ${FACT_SELECT_COLUMNS}
+       FROM facts
+       WHERE subject = ?
+       ORDER BY valid_from_chapter`,
     ).all(subject) as unknown as Fact[];
   }
 
   /** Search facts by predicate (e.g., all "location" facts). */
   getFactsByPredicate(predicate: string): ReadonlyArray<Fact> {
     return this.db.prepare(
-      "SELECT * FROM facts WHERE predicate = ? AND valid_until_chapter IS NULL ORDER BY subject",
+      `SELECT ${FACT_SELECT_COLUMNS}
+       FROM facts
+       WHERE predicate = ? AND valid_until_chapter IS NULL
+       ORDER BY subject`,
     ).all(predicate) as unknown as Fact[];
   }
 
@@ -136,10 +178,24 @@ export class MemoryDB {
     if (names.length === 0) return [];
     const placeholders = names.map(() => "?").join(",");
     return this.db.prepare(
-      `SELECT * FROM facts WHERE subject IN (${placeholders}) AND valid_until_chapter IS NULL ORDER BY subject, predicate`,
+      `SELECT ${FACT_SELECT_COLUMNS}
+       FROM facts
+       WHERE subject IN (${placeholders}) AND valid_until_chapter IS NULL
+       ORDER BY subject, predicate`,
     ).all(...names) as unknown as Fact[];
   }
 
+  replaceCurrentFacts(facts: ReadonlyArray<Omit<Fact, "id">>): void {
+    this.db.exec("DELETE FROM facts WHERE valid_until_chapter IS NULL");
+    for (const fact of facts) {
+      this.addFact(fact);
+    }
+  }
+
+  resetFacts(): void {
+    this.db.exec("DELETE FROM facts");
+  }
+
   // ---------------------------------------------------------------------------
   // Chapter summaries
   // ---------------------------------------------------------------------------
@@ -155,10 +211,28 @@ export class MemoryDB {
     );
   }
 
+  replaceSummaries(summaries: ReadonlyArray<StoredSummary>): void {
+    this.db.exec("DELETE FROM chapter_summaries");
+    for (const summary of summaries) {
+      this.upsertSummary(summary);
+    }
+  }
+
   /** Get summaries for a range of chapters. */
   getSummaries(fromChapter: number, toChapter: number): ReadonlyArray<StoredSummary> {
     return this.db.prepare(
-      "SELECT * FROM chapter_summaries WHERE chapter >= ? AND chapter <= ? ORDER BY chapter",
+      `SELECT
+         chapter,
+         title,
+         characters,
+         events,
+         state_changes AS stateChanges,
+         hook_activity AS hookActivity,
+         mood,
+         chapter_type AS chapterType
+       FROM chapter_summaries
+       WHERE chapter >= ? AND chapter <= ?
+       ORDER BY chapter`,
     ).all(fromChapter, toChapter) as unknown as StoredSummary[];
   }
 
@@ -168,7 +242,18 @@ export class MemoryDB {
     const conditions = names.map(() => "characters LIKE ?").join(" OR ");
     const params = names.map((n) => `%${n}%`);
     return this.db.prepare(
-      `SELECT * FROM chapter_summaries WHERE ${conditions} ORDER BY chapter`,
+      `SELECT
+         chapter,
+         title,
+         characters,
+         events,
+         state_changes AS stateChanges,
+         hook_activity AS hookActivity,
+         mood,
+         chapter_type AS chapterType
+       FROM chapter_summaries
+       WHERE ${conditions}
+       ORDER BY chapter`,
     ).all(...params) as unknown as StoredSummary[];
   }
 
@@ -181,10 +266,63 @@ export class MemoryDB {
   /** Get the most recent N summaries. */
   getRecentSummaries(count: number): ReadonlyArray<StoredSummary> {
     return this.db.prepare(
-      "SELECT * FROM chapter_summaries ORDER BY chapter DESC LIMIT ?",
+      `SELECT
+         chapter,
+         title,
+         characters,
+         events,
+         state_changes AS stateChanges,
+         hook_activity AS hookActivity,
+         mood,
+         chapter_type AS chapterType
+       FROM chapter_summaries
+       ORDER BY chapter DESC
+       LIMIT ?`,
     ).all(count) as unknown as ReadonlyArray<StoredSummary>;
   }
 
+  // ---------------------------------------------------------------------------
+  // Hooks
+  // ---------------------------------------------------------------------------
+
+  upsertHook(hook: StoredHook): void {
+    this.db.prepare(
+      `INSERT OR REPLACE INTO hooks (hook_id, start_chapter, type, status, last_advanced_chapter, expected_payoff, notes)
+       VALUES (?, ?, ?, ?, ?, ?, ?)`,
+    ).run(
+      hook.hookId,
+      hook.startChapter,
+      hook.type,
+      hook.status,
+      hook.lastAdvancedChapter,
+      hook.expectedPayoff,
+      hook.notes,
+    );
+  }
+
+  replaceHooks(hooks: ReadonlyArray<StoredHook>): void {
+    this.db.exec("DELETE FROM hooks");
+    for (const hook of hooks) {
+      this.upsertHook(hook);
+    }
+  }
+
+  getActiveHooks(): ReadonlyArray<StoredHook> {
+    return this.db.prepare(
+      `SELECT
+         hook_id AS hookId,
+         start_chapter AS startChapter,
+         type,
+         status,
+         last_advanced_chapter AS lastAdvancedChapter,
+         expected_payoff AS expectedPayoff,
+         notes
+       FROM hooks
+       WHERE lower(status) NOT IN ('resolved', 'closed', '已回收', '已解决')
+       ORDER BY last_advanced_chapter DESC, start_chapter DESC, hook_id ASC`,
+    ).all() as unknown as ReadonlyArray<StoredHook>;
+  }
+
   // ---------------------------------------------------------------------------
   // Lifecycle
   // ---------------------------------------------------------------------------

+ 1 - 1
packages/core/src/utils/context-filter.ts

@@ -108,7 +108,7 @@ function extractNames(text: string): Set<string> {
 
 function isHeaderRow(line: string): boolean {
   // First data-like row in a table (contains column names)
-  return /\|\s*(章节|角色|支线|hook_id|Chapter|Character|Subplot)/i.test(line);
+  return /^\|\s*(章节|角色|支线|hook_id|Chapter|Character|Subplot)/i.test(line);
 }
 
 /**

+ 33 - 0
packages/core/src/utils/governed-context.ts

@@ -0,0 +1,33 @@
+import type { ContextPackage } from "../models/input-governance.js";
+
+export function buildGovernedMemoryEvidenceBlocks(contextPackage: ContextPackage): {
+  readonly hooksBlock?: string;
+  readonly summariesBlock?: string;
+} {
+  const hookEntries = contextPackage.selectedContext.filter((entry) =>
+    entry.source.startsWith("story/pending_hooks.md#"),
+  );
+  const summaryEntries = contextPackage.selectedContext.filter((entry) =>
+    entry.source.startsWith("story/chapter_summaries.md#"),
+  );
+
+  return {
+    hooksBlock: hookEntries.length > 0
+      ? renderEvidenceBlock("已选伏笔证据", hookEntries)
+      : undefined,
+    summariesBlock: summaryEntries.length > 0
+      ? renderEvidenceBlock("已选章节摘要证据", summaryEntries)
+      : undefined,
+  };
+}
+
+function renderEvidenceBlock(
+  heading: string,
+  entries: ContextPackage["selectedContext"],
+): string {
+  const lines = entries.map((entry) =>
+    `- ${entry.source}: ${entry.excerpt ?? entry.reason}`,
+  );
+
+  return `\n## ${heading}\n${lines.join("\n")}\n`;
+}

+ 14 - 1
packages/core/src/utils/length-metrics.ts

@@ -20,6 +20,19 @@ export function countChapterLength(
   return normalized.replace(/\s+/g, "").length;
 }
 
+export function resolveLengthCountingMode(
+  language: LengthLanguage = "zh",
+): LengthCountingMode {
+  return language === "en" ? "en_words" : "zh_chars";
+}
+
+export function formatLengthCount(
+  count: number,
+  countingMode: LengthCountingMode,
+): string {
+  return countingMode === "en_words" ? `${count} words` : `${count}字`;
+}
+
 export function buildLengthSpec(
   target: number,
   language: LengthLanguage = "zh",
@@ -37,7 +50,7 @@ export function buildLengthSpec(
     softMax,
     hardMin,
     hardMax,
-    countingMode: language === "en" ? "en_words" : "zh_chars",
+    countingMode: resolveLengthCountingMode(language),
     normalizeMode: "none",
   };
 }

+ 378 - 0
packages/core/src/utils/memory-retrieval.ts

@@ -0,0 +1,378 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js";
+
+export interface MemorySelection {
+  readonly summaries: ReadonlyArray<StoredSummary>;
+  readonly hooks: ReadonlyArray<StoredHook>;
+  readonly facts: ReadonlyArray<Fact>;
+  readonly dbPath?: string;
+}
+
+export async function retrieveMemorySelection(params: {
+  readonly bookDir: string;
+  readonly chapterNumber: number;
+  readonly goal: string;
+  readonly outlineNode?: string;
+  readonly mustKeep?: ReadonlyArray<string>;
+}): Promise<MemorySelection> {
+  const storyDir = join(params.bookDir, "story");
+  const [summariesMarkdown, hooksMarkdown, currentStateMarkdown] = await Promise.all([
+    readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
+    readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
+    readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
+  ]);
+
+  const summaries = parseChapterSummariesMarkdown(summariesMarkdown);
+  const hooks = parsePendingHooksMarkdown(hooksMarkdown);
+  const facts = parseCurrentStateFacts(
+    currentStateMarkdown,
+    Math.max(0, params.chapterNumber - 1),
+  );
+  const queryTerms = extractQueryTerms(
+    params.goal,
+    params.outlineNode,
+    params.mustKeep ?? [],
+  );
+
+  const memoryDb = openMemoryDB(params.bookDir);
+  if (memoryDb) {
+    try {
+      memoryDb.replaceSummaries(summaries);
+      memoryDb.replaceHooks(hooks);
+      memoryDb.replaceCurrentFacts(facts);
+
+      return {
+        summaries: selectRelevantSummaries(
+          memoryDb.getSummaries(1, Math.max(1, params.chapterNumber - 1)),
+          params.chapterNumber,
+          queryTerms,
+        ),
+        hooks: selectRelevantHooks(memoryDb.getActiveHooks(), queryTerms),
+        facts: selectRelevantFacts(memoryDb.getCurrentFacts(), queryTerms),
+        dbPath: join(storyDir, "memory.db"),
+      };
+    } finally {
+      memoryDb.close();
+    }
+  }
+
+  return {
+    summaries: selectRelevantSummaries(summaries, params.chapterNumber, queryTerms),
+    hooks: selectRelevantHooks(hooks, queryTerms),
+    facts: selectRelevantFacts(facts, queryTerms),
+  };
+}
+
+export function renderSummarySnapshot(summaries: ReadonlyArray<StoredSummary>): string {
+  if (summaries.length === 0) return "- none";
+
+  return [
+    "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+    "| --- | --- | --- | --- | --- | --- | --- | --- |",
+    ...summaries.map((summary) => [
+      summary.chapter,
+      summary.title,
+      summary.characters,
+      summary.events,
+      summary.stateChanges,
+      summary.hookActivity,
+      summary.mood,
+      summary.chapterType,
+    ].map(escapeTableCell).join(" | ")).map((row) => `| ${row} |`),
+  ].join("\n");
+}
+
+export function renderHookSnapshot(hooks: ReadonlyArray<StoredHook>): string {
+  if (hooks.length === 0) return "- none";
+
+  return [
+    "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+    "| --- | --- | --- | --- | --- | --- | --- |",
+    ...hooks.map((hook) => [
+      hook.hookId,
+      hook.startChapter,
+      hook.type,
+      hook.status,
+      hook.lastAdvancedChapter,
+      hook.expectedPayoff,
+      hook.notes,
+    ].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`),
+  ].join("\n");
+}
+
+function openMemoryDB(bookDir: string): MemoryDB | null {
+  try {
+    return new MemoryDB(bookDir);
+  } catch {
+    return null;
+  }
+}
+
+function extractQueryTerms(goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray<string>): string[] {
+  const stopWords = new Set([
+    "bring", "focus", "back", "chapter", "clear", "narrative", "before", "opening",
+    "track", "the", "with", "from", "that", "this", "into", "still", "cannot",
+    "current", "state", "advance", "conflict", "story", "keep", "must", "local",
+  ]);
+
+  const source = [goal, outlineNode ?? "", ...mustKeep].join(" ");
+  const english = source.match(/[a-z]{4,}/gi) ?? [];
+  const chinese = source.match(/[\u4e00-\u9fff]{2,4}/g) ?? [];
+
+  return [...new Set(
+    [...english, ...chinese]
+      .map((term) => term.trim())
+      .filter((term) => term.length >= 2)
+      .filter((term) => !stopWords.has(term.toLowerCase())),
+  )].slice(0, 12);
+}
+
+function parseChapterSummariesMarkdown(markdown: string): StoredSummary[] {
+  const rows = parseMarkdownTableRows(markdown)
+    .filter((row) => /^\d+$/.test(row[0] ?? ""));
+
+  return rows.map((row) => ({
+    chapter: parseInt(row[0]!, 10),
+    title: row[1] ?? "",
+    characters: row[2] ?? "",
+    events: row[3] ?? "",
+    stateChanges: row[4] ?? "",
+    hookActivity: row[5] ?? "",
+    mood: row[6] ?? "",
+    chapterType: row[7] ?? "",
+  }));
+}
+
+function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
+  const tableRows = parseMarkdownTableRows(markdown)
+    .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id");
+
+  if (tableRows.length > 0) {
+    return tableRows
+      .filter((row) => (row[0] ?? "").length > 0)
+      .map((row) => ({
+        hookId: row[0] ?? "",
+        startChapter: parseInteger(row[1]),
+        type: row[2] ?? "",
+        status: row[3] ?? "open",
+        lastAdvancedChapter: parseInteger(row[4]),
+        expectedPayoff: row[5] ?? "",
+        notes: row[6] ?? "",
+      }));
+  }
+
+  return markdown
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.startsWith("-"))
+    .map((line) => line.replace(/^-\s*/, ""))
+    .filter(Boolean)
+    .map((line, index) => ({
+      hookId: `hook-${index + 1}`,
+      startChapter: 0,
+      type: "unspecified",
+      status: "open",
+      lastAdvancedChapter: 0,
+      expectedPayoff: "",
+      notes: line,
+    }));
+}
+
+export function parseCurrentStateFacts(
+  markdown: string,
+  fallbackChapter: number,
+): Fact[] {
+  const tableRows = parseMarkdownTableRows(markdown);
+  const fieldValueRows = tableRows
+    .filter((row) => row.length >= 2)
+    .filter((row) => !isStateTableHeaderRow(row));
+
+  if (fieldValueRows.length > 0) {
+    const chapterFromTable = fieldValueRows.find((row) => isCurrentChapterLabel(row[0] ?? ""));
+    const stateChapter = parseInteger(chapterFromTable?.[1]) || fallbackChapter;
+
+    return fieldValueRows
+      .filter((row) => !isCurrentChapterLabel(row[0] ?? ""))
+      .flatMap((row): Fact[] => {
+        const label = (row[0] ?? "").trim();
+        const value = (row[1] ?? "").trim();
+        if (!label || !value) return [];
+
+        return [{
+          subject: inferFactSubject(label),
+          predicate: label,
+          object: value,
+          validFromChapter: stateChapter,
+          validUntilChapter: null,
+          sourceChapter: stateChapter,
+        }];
+      });
+  }
+
+  const bulletFacts = markdown
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.startsWith("-"))
+    .map((line) => line.replace(/^-\s*/, ""))
+    .filter(Boolean);
+
+  return bulletFacts.map((line, index) => ({
+    subject: "current_state",
+    predicate: `note_${index + 1}`,
+    object: line,
+    validFromChapter: fallbackChapter,
+    validUntilChapter: null,
+    sourceChapter: fallbackChapter,
+  }));
+}
+
+function parseMarkdownTableRows(markdown: string): string[][] {
+  return markdown
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.startsWith("|"))
+    .filter((line) => !line.includes("---"))
+    .map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()))
+    .filter((cells) => cells.some(Boolean));
+}
+
+function isStateTableHeaderRow(row: ReadonlyArray<string>): boolean {
+  const first = (row[0] ?? "").trim().toLowerCase();
+  const second = (row[1] ?? "").trim().toLowerCase();
+  return (first === "字段" && second === "值") || (first === "field" && second === "value");
+}
+
+function isCurrentChapterLabel(label: string): boolean {
+  return /^(当前章节|current chapter)$/i.test(label.trim());
+}
+
+function inferFactSubject(label: string): string {
+  if (/^(当前位置|current location)$/i.test(label)) return "protagonist";
+  if (/^(主角状态|protagonist state)$/i.test(label)) return "protagonist";
+  if (/^(当前目标|current goal)$/i.test(label)) return "protagonist";
+  if (/^(当前限制|current constraint)$/i.test(label)) return "protagonist";
+  if (/^(当前敌我|current alliances|current relationships)$/i.test(label)) return "protagonist";
+  if (/^(当前冲突|current conflict)$/i.test(label)) return "protagonist";
+  return "current_state";
+}
+
+function selectRelevantSummaries(
+  summaries: ReadonlyArray<StoredSummary>,
+  chapterNumber: number,
+  queryTerms: ReadonlyArray<string>,
+): StoredSummary[] {
+  return summaries
+    .filter((summary) => summary.chapter < chapterNumber)
+    .map((summary) => ({
+      summary,
+      score: scoreSummary(summary, chapterNumber, queryTerms),
+      matched: matchesAny([
+        summary.title,
+        summary.characters,
+        summary.events,
+        summary.stateChanges,
+        summary.hookActivity,
+        summary.chapterType,
+      ].join(" "), queryTerms),
+    }))
+    .filter((entry) => entry.matched || entry.summary.chapter >= chapterNumber - 3)
+    .sort((left, right) => right.score - left.score || right.summary.chapter - left.summary.chapter)
+    .slice(0, 4)
+    .map((entry) => entry.summary)
+    .sort((left, right) => left.chapter - right.chapter);
+}
+
+function selectRelevantHooks(
+  hooks: ReadonlyArray<StoredHook>,
+  queryTerms: ReadonlyArray<string>,
+): StoredHook[] {
+  return hooks
+    .map((hook) => ({
+      hook,
+      score: scoreHook(hook, queryTerms),
+      matched: matchesAny(
+        [hook.hookId, hook.type, hook.expectedPayoff, hook.notes].join(" "),
+        queryTerms,
+      ),
+    }))
+    .filter((entry) => entry.matched || entry.hook.status.trim().length === 0 || /open|待定|推进|active/i.test(entry.hook.status))
+    .sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter)
+    .slice(0, 3)
+    .map((entry) => entry.hook);
+}
+
+function selectRelevantFacts(
+  facts: ReadonlyArray<Fact>,
+  queryTerms: ReadonlyArray<string>,
+): Fact[] {
+  const prioritizedPredicates = [
+    /^(当前冲突|current conflict)$/i,
+    /^(当前目标|current goal)$/i,
+    /^(主角状态|protagonist state)$/i,
+    /^(当前限制|current constraint)$/i,
+    /^(当前位置|current location)$/i,
+    /^(当前敌我|current alliances|current relationships)$/i,
+  ];
+
+  return facts
+    .map((fact) => {
+      const text = [fact.subject, fact.predicate, fact.object].join(" ");
+      const priority = prioritizedPredicates.findIndex((pattern) => pattern.test(fact.predicate));
+      const baseScore = priority === -1 ? 5 : 20 - priority * 2;
+      const termScore = queryTerms.reduce(
+        (score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0),
+        0,
+      );
+
+      return {
+        fact,
+        score: baseScore + termScore,
+        matched: matchesAny(text, queryTerms),
+      };
+    })
+    .filter((entry) => entry.matched || entry.score >= 14)
+    .sort((left, right) => right.score - left.score)
+    .slice(0, 4)
+    .map((entry) => entry.fact);
+}
+
+function scoreSummary(summary: StoredSummary, chapterNumber: number, queryTerms: ReadonlyArray<string>): number {
+  const text = [
+    summary.title,
+    summary.characters,
+    summary.events,
+    summary.stateChanges,
+    summary.hookActivity,
+    summary.chapterType,
+  ].join(" ");
+  const age = Math.max(0, chapterNumber - summary.chapter);
+  const recencyScore = Math.max(0, 12 - age);
+  const termScore = queryTerms.reduce((score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0), 0);
+  return recencyScore + termScore;
+}
+
+function scoreHook(hook: StoredHook, queryTerms: ReadonlyArray<string>): number {
+  const text = [hook.hookId, hook.type, hook.expectedPayoff, hook.notes].join(" ");
+  const freshness = Math.max(0, hook.lastAdvancedChapter);
+  const termScore = queryTerms.reduce((score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0), 0);
+  return termScore + freshness;
+}
+
+function matchesAny(text: string, queryTerms: ReadonlyArray<string>): boolean {
+  return queryTerms.some((term) => includesTerm(text, term));
+}
+
+function includesTerm(text: string, term: string): boolean {
+  return text.toLowerCase().includes(term.toLowerCase());
+}
+
+function parseInteger(value: string | undefined): number {
+  if (!value) return 0;
+  const match = value.match(/\d+/);
+  return match ? parseInt(match[0], 10) : 0;
+}
+
+function escapeTableCell(value: string | number): string {
+  return String(value).replace(/\|/g, "\\|").trim();
+}

+ 9 - 3
scripts/prepare-package-for-publish.mjs

@@ -7,13 +7,19 @@
  * Expects process.cwd() to be the package directory (npm/pnpm guarantee this).
  */
 
-import { readFile, writeFile, copyFile, rm } from "node:fs/promises";
+import { readFile, writeFile, copyFile, rm, rename } from "node:fs/promises";
 import { join, resolve } from "node:path";
 
 const packageDir = process.cwd();
 const packageJsonPath = join(packageDir, "package.json");
 const backupPath = join(packageDir, ".package.json.publish-backup");
 
+async function writeAtomic(path, content) {
+  const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
+  await writeFile(tempPath, content, "utf-8");
+  await rename(tempPath, path);
+}
+
 // Walk up to workspace root (contains pnpm-workspace.yaml)
 function findWorkspaceRoot(startDir) {
   let dir = startDir;
@@ -97,7 +103,7 @@ async function main() {
     }
   }
 
-  await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
+  await writeAtomic(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
   process.stderr.write(`[prepack] Replaced workspace:* deps in ${pkg.name}\n`);
 
   // Verify: re-read and confirm no workspace: references remain
@@ -120,7 +126,7 @@ async function main() {
     );
     // Restore backup before aborting
     const original = await readFile(backupPath, "utf-8");
-    await writeFile(packageJsonPath, original, "utf-8");
+    await writeAtomic(packageJsonPath, original);
     await rm(backupPath, { force: true });
     process.exit(1);
   }

+ 8 - 2
scripts/restore-package-json.mjs

@@ -5,17 +5,23 @@
  *   "postpack": "node ../../scripts/restore-package-json.mjs"
  */
 
-import { readFile, rm, writeFile } from "node:fs/promises";
+import { readFile, rm, writeFile, rename } from "node:fs/promises";
 import { join } from "node:path";
 
 const packageDir = process.cwd();
 const packageJsonPath = join(packageDir, "package.json");
 const backupPath = join(packageDir, ".package.json.publish-backup");
 
+async function writeAtomic(path, content) {
+  const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
+  await writeFile(tempPath, content, "utf-8");
+  await rename(tempPath, path);
+}
+
 async function main() {
   try {
     const original = await readFile(backupPath, "utf-8");
-    await writeFile(packageJsonPath, original, "utf-8");
+    await writeAtomic(packageJsonPath, original);
     await rm(backupPath, { force: true });
   } catch {
     // No backup means prepack found nothing to replace — fine.