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

feat(memory): selective context injection + volume consolidation + state validation

1. Context filter: Writer and Auditor now inject only relevant truth file content
   - hooks: only open/active
   - summaries: recent 5 chapters only (older via relevantSummaries)
   - subplots: only active
   - emotional_arcs: recent 5 chapters
   - character_matrix: only characters in current outline section + protagonist
   - Settler unchanged (needs full files for incremental updates)

2. Volume consolidation: `inkos consolidate` compresses chapter summaries
   into volume-level narrative summaries via LLM. Archives detailed summaries.
   Caps context growth for 100+ chapter books.

3. State validator: LLM-based diff validation after Settler output.
   Catches contradictions, missing state changes, temporal impossibilities.
   Non-blocking — records warnings for next chapter's writer context.
Ma 7 месяцев назад
Родитель
Сommit
f2133d4de8

BIN
novel-to-comic-research.docx


+ 50 - 0
packages/cli/src/commands/consolidate.ts

@@ -0,0 +1,50 @@
+import { Command } from "commander";
+import { ConsolidatorAgent } from "@actalk/inkos-core";
+import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
+
+export const consolidateCommand = new Command("consolidate")
+  .description("Consolidate chapter summaries into volume-level summaries (reduces context for long books)")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string | undefined, opts) => {
+    try {
+      const config = await loadConfig();
+      const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
+
+      const pipelineConfig = buildPipelineConfig(config, root);
+      const consolidator = new ConsolidatorAgent({
+        client: pipelineConfig.client,
+        model: pipelineConfig.model,
+        projectRoot: root,
+      });
+
+      const { StateManager } = await import("@actalk/inkos-core");
+      const state = new StateManager(root);
+      const bookDir = state.bookDir(bookId);
+
+      if (!opts.json) log(`Consolidating chapter summaries for "${bookId}"...`);
+
+      const result = await consolidator.consolidate(bookDir);
+
+      if (opts.json) {
+        log(JSON.stringify(result, null, 2));
+      } else {
+        if (result.archivedVolumes === 0) {
+          log("No completed volumes found to consolidate.");
+        } else {
+          log(`Consolidated ${result.archivedVolumes} volume(s).`);
+          log(`Retained ${result.retainedChapters} recent chapter summaries.`);
+          log(`Volume summaries saved to story/volume_summaries.md`);
+          log(`Detailed summaries archived to story/summaries_archive/`);
+        }
+      }
+    } catch (e) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Consolidation failed: ${e}`);
+      }
+      process.exit(1);
+    }
+  });

+ 2 - 0
packages/cli/src/index.ts

@@ -24,6 +24,7 @@ import { analyticsCommand } from "./commands/analytics.js";
 import { importCommand } from "./commands/import.js";
 import { importCommand } from "./commands/import.js";
 import { fanficCommand } from "./commands/fanfic.js";
 import { fanficCommand } from "./commands/fanfic.js";
 import { studioCommand } from "./commands/studio.js";
 import { studioCommand } from "./commands/studio.js";
+import { consolidateCommand } from "./commands/consolidate.js";
 
 
 const require = createRequire(import.meta.url);
 const require = createRequire(import.meta.url);
 const { version } = require("../package.json") as { version: string };
 const { version } = require("../package.json") as { version: string };
@@ -58,5 +59,6 @@ program.addCommand(analyticsCommand);
 program.addCommand(importCommand);
 program.addCommand(importCommand);
 program.addCommand(fanficCommand);
 program.addCommand(fanficCommand);
 program.addCommand(studioCommand);
 program.addCommand(studioCommand);
+program.addCommand(consolidateCommand);
 
 
 program.parse();
 program.parse();

+ 157 - 0
packages/core/src/agents/consolidator.ts

@@ -0,0 +1,157 @@
+import { BaseAgent } from "./base.js";
+import { readFile, writeFile, mkdir } from "node:fs/promises";
+import { join } from "node:path";
+
+export interface ConsolidationResult {
+  readonly volumeSummaries: string;
+  readonly archivedVolumes: number;
+  readonly retainedChapters: number;
+}
+
+/**
+ * Consolidates chapter summaries into volume-level narrative summaries.
+ * Reduces token usage for long books while preserving critical context.
+ */
+export class ConsolidatorAgent extends BaseAgent {
+  get name(): string {
+    return "consolidator";
+  }
+
+  /**
+   * Consolidate chapter summaries by volume.
+   * - Reads volume_outline to determine volume boundaries
+   * - For each completed volume, LLM compresses chapter summaries into a narrative paragraph
+   * - Archives detailed summaries, keeps only recent volume's per-chapter rows
+   */
+  async consolidate(bookDir: string): Promise<ConsolidationResult> {
+    const storyDir = join(bookDir, "story");
+    const summariesPath = join(storyDir, "chapter_summaries.md");
+    const outlinePath = join(storyDir, "volume_outline.md");
+    const volumeSummariesPath = join(storyDir, "volume_summaries.md");
+
+    const [summariesRaw, outlineRaw] = await Promise.all([
+      readFile(summariesPath, "utf-8").catch(() => ""),
+      readFile(outlinePath, "utf-8").catch(() => ""),
+    ]);
+
+    if (!summariesRaw || !outlineRaw) {
+      return { volumeSummaries: "", archivedVolumes: 0, retainedChapters: 0 };
+    }
+
+    // Parse volume boundaries from outline
+    const volumeBoundaries = this.parseVolumeBoundaries(outlineRaw);
+    if (volumeBoundaries.length === 0) {
+      return { volumeSummaries: "", archivedVolumes: 0, retainedChapters: 0 };
+    }
+
+    // Parse chapter summaries into rows
+    const { header, rows } = this.parseSummaryTable(summariesRaw);
+    if (rows.length === 0) {
+      return { volumeSummaries: "", archivedVolumes: 0, retainedChapters: 0 };
+    }
+
+    const maxChapter = Math.max(...rows.map((r) => r.chapter));
+
+    // Determine which volumes are "completed" (all chapters written)
+    const completedVolumes: Array<{ name: string; startCh: number; endCh: number; rows: typeof rows }> = [];
+    const currentVolumeRows: typeof rows = [];
+
+    for (const vol of volumeBoundaries) {
+      const volRows = rows.filter((r) => r.chapter >= vol.startCh && r.chapter <= vol.endCh);
+      if (vol.endCh <= maxChapter && volRows.length > 0) {
+        completedVolumes.push({ ...vol, rows: volRows });
+      } else {
+        // Current/incomplete volume — keep detailed rows
+        currentVolumeRows.push(...volRows);
+      }
+    }
+
+    // Also keep any rows not covered by volume boundaries
+    const coveredChapters = new Set(volumeBoundaries.flatMap((v) => {
+      const chs: number[] = [];
+      for (let i = v.startCh; i <= v.endCh; i++) chs.push(i);
+      return chs;
+    }));
+    for (const r of rows) {
+      if (!coveredChapters.has(r.chapter)) currentVolumeRows.push(r);
+    }
+
+    if (completedVolumes.length === 0) {
+      return { volumeSummaries: "", archivedVolumes: 0, retainedChapters: currentVolumeRows.length };
+    }
+
+    // LLM consolidation for each completed volume
+    const existingVolSummaries = await readFile(volumeSummariesPath, "utf-8").catch(() => "");
+    const newSummaries: string[] = existingVolSummaries ? [existingVolSummaries.trim()] : ["# Volume Summaries\n"];
+
+    for (const vol of completedVolumes) {
+      const volSummaryRows = vol.rows.map((r) => r.raw).join("\n");
+
+      const response = await this.chat([
+        {
+          role: "system",
+          content: `You are a narrative summarizer. Compress chapter-by-chapter summaries into a single coherent paragraph (max 500 words) that captures the key events, character developments, and plot progression of this volume. Preserve specific names, locations, and plot points. Write in the same language as the input.`,
+        },
+        {
+          role: "user",
+          content: `Volume: ${vol.name} (Chapters ${vol.startCh}-${vol.endCh})\n\nChapter summaries:\n${header}\n${volSummaryRows}`,
+        },
+      ], { temperature: 0.3, maxTokens: 1024 });
+
+      newSummaries.push(`\n## ${vol.name} (Ch.${vol.startCh}-${vol.endCh})\n\n${response.content.trim()}`);
+    }
+
+    // Write volume summaries
+    await writeFile(volumeSummariesPath, newSummaries.join("\n"), "utf-8");
+
+    // Archive detailed summaries
+    const archiveDir = join(storyDir, "summaries_archive");
+    await mkdir(archiveDir, { recursive: true });
+    for (const vol of completedVolumes) {
+      const archivePath = join(archiveDir, `vol_${vol.startCh}-${vol.endCh}.md`);
+      await writeFile(archivePath, `# ${vol.name}\n\n${header}\n${vol.rows.map((r) => r.raw).join("\n")}`, "utf-8");
+    }
+
+    // Rewrite chapter_summaries.md with only current volume rows
+    const retainedContent = currentVolumeRows.length > 0
+      ? `${header}\n${currentVolumeRows.map((r) => r.raw).join("\n")}\n`
+      : `${header}\n`;
+    await writeFile(summariesPath, retainedContent, "utf-8");
+
+    return {
+      volumeSummaries: newSummaries.join("\n"),
+      archivedVolumes: completedVolumes.length,
+      retainedChapters: currentVolumeRows.length,
+    };
+  }
+
+  private parseVolumeBoundaries(outline: string): Array<{ name: string; startCh: number; endCh: number }> {
+    const volumes: Array<{ name: string; startCh: number; endCh: number }> = [];
+    // Match patterns like "第一卷 ... 第1-30章" or "Volume 1 ... Chapters 1-30"
+    const regex = /(?:第[一二三四五六七八九十\d]+卷|Volume\s+\d+)[::\s]*([^\n]*?)(?:第(\d+)[-–~](\d+)章|[Cc]hapters?\s+(\d+)[-–~](\d+))/g;
+    let match: RegExpExecArray | null;
+    while ((match = regex.exec(outline)) !== null) {
+      const name = match[0].split(/[((]/)[0]!.trim();
+      const startCh = parseInt(match[2] ?? match[4] ?? "0", 10);
+      const endCh = parseInt(match[3] ?? match[5] ?? "0", 10);
+      if (startCh > 0 && endCh > 0) {
+        volumes.push({ name, startCh, endCh });
+      }
+    }
+    return volumes;
+  }
+
+  private parseSummaryTable(raw: string): { header: string; rows: Array<{ chapter: number; raw: string }> } {
+    const lines = raw.split("\n");
+    const headerLines = lines.filter((l) => l.startsWith("|") && (l.includes("章节") || l.includes("Chapter") || l.includes("---")));
+    const dataLines = lines.filter((l) => l.startsWith("|") && !l.includes("章节") && !l.includes("Chapter") && !l.includes("---"));
+
+    const header = headerLines.join("\n");
+    const rows = dataLines.map((line) => {
+      const match = line.match(/\|\s*(\d+)\s*\|/);
+      return { chapter: match ? parseInt(match[1]!, 10) : 0, raw: line };
+    }).filter((r) => r.chapter > 0);
+
+    return { header, rows };
+  }
+}

+ 18 - 9
packages/core/src/agents/continuity.ts

@@ -5,6 +5,7 @@ import type { FanficMode } from "../models/book.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions.js";
 import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions.js";
 import { readFile, readdir } from "node:fs/promises";
 import { readFile, readdir } from "node:fs/promises";
+import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
 import { join } from "node:path";
 import { join } from "node:path";
 
 
 export interface AuditResult {
 export interface AuditResult {
@@ -302,17 +303,25 @@ ${dimList}
       ? `\n## 资源账本\n${ledger}`
       ? `\n## 资源账本\n${ledger}`
       : "";
       : "";
 
 
-    const subplotBlock = subplotBoard !== "(文件不存在)"
-      ? `\n## 支线进度板\n${subplotBoard}\n`
+    // Smart context filtering for auditor — same logic as writer
+    const bookRulesForFilter = parsedRules?.rules ?? null;
+    const filteredSubplots = filterSubplots(subplotBoard);
+    const filteredArcs = filterEmotionalArcs(emotionalArcs, chapterNumber);
+    const filteredMatrix = filterCharacterMatrix(characterMatrix, volumeOutline, bookRulesForFilter?.protagonist?.name);
+    const filteredSummaries = filterSummaries(chapterSummaries, chapterNumber);
+    const filteredHooks = filterHooks(hooks);
+
+    const subplotBlock = filteredSubplots !== "(文件不存在)"
+      ? `\n## 支线进度板\n${filteredSubplots}\n`
       : "";
       : "";
-    const emotionalBlock = emotionalArcs !== "(文件不存在)"
-      ? `\n## 情感弧线\n${emotionalArcs}\n`
+    const emotionalBlock = filteredArcs !== "(文件不存在)"
+      ? `\n## 情感弧线\n${filteredArcs}\n`
       : "";
       : "";
-    const matrixBlock = characterMatrix !== "(文件不存在)"
-      ? `\n## 角色交互矩阵\n${characterMatrix}\n`
+    const matrixBlock = filteredMatrix !== "(文件不存在)"
+      ? `\n## 角色交互矩阵\n${filteredMatrix}\n`
       : "";
       : "";
-    const summariesBlock = chapterSummaries !== "(文件不存在)"
-      ? `\n## 章节摘要(用于节奏检查)\n${chapterSummaries}\n`
+    const summariesBlock = filteredSummaries !== "(文件不存在)"
+      ? `\n## 章节摘要(用于节奏检查)\n${filteredSummaries}\n`
       : "";
       : "";
 
 
     const canonBlock = hasParentCanon
     const canonBlock = hasParentCanon
@@ -337,7 +346,7 @@ ${dimList}
 ${currentState}
 ${currentState}
 ${ledgerBlock}
 ${ledgerBlock}
 ## 伏笔池
 ## 伏笔池
-${hooks}
+${filteredHooks}
 ${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${outlineBlock}${prevChapterBlock}
 ${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${outlineBlock}${prevChapterBlock}
 ## 文风指南
 ## 文风指南
 ${styleGuide}
 ${styleGuide}

+ 127 - 0
packages/core/src/agents/state-validator.ts

@@ -0,0 +1,127 @@
+import { BaseAgent } from "./base.js";
+
+export interface ValidationWarning {
+  readonly category: string;
+  readonly description: string;
+}
+
+export interface ValidationResult {
+  readonly warnings: ReadonlyArray<ValidationWarning>;
+  readonly passed: boolean;
+}
+
+/**
+ * Validates Settler output by comparing old and new truth files via LLM.
+ * Catches contradictions, missing state changes, and temporal inconsistencies.
+ * Non-blocking: returns warnings but does not prevent truth file writes.
+ */
+export class StateValidatorAgent extends BaseAgent {
+  get name(): string {
+    return "state-validator";
+  }
+
+  async validate(
+    chapterContent: string,
+    chapterNumber: number,
+    oldState: string,
+    newState: string,
+    oldHooks: string,
+    newHooks: string,
+    language: "zh" | "en" = "zh",
+  ): Promise<ValidationResult> {
+    const stateDiff = this.computeDiff(oldState, newState, "State Card");
+    const hooksDiff = this.computeDiff(oldHooks, newHooks, "Hooks Pool");
+
+    // Skip validation if nothing changed
+    if (!stateDiff && !hooksDiff) {
+      return { warnings: [], passed: true };
+    }
+
+    const langInstruction = language === "en"
+      ? "Respond in English."
+      : "用中文回答。";
+
+    const systemPrompt = `You are a continuity validator for a novel writing system. ${langInstruction}
+
+Given the chapter text and the CHANGES made to truth files (state card + hooks pool), check for contradictions:
+
+1. State change without narrative support — truth file says something changed but the chapter text doesn't describe it
+2. Missing state change — chapter text describes something happening but the truth file didn't capture it
+3. Temporal impossibility — character moves locations without transition, injury heals without time passing
+4. Hook anomaly — a hook disappeared without being marked resolved, or a new hook has no basis in the chapter
+5. Retroactive edit — truth file change implies something happened in a PREVIOUS chapter, not the current one
+
+Output JSON:
+{
+  "warnings": [
+    { "category": "missing_state_change", "description": "..." },
+    { "category": "unsupported_change", "description": "..." }
+  ],
+  "passed": true/false
+}
+
+passed = true means no serious contradictions found. Minor observations are still reported as warnings.
+If there are no issues at all, return {"warnings": [], "passed": true}.`;
+
+    const userPrompt = `Chapter ${chapterNumber} validation:
+
+## State Card Changes
+${stateDiff || "(no changes)"}
+
+## Hooks Pool Changes
+${hooksDiff || "(no changes)"}
+
+## Chapter Text (for reference)
+${chapterContent.slice(0, 6000)}`;
+
+    try {
+      const response = await this.chat(
+        [
+          { role: "system", content: systemPrompt },
+          { role: "user", content: userPrompt },
+        ],
+        { temperature: 0, maxTokens: 2048 },
+      );
+
+      return this.parseResult(response.content);
+    } catch (e) {
+      // Validation failure should never block the pipeline
+      this.log?.warn(`State validation failed: ${e}`);
+      return { warnings: [], passed: true };
+    }
+  }
+
+  private computeDiff(oldText: string, newText: string, label: string): string | null {
+    if (oldText === newText) return null;
+
+    const oldLines = oldText.split("\n").filter((l) => l.trim());
+    const newLines = newText.split("\n").filter((l) => l.trim());
+
+    const added = newLines.filter((l) => !oldLines.includes(l));
+    const removed = oldLines.filter((l) => !newLines.includes(l));
+
+    if (added.length === 0 && removed.length === 0) return null;
+
+    const parts = [`### ${label}`];
+    if (removed.length > 0) parts.push("Removed:\n" + removed.map((l) => `- ${l}`).join("\n"));
+    if (added.length > 0) parts.push("Added:\n" + added.map((l) => `+ ${l}`).join("\n"));
+    return parts.join("\n");
+  }
+
+  private parseResult(content: string): ValidationResult {
+    try {
+      const jsonMatch = content.match(/\{[\s\S]*\}/);
+      if (!jsonMatch) return { warnings: [], passed: true };
+      const parsed = JSON.parse(jsonMatch[0]) as { warnings?: Array<{ category?: string; description?: string }>; passed?: boolean };
+      return {
+        warnings: (parsed.warnings ?? []).map((w) => ({
+          category: w.category ?? "unknown",
+          description: w.description ?? "",
+        })),
+        passed: parsed.passed !== false,
+      };
+    } catch {
+      return { warnings: [], passed: true };
+    }
+  }
+}

+ 13 - 5
packages/core/src/agents/writer.ts

@@ -8,6 +8,7 @@ import { parseSettlementOutput } from "./settler-parser.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { validatePostWrite, type PostWriteViolation } from "./post-write-validator.js";
 import { validatePostWrite, type PostWriteViolation } from "./post-write-validator.js";
 import { analyzeAITells } from "./ai-tells.js";
 import { analyzeAITells } from "./ai-tells.js";
+import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
 import { parseCreativeOutput } from "./writer-parser.js";
 import { parseCreativeOutput } from "./writer-parser.js";
 import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
 import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
@@ -107,20 +108,27 @@ export class WriterAgent extends BaseAgent {
       chapterNumber, "creative", fanficContext, resolvedLanguage,
       chapterNumber, "creative", fanficContext, resolvedLanguage,
     );
     );
 
 
+    // Smart context filtering: inject only relevant parts of truth files
+    const filteredHooks = filterHooks(hooks);
+    const filteredSummaries = filterSummaries(chapterSummaries, chapterNumber);
+    const filteredSubplots = filterSubplots(subplotBoard);
+    const filteredArcs = filterEmotionalArcs(emotionalArcs, chapterNumber);
+    const filteredMatrix = filterCharacterMatrix(characterMatrix, volumeOutline, bookRules?.protagonist?.name);
+
     const creativeUserPrompt = this.buildUserPrompt({
     const creativeUserPrompt = this.buildUserPrompt({
       chapterNumber,
       chapterNumber,
       storyBible,
       storyBible,
       volumeOutline,
       volumeOutline,
       currentState,
       currentState,
       ledger: genreProfile.numericalSystem ? ledger : "",
       ledger: genreProfile.numericalSystem ? ledger : "",
-      hooks,
+      hooks: filteredHooks,
       recentChapters,
       recentChapters,
       wordCount: input.wordCountOverride ?? book.chapterWordCount,
       wordCount: input.wordCountOverride ?? book.chapterWordCount,
       externalContext: input.externalContext,
       externalContext: input.externalContext,
-      chapterSummaries,
-      subplotBoard,
-      emotionalArcs,
-      characterMatrix,
+      chapterSummaries: filteredSummaries,
+      subplotBoard: filteredSubplots,
+      emotionalArcs: filteredArcs,
+      characterMatrix: filteredMatrix,
       dialogueFingerprints,
       dialogueFingerprints,
       relevantSummaries,
       relevantSummaries,
       parentCanon: hasParentCanon ? parentCanon : undefined,
       parentCanon: hasParentCanon ? parentCanon : undefined,

+ 3 - 0
packages/core/src/index.ts

@@ -37,6 +37,9 @@ export { buildFanficCanonSection, buildCharacterVoiceProfiles, buildFanficModeIn
 
 
 // Utils
 // Utils
 export { fetchUrl, searchWeb } from "./utils/web-search.js";
 export { fetchUrl, searchWeb } from "./utils/web-search.js";
+export { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "./utils/context-filter.js";
+export { ConsolidatorAgent } from "./agents/consolidator.js";
+export { StateValidatorAgent } from "./agents/state-validator.js";
 export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js";
 export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js";
 export { createLogger, createStderrSink, createJsonLineSink, nullSink, type Logger, type LogSink, type LogLevel, type LogEntry } from "./utils/logger.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 { loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH } from "./utils/config-loader.js";

+ 143 - 0
packages/core/src/utils/context-filter.ts

@@ -0,0 +1,143 @@
+/**
+ * Smart context filtering for Writer and Auditor prompts.
+ *
+ * Reduces noise by injecting only relevant parts of truth files.
+ * Every filter falls back to the full input if filtering would empty it.
+ */
+
+/** Filter pending_hooks: remove resolved/closed hooks. */
+export function filterHooks(hooks: string): string {
+  if (!hooks || hooks === "(文件尚未创建)") return hooks;
+  return filterTableRows(hooks, (row) => {
+    const lower = row.toLowerCase();
+    return !lower.includes("已回收") && !lower.includes("resolved") && !lower.includes("closed");
+  });
+}
+
+/** Filter chapter_summaries: keep only the most recent N chapters. */
+export function filterSummaries(summaries: string, currentChapter: number, keepRecent = 5): string {
+  if (!summaries || summaries === "(文件尚未创建)") return summaries;
+  return filterTableRows(summaries, (row) => {
+    const match = row.match(/\|\s*(\d+)\s*\|/);
+    if (!match) return true;
+    return parseInt(match[1]!, 10) > currentChapter - keepRecent;
+  });
+}
+
+/** Filter subplot_board: remove closed/resolved subplots. */
+export function filterSubplots(board: string): string {
+  if (!board || board === "(文件尚未创建)") return board;
+  return filterTableRows(board, (row) => {
+    const lower = row.toLowerCase();
+    return !lower.includes("已回收") && !lower.includes("closed") && !lower.includes("resolved") && !lower.includes("已完结");
+  });
+}
+
+/** Filter emotional_arcs: keep only the most recent N chapters. */
+export function filterEmotionalArcs(arcs: string, currentChapter: number, keepRecent = 5): string {
+  if (!arcs || arcs === "(文件尚未创建)") return arcs;
+  return filterTableRows(arcs, (row) => {
+    const match = row.match(/\|\s*(\d+)\s*\|/);
+    if (!match) return true;
+    return parseInt(match[1]!, 10) > currentChapter - keepRecent;
+  });
+}
+
+/**
+ * Filter character_matrix: keep only characters mentioned in the volume outline
+ * current section + protagonist.
+ */
+export function filterCharacterMatrix(
+  matrix: string,
+  volumeOutline: string,
+  protagonistName?: string,
+): string {
+  if (!matrix || matrix === "(文件尚未创建)") return matrix;
+
+  // Extract names from outline
+  const names = extractNames(volumeOutline);
+  if (protagonistName) names.add(protagonistName);
+  if (names.size === 0) return matrix;
+
+  // Split into sections (### 角色档案, ### 相遇记录, ### 信息边界)
+  const sections = matrix.split(/(?=^###)/m);
+  const filtered = sections.map((section) => {
+    const result = filterTableRows(section, (row) => {
+      for (const name of names) {
+        if (row.includes(name)) return true;
+      }
+      return false;
+    });
+    // Keep section even if no matching rows (preserve headers for structure)
+    return result;
+  });
+
+  const result = filtered.join("\n");
+  // Fallback: if filtering removed all data rows, return original
+  const dataRowCount = result.split("\n").filter((l) => l.startsWith("|") && !l.includes("---") && !isHeaderRow(l)).length;
+  return dataRowCount > 0 ? result : matrix;
+}
+
+/**
+ * Extract character names from text.
+ * Chinese: 2-4 char sequences before punctuation.
+ * English: Capitalized words 3+ chars.
+ */
+function extractNames(text: string): Set<string> {
+  const names = new Set<string>();
+
+  // Chinese names
+  const cnRegex = /[\u4e00-\u9fff]{2,4}(?=[,、。:\s]|$)/g;
+  let match: RegExpExecArray | null;
+  while ((match = cnRegex.exec(text)) !== null) {
+    names.add(match[0]);
+  }
+
+  // English names
+  const enRegex = /\b[A-Z][a-z]{2,}\b/g;
+  while ((match = enRegex.exec(text)) !== null) {
+    names.add(match[0]);
+  }
+
+  return names;
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+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);
+}
+
+/**
+ * Generic markdown table row filter.
+ * Keeps header rows + separator rows + rows passing the predicate.
+ * Falls back to original if filtering empties all data rows.
+ */
+function filterTableRows(content: string, predicate: (row: string) => boolean): string {
+  const lines = content.split("\n");
+  const nonTableLines: string[] = [];
+  const headerLines: string[] = [];
+  const dataLines: string[] = [];
+
+  for (const line of lines) {
+    if (!line.startsWith("|")) {
+      nonTableLines.push(line);
+    } else if (line.includes("---") || isHeaderRow(line)) {
+      headerLines.push(line);
+    } else {
+      dataLines.push(line);
+    }
+  }
+
+  const filtered = dataLines.filter(predicate);
+
+  // Fallback: if no rows pass, return original
+  if (filtered.length === 0 && dataLines.length > 0) {
+    return content;
+  }
+
+  return [...nonTableLines, ...headerLines, ...filtered].join("\n");
+}