Bladeren bron

feat(governed): inject explicit history and canon evidence into writer

Ma 7 maanden geleden
bovenliggende
commit
8a05275d32

+ 67 - 0
packages/core/src/__tests__/composer.test.ts

@@ -391,4 +391,71 @@ describe("ComposerAgent", () => {
     expect(volumeEntry).toBeDefined();
     expect(volumeEntry).toBeDefined();
     expect(volumeEntry?.excerpt).toContain("mentor oath");
     expect(volumeEntry?.excerpt).toContain("mentor oath");
   });
   });
+
+  it("adds explicit title history, mood trail, and canon evidence for governed writing", async () => {
+    await Promise.all([
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "# Chapter Summaries",
+          "",
+          "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 1 | Ledger in Rain | Lin Yue | First ledger clue appears | None | none | tight | investigation |",
+          "| 2 | Ledger at Dusk | Lin Yue | Second ledger clue appears | None | none | tight | investigation |",
+          "| 3 | Harbor Ledger | Lin Yue | Third ledger clue appears | None | none | tight | investigation |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "parent_canon.md"),
+        "# Parent Canon\n\nThe mentor does not learn about the archive fire until volume two.\n",
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "fanfic_canon.md"),
+        "# Fanfic Canon\n\nMara may diverge from the archive route, but the oath debt logic must stay intact.\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: 4,
+      plan,
+    });
+
+    const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
+    expect(selectedSources).toContain("story/chapter_summaries.md#recent_titles");
+    expect(selectedSources).toContain("story/chapter_summaries.md#recent_mood_type_trail");
+    expect(selectedSources).toContain("story/parent_canon.md");
+    expect(selectedSources).toContain("story/fanfic_canon.md");
+
+    const titleEntry = result.contextPackage.selectedContext.find((entry) =>
+      entry.source === "story/chapter_summaries.md#recent_titles",
+    );
+    const moodEntry = result.contextPackage.selectedContext.find((entry) =>
+      entry.source === "story/chapter_summaries.md#recent_mood_type_trail",
+    );
+    const parentCanonEntry = result.contextPackage.selectedContext.find((entry) =>
+      entry.source === "story/parent_canon.md",
+    );
+    const fanficCanonEntry = result.contextPackage.selectedContext.find((entry) =>
+      entry.source === "story/fanfic_canon.md",
+    );
+
+    expect(titleEntry?.excerpt).toContain("Ledger in Rain");
+    expect(moodEntry?.excerpt).toContain("tight / investigation");
+    expect(parentCanonEntry?.excerpt).toContain("archive fire");
+    expect(fanficCanonEntry?.excerpt).toContain("oath debt logic");
+  });
 });
 });

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

@@ -764,4 +764,147 @@ describe("WriterAgent", () => {
       await rm(root, { recursive: true, force: true });
       await rm(root, { recursive: true, force: true });
     }
     }
   });
   });
+
+  it("renders explicit title history, mood trail, and canon blocks in governed creative prompts", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-writer-governed-evidence-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- Registry seals still matter.\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nPush Mara back toward the archive ledger.\n", "utf-8"),
+      writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose lean.\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara still hides the ledger fragment.\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- ledger-fragment\n", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
+    ]);
+
+    const agent = new WriterAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
+      .mockResolvedValueOnce({
+        content: [
+          "=== CHAPTER_TITLE ===",
+          "Archive Pressure",
+          "",
+          "=== CHAPTER_CONTENT ===",
+          "Mara corners Taryn beside the archive ledger.",
+          "",
+          "=== PRE_WRITE_CHECK ===",
+          "- ok",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: "=== OBSERVATIONS ===\n- observed",
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: [
+          "=== POST_SETTLEMENT ===",
+          "- ledger-fragment advanced",
+          "",
+          "=== UPDATED_STATE ===",
+          "state",
+          "",
+          "=== UPDATED_HOOKS ===",
+          "hooks",
+          "",
+          "=== CHAPTER_SUMMARY ===",
+          "| 4 | Archive Pressure | Mara,Taryn | Pressure rises | Trail narrows | ledger-fragment advanced | tense | confrontation |",
+          "",
+          "=== UPDATED_SUBPLOTS ===",
+          "subplots",
+          "",
+          "=== UPDATED_EMOTIONAL_ARCS ===",
+          "arcs",
+          "",
+          "=== UPDATED_CHARACTER_MATRIX ===",
+          "matrix",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    try {
+      await agent.writeChapter({
+        book: {
+          id: "writer-book",
+          title: "Writer Book",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 20,
+          chapterWordCount: 2200,
+          language: "en",
+          createdAt: "2026-03-26T00:00:00.000Z",
+          updatedAt: "2026-03-26T00:00:00.000Z",
+        },
+        bookDir,
+        chapterNumber: 4,
+        chapterIntent: "# Chapter Intent\n\n## Goal\nPush Mara back toward the archive ledger.\n",
+        contextPackage: {
+          chapter: 4,
+          selectedContext: [
+            {
+              source: "story/chapter_summaries.md#recent_titles",
+              reason: "Avoid repeated ledger titles.",
+              excerpt: "1: Ledger in Rain | 2: Ledger at Dusk | 3: Harbor Ledger",
+            },
+            {
+              source: "story/chapter_summaries.md#recent_mood_type_trail",
+              reason: "Track recent emotional and chapter-type cadence.",
+              excerpt: "1: tight / investigation | 2: tight / investigation | 3: tight / investigation",
+            },
+            {
+              source: "story/parent_canon.md",
+              reason: "Preserve parent canon constraints.",
+              excerpt: "The mentor does not learn about the archive fire until volume two.",
+            },
+            {
+              source: "story/fanfic_canon.md",
+              reason: "Preserve extracted fanfic canon constraints.",
+              excerpt: "Mara may diverge from the archive route, but the oath debt logic must stay intact.",
+            },
+          ],
+        },
+        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(2200, "en"),
+      });
+
+      const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
+      expect(creativePrompt).toContain("## Recent Title History");
+      expect(creativePrompt).toContain("Ledger in Rain");
+      expect(creativePrompt).toContain("## Recent Mood / Chapter Type Trail");
+      expect(creativePrompt).toContain("tight / investigation");
+      expect(creativePrompt).toContain("## Canon Evidence");
+      expect(creativePrompt).toContain("archive fire until volume two");
+      expect(creativePrompt).toContain("oath debt logic must stay intact");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
 });
 });

+ 61 - 1
packages/core/src/agents/composer.ts

@@ -12,7 +12,10 @@ import {
   type RuleStack,
   type RuleStack,
 } from "../models/input-governance.js";
 } from "../models/input-governance.js";
 import type { PlanChapterOutput } from "./planner.js";
 import type { PlanChapterOutput } from "./planner.js";
-import { retrieveMemorySelection } from "../utils/memory-retrieval.js";
+import {
+  parseChapterSummariesMarkdown,
+  retrieveMemorySelection,
+} from "../utils/memory-retrieval.js";
 
 
 export interface ComposeChapterInput {
 export interface ComposeChapterInput {
   readonly book: BookConfig;
   readonly book: BookConfig;
@@ -121,7 +124,18 @@ export class ComposerAgent extends BaseAgent {
         "Anchor the default planning node for this chapter.",
         "Anchor the default planning node for this chapter.",
         plan.intent.outlineNode ? [plan.intent.outlineNode] : [],
         plan.intent.outlineNode ? [plan.intent.outlineNode] : [],
       ),
       ),
+      this.maybeContextSource(
+        storyDir,
+        "parent_canon.md",
+        "Preserve parent canon constraints for governed continuation or fanfic writing.",
+      ),
+      this.maybeContextSource(
+        storyDir,
+        "fanfic_canon.md",
+        "Preserve extracted fanfic canon constraints for governed writing.",
+      ),
     ]);
     ]);
+    const trailEntries = await this.buildRecentChapterTrailEntries(storyDir, plan.intent.chapter);
 
 
     const planningAnchor = plan.intent.conflicts.length > 0 ? undefined : plan.intent.outlineNode;
     const planningAnchor = plan.intent.conflicts.length > 0 ? undefined : plan.intent.outlineNode;
     const memorySelection = await retrieveMemorySelection({
     const memorySelection = await retrieveMemorySelection({
@@ -159,6 +173,7 @@ export class ComposerAgent extends BaseAgent {
 
 
     return [
     return [
       ...entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
       ...entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
+      ...trailEntries,
       ...factEntries,
       ...factEntries,
       ...summaryEntries,
       ...summaryEntries,
       ...volumeSummaryEntries,
       ...volumeSummaryEntries,
@@ -166,6 +181,51 @@ export class ComposerAgent extends BaseAgent {
     ];
     ];
   }
   }
 
 
+  private async buildRecentChapterTrailEntries(
+    storyDir: string,
+    chapterNumber: number,
+  ): Promise<ContextPackage["selectedContext"]> {
+    const content = await this.readFileOrDefault(join(storyDir, "chapter_summaries.md"));
+    if (!content || content === "(文件尚未创建)") {
+      return [];
+    }
+
+    const recentSummaries = parseChapterSummariesMarkdown(content)
+      .filter((summary) => summary.chapter < chapterNumber)
+      .sort((left, right) => right.chapter - left.chapter)
+      .slice(0, 5);
+    if (recentSummaries.length === 0) {
+      return [];
+    }
+
+    const entries: ContextPackage["selectedContext"] = [];
+    const recentTitles = recentSummaries
+      .map((summary) => [summary.chapter, summary.title].filter(Boolean).join(": "))
+      .filter(Boolean)
+      .join(" | ");
+    if (recentTitles) {
+      entries.push({
+        source: "story/chapter_summaries.md#recent_titles",
+        reason: "Keep recent title history visible to avoid repetitive chapter naming.",
+        excerpt: recentTitles,
+      });
+    }
+
+    const moodTrail = recentSummaries
+      .filter((summary) => summary.mood || summary.chapterType)
+      .map((summary) => `${summary.chapter}: ${summary.mood || "(none)"} / ${summary.chapterType || "(none)"}`)
+      .join(" | ");
+    if (moodTrail) {
+      entries.push({
+        source: "story/chapter_summaries.md#recent_mood_type_trail",
+        reason: "Keep recent mood and chapter-type cadence visible before writing the next chapter.",
+        excerpt: moodTrail,
+      });
+    }
+
+    return entries;
+  }
+
   private async maybeContextSource(
   private async maybeContextSource(
     storyDir: string,
     storyDir: string,
     fileName: string,
     fileName: string,

+ 28 - 8
packages/core/src/agents/writer.ts

@@ -147,7 +147,7 @@ export class WriterAgent extends BaseAgent {
     const targetWords = input.lengthSpec?.target ?? input.wordCountOverride ?? book.chapterWordCount;
     const targetWords = input.lengthSpec?.target ?? input.wordCountOverride ?? book.chapterWordCount;
     const resolvedLengthSpec = input.lengthSpec ?? buildLengthSpec(targetWords, resolvedLanguage);
     const resolvedLengthSpec = input.lengthSpec ?? buildLengthSpec(targetWords, resolvedLanguage);
     const governedMemoryBlocks = input.contextPackage
     const governedMemoryBlocks = input.contextPackage
-      ? buildGovernedMemoryEvidenceBlocks(input.contextPackage)
+      ? buildGovernedMemoryEvidenceBlocks(input.contextPackage, resolvedLanguage)
       : undefined;
       : undefined;
     const englishVarianceBrief = resolvedLanguage === "en"
     const englishVarianceBrief = resolvedLanguage === "en"
       ? await buildEnglishVarianceBrief({
       ? await buildEnglishVarianceBrief({
@@ -183,6 +183,7 @@ export class WriterAgent extends BaseAgent {
           lengthSpec: resolvedLengthSpec,
           lengthSpec: resolvedLengthSpec,
           language: book.language ?? genreProfile.language,
           language: book.language ?? genreProfile.language,
           varianceBrief: englishVarianceBrief?.text,
           varianceBrief: englishVarianceBrief?.text,
+          selectedEvidenceBlock: this.joinGovernedEvidenceBlocks(governedMemoryBlocks),
         })
         })
       : (() => {
       : (() => {
           // Smart context filtering: inject only relevant parts of truth files
           // Smart context filtering: inject only relevant parts of truth files
@@ -289,13 +290,7 @@ export class WriterAgent extends BaseAgent {
       characterMatrix: filteredMatrixForSettlement,
       characterMatrix: filteredMatrixForSettlement,
       volumeOutline,
       volumeOutline,
       selectedEvidenceBlock: governedMemoryBlocks
       selectedEvidenceBlock: governedMemoryBlocks
-        ? [
-            governedMemoryBlocks.hooksBlock,
-            governedMemoryBlocks.summariesBlock,
-            governedMemoryBlocks.volumeSummariesBlock,
-          ]
-          .filter(Boolean)
-          .join("\n")
+        ? this.joinGovernedEvidenceBlocks(governedMemoryBlocks)
         : undefined,
         : undefined,
       chapterIntent: input.chapterIntent,
       chapterIntent: input.chapterIntent,
       contextPackage: input.contextPackage,
       contextPackage: input.contextPackage,
@@ -708,6 +703,7 @@ ${lengthRequirementBlock}
     readonly lengthSpec: LengthSpec;
     readonly lengthSpec: LengthSpec;
     readonly language?: "zh" | "en";
     readonly language?: "zh" | "en";
     readonly varianceBrief?: string;
     readonly varianceBrief?: string;
+    readonly selectedEvidenceBlock?: string;
   }): string {
   }): string {
     const contextSections = params.contextPackage.selectedContext
     const contextSections = params.contextPackage.selectedContext
       .map((entry) => [
       .map((entry) => [
@@ -734,6 +730,9 @@ ${lengthRequirementBlock}
     const varianceBlock = params.varianceBrief
     const varianceBlock = params.varianceBrief
       ? `\n${params.varianceBrief}\n`
       ? `\n${params.varianceBrief}\n`
       : "";
       : "";
+    const selectedEvidenceBlock = params.selectedEvidenceBlock
+      ? `\n${params.selectedEvidenceBlock}\n`
+      : "";
 
 
     if (params.language === "en") {
     if (params.language === "en") {
       return `Write chapter ${params.chapterNumber}.
       return `Write chapter ${params.chapterNumber}.
@@ -743,6 +742,7 @@ ${params.chapterIntent}
 
 
 ## Selected Context
 ## Selected Context
 ${contextSections || "(none)"}
 ${contextSections || "(none)"}
+${selectedEvidenceBlock}
 
 
 ## Rule Stack
 ## Rule Stack
 - Hard: ${params.ruleStack.sections.hard.join(", ") || "(none)"}
 - Hard: ${params.ruleStack.sections.hard.join(", ") || "(none)"}
@@ -768,6 +768,7 @@ ${params.chapterIntent}
 
 
 ## 已选上下文
 ## 已选上下文
 ${contextSections || "(无)"}
 ${contextSections || "(无)"}
+${selectedEvidenceBlock}
 
 
 ## 规则栈
 ## 规则栈
 - 硬护栏:${params.ruleStack.sections.hard.join("、") || "(无)"}
 - 硬护栏:${params.ruleStack.sections.hard.join("、") || "(无)"}
@@ -786,6 +787,25 @@ ${lengthRequirementBlock}
 - 只需输出 PRE_WRITE_CHECK、CHAPTER_TITLE、CHAPTER_CONTENT 三个区块`;
 - 只需输出 PRE_WRITE_CHECK、CHAPTER_TITLE、CHAPTER_CONTENT 三个区块`;
   }
   }
 
 
+  private joinGovernedEvidenceBlocks(blocks: ReturnType<typeof buildGovernedMemoryEvidenceBlocks> | undefined): string | undefined {
+    if (!blocks) {
+      return undefined;
+    }
+
+    const joined = [
+      blocks.titleHistoryBlock,
+      blocks.moodTrailBlock,
+      blocks.canonBlock,
+      blocks.hooksBlock,
+      blocks.summariesBlock,
+      blocks.volumeSummariesBlock,
+    ]
+      .filter((block): block is string => Boolean(block))
+      .join("\n");
+
+    return joined || undefined;
+  }
+
   private buildSettlerGovernedControlBlock(
   private buildSettlerGovernedControlBlock(
     chapterIntent: string,
     chapterIntent: string,
     contextPackage: ContextPackage,
     contextPackage: ContextPackage,

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

@@ -7,6 +7,9 @@ export function buildGovernedMemoryEvidenceBlocks(
   readonly hooksBlock?: string;
   readonly hooksBlock?: string;
   readonly summariesBlock?: string;
   readonly summariesBlock?: string;
   readonly volumeSummariesBlock?: string;
   readonly volumeSummariesBlock?: string;
+  readonly titleHistoryBlock?: string;
+  readonly moodTrailBlock?: string;
+  readonly canonBlock?: string;
 } {
 } {
   const resolvedLanguage = language ?? "zh";
   const resolvedLanguage = language ?? "zh";
   const hookEntries = contextPackage.selectedContext.filter((entry) =>
   const hookEntries = contextPackage.selectedContext.filter((entry) =>
@@ -18,6 +21,16 @@ export function buildGovernedMemoryEvidenceBlocks(
   const volumeSummaryEntries = contextPackage.selectedContext.filter((entry) =>
   const volumeSummaryEntries = contextPackage.selectedContext.filter((entry) =>
     entry.source.startsWith("story/volume_summaries.md#"),
     entry.source.startsWith("story/volume_summaries.md#"),
   );
   );
+  const titleHistoryEntries = contextPackage.selectedContext.filter((entry) =>
+    entry.source === "story/chapter_summaries.md#recent_titles",
+  );
+  const moodTrailEntries = contextPackage.selectedContext.filter((entry) =>
+    entry.source === "story/chapter_summaries.md#recent_mood_type_trail",
+  );
+  const canonEntries = contextPackage.selectedContext.filter((entry) =>
+    entry.source === "story/parent_canon.md"
+    || entry.source === "story/fanfic_canon.md",
+  );
 
 
   return {
   return {
     hooksBlock: hookEntries.length > 0
     hooksBlock: hookEntries.length > 0
@@ -38,6 +51,24 @@ export function buildGovernedMemoryEvidenceBlocks(
           volumeSummaryEntries,
           volumeSummaryEntries,
         )
         )
       : undefined,
       : undefined,
+    titleHistoryBlock: titleHistoryEntries.length > 0
+      ? renderEvidenceBlock(
+          resolvedLanguage === "en" ? "Recent Title History" : "近期标题历史",
+          titleHistoryEntries,
+        )
+      : undefined,
+    moodTrailBlock: moodTrailEntries.length > 0
+      ? renderEvidenceBlock(
+          resolvedLanguage === "en" ? "Recent Mood / Chapter Type Trail" : "近期情绪/章节类型轨迹",
+          moodTrailEntries,
+        )
+      : undefined,
+    canonBlock: canonEntries.length > 0
+      ? renderEvidenceBlock(
+          resolvedLanguage === "en" ? "Canon Evidence" : "正典约束证据",
+          canonEntries,
+        )
+      : undefined,
   };
   };
 }
 }