From c442a9a8050f155b7ed44b45b48b74e668643e30 Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 31 Mar 2026 14:37:20 +0800 Subject: [PATCH 01/53] fix(planner): support chapter range outline anchors --- packages/core/src/__tests__/planner.test.ts | 165 ++++++++++++++++++++ packages/core/src/agents/planner.ts | 78 ++++++++- 2 files changed, 235 insertions(+), 8 deletions(-) diff --git a/packages/core/src/__tests__/planner.test.ts b/packages/core/src/__tests__/planner.test.ts index c6833d04..18057865 100644 --- a/packages/core/src/__tests__/planner.test.ts +++ b/packages/core/src/__tests__/planner.test.ts @@ -242,6 +242,171 @@ describe("PlannerAgent", () => { expect(result.intent.goal).not.toBe("**"); }); + it("uses Chinese chapter-range outline nodes when the chapter falls inside the range", async () => { + await Promise.all([ + writeFile( + join(storyDir, "author_intent.md"), + "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n", + "utf-8", + ), + writeFile( + join(storyDir, "current_focus.md"), + [ + "# Current Focus", + "", + "## Active Focus", + "", + "(Describe what the next 1-3 chapters should prioritize.)", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "volume_outline.md"), + [ + "# Volume Outline", + "", + "## 第1-6章", + "Stay with the early city setup and mentor fallout.", + "", + "## 第7-20章", + "Track the merchant guild's escape route through the western canal.", + "", + ].join("\n"), + "utf-8", + ), + ]); + + const planner = new PlannerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 7, + }); + + expect(result.intent.outlineNode).toContain("merchant guild's escape route"); + expect(result.intent.goal).toContain("merchant guild's escape route"); + expect(result.intent.goal).not.toContain("Describe the long-horizon vision"); + }); + + it("uses English chapter-range outline nodes when the chapter falls inside the range", async () => { + book = { + ...book, + genre: "other", + language: "en", + }; + + await Promise.all([ + writeFile( + join(storyDir, "author_intent.md"), + "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n", + "utf-8", + ), + writeFile( + join(storyDir, "current_focus.md"), + [ + "# Current Focus", + "", + "## Active Focus", + "", + "(Describe what the next 1-3 chapters should prioritize.)", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "volume_outline.md"), + [ + "# Volume Outline", + "", + "### Chapter 1-3", + "Keep the opening pressure on the first examiner.", + "", + "### Chapter 4-6", + "Recover the sealed ledger before dawn.", + "", + ].join("\n"), + "utf-8", + ), + ]); + + const planner = new PlannerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 5, + }); + + expect(result.intent.outlineNode).toContain("sealed ledger"); + expect(result.intent.goal).toContain("sealed ledger"); + expect(result.intent.goal).not.toContain("Describe the long-horizon vision"); + }); + + it("falls back to the first outline directive when no range matches", async () => { + await Promise.all([ + writeFile( + join(storyDir, "author_intent.md"), + "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n", + "utf-8", + ), + writeFile( + join(storyDir, "current_focus.md"), + [ + "# Current Focus", + "", + "## Active Focus", + "", + "(Describe what the next 1-3 chapters should prioritize.)", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "volume_outline.md"), + [ + "# Volume Outline", + "", + "## 第1-6章", + "Stay with the early city setup and mentor fallout.", + "", + "## 第7-20章", + "Track the merchant guild's escape route through the western canal.", + "", + ].join("\n"), + "utf-8", + ), + ]); + + const planner = new PlannerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 25, + }); + + expect(result.intent.outlineNode).toContain("Stay with the early city setup"); + expect(result.intent.goal).toContain("Stay with the early city setup"); + expect(result.intent.goal).not.toContain("merchant guild's escape route"); + }); + it("preserves hard facts from state and canon in mustKeep", async () => { const planner = new PlannerAgent({ client: {} as ConstructorParameters[0]["client"], diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index b1c152ee..d4120032 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -299,18 +299,18 @@ export class PlannerAgent extends BaseAgent { private findOutlineNode(volumeOutline: string, chapterNumber: number): string | undefined { const lines = volumeOutline.split("\n").map((line) => line.trim()).filter(Boolean); - const chapterPatterns = [ + const exactHeadingPatterns = [ new RegExp(`^#+\\s*Chapter\\s*${chapterNumber}\\b`, "i"), new RegExp(`^#+\\s*第\\s*${chapterNumber}\\s*章`), ]; - const inlinePatterns = [ + const exactInlinePatterns = [ new RegExp(`^(?:[-*]\\s+)?(?:\\*\\*)?Chapter\\s*${chapterNumber}(?:[::-])?(?:\\*\\*)?\\s*(.+)$`, "i"), new RegExp(`^(?:[-*]\\s+)?(?:\\*\\*)?第\\s*${chapterNumber}\\s*章(?:[::-])?(?:\\*\\*)?\\s*(.+)$`), ]; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]!; - const match = inlinePatterns + const match = exactInlinePatterns .map((pattern) => line.match(pattern)) .find((result): result is RegExpMatchArray => Boolean(result)); if (!match) continue; @@ -326,12 +326,37 @@ export class PlannerAgent extends BaseAgent { } } - const heading = lines.find((line) => chapterPatterns.some((pattern) => pattern.test(line))); - if (!heading) return this.extractFirstDirective(volumeOutline); + const exactHeading = lines.find((line) => exactHeadingPatterns.some((pattern) => pattern.test(line))); + if (exactHeading) { + const headingIndex = lines.indexOf(exactHeading); + const nextLine = lines[headingIndex + 1]; + return nextLine && !nextLine.startsWith("#") ? nextLine : exactHeading.replace(/^#+\s*/, ""); + } + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + const match = this.matchRangeOutlineLine(line, chapterNumber); + if (!match) continue; + + const inlineContent = this.cleanOutlineContent(match[3]); + if (inlineContent) { + return inlineContent; + } + + const nextContent = this.findNextOutlineContent(lines, index + 1); + if (nextContent) { + return nextContent; + } + } - const headingIndex = lines.indexOf(heading); - const nextLine = lines[headingIndex + 1]; - return nextLine && !nextLine.startsWith("#") ? nextLine : heading.replace(/^#+\s*/, ""); + const rangeHeading = lines.find((line) => this.matchRangeOutlineHeading(line, chapterNumber)); + if (rangeHeading) { + const headingIndex = lines.indexOf(rangeHeading); + const nextLine = lines[headingIndex + 1]; + return nextLine && !nextLine.startsWith("#") ? nextLine : rangeHeading.replace(/^#+\s*/, ""); + } + + return this.extractFirstDirective(volumeOutline); } private cleanOutlineContent(content?: string): string | undefined { @@ -364,6 +389,43 @@ export class PlannerAgent extends BaseAgent { return undefined; } + private matchRangeOutlineLine(line: string, chapterNumber: number): RegExpMatchArray | undefined { + const patterns = [ + /^(?:[-*]\s+)?(?:\*\*)?Chapter\s*(\d+)\s*[-~–—]\s*(\d+)(?:[::-])?(?:\*\*)?\s*(.+)$/i, + /^(?:[-*]\s+)?(?:\*\*)?第\s*(\d+)\s*[-~–—]\s*(\d+)\s*章(?:[::-])?(?:\*\*)?\s*(.+)$/i, + ]; + + for (const pattern of patterns) { + const match = line.match(pattern); + if (!match) continue; + if (this.isChapterWithinRange(match[1], match[2], chapterNumber)) { + return match; + } + } + + return undefined; + } + + private matchRangeOutlineHeading(line: string, chapterNumber: number): boolean { + return this.matchRangeOutlineLine(line, chapterNumber) !== undefined + || [ + /^#+\s*Chapter\s*(\d+)\s*[-~–—]\s*(\d+)\b/i, + /^#+\s*第\s*(\d+)\s*[-~–—]\s*(\d+)\s*章/i, + ].some((pattern) => { + const match = line.match(pattern); + return match ? this.isChapterWithinRange(match[1], match[2], chapterNumber) : false; + }); + } + + private isChapterWithinRange(startText: string | undefined, endText: string | undefined, chapterNumber: number): boolean { + const start = Number.parseInt(startText ?? "", 10); + const end = Number.parseInt(endText ?? "", 10); + if (!Number.isFinite(start) || !Number.isFinite(end)) return false; + const lower = Math.min(start, end); + const upper = Math.max(start, end); + return chapterNumber >= lower && chapterNumber <= upper; + } + private hasKeywordOverlap(left: string, right: string): boolean { const keywords = this.extractKeywords(left); if (keywords.length === 0) return false; From 2061c7ef30d8fc86379b999c980155f01277a7e7 Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 31 Mar 2026 15:05:06 +0800 Subject: [PATCH 02/53] fix(planner): harden exact and range outline matching --- packages/core/src/__tests__/planner.test.ts | 187 ++++++++++++++++++-- packages/core/src/agents/planner.ts | 117 +++++++----- 2 files changed, 248 insertions(+), 56 deletions(-) diff --git a/packages/core/src/__tests__/planner.test.ts b/packages/core/src/__tests__/planner.test.ts index 18057865..8ced9dfb 100644 --- a/packages/core/src/__tests__/planner.test.ts +++ b/packages/core/src/__tests__/planner.test.ts @@ -242,7 +242,61 @@ describe("PlannerAgent", () => { expect(result.intent.goal).not.toBe("**"); }); - it("uses Chinese chapter-range outline nodes when the chapter falls inside the range", async () => { + it("does not confuse Chapter 1 with Chapter 10 when matching exact English chapter labels", async () => { + book = { + ...book, + genre: "other", + language: "en", + }; + + await Promise.all([ + writeFile( + join(storyDir, "current_focus.md"), + [ + "# Current Focus", + "", + "## Active Focus", + "", + "(Describe what the next 1-3 chapters should prioritize.)", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "volume_outline.md"), + [ + "# Volume Outline", + "", + "### Chapter 10", + "This late-volume node should not be selected for chapter one.", + "", + "### Chapter 1", + "Open with the dead examiner and the sealed folio dispute.", + "", + ].join("\n"), + "utf-8", + ), + ]); + + const planner = new PlannerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 1, + }); + + expect(result.intent.outlineNode).toContain("dead examiner"); + expect(result.intent.outlineNode).not.toContain("late-volume"); + expect(result.intent.goal).toContain("dead examiner"); + }); + + it("uses inline Chinese exact chapter labels with a title suffix", async () => { await Promise.all([ writeFile( join(storyDir, "author_intent.md"), @@ -266,10 +320,59 @@ describe("PlannerAgent", () => { [ "# Volume Outline", "", - "## 第1-6章", + "第 7 章:在码头接头并截住逃跑账房。", + "", + ].join("\n"), + "utf-8", + ), + ]); + + const planner = new PlannerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 7, + }); + + expect(result.intent.outlineNode).toContain("在码头接头"); + expect(result.intent.goal).toContain("在码头接头"); + expect(result.intent.goal).not.toContain("Describe the long-horizon vision"); + }); + + it("uses standalone Chinese chapter-range labels when the chapter falls inside the range", async () => { + await Promise.all([ + writeFile( + join(storyDir, "author_intent.md"), + "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n", + "utf-8", + ), + writeFile( + join(storyDir, "current_focus.md"), + [ + "# Current Focus", + "", + "## Active Focus", + "", + "(Describe what the next 1-3 chapters should prioritize.)", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "volume_outline.md"), + [ + "# Volume Outline", + "", + "第1-6章", "Stay with the early city setup and mentor fallout.", "", - "## 第7-20章", + "第7-20章", "Track the merchant guild's escape route through the western canal.", "", ].join("\n"), @@ -295,7 +398,7 @@ describe("PlannerAgent", () => { expect(result.intent.goal).not.toContain("Describe the long-horizon vision"); }); - it("uses English chapter-range outline nodes when the chapter falls inside the range", async () => { + it("uses standalone English chapter-range labels at the start of the range", async () => { book = { ...book, genre: "other", @@ -325,10 +428,10 @@ describe("PlannerAgent", () => { [ "# Volume Outline", "", - "### Chapter 1-3", + "Chapter 1-3", "Keep the opening pressure on the first examiner.", "", - "### Chapter 4-6", + "Chapter 4-6", "Recover the sealed ledger before dawn.", "", ].join("\n"), @@ -346,15 +449,78 @@ describe("PlannerAgent", () => { const result = await planner.planChapter({ book, bookDir, - chapterNumber: 5, + chapterNumber: 4, }); expect(result.intent.outlineNode).toContain("sealed ledger"); expect(result.intent.goal).toContain("sealed ledger"); + expect(result.intent.outlineNode).not.toContain("6"); expect(result.intent.goal).not.toContain("Describe the long-horizon vision"); }); - it("falls back to the first outline directive when no range matches", async () => { + it("uses the next paragraph for bold standalone English chapter-range labels instead of bleeding into the next range", async () => { + book = { + ...book, + genre: "other", + language: "en", + }; + + await Promise.all([ + writeFile( + join(storyDir, "author_intent.md"), + "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n", + "utf-8", + ), + writeFile( + join(storyDir, "current_focus.md"), + [ + "# Current Focus", + "", + "## Active Focus", + "", + "(Describe what the next 1-3 chapters should prioritize.)", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "volume_outline.md"), + [ + "# Volume Outline", + "", + "**Chapter 1-3:**", + "Keep the opening pressure on the first examiner.", + "", + "**Chapter 4-6:**", + "Recover the sealed ledger before dawn.", + "", + "**Chapter 7-9:**", + "Trigger the registry fire and expose the false witness.", + "", + ].join("\n"), + "utf-8", + ), + ]); + + const planner = new PlannerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 4, + }); + + expect(result.intent.outlineNode).toContain("sealed ledger"); + expect(result.intent.outlineNode).not.toContain("registry fire"); + expect(result.intent.goal).toContain("sealed ledger"); + }); + + it("falls back to the first outline directive when no standalone range matches", async () => { await Promise.all([ writeFile( join(storyDir, "author_intent.md"), @@ -378,10 +544,10 @@ describe("PlannerAgent", () => { [ "# Volume Outline", "", - "## 第1-6章", + "第1-6章", "Stay with the early city setup and mentor fallout.", "", - "## 第7-20章", + "第7-20章", "Track the merchant guild's escape route through the western canal.", "", ].join("\n"), @@ -404,6 +570,7 @@ describe("PlannerAgent", () => { expect(result.intent.outlineNode).toContain("Stay with the early city setup"); expect(result.intent.goal).toContain("Stay with the early city setup"); + expect(result.intent.outlineNode).not.toBe("第1-6章"); expect(result.intent.goal).not.toContain("merchant guild's escape route"); }); diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index d4120032..28d53bfd 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -299,20 +299,10 @@ export class PlannerAgent extends BaseAgent { private findOutlineNode(volumeOutline: string, chapterNumber: number): string | undefined { const lines = volumeOutline.split("\n").map((line) => line.trim()).filter(Boolean); - const exactHeadingPatterns = [ - new RegExp(`^#+\\s*Chapter\\s*${chapterNumber}\\b`, "i"), - new RegExp(`^#+\\s*第\\s*${chapterNumber}\\s*章`), - ]; - const exactInlinePatterns = [ - new RegExp(`^(?:[-*]\\s+)?(?:\\*\\*)?Chapter\\s*${chapterNumber}(?:[::-])?(?:\\*\\*)?\\s*(.+)$`, "i"), - new RegExp(`^(?:[-*]\\s+)?(?:\\*\\*)?第\\s*${chapterNumber}\\s*章(?:[::-])?(?:\\*\\*)?\\s*(.+)$`), - ]; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]!; - const match = exactInlinePatterns - .map((pattern) => line.match(pattern)) - .find((result): result is RegExpMatchArray => Boolean(result)); + const match = this.matchExactOutlineLine(line, chapterNumber); if (!match) continue; const inlineContent = this.cleanOutlineContent(match[1]); @@ -326,13 +316,6 @@ export class PlannerAgent extends BaseAgent { } } - const exactHeading = lines.find((line) => exactHeadingPatterns.some((pattern) => pattern.test(line))); - if (exactHeading) { - const headingIndex = lines.indexOf(exactHeading); - const nextLine = lines[headingIndex + 1]; - return nextLine && !nextLine.startsWith("#") ? nextLine : exactHeading.replace(/^#+\s*/, ""); - } - for (let index = 0; index < lines.length; index += 1) { const line = lines[index]!; const match = this.matchRangeOutlineLine(line, chapterNumber); @@ -349,11 +332,32 @@ export class PlannerAgent extends BaseAgent { } } - const rangeHeading = lines.find((line) => this.matchRangeOutlineHeading(line, chapterNumber)); - if (rangeHeading) { - const headingIndex = lines.indexOf(rangeHeading); - const nextLine = lines[headingIndex + 1]; - return nextLine && !nextLine.startsWith("#") ? nextLine : rangeHeading.replace(/^#+\s*/, ""); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + if (!this.isOutlineAnchorLine(line)) continue; + + const exactMatch = this.matchAnyExactOutlineLine(line); + if (exactMatch) { + const inlineContent = this.cleanOutlineContent(exactMatch[1]); + if (inlineContent) { + return inlineContent; + } + } + + const rangeMatch = this.matchAnyRangeOutlineLine(line); + if (rangeMatch) { + const inlineContent = this.cleanOutlineContent(rangeMatch[3]); + if (inlineContent) { + return inlineContent; + } + } + + const nextContent = this.findNextOutlineContent(lines, index + 1); + if (nextContent) { + return nextContent; + } + + break; } return this.extractFirstDirective(volumeOutline); @@ -369,17 +373,18 @@ export class PlannerAgent extends BaseAgent { private findNextOutlineContent(lines: ReadonlyArray, startIndex: number): string | undefined { for (let index = startIndex; index < lines.length; index += 1) { const line = lines[index]!; - if (!line || line.startsWith("#")) { + if (!line) { continue; } - if ( - /^(?:[-*]\s+)?(?:\*\*)?Chapter\s*\d+(?:[::-])?(?:\*\*)?\s*$/i.test(line) - || /^(?:[-*]\s+)?(?:\*\*)?第\s*\d+\s*章(?:[::-])?(?:\*\*)?\s*$/.test(line) - ) { + if (this.isOutlineAnchorLine(line)) { return undefined; } + if (line.startsWith("#")) { + continue; + } + const cleaned = this.cleanOutlineContent(line); if (cleaned) { return cleaned; @@ -389,32 +394,52 @@ export class PlannerAgent extends BaseAgent { return undefined; } - private matchRangeOutlineLine(line: string, chapterNumber: number): RegExpMatchArray | undefined { + private matchExactOutlineLine(line: string, chapterNumber: number): RegExpMatchArray | undefined { const patterns = [ - /^(?:[-*]\s+)?(?:\*\*)?Chapter\s*(\d+)\s*[-~–—]\s*(\d+)(?:[::-])?(?:\*\*)?\s*(.+)$/i, - /^(?:[-*]\s+)?(?:\*\*)?第\s*(\d+)\s*[-~–—]\s*(\d+)\s*章(?:[::-])?(?:\*\*)?\s*(.+)$/i, + new RegExp(`^(?:#+\\s*)?(?:[-*]\\s+)?(?:\\*\\*)?Chapter\\s*${chapterNumber}(?!\\d|\\s*[-~–—]\\s*\\d)(?:[::-])?(?:\\*\\*)?\\s*(.*)$`, "i"), + new RegExp(`^(?:#+\\s*)?(?:[-*]\\s+)?(?:\\*\\*)?第\\s*${chapterNumber}\\s*章(?!\\d|\\s*[-~–—]\\s*\\d)(?:[::-])?(?:\\*\\*)?\\s*(.*)$`), ]; - for (const pattern of patterns) { - const match = line.match(pattern); - if (!match) continue; - if (this.isChapterWithinRange(match[1], match[2], chapterNumber)) { - return match; - } + return patterns + .map((pattern) => line.match(pattern)) + .find((result): result is RegExpMatchArray => Boolean(result)); + } + + private matchAnyExactOutlineLine(line: string): RegExpMatchArray | undefined { + const patterns = [ + /^(?:#+\s*)?(?:[-*]\s+)?(?:\*\*)?Chapter\s*\d+(?!\s*[-~–—]\s*\d)(?:[::-])?(?:\*\*)?\s*(.*)$/i, + /^(?:#+\s*)?(?:[-*]\s+)?(?:\*\*)?第\s*\d+\s*章(?!\s*[-~–—]\s*\d)(?:[::-])?(?:\*\*)?\s*(.*)$/i, + ]; + + return patterns + .map((pattern) => line.match(pattern)) + .find((result): result is RegExpMatchArray => Boolean(result)); + } + + private matchRangeOutlineLine(line: string, chapterNumber: number): RegExpMatchArray | undefined { + const match = this.matchAnyRangeOutlineLine(line); + if (!match) return undefined; + if (this.isChapterWithinRange(match[1], match[2], chapterNumber)) { + return match; } return undefined; } - private matchRangeOutlineHeading(line: string, chapterNumber: number): boolean { - return this.matchRangeOutlineLine(line, chapterNumber) !== undefined - || [ - /^#+\s*Chapter\s*(\d+)\s*[-~–—]\s*(\d+)\b/i, - /^#+\s*第\s*(\d+)\s*[-~–—]\s*(\d+)\s*章/i, - ].some((pattern) => { - const match = line.match(pattern); - return match ? this.isChapterWithinRange(match[1], match[2], chapterNumber) : false; - }); + private matchAnyRangeOutlineLine(line: string): RegExpMatchArray | undefined { + const patterns = [ + /^(?:#+\s*)?(?:[-*]\s+)?(?:\*\*)?Chapter\s*(\d+)\s*[-~–—]\s*(\d+)\b(?:[::-])?(?:\*\*)?\s*(.*)$/i, + /^(?:#+\s*)?(?:[-*]\s+)?(?:\*\*)?第\s*(\d+)\s*[-~–—]\s*(\d+)\s*章(?:[::-])?(?:\*\*)?\s*(.*)$/i, + ]; + + return patterns + .map((pattern) => line.match(pattern)) + .find((result): result is RegExpMatchArray => Boolean(result)); + } + + private isOutlineAnchorLine(line: string): boolean { + return this.matchAnyExactOutlineLine(line) !== undefined + || this.matchAnyRangeOutlineLine(line) !== undefined; } private isChapterWithinRange(startText: string | undefined, endText: string | undefined, chapterNumber: number): boolean { From b9c05e1ad18998f3df4a6ff740c678db5cc98d93 Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 31 Mar 2026 15:22:11 +0800 Subject: [PATCH 03/53] fix(planner): demote stale current focus below outline anchors --- .../core/src/__tests__/pipeline-agent.test.ts | 33 ++++ packages/core/src/__tests__/planner.test.ts | 167 +++++++++++++++++- packages/core/src/agents/planner.ts | 56 +++++- 3 files changed, 246 insertions(+), 10 deletions(-) diff --git a/packages/core/src/__tests__/pipeline-agent.test.ts b/packages/core/src/__tests__/pipeline-agent.test.ts index dd38c3f4..ec652eb9 100644 --- a/packages/core/src/__tests__/pipeline-agent.test.ts +++ b/packages/core/src/__tests__/pipeline-agent.test.ts @@ -117,6 +117,39 @@ describe("agent pipeline tools", () => { .resolves.toContain("mentor fallout"); }); + it("keeps update_current_focus usable for explicit local overrides through the tool surface", async () => { + await executeAgentTool(pipeline, state, config, "update_current_focus", { + bookId, + content: [ + "# Current Focus", + "", + "## Active Focus", + "", + "Keep the merchant guild trail visible in the background.", + "", + "## Local Override", + "", + "Stay inside the mentor debt confrontation first and delay the guild chase by one chapter.", + "", + ].join("\n"), + }); + + const planResult = JSON.parse(await executeAgentTool( + pipeline, + state, + config, + "plan_chapter", + { bookId }, + )); + + const runtimePath = join(state.bookDir(bookId), planResult.intentPath); + const intentMarkdown = await readFile(runtimePath, "utf-8"); + expect(intentMarkdown).toContain([ + "## Goal", + "Stay inside the mentor debt confrontation first and delay the guild chase by one chapter.", + ].join("\n")); + }); + it("blocks write_full_pipeline when runtime progress is ahead of the chapter index", async () => { const stateDir = join(state.bookDir(bookId), "story", "state"); await mkdir(stateDir, { recursive: true }); diff --git a/packages/core/src/__tests__/planner.test.ts b/packages/core/src/__tests__/planner.test.ts index 8ced9dfb..2e8c98c4 100644 --- a/packages/core/src/__tests__/planner.test.ts +++ b/packages/core/src/__tests__/planner.test.ts @@ -77,7 +77,13 @@ describe("PlannerAgent", () => { await rm(root, { recursive: true, force: true }); }); - it("uses current focus as the chapter goal and writes a chapter intent file", async () => { + it("uses current focus as the chapter goal when no outline node is available", async () => { + await writeFile( + join(storyDir, "volume_outline.md"), + "# Volume Outline\n", + "utf-8", + ); + const planner = new PlannerAgent({ client: {} as ConstructorParameters[0]["client"], model: "test-model", @@ -95,6 +101,158 @@ describe("PlannerAgent", () => { await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("mentor conflict"); }); + it("prefers a matched outline node over ordinary current focus text", async () => { + await Promise.all([ + writeFile( + join(storyDir, "current_focus.md"), + [ + "# Current Focus", + "", + "## Active Focus", + "", + "Pull the next chapter back toward the mentor fallout instead of the guild route.", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "volume_outline.md"), + [ + "# Volume Outline", + "", + "## Chapter 3", + "Track the merchant guild's escape route through the western canal.", + "", + ].join("\n"), + "utf-8", + ), + ]); + + const planner = new PlannerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 3, + }); + + expect(result.intent.outlineNode).toContain("merchant guild's escape route"); + expect(result.intent.goal).toContain("merchant guild's escape route"); + expect(result.intent.goal).not.toContain("mentor fallout"); + expect(result.intent.conflicts).toEqual([]); + }); + + it("lets explicit local override focus beat the matched outline node", async () => { + await Promise.all([ + writeFile( + join(storyDir, "current_focus.md"), + [ + "# Current Focus", + "", + "## Active Focus", + "", + "Keep pressure on the guild route in the background.", + "", + "## Local Override", + "", + "Stay inside the mentor debt confrontation first and delay the canal pursuit by one chapter.", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "volume_outline.md"), + [ + "# Volume Outline", + "", + "## Chapter 3", + "Track the merchant guild's escape route through the western canal.", + "", + ].join("\n"), + "utf-8", + ), + ]); + + const planner = new PlannerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 3, + }); + + expect(result.intent.outlineNode).toContain("merchant guild's escape route"); + expect(result.intent.goal).toContain("mentor debt confrontation"); + expect(result.intent.goal).not.toContain("merchant guild's escape route"); + expect(result.intent.conflicts).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: "outline_vs_current_focus", + resolution: "allow explicit current focus override", + }), + ])); + }); + + it("keeps external context above both outline anchors and current focus", async () => { + await Promise.all([ + writeFile( + join(storyDir, "current_focus.md"), + [ + "# Current Focus", + "", + "## Active Focus", + "", + "Pull the next chapter back toward the mentor fallout instead of the guild route.", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "volume_outline.md"), + [ + "# Volume Outline", + "", + "## Chapter 3", + "Track the merchant guild's escape route through the western canal.", + "", + ].join("\n"), + "utf-8", + ), + ]); + + const planner = new PlannerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 3, + externalContext: "Ignore the canal pursuit for now and force the next chapter into the mentor debt confrontation.", + }); + + expect(result.intent.goal).toContain("mentor debt confrontation"); + expect(result.intent.goal).not.toContain("merchant guild's escape route"); + expect(result.intent.conflicts).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: "outline_vs_request", + resolution: "allow local outline deferral", + }), + ])); + }); + it("ignores the default current_focus placeholder and falls back to author intent when no chapter outline is available", async () => { await Promise.all([ writeFile( @@ -756,6 +914,11 @@ describe("PlannerAgent", () => { ].join("\n"), "utf-8", ), + writeFile( + join(storyDir, "volume_outline.md"), + "# Volume Outline\n", + "utf-8", + ), ]); const planner = new PlannerAgent({ @@ -768,7 +931,7 @@ describe("PlannerAgent", () => { const result = await planner.planChapter({ book, bookDir, - chapterNumber: 3, + chapterNumber: 2, }); expect(result.intent.goal).toContain("private confrontation"); diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index 28d53bfd..644db17a 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -66,7 +66,7 @@ export class PlannerAgent extends BaseAgent { const mustKeep = this.collectMustKeep(currentState, storyBible); const mustAvoid = this.collectMustAvoid(currentFocus, parsedRules.rules.prohibitions); const styleEmphasis = this.collectStyleEmphasis(authorIntent, currentFocus); - const conflicts = this.collectConflicts(input.externalContext, outlineNode, volumeOutline); + const conflicts = this.collectConflicts(input.externalContext, currentFocus, outlineNode, volumeOutline); const planningAnchor = conflicts.length > 0 ? undefined : outlineNode; const memorySelection = await retrieveMemorySelection({ bookDir: input.bookDir, @@ -121,10 +121,12 @@ export class PlannerAgent extends BaseAgent { ): string { const first = this.extractFirstDirective(externalContext); if (first) return first; - const focus = this.extractFocusGoal(currentFocus); - if (focus) return focus; + const localOverride = this.extractLocalOverrideGoal(currentFocus); + if (localOverride) return localOverride; const outline = this.extractFirstDirective(outlineNode); if (outline) return outline; + const focus = this.extractFocusGoal(currentFocus); + if (focus) return focus; const author = this.extractFirstDirective(authorIntent); if (author) return author; return `Advance chapter ${chapterNumber} with clear narrative focus.`; @@ -169,19 +171,34 @@ export class PlannerAgent extends BaseAgent { private collectConflicts( externalContext: string | undefined, + currentFocus: string, outlineNode: string | undefined, volumeOutline: string, ): ChapterConflict[] { - if (!externalContext) return []; const outlineText = outlineNode ?? volumeOutline; if (!outlineText || outlineText === "(文件尚未创建)") return []; - const indicatesOverride = /ignore|skip|defer|instead|不要|别|先别|暂停/i.test(externalContext); - if (!indicatesOverride && this.hasKeywordOverlap(externalContext, outlineText)) return []; + if (externalContext) { + const indicatesOverride = /ignore|skip|defer|instead|不要|别|先别|暂停/i.test(externalContext); + if (!indicatesOverride && this.hasKeywordOverlap(externalContext, outlineText)) return []; + + return [ + { + type: "outline_vs_request", + resolution: "allow local outline deferral", + }, + ]; + } + + const localOverride = this.extractLocalOverrideGoal(currentFocus); + if (!localOverride || !outlineNode) { + return []; + } return [ { - type: "outline_vs_request", - resolution: "allow local outline deferral", + type: "outline_vs_current_focus", + resolution: "allow explicit current focus override", + detail: localOverride, }, ]; } @@ -224,6 +241,29 @@ export class PlannerAgent extends BaseAgent { return directives.join(this.containsChinese(focusSection) ? ";" : "; "); } + private extractLocalOverrideGoal(currentFocus: string): string | undefined { + const overrideSection = this.extractSection(currentFocus, [ + "local override", + "explicit override", + "chapter override", + "local task override", + "局部覆盖", + "本章覆盖", + "临时覆盖", + "当前覆盖", + ]); + if (!overrideSection) { + return undefined; + } + + const directives = this.extractListItems(overrideSection, 3); + if (directives.length > 0) { + return directives.join(this.containsChinese(overrideSection) ? ";" : "; "); + } + + return this.extractFirstDirective(overrideSection); + } + private extractFocusStyleItems(currentFocus: string, limit = 3): string[] { const focusSection = this.extractSection(currentFocus, [ "active focus", From 6be56cf08c86765f2e0d563a9878050417b0479d Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 31 Mar 2026 15:31:56 +0800 Subject: [PATCH 04/53] feat(planner): emit structured writing directives --- packages/core/src/__tests__/models.test.ts | 12 ++ packages/core/src/__tests__/planner.test.ts | 71 ++++++++++ packages/core/src/agents/planner.ts | 131 ++++++++++++++++++- packages/core/src/models/input-governance.ts | 4 + 4 files changed, 217 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/models.test.ts b/packages/core/src/__tests__/models.test.ts index ac48e0b1..bfbb70f3 100644 --- a/packages/core/src/__tests__/models.test.ts +++ b/packages/core/src/__tests__/models.test.ts @@ -495,6 +495,10 @@ describe("ChapterIntentSchema", () => { chapter: 12, goal: "Pull focus back to the mentor conflict", outlineNode: "Volume 2 / Chapter 12", + sceneDirective: "Break the repeated investigation-room rhythm with a location change.", + arcDirective: "Advance toward the next concrete arc beat instead of replaying the fallback setup.", + moodDirective: "Release pressure for one chapter before the next escalation.", + titleDirective: "Avoid another ledger title and use a new concrete image.", mustKeep: ["Protagonist remains injured"], mustAvoid: ["Do not reveal the mastermind"], styleEmphasis: ["dialogue tension", "character conflict"], @@ -517,6 +521,10 @@ describe("ChapterIntentSchema", () => { expect(result.chapter).toBe(12); expect(result.goal).toContain("mentor conflict"); + expect(result.sceneDirective).toContain("location change"); + expect(result.arcDirective).toContain("arc beat"); + expect(result.moodDirective).toContain("Release pressure"); + expect(result.titleDirective).toContain("ledger title"); expect(result.conflicts).toHaveLength(1); expect(result.hookAgenda.mustAdvance).toEqual(["H019"]); expect(result.hookAgenda.eligibleResolve).toEqual(["H045"]); @@ -533,6 +541,10 @@ describe("ChapterIntentSchema", () => { goal: "Establish the protagonist's first setback", }); + expect(result.sceneDirective).toBeUndefined(); + expect(result.arcDirective).toBeUndefined(); + expect(result.moodDirective).toBeUndefined(); + expect(result.titleDirective).toBeUndefined(); expect(result.mustKeep).toEqual([]); expect(result.mustAvoid).toEqual([]); expect(result.styleEmphasis).toEqual([]); diff --git a/packages/core/src/__tests__/planner.test.ts b/packages/core/src/__tests__/planner.test.ts index 2e8c98c4..00d46369 100644 --- a/packages/core/src/__tests__/planner.test.ts +++ b/packages/core/src/__tests__/planner.test.ts @@ -253,6 +253,77 @@ describe("PlannerAgent", () => { ])); }); + it("emits structured directives when fallback planning, chapter type repetition, and title collapse stack up", async () => { + book = { + ...book, + genre: "other", + language: "en", + }; + + await Promise.all([ + writeFile( + join(storyDir, "author_intent.md"), + "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n", + "utf-8", + ), + writeFile( + join(storyDir, "current_focus.md"), + [ + "# Current Focus", + "", + "## Active Focus", + "", + "(Describe what the next 1-3 chapters should prioritize.)", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "volume_outline.md"), + [ + "# Volume Outline", + "", + "## Chapter 8", + "Expose the registry clerk's hidden ledger in the floodgate archive.", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "chapter_summaries.md"), + [ + "# Chapter Summaries", + "", + "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + "| 1 | Ledger in Rain | Taryn | Taryn checks the first false folio | None | hook advanced | tight | investigation |", + "| 2 | Ledger at Dusk | Taryn | Taryn questions the dock clerk | None | hook advanced | tight | investigation |", + "| 3 | Ledger Below | Taryn | Taryn searches the under-archive | None | hook advanced | tight | investigation |", + "", + ].join("\n"), + "utf-8", + ), + ]); + + const planner = new PlannerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 4, + }); + + expect(result.intent.arcDirective).toContain("fallback"); + expect(result.intent.sceneDirective).toContain("investigation"); + expect(result.intent.titleDirective?.toLowerCase()).toContain("ledger"); + expect(result.intent.moodDirective).toBeUndefined(); + }); + it("ignores the default current_focus placeholder and falls back to author intent when no chapter outline is available", async () => { await Promise.all([ writeFile( diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index 644db17a..a3a323d1 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -6,6 +6,7 @@ import { parseBookRules } from "../models/book-rules.js"; import { ChapterIntentSchema, type ChapterConflict, type ChapterIntent } from "../models/input-governance.js"; import { buildPlannerHookAgenda, + parseChapterSummariesMarkdown, renderHookSnapshot, renderSummarySnapshot, retrieveMemorySelection, @@ -40,6 +41,7 @@ export class PlannerAgent extends BaseAgent { currentFocus: join(storyDir, "current_focus.md"), storyBible: join(storyDir, "story_bible.md"), volumeOutline: join(storyDir, "volume_outline.md"), + chapterSummaries: join(storyDir, "chapter_summaries.md"), bookRules: join(storyDir, "book_rules.md"), currentState: join(storyDir, "current_state.md"), } as const; @@ -49,6 +51,7 @@ export class PlannerAgent extends BaseAgent { currentFocus, storyBible, volumeOutline, + chapterSummaries, bookRulesRaw, currentState, ] = await Promise.all([ @@ -56,11 +59,13 @@ export class PlannerAgent extends BaseAgent { this.readFileOrDefault(sourcePaths.currentFocus), this.readFileOrDefault(sourcePaths.storyBible), this.readFileOrDefault(sourcePaths.volumeOutline), + this.readFileOrDefault(sourcePaths.chapterSummaries), this.readFileOrDefault(sourcePaths.bookRules), this.readFileOrDefault(sourcePaths.currentState), ]); const outlineNode = this.findOutlineNode(volumeOutline, input.chapterNumber); + const matchedOutlineAnchor = this.hasMatchedOutlineAnchor(volumeOutline, input.chapterNumber); const goal = this.deriveGoal(input.externalContext, currentFocus, authorIntent, outlineNode, input.chapterNumber); const parsedRules = parseBookRules(bookRulesRaw); const mustKeep = this.collectMustKeep(currentState, storyBible); @@ -79,11 +84,20 @@ export class PlannerAgent extends BaseAgent { hooks: memorySelection.activeHooks, chapterNumber: input.chapterNumber, }); + const directives = this.buildStructuredDirectives({ + chapterNumber: input.chapterNumber, + language: input.book.language, + volumeOutline, + outlineNode, + matchedOutlineAnchor, + chapterSummaries, + }); const intent = ChapterIntentSchema.parse({ chapter: input.chapterNumber, goal, outlineNode, + ...directives, mustKeep, mustAvoid, styleEmphasis, @@ -105,13 +119,38 @@ export class PlannerAgent extends BaseAgent { plannerInputs: [ ...Object.values(sourcePaths), join(storyDir, "pending_hooks.md"), - join(storyDir, "chapter_summaries.md"), ...(memorySelection.dbPath ? [memorySelection.dbPath] : []), ], runtimePath, }; } + private buildStructuredDirectives(input: { + readonly chapterNumber: number; + readonly language?: string; + readonly volumeOutline: string; + readonly outlineNode: string | undefined; + readonly matchedOutlineAnchor: boolean; + readonly chapterSummaries: string; + }): Pick { + const recentSummaries = parseChapterSummariesMarkdown(input.chapterSummaries) + .filter((summary) => summary.chapter < input.chapterNumber) + .sort((left, right) => right.chapter - left.chapter) + .slice(0, 4); + + return { + arcDirective: this.buildArcDirective( + input.language, + input.volumeOutline, + input.outlineNode, + input.matchedOutlineAnchor, + ), + sceneDirective: this.buildSceneDirective(input.language, recentSummaries), + moodDirective: undefined, + titleDirective: this.buildTitleDirective(input.language, recentSummaries), + }; + } + private deriveGoal( externalContext: string | undefined, currentFocus: string, @@ -275,6 +314,75 @@ export class PlannerAgent extends BaseAgent { return this.extractListItems(focusSection, limit); } + private buildArcDirective( + language: string | undefined, + volumeOutline: string, + outlineNode: string | undefined, + matchedOutlineAnchor: boolean, + ): string | undefined { + if (matchedOutlineAnchor || !outlineNode || volumeOutline === "(文件尚未创建)") { + return undefined; + } + + return this.isChineseLanguage(language) + ? "不要继续依赖卷纲的 fallback 指令,必须把本章推进到新的弧线节点或地点变化。" + : "Do not keep leaning on the outline fallback. Force this chapter toward a fresh arc beat or location change."; + } + + private buildSceneDirective( + language: string | undefined, + recentSummaries: ReadonlyArray<{ chapterType: string }>, + ): string | undefined { + const repeatedType = this.findRepeatedValue( + recentSummaries.map((summary) => summary.chapterType), + 3, + ); + if (!repeatedType) { + return undefined; + } + + return this.isChineseLanguage(language) + ? `最近章节连续停留在“${repeatedType}”,本章必须更换场景容器、地点或行动方式。` + : `Recent chapters are stuck in repeated ${repeatedType} beats. Change the scene container, location, or action pattern this chapter.`; + } + + private buildTitleDirective( + language: string | undefined, + recentSummaries: ReadonlyArray<{ title: string }>, + ): string | undefined { + const tokenCounts = new Map(); + + for (const summary of recentSummaries) { + for (const token of this.extractKeywords(summary.title)) { + tokenCounts.set(token, (tokenCounts.get(token) ?? 0) + 1); + } + } + + const repeatedToken = [...tokenCounts.entries()] + .sort((left, right) => right[1] - left[1]) + .find((entry) => entry[1] >= 3)?.[0]; + if (!repeatedToken) { + return undefined; + } + + return this.isChineseLanguage(language) + ? `标题不要再围绕“${repeatedToken}”重复命名,换一个新的意象或动作焦点。` + : `Avoid another ${repeatedToken}-centric title. Pick a new image or action focus for this chapter title.`; + } + + private findRepeatedValue(values: ReadonlyArray, threshold: number): string | undefined { + const counts = new Map(); + + for (const value of values.map((value) => value.trim()).filter(Boolean)) { + counts.set(value, (counts.get(value) ?? 0) + 1); + if ((counts.get(value) ?? 0) >= threshold) { + return value; + } + } + + return undefined; + } + private extractSection(content: string, headings: ReadonlyArray): string | undefined { const targets = headings.map((heading) => this.normalizeHeading(heading)); const lines = content.split("\n"); @@ -434,6 +542,14 @@ export class PlannerAgent extends BaseAgent { return undefined; } + private hasMatchedOutlineAnchor(volumeOutline: string, chapterNumber: number): boolean { + const lines = volumeOutline.split("\n").map((line) => line.trim()).filter(Boolean); + return lines.some((line) => + this.matchExactOutlineLine(line, chapterNumber) !== undefined + || this.matchRangeOutlineLine(line, chapterNumber) !== undefined, + ); + } + private matchExactOutlineLine(line: string, chapterNumber: number): RegExpMatchArray | undefined { const patterns = [ new RegExp(`^(?:#+\\s*)?(?:[-*]\\s+)?(?:\\*\\*)?Chapter\\s*${chapterNumber}(?!\\d|\\s*[-~–—]\\s*\\d)(?:[::-])?(?:\\*\\*)?\\s*(.*)$`, "i"), @@ -524,6 +640,12 @@ export class PlannerAgent extends BaseAgent { const styleEmphasis = intent.styleEmphasis.length > 0 ? intent.styleEmphasis.map((item) => `- ${item}`).join("\n") : "- none"; + const directives = [ + intent.arcDirective ? `- arc: ${intent.arcDirective}` : undefined, + intent.sceneDirective ? `- scene: ${intent.sceneDirective}` : undefined, + intent.moodDirective ? `- mood: ${intent.moodDirective}` : undefined, + intent.titleDirective ? `- title: ${intent.titleDirective}` : undefined, + ].filter(Boolean).join("\n") || "- none"; const hookAgenda = [ "### Must Advance", intent.hookAgenda.mustAdvance.length > 0 @@ -564,6 +686,9 @@ export class PlannerAgent extends BaseAgent { "## Style Emphasis", styleEmphasis, "", + "## Structured Directives", + directives, + "", "## Hook Agenda", hookAgenda, "", @@ -583,6 +708,10 @@ export class PlannerAgent extends BaseAgent { return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; } + private isChineseLanguage(language: string | undefined): boolean { + return (language ?? "zh").toLowerCase().startsWith("zh"); + } + private async readFileOrDefault(path: string): Promise { try { return await readFile(path, "utf-8"); diff --git a/packages/core/src/models/input-governance.ts b/packages/core/src/models/input-governance.ts index 180d1c9c..461802a8 100644 --- a/packages/core/src/models/input-governance.ts +++ b/packages/core/src/models/input-governance.ts @@ -21,6 +21,10 @@ export const ChapterIntentSchema = z.object({ chapter: z.number().int().min(1), goal: z.string().min(1), outlineNode: z.string().optional(), + sceneDirective: z.string().min(1).optional(), + arcDirective: z.string().min(1).optional(), + moodDirective: z.string().min(1).optional(), + titleDirective: z.string().min(1).optional(), mustKeep: z.array(z.string()).default([]), mustAvoid: z.array(z.string()).default([]), styleEmphasis: z.array(z.string()).default([]), From 8a05275d326ea0fb3a9f6d6120b7327c3151c168 Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 31 Mar 2026 15:41:50 +0800 Subject: [PATCH 05/53] feat(governed): inject explicit history and canon evidence into writer --- packages/core/src/__tests__/composer.test.ts | 67 +++++++++ packages/core/src/__tests__/writer.test.ts | 143 +++++++++++++++++++ packages/core/src/agents/composer.ts | 62 +++++++- packages/core/src/agents/writer.ts | 36 +++-- packages/core/src/utils/governed-context.ts | 31 ++++ 5 files changed, 330 insertions(+), 9 deletions(-) diff --git a/packages/core/src/__tests__/composer.test.ts b/packages/core/src/__tests__/composer.test.ts index 381d4df9..c898f329 100644 --- a/packages/core/src/__tests__/composer.test.ts +++ b/packages/core/src/__tests__/composer.test.ts @@ -391,4 +391,71 @@ describe("ComposerAgent", () => { expect(volumeEntry).toBeDefined(); 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[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"); + }); }); diff --git a/packages/core/src/__tests__/writer.test.ts b/packages/core/src/__tests__/writer.test.ts index d2d54de0..cceb1b00 100644 --- a/packages/core/src/__tests__/writer.test.ts +++ b/packages/core/src/__tests__/writer.test.ts @@ -764,4 +764,147 @@ describe("WriterAgent", () => { 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 }); + } + }); }); diff --git a/packages/core/src/agents/composer.ts b/packages/core/src/agents/composer.ts index e7fce50e..110d8221 100644 --- a/packages/core/src/agents/composer.ts +++ b/packages/core/src/agents/composer.ts @@ -12,7 +12,10 @@ import { type RuleStack, } from "../models/input-governance.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 { readonly book: BookConfig; @@ -121,7 +124,18 @@ export class ComposerAgent extends BaseAgent { "Anchor the default planning node for this chapter.", 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 memorySelection = await retrieveMemorySelection({ @@ -159,6 +173,7 @@ export class ComposerAgent extends BaseAgent { return [ ...entries.filter((entry): entry is NonNullable => entry !== null), + ...trailEntries, ...factEntries, ...summaryEntries, ...volumeSummaryEntries, @@ -166,6 +181,51 @@ export class ComposerAgent extends BaseAgent { ]; } + private async buildRecentChapterTrailEntries( + storyDir: string, + chapterNumber: number, + ): Promise { + 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( storyDir: string, fileName: string, diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index 5faf8c0c..94419723 100644 --- a/packages/core/src/agents/writer.ts +++ b/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 resolvedLengthSpec = input.lengthSpec ?? buildLengthSpec(targetWords, resolvedLanguage); const governedMemoryBlocks = input.contextPackage - ? buildGovernedMemoryEvidenceBlocks(input.contextPackage) + ? buildGovernedMemoryEvidenceBlocks(input.contextPackage, resolvedLanguage) : undefined; const englishVarianceBrief = resolvedLanguage === "en" ? await buildEnglishVarianceBrief({ @@ -183,6 +183,7 @@ export class WriterAgent extends BaseAgent { lengthSpec: resolvedLengthSpec, language: book.language ?? genreProfile.language, varianceBrief: englishVarianceBrief?.text, + selectedEvidenceBlock: this.joinGovernedEvidenceBlocks(governedMemoryBlocks), }) : (() => { // Smart context filtering: inject only relevant parts of truth files @@ -289,13 +290,7 @@ export class WriterAgent extends BaseAgent { characterMatrix: filteredMatrixForSettlement, volumeOutline, selectedEvidenceBlock: governedMemoryBlocks - ? [ - governedMemoryBlocks.hooksBlock, - governedMemoryBlocks.summariesBlock, - governedMemoryBlocks.volumeSummariesBlock, - ] - .filter(Boolean) - .join("\n") + ? this.joinGovernedEvidenceBlocks(governedMemoryBlocks) : undefined, chapterIntent: input.chapterIntent, contextPackage: input.contextPackage, @@ -708,6 +703,7 @@ ${lengthRequirementBlock} readonly lengthSpec: LengthSpec; readonly language?: "zh" | "en"; readonly varianceBrief?: string; + readonly selectedEvidenceBlock?: string; }): string { const contextSections = params.contextPackage.selectedContext .map((entry) => [ @@ -734,6 +730,9 @@ ${lengthRequirementBlock} const varianceBlock = params.varianceBrief ? `\n${params.varianceBrief}\n` : ""; + const selectedEvidenceBlock = params.selectedEvidenceBlock + ? `\n${params.selectedEvidenceBlock}\n` + : ""; if (params.language === "en") { return `Write chapter ${params.chapterNumber}. @@ -743,6 +742,7 @@ ${params.chapterIntent} ## Selected Context ${contextSections || "(none)"} +${selectedEvidenceBlock} ## Rule Stack - Hard: ${params.ruleStack.sections.hard.join(", ") || "(none)"} @@ -768,6 +768,7 @@ ${params.chapterIntent} ## 已选上下文 ${contextSections || "(无)"} +${selectedEvidenceBlock} ## 规则栈 - 硬护栏:${params.ruleStack.sections.hard.join("、") || "(无)"} @@ -786,6 +787,25 @@ ${lengthRequirementBlock} - 只需输出 PRE_WRITE_CHECK、CHAPTER_TITLE、CHAPTER_CONTENT 三个区块`; } + private joinGovernedEvidenceBlocks(blocks: ReturnType | 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( chapterIntent: string, contextPackage: ContextPackage, diff --git a/packages/core/src/utils/governed-context.ts b/packages/core/src/utils/governed-context.ts index 9990a946..25d4e0de 100644 --- a/packages/core/src/utils/governed-context.ts +++ b/packages/core/src/utils/governed-context.ts @@ -7,6 +7,9 @@ export function buildGovernedMemoryEvidenceBlocks( readonly hooksBlock?: string; readonly summariesBlock?: string; readonly volumeSummariesBlock?: string; + readonly titleHistoryBlock?: string; + readonly moodTrailBlock?: string; + readonly canonBlock?: string; } { const resolvedLanguage = language ?? "zh"; const hookEntries = contextPackage.selectedContext.filter((entry) => @@ -18,6 +21,16 @@ export function buildGovernedMemoryEvidenceBlocks( const volumeSummaryEntries = contextPackage.selectedContext.filter((entry) => 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 { hooksBlock: hookEntries.length > 0 @@ -38,6 +51,24 @@ export function buildGovernedMemoryEvidenceBlocks( volumeSummaryEntries, ) : 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, }; } From db55742fce09482f11a11c170001e8fda4d960f5 Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 31 Mar 2026 15:50:19 +0800 Subject: [PATCH 06/53] fix(hooks): promote stale debt and explicit hook agenda in writer --- packages/core/src/__tests__/planner.test.ts | 9 +- packages/core/src/__tests__/writer.test.ts | 148 ++++++++++++++++++++ packages/core/src/agents/writer-prompts.ts | 4 +- packages/core/src/agents/writer.ts | 31 ++++ packages/core/src/utils/memory-retrieval.ts | 29 ++-- 5 files changed, 203 insertions(+), 18 deletions(-) diff --git a/packages/core/src/__tests__/planner.test.ts b/packages/core/src/__tests__/planner.test.ts index 00d46369..19e64589 100644 --- a/packages/core/src/__tests__/planner.test.ts +++ b/packages/core/src/__tests__/planner.test.ts @@ -1109,9 +1109,10 @@ describe("PlannerAgent", () => { externalContext: "Keep the chapter on the mainline debt conflict.", }); - expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-route", "ready-payoff"]); + expect(result.intent.hookAgenda.mustAdvance).toEqual(["stale-debt", "ready-payoff"]); expect(result.intent.hookAgenda.eligibleResolve).toEqual(["ready-payoff"]); expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-debt"]); + expect(result.intent.hookAgenda.avoidNewHookFamilies).toContain("relationship"); const intentMarkdown = await readFile(result.runtimePath, "utf-8"); expect(intentMarkdown).toContain("## Hook Agenda"); @@ -1226,7 +1227,11 @@ describe("PlannerAgent", () => { externalContext: "Keep the chapter on the route pressure.", }); - expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-route", "recent-guild"]); + expect(result.intent.hookAgenda.mustAdvance).toEqual(["stale-omega", "stale-sable"]); expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-omega", "stale-sable"]); + expect(result.intent.hookAgenda.avoidNewHookFamilies).toEqual(expect.arrayContaining([ + "relationship", + "mystery", + ])); }); }); diff --git a/packages/core/src/__tests__/writer.test.ts b/packages/core/src/__tests__/writer.test.ts index cceb1b00..3661f249 100644 --- a/packages/core/src/__tests__/writer.test.ts +++ b/packages/core/src/__tests__/writer.test.ts @@ -907,4 +907,152 @@ describe("WriterAgent", () => { await rm(root, { recursive: true, force: true }); } }); + + it("renders an explicit hook agenda block and removes placeholder hook ids from the governed write contract", async () => { + const root = await mkdtemp(join(tmpdir(), "inkos-writer-hook-agenda-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", + "", + "## Goal", + "Push Mara back toward the archive ledger.", + "", + "## Hook Agenda", + "### Must Advance", + "- mentor-oath", + "", + "### Eligible Resolve", + "- ledger-fragment", + "", + "### Stale Debt", + "- stale-ledger", + "", + "### Avoid New Hook Families", + "- relationship", + ].join("\n"), + contextPackage: { + chapter: 4, + selectedContext: [ + { + source: "story/pending_hooks.md#mentor-oath", + reason: "Carry the unresolved oath line.", + excerpt: "relationship | open | old oath debt", + }, + ], + }, + 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 systemPrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[0]?.content ?? ""; + const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? ""; + + expect(systemPrompt).not.toContain("Hook-A / Hook-B"); + expect(systemPrompt).toContain("真实 hook_id"); + expect(creativePrompt).toContain("## Explicit Hook Agenda"); + expect(creativePrompt).toContain("mentor-oath"); + expect(creativePrompt).toContain("ledger-fragment"); + expect(creativePrompt).toContain("stale-ledger"); + expect(creativePrompt).toContain("relationship"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/packages/core/src/agents/writer-prompts.ts b/packages/core/src/agents/writer-prompts.ts index ddf82db0..4def1727 100644 --- a/packages/core/src/agents/writer-prompts.ts +++ b/packages/core/src/agents/writer-prompts.ts @@ -525,7 +525,7 @@ function buildCreativeOutputFormat(book: BookConfig, gp: GenreProfile, lengthSpe | 大纲锚定 | 当前卷名/阶段 + 本章应推进的具体节点 | 严禁跳过节点或提前消耗后续剧情 | | 上下文范围 | 第X章至第Y章 / 状态卡 / 设定文件 | | | 当前锚点 | 地点 / 对手 / 收益目标 | 锚点必须具体 | -${resourceRow}| 待回收伏笔 | Hook-A / Hook-B | 与伏笔池一致 | +${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) | 与伏笔池一致 | | 本章冲突 | 一句话概括 | | | 章节类型 | ${gp.chapterTypes.join("/")} | | | 风险扫描 | OOC/信息越界/设定冲突${gp.powerScaling ? "/战力崩坏" : ""}/节奏/词汇疲劳 | |`; @@ -560,7 +560,7 @@ function buildOutputFormat(book: BookConfig, gp: GenreProfile, lengthSpec: Lengt | 大纲锚定 | 当前卷名/阶段 + 本章应推进的具体节点 | 严禁跳过节点或提前消耗后续剧情 | | 上下文范围 | 第X章至第Y章 / 状态卡 / 设定文件 | | | 当前锚点 | 地点 / 对手 / 收益目标 | 锚点必须具体 | -${resourceRow}| 待回收伏笔 | Hook-A / Hook-B | 与伏笔池一致 | +${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) | 与伏笔池一致 | | 本章冲突 | 一句话概括 | | | 章节类型 | ${gp.chapterTypes.join("/")} | | | 风险扫描 | OOC/信息越界/设定冲突${gp.powerScaling ? "/战力崩坏" : ""}/节奏/词汇疲劳 | |`; diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index 94419723..9d82372a 100644 --- a/packages/core/src/agents/writer.ts +++ b/packages/core/src/agents/writer.ts @@ -733,6 +733,12 @@ ${lengthRequirementBlock} const selectedEvidenceBlock = params.selectedEvidenceBlock ? `\n${params.selectedEvidenceBlock}\n` : ""; + const explicitHookAgenda = this.extractMarkdownSection(params.chapterIntent, "## Hook Agenda"); + const hookAgendaBlock = explicitHookAgenda + ? params.language === "en" + ? `\n## Explicit Hook Agenda\n${explicitHookAgenda}\n` + : `\n## 显式 Hook Agenda\n${explicitHookAgenda}\n` + : ""; if (params.language === "en") { return `Write chapter ${params.chapterNumber}. @@ -743,6 +749,7 @@ ${params.chapterIntent} ## Selected Context ${contextSections || "(none)"} ${selectedEvidenceBlock} +${hookAgendaBlock} ## Rule Stack - Hard: ${params.ruleStack.sections.hard.join(", ") || "(none)"} @@ -769,6 +776,7 @@ ${params.chapterIntent} ## 已选上下文 ${contextSections || "(无)"} ${selectedEvidenceBlock} +${hookAgendaBlock} ## 规则栈 - 硬护栏:${params.ruleStack.sections.hard.join("、") || "(无)"} @@ -806,6 +814,29 @@ ${lengthRequirementBlock} return joined || undefined; } + private extractMarkdownSection(content: string, heading: string): string | undefined { + const lines = content.split("\n"); + let buffer: string[] | null = null; + + for (const line of lines) { + if (line.trim() === heading) { + buffer = []; + continue; + } + + if (buffer && line.startsWith("## ") && line.trim() !== heading) { + break; + } + + if (buffer) { + buffer.push(line); + } + } + + const section = buffer?.join("\n").trim(); + return section && section.length > 0 ? section : undefined; + } + private buildSettlerGovernedControlBlock( chapterIntent: string, contextPackage: ContextPackage, diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index d86e803d..959343e7 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -224,22 +224,20 @@ export function buildPlannerHookAgenda(params: { .map(normalizeStoredHook) .filter((hook) => !isFuturePlannedHook(hook, params.chapterNumber, 0)) .filter((hook) => hook.status !== "resolved" && hook.status !== "deferred"); - const mustAdvance = agendaHooks + const mustAdvanceHooks = agendaHooks .slice() .sort((left, right) => ( - right.lastAdvancedChapter - left.lastAdvancedChapter + left.lastAdvancedChapter - right.lastAdvancedChapter || left.startChapter - right.startChapter || left.hookId.localeCompare(right.hookId) )) - .slice(0, params.maxMustAdvance ?? 2) - .map((hook) => hook.hookId); - const staleDebt = collectStaleHookDebt({ + .slice(0, params.maxMustAdvance ?? 2); + const staleDebtHooks = collectStaleHookDebt({ hooks: agendaHooks, chapterNumber: params.chapterNumber, }) - .slice(0, params.maxStaleDebt ?? 2) - .map((hook) => hook.hookId); - const eligibleResolve = agendaHooks + .slice(0, params.maxStaleDebt ?? 2); + const eligibleResolveHooks = agendaHooks .filter((hook) => hook.startChapter <= params.chapterNumber - 3) .filter((hook) => hook.lastAdvancedChapter >= params.chapterNumber - 2) .sort((left, right) => ( @@ -247,14 +245,17 @@ export function buildPlannerHookAgenda(params: { || right.lastAdvancedChapter - left.lastAdvancedChapter || left.hookId.localeCompare(right.hookId) )) - .slice(0, params.maxEligibleResolve ?? 1) - .map((hook) => hook.hookId); + .slice(0, params.maxEligibleResolve ?? 1); + const avoidNewHookFamilies = [...new Set([ + ...staleDebtHooks.map((hook) => hook.type.trim()).filter(Boolean), + ...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean), + ])].slice(0, 3); return { - mustAdvance, - eligibleResolve, - staleDebt, - avoidNewHookFamilies: [], + mustAdvance: mustAdvanceHooks.map((hook) => hook.hookId), + eligibleResolve: eligibleResolveHooks.map((hook) => hook.hookId), + staleDebt: staleDebtHooks.map((hook) => hook.hookId), + avoidNewHookFamilies, }; } From c5af09f897bfa5f4474f95a349968529c58f1950 Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 31 Mar 2026 16:00:18 +0800 Subject: [PATCH 07/53] fix(titles): regenerate duplicates before suffix fallback --- .../src/__tests__/pipeline-runner.test.ts | 60 ++++++++- .../__tests__/post-write-validator.test.ts | 15 +++ .../core/src/agents/post-write-validator.ts | 116 ++++++++++++++++++ packages/core/src/agents/writer-prompts.ts | 4 +- packages/core/src/pipeline/runner.ts | 2 + 5 files changed, 193 insertions(+), 4 deletions(-) diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts index b4a65d0b..c4fc9745 100644 --- a/packages/core/src/__tests__/pipeline-runner.test.ts +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -3243,8 +3243,8 @@ describe("PipelineRunner", () => { createWriterOutput({ chapterNumber: 2, title: "回声", - content: "这次的正文完全不同,只是标题碰巧重复了。", - wordCount: "这次的正文完全不同,只是标题碰巧重复了。".length, + content: "啊。", + wordCount: "啊。".length, }), ); vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue( @@ -3266,6 +3266,62 @@ describe("PipelineRunner", () => { } }); + it("regenerates duplicate chapter titles before falling back to numeric suffixes", async () => { + const { root, runner, state, bookId } = await createRunnerFixture(); + const storyDir = join(state.bookDir(bookId), "story"); + const chaptersDir = join(state.bookDir(bookId), "chapters"); + const now = "2026-03-19T00:00:00.000Z"; + + await Promise.all([ + writeFile(join(chaptersDir, "0001_回声.md"), "# 第1章 回声\n\n旧章节。", "utf-8"), + writeFile(join(storyDir, "current_state.md"), createStateCard({ + chapter: 1, + location: "Ashen ferry crossing", + protagonistState: "Lin Yue still hides the oath token.", + goal: "Find the vanished mentor.", + conflict: "The debt trail keeps narrowing.", + }), "utf-8"), + writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"), + ]); + await state.saveChapterIndex(bookId, [{ + number: 1, + title: "回声", + status: "ready-for-review", + wordCount: 12, + createdAt: now, + updatedAt: now, + auditIssues: [], + lengthWarnings: [], + }]); + + vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue( + createWriterOutput({ + chapterNumber: 2, + title: "回声", + content: "塔楼里的铜铃只响了一声,风从缺口灌进来,守夜人没有回头。", + wordCount: "塔楼里的铜铃只响了一声,风从缺口灌进来,守夜人没有回头。".length, + }), + ); + vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue( + createAuditResult({ + passed: true, + issues: [], + summary: "clean", + }), + ); + + try { + const result = await runner.writeNextChapter(bookId, 120); + const index = await state.loadChapterIndex(bookId); + + expect(result.title).toContain("塔楼"); + expect(result.title).not.toBe("回声(2)"); + expect(index.at(-1)?.title).toBe(result.title); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("defaults manual reviseDraft to spot-fix when mode is omitted", async () => { const { root, runner, state, bookId } = await createRunnerFixture(); const storyDir = join(state.bookDir(bookId), "story"); diff --git a/packages/core/src/__tests__/post-write-validator.test.ts b/packages/core/src/__tests__/post-write-validator.test.ts index 8e82d11c..734de17c 100644 --- a/packages/core/src/__tests__/post-write-validator.test.ts +++ b/packages/core/src/__tests__/post-write-validator.test.ts @@ -3,6 +3,7 @@ import { detectDuplicateTitle, detectParagraphLengthDrift, detectParagraphShapeWarnings, + resolveDuplicateTitle, validatePostWrite, type PostWriteViolation, } from "../agents/post-write-validator.js"; @@ -244,4 +245,18 @@ describe("validatePostWrite", () => { const result = detectDuplicateTitle("Echo-2", ["Echo 2"]); expect(findRule(result, "near-duplicate-title")).toBeDefined(); }); + + it("prefers regenerating a duplicate title from chapter content before numeric suffix fallback", () => { + const result = resolveDuplicateTitle( + "回声", + ["旧路", "回声"], + "zh", + { + content: "塔楼里的铜铃只响了一声,风从缺口灌进来,守夜人没有回头。", + }, + ); + + expect(result.title).toContain("塔楼"); + expect(result.title).not.toBe("回声(2)"); + }); }); diff --git a/packages/core/src/agents/post-write-validator.ts b/packages/core/src/agents/post-write-validator.ts index 3573d912..de1f295d 100644 --- a/packages/core/src/agents/post-write-validator.ts +++ b/packages/core/src/agents/post-write-validator.ts @@ -561,6 +561,21 @@ const ENGLISH_NAME_STOP_WORDS = new Set([ "They", ]); +const CHINESE_TITLE_STOP_WORDS = new Set([ + "这次", + "正文", + "标题", + "重复", + "不同", + "完全", + "只是", + "碰巧", + "没有", + "回头", +]); + +const CHINESE_TITLE_STOP_CHARS = new Set(["的", "了", "着", "一", "只", "从", "在", "和", "与", "把", "被", "有", "没", "里", "又", "才"]); + /** * Detect duplicate or near-duplicate chapter titles. * Compares the new title against existing chapter titles from index. @@ -609,6 +624,9 @@ export function resolveDuplicateTitle( newTitle: string, existingTitles: ReadonlyArray, language: "zh" | "en" = "zh", + options?: { + readonly content?: string; + }, ): { readonly title: string; readonly issues: ReadonlyArray; @@ -623,6 +641,11 @@ export function resolveDuplicateTitle( return { title: trimmed, issues: [] }; } + const regenerated = regenerateDuplicateTitle(trimmed, existingTitles, language, options?.content); + if (regenerated && detectDuplicateTitle(regenerated, existingTitles).length === 0) { + return { title: regenerated, issues }; + } + let counter = 2; while (counter < 100) { const candidate = language === "en" @@ -636,3 +659,96 @@ export function resolveDuplicateTitle( return { title: trimmed, issues }; } + +function regenerateDuplicateTitle( + baseTitle: string, + existingTitles: ReadonlyArray, + language: "zh" | "en", + content?: string, +): string | undefined { + if (!content || !content.trim()) { + return undefined; + } + + const qualifier = language === "en" + ? extractEnglishTitleQualifier(baseTitle, existingTitles, content) + : extractChineseTitleQualifier(baseTitle, existingTitles, content); + if (!qualifier) { + return undefined; + } + + return language === "en" + ? `${baseTitle}: ${qualifier}` + : `${baseTitle}:${qualifier}`; +} + +function extractEnglishTitleQualifier( + baseTitle: string, + existingTitles: ReadonlyArray, + content: string, +): string | undefined { + const blocked = new Set(extractEnglishTitleTerms([baseTitle, ...existingTitles].join(" "))); + const words = (content.match(/[A-Za-z]{4,}/g) ?? []) + .map((word) => word.toLowerCase()) + .filter((word) => !ENGLISH_NAME_STOP_WORDS.has(capitalize(word))) + .filter((word) => !blocked.has(word)); + const first = words[0]; + if (!first) { + return undefined; + } + + const second = words.find((word) => word !== first && !blocked.has(word)); + return second + ? `${capitalize(first)} ${capitalize(second)}` + : capitalize(first); +} + +function extractChineseTitleQualifier( + baseTitle: string, + existingTitles: ReadonlyArray, + content: string, +): string | undefined { + const blocked = new Set(extractChineseTitleTerms([baseTitle, ...existingTitles].join(""))); + const segments = content.match(/[\u4e00-\u9fff]+/g) ?? []; + + for (const segment of segments) { + for (let start = 0; start < segment.length; start += 1) { + for (let size = 2; size <= 4; size += 1) { + const candidate = segment.slice(start, start + size).trim(); + if (candidate.length < 2) continue; + if (CHINESE_TITLE_STOP_WORDS.has(candidate)) continue; + if ([...candidate].some((char) => CHINESE_TITLE_STOP_CHARS.has(char))) continue; + if (blocked.has(candidate)) continue; + return candidate; + } + } + } + + return undefined; +} + +function extractEnglishTitleTerms(text: string): string[] { + return [...new Set((text.match(/[A-Za-z]{4,}/g) ?? []).map((word) => word.toLowerCase()))]; +} + +function extractChineseTitleTerms(text: string): string[] { + const terms = new Set(); + const segments = text.match(/[\u4e00-\u9fff]+/g) ?? []; + + for (const segment of segments) { + for (let start = 0; start < segment.length; start += 1) { + for (let size = 2; size <= 4; size += 1) { + const candidate = segment.slice(start, start + size).trim(); + if (candidate.length < 2) continue; + if ([...candidate].some((char) => CHINESE_TITLE_STOP_CHARS.has(char))) continue; + terms.add(candidate); + } + } + } + + return [...terms]; +} + +function capitalize(word: string): string { + return word.length === 0 ? word : `${word[0]!.toUpperCase()}${word.slice(1)}`; +} diff --git a/packages/core/src/agents/writer-prompts.ts b/packages/core/src/agents/writer-prompts.ts index 4def1727..4a6fd686 100644 --- a/packages/core/src/agents/writer-prompts.ts +++ b/packages/core/src/agents/writer-prompts.ts @@ -535,7 +535,7 @@ ${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) | ${preWriteTable} === CHAPTER_TITLE === -(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题) +(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题;若提供了 recent title history 或高频标题词,必须主动避开重复词根和高频意象) === CHAPTER_CONTENT === (正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字) @@ -588,7 +588,7 @@ ${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) | ${preWriteTable} === CHAPTER_TITLE === -(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题) +(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题;若提供了 recent title history 或高频标题词,必须主动避开重复词根和高频意象) === CHAPTER_CONTENT === (正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字) diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 48910617..6c4ac7bb 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -1135,6 +1135,7 @@ export class PipelineRunner { output.title, chapterIndexBeforePersist.map((chapter) => chapter.title), pipelineLang, + { content: finalContent }, ); let persistenceOutput = await this.buildPersistenceOutput( bookId, @@ -1152,6 +1153,7 @@ export class PipelineRunner { persistenceOutput.title, chapterIndexBeforePersist.map((chapter) => chapter.title), pipelineLang, + { content: finalContent }, ); if (finalTitleResolution.title !== persistenceOutput.title) { persistenceOutput = { From 1fa53d313197ac3d597e078dba31e021cef76010 Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 31 Mar 2026 19:58:43 +0800 Subject: [PATCH 08/53] feat(planner): emit mood directive when consecutive chapters are high-tension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements buildMoodDirective — detects when 3+ recent chapters share high-tension mood keywords (紧张/冷硬/压抑/tense/grim/etc.) and emits a structured directive forcing the writer to downshift to a quieter scene. Completes the moodDirective slot that was declared but left as undefined in the previous structured-directives commit. --- packages/core/src/__tests__/planner.test.ts | 94 +++++++++++++++++++++ packages/core/src/agents/planner.ts | 37 +++++++- 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/planner.test.ts b/packages/core/src/__tests__/planner.test.ts index 19e64589..b7a71238 100644 --- a/packages/core/src/__tests__/planner.test.ts +++ b/packages/core/src/__tests__/planner.test.ts @@ -324,6 +324,100 @@ describe("PlannerAgent", () => { expect(result.intent.moodDirective).toBeUndefined(); }); + it("emits a mood directive when recent chapters are all high-tension", async () => { + book = { + ...book, + genre: "other", + language: "zh", + }; + + await Promise.all([ + writeFile( + join(storyDir, "volume_outline.md"), + "# Volume Outline\n\n## Chapter 5\n进入新的地点。\n", + "utf-8", + ), + writeFile( + join(storyDir, "chapter_summaries.md"), + [ + "# Chapter Summaries", + "", + "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + "| 1 | 暗巷追踪 | 周谨川 | 追踪目标 | None | none | 紧张、压抑 | 悬念验证章 |", + "| 2 | 旧楼对峙 | 周谨川 | 对峙 | None | none | 冷硬、逼仄 | 冲突章 |", + "| 3 | 夜色围堵 | 周谨川 | 围堵 | None | none | 肃杀、凝重 | 追击章 |", + "| 4 | 地下通道 | 周谨川 | 逃脱 | None | none | 压迫、窒息 | 逃亡章 |", + "", + ].join("\n"), + "utf-8", + ), + ]); + + const planner = new PlannerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 5, + }); + + expect(result.intent.moodDirective).toBeDefined(); + expect(result.intent.moodDirective).toContain("降调"); + expect(result.intent.moodDirective).toContain("日常"); + }); + + it("does not emit a mood directive when recent moods are varied", async () => { + book = { + ...book, + genre: "other", + language: "en", + }; + + await Promise.all([ + writeFile( + join(storyDir, "volume_outline.md"), + "# Volume Outline\n\n## Chapter 5\nMove to the harbor.\n", + "utf-8", + ), + writeFile( + join(storyDir, "chapter_summaries.md"), + [ + "# Chapter Summaries", + "", + "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + "| 1 | Morning Calm | Taryn | A quiet walk | None | none | warm, gentle | slice-of-life |", + "| 2 | Sudden Rain | Taryn | Storm arrives | None | none | tense, ominous | tension |", + "| 3 | Harbor Light | Taryn | Finds shelter | None | none | hopeful, light | transition |", + "| 4 | The Letter | Taryn | Reads bad news | None | none | melancholy, reflective | introspection |", + "", + ].join("\n"), + "utf-8", + ), + ]); + + const planner = new PlannerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 5, + }); + + expect(result.intent.moodDirective).toBeUndefined(); + }); + it("ignores the default current_focus placeholder and falls back to author intent when no chapter outline is available", async () => { await Promise.all([ writeFile( diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index a3a323d1..16c6bbdd 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -146,7 +146,7 @@ export class PlannerAgent extends BaseAgent { input.matchedOutlineAnchor, ), sceneDirective: this.buildSceneDirective(input.language, recentSummaries), - moodDirective: undefined, + moodDirective: this.buildMoodDirective(input.language, recentSummaries), titleDirective: this.buildTitleDirective(input.language, recentSummaries), }; } @@ -346,6 +346,41 @@ export class PlannerAgent extends BaseAgent { : `Recent chapters are stuck in repeated ${repeatedType} beats. Change the scene container, location, or action pattern this chapter.`; } + private buildMoodDirective( + language: string | undefined, + recentSummaries: ReadonlyArray<{ mood: string }>, + ): string | undefined { + if (recentSummaries.length < 3) { + return undefined; + } + + const moods = recentSummaries.map((summary) => summary.mood.trim()).filter(Boolean); + if (moods.length < 3) { + return undefined; + } + + const allHighTension = moods.every((mood) => this.isHighTensionMood(mood)); + if (!allHighTension) { + return undefined; + } + + return this.isChineseLanguage(language) + ? `最近${moods.length}章情绪持续高压(${moods.slice(0, 3).join("、")}),本章必须降调——安排日常/喘息/温情/幽默场景,让读者呼吸。` + : `The last ${moods.length} chapters have been relentlessly tense (${moods.slice(0, 3).join(", ")}). This chapter must downshift — write a quieter scene with warmth, humor, or breathing room.`; + } + + private isHighTensionMood(mood: string): boolean { + const tensionKeywords = [ + "紧张", "冷硬", "压抑", "逼仄", "肃杀", "沉重", "凝重", + "冷峻", "压迫", "阴沉", "焦灼", "窒息", "凛冽", "锋利", + "克制", "危机", "对峙", "绷紧", "僵持", "杀意", + "tense", "cold", "oppressive", "grim", "ominous", "dark", + "bleak", "hostile", "threatening", "heavy", "suffocating", + ]; + const lowerMood = mood.toLowerCase(); + return tensionKeywords.some((keyword) => lowerMood.includes(keyword)); + } + private buildTitleDirective( language: string | undefined, recentSummaries: ReadonlyArray<{ title: string }>, From 7316ed70274cc53c50aeb7c1dbfd6c51acac4942 Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 31 Mar 2026 19:58:58 +0800 Subject: [PATCH 09/53] feat(style): extract writing style in fanfic/continuation/import flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes: 1. FanficCanonImporter now extracts a writing_style section from source material — narration voice, sentence rhythm, dialogue markers, imagery preferences, emotional expression patterns, with source-text citations. 2. initFanficBook calls generateStyleGuide after canon import so fanfic books start with both style_profile.json and style_guide.md. 3. importCanon reads up to 5 parent chapters and generates a style guide for the target book. importChapters does the same on first run. These ensure continuation and fanfic books have style reference data available to the writer from the first chapter onward. --- .../core/src/agents/fanfic-canon-importer.ts | 21 ++++++++- packages/core/src/pipeline/runner.ts | 47 ++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agents/fanfic-canon-importer.ts b/packages/core/src/agents/fanfic-canon-importer.ts index e77d428e..9adb2bc1 100644 --- a/packages/core/src/agents/fanfic-canon-importer.ts +++ b/packages/core/src/agents/fanfic-canon-importer.ts @@ -6,6 +6,7 @@ export interface FanficCanonOutput { readonly characterProfiles: string; readonly keyEvents: string; readonly powerSystem: string; + readonly writingStyle: string; readonly fullDocument: string; } @@ -68,10 +69,24 @@ export class FanficCanonImporter extends BaseAgent { 力量/能力体系(如果适用)。包括等级划分、核心规则、已知限制。 如果原作没有明确的力量体系,输出"(原作无明确力量体系)"。 +=== SECTION: writing_style === +原作写作风格特征(供同人写作模仿): + +1. 叙事人称与视角(第一人称/第三人称有限/全知,是否频繁切换) +2. 句式节奏(长短句交替模式、段落平均长度感受、对话占比) +3. 场景描写手法(五感偏好、意象选择、环境描写密度) +4. 对话标记习惯(说/道/笑道 等用法,对话前后是否有动作/表情补充) +5. 情绪表达方式(直白内心独白 vs 动作外化 vs 环境映射) +6. 比喻/修辞倾向(常用比喻类型、修辞频率) +7. 节奏转换(紧张→舒缓的过渡方式、章节结尾习惯) + +每项用1-2个原文例句佐证。只提取原文实际存在的特征,不要泛泛描述。 + 提取原则: - 忠实于原作素材,不捏造原作中没有的信息 - 信息不足时标注"(素材未提及)"而非编造 - 角色语癖是最重要的字段——同人读者最在意角色"像不像" +- 写作风格提取必须基于实际文本特征,附原文例句 ${truncated ? "\n注意:原作素材过长,已截断。请基于已有部分提取。" : ""}`; const response = await this.chat( @@ -95,6 +110,7 @@ ${truncated ? "\n注意:原作素材过长,已截断。请基于已有部分 const characterProfiles = extract("character_profiles"); const keyEvents = extract("key_events"); const powerSystem = extract("power_system"); + const writingStyle = extract("writing_style"); const meta = [ "---", @@ -119,9 +135,12 @@ ${truncated ? "\n注意:原作素材过长,已截断。请基于已有部分 "## 力量体系", powerSystem || "(原作无明确力量体系)", "", + "## 原作写作风格", + writingStyle || "(素材不足以提取风格特征)", + "", meta, ].join("\n"); - return { worldRules, characterProfiles, keyEvents, powerSystem, fullDocument }; + return { worldRules, characterProfiles, keyEvents, powerSystem, writingStyle, fullDocument }; } } diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 6c4ac7bb..83a97702 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -388,7 +388,13 @@ export class PipelineRunner { this.logStage(stageLanguage, { zh: "初始化控制文档", en: "initializing control documents" }); await this.state.ensureControlDocuments(book.id, this.config.externalContext); - // Step 3: Initialize chapters directory + snapshot + // Step 3: Generate style guide from source material + if (sourceText.length >= 500) { + this.logStage(stageLanguage, { zh: "提取原作风格指纹", en: "extracting source style fingerprint" }); + await this.generateStyleGuide(book.id, sourceText, sourceName); + } + + // Step 4: Initialize chapters directory + snapshot this.logStage(stageLanguage, { zh: "创建初始快照", en: "creating initial snapshot" }); await mkdir(join(bookDir, "chapters"), { recursive: true }); await this.state.saveChapterIndex(book.id, []); @@ -1581,9 +1587,38 @@ ${matrix}`, const canon = response.content + metaBlock; await writeFile(join(storyDir, "parent_canon.md"), canon, "utf-8"); + + // Also generate style guide from parent's chapter text if available + const parentChaptersDir = join(parentDir, "chapters"); + const parentChapterText = await this.readParentChapterSample(parentChaptersDir); + if (parentChapterText.length >= 500) { + await this.generateStyleGuide(targetBookId, parentChapterText, parentBook.title); + } + return canon; } + private async readParentChapterSample(chaptersDir: string): Promise { + try { + const entries = await readdir(chaptersDir); + const mdFiles = entries + .filter((file) => file.endsWith(".md")) + .sort() + .slice(0, 5); + const chunks: string[] = []; + let totalLength = 0; + for (const file of mdFiles) { + if (totalLength >= 20000) break; + const content = await readFile(join(chaptersDir, file), "utf-8"); + chunks.push(content); + totalLength += content.length; + } + return chunks.join("\n\n---\n\n"); + } catch { + return ""; + } + } + // --------------------------------------------------------------------------- // Chapter import (for continuation writing from existing chapters) // --------------------------------------------------------------------------- @@ -1630,6 +1665,16 @@ ${matrix}`, await this.resetImportReplayTruthFiles(bookDir, resolvedLanguage); await this.state.saveChapterIndex(input.bookId, []); await this.state.snapshotState(input.bookId, 0); + + // Generate style guide from imported chapters + if (allText.length >= 500) { + log?.info(this.localize(resolvedLanguage, { + zh: "提取原文风格指纹...", + en: "Extracting source style fingerprint...", + })); + await this.generateStyleGuide(input.bookId, allText, book.title); + } + log?.info(this.localize(resolvedLanguage, { zh: "基础设定已生成。", en: "Foundation generated.", From 5f187f957c1b5c122a71a6814ea32769482fb99f Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 31 Mar 2026 20:36:50 +0800 Subject: [PATCH 10/53] feat(review): reject rolls back state and discards downstream chapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds StateManager.rollbackToChapter(bookId, targetChapter) which: - Restores the snapshot at targetChapter - Deletes chapter files, snapshots, and runtime artifacts beyond it - Updates the chapter index to remove discarded entries review reject now calls rollbackToChapter(chapterNum - 1) by default, undoing the rejected chapter's state mutations and any subsequent chapters that depended on them. --keep-subsequent preserves legacy behavior. review approve now re-snapshots the approved chapter to ensure a clean rollback target exists. This prevents bad drafts from permanently polluting truth files — the core "audit-then-commit" invariant that was missing. --- packages/cli/src/commands/review.ts | 56 ++++++-- .../core/src/__tests__/state-manager.test.ts | 122 ++++++++++++++++++ packages/core/src/state/manager.ts | 96 ++++++++++++++ 3 files changed, 261 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/review.ts b/packages/cli/src/commands/review.ts index e4a601c2..00062ca3 100644 --- a/packages/cli/src/commands/review.ts +++ b/packages/cli/src/commands/review.ts @@ -106,7 +106,7 @@ function parseBookAndChapter( reviewCommand .command("approve") - .description("Approve a chapter: approve [book-id] ") + .description("Approve a chapter and commit its state: approve [book-id] ") .argument("", "Book ID (optional) and chapter number") .option("--json", "Output JSON") .action(async (args: ReadonlyArray, opts) => { @@ -129,10 +129,14 @@ reviewCommand }; await state.saveChapterIndex(bookId, index); + // Ensure a snapshot exists at the approved chapter so reject can + // roll back to this committed state later. + await state.snapshotState(bookId, chapterNum); + if (opts.json) { log(JSON.stringify({ bookId, chapter: chapterNum, status: "approved" })); } else { - log(`Chapter ${chapterNum} approved.`); + log(`Chapter ${chapterNum} approved (state committed).`); } } catch (e) { if (opts.json) { @@ -186,9 +190,10 @@ reviewCommand reviewCommand .command("reject") - .description("Reject a chapter: reject [book-id] ") + .description("Reject a chapter and roll back state: reject [book-id] ") .argument("", "Book ID (optional) and chapter number") .option("--reason ", "Rejection reason") + .option("--keep-subsequent", "Only reject this chapter, do not discard subsequent chapters") .option("--json", "Output JSON") .action(async (args: ReadonlyArray, opts) => { try { @@ -197,24 +202,49 @@ reviewCommand const bookId = await resolveBookId(bookIdArg, root); const state = new StateManager(root); - const index = [...(await state.loadChapterIndex(bookId))]; + const index = await state.loadChapterIndex(bookId); const idx = index.findIndex((ch) => ch.number === chapterNum); if (idx === -1) { throw new Error(`Chapter ${chapterNum} not found in "${bookId}"`); } - index[idx] = { - ...index[idx]!, - status: "rejected", - reviewNote: opts.reason ?? "Rejected without reason", - updatedAt: new Date().toISOString(), - }; - await state.saveChapterIndex(bookId, index); + if (opts.keepSubsequent) { + // Legacy behavior: only mark as rejected, no state rollback + const updated = [...index]; + updated[idx] = { + ...updated[idx]!, + status: "rejected", + reviewNote: opts.reason ?? "Rejected without reason", + updatedAt: new Date().toISOString(), + }; + await state.saveChapterIndex(bookId, updated); + + if (opts.json) { + log(JSON.stringify({ bookId, chapter: chapterNum, status: "rejected", discarded: [] })); + } else { + log(`Chapter ${chapterNum} rejected (state not rolled back).`); + } + return; + } + + // Default: roll back state to before the rejected chapter and discard + // it along with all subsequent chapters that depend on its state. + const rollbackTarget = chapterNum - 1; + const discarded = await state.rollbackToChapter(bookId, rollbackTarget); if (opts.json) { - log(JSON.stringify({ bookId, chapter: chapterNum, status: "rejected" })); + log(JSON.stringify({ + bookId, + chapter: chapterNum, + status: "rejected", + rolledBackTo: rollbackTarget, + discarded, + })); } else { - log(`Chapter ${chapterNum} rejected.`); + log(`Chapter ${chapterNum} rejected. State rolled back to chapter ${rollbackTarget}.`); + if (discarded.length > 1) { + log(` Also discarded ${discarded.length - 1} subsequent chapter(s): ${discarded.filter((n) => n !== chapterNum).join(", ")}`); + } } } catch (e) { if (opts.json) { diff --git a/packages/core/src/__tests__/state-manager.test.ts b/packages/core/src/__tests__/state-manager.test.ts index 4cebef14..a38d703c 100644 --- a/packages/core/src/__tests__/state-manager.test.ts +++ b/packages/core/src/__tests__/state-manager.test.ts @@ -963,4 +963,126 @@ describe("StateManager", () => { expect(hooks.hooks.map((hook) => hook.hookId)).toEqual(["H009"]); }); }); + + // ------------------------------------------------------------------------- + // rollbackToChapter — reject a chapter and discard downstream state + // ------------------------------------------------------------------------- + + describe("rollbackToChapter", () => { + const bookId = "rollback-book"; + + async function setupRollbackBook(): Promise { + await manager.saveBookConfig(bookId, { + id: bookId, + title: "Rollback Test", + platform: "tomato", + genre: "xuanhuan", + status: "active", + targetChapters: 10, + chapterWordCount: 3000, + createdAt: "2026-03-31T00:00:00Z", + updatedAt: "2026-03-31T00:00:00Z", + }); + + const bookDir = manager.bookDir(bookId); + const storyDir = join(bookDir, "story"); + const chaptersDir = join(bookDir, "chapters"); + const runtimeDir = join(storyDir, "runtime"); + await mkdir(runtimeDir, { recursive: true }); + await mkdir(chaptersDir, { recursive: true }); + + // Write initial state (chapter 0 baseline) + await writeFile(join(storyDir, "current_state.md"), "# State\n\n- Initial state.\n", "utf-8"); + await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n", "utf-8"); + await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n", "utf-8"); + await manager.snapshotState(bookId, 0); + + // Write chapter 1 state + file + await writeFile(join(storyDir, "current_state.md"), "# State\n\n- After chapter 1.\n", "utf-8"); + await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n- hook-2\n", "utf-8"); + await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n\n| 1 | Title 1 |\n", "utf-8"); + await writeFile(join(chaptersDir, "0001_Title_One.md"), "# Chapter 1\n\nContent 1.", "utf-8"); + await manager.snapshotState(bookId, 1); + + // Write chapter 2 state + file + await writeFile(join(storyDir, "current_state.md"), "# State\n\n- After chapter 2.\n", "utf-8"); + await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n- hook-2\n- hook-3\n", "utf-8"); + await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n\n| 1 | Title 1 |\n| 2 | Title 2 |\n", "utf-8"); + await writeFile(join(chaptersDir, "0002_Title_Two.md"), "# Chapter 2\n\nContent 2.", "utf-8"); + await writeFile(join(runtimeDir, "chapter-002.intent.md"), "intent 2", "utf-8"); + await manager.snapshotState(bookId, 2); + + // Write chapter 3 state + file + await writeFile(join(storyDir, "current_state.md"), "# State\n\n- After chapter 3.\n", "utf-8"); + await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n- hook-2\n- hook-3\n- hook-4\n", "utf-8"); + await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n\n| 1 | Title 1 |\n| 2 | Title 2 |\n| 3 | Title 3 |\n", "utf-8"); + await writeFile(join(chaptersDir, "0003_Title_Three.md"), "# Chapter 3\n\nContent 3.", "utf-8"); + await writeFile(join(runtimeDir, "chapter-003.intent.md"), "intent 3", "utf-8"); + await manager.snapshotState(bookId, 3); + + // Save index with all 3 chapters + const now = "2026-03-31T00:00:00Z"; + await manager.saveChapterIndex(bookId, [ + { number: 1, title: "Title One", status: "approved", wordCount: 100, createdAt: now, updatedAt: now, auditIssues: [], lengthWarnings: [] }, + { number: 2, title: "Title Two", status: "ready-for-review", wordCount: 100, createdAt: now, updatedAt: now, auditIssues: [], lengthWarnings: [] }, + { number: 3, title: "Title Three", status: "audit-failed", wordCount: 100, createdAt: now, updatedAt: now, auditIssues: ["pacing"], lengthWarnings: [] }, + ]); + } + + it("restores state to the target chapter and removes subsequent chapters", async () => { + await setupRollbackBook(); + + const discarded = await manager.rollbackToChapter(bookId, 1); + + expect(discarded).toEqual([2, 3]); + + // State should be restored to chapter 1 snapshot + const bookDir = manager.bookDir(bookId); + const state = await readFile(join(bookDir, "story", "current_state.md"), "utf-8"); + expect(state).toContain("After chapter 1"); + expect(state).not.toContain("After chapter 3"); + + const hooks = await readFile(join(bookDir, "story", "pending_hooks.md"), "utf-8"); + expect(hooks).toContain("hook-2"); + expect(hooks).not.toContain("hook-4"); + + // Chapter index should only have chapter 1 + const index = await manager.loadChapterIndex(bookId); + expect(index).toHaveLength(1); + expect(index[0]!.number).toBe(1); + expect(index[0]!.status).toBe("approved"); + + // Chapter files for 2 and 3 should be deleted + const chaptersDir = join(bookDir, "chapters"); + const { readdir: rd } = await import("node:fs/promises"); + const remaining = (await rd(chaptersDir)).filter((f) => f.endsWith(".md")); + expect(remaining).toEqual(["0001_Title_One.md"]); + + // Snapshots for 2 and 3 should be deleted + const snapshotsDir = join(bookDir, "story", "snapshots"); + const snapshots = await rd(snapshotsDir); + expect(snapshots.sort()).toEqual(["0", "1"]); + }); + + it("rolls back to chapter 0 (initial state) when rejecting chapter 1", async () => { + await setupRollbackBook(); + + const discarded = await manager.rollbackToChapter(bookId, 0); + + expect(discarded).toEqual([1, 2, 3]); + + const bookDir = manager.bookDir(bookId); + const state = await readFile(join(bookDir, "story", "current_state.md"), "utf-8"); + expect(state).toContain("Initial state"); + + const index = await manager.loadChapterIndex(bookId); + expect(index).toHaveLength(0); + }); + + it("throws when the target snapshot does not exist", async () => { + await setupRollbackBook(); + + await expect(manager.rollbackToChapter(bookId, 99)).rejects.toThrow("Cannot restore snapshot"); + }); + }); }); diff --git a/packages/core/src/state/manager.ts b/packages/core/src/state/manager.ts index 702f3ef5..830788fd 100644 --- a/packages/core/src/state/manager.ts +++ b/packages/core/src/state/manager.ts @@ -393,6 +393,102 @@ export class StateManager { } } + /** + * Roll back state to the snapshot at `targetChapter`, removing all chapters + * after it and their associated files (chapter markdown, snapshots, runtime). + * Used by review reject to undo a bad chapter and everything that followed. + * + * Returns the list of chapter numbers that were discarded. + */ + async rollbackToChapter( + bookId: string, + targetChapter: number, + ): Promise> { + const restored = await this.restoreState(bookId, targetChapter); + if (!restored) { + throw new Error(`Cannot restore snapshot for chapter ${targetChapter} in "${bookId}"`); + } + + const bookDir = this.bookDir(bookId); + const chaptersDir = join(bookDir, "chapters"); + const index = await this.loadChapterIndex(bookId); + + const kept: ChapterMeta[] = []; + const discarded: number[] = []; + + for (const entry of index) { + if (entry.number <= targetChapter) { + kept.push(entry); + } else { + discarded.push(entry.number); + } + } + + // Delete chapter markdown files for discarded chapters + try { + const files = await readdir(chaptersDir); + for (const file of files) { + const match = file.match(/^(\d+)_.*\.md$/); + if (!match) continue; + const num = parseInt(match[1]!, 10); + if (num > targetChapter) { + await unlink(join(chaptersDir, file)).catch(() => {}); + } + } + } catch { + // chapters directory missing + } + + // Delete snapshots for discarded chapters + const snapshotsDir = join(bookDir, "story", "snapshots"); + try { + const snapshots = await readdir(snapshotsDir); + for (const snap of snapshots) { + const num = parseInt(snap, 10); + if (Number.isFinite(num) && num > targetChapter) { + await rm(join(snapshotsDir, snap), { recursive: true, force: true }); + } + } + } catch { + // snapshots directory missing + } + + // Delete runtime artifacts for discarded chapters + const runtimeDir = join(bookDir, "story", "runtime"); + try { + const runtimeFiles = await readdir(runtimeDir); + for (const file of runtimeFiles) { + const match = file.match(/^chapter-(\d+)\./); + if (!match) continue; + const num = parseInt(match[1]!, 10); + if (num > targetChapter) { + await unlink(join(runtimeDir, file)).catch(() => {}); + } + } + } catch { + // runtime directory missing + } + + // Also check story/drafts/ for discarded chapter files + const draftsDir = join(bookDir, "story", "drafts"); + try { + const draftFiles = await readdir(draftsDir); + for (const file of draftFiles) { + const match = file.match(/^(\d+)_.*\.md$/); + if (!match) continue; + const num = parseInt(match[1]!, 10); + if (num > targetChapter) { + await unlink(join(draftsDir, file)).catch(() => {}); + } + } + } catch { + // drafts directory missing + } + + await this.saveChapterIndex(bookId, kept); + return discarded; + } + private async writeIfMissing(path: string, content: string): Promise { try { await stat(path); From 48ea3e353db7521ca67ccdf09c886e6004514172 Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 31 Mar 2026 21:28:09 +0800 Subject: [PATCH 11/53] fix(review): preserve committed snapshots on approval --- .../cli/src/__tests__/cli-integration.test.ts | 69 +++++++++++++++++++ packages/cli/src/commands/review.ts | 4 -- .../core/src/__tests__/state-manager.test.ts | 17 +++++ packages/core/src/state/manager.ts | 8 +++ 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/__tests__/cli-integration.test.ts b/packages/cli/src/__tests__/cli-integration.test.ts index b6731a4b..e396cc20 100644 --- a/packages/cli/src/__tests__/cli-integration.test.ts +++ b/packages/cli/src/__tests__/cli-integration.test.ts @@ -620,6 +620,75 @@ describe("CLI integration", () => { }); }); + describe("inkos review", () => { + it("preserves the original chapter snapshot when approving review", async () => { + const configPath = join(projectDir, "inkos.json"); + const initialized = await stat(configPath).then(() => true).catch(() => false); + if (!initialized) run(["init"]); + + const state = new StateManager(projectDir); + const bookId = "review-approve-cli"; + const bookDir = join(projectDir, "books", bookId); + const storyDir = join(bookDir, "story"); + const chaptersDir = join(bookDir, "chapters"); + await mkdir(chaptersDir, { recursive: true }); + await mkdir(storyDir, { recursive: true }); + + await writeFile( + join(bookDir, "book.json"), + JSON.stringify({ + id: bookId, + title: "Review Approve CLI", + platform: "other", + genre: "other", + status: "active", + targetChapters: 10, + chapterWordCount: 2200, + createdAt: "2026-03-22T00:00:00.000Z", + updatedAt: "2026-03-22T00:00:00.000Z", + }, null, 2), + "utf-8", + ); + await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8"); + await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8"); + await writeFile(join(chaptersDir, "0001_ch1.md"), "# Chapter 1\n\nContent 1", "utf-8"); + await writeFile( + join(chaptersDir, "index.json"), + JSON.stringify([ + { + number: 1, + title: "Ch1", + status: "ready-for-review", + wordCount: 100, + createdAt: "", + updatedAt: "", + auditIssues: [], + lengthWarnings: [], + }, + ], null, 2), + "utf-8", + ); + + await state.snapshotState(bookId, 1); + + await writeFile(join(storyDir, "current_state.md"), "State at ch3", "utf-8"); + await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch3", "utf-8"); + + const output = run(["review", "approve", bookId, "1"]); + expect(output).toContain("Chapter 1 approved"); + + await expect( + readFile(join(storyDir, "snapshots", "1", "current_state.md"), "utf-8"), + ).resolves.toBe("State at ch1"); + await expect( + readFile(join(storyDir, "snapshots", "1", "pending_hooks.md"), "utf-8"), + ).resolves.toBe("Hooks at ch1"); + + const index = await state.loadChapterIndex(bookId); + expect(index[0]?.status).toBe("approved"); + }); + }); + describe("inkos plan/compose", () => { beforeAll(async () => { const configPath = join(projectDir, "inkos.json"); diff --git a/packages/cli/src/commands/review.ts b/packages/cli/src/commands/review.ts index 00062ca3..8d2a9ced 100644 --- a/packages/cli/src/commands/review.ts +++ b/packages/cli/src/commands/review.ts @@ -129,10 +129,6 @@ reviewCommand }; await state.saveChapterIndex(bookId, index); - // Ensure a snapshot exists at the approved chapter so reject can - // roll back to this committed state later. - await state.snapshotState(bookId, chapterNum); - if (opts.json) { log(JSON.stringify({ bookId, chapter: chapterNum, status: "approved" })); } else { diff --git a/packages/core/src/__tests__/state-manager.test.ts b/packages/core/src/__tests__/state-manager.test.ts index a38d703c..53254ab8 100644 --- a/packages/core/src/__tests__/state-manager.test.ts +++ b/packages/core/src/__tests__/state-manager.test.ts @@ -1084,5 +1084,22 @@ describe("StateManager", () => { await expect(manager.rollbackToChapter(bookId, 99)).rejects.toThrow("Cannot restore snapshot"); }); + + it("removes sqlite memory files when rolling back", async () => { + await setupRollbackBook(); + + const storyDir = join(manager.bookDir(bookId), "story"); + await Promise.all([ + writeFile(join(storyDir, "memory.db"), "stale db", "utf-8"), + writeFile(join(storyDir, "memory.db-shm"), "stale shm", "utf-8"), + writeFile(join(storyDir, "memory.db-wal"), "stale wal", "utf-8"), + ]); + + await manager.rollbackToChapter(bookId, 1); + + await expect(stat(join(storyDir, "memory.db"))).rejects.toThrow(); + await expect(stat(join(storyDir, "memory.db-shm"))).rejects.toThrow(); + await expect(stat(join(storyDir, "memory.db-wal"))).rejects.toThrow(); + }); }); }); diff --git a/packages/core/src/state/manager.ts b/packages/core/src/state/manager.ts index 830788fd..cd19e7bc 100644 --- a/packages/core/src/state/manager.ts +++ b/packages/core/src/state/manager.ts @@ -485,6 +485,14 @@ export class StateManager { // drafts directory missing } + // Drop any persisted sqlite acceleration index so discarded chapters + // cannot leak back into retrieval after the markdown/state rollback. + await Promise.all([ + rm(join(bookDir, "story", "memory.db"), { force: true }), + rm(join(bookDir, "story", "memory.db-shm"), { force: true }), + rm(join(bookDir, "story", "memory.db-wal"), { force: true }), + ]); + await this.saveChapterIndex(bookId, kept); return discarded; } From f9faefc27b13ddb1195132417876263f13ed5641 Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 31 Mar 2026 21:28:51 +0800 Subject: [PATCH 12/53] fix(style): degrade gracefully when fingerprint extraction fails --- .../src/__tests__/pipeline-runner.test.ts | 145 ++++++++++++++++++ packages/core/src/pipeline/runner.ts | 24 ++- 2 files changed, 166 insertions(+), 3 deletions(-) diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts index c4fc9745..90343fb2 100644 --- a/packages/core/src/__tests__/pipeline-runner.test.ts +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -4,6 +4,7 @@ import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/ import { tmpdir } from "node:os"; import { join } from "node:path"; import { PipelineRunner } from "../pipeline/runner.js"; +import * as llmProvider from "../llm/provider.js"; import { StateManager } from "../state/manager.js"; import { ArchitectAgent } from "../agents/architect.js"; import { PlannerAgent } from "../agents/planner.js"; @@ -2321,6 +2322,150 @@ describe("PipelineRunner", () => { await rm(root, { recursive: true, force: true }); }); + it("keeps fanfic initialization running when style guide extraction fails", async () => { + const { root, runner, state } = await createRunnerFixture(); + const bookId = "fanfic-style-fallback"; + const now = "2026-03-19T00:00:00.000Z"; + const book: BookConfig = { + id: bookId, + title: "Fanfic Fallback", + platform: "tomato", + genre: "xuanhuan", + status: "active", + targetChapters: 10, + chapterWordCount: 3000, + createdAt: now, + updatedAt: now, + }; + + vi.spyOn(runner, "importFanficCanon").mockImplementation(async (targetBookId) => { + const storyDir = join(state.bookDir(targetBookId), "story"); + await mkdir(storyDir, { recursive: true }); + await writeFile(join(storyDir, "fanfic_canon.md"), "# Fanfic Canon\n", "utf-8"); + return "# Fanfic Canon\n"; + }); + vi.spyOn(ArchitectAgent.prototype, "generateFanficFoundation").mockResolvedValue({ + storyBible: "# Story Bible\n", + volumeOutline: "# Volume Outline\n", + bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n", + currentState: createStateCard({ + chapter: 0, + location: "Lantern quay", + protagonistState: "Lin Yue enters the fanfic timeline with a hidden debt.", + goal: "Find the canon fissure.", + conflict: "The old faction watches every move.", + }), + pendingHooks: "# Pending Hooks\n", + }); + vi.spyOn(runner, "generateStyleGuide").mockRejectedValue(new Error("style failed")); + + try { + await expect(runner.initFanficBook(book, "A".repeat(600), "canon.txt", "canon")).resolves.toBeUndefined(); + + expect(await state.loadChapterIndex(bookId)).toEqual([]); + await expect(readFile(join(state.bookDir(bookId), "story", "fanfic_canon.md"), "utf-8")).resolves.toContain("Fanfic Canon"); + await expect(stat(join(state.bookDir(bookId), "story", "snapshots", "0"))).resolves.toBeTruthy(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("keeps canon import running when style guide extraction fails", async () => { + const { root, runner, state, bookId } = await createRunnerFixture(); + const parentBookId = "parent-book"; + const now = "2026-03-19T00:00:00.000Z"; + const parentBook: BookConfig = { + id: parentBookId, + title: "Parent Book", + platform: "tomato", + genre: "xuanhuan", + status: "active", + targetChapters: 10, + chapterWordCount: 3000, + createdAt: now, + updatedAt: now, + }; + const parentStoryDir = join(state.bookDir(parentBookId), "story"); + const parentChaptersDir = join(state.bookDir(parentBookId), "chapters"); + + await state.saveBookConfig(parentBookId, parentBook); + await mkdir(parentStoryDir, { recursive: true }); + await mkdir(parentChaptersDir, { recursive: true }); + await Promise.all([ + writeFile(join(parentStoryDir, "story_bible.md"), "# Story Bible\n", "utf-8"), + writeFile(join(parentStoryDir, "current_state.md"), createStateCard({ + chapter: 3, + location: "North watchtower", + protagonistState: "The mentor debt is no longer secret.", + goal: "Protect the watchtower archive.", + conflict: "Guild spies are already inside the archive.", + }), "utf-8"), + writeFile(join(parentStoryDir, "particle_ledger.md"), "# Ledger\n", "utf-8"), + writeFile(join(parentStoryDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"), + writeFile(join(parentStoryDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"), + writeFile(join(parentChaptersDir, "0001_Parent.md"), `# Chapter 1\n\n${"Parent text. ".repeat(60)}`, "utf-8"), + ]); + + vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({ + content: "# Parent Canon\n\nImported canon body.", + } as Awaited>); + vi.spyOn(runner, "generateStyleGuide").mockRejectedValue(new Error("style failed")); + + try { + const canon = await runner.importCanon(bookId, parentBookId); + + expect(canon).toContain("# Parent Canon"); + await expect(readFile(join(state.bookDir(bookId), "story", "parent_canon.md"), "utf-8")).resolves.toContain("Imported canon body."); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("keeps chapter import running when style guide extraction fails", async () => { + const { root, runner, state, bookId } = await createRunnerFixture(); + const chapterContent = "章节正文。".repeat(120); + + 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: "Ashen ferry crossing", + protagonistState: "Lin Yue still hides the oath token.", + goal: "Find the vanished mentor.", + conflict: "The mentor debt is still personal.", + }), + pendingHooks: "# Pending Hooks\n", + }); + vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue( + createAnalyzedOutput({ + chapterNumber: 1, + title: "Prelude", + content: chapterContent, + wordCount: chapterContent.length, + }), + ); + vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined); + vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined); + vi.spyOn(runner, "generateStyleGuide").mockRejectedValue(new Error("style failed")); + + try { + const result = await runner.importChapters({ + bookId, + chapters: [ + { title: "Prelude", content: chapterContent }, + ], + }); + + expect(result.importedCount).toBe(1); + expect((await state.loadChapterIndex(bookId))[0]?.status).toBe("imported"); + await expect(readFile(join(state.bookDir(bookId), "story", "story_bible.md"), "utf-8")).resolves.toContain("# Story Bible"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + sqliteIt("rebuilds fact history from imported chapter snapshots", async () => { const { root, runner, state, bookId } = await createRunnerFixture(); diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 83a97702..ac7c806c 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -194,6 +194,24 @@ export class PipelineRunner { this.config.logger?.warn(this.localize(language, message)); } + private async tryGenerateStyleGuide( + bookId: string, + referenceText: string, + sourceName: string | undefined, + language?: LengthLanguage, + ): Promise { + try { + await this.generateStyleGuide(bookId, referenceText, sourceName); + } catch (error) { + const resolvedLanguage = language ?? await this.resolveBookLanguageById(bookId); + const detail = error instanceof Error ? error.message : String(error); + this.logWarn(resolvedLanguage, { + zh: `风格指纹提取失败,已跳过:${detail}`, + en: `Style fingerprint extraction failed and was skipped: ${detail}`, + }); + } + } + private agentCtx(bookId?: string): AgentContext { return { client: this.config.client, @@ -391,7 +409,7 @@ export class PipelineRunner { // Step 3: Generate style guide from source material if (sourceText.length >= 500) { this.logStage(stageLanguage, { zh: "提取原作风格指纹", en: "extracting source style fingerprint" }); - await this.generateStyleGuide(book.id, sourceText, sourceName); + await this.tryGenerateStyleGuide(book.id, sourceText, sourceName, stageLanguage); } // Step 4: Initialize chapters directory + snapshot @@ -1592,7 +1610,7 @@ ${matrix}`, const parentChaptersDir = join(parentDir, "chapters"); const parentChapterText = await this.readParentChapterSample(parentChaptersDir); if (parentChapterText.length >= 500) { - await this.generateStyleGuide(targetBookId, parentChapterText, parentBook.title); + await this.tryGenerateStyleGuide(targetBookId, parentChapterText, parentBook.title); } return canon; @@ -1672,7 +1690,7 @@ ${matrix}`, zh: "提取原文风格指纹...", en: "Extracting source style fingerprint...", })); - await this.generateStyleGuide(input.bookId, allText, book.title); + await this.tryGenerateStyleGuide(input.bookId, allText, book.title, resolvedLanguage); } log?.info(this.localize(resolvedLanguage, { From b98bc782771249b39903b5565c9833acc002eb8e Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 01:34:41 +0800 Subject: [PATCH 13/53] fix(studio): persist manual theme selection --- packages/studio/src/hooks/use-theme.test.ts | 31 ++++++++++ packages/studio/src/hooks/use-theme.ts | 65 +++++++++++++++++++-- 2 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 packages/studio/src/hooks/use-theme.test.ts diff --git a/packages/studio/src/hooks/use-theme.test.ts b/packages/studio/src/hooks/use-theme.test.ts new file mode 100644 index 00000000..311d7b22 --- /dev/null +++ b/packages/studio/src/hooks/use-theme.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { getTimeBasedThemeForHour, readStoredTheme, resolveThemePreference } from "./use-theme"; + +describe("resolveThemePreference", () => { + it("keeps a stored manual light theme during night hours", () => { + expect(resolveThemePreference({ hour: 23, storedTheme: "light" })).toBe("light"); + }); + + it("falls back to the clock when no manual theme is stored", () => { + expect(resolveThemePreference({ hour: 23, storedTheme: null })).toBe("dark"); + expect(resolveThemePreference({ hour: 9, storedTheme: null })).toBe("light"); + }); +}); + +describe("getTimeBasedThemeForHour", () => { + it("switches at 6:00 and 18:00", () => { + expect(getTimeBasedThemeForHour(5)).toBe("dark"); + expect(getTimeBasedThemeForHour(6)).toBe("light"); + expect(getTimeBasedThemeForHour(17)).toBe("light"); + expect(getTimeBasedThemeForHour(18)).toBe("dark"); + }); +}); + +describe("readStoredTheme", () => { + it("accepts only light and dark values from storage", () => { + expect(readStoredTheme({ getItem: () => "light" })).toBe("light"); + expect(readStoredTheme({ getItem: () => "dark" })).toBe("dark"); + expect(readStoredTheme({ getItem: () => "auto" })).toBeNull(); + expect(readStoredTheme({ getItem: () => null })).toBeNull(); + }); +}); diff --git a/packages/studio/src/hooks/use-theme.ts b/packages/studio/src/hooks/use-theme.ts index 9cd44b6c..8c44417d 100644 --- a/packages/studio/src/hooks/use-theme.ts +++ b/packages/studio/src/hooks/use-theme.ts @@ -1,22 +1,75 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; export type Theme = "light" | "dark"; -function getTimeBasedTheme(): Theme { - const hour = new Date().getHours(); +const THEME_STORAGE_KEY = "inkos:studio:theme"; + +interface ThemeStorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export function getTimeBasedThemeForHour(hour: number): Theme { return hour >= 6 && hour < 18 ? "light" : "dark"; } +function getTimeBasedTheme(): Theme { + return getTimeBasedThemeForHour(new Date().getHours()); +} + +function getThemeStorage(): ThemeStorageLike | null { + if (typeof window === "undefined") { + return null; + } + + try { + return window.localStorage; + } catch { + return null; + } +} + +export function readStoredTheme(storage: Pick | null | undefined): Theme | null { + const storedTheme = storage?.getItem(THEME_STORAGE_KEY); + return storedTheme === "light" || storedTheme === "dark" ? storedTheme : null; +} + +export function resolveThemePreference(params: { + readonly hour: number; + readonly storedTheme: Theme | null; +}): Theme { + return params.storedTheme ?? getTimeBasedThemeForHour(params.hour); +} + export function useTheme() { - const [theme, setTheme] = useState(getTimeBasedTheme); + const [theme, setThemeState] = useState(() => + resolveThemePreference({ + hour: new Date().getHours(), + storedTheme: readStoredTheme(getThemeStorage()), + }), + ); useEffect(() => { - // Check every minute for time-based switch const timer = setInterval(() => { - setTheme(getTimeBasedTheme()); + const storedTheme = readStoredTheme(getThemeStorage()); + setThemeState(resolveThemePreference({ + hour: new Date().getHours(), + storedTheme, + })); }, 60000); + return () => clearInterval(timer); }, []); + const setTheme = (nextTheme: Theme) => { + const storage = getThemeStorage(); + try { + storage?.setItem(THEME_STORAGE_KEY, nextTheme); + } catch { + // Ignore storage failures and keep the in-memory preference for this session. + } + setThemeState(nextTheme); + }; + return { theme, setTheme }; } From b688531b81f8dea522a23fde242dcfd872bfa116 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 01:35:02 +0800 Subject: [PATCH 14/53] fix(studio): reload latest llm config per request --- packages/studio/src/api/server.test.ts | 124 ++++++++++++++++++++++++- packages/studio/src/api/server.ts | 102 +++++++++++--------- 2 files changed, 177 insertions(+), 49 deletions(-) diff --git a/packages/studio/src/api/server.test.ts b/packages/studio/src/api/server.test.ts index 37d4c5f9..2745cdf1 100644 --- a/packages/studio/src/api/server.test.ts +++ b/packages/studio/src/api/server.test.ts @@ -1,10 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; const schedulerStartMock = vi.fn<() => Promise>(); const initBookMock = vi.fn(); +const runRadarMock = vi.fn(); +const createLLMClientMock = vi.fn(() => ({})); +const chatCompletionMock = vi.fn(); +const loadProjectConfigMock = vi.fn(); +const pipelineConfigs: unknown[] = []; const logger = { child: () => logger, @@ -39,9 +44,12 @@ vi.mock("@actalk/inkos-core", () => { } class MockPipelineRunner { - constructor(_config: unknown) {} + constructor(config: unknown) { + pipelineConfigs.push(config); + } initBook = initBookMock; + runRadar = runRadarMock; } class MockScheduler { @@ -67,9 +75,12 @@ vi.mock("@actalk/inkos-core", () => { StateManager: MockStateManager, PipelineRunner: MockPipelineRunner, Scheduler: MockScheduler, - createLLMClient: vi.fn(() => ({})), + createLLMClient: createLLMClientMock, createLogger: vi.fn(() => logger), computeAnalytics: vi.fn(() => ({})), + chatCompletion: chatCompletionMock, + loadProjectConfig: loadProjectConfigMock, + GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"), }; }); @@ -113,6 +124,37 @@ describe("createStudioServer daemon lifecycle", () => { await writeFile(join(root, "inkos.json"), JSON.stringify(projectConfig, null, 2), "utf-8"); schedulerStartMock.mockReset(); initBookMock.mockReset(); + runRadarMock.mockReset(); + runRadarMock.mockResolvedValue({ + marketSummary: "Fresh market summary", + recommendations: [], + }); + createLLMClientMock.mockReset(); + createLLMClientMock.mockReturnValue({}); + chatCompletionMock.mockReset(); + chatCompletionMock.mockResolvedValue({ + content: "pong", + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + }); + loadProjectConfigMock.mockReset(); + loadProjectConfigMock.mockImplementation(async () => { + const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")) as Record; + return { + ...cloneProjectConfig(), + ...raw, + llm: { + ...cloneProjectConfig().llm, + ...((raw.llm ?? {}) as Record), + }, + daemon: { + ...cloneProjectConfig().daemon, + ...((raw.daemon ?? {}) as Record), + }, + modelOverrides: (raw.modelOverrides ?? {}) as Record, + notify: (raw.notify ?? []) as unknown[], + }; + }); + pipelineConfigs.length = 0; }); afterEach(async () => { @@ -191,6 +233,82 @@ describe("createStudioServer daemon lifecycle", () => { }); }); + it("reloads latest llm config for doctor checks without restarting the studio server", async () => { + const startupConfig = { + ...cloneProjectConfig(), + llm: { + ...cloneProjectConfig().llm, + model: "stale-model", + baseUrl: "https://stale.example.com/v1", + }, + }; + + const freshConfig = { + ...cloneProjectConfig(), + llm: { + ...cloneProjectConfig().llm, + model: "fresh-model", + baseUrl: "https://fresh.example.com/v1", + }, + }; + loadProjectConfigMock.mockResolvedValue(freshConfig); + + const { createStudioServer } = await import("./server.js"); + const app = createStudioServer(startupConfig as never, root); + + const response = await app.request("http://localhost/api/doctor"); + + expect(response.status).toBe(200); + expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({ + model: "fresh-model", + baseUrl: "https://fresh.example.com/v1", + })); + expect(chatCompletionMock).toHaveBeenCalledWith( + expect.anything(), + "fresh-model", + expect.any(Array), + expect.objectContaining({ maxTokens: 5 }), + ); + }); + + it("reloads latest llm config for radar scans without restarting the studio server", async () => { + const startupConfig = { + ...cloneProjectConfig(), + llm: { + ...cloneProjectConfig().llm, + model: "stale-model", + baseUrl: "https://stale.example.com/v1", + }, + }; + + const freshConfig = { + ...cloneProjectConfig(), + llm: { + ...cloneProjectConfig().llm, + model: "fresh-model", + baseUrl: "https://fresh.example.com/v1", + }, + }; + loadProjectConfigMock.mockResolvedValue(freshConfig); + + const { createStudioServer } = await import("./server.js"); + const app = createStudioServer(startupConfig as never, root); + + const response = await app.request("http://localhost/api/radar/scan", { + method: "POST", + }); + + expect(response.status).toBe(200); + expect(runRadarMock).toHaveBeenCalledTimes(1); + expect(pipelineConfigs.at(-1)).toMatchObject({ + model: "fresh-model", + defaultLLMConfig: expect.objectContaining({ + model: "fresh-model", + baseUrl: "https://fresh.example.com/v1", + }), + }); + }); + it("updates the first-run language immediately after the language selector saves", async () => { const { createStudioServer } = await import("./server.js"); const app = createStudioServer(cloneProjectConfig() as never, root); diff --git a/packages/studio/src/api/server.ts b/packages/studio/src/api/server.ts index d59feb34..558dc30c 100644 --- a/packages/studio/src/api/server.ts +++ b/packages/studio/src/api/server.ts @@ -8,6 +8,7 @@ import { createLLMClient, createLogger, computeAnalytics, + loadProjectConfig, type PipelineConfig, type ProjectConfig, type LogSink, @@ -33,9 +34,10 @@ function broadcast(event: string, data: unknown): void { // --- Server factory --- -export function createStudioServer(config: ProjectConfig, root: string) { +export function createStudioServer(initialConfig: ProjectConfig, root: string) { const app = new Hono(); const state = new StateManager(root); + let cachedConfig = initialConfig; app.use("/*", cors()); @@ -73,15 +75,24 @@ export function createStudioServer(config: ProjectConfig, root: string) { }, }; - function buildPipelineConfig(): PipelineConfig { + async function loadCurrentProjectConfig( + options?: { readonly requireApiKey?: boolean }, + ): Promise { + const freshConfig = await loadProjectConfig(root, options); + cachedConfig = freshConfig; + return freshConfig; + } + + async function buildPipelineConfig(): Promise { + const currentConfig = await loadCurrentProjectConfig(); const logger = createLogger({ tag: "studio", sinks: [sseSink] }); return { - client: createLLMClient(config.llm), - model: config.llm.model, + client: createLLMClient(currentConfig.llm), + model: currentConfig.llm.model, projectRoot: root, - defaultLLMConfig: config.llm, - modelOverrides: config.modelOverrides, - notifyChannels: config.notify, + defaultLLMConfig: currentConfig.llm, + modelOverrides: currentConfig.modelOverrides, + notifyChannels: currentConfig.notify, logger, onStreamProgress: (progress) => { if (progress.status === "streaming") { @@ -167,7 +178,7 @@ export function createStudioServer(config: ProjectConfig, root: string) { broadcast("book:creating", { bookId, title: body.title }); bookCreateStatus.set(bookId, { status: "creating" }); - const pipeline = new PipelineRunner(buildPipelineConfig()); + const pipeline = new PipelineRunner(await buildPipelineConfig()); pipeline.initBook(bookConfig).then( () => { bookCreateStatus.delete(bookId); @@ -282,7 +293,7 @@ export function createStudioServer(config: ProjectConfig, root: string) { broadcast("write:start", { bookId: id }); // Fire and forget — progress/completion/errors pushed via SSE - const pipeline = new PipelineRunner(buildPipelineConfig()); + const pipeline = new PipelineRunner(await buildPipelineConfig()); pipeline.writeNextChapter(id, body.wordCount).then( (result) => { broadcast("write:complete", { bookId: id, chapterNumber: result.chapterNumber, status: result.status, title: result.title, wordCount: result.wordCount }); @@ -301,7 +312,7 @@ export function createStudioServer(config: ProjectConfig, root: string) { broadcast("draft:start", { bookId: id }); - const pipeline = new PipelineRunner(buildPipelineConfig()); + const pipeline = new PipelineRunner(await buildPipelineConfig()); pipeline.writeDraft(id, body.context, body.wordCount).then( (result) => { broadcast("draft:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount }); @@ -373,20 +384,21 @@ export function createStudioServer(config: ProjectConfig, root: string) { // --- Project info --- app.get("/api/project", async (c) => { + const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false }); // Check if language was explicitly set in inkos.json (not just the schema default) const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")); const languageExplicit = "language" in raw && raw.language !== ""; return c.json({ - name: config.name, - language: config.language, + name: currentConfig.name, + language: currentConfig.language, languageExplicit, - model: config.llm.model, - provider: config.llm.provider, - baseUrl: config.llm.baseUrl, - stream: config.llm.stream, - temperature: config.llm.temperature, - maxTokens: config.llm.maxTokens, + model: currentConfig.llm.model, + provider: currentConfig.llm.provider, + baseUrl: currentConfig.llm.baseUrl, + stream: currentConfig.llm.stream, + temperature: currentConfig.llm.temperature, + maxTokens: currentConfig.llm.maxTokens, }); }); @@ -401,19 +413,15 @@ export function createStudioServer(config: ProjectConfig, root: string) { // Merge LLM settings if (updates.temperature !== undefined) { existing.llm.temperature = updates.temperature; - config.llm.temperature = Number(updates.temperature); } if (updates.maxTokens !== undefined) { existing.llm.maxTokens = updates.maxTokens; - config.llm.maxTokens = Number(updates.maxTokens); } if (updates.stream !== undefined) { existing.llm.stream = updates.stream; - config.llm.stream = Boolean(updates.stream); } if (updates.language === "zh" || updates.language === "en") { existing.language = updates.language; - config.language = updates.language; } const { writeFile: writeFileFs } = await import("node:fs/promises"); await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8"); @@ -460,15 +468,16 @@ export function createStudioServer(config: ProjectConfig, root: string) { } try { const { Scheduler } = await import("@actalk/inkos-core"); + const currentConfig = await loadCurrentProjectConfig(); const scheduler = new Scheduler({ - ...buildPipelineConfig(), - radarCron: config.daemon.schedule.radarCron, - writeCron: config.daemon.schedule.writeCron, - maxConcurrentBooks: config.daemon.maxConcurrentBooks, - chaptersPerCycle: config.daemon.chaptersPerCycle, - retryDelayMs: config.daemon.retryDelayMs, - cooldownAfterChapterMs: config.daemon.cooldownAfterChapterMs, - maxChaptersPerDay: config.daemon.maxChaptersPerDay, + ...(await buildPipelineConfig()), + radarCron: currentConfig.daemon.schedule.radarCron, + writeCron: currentConfig.daemon.schedule.writeCron, + maxConcurrentBooks: currentConfig.daemon.maxConcurrentBooks, + chaptersPerCycle: currentConfig.daemon.chaptersPerCycle, + retryDelayMs: currentConfig.daemon.retryDelayMs, + cooldownAfterChapterMs: currentConfig.daemon.cooldownAfterChapterMs, + maxChaptersPerDay: currentConfig.daemon.maxChaptersPerDay, onChapterComplete: (bookId, chapter, status) => { broadcast("daemon:chapter", { bookId, chapter, status }); }, @@ -533,7 +542,7 @@ export function createStudioServer(config: ProjectConfig, root: string) { const { runAgentLoop } = await import("@actalk/inkos-core"); const result = await runAgentLoop( - buildPipelineConfig(), + await buildPipelineConfig(), instruction ); @@ -555,7 +564,6 @@ export function createStudioServer(config: ProjectConfig, root: string) { const raw = await readFile(configPath, "utf-8"); const existing = JSON.parse(raw); existing.language = language; - config.language = language; const { writeFile: writeFileFs } = await import("node:fs/promises"); await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8"); return c.json({ ok: true, language }); @@ -581,10 +589,11 @@ export function createStudioServer(config: ProjectConfig, root: string) { if (!match) return c.json({ error: "Chapter not found" }, 404); const content = await readFile(join(chaptersDir, match), "utf-8"); + const currentConfig = await loadCurrentProjectConfig(); const { ContinuityAuditor } = await import("@actalk/inkos-core"); const auditor = new ContinuityAuditor({ - client: createLLMClient(config.llm), - model: config.llm.model, + client: createLLMClient(currentConfig.llm), + model: currentConfig.llm.model, projectRoot: root, bookId: id, }); @@ -626,10 +635,11 @@ export function createStudioServer(config: ProjectConfig, root: string) { suggestion: "", })); + const currentConfig = await loadCurrentProjectConfig(); const { ReviserAgent } = await import("@actalk/inkos-core"); const reviser = new ReviserAgent({ - client: createLLMClient(config.llm), - model: config.llm.model, + client: createLLMClient(currentConfig.llm), + model: currentConfig.llm.model, projectRoot: root, bookId: id, }); @@ -928,7 +938,7 @@ export function createStudioServer(config: ProjectConfig, root: string) { if (!restored) { return c.json({ error: `Cannot restore state to chapter ${chapterNum}` }, 400); } - const pipeline = new PipelineRunner(buildPipelineConfig()); + const pipeline = new PipelineRunner(await buildPipelineConfig()); pipeline.writeNextChapter(id).then( (result) => broadcast("rewrite:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount }), (e) => broadcast("rewrite:error", { bookId: id, error: e instanceof Error ? e.message : String(e) }), @@ -1102,7 +1112,7 @@ export function createStudioServer(config: ProjectConfig, root: string) { broadcast("style:start", { bookId: id }); try { - const pipeline = new PipelineRunner(buildPipelineConfig()); + const pipeline = new PipelineRunner(await buildPipelineConfig()); const result = await pipeline.generateStyleGuide(id, text, sourceName ?? "unknown"); broadcast("style:complete", { bookId: id }); return c.json({ ok: true, result }); @@ -1124,7 +1134,7 @@ export function createStudioServer(config: ProjectConfig, root: string) { const { splitChapters } = await import("@actalk/inkos-core"); const chapters = [...splitChapters(text, splitRegex)]; - const pipeline = new PipelineRunner(buildPipelineConfig()); + const pipeline = new PipelineRunner(await buildPipelineConfig()); const result = await pipeline.importChapters({ bookId: id, chapters }); broadcast("import:complete", { bookId: id, type: "chapters", count: result.importedCount }); return c.json(result); @@ -1143,7 +1153,7 @@ export function createStudioServer(config: ProjectConfig, root: string) { broadcast("import:start", { bookId: id, type: "canon" }); try { - const pipeline = new PipelineRunner(buildPipelineConfig()); + const pipeline = new PipelineRunner(await buildPipelineConfig()); await pipeline.importCanon(id, fromBookId); broadcast("import:complete", { bookId: id, type: "canon" }); return c.json({ ok: true }); @@ -1184,7 +1194,7 @@ export function createStudioServer(config: ProjectConfig, root: string) { broadcast("fanfic:start", { bookId, title: body.title }); try { - const pipeline = new PipelineRunner(buildPipelineConfig()); + const pipeline = new PipelineRunner(await buildPipelineConfig()); await pipeline.initFanficBook(bookConfig, body.sourceText, body.sourceName ?? "source", (body.mode ?? "canon") as "canon"); broadcast("fanfic:complete", { bookId }); return c.json({ ok: true, bookId }); @@ -1217,7 +1227,7 @@ export function createStudioServer(config: ProjectConfig, root: string) { broadcast("fanfic:refresh:start", { bookId: id }); try { const book = await state.loadBookConfig(id); - const pipeline = new PipelineRunner(buildPipelineConfig()); + const pipeline = new PipelineRunner(await buildPipelineConfig()); await pipeline.importFanficCanon(id, sourceText, sourceName ?? "source", (book.fanficMode ?? "canon") as "canon"); broadcast("fanfic:refresh:complete", { bookId: id }); return c.json({ ok: true }); @@ -1232,7 +1242,7 @@ export function createStudioServer(config: ProjectConfig, root: string) { app.post("/api/radar/scan", async (c) => { broadcast("radar:start", {}); try { - const pipeline = new PipelineRunner(buildPipelineConfig()); + const pipeline = new PipelineRunner(await buildPipelineConfig()); const result = await pipeline.runRadar(); broadcast("radar:complete", { result }); return c.json(result); @@ -1263,9 +1273,10 @@ export function createStudioServer(config: ProjectConfig, root: string) { } catch { /* ignore */ } try { - const client = createLLMClient(config.llm); + const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false }); + const client = createLLMClient(currentConfig.llm); const { chatCompletion } = await import("@actalk/inkos-core"); - await chatCompletion(client, config.llm.model, [{ role: "user", content: "ping" }], { maxTokens: 5 }); + await chatCompletion(client, currentConfig.llm.model, [{ role: "user", content: "ping" }], { maxTokens: 5 }); checks.llmConnected = true; } catch { /* ignore */ } @@ -1282,7 +1293,6 @@ export async function startStudioServer( port = 4567, options?: { readonly staticDir?: string }, ): Promise { - const { loadProjectConfig } = await import("@actalk/inkos-core"); const config = await loadProjectConfig(root); const app = createStudioServer(config, root); From bbb059bcdbda0bd46f292e25814d9e6e534031c2 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 01:35:14 +0800 Subject: [PATCH 15/53] fix(architect): tolerate section label format drift --- packages/core/src/__tests__/architect.test.ts | 64 +++++++++++++++++++ packages/core/src/agents/architect.ts | 28 ++++++-- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/packages/core/src/__tests__/architect.test.ts b/packages/core/src/__tests__/architect.test.ts index 2515e56f..4d6559ba 100644 --- a/packages/core/src/__tests__/architect.test.ts +++ b/packages/core/src/__tests__/architect.test.ts @@ -271,6 +271,70 @@ describe("ArchitectAgent", () => { expect(result.pendingHooks).toContain("| H13 | 22 | 舆情操盘 | 待推进 | 0 | 51-60章 | 庄蔓出场后逐步揭露(初始线索:一家自媒体公司在多个旧案节点同步接单) |"); }); + it("accepts section labels with spacing and punctuation drift from non-strict models", async () => { + const agent = new ArchitectAgent({ + client: { + provider: "openai", + apiFormat: "chat", + stream: false, + defaults: { + temperature: 0.7, + maxTokens: 4096, + thinkingBudget: 0, maxTokensCap: null, + extra: {}, + }, + }, + model: "test-model", + projectRoot: process.cwd(), + }); + + const book: BookConfig = { + id: "format-drift-book", + title: "格式漂移测试", + platform: "other", + genre: "other", + status: "active", + targetChapters: 20, + chapterWordCount: 2200, + language: "zh", + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }; + + vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise }, "chat") + .mockResolvedValue({ + content: [ + "=== Section:Story Bible ===", + "# 故事圣经", + "", + "=== section: Volume Outline ===", + "# 卷纲", + "", + "=== SECTION: book-rules ===", + "---", + "version: \"1.0\"", + "---", + "", + "=== SECTION : current state ===", + "# 当前状态", + "", + "=== SECTION: pending hooks ===", + "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |", + "| --- | --- | --- | --- | --- | --- | --- |", + "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |", + ].join("\n"), + usage: ZERO_USAGE, + }); + + const result = await agent.generateFoundation(book); + + expect(result.storyBible).toBe("# 故事圣经"); + expect(result.volumeOutline).toBe("# 卷纲"); + expect(result.bookRules).toContain("version: \"1.0\""); + expect(result.currentState).toBe("# 当前状态"); + expect(result.pendingHooks).toContain("| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |"); + }); + it("throws when a required foundation section is missing", async () => { const agent = new ArchitectAgent({ client: { diff --git a/packages/core/src/agents/architect.ts b/packages/core/src/agents/architect.ts index 22a36a5a..0bf21846 100644 --- a/packages/core/src/agents/architect.ts +++ b/packages/core/src/agents/architect.ts @@ -654,12 +654,21 @@ prohibitions: } private parseSections(content: string): ArchitectOutput { + const parsedSections = new Map(); + const sectionPattern = /^\s*===\s*SECTION\s*[::]\s*([^\n=]+?)\s*===\s*$/gim; + const matches = [...content.matchAll(sectionPattern)]; + + for (let i = 0; i < matches.length; i++) { + const match = matches[i]!; + const rawName = match[1] ?? ""; + const start = (match.index ?? 0) + match[0].length; + const end = matches[i + 1]?.index ?? content.length; + const normalizedName = this.normalizeSectionName(rawName); + parsedSections.set(normalizedName, content.slice(start, end).trim()); + } + const extract = (name: string): string => { - const regex = new RegExp( - `=== SECTION: ${name} ===\\s*([\\s\\S]*?)(?==== SECTION:|$)`, - ); - const match = content.match(regex); - const section = match?.[1]?.trim(); + const section = parsedSections.get(this.normalizeSectionName(name)); if (!section) { throw new Error(`Architect output missing required section: ${name}`); } @@ -678,6 +687,15 @@ prohibitions: }; } + private normalizeSectionName(name: string): string { + return name + .normalize("NFKC") + .toLowerCase() + .replace(/[`"'*_]/g, " ") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + } + private stripTrailingAssistantCoda(section: string): string { const lines = section.split("\n"); const cutoff = lines.findIndex((line) => { From 2e2264f255747896562df75265aff17ba31bbafa Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 09:50:59 +0800 Subject: [PATCH 16/53] fix(pipeline): recover from state validation failures --- packages/core/src/__tests__/models.test.ts | 6 +- .../src/__tests__/pipeline-runner.test.ts | 251 ++++++++++ packages/core/src/__tests__/scheduler.test.ts | 55 +++ packages/core/src/agents/settler-prompts.ts | 5 + packages/core/src/agents/writer.ts | 108 ++++- packages/core/src/models/chapter.ts | 1 + packages/core/src/pipeline/runner.ts | 432 +++++++++++++++++- 7 files changed, 837 insertions(+), 21 deletions(-) diff --git a/packages/core/src/__tests__/models.test.ts b/packages/core/src/__tests__/models.test.ts index bfbb70f3..812eeddf 100644 --- a/packages/core/src/__tests__/models.test.ts +++ b/packages/core/src/__tests__/models.test.ts @@ -281,19 +281,21 @@ describe("ChapterStatusSchema", () => { "auditing", "audit-passed", "audit-failed", + "state-degraded", "revising", "ready-for-review", "approved", "rejected", "published", + "imported", ] as const; it.each(allStatuses)("accepts '%s'", (value) => { expect(ChapterStatusSchema.parse(value)).toBe(value); }); - it("has exactly 12 valid statuses", () => { - expect(ChapterStatusSchema.options).toHaveLength(12); + it("has exactly 13 valid statuses", () => { + expect(ChapterStatusSchema.options).toHaveLength(13); }); it("rejects unknown status", () => { diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts index 90343fb2..62de1d7f 100644 --- a/packages/core/src/__tests__/pipeline-runner.test.ts +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -2145,6 +2145,257 @@ describe("PipelineRunner", () => { await rm(root, { recursive: true, force: true }); }); + it("retries settlement after state contradictions without rewriting the chapter body", async () => { + const { root, runner, state, bookId } = await createRunnerFixture({ + inputGovernanceMode: "legacy", + }); + const storyDir = join(state.bookDir(bookId), "story"); + + await Promise.all([ + writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"), + writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"), + writeFile(join(storyDir, "particle_ledger.md"), "stable ledger", "utf-8"), + ]); + + const writeSpy = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue( + createWriterOutput({ + content: "Healthy chapter body with the copper token in his coat.", + wordCount: "Healthy chapter body with the copper token in his coat.".length, + updatedState: "broken state", + updatedHooks: "broken hooks", + updatedLedger: "broken ledger", + }), + ); + const settleSpy = vi.spyOn( + WriterAgent.prototype as unknown as { + settleChapterState: (input: Record) => Promise; + }, + "settleChapterState", + ).mockResolvedValue( + createWriterOutput({ + content: "Healthy chapter body with the copper token in his coat.", + wordCount: "Healthy chapter body with the copper token in his coat.".length, + updatedState: "fixed state", + updatedHooks: "fixed hooks", + updatedLedger: "fixed ledger", + }), + ); + vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue( + createAuditResult({ + passed: true, + issues: [], + summary: "clean", + }), + ); + vi.spyOn(StateValidatorAgent.prototype, "validate") + .mockResolvedValueOnce({ + passed: false, + warnings: [{ + category: "unsupported_change", + description: "状态写成铜牌未带在身上,但正文明确写了怀里的铜牌。", + }], + }) + .mockResolvedValueOnce({ + passed: true, + warnings: [], + }); + + const result = await runner.writeNextChapter(bookId); + + expect(result.status).toBe("ready-for-review"); + expect(writeSpy).toHaveBeenCalledTimes(1); + expect(settleSpy).toHaveBeenCalledTimes(1); + expect(settleSpy).toHaveBeenCalledWith(expect.objectContaining({ + chapterNumber: 1, + title: "Test Chapter", + content: "Healthy chapter body with the copper token in his coat.", + validationFeedback: expect.stringContaining("怀里的铜牌"), + })); + await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("fixed state"); + await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("fixed hooks"); + + await rm(root, { recursive: true, force: true }); + }); + + it("persists a state-degraded chapter without advancing truth files when settlement retry still contradicts the body", async () => { + const { root, runner, state, bookId } = await createRunnerFixture({ + inputGovernanceMode: "legacy", + }); + const bookDir = state.bookDir(bookId); + const storyDir = join(bookDir, "story"); + const chaptersDir = join(bookDir, "chapters"); + + await Promise.all([ + writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"), + writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"), + writeFile(join(storyDir, "particle_ledger.md"), "stable ledger", "utf-8"), + ]); + + vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue( + createWriterOutput({ + content: "Healthy chapter body with the copper token in his coat.", + wordCount: "Healthy chapter body with the copper token in his coat.".length, + updatedState: "broken state", + updatedHooks: "broken hooks", + updatedLedger: "broken ledger", + }), + ); + vi.spyOn( + WriterAgent.prototype as unknown as { + settleChapterState: (input: Record) => Promise; + }, + "settleChapterState", + ).mockResolvedValue( + createWriterOutput({ + content: "Healthy chapter body with the copper token in his coat.", + wordCount: "Healthy chapter body with the copper token in his coat.".length, + updatedState: "still broken state", + updatedHooks: "still broken hooks", + updatedLedger: "still broken ledger", + }), + ); + vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue( + createAuditResult({ + passed: true, + issues: [], + summary: "clean", + }), + ); + vi.spyOn(StateValidatorAgent.prototype, "validate") + .mockResolvedValueOnce({ + passed: false, + warnings: [{ + category: "unsupported_change", + description: "settler 把铜牌写没了,但正文仍然明确带在身上。", + }], + }) + .mockResolvedValueOnce({ + passed: false, + warnings: [{ + category: "unsupported_change", + description: "重试后仍然把铜牌写没了。", + }], + }); + + const result = await runner.writeNextChapter(bookId); + const savedIndex = await state.loadChapterIndex(bookId); + + expect(result.status).toBe("state-degraded"); + expect(savedIndex[0]?.status).toBe("state-degraded"); + expect(savedIndex[0]?.auditIssues).toContain("[warning] 重试后仍然把铜牌写没了。"); + await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("stable state"); + await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("stable hooks"); + await expect(readFile(join(storyDir, "particle_ledger.md"), "utf-8")).resolves.toBe("stable ledger"); + await expect(readdir(chaptersDir)).resolves.toContain("0001_Test_Chapter.md"); + await expect(stat(join(storyDir, "snapshots", "1"))).rejects.toThrow(); + + await rm(root, { recursive: true, force: true }); + }); + + it("blocks writing a new chapter when the latest persisted chapter is state-degraded", async () => { + const { root, runner, state, bookId } = await createRunnerFixture({ + inputGovernanceMode: "legacy", + }); + const now = "2026-03-19T00:00:00.000Z"; + const storyDir = join(state.bookDir(bookId), "story"); + + await Promise.all([ + writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"), + writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"), + state.saveChapterIndex(bookId, [{ + number: 1, + title: "Broken Persistence", + status: "state-degraded" as ChapterMeta["status"], + wordCount: 1234, + createdAt: now, + updatedAt: now, + auditIssues: ["[warning] state validation degraded"], + lengthWarnings: [], + }]), + writeFile(join(state.bookDir(bookId), "chapters", "0001_Broken_Persistence.md"), "# 第1章 Broken Persistence\n\nbody", "utf-8"), + ]); + + await expect(runner.writeNextChapter(bookId)).rejects.toThrow(/state-degraded/i); + + await rm(root, { recursive: true, force: true }); + }); + + it("repairs the latest state-degraded chapter from persisted body without rewriting it", async () => { + const { root, runner, state, bookId } = await createRunnerFixture({ + inputGovernanceMode: "legacy", + }); + const now = "2026-03-19T00:00:00.000Z"; + const bookDir = state.bookDir(bookId); + const storyDir = join(bookDir, "story"); + + await Promise.all([ + writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"), + writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"), + writeFile(join(storyDir, "particle_ledger.md"), "stable ledger", "utf-8"), + writeFile( + join(bookDir, "chapters", "0001_Broken_Persistence.md"), + "# 第1章 Broken Persistence\n\nHealthy chapter body with the copper token in his coat.", + "utf-8", + ), + state.saveChapterIndex(bookId, [{ + number: 1, + title: "Broken Persistence", + status: "state-degraded" as ChapterMeta["status"], + wordCount: 55, + createdAt: now, + updatedAt: now, + auditIssues: ["[warning] 重试后仍然把铜牌写没了。"], + lengthWarnings: [], + reviewNote: JSON.stringify({ + kind: "state-degraded", + baseStatus: "ready-for-review", + injectedIssues: ["[warning] 重试后仍然把铜牌写没了。"], + }), + }]), + ]); + + vi.spyOn( + WriterAgent.prototype as unknown as { + settleChapterState: (input: Record) => Promise; + }, + "settleChapterState", + ).mockResolvedValue( + createWriterOutput({ + chapterNumber: 1, + title: "Broken Persistence", + content: "Healthy chapter body with the copper token in his coat.", + wordCount: "Healthy chapter body with the copper token in his coat.".length, + updatedState: "fixed state", + updatedHooks: "fixed hooks", + updatedLedger: "fixed ledger", + }), + ); + vi.spyOn(StateValidatorAgent.prototype, "validate").mockResolvedValue({ + passed: true, + warnings: [], + }); + + const result = await ( + runner as unknown as { + repairChapterState: (bookId: string, chapterNumber?: number) => Promise<{ + status: string; + chapterNumber: number; + }>; + } + ).repairChapterState(bookId, 1); + const savedIndex = await state.loadChapterIndex(bookId); + + expect(result.status).toBe("ready-for-review"); + expect(result.chapterNumber).toBe(1); + await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("fixed state"); + await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("fixed hooks"); + expect(savedIndex[0]?.status).toBe("ready-for-review"); + expect(savedIndex[0]?.auditIssues).toEqual([]); + expect(savedIndex[0]?.reviewNote).toBeUndefined(); + + await rm(root, { recursive: true, force: true }); + }); + it("still persists the chapter when the state validator appends markdown after a valid JSON verdict", async () => { vi.restoreAllMocks(); vi.spyOn(LengthNormalizerAgent.prototype, "normalizeChapter").mockImplementation( diff --git a/packages/core/src/__tests__/scheduler.test.ts b/packages/core/src/__tests__/scheduler.test.ts index 6aa2b597..6dac95aa 100644 --- a/packages/core/src/__tests__/scheduler.test.ts +++ b/packages/core/src/__tests__/scheduler.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Scheduler, type SchedulerConfig } from "../pipeline/scheduler.js"; +import type { BookConfig } from "../models/book.js"; function createConfig(): SchedulerConfig { return { @@ -65,4 +66,58 @@ describe("Scheduler", () => { await blockedCycle; scheduler.stop(); }); + + it("treats state-degraded chapter results as handled failures", async () => { + const onChapterComplete = vi.fn(); + const scheduler = new Scheduler({ + ...createConfig(), + onChapterComplete, + }); + const bookConfig: BookConfig = { + id: "book-1", + title: "Book 1", + platform: "other", + genre: "other", + status: "active", + targetChapters: 10, + chapterWordCount: 2200, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }; + + vi.spyOn( + (scheduler as unknown as { pipeline: { writeNextChapter: (bookId: string, words?: number, temp?: number) => Promise } }).pipeline, + "writeNextChapter", + ).mockResolvedValue({ + chapterNumber: 3, + title: "Broken State", + wordCount: 2100, + revised: false, + status: "state-degraded", + auditResult: { + passed: true, + issues: [{ + severity: "warning", + category: "state-validation", + description: "state validation still failed after retry", + suggestion: "repair state before continuing", + }], + summary: "clean", + }, + }); + const handleAuditFailure = vi.spyOn( + scheduler as unknown as { handleAuditFailure: (bookId: string, chapterNumber: number, issueCategories?: string[]) => Promise }, + "handleAuditFailure", + ).mockResolvedValue(undefined); + + const success = await ( + scheduler as unknown as { + writeOneChapter: (bookId: string, bookConfig: BookConfig) => Promise; + } + ).writeOneChapter("book-1", bookConfig); + + expect(success).toBe(false); + expect(handleAuditFailure).toHaveBeenCalledWith("book-1", 3, ["state-validation"]); + expect(onChapterComplete).toHaveBeenCalledWith("book-1", 3, "state-degraded"); + }); }); diff --git a/packages/core/src/agents/settler-prompts.ts b/packages/core/src/agents/settler-prompts.ts index 2f1c62d5..3d28a064 100644 --- a/packages/core/src/agents/settler-prompts.ts +++ b/packages/core/src/agents/settler-prompts.ts @@ -171,6 +171,7 @@ export function buildSettlerUserPrompt(params: { readonly observations?: string; readonly selectedEvidenceBlock?: string; readonly governedControlBlock?: string; + readonly validationFeedback?: string; }): string { const ledgerBlock = params.ledger ? `\n## 当前资源账本\n${params.ledger}\n` @@ -202,9 +203,13 @@ export function buildSettlerUserPrompt(params: { const outlineBlock = controlBlock.length === 0 ? `\n## 卷纲\n${params.volumeOutline}\n` : ""; + const validationFeedbackBlock = params.validationFeedback + ? `\n## 状态校验反馈\n${params.validationFeedback}\n\n请严格纠正这些矛盾,只修正 truth files,不要改写正文,不要引入正文中不存在的新事实。\n` + : ""; return `请分析第${params.chapterNumber}章「${params.title}」的正文,更新所有追踪文件。 ${observationsBlock} +${validationFeedbackBlock} ## 本章正文 ${params.content} diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index 9d82372a..b3868466 100644 --- a/packages/core/src/agents/writer.ts +++ b/packages/core/src/agents/writer.ts @@ -18,7 +18,7 @@ import { analyzeAITells } from "./ai-tells.js"; import type { ChapterTrace, ContextPackage, RuleStack } from "../models/input-governance.js"; import type { LengthSpec } from "../models/length-governance.js"; import type { RuntimeStateDelta } from "../models/runtime-state.js"; -import { buildLengthSpec } from "../utils/length-metrics.js"; +import { buildLengthSpec, countChapterLength } from "../utils/length-metrics.js"; import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js"; import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js"; import { @@ -51,6 +51,18 @@ export interface WriteChapterInput { readonly temperatureOverride?: number; } +export interface SettleChapterStateInput { + readonly book: BookConfig; + readonly bookDir: string; + readonly chapterNumber: number; + readonly title: string; + readonly content: string; + readonly chapterIntent?: string; + readonly contextPackage?: ContextPackage; + readonly ruleStack?: RuleStack; + readonly validationFeedback?: string; +} + export interface TokenUsage { readonly promptTokens: number; readonly completionTokens: number; @@ -295,6 +307,7 @@ export class WriterAgent extends BaseAgent { chapterIntent: input.chapterIntent, contextPackage: input.contextPackage, ruleStack: input.ruleStack, + validationFeedback: undefined, originalHooks: hooks, originalSubplots: subplotBoard, originalEmotionalArcs: emotionalArcs, @@ -392,6 +405,97 @@ export class WriterAgent extends BaseAgent { }; } + async settleChapterState(input: SettleChapterStateInput): Promise { + const [ + currentState, + ledger, + hooks, + chapterSummaries, + subplotBoard, + emotionalArcs, + characterMatrix, + volumeOutline, + ] = await Promise.all([ + this.readFileOrDefault(join(input.bookDir, "story/current_state.md")), + this.readFileOrDefault(join(input.bookDir, "story/particle_ledger.md")), + this.readFileOrDefault(join(input.bookDir, "story/pending_hooks.md")), + this.readFileOrDefault(join(input.bookDir, "story/chapter_summaries.md")), + this.readFileOrDefault(join(input.bookDir, "story/subplot_board.md")), + this.readFileOrDefault(join(input.bookDir, "story/emotional_arcs.md")), + this.readFileOrDefault(join(input.bookDir, "story/character_matrix.md")), + this.readFileOrDefault(join(input.bookDir, "story/volume_outline.md")), + ]); + + const { profile: genreProfile } = await readGenreProfile(this.ctx.projectRoot, input.book.genre); + const parsedBookRules = await readBookRules(input.bookDir); + const bookRules = parsedBookRules?.rules ?? null; + const resolvedLanguage = input.book.language ?? genreProfile.language; + const governedMemoryBlocks = input.contextPackage + ? buildGovernedMemoryEvidenceBlocks(input.contextPackage, resolvedLanguage) + : undefined; + + const settleResult = await this.settle({ + book: input.book, + genreProfile, + bookRules, + chapterNumber: input.chapterNumber, + title: input.title, + content: input.content, + currentState, + ledger: genreProfile.numericalSystem ? ledger : "", + hooks, + chapterSummaries, + subplotBoard, + emotionalArcs, + characterMatrix, + volumeOutline, + selectedEvidenceBlock: governedMemoryBlocks + ? this.joinGovernedEvidenceBlocks(governedMemoryBlocks) + : undefined, + chapterIntent: input.chapterIntent, + contextPackage: input.contextPackage, + ruleStack: input.ruleStack, + validationFeedback: input.validationFeedback, + originalHooks: hooks, + originalSubplots: subplotBoard, + originalEmotionalArcs: emotionalArcs, + originalCharacterMatrix: characterMatrix, + }); + const settlement = settleResult.settlement; + const runtimeStateArtifacts = await this.buildRuntimeStateArtifactsIfPresent( + input.bookDir, + settlement.runtimeStateDelta, + resolvedLanguage, + ); + + return { + chapterNumber: input.chapterNumber, + title: input.title, + content: input.content, + wordCount: countChapterLength( + input.content, + resolvedLanguage === "en" ? "en_words" : "zh_chars", + ), + preWriteCheck: "", + postSettlement: settlement.postSettlement, + runtimeStateDelta: runtimeStateArtifacts?.resolvedDelta ?? settlement.runtimeStateDelta, + runtimeStateSnapshot: runtimeStateArtifacts?.snapshot ?? settlement.runtimeStateSnapshot, + updatedState: runtimeStateArtifacts?.currentStateMarkdown ?? settlement.updatedState, + updatedLedger: settlement.updatedLedger, + updatedHooks: runtimeStateArtifacts?.hooksMarkdown ?? settlement.updatedHooks, + chapterSummary: settlement.runtimeStateDelta + ? this.renderDeltaSummaryRow(settlement.runtimeStateDelta) + : settlement.chapterSummary, + updatedChapterSummaries: runtimeStateArtifacts?.chapterSummariesMarkdown, + updatedSubplots: settlement.updatedSubplots, + updatedEmotionalArcs: settlement.updatedEmotionalArcs, + updatedCharacterMatrix: settlement.updatedCharacterMatrix, + postWriteErrors: [], + postWriteWarnings: [], + tokenUsage: settleResult.usage, + }; + } + private async settle(params: { readonly book: BookConfig; readonly genreProfile: GenreProfile; @@ -411,6 +515,7 @@ export class WriterAgent extends BaseAgent { readonly chapterIntent?: string; readonly contextPackage?: ContextPackage; readonly ruleStack?: RuleStack; + readonly validationFeedback?: string; readonly originalHooks: string; readonly originalSubplots: string; readonly originalEmotionalArcs: string; @@ -472,6 +577,7 @@ export class WriterAgent extends BaseAgent { observations, selectedEvidenceBlock: params.selectedEvidenceBlock, governedControlBlock, + validationFeedback: params.validationFeedback, }); // Settler outputs all truth files — scale with content size diff --git a/packages/core/src/models/chapter.ts b/packages/core/src/models/chapter.ts index 11e854c3..b476dac8 100644 --- a/packages/core/src/models/chapter.ts +++ b/packages/core/src/models/chapter.ts @@ -8,6 +8,7 @@ export const ChapterStatusSchema = z.enum([ "auditing", "audit-passed", "audit-failed", + "state-degraded", "revising", "ready-for-review", "approved", diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index ac7c806c..122c9c60 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -13,7 +13,7 @@ import { LengthNormalizerAgent } from "../agents/length-normalizer.js"; import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js"; import { ContinuityAuditor } from "../agents/continuity.js"; import { ReviserAgent, DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js"; -import { StateValidatorAgent } from "../agents/state-validator.js"; +import { StateValidatorAgent, type ValidationResult, type ValidationWarning } from "../agents/state-validator.js"; import { RadarAgent } from "../agents/radar.js"; import type { RadarSource } from "../agents/radar-source.js"; import { readGenreProfile } from "../agents/rules-reader.js"; @@ -61,7 +61,7 @@ export interface ChapterPipelineResult { readonly wordCount: number; readonly auditResult: AuditResult; readonly revised: boolean; - readonly status: "ready-for-review" | "audit-failed"; + readonly status: "ready-for-review" | "audit-failed" | "state-degraded"; readonly lengthWarnings?: ReadonlyArray; readonly lengthTelemetry?: LengthTelemetry; readonly tokenUsage?: TokenUsageSummary; @@ -964,10 +964,20 @@ export class PipelineRunner { } } + async repairChapterState(bookId: string, chapterNumber?: number): Promise { + const releaseLock = await this.state.acquireBookLock(bookId); + try { + return await this._repairChapterStateLocked(bookId, chapterNumber); + } finally { + await releaseLock(); + } + } + private async _writeNextChapterLocked(bookId: string, wordCount?: number, temperatureOverride?: number): Promise { await this.state.ensureControlDocuments(bookId); const book = await this.state.loadBookConfig(bookId); const bookDir = this.state.bookDir(bookId); + await this.assertNoPendingStateRepair(bookId); const chapterNumber = await this.state.getNextChapterNumber(bookId); const stageLanguage = await this.resolveBookLanguage(book); this.logStage(stageLanguage, { zh: "准备章节输入", en: "preparing chapter inputs" }); @@ -1237,12 +1247,15 @@ export class PipelineRunner { // 4.1 Validate settler output before writing this.logStage(stageLanguage, { zh: "校验真相文件变更", en: "validating truth file updates" }); const storyDir = join(bookDir, "story"); - const [oldState, oldHooks] = await Promise.all([ + const [oldState, oldHooks, oldLedger] = await Promise.all([ readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""), readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""), + readFile(join(storyDir, "particle_ledger.md"), "utf-8").catch(() => ""), ]); const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId)); - let validation; + let validation: ValidationResult; + let chapterStatus: ChapterPipelineResult["status"] | null = null; + let degradedIssues: ReadonlyArray = []; try { validation = await validator.validate( finalContent, chapterNumber, @@ -1264,8 +1277,38 @@ export class PipelineRunner { } } if (!validation.passed) { - const reason = validation.warnings[0]?.description ?? "validator reported contradictions"; - throw new Error(`State validation failed for chapter ${chapterNumber}: ${reason}`); + const recovery = await this.retrySettlementAfterValidationFailure({ + writer, + validator, + book, + bookDir, + chapterNumber, + title: persistenceOutput.title, + content: finalContent, + reducedControlInput, + oldState, + oldHooks, + originalValidation: validation, + language: pipelineLang, + }); + + if (recovery.kind === "recovered") { + persistenceOutput = recovery.output; + validation = recovery.validation; + } else { + chapterStatus = "state-degraded"; + degradedIssues = recovery.issues; + persistenceOutput = this.buildStateDegradedPersistenceOutput({ + output: persistenceOutput, + oldState, + oldHooks, + oldLedger, + }); + auditResult = { + ...auditResult, + issues: [...auditResult.issues, ...recovery.issues], + }; + } } // 4.2 Final paragraph shape check on persisted content (post-normalize, post-revise) @@ -1303,10 +1346,12 @@ export class PipelineRunner { } await writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang); - await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang); - await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput); - this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" }); - await this.syncNarrativeMemoryIndex(bookId); + if (chapterStatus !== "state-degraded") { + await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang); + await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput); + this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" }); + await this.syncNarrativeMemoryIndex(bookId); + } // 5. Update chapter index const existingIndex = await this.state.loadChapterIndex(bookId); @@ -1314,7 +1359,7 @@ export class PipelineRunner { const newEntry: ChapterMeta = { number: chapterNumber, title: persistenceOutput.title, - status: auditResult.passed ? "ready-for-review" : "audit-failed", + status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"), wordCount: finalWordCount, createdAt: now, updatedAt: now, @@ -1322,6 +1367,12 @@ export class PipelineRunner { (i) => `[${i.severity}] ${i.description}`, ), lengthWarnings, + reviewNote: chapterStatus === "state-degraded" + ? this.buildStateDegradedReviewNote( + auditResult.passed ? "ready-for-review" : "audit-failed", + degradedIssues, + ) + : undefined, lengthTelemetry, tokenUsage: totalUsage, }; @@ -1333,7 +1384,7 @@ export class PipelineRunner { const driftIssues = auditResult.issues.filter( (i) => i.severity === "critical" || i.severity === "warning", ); - if (driftIssues.length > 0) { + if (chapterStatus !== "state-degraded" && driftIssues.length > 0) { const storyDir = join(bookDir, "story"); try { const statePath = join(storyDir, "current_state.md"); @@ -1367,20 +1418,26 @@ export class PipelineRunner { } // 5.6 Snapshot state for rollback support - this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" }); - await this.state.snapshotState(bookId, chapterNumber); - await this.syncCurrentStateFactHistory(bookId, chapterNumber); + if (chapterStatus !== "state-degraded") { + this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" }); + 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 statusEmoji = chapterStatus === "state-degraded" + ? "🧯" + : auditResult.passed ? "✅" : "⚠️"; const chapterLength = formatLengthCount(finalWordCount, lengthSpec.countingMode); await dispatchNotification(this.config.notifyChannels, { title: `${statusEmoji} ${book.title} 第${chapterNumber}章`, body: [ `**${persistenceOutput.title}** | ${chapterLength}`, revised ? "📝 已自动修正" : "", - `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`, + chapterStatus === "state-degraded" + ? "状态结算: 已降级保存,需先修复 state 再继续" + : `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`, ...auditResult.issues .filter((i) => i.severity !== "info") .map((i) => `- [${i.severity}] ${i.description}`), @@ -1395,6 +1452,7 @@ export class PipelineRunner { wordCount: finalWordCount, passed: auditResult.passed, revised, + status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"), }); return { @@ -1403,13 +1461,130 @@ export class PipelineRunner { wordCount: finalWordCount, auditResult, revised, - status: auditResult.passed ? "ready-for-review" : "audit-failed", + status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"), lengthWarnings, lengthTelemetry, tokenUsage: totalUsage, }; } + private async _repairChapterStateLocked(bookId: string, chapterNumber?: number): Promise { + const book = await this.state.loadBookConfig(bookId); + const bookDir = this.state.bookDir(bookId); + const stageLanguage = await this.resolveBookLanguage(book); + const index = [...(await this.state.loadChapterIndex(bookId))]; + if (index.length === 0) { + throw new Error(`Book "${bookId}" has no persisted chapters to repair.`); + } + + const targetChapter = chapterNumber ?? index[index.length - 1]!.number; + const targetIndex = index.findIndex((chapter) => chapter.number === targetChapter); + if (targetIndex < 0) { + throw new Error(`Chapter ${targetChapter} not found in "${bookId}".`); + } + const targetMeta = index[targetIndex]!; + const latestChapter = Math.max(...index.map((chapter) => chapter.number)); + if (targetMeta.status !== "state-degraded") { + throw new Error(`Chapter ${targetChapter} is not state-degraded.`); + } + if (targetChapter !== latestChapter) { + throw new Error(`Only the latest state-degraded chapter can be repaired safely (latest is ${latestChapter}).`); + } + + this.logStage(stageLanguage, { zh: "修复章节状态结算", en: "repairing chapter state settlement" }); + const { profile: gp } = await this.loadGenreProfile(book.genre); + const pipelineLang = book.language ?? gp.language; + const content = await this.readChapterContent(bookDir, targetChapter); + const storyDir = join(bookDir, "story"); + const [oldState, oldHooks] = await Promise.all([ + readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""), + readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""), + ]); + + const writer = new WriterAgent(this.agentCtxFor("writer", bookId)); + let repairedOutput = await writer.settleChapterState({ + book, + bookDir, + chapterNumber: targetChapter, + title: targetMeta.title, + content, + }); + const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId)); + let validation = await validator.validate( + content, + targetChapter, + oldState, + repairedOutput.updatedState, + oldHooks, + repairedOutput.updatedHooks, + pipelineLang, + ); + + if (!validation.passed) { + const recovery = await this.retrySettlementAfterValidationFailure({ + writer, + validator, + book, + bookDir, + chapterNumber: targetChapter, + title: targetMeta.title, + content, + oldState, + oldHooks, + originalValidation: validation, + language: pipelineLang, + }); + if (recovery.kind !== "recovered") { + throw new Error( + recovery.issues[0]?.description + ?? `State repair still failed for chapter ${targetChapter}.`, + ); + } + repairedOutput = recovery.output; + validation = recovery.validation; + } + + if (!validation.passed) { + throw new Error(`State repair still failed for chapter ${targetChapter}.`); + } + + await writer.saveChapter(bookDir, repairedOutput, gp.numericalSystem, pipelineLang); + await writer.saveNewTruthFiles(bookDir, repairedOutput, pipelineLang); + await this.syncLegacyStructuredStateFromMarkdown(bookDir, targetChapter, repairedOutput); + await this.syncNarrativeMemoryIndex(bookId); + await this.state.snapshotState(bookId, targetChapter); + await this.syncCurrentStateFactHistory(bookId, targetChapter); + + const baseStatus = this.resolveStateDegradedBaseStatus(targetMeta); + const degradedMetadata = this.parseStateDegradedReviewNote(targetMeta.reviewNote); + const injectedIssues = new Set(degradedMetadata?.injectedIssues ?? []); + index[targetIndex] = { + ...targetMeta, + status: baseStatus, + updatedAt: new Date().toISOString(), + auditIssues: targetMeta.auditIssues.filter((issue) => !injectedIssues.has(issue)), + reviewNote: undefined, + }; + await this.state.saveChapterIndex(bookId, index); + + const repairedPassesAudit = baseStatus !== "audit-failed"; + return { + chapterNumber: targetChapter, + title: targetMeta.title, + wordCount: targetMeta.wordCount, + auditResult: { + passed: repairedPassesAudit, + issues: [], + summary: repairedPassesAudit ? "state repaired" : "state repaired but chapter still needs review", + }, + revised: false, + status: baseStatus, + lengthWarnings: targetMeta.lengthWarnings, + lengthTelemetry: targetMeta.lengthTelemetry, + tokenUsage: targetMeta.tokenUsage, + }; + } + // --------------------------------------------------------------------------- // Import operations (style imitation + canon for spinoff) // --------------------------------------------------------------------------- @@ -1851,6 +2026,227 @@ ${matrix}`, }; } + private async assertNoPendingStateRepair(bookId: string): Promise { + const existingIndex = await this.state.loadChapterIndex(bookId); + const latestChapter = [...existingIndex].sort((left, right) => right.number - left.number)[0]; + if (latestChapter?.status !== "state-degraded") { + return; + } + + throw new Error( + `Latest chapter ${latestChapter.number} is state-degraded. Repair state or rewrite that chapter before continuing.`, + ); + } + + private async retrySettlementAfterValidationFailure(params: { + readonly writer: WriterAgent; + readonly validator: StateValidatorAgent; + readonly book: BookConfig; + readonly bookDir: string; + readonly chapterNumber: number; + readonly title: string; + readonly content: string; + readonly reducedControlInput?: { + chapterIntent: string; + contextPackage: ContextPackage; + ruleStack: RuleStack; + }; + readonly oldState: string; + readonly oldHooks: string; + readonly originalValidation: ValidationResult; + readonly language: LengthLanguage; + }): Promise< + | { + readonly kind: "recovered"; + readonly output: WriteChapterOutput; + readonly validation: ValidationResult; + } + | { + readonly kind: "degraded"; + readonly issues: ReadonlyArray; + } + > { + this.logWarn(params.language, { + zh: `状态校验失败,正在仅重试结算层(第${params.chapterNumber}章)`, + en: `State validation failed; retrying settlement only for chapter ${params.chapterNumber}`, + }); + + const retryOutput = await params.writer.settleChapterState({ + book: params.book, + bookDir: params.bookDir, + chapterNumber: params.chapterNumber, + title: params.title, + content: params.content, + chapterIntent: params.reducedControlInput?.chapterIntent, + contextPackage: params.reducedControlInput?.contextPackage, + ruleStack: params.reducedControlInput?.ruleStack, + validationFeedback: this.buildStateValidationFeedback( + params.originalValidation.warnings, + params.language, + ), + }); + + let retryValidation: ValidationResult; + try { + retryValidation = await params.validator.validate( + params.content, + params.chapterNumber, + params.oldState, + retryOutput.updatedState, + params.oldHooks, + retryOutput.updatedHooks, + params.language, + ); + } catch (error) { + throw new Error(`State validation retry failed for chapter ${params.chapterNumber}: ${String(error)}`); + } + + if (retryValidation.warnings.length > 0) { + this.logWarn(params.language, { + zh: `状态校验重试后,第${params.chapterNumber}章仍有 ${retryValidation.warnings.length} 条警告`, + en: `State validation retry still reports ${retryValidation.warnings.length} warning(s) for chapter ${params.chapterNumber}`, + }); + for (const warning of retryValidation.warnings) { + this.config.logger?.warn(` [${warning.category}] ${warning.description}`); + } + } + + if (retryValidation.passed) { + return { + kind: "recovered", + output: retryOutput, + validation: retryValidation, + }; + } + + return { + kind: "degraded", + issues: this.buildStateDegradedIssues(retryValidation.warnings, params.language), + }; + } + + private buildStateValidationFeedback( + warnings: ReadonlyArray, + language: LengthLanguage, + ): string { + if (warnings.length === 0) { + return language === "en" + ? "The previous settlement contradicted the chapter text. Reconcile truth files strictly to the body." + : "上一次状态结算与正文矛盾。请严格以正文为准修正 truth files。"; + } + + if (language === "en") { + return [ + "The previous settlement failed validation. Fix these contradictions against the chapter body:", + ...warnings.map((warning) => `- [${warning.category}] ${warning.description}`), + ].join("\n"); + } + + return [ + "上一次状态结算未通过校验。请对照正文修正以下矛盾:", + ...warnings.map((warning) => `- [${warning.category}] ${warning.description}`), + ].join("\n"); + } + + private buildStateDegradedIssues( + warnings: ReadonlyArray, + language: LengthLanguage, + ): ReadonlyArray { + if (warnings.length > 0) { + return warnings.map((warning) => ({ + severity: "warning" as const, + category: "state-validation", + description: warning.description, + suggestion: language === "en" + ? "Repair chapter state from the persisted body before continuing." + : "请先基于已保存正文修复本章 state,再继续后续章节。", + })); + } + + return [{ + severity: "warning", + category: "state-validation", + description: language === "en" + ? "State validation still failed after settlement retry." + : "状态结算重试后仍未通过校验。", + suggestion: language === "en" + ? "Repair chapter state from the persisted body before continuing." + : "请先基于已保存正文修复本章 state,再继续后续章节。", + }]; + } + + private buildStateDegradedPersistenceOutput(params: { + readonly output: WriteChapterOutput; + readonly oldState: string; + readonly oldHooks: string; + readonly oldLedger: string; + }): WriteChapterOutput { + return { + ...params.output, + runtimeStateDelta: undefined, + runtimeStateSnapshot: undefined, + updatedState: params.oldState, + updatedLedger: params.oldLedger, + updatedHooks: params.oldHooks, + updatedChapterSummaries: undefined, + }; + } + + private buildStateDegradedReviewNote( + baseStatus: "ready-for-review" | "audit-failed", + issues: ReadonlyArray, + ): string { + return JSON.stringify({ + kind: "state-degraded", + baseStatus, + injectedIssues: issues.map((issue) => `[${issue.severity}] ${issue.description}`), + }); + } + + private parseStateDegradedReviewNote(reviewNote?: string): { + readonly kind: "state-degraded"; + readonly baseStatus: "ready-for-review" | "audit-failed"; + readonly injectedIssues: ReadonlyArray; + } | null { + if (!reviewNote) { + return null; + } + + try { + const parsed = JSON.parse(reviewNote) as { + kind?: unknown; + baseStatus?: unknown; + injectedIssues?: unknown; + }; + if ( + parsed.kind !== "state-degraded" + || (parsed.baseStatus !== "ready-for-review" && parsed.baseStatus !== "audit-failed") + || !Array.isArray(parsed.injectedIssues) + ) { + return null; + } + + return { + kind: "state-degraded", + baseStatus: parsed.baseStatus, + injectedIssues: parsed.injectedIssues.filter((issue): issue is string => typeof issue === "string"), + }; + } catch { + return null; + } + } + + private resolveStateDegradedBaseStatus(chapter: ChapterMeta): "ready-for-review" | "audit-failed" { + const metadata = this.parseStateDegradedReviewNote(chapter.reviewNote); + if (metadata) { + return metadata.baseStatus; + } + + return chapter.auditIssues.some((issue) => issue.startsWith("[critical]")) + ? "audit-failed" + : "ready-for-review"; + } + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- From e689a8391f4584aa59e36d98218773363b09ae5c Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 09:52:01 +0800 Subject: [PATCH 17/53] feat(cli): expose degraded-state recovery flow --- packages/cli/src/__tests__/analytics.test.ts | 15 +++++ .../cli/src/__tests__/cli-integration.test.ts | 44 +++++++++++++ packages/cli/src/commands/daemon.ts | 6 +- packages/cli/src/commands/status.ts | 28 ++++++-- packages/cli/src/commands/write.ts | 64 +++++++++++++++++++ 5 files changed, 151 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/__tests__/analytics.test.ts b/packages/cli/src/__tests__/analytics.test.ts index a66c134c..376ee3b0 100644 --- a/packages/cli/src/__tests__/analytics.test.ts +++ b/packages/cli/src/__tests__/analytics.test.ts @@ -40,6 +40,21 @@ describe("computeAnalytics", () => { expect(result.auditPassRate).toBe(67); }); + it("counts state-degraded chapters as audited but not passed", () => { + const chapters = [ + { number: 1, status: "approved", wordCount: 3000, auditIssues: [] }, + { number: 2, status: "state-degraded", wordCount: 2800, auditIssues: ["[warning] state validation drift"] }, + { number: 3, status: "drafted", wordCount: 2600, auditIssues: [] }, + ]; + const result = computeAnalytics("book-state-degraded", chapters); + expect(result.auditPassRate).toBe(50); + expect(result.statusDistribution).toEqual({ + approved: 1, + "state-degraded": 1, + drafted: 1, + }); + }); + it("extracts issue categories from formatted strings", () => { const chapters = [ { diff --git a/packages/cli/src/__tests__/cli-integration.test.ts b/packages/cli/src/__tests__/cli-integration.test.ts index e396cc20..670b6cd4 100644 --- a/packages/cli/src/__tests__/cli-integration.test.ts +++ b/packages/cli/src/__tests__/cli-integration.test.ts @@ -318,6 +318,50 @@ describe("CLI integration", () => { expect(output).not.toContain("7字"); }); + it("shows degraded chapter counts and issues explicitly", async () => { + const bookDir = join(projectDir, "books", "degraded-status"); + await mkdir(join(bookDir, "chapters"), { recursive: true }); + await writeFile( + join(bookDir, "book.json"), + JSON.stringify({ + id: "degraded-status", + title: "Degraded Status Book", + platform: "other", + genre: "other", + status: "active", + targetChapters: 10, + chapterWordCount: 2200, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, null, 2), + "utf-8", + ); + await writeFile( + join(bookDir, "chapters", "index.json"), + JSON.stringify([ + { + number: 1, + title: "Broken State", + status: "state-degraded", + wordCount: 1800, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + auditIssues: ["[warning] state validation still failed after retry"], + lengthWarnings: [], + }, + ], null, 2), + "utf-8", + ); + + const output = run(["status", "degraded-status", "--chapters"]); + expect(output).toContain("Degraded: 1"); + expect(output).toContain('Ch.1 "Broken State" | 1800字 | state-degraded'); + expect(output).toContain("[warning] state validation still failed after retry"); + + const json = JSON.parse(run(["status", "degraded-status", "--json"])); + expect(json.books[0]?.degraded).toBe(1); + }); + it("shows a migration hint for legacy pre-v0.6 books", async () => { const bookDir = join(projectDir, "books", "legacy-status-hint"); const storyDir = join(bookDir, "story"); diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index da2813c4..255b40ef 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -50,7 +50,11 @@ export const upCommand = new Command("up") cooldownAfterChapterMs: config.daemon.cooldownAfterChapterMs, maxChaptersPerDay: config.daemon.maxChaptersPerDay, onChapterComplete: (bookId, chapter, status) => { - const icon = status === "ready-for-review" ? "+" : "!"; + const icon = status === "ready-for-review" + ? "+" + : status === "state-degraded" + ? "x" + : "!"; log(` [${icon}] ${bookId} Ch.${chapter} — ${status}`); }, onError: (bookId, error) => { diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 5a70d0a7..a2e3ed11 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -44,6 +44,9 @@ export const statusCommand = new Command("status") const failed = index.filter( (ch) => ch.status === "audit-failed", ).length; + const degraded = index.filter( + (ch) => ch.status === "state-degraded", + ).length; const totalWords = index.reduce((sum, ch) => sum + ch.wordCount, 0); const avgWords = index.length > 0 ? Math.round(totalWords / index.length) : 0; @@ -60,6 +63,7 @@ export const statusCommand = new Command("status") approved, pending, failed, + degraded, ...(migrationHint ? { migrationHint } : {}), ...(opts.chapters ? { chapterList: index.map((ch) => ({ @@ -67,7 +71,9 @@ export const statusCommand = new Command("status") title: ch.title, status: ch.status, wordCount: ch.wordCount, - ...(ch.status === "audit-failed" ? { issues: ch.auditIssues } : {}), + ...(ch.status === "audit-failed" || ch.status === "state-degraded" + ? { issues: ch.auditIssues } + : {}), })), } : {}), }); @@ -78,7 +84,7 @@ export const statusCommand = new Command("status") log(` Platform: ${book.platform} | Genre: ${book.genre}`); log(` Chapters: ${persistedChapterCount} / ${book.targetChapters}`); log(` Words: ${totalWords.toLocaleString()} (avg ${avgWords}/ch)`); - log(` Approved: ${approved} | Pending: ${pending} | Failed: ${failed}`); + log(` Approved: ${approved} | Pending: ${pending} | Failed: ${failed} | Degraded: ${degraded}`); if (migrationHint) { log(` Migration: ${migrationHint}`); } @@ -86,9 +92,15 @@ export const statusCommand = new Command("status") if (opts.chapters && index.length > 0) { log(""); for (const ch of index) { - const icon = ch.status === "approved" ? "+" : ch.status === "audit-failed" ? "!" : "~"; + const icon = ch.status === "approved" + ? "+" + : ch.status === "audit-failed" + ? "!" + : ch.status === "state-degraded" + ? "x" + : "~"; log(` [${icon}] Ch.${ch.number} "${ch.title}" | ${formatLengthCount(ch.wordCount, countingMode)} | ${ch.status}`); - if (ch.status === "audit-failed" && ch.auditIssues.length > 0) { + if ((ch.status === "audit-failed" || ch.status === "state-degraded") && ch.auditIssues.length > 0) { const criticals = ch.auditIssues.filter((i: string) => i.startsWith("[critical]")); const warnings = ch.auditIssues.filter((i: string) => i.startsWith("[warning]")); if (criticals.length > 0) { @@ -97,7 +109,13 @@ export const statusCommand = new Command("status") } } if (warnings.length > 0) { - log(` + ${warnings.length} warning(s)`); + if (ch.status === "state-degraded") { + for (const issue of warnings) { + log(` ${issue}`); + } + } else { + log(` + ${warnings.length} warning(s)`); + } } } } diff --git a/packages/cli/src/commands/write.ts b/packages/cli/src/commands/write.ts index 83a77c1d..63b02e66 100644 --- a/packages/cli/src/commands/write.ts +++ b/packages/cli/src/commands/write.ts @@ -59,6 +59,15 @@ writeCommand } log(""); } + + if (result.status === "state-degraded") { + if (!opts.json) { + log(language === "en" + ? "State repair required before continuing. Stopping batch." + : "需要先修复 state,已停止后续连写。"); + } + break; + } } if (opts.json) { @@ -189,3 +198,58 @@ writeCommand process.exit(1); } }); + +writeCommand + .command("repair-state") + .description("Rebuild truth files for a persisted state-degraded chapter without rewriting body text") + .argument("", "Book ID (optional) and chapter number") + .option("--json", "Output JSON") + .action(async (args: ReadonlyArray, opts) => { + try { + const root = findProjectRoot(); + + let bookId: string; + let chapter: number; + if (args.length === 1) { + chapter = parseInt(args[0]!, 10); + if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[0]}"`); + bookId = await resolveBookId(undefined, root); + } else if (args.length === 2) { + chapter = parseInt(args[1]!, 10); + if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[1]}"`); + bookId = await resolveBookId(args[0], root); + } else { + throw new Error("Usage: inkos write repair-state [book-id] "); + } + + const state = new StateManager(root); + const book = await state.loadBookConfig(bookId); + const language = resolveCliLanguage(book.language); + const config = await loadConfig(); + const pipeline = new PipelineRunner(buildPipelineConfig(config, root)); + const result = await pipeline.repairChapterState(bookId, chapter); + + if (opts.json) { + log(JSON.stringify(result, null, 2)); + } else { + for (const line of formatWriteNextResultLines(language, { + chapterNumber: result.chapterNumber, + title: result.title, + wordCount: result.wordCount, + auditPassed: result.auditResult.passed, + revised: result.revised, + status: result.status, + issues: result.auditResult.issues, + })) { + log(line); + } + } + } catch (e) { + if (opts.json) { + log(JSON.stringify({ error: String(e) })); + } else { + logError(`Failed to repair chapter state: ${e}`); + } + process.exit(1); + } + }); From aeb64b658c6a11fce04ecafabb76774847a7c7aa Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 10:11:18 +0800 Subject: [PATCH 18/53] feat(llm): support custom HTTP headers via INKOS_LLM_HEADERS env var Some API providers (e.g. openclaudecode.cn) require a specific User-Agent header for external clients. Without it requests get 403. Adds INKOS_LLM_HEADERS (JSON object) which is injected as defaultHeaders into the OpenAI SDK client. No effect when unset. Also adds an optional `headers` field to LLMConfigSchema. --- packages/core/src/llm/provider.ts | 25 ++++++++++++++++++++++++- packages/core/src/models/project.ts | 1 + 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts index 819c468d..fd627bbb 100644 --- a/packages/core/src/llm/provider.ts +++ b/packages/core/src/llm/provider.ts @@ -135,15 +135,38 @@ export function createLLMClient(config: LLMConfig): LLMClient { }; } // openai or custom — both use OpenAI SDK + const extraHeaders = config.headers ?? parseEnvHeaders(); return { provider: "openai", apiFormat, stream, - _openai: new OpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl }), + _openai: new OpenAI({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + ...(extraHeaders ? { defaultHeaders: extraHeaders } : {}), + }), defaults, }; } +function parseEnvHeaders(): Record | undefined { + const raw = process.env.INKOS_LLM_HEADERS; + if (!raw) return undefined; + try { + const parsed: unknown = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // not JSON — treat as single "Key: Value" pair + const idx = raw.indexOf(":"); + if (idx > 0) { + return { [raw.slice(0, idx).trim()]: raw.slice(idx + 1).trim() }; + } + } + return undefined; +} + // === Partial Response (stream interrupted but usable content received) === export class PartialResponseError extends Error { diff --git a/packages/core/src/models/project.ts b/packages/core/src/models/project.ts index b3d600a5..f1325a33 100644 --- a/packages/core/src/models/project.ts +++ b/packages/core/src/models/project.ts @@ -9,6 +9,7 @@ export const LLMConfigSchema = z.object({ maxTokens: z.number().int().min(1).default(8192), thinkingBudget: z.number().int().min(0).default(0), extra: z.record(z.unknown()).optional(), + headers: z.record(z.string()).optional(), apiFormat: z.enum(["chat", "responses"]).default("chat"), stream: z.boolean().default(true), }); From d6855e4e60bdaf69d03b221f4fa7c8fc08821393 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 11:52:26 +0800 Subject: [PATCH 19/53] feat(pipeline): add semantic hook lifecycle guidance --- packages/core/src/__tests__/composer.test.ts | 63 ++++++ .../src/__tests__/memory-retrieval.test.ts | 58 +++++ packages/core/src/__tests__/planner.test.ts | 2 +- packages/core/src/__tests__/writer.test.ts | 9 +- packages/core/src/agents/architect.ts | 13 +- packages/core/src/agents/chapter-analyzer.ts | 4 +- packages/core/src/agents/composer.ts | 134 ++++++++++- packages/core/src/agents/planner.ts | 1 + packages/core/src/agents/settler-prompts.ts | 5 +- packages/core/src/agents/writer-prompts.ts | 6 + packages/core/src/agents/writer.ts | 1 + packages/core/src/models/runtime-state.ts | 11 + packages/core/src/state/memory-db.ts | 18 +- .../core/src/state/runtime-state-store.ts | 19 +- packages/core/src/state/state-bootstrap.ts | 29 ++- packages/core/src/state/state-projections.ts | 13 +- packages/core/src/state/state-reducer.ts | 6 + packages/core/src/utils/governed-context.ts | 17 ++ packages/core/src/utils/hook-arbiter.ts | 7 + packages/core/src/utils/hook-governance.ts | 27 ++- packages/core/src/utils/hook-lifecycle.ts | 212 ++++++++++++++++++ packages/core/src/utils/memory-retrieval.ts | 116 +++++++--- 22 files changed, 694 insertions(+), 77 deletions(-) create mode 100644 packages/core/src/utils/hook-lifecycle.ts diff --git a/packages/core/src/__tests__/composer.test.ts b/packages/core/src/__tests__/composer.test.ts index c898f329..b2400c3b 100644 --- a/packages/core/src/__tests__/composer.test.ts +++ b/packages/core/src/__tests__/composer.test.ts @@ -458,4 +458,67 @@ describe("ComposerAgent", () => { expect(parentCanonEntry?.excerpt).toContain("archive fire"); expect(fanficCanonEntry?.excerpt).toContain("oath debt logic"); }); + + it("emits hook debt briefs for agenda-targeted hooks", async () => { + await Promise.all([ + writeFile( + join(storyDir, "pending_hooks.md"), + [ + "# Pending Hooks", + "", + "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + "| mentor-oath | 8 | relationship | progressing | 9 | 揭开师债为何断裂 | 慢烧 | 师债需要跨更大弧线回收 |", + "| guild-route | 1 | mystery | open | 2 | 查清商会路线背后的买家 | 近期 | 商会路线仍在旁支干扰 |", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "chapter_summaries.md"), + [ + "# Chapter Summaries", + "", + "| 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 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 composer = new ComposerAgent({ + client: {} as ConstructorParameters[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 mentor oath conflict.", + hookAgenda: { + mustAdvance: ["mentor-oath"], + eligibleResolve: [], + staleDebt: [], + avoidNewHookFamilies: ["relationship"], + }, + }, + }, + }); + + const hookDebtEntry = result.contextPackage.selectedContext.find((entry) => entry.source === "runtime/hook_debt#mentor-oath"); + expect(hookDebtEntry).toBeDefined(); + expect(hookDebtEntry?.excerpt).toContain("mentor-oath"); + expect(hookDebtEntry?.excerpt).toContain("River Camp"); + expect(hookDebtEntry?.excerpt).toContain("Trial Echo"); + }); }); diff --git a/packages/core/src/__tests__/memory-retrieval.test.ts b/packages/core/src/__tests__/memory-retrieval.test.ts index 63eb2ed7..0a3b8c9a 100644 --- a/packages/core/src/__tests__/memory-retrieval.test.ts +++ b/packages/core/src/__tests__/memory-retrieval.test.ts @@ -1085,4 +1085,62 @@ describe("parsePendingHooksMarkdown", () => { expect(hooks.map((hook) => hook.hookId)).toEqual(["H009", "H010"]); }); + + it("parses semantic payoff timing from extended pending hooks tables", () => { + const hooks = memoryRetrieval.parsePendingHooksMarkdown([ + "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + "| oath-debt | 8 | relationship | open | 12 | Reveal why the mentor broke the oath | slow-burn | Long-buried debt stays unresolved |", + "| kiln-key | 15 | mystery | open | 15 | Find out what the kiln key opens next chapter | immediate | Fresh key with a fast local payoff |", + "", + ].join("\n")); + + expect(hooks).toEqual([ + expect.objectContaining({ + hookId: "oath-debt", + payoffTiming: "slow-burn", + notes: "Long-buried debt stays unresolved", + }), + expect.objectContaining({ + hookId: "kiln-key", + payoffTiming: "immediate", + notes: "Fresh key with a fast local payoff", + }), + ]); + }); + + it("keeps slow-burn hooks out of early resolve slots while still advancing them", () => { + const agenda = memoryRetrieval.buildPlannerHookAgenda({ + chapterNumber: 18, + hooks: [ + { + hookId: "slow-oath", + startChapter: 10, + type: "relationship", + status: "progressing", + lastAdvancedChapter: 17, + expectedPayoff: "Reveal why the mentor buried the oath debt", + payoffTiming: "slow-burn", + notes: "The debt should simmer across the wider arc.", + }, + { + hookId: "ready-packet", + startChapter: 14, + type: "mystery", + status: "progressing", + lastAdvancedChapter: 17, + expectedPayoff: "Open the missing packet and expose the inside hand", + payoffTiming: "near-term", + notes: "The local sequence is ready for a concrete payoff.", + }, + ] as never, + maxMustAdvance: 2, + maxEligibleResolve: 2, + targetChapters: 40, + } as never); + + expect(agenda.mustAdvance).toContain("slow-oath"); + expect(agenda.eligibleResolve).toContain("ready-packet"); + expect(agenda.eligibleResolve).not.toContain("slow-oath"); + }); }); diff --git a/packages/core/src/__tests__/planner.test.ts b/packages/core/src/__tests__/planner.test.ts index b7a71238..ac0b305d 100644 --- a/packages/core/src/__tests__/planner.test.ts +++ b/packages/core/src/__tests__/planner.test.ts @@ -1041,7 +1041,7 @@ describe("PlannerAgent", () => { }); const intentMarkdown = await readFile(result.runtimePath, "utf-8"); - expect(intentMarkdown).toContain("| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |"); + expect(intentMarkdown).toContain("| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |"); expect(intentMarkdown).toContain("| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |"); expect(intentMarkdown).not.toContain("| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |"); expect(intentMarkdown).not.toContain("| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |"); diff --git a/packages/core/src/__tests__/writer.test.ts b/packages/core/src/__tests__/writer.test.ts index 3661f249..b997ec19 100644 --- a/packages/core/src/__tests__/writer.test.ts +++ b/packages/core/src/__tests__/writer.test.ts @@ -205,6 +205,11 @@ describe("WriterAgent", () => { reason: "Carry forward unresolved hook.", excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue", }, + { + source: "runtime/hook_debt#mentor-oath", + reason: "Explicit hook debt brief for the agenda target.", + excerpt: "mentor-oath | cadence: slow-burn | seed: ch8 River Camp - Mentor debt becomes personal | latest: ch99 Locked Gate - Lin Yue chooses the mentor line over the guild line | unpaid: reveal why the mentor broke the oath", + }, ], }, ruleStack: { @@ -224,7 +229,9 @@ describe("WriterAgent", () => { expect(settlePrompt).toContain("## 本章控制输入"); expect(settlePrompt).toContain("story/chapter_summaries.md#99"); expect(settlePrompt).toContain("| 99 | Locked Gate |"); - expect(settlePrompt).toContain("| stale-ledger | 14 | mystery | open | 70 | 120 | Old ledger debt is dormant but unresolved |"); + expect(settlePrompt).toContain("## Hook Debt Briefs"); + expect(settlePrompt).toContain("mentor-oath | cadence: slow-burn"); + expect(settlePrompt).toContain("| stale-ledger | 14 | mystery | open | 70 | 120 | 中程 | Old ledger debt is dormant but unresolved |"); expect(settlePrompt).not.toContain("| 1 | Guild Trail |"); expect(settlePrompt).not.toContain("old-seal"); expect(settlePrompt).not.toContain("Guildmaster Ren"); diff --git a/packages/core/src/agents/architect.ts b/packages/core/src/agents/architect.ts index 22a36a5a..62dd50c9 100644 --- a/packages/core/src/agents/architect.ts +++ b/packages/core/src/agents/architect.ts @@ -195,18 +195,20 @@ enableFullCastTracking: false const pendingHooksPrompt = resolvedLanguage === "en" ? `Initial hook pool (Markdown table): -| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes | +| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes | Rules for the hook table: - Column 5 must be a pure chapter number, never natural-language description - During book creation, all planned hooks are still unapplied, so last_advanced_chapter = 0 +- Column 7 must be one of: immediate / near-term / mid-arc / slow-burn / endgame - If you want to describe the initial clue/signal, put it in notes instead of column 5` : `初始伏笔池(Markdown表格): -| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 | +| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 | 伏笔表规则: - 第5列必须是纯数字章节号,不能写自然语言描述 - 建书阶段所有伏笔都还没正式推进,所以第5列统一填 0 +- 第7列必须填写:立即 / 近期 / 中程 / 慢烧 / 终局 之一 - 如果要说明“初始线索/最初信号”,写进备注,不要写进第5列`; const finalRequirementsPrompt = resolvedLanguage === "en" @@ -489,9 +491,9 @@ enableFullCastTracking: false const pendingHooksPrompt = resolvedLanguage === "en" ? `Identify all active hooks from the source text (Markdown table): -| hook_id | start_chapter | type | status | latest_progress | expected_payoff | notes |` +| hook_id | start_chapter | type | status | latest_progress | expected_payoff | payoff_timing | notes |` : `从正文中识别的所有伏笔(Markdown表格): -| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |`; +| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |`; const keyPrinciplesPrompt = resolvedLanguage === "en" ? `## Key Principles @@ -727,7 +729,8 @@ prohibitions: status: row[3] ?? "open", lastAdvancedChapter: normalizedProgress, expectedPayoff: row[5] ?? "", - notes, + payoffTiming: row.length >= 8 ? row[6] ?? "" : "", + notes: row.length >= 8 ? this.mergeHookNotes(row[7] ?? "", seedNote, language) : notes, }; }); diff --git a/packages/core/src/agents/chapter-analyzer.ts b/packages/core/src/agents/chapter-analyzer.ts index 97ba8437..bce83406 100644 --- a/packages/core/src/agents/chapter-analyzer.ts +++ b/packages/core/src/agents/chapter-analyzer.ts @@ -279,7 +279,7 @@ Updated state card as a Markdown table reflecting the end-of-chapter state: === UPDATED_HOOKS === Updated hooks pool as a Markdown table with the latest status of every known hook: -| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes | +| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes | === CHAPTER_SUMMARY === Single Markdown table row: @@ -372,7 +372,7 @@ ${bookRulesBody ? `## 本书规则\n\n${bookRulesBody}` : ""} === UPDATED_HOOKS === 更新后的伏笔池(Markdown表格),包含所有已知伏笔的最新状态: -| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 | +| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 | === CHAPTER_SUMMARY === 本章摘要(Markdown表格行): diff --git a/packages/core/src/agents/composer.ts b/packages/core/src/agents/composer.ts index 110d8221..8a89a37a 100644 --- a/packages/core/src/agents/composer.ts +++ b/packages/core/src/agents/composer.ts @@ -16,6 +16,10 @@ import { parseChapterSummariesMarkdown, retrieveMemorySelection, } from "../utils/memory-retrieval.js"; +import { + localizeHookPayoffTiming, + resolveHookPayoffTiming, +} from "../utils/hook-lifecycle.js"; export interface ComposeChapterInput { readonly book: BookConfig; @@ -43,7 +47,11 @@ export class ComposerAgent extends BaseAgent { const runtimeDir = join(storyDir, "runtime"); await mkdir(runtimeDir, { recursive: true }); - const selectedContext = await this.collectSelectedContext(storyDir, input.plan); + const selectedContext = await this.collectSelectedContext( + storyDir, + input.plan, + input.book.language ?? "zh", + ); const contextPackage = ContextPackageSchema.parse({ chapter: input.chapterNumber, selectedContext, @@ -103,7 +111,11 @@ export class ComposerAgent extends BaseAgent { }; } - private async collectSelectedContext(storyDir: string, plan: PlanChapterOutput): Promise { + private async collectSelectedContext( + storyDir: string, + plan: PlanChapterOutput, + language: "zh" | "en", + ): Promise { const entries = await Promise.all([ this.maybeContextSource(storyDir, "current_focus.md", "Current task focus for this chapter."), this.maybeContextSource( @@ -145,6 +157,12 @@ export class ComposerAgent extends BaseAgent { outlineNode: planningAnchor, mustKeep: plan.intent.mustKeep, }); + const hookDebtEntries = await this.buildHookDebtEntries( + storyDir, + plan, + memorySelection.activeHooks, + language, + ); const summaryEntries = memorySelection.summaries.map((summary) => ({ source: `story/chapter_summaries.md#${summary.chapter}`, @@ -161,7 +179,7 @@ export class ComposerAgent extends BaseAgent { 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] + excerpt: [hook.type, hook.status, hook.expectedPayoff, hook.payoffTiming, hook.notes] .filter(Boolean) .join(" | "), })); @@ -174,6 +192,7 @@ export class ComposerAgent extends BaseAgent { return [ ...entries.filter((entry): entry is NonNullable => entry !== null), ...trailEntries, + ...hookDebtEntries, ...factEntries, ...summaryEntries, ...volumeSummaryEntries, @@ -226,6 +245,64 @@ export class ComposerAgent extends BaseAgent { return entries; } + private async buildHookDebtEntries( + storyDir: string, + plan: PlanChapterOutput, + activeHooks: ReadonlyArray<{ + readonly hookId: string; + readonly startChapter: number; + readonly type: string; + readonly status: string; + readonly lastAdvancedChapter: number; + readonly expectedPayoff: string; + readonly payoffTiming?: string; + readonly notes: string; + }>, + language: "zh" | "en", + ): Promise { + const targetHookIds = [...new Set([ + ...plan.intent.hookAgenda.eligibleResolve, + ...plan.intent.hookAgenda.mustAdvance, + ...plan.intent.hookAgenda.staleDebt, + ])]; + if (targetHookIds.length === 0) { + return []; + } + + const summaries = parseChapterSummariesMarkdown( + await this.readFileOrDefault(join(storyDir, "chapter_summaries.md")), + ); + + return targetHookIds.flatMap((hookId) => { + const hook = activeHooks.find((entry) => entry.hookId === hookId); + if (!hook) { + return []; + } + + const seedSummary = this.findHookSummary(summaries, hook.hookId, hook.startChapter, "seed"); + const latestSummary = this.findHookSummary(summaries, hook.hookId, hook.lastAdvancedChapter, "latest"); + const cadence = localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language); + const role = this.describeHookAgendaRole(plan, hook.hookId, language); + const promise = hook.expectedPayoff || (language === "en" ? "(unspecified)" : "(未写明)"); + const seedBeat = seedSummary + ? this.renderHookDebtBeat(seedSummary) + : (hook.notes || promise); + const latestBeat = latestSummary + ? this.renderHookDebtBeat(latestSummary) + : (hook.notes || promise); + + return [{ + source: `runtime/hook_debt#${hook.hookId}`, + reason: language === "en" + ? "Narrative debt brief for an explicit hook agenda target." + : "显式 hook agenda 目标的叙事债务简报。", + excerpt: language === "en" + ? `${hook.hookId} | role: ${role} | cadence: ${cadence} | promise: ${promise} | seed: ${seedBeat} | latest: ${latestBeat}` + : `${hook.hookId} | 角色: ${role} | 节奏: ${cadence} | 承诺: ${promise} | 种子: ${seedBeat} | 最近推进: ${latestBeat}`, + }]; + }); + } + private async maybeContextSource( storyDir: string, fileName: string, @@ -270,4 +347,55 @@ export class ComposerAgent extends BaseAgent { return "(文件尚未创建)"; } } + + private describeHookAgendaRole( + plan: PlanChapterOutput, + hookId: string, + language: "zh" | "en", + ): string { + if (plan.intent.hookAgenda.eligibleResolve.includes(hookId)) { + return language === "en" ? "payoff candidate" : "本章优先兑现"; + } + if (plan.intent.hookAgenda.staleDebt.includes(hookId)) { + return language === "en" ? "stale debt" : "高压旧债"; + } + return language === "en" ? "must advance" : "本章必须推进"; + } + + private findHookSummary( + summaries: ReadonlyArray[number]>, + hookId: string, + chapter: number, + mode: "seed" | "latest", + ) { + const directChapterHit = summaries.find((summary) => summary.chapter === chapter); + const hookMentions = summaries.filter((summary) => this.summaryMentionsHook(summary, hookId)); + if (mode === "seed") { + return hookMentions.find((summary) => summary.chapter === chapter) + ?? hookMentions.at(0) + ?? directChapterHit; + } + + return [...hookMentions].reverse().find((summary) => summary.chapter === chapter) + ?? hookMentions.at(-1) + ?? directChapterHit; + } + + private summaryMentionsHook( + summary: ReturnType[number], + hookId: string, + ): boolean { + return [ + summary.title, + summary.events, + summary.stateChanges, + summary.hookActivity, + ].some((text) => text.includes(hookId)); + } + + private renderHookDebtBeat( + summary: ReturnType[number], + ): string { + return `ch${summary.chapter} ${summary.title} - ${summary.events || summary.hookActivity || summary.stateChanges || "(none)"}`; + } } diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index 16c6bbdd..c1212db5 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -83,6 +83,7 @@ export class PlannerAgent extends BaseAgent { const hookAgenda = buildPlannerHookAgenda({ hooks: memorySelection.activeHooks, chapterNumber: input.chapterNumber, + targetChapters: input.book.targetChapters, }); const directives = this.buildStructuredDirectives({ chapterNumber: input.chapterNumber, diff --git a/packages/core/src/agents/settler-prompts.ts b/packages/core/src/agents/settler-prompts.ts index 3d28a064..111c1444 100644 --- a/packages/core/src/agents/settler-prompts.ts +++ b/packages/core/src/agents/settler-prompts.ts @@ -22,8 +22,9 @@ export function buildSettlerSystemPrompt( - 提及伏笔:已有伏笔在本章被提到,但没有新增信息、没有改变读者或角色对该问题的理解 → 放入 mention 数组,不要更新最近推进 - 推进伏笔:已有伏笔在本章出现了新的事实、证据、关系变化、风险升级或范围收缩 → **必须**更新"最近推进"列为当前章节号,更新状态和备注 - 回收伏笔:伏笔在本章被明确揭示、解决、或不再成立 → 状态改为"已回收",备注回收方式 -- 延后伏笔:超过5章未推进 → 标注"延后",备注原因 +- 延后伏笔:只有当正文明确显示该线被主动搁置、转入后台、或被剧情压后时,才标注"延后";不要因为“已经过了几章”就机械延后 - brand-new unresolved thread:不要直接发明新的 hookId。把候选放进 newHookCandidates,由系统决定它是映射到旧 hook、变成真正新 hook,还是被拒绝为重述 +- payoffTiming 使用语义节奏,不用硬写章节号:只允许 immediate / near-term / mid-arc / slow-burn / endgame - **铁律**:不要把“再次提到”“换个说法重述”“抽象复盘”当成推进。只有状态真的变了,才更新最近推进。只是出现过的旧 hook,放进 mention 数组。`; const fullCastBlock = bookRules?.enableFullCastTracking @@ -114,6 +115,7 @@ function buildSettlerOutputFormat(gp: GenreProfile): string { "status": "progressing", "lastAdvancedChapter": 12, "expectedPayoff": "揭开师债真相", + "payoffTiming": "slow-burn", "notes": "本章为何推进/延后/回收" } ], @@ -125,6 +127,7 @@ function buildSettlerOutputFormat(gp: GenreProfile): string { { "type": "mystery", "expectedPayoff": "新伏笔未来要回收到哪里", + "payoffTiming": "near-term", "notes": "本章为什么会形成新的未解问题" } ], diff --git a/packages/core/src/agents/writer-prompts.ts b/packages/core/src/agents/writer-prompts.ts index 4a6fd686..fcba6ca1 100644 --- a/packages/core/src/agents/writer-prompts.ts +++ b/packages/core/src/agents/writer-prompts.ts @@ -105,6 +105,9 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s - When the runtime rule stack records an active L4 -> L3 override, follow the current task over local planning. - Keep hard guardrails compact: canon, continuity facts, and explicit prohibitions still win. - If an English Variance Brief is provided, obey it: avoid the listed phrase/opening/ending patterns and satisfy the scene obligation. +- If Hook Debt Briefs are provided, treat them as the active memory of what the reader is still owed: preserve the original promise and change the on-page situation. +- When the explicit hook agenda names an eligible resolve target, land a concrete payoff beat instead of merely mentioning the old thread. +- When stale debt is present, do not open sibling hooks casually; clear pressure from old promises before minting fresh debt. - In multi-character scenes, include at least one resistance-bearing exchange instead of reducing the beat to summary or explanation.`; } @@ -115,6 +118,9 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s - 当 runtime rule stack 明确记录了 L4 -> L3 的 active override 时,优先执行当前任务意图,再局部调整规划层。 - 真正不能突破的只有硬护栏:世界设定、连续性事实、显式禁令。 - 如果提供了 English Variance Brief,必须主动避开其中列出的高频短语、重复开头和重复结尾模式,并完成 scene obligation。 +- 如果提供了 Hook Debt 简报,把它当成读者仍在等待兑现的承诺记忆:保留原始 promise,并让本章在页上发生真实变化。 +- 如果显式 hook agenda 里出现了可回收目标,本章必须写出具体兑现片段,不能只是重新提一句旧线索。 +- 如果存在 stale debt,先消化旧承诺的压力,再决定是否开新坑;同类 sibling hook 不得随手再开。 - 多角色场景里,至少给出一轮带阻力的直接交锋,不要把人物关系写成纯解释或纯总结。`; } diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index b3868466..28c9c6df 100644 --- a/packages/core/src/agents/writer.ts +++ b/packages/core/src/agents/writer.ts @@ -910,6 +910,7 @@ ${lengthRequirementBlock} blocks.titleHistoryBlock, blocks.moodTrailBlock, blocks.canonBlock, + blocks.hookDebtBlock, blocks.hooksBlock, blocks.summariesBlock, blocks.volumeSummariesBlock, diff --git a/packages/core/src/models/runtime-state.ts b/packages/core/src/models/runtime-state.ts index f42cd5ea..d87781be 100644 --- a/packages/core/src/models/runtime-state.ts +++ b/packages/core/src/models/runtime-state.ts @@ -16,6 +16,15 @@ export type StateManifest = z.infer; export const HookStatusSchema = z.enum(["open", "progressing", "deferred", "resolved"]); export type HookStatus = z.infer; +export const HookPayoffTimingSchema = z.enum([ + "immediate", + "near-term", + "mid-arc", + "slow-burn", + "endgame", +]); +export type HookPayoffTiming = z.infer; + export const HookRecordSchema = z.object({ hookId: z.string().min(1), startChapter: z.number().int().min(0), @@ -23,6 +32,7 @@ export const HookRecordSchema = z.object({ status: HookStatusSchema, lastAdvancedChapter: z.number().int().min(0), expectedPayoff: z.string().default(""), + payoffTiming: HookPayoffTimingSchema.optional(), notes: z.string().default(""), }); @@ -94,6 +104,7 @@ export type HookOps = z.infer; export const NewHookCandidateSchema = z.object({ type: z.string().min(1), expectedPayoff: z.string().default(""), + payoffTiming: HookPayoffTimingSchema.optional(), notes: z.string().default(""), }); diff --git a/packages/core/src/state/memory-db.ts b/packages/core/src/state/memory-db.ts index ec119b67..9382a4bd 100644 --- a/packages/core/src/state/memory-db.ts +++ b/packages/core/src/state/memory-db.ts @@ -52,6 +52,7 @@ export interface StoredHook { readonly status: string; readonly lastAdvancedChapter: number; readonly expectedPayoff: string; + readonly payoffTiming?: string; readonly notes: string; } @@ -99,6 +100,7 @@ export class MemoryDB { status TEXT NOT NULL DEFAULT 'open', last_advanced_chapter INTEGER NOT NULL DEFAULT 0, expected_payoff TEXT NOT NULL DEFAULT '', + payoff_timing TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '' ); @@ -108,6 +110,16 @@ export class MemoryDB { 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); `); + + this.ensureColumn("hooks", "payoff_timing", "TEXT NOT NULL DEFAULT ''"); + } + + private ensureColumn(table: string, column: string, definition: string): void { + try { + this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + } catch { + // Column already exists on existing databases. + } } // --------------------------------------------------------------------------- @@ -289,8 +301,8 @@ export class MemoryDB { 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 (?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR REPLACE INTO hooks (hook_id, start_chapter, type, status, last_advanced_chapter, expected_payoff, payoff_timing, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ).run( hook.hookId, hook.startChapter, @@ -298,6 +310,7 @@ export class MemoryDB { hook.status, hook.lastAdvancedChapter, hook.expectedPayoff, + hook.payoffTiming ?? "", hook.notes, ); } @@ -318,6 +331,7 @@ export class MemoryDB { status, last_advanced_chapter AS lastAdvancedChapter, expected_payoff AS expectedPayoff, + payoff_timing AS payoffTiming, notes FROM hooks WHERE lower(status) NOT IN ('resolved', 'closed', '已回收', '已解决') diff --git a/packages/core/src/state/runtime-state-store.ts b/packages/core/src/state/runtime-state-store.ts index fc7e88d7..7e4d66bf 100644 --- a/packages/core/src/state/runtime-state-store.ts +++ b/packages/core/src/state/runtime-state-store.ts @@ -109,15 +109,16 @@ export async function loadNarrativeMemorySeed(bookDir: string): Promise ({ - hookId: hook.hookId, - startChapter: hook.startChapter, - type: hook.type, - status: hook.status, - lastAdvancedChapter: hook.lastAdvancedChapter, - expectedPayoff: hook.expectedPayoff, - notes: hook.notes, - })), + hooks: snapshot.hooks.hooks.map((hook) => ({ + hookId: hook.hookId, + startChapter: hook.startChapter, + type: hook.type, + status: hook.status, + lastAdvancedChapter: hook.lastAdvancedChapter, + expectedPayoff: hook.expectedPayoff, + payoffTiming: hook.payoffTiming, + notes: hook.notes, + })), }; } diff --git a/packages/core/src/state/state-bootstrap.ts b/packages/core/src/state/state-bootstrap.ts index 1b32b21f..932396f0 100644 --- a/packages/core/src/state/state-bootstrap.ts +++ b/packages/core/src/state/state-bootstrap.ts @@ -11,6 +11,7 @@ import { type StateManifest, } from "../models/runtime-state.js"; import type { Fact, StoredHook, StoredSummary } from "./memory-db.js"; +import { normalizeHookPayoffTiming } from "../utils/hook-lifecycle.js"; export interface BootstrapStructuredStateResult { readonly createdFiles: ReadonlyArray; @@ -186,15 +187,19 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] { if (tableRows.length > 0) { return tableRows .filter((row) => normalizeHookId(row[0]).length > 0) - .map((row) => ({ - hookId: normalizeHookId(row[0]), - startChapter: parseInteger(row[1]), - type: row[2] ?? "", - status: row[3] ?? "open", - lastAdvancedChapter: parseInteger(row[4]), - expectedPayoff: row[5] ?? "", - notes: row[6] ?? "", - })); + .map((row) => { + const legacyShape = row.length < 8; + return { + hookId: normalizeHookId(row[0]), + startChapter: parseInteger(row[1]), + type: row[2] ?? "", + status: row[3] ?? "open", + lastAdvancedChapter: parseInteger(row[4]), + expectedPayoff: row[5] ?? "", + payoffTiming: legacyShape ? undefined : normalizeHookPayoffTiming(row[6]), + notes: legacyShape ? (row[6] ?? "") : (row[7] ?? ""), + }; + }); } return markdown @@ -210,6 +215,7 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] { status: "open", lastAdvancedChapter: 0, expectedPayoff: "", + payoffTiming: undefined, notes: line, })); } @@ -333,6 +339,7 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) { .filter((row) => normalizeHookId(row[0]).length > 0) .map((row) => { const hookId = normalizeHookId(row[0]); + const legacyShape = row.length < 8; return { hookId, startChapter: parseIntegerWithWarning(row[1], warnings, `${hookId}:startChapter`), @@ -340,7 +347,8 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) { status: normalizeHookStatus(row[3], warnings, hookId), lastAdvancedChapter: parseIntegerWithWarning(row[4], warnings, `${hookId}:lastAdvancedChapter`), expectedPayoff: row[5] ?? "", - notes: row[6] ?? "", + payoffTiming: legacyShape ? undefined : normalizeHookPayoffTiming(row[6]), + notes: legacyShape ? (row[6] ?? "") : (row[7] ?? ""), }; }), }); @@ -360,6 +368,7 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) { status: "open" as HookStatus, lastAdvancedChapter: 0, expectedPayoff: "", + payoffTiming: undefined, notes: line, })), }); diff --git a/packages/core/src/state/state-projections.ts b/packages/core/src/state/state-projections.ts index fcce07e2..8e6614bb 100644 --- a/packages/core/src/state/state-projections.ts +++ b/packages/core/src/state/state-projections.ts @@ -3,6 +3,10 @@ import type { CurrentStateState, HooksState, } from "../models/runtime-state.js"; +import { + localizeHookPayoffTiming, + resolveHookPayoffTiming, +} from "../utils/hook-lifecycle.js"; export function renderHooksProjection( state: HooksState, @@ -11,12 +15,12 @@ export function renderHooksProjection( const title = language === "en" ? "# Pending Hooks" : "# 伏笔池"; const headers = language === "en" ? [ - "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |", - "| --- | --- | --- | --- | --- | --- | --- |", + "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", ] : [ - "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |", - "| --- | --- | --- | --- | --- | --- | --- |", + "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", ]; const rows = [...state.hooks] @@ -33,6 +37,7 @@ export function renderHooksProjection( hook.status, hook.lastAdvancedChapter, hook.expectedPayoff, + localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language), hook.notes, ].map(escapeTableCell).join(" | ") } |`); diff --git a/packages/core/src/state/state-reducer.ts b/packages/core/src/state/state-reducer.ts index 3370b0ee..61321fa4 100644 --- a/packages/core/src/state/state-reducer.ts +++ b/packages/core/src/state/state-reducer.ts @@ -12,6 +12,7 @@ import { type StateManifest, } from "../models/runtime-state.js"; import { evaluateHookAdmission } from "../utils/hook-governance.js"; +import { resolveHookPayoffTiming } from "../utils/hook-lifecycle.js"; import { validateRuntimeState } from "./state-validator.js"; export interface RuntimeStateSnapshot { @@ -153,6 +154,11 @@ function mergeDuplicateHookFamily(existing: HookRecord, incoming: HookRecord): H : existing.status, lastAdvancedChapter: advanced, expectedPayoff, + payoffTiming: resolveHookPayoffTiming({ + payoffTiming: incoming.payoffTiming ?? existing.payoffTiming, + expectedPayoff, + notes, + }), notes, }; } diff --git a/packages/core/src/utils/governed-context.ts b/packages/core/src/utils/governed-context.ts index 25d4e0de..38db1755 100644 --- a/packages/core/src/utils/governed-context.ts +++ b/packages/core/src/utils/governed-context.ts @@ -4,6 +4,7 @@ export function buildGovernedMemoryEvidenceBlocks( contextPackage: ContextPackage, language?: "zh" | "en", ): { + readonly hookDebtBlock?: string; readonly hooksBlock?: string; readonly summariesBlock?: string; readonly volumeSummariesBlock?: string; @@ -15,6 +16,9 @@ export function buildGovernedMemoryEvidenceBlocks( const hookEntries = contextPackage.selectedContext.filter((entry) => entry.source.startsWith("story/pending_hooks.md#"), ); + const hookDebtEntries = contextPackage.selectedContext.filter((entry) => + entry.source.startsWith("runtime/hook_debt#"), + ); const summaryEntries = contextPackage.selectedContext.filter((entry) => entry.source.startsWith("story/chapter_summaries.md#"), ); @@ -33,6 +37,12 @@ export function buildGovernedMemoryEvidenceBlocks( ); return { + hookDebtBlock: hookDebtEntries.length > 0 + ? renderHookDebtBlock( + resolvedLanguage === "en" ? "Hook Debt Briefs" : "Hook Debt Briefs", + hookDebtEntries, + ) + : undefined, hooksBlock: hookEntries.length > 0 ? renderEvidenceBlock( resolvedLanguage === "en" ? "Selected Hook Evidence" : "已选伏笔证据", @@ -72,6 +82,13 @@ export function buildGovernedMemoryEvidenceBlocks( }; } +function renderHookDebtBlock( + heading: string, + entries: ContextPackage["selectedContext"], +): string { + return `\n## ${heading}\n${entries.map((entry) => `- ${entry.excerpt ?? entry.reason}`).join("\n")}\n`; +} + function renderEvidenceBlock( heading: string, entries: ContextPackage["selectedContext"], diff --git a/packages/core/src/utils/hook-arbiter.ts b/packages/core/src/utils/hook-arbiter.ts index a6c89dc5..60e92a0d 100644 --- a/packages/core/src/utils/hook-arbiter.ts +++ b/packages/core/src/utils/hook-arbiter.ts @@ -5,6 +5,7 @@ import { type RuntimeStateDelta, } from "../models/runtime-state.js"; import { evaluateHookAdmission } from "./hook-governance.js"; +import { resolveHookPayoffTiming } from "./hook-lifecycle.js"; export interface HookArbiterDecision { readonly action: "created" | "mapped" | "mentioned" | "rejected"; @@ -154,6 +155,11 @@ function mergeCandidateIntoExistingHook( status: existing.status === "resolved" ? "resolved" : "progressing", lastAdvancedChapter: Math.max(existing.lastAdvancedChapter, chapter), expectedPayoff: preferRicherText(existing.expectedPayoff, candidate.expectedPayoff), + payoffTiming: resolveHookPayoffTiming({ + payoffTiming: candidate.payoffTiming ?? existing.payoffTiming, + expectedPayoff: preferRicherText(existing.expectedPayoff, candidate.expectedPayoff), + notes: preferRicherText(existing.notes, candidate.notes), + }), notes: preferRicherText(existing.notes, candidate.notes), }; } @@ -170,6 +176,7 @@ function createCanonicalHook(params: { status: "open", lastAdvancedChapter: params.chapter, expectedPayoff: params.candidate.expectedPayoff.trim(), + payoffTiming: resolveHookPayoffTiming(params.candidate), notes: params.candidate.notes.trim(), }; } diff --git a/packages/core/src/utils/hook-governance.ts b/packages/core/src/utils/hook-governance.ts index 9bcdfc86..5c6e2a44 100644 --- a/packages/core/src/utils/hook-governance.ts +++ b/packages/core/src/utils/hook-governance.ts @@ -1,10 +1,12 @@ import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js"; +import { describeHookLifecycle } from "./hook-lifecycle.js"; export type HookDisposition = "none" | "mention" | "advance" | "resolve" | "defer"; export interface HookAdmissionCandidate { readonly type: string; readonly expectedPayoff?: string; + readonly payoffTiming?: string; readonly notes?: string; } @@ -17,15 +19,30 @@ export interface HookAdmissionDecision { export function collectStaleHookDebt(params: { readonly hooks: ReadonlyArray; readonly chapterNumber: number; + readonly targetChapters?: number; readonly staleAfterChapters?: number; }): HookRecord[] { - const staleAfterChapters = params.staleAfterChapters ?? 10; - const staleCutoff = params.chapterNumber - staleAfterChapters; - return params.hooks .filter((hook) => hook.status !== "resolved" && hook.status !== "deferred") .filter((hook) => hook.startChapter <= params.chapterNumber) - .filter((hook) => hook.lastAdvancedChapter <= staleCutoff) + .filter((hook) => { + const lifecycle = describeHookLifecycle({ + payoffTiming: hook.payoffTiming, + expectedPayoff: hook.expectedPayoff, + notes: hook.notes, + startChapter: hook.startChapter, + lastAdvancedChapter: hook.lastAdvancedChapter, + status: hook.status, + chapterNumber: params.chapterNumber, + targetChapters: params.targetChapters, + }); + + if (params.staleAfterChapters !== undefined) { + return hook.lastAdvancedChapter <= params.chapterNumber - params.staleAfterChapters; + } + + return lifecycle.stale || lifecycle.overdue; + }) .sort((left, right) => ( left.lastAdvancedChapter - right.lastAdvancedChapter || left.startChapter - right.startChapter @@ -60,6 +77,7 @@ export function evaluateHookAdmission(params: { const candidateNormalized = normalizeText([ params.candidate.type, params.candidate.expectedPayoff ?? "", + params.candidate.payoffTiming ?? "", params.candidate.notes ?? "", ].join(" ")); const candidateTerms = extractTerms(candidateNormalized); @@ -69,6 +87,7 @@ export function evaluateHookAdmission(params: { const activeNormalized = normalizeText([ hook.type, hook.expectedPayoff, + hook.payoffTiming ?? "", hook.notes, ].join(" ")); diff --git a/packages/core/src/utils/hook-lifecycle.ts b/packages/core/src/utils/hook-lifecycle.ts new file mode 100644 index 00000000..db98fbf4 --- /dev/null +++ b/packages/core/src/utils/hook-lifecycle.ts @@ -0,0 +1,212 @@ +import type { HookPayoffTiming } from "../models/runtime-state.js"; + +type HookPhase = "opening" | "middle" | "late"; + +interface LifecycleProfile { + readonly earliestResolveAge: number; + readonly staleDormancy: number; + readonly overdueAge: number; + readonly minimumPhase: HookPhase; + readonly resolveBias: number; +} + +const TIMING_PROFILES: Record = { + immediate: { + earliestResolveAge: 1, + staleDormancy: 1, + overdueAge: 3, + minimumPhase: "opening", + resolveBias: 5, + }, + "near-term": { + earliestResolveAge: 1, + staleDormancy: 2, + overdueAge: 5, + minimumPhase: "opening", + resolveBias: 4, + }, + "mid-arc": { + earliestResolveAge: 2, + staleDormancy: 4, + overdueAge: 8, + minimumPhase: "opening", + resolveBias: 3, + }, + "slow-burn": { + earliestResolveAge: 4, + staleDormancy: 5, + overdueAge: 12, + minimumPhase: "middle", + resolveBias: 2, + }, + endgame: { + earliestResolveAge: 6, + staleDormancy: 6, + overdueAge: 16, + minimumPhase: "late", + resolveBias: 1, + }, +}; + +const PHASE_WEIGHT: Record = { + opening: 0, + middle: 1, + late: 2, +}; + +const LABELS: Record<"zh" | "en", Record> = { + en: { + immediate: "immediate", + "near-term": "near-term", + "mid-arc": "mid-arc", + "slow-burn": "slow-burn", + endgame: "endgame", + }, + zh: { + immediate: "立即", + "near-term": "近期", + "mid-arc": "中程", + "slow-burn": "慢烧", + endgame: "终局", + }, +}; + +const TIMING_ALIASES: Array<[HookPayoffTiming, RegExp]> = [ + ["immediate", /^(?:立即|马上|当章|本章|下一章|immediate|instant|next(?:\s+chapter|\s+beat)?|right\s+away)$/i], + ["near-term", /^(?:近期|近几章|短线|soon|short(?:\s+run)?|near(?:\s*-\s*|\s+)term|current\s+sequence)$/i], + ["mid-arc", /^(?:中程|中期|卷中|mid(?:\s*-\s*|\s+)arc|mid(?:\s*-\s*|\s+)book|middle)$/i], + ["slow-burn", /^(?:慢烧|长线|后续|later|late(?:r)?|long(?:\s*-\s*|\s+)arc|slow(?:\s*-\s*|\s+)burn)$/i], + ["endgame", /^(?:终局|终章|大结局|最终|climax|finale|endgame|late\s+book)$/i], +]; + +const SIGNAL_PATTERNS: Array<[HookPayoffTiming, RegExp]> = [ + ["endgame", /(终局|终章|大结局|最终揭晓|最终摊牌|climax|finale|endgame|final reveal|last act)/i], + ["immediate", /(当章|本章|下一章|马上|立刻|即刻|immediate|next chapter|right away|at once)/i], + ["near-term", /(近期|近几章|很快|短线|soon|near-term|short run|current sequence)/i], + ["mid-arc", /(中期|卷中|本卷中段|mid-book|mid arc|middle of the arc)/i], + ["slow-burn", /(长线|慢烧|后续发酵|慢慢揭开|later|slow burn|long arc|long tail)/i], +]; + +export function normalizeHookPayoffTiming(value: string | undefined | null): HookPayoffTiming | undefined { + const normalized = value?.trim(); + if (!normalized) return undefined; + + for (const [timing, pattern] of TIMING_ALIASES) { + if (pattern.test(normalized)) { + return timing; + } + } + + return undefined; +} + +export function inferHookPayoffTiming(params: { + readonly expectedPayoff?: string; + readonly notes?: string; +}): HookPayoffTiming { + const combined = [params.expectedPayoff, params.notes] + .filter((value): value is string => Boolean(value && value.trim())) + .join(" ") + .trim(); + if (!combined) return "mid-arc"; + + for (const [timing, pattern] of SIGNAL_PATTERNS) { + if (pattern.test(combined)) { + return timing; + } + } + + return "mid-arc"; +} + +export function resolveHookPayoffTiming(params: { + readonly payoffTiming?: string | null; + readonly expectedPayoff?: string; + readonly notes?: string; +}): HookPayoffTiming { + return normalizeHookPayoffTiming(params.payoffTiming) + ?? inferHookPayoffTiming({ + expectedPayoff: params.expectedPayoff, + notes: params.notes, + }); +} + +export function localizeHookPayoffTiming( + timing: HookPayoffTiming, + language: "zh" | "en", +): string { + return LABELS[language][timing]; +} + +export function describeHookLifecycle(params: { + readonly payoffTiming?: string | null; + readonly expectedPayoff?: string; + readonly notes?: string; + readonly startChapter: number; + readonly lastAdvancedChapter: number; + readonly status: string; + readonly chapterNumber: number; + readonly targetChapters?: number; +}): { + readonly timing: HookPayoffTiming; + readonly phase: HookPhase; + readonly age: number; + readonly dormancy: number; + readonly readyToResolve: boolean; + readonly stale: boolean; + readonly overdue: boolean; + readonly advancePressure: number; + readonly resolvePressure: number; +} { + const timing = resolveHookPayoffTiming(params); + const profile = TIMING_PROFILES[timing]; + const phase = resolveHookPhase(params.chapterNumber, params.targetChapters); + const age = Math.max(0, params.chapterNumber - Math.max(1, params.startChapter)); + const lastTouchChapter = Math.max(params.startChapter, params.lastAdvancedChapter); + const dormancy = Math.max(0, params.chapterNumber - Math.max(1, lastTouchChapter)); + const explicitProgressing = /^(progressing|advanced|重大推进|持续推进)$/i.test(params.status.trim()); + const phaseReady = PHASE_WEIGHT[phase] >= PHASE_WEIGHT[profile.minimumPhase]; + const recentlyTouched = dormancy <= 1; + const overdue = phaseReady && age >= profile.overdueAge; + const cadenceReady = timing === "slow-burn" + ? phase === "late" || overdue + : timing === "endgame" + ? phase === "late" + : true; + const momentum = explicitProgressing || recentlyTouched; + const stale = phaseReady && ( + dormancy >= profile.staleDormancy + || (overdue && !momentum) + ); + const readyToResolve = phaseReady + && cadenceReady + && age >= profile.earliestResolveAge + && (momentum || (overdue && explicitProgressing)); + + return { + timing, + phase, + age, + dormancy, + readyToResolve, + stale, + overdue, + advancePressure: age + dormancy + (stale ? 8 : 0) + (overdue ? 6 : 0), + resolvePressure: readyToResolve + ? profile.resolveBias * 10 + (explicitProgressing ? 5 : 0) + Math.min(12, dormancy * 2) + (overdue ? 10 : 0) + : 0, + }; +} + +function resolveHookPhase(chapterNumber: number, targetChapters?: number): HookPhase { + if (targetChapters && targetChapters > 0) { + const progress = chapterNumber / targetChapters; + if (progress >= 0.72) return "late"; + if (progress >= 0.33) return "middle"; + return "opening"; + } + + if (chapterNumber >= 24) return "late"; + if (chapterNumber >= 8) return "middle"; + return "opening"; +} diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index 959343e7..19b5ce20 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -10,7 +10,12 @@ import { } from "../models/runtime-state.js"; import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js"; import { bootstrapStructuredStateFromMarkdown, normalizeHookId } from "../state/state-bootstrap.js"; -import { collectStaleHookDebt } from "./hook-governance.js"; +import { + describeHookLifecycle, + localizeHookPayoffTiming, + resolveHookPayoffTiming, + normalizeHookPayoffTiming, +} from "./hook-lifecycle.js"; export interface MemorySelection { readonly summaries: ReadonlyArray; @@ -191,12 +196,12 @@ export function renderHookSnapshot( const headers = language === "en" ? [ - "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |", - "| --- | --- | --- | --- | --- | --- | --- |", + "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", ] : [ - "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |", - "| --- | --- | --- | --- | --- | --- | --- |", + "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", ]; return [ @@ -208,6 +213,7 @@ export function renderHookSnapshot( hook.status, hook.lastAdvancedChapter, hook.expectedPayoff, + localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language), hook.notes, ].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`), ].join("\n"); @@ -216,6 +222,7 @@ export function renderHookSnapshot( export function buildPlannerHookAgenda(params: { readonly hooks: ReadonlyArray; readonly chapterNumber: number; + readonly targetChapters?: number; readonly maxMustAdvance?: number; readonly maxEligibleResolve?: number; readonly maxStaleDebt?: number; @@ -224,31 +231,55 @@ export function buildPlannerHookAgenda(params: { .map(normalizeStoredHook) .filter((hook) => !isFuturePlannedHook(hook, params.chapterNumber, 0)) .filter((hook) => hook.status !== "resolved" && hook.status !== "deferred"); - const mustAdvanceHooks = agendaHooks + const lifecycleEntries = agendaHooks.map((hook) => ({ + hook, + lifecycle: describeHookLifecycle({ + payoffTiming: hook.payoffTiming, + expectedPayoff: hook.expectedPayoff, + notes: hook.notes, + startChapter: hook.startChapter, + lastAdvancedChapter: hook.lastAdvancedChapter, + status: hook.status, + chapterNumber: params.chapterNumber, + targetChapters: params.targetChapters, + }), + })); + const staleDebtHooks = lifecycleEntries + .filter((entry) => entry.lifecycle.stale) + .sort((left, right) => ( + Number(right.lifecycle.overdue) - Number(left.lifecycle.overdue) + || right.lifecycle.advancePressure - left.lifecycle.advancePressure + || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter + || left.hook.startChapter - right.hook.startChapter + || left.hook.hookId.localeCompare(right.hook.hookId) + )) + .slice(0, params.maxStaleDebt ?? 2) + .map((entry) => entry.hook); + const mustAdvanceHooks = lifecycleEntries .slice() .sort((left, right) => ( - left.lastAdvancedChapter - right.lastAdvancedChapter - || left.startChapter - right.startChapter - || left.hookId.localeCompare(right.hookId) + Number(right.lifecycle.stale) - Number(left.lifecycle.stale) + || right.lifecycle.advancePressure - left.lifecycle.advancePressure + || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter + || left.hook.startChapter - right.hook.startChapter + || left.hook.hookId.localeCompare(right.hook.hookId) )) - .slice(0, params.maxMustAdvance ?? 2); - const staleDebtHooks = collectStaleHookDebt({ - hooks: agendaHooks, - chapterNumber: params.chapterNumber, - }) - .slice(0, params.maxStaleDebt ?? 2); - const eligibleResolveHooks = agendaHooks - .filter((hook) => hook.startChapter <= params.chapterNumber - 3) - .filter((hook) => hook.lastAdvancedChapter >= params.chapterNumber - 2) + .slice(0, params.maxMustAdvance ?? 2) + .map((entry) => entry.hook); + const eligibleResolveHooks = lifecycleEntries + .filter((entry) => entry.lifecycle.readyToResolve) .sort((left, right) => ( - left.startChapter - right.startChapter - || right.lastAdvancedChapter - left.lastAdvancedChapter - || left.hookId.localeCompare(right.hookId) + right.lifecycle.resolvePressure - left.lifecycle.resolvePressure + || Number(right.lifecycle.stale) - Number(left.lifecycle.stale) + || left.hook.startChapter - right.hook.startChapter + || left.hook.hookId.localeCompare(right.hook.hookId) )) - .slice(0, params.maxEligibleResolve ?? 1); + .slice(0, params.maxEligibleResolve ?? 1) + .map((entry) => entry.hook); const avoidNewHookFamilies = [...new Set([ ...staleDebtHooks.map((hook) => hook.type.trim()).filter(Boolean), ...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean), + ...eligibleResolveHooks.map((hook) => hook.type.trim()).filter(Boolean), ])].slice(0, 3); return { @@ -388,15 +419,7 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] { if (tableRows.length > 0) { return tableRows .filter((row) => normalizeHookId(row[0]).length > 0) - .map((row) => ({ - hookId: normalizeHookId(row[0]), - startChapter: parseInteger(row[1]), - type: row[2] ?? "", - status: row[3] ?? "open", - lastAdvancedChapter: parseInteger(row[4]), - expectedPayoff: row[5] ?? "", - notes: row[6] ?? "", - })); + .map((row) => parsePendingHookRow(row)); } return markdown @@ -412,10 +435,28 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] { status: "open", lastAdvancedChapter: 0, expectedPayoff: "", + payoffTiming: undefined, notes: line, })); } +function parsePendingHookRow(row: ReadonlyArray): StoredHook { + const legacyShape = row.length < 8; + const payoffTiming = legacyShape ? undefined : normalizeHookPayoffTiming(row[6]); + const notes = legacyShape ? (row[6] ?? "") : (row[7] ?? ""); + + return { + hookId: normalizeHookId(row[0]), + startChapter: parseInteger(row[1]), + type: row[2] ?? "", + status: row[3] ?? "open", + lastAdvancedChapter: parseInteger(row[4]), + expectedPayoff: row[5] ?? "", + payoffTiming, + notes, + }; +} + export function parseCurrentStateFacts( markdown: string, fallbackChapter: number, @@ -553,9 +594,9 @@ function selectRelevantHooks( const ranked = hooks .map((hook) => ({ hook, - score: scoreHook(hook, queryTerms), + score: scoreHook(hook, queryTerms, chapterNumber), matched: matchesAny( - [hook.hookId, hook.type, hook.expectedPayoff, hook.notes].join(" "), + [hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" "), queryTerms, ), })) @@ -665,8 +706,12 @@ function scoreSummary(summary: StoredSummary, chapterNumber: number, queryTerms: return recencyScore + termScore; } -function scoreHook(hook: StoredHook, queryTerms: ReadonlyArray): number { - const text = [hook.hookId, hook.type, hook.expectedPayoff, hook.notes].join(" "); +function scoreHook( + hook: StoredHook, + queryTerms: ReadonlyArray, + _chapterNumber: number, +): number { + const text = [hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", 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; @@ -680,6 +725,7 @@ function normalizeStoredHook(hook: StoredHook): HookRecord { status: normalizeStoredHookStatus(hook.status), lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter), expectedPayoff: hook.expectedPayoff, + payoffTiming: resolveHookPayoffTiming(hook), notes: hook.notes, }; } From 6b12293dd5561c505efc3a3161052badbd9d8808 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 12:15:34 +0800 Subject: [PATCH 20/53] feat(pipeline): add structured hook pressure map --- packages/core/src/__tests__/composer.test.ts | 14 ++ .../src/__tests__/memory-retrieval.test.ts | 18 +++ packages/core/src/__tests__/models.test.ts | 24 ++++ packages/core/src/__tests__/planner.test.ts | 31 ++++ packages/core/src/agents/composer.ts | 99 +++++++++++-- packages/core/src/agents/planner.ts | 24 ++++ packages/core/src/agents/writer-prompts.ts | 4 + packages/core/src/index.ts | 6 + packages/core/src/models/input-governance.ts | 41 ++++++ packages/core/src/utils/memory-retrieval.ts | 132 +++++++++++++++++- 10 files changed, 384 insertions(+), 9 deletions(-) diff --git a/packages/core/src/__tests__/composer.test.ts b/packages/core/src/__tests__/composer.test.ts index b2400c3b..65faf0e1 100644 --- a/packages/core/src/__tests__/composer.test.ts +++ b/packages/core/src/__tests__/composer.test.ts @@ -73,6 +73,7 @@ describe("ComposerAgent", () => { }, ], hookAgenda: { + pressureMap: [], mustAdvance: [], eligibleResolve: [], staleDebt: [], @@ -506,6 +507,16 @@ describe("ComposerAgent", () => { chapter: 10, goal: "Bring the focus back to the mentor oath conflict.", hookAgenda: { + pressureMap: [{ + hookId: "mentor-oath", + type: "relationship", + payoffTiming: "slow-burn", + phase: "middle", + pressure: "high", + movement: "partial-payoff", + reason: "stale-promise", + blockSiblingHooks: true, + }], mustAdvance: ["mentor-oath"], eligibleResolve: [], staleDebt: [], @@ -518,6 +529,9 @@ describe("ComposerAgent", () => { const hookDebtEntry = result.contextPackage.selectedContext.find((entry) => entry.source === "runtime/hook_debt#mentor-oath"); expect(hookDebtEntry).toBeDefined(); expect(hookDebtEntry?.excerpt).toContain("mentor-oath"); + expect(hookDebtEntry?.excerpt).toContain("动作: 局部兑现"); + expect(hookDebtEntry?.excerpt).toContain("压力: 高"); + expect(hookDebtEntry?.excerpt).toContain("抑制同类开坑: 是"); expect(hookDebtEntry?.excerpt).toContain("River Camp"); expect(hookDebtEntry?.excerpt).toContain("Trial Echo"); }); diff --git a/packages/core/src/__tests__/memory-retrieval.test.ts b/packages/core/src/__tests__/memory-retrieval.test.ts index 0a3b8c9a..7619216b 100644 --- a/packages/core/src/__tests__/memory-retrieval.test.ts +++ b/packages/core/src/__tests__/memory-retrieval.test.ts @@ -1142,5 +1142,23 @@ describe("parsePendingHooksMarkdown", () => { expect(agenda.mustAdvance).toContain("slow-oath"); expect(agenda.eligibleResolve).toContain("ready-packet"); expect(agenda.eligibleResolve).not.toContain("slow-oath"); + expect(agenda.pressureMap).toEqual(expect.arrayContaining([ + expect.objectContaining({ + hookId: "slow-oath", + movement: "advance", + pressure: "medium", + type: "relationship", + payoffTiming: "slow-burn", + reason: "building-debt", + }), + expect.objectContaining({ + hookId: "ready-packet", + movement: "full-payoff", + pressure: "high", + type: "mystery", + payoffTiming: "near-term", + reason: "ripe-payoff", + }), + ])); }); }); diff --git a/packages/core/src/__tests__/models.test.ts b/packages/core/src/__tests__/models.test.ts index 812eeddf..f92351cc 100644 --- a/packages/core/src/__tests__/models.test.ts +++ b/packages/core/src/__tests__/models.test.ts @@ -511,6 +511,18 @@ describe("ChapterIntentSchema", () => { }, ], hookAgenda: { + pressureMap: [ + { + hookId: "H019", + type: "relationship", + payoffTiming: "slow-burn", + phase: "middle", + pressure: "high", + movement: "partial-payoff", + reason: "stale-promise", + blockSiblingHooks: true, + }, + ], mustAdvance: ["H019"], eligibleResolve: ["H045"], staleDebt: ["H023", "H027"], @@ -528,6 +540,17 @@ describe("ChapterIntentSchema", () => { expect(result.moodDirective).toContain("Release pressure"); expect(result.titleDirective).toContain("ledger title"); expect(result.conflicts).toHaveLength(1); + expect(result.hookAgenda.pressureMap).toEqual([ + expect.objectContaining({ + hookId: "H019", + type: "relationship", + phase: "middle", + movement: "partial-payoff", + pressure: "high", + payoffTiming: "slow-burn", + reason: "stale-promise", + }), + ]); expect(result.hookAgenda.mustAdvance).toEqual(["H019"]); expect(result.hookAgenda.eligibleResolve).toEqual(["H045"]); expect(result.hookAgenda.staleDebt).toEqual(["H023", "H027"]); @@ -551,6 +574,7 @@ describe("ChapterIntentSchema", () => { expect(result.mustAvoid).toEqual([]); expect(result.styleEmphasis).toEqual([]); expect(result.conflicts).toEqual([]); + expect(result.hookAgenda.pressureMap).toEqual([]); expect(result.hookAgenda.mustAdvance).toEqual([]); expect(result.hookAgenda.eligibleResolve).toEqual([]); expect(result.hookAgenda.staleDebt).toEqual([]); diff --git a/packages/core/src/__tests__/planner.test.ts b/packages/core/src/__tests__/planner.test.ts index ac0b305d..1f0de79d 100644 --- a/packages/core/src/__tests__/planner.test.ts +++ b/packages/core/src/__tests__/planner.test.ts @@ -1207,9 +1207,26 @@ describe("PlannerAgent", () => { expect(result.intent.hookAgenda.eligibleResolve).toEqual(["ready-payoff"]); expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-debt"]); expect(result.intent.hookAgenda.avoidNewHookFamilies).toContain("relationship"); + expect(result.intent.hookAgenda.pressureMap).toEqual(expect.arrayContaining([ + expect.objectContaining({ + hookId: "ready-payoff", + movement: "full-payoff", + pressure: "critical", + type: "mystery", + reason: "overdue-payoff", + }), + expect.objectContaining({ + hookId: "stale-debt", + movement: "advance", + pressure: "critical", + type: "relationship", + reason: "stale-promise", + }), + ])); const intentMarkdown = await readFile(result.runtimePath, "utf-8"); expect(intentMarkdown).toContain("## Hook Agenda"); + expect(intentMarkdown).toContain("### Pressure Map"); expect(intentMarkdown).toContain("recent-route"); expect(intentMarkdown).toContain("ready-payoff"); expect(intentMarkdown).toContain("stale-debt"); @@ -1327,5 +1344,19 @@ describe("PlannerAgent", () => { "relationship", "mystery", ])); + expect(result.intent.hookAgenda.pressureMap).toEqual(expect.arrayContaining([ + expect.objectContaining({ + hookId: "stale-omega", + movement: "advance", + pressure: "critical", + reason: "stale-promise", + }), + expect.objectContaining({ + hookId: "stale-sable", + movement: "advance", + pressure: "critical", + reason: "stale-promise", + }), + ])); }); }); diff --git a/packages/core/src/agents/composer.ts b/packages/core/src/agents/composer.ts index 8a89a37a..048437a4 100644 --- a/packages/core/src/agents/composer.ts +++ b/packages/core/src/agents/composer.ts @@ -260,11 +260,14 @@ export class ComposerAgent extends BaseAgent { }>, language: "zh" | "en", ): Promise { - const targetHookIds = [...new Set([ - ...plan.intent.hookAgenda.eligibleResolve, - ...plan.intent.hookAgenda.mustAdvance, - ...plan.intent.hookAgenda.staleDebt, - ])]; + const targetHookIds = [ + ...new Set([ + ...plan.intent.hookAgenda.pressureMap.map((entry) => entry.hookId), + ...plan.intent.hookAgenda.eligibleResolve, + ...plan.intent.hookAgenda.mustAdvance, + ...plan.intent.hookAgenda.staleDebt, + ]), + ]; if (targetHookIds.length === 0) { return []; } @@ -282,7 +285,14 @@ export class ComposerAgent extends BaseAgent { const seedSummary = this.findHookSummary(summaries, hook.hookId, hook.startChapter, "seed"); const latestSummary = this.findHookSummary(summaries, hook.hookId, hook.lastAdvancedChapter, "latest"); const cadence = localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language); + const guidance = this.findHookPressure(plan, hook.hookId); const role = this.describeHookAgendaRole(plan, hook.hookId, language); + const movement = guidance + ? this.describeHookMovement(guidance.movement, language) + : role; + const pressure = guidance + ? this.describeHookPressure(guidance.pressure, language) + : (language === "en" ? "medium" : "中"); const promise = hook.expectedPayoff || (language === "en" ? "(unspecified)" : "(未写明)"); const seedBeat = seedSummary ? this.renderHookDebtBeat(seedSummary) @@ -290,6 +300,14 @@ export class ComposerAgent extends BaseAgent { const latestBeat = latestSummary ? this.renderHookDebtBeat(latestSummary) : (hook.notes || promise); + const reason = guidance + ? this.describeHookReason(guidance.reason, language) + : (language === "en" + ? "Keep the original promise legible and materially change the on-page situation." + : "保持原始承诺清晰可见,并让页上局势发生实质变化。"); + const suppressSiblingHooks = guidance?.blockSiblingHooks + ? (language === "en" ? "yes" : "是") + : (language === "en" ? "no" : "否"); return [{ source: `runtime/hook_debt#${hook.hookId}`, @@ -297,8 +315,8 @@ export class ComposerAgent extends BaseAgent { ? "Narrative debt brief for an explicit hook agenda target." : "显式 hook agenda 目标的叙事债务简报。", excerpt: language === "en" - ? `${hook.hookId} | role: ${role} | cadence: ${cadence} | promise: ${promise} | seed: ${seedBeat} | latest: ${latestBeat}` - : `${hook.hookId} | 角色: ${role} | 节奏: ${cadence} | 承诺: ${promise} | 种子: ${seedBeat} | 最近推进: ${latestBeat}`, + ? `${hook.hookId} | role: ${role} | movement: ${movement} | pressure: ${pressure} | cadence: ${cadence} | suppress siblings: ${suppressSiblingHooks} | reason: ${reason} | promise: ${promise} | seed: ${seedBeat} | latest: ${latestBeat}` + : `${hook.hookId} | 角色: ${role} | 动作: ${movement} | 压力: ${pressure} | 节奏: ${cadence} | 抑制同类开坑: ${suppressSiblingHooks} | 原因: ${reason} | 承诺: ${promise} | 种子: ${seedBeat} | 最近推进: ${latestBeat}`, }]; }); } @@ -354,7 +372,7 @@ export class ComposerAgent extends BaseAgent { language: "zh" | "en", ): string { if (plan.intent.hookAgenda.eligibleResolve.includes(hookId)) { - return language === "en" ? "payoff candidate" : "本章优先兑现"; + return language === "en" ? "payoff candidate" : "优先兑现"; } if (plan.intent.hookAgenda.staleDebt.includes(hookId)) { return language === "en" ? "stale debt" : "高压旧债"; @@ -362,6 +380,71 @@ export class ComposerAgent extends BaseAgent { return language === "en" ? "must advance" : "本章必须推进"; } + private findHookPressure( + plan: PlanChapterOutput, + hookId: string, + ): PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number] | undefined { + return plan.intent.hookAgenda.pressureMap.find((entry) => entry.hookId === hookId); + } + + private describeHookMovement( + movement: PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number]["movement"], + language: "zh" | "en", + ): string { + if (language === "en") { + return movement.replace(/-/g, " "); + } + + return { + "quiet-hold": "轻压保温", + refresh: "重新点亮", + advance: "推进", + "partial-payoff": "局部兑现", + "full-payoff": "完整兑现", + }[movement]; + } + + private describeHookPressure( + pressure: PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number]["pressure"], + language: "zh" | "en", + ): string { + if (language === "en") { + return pressure; + } + + return { + low: "低", + medium: "中", + high: "高", + critical: "极高", + }[pressure]; + } + + private describeHookReason( + reason: PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number]["reason"], + language: "zh" | "en", + ): string { + if (language === "en") { + return { + "fresh-promise": "fresh promise", + "building-debt": "building debt", + "stale-promise": "stale promise", + "ripe-payoff": "ripe payoff", + "overdue-payoff": "overdue payoff", + "long-arc-hold": "long arc hold", + }[reason]; + } + + return { + "fresh-promise": "新近承诺,先保持清晰存在", + "building-debt": "债务正在累积,需要继续加码", + "stale-promise": "旧承诺已停滞,需要重新推动或缩圈", + "ripe-payoff": "已经进入可兑现窗口", + "overdue-payoff": "已经拖过理想兑现窗口", + "long-arc-hold": "长线承诺仍应保温,不宜提前兑付", + }[reason]; + } + private findHookSummary( summaries: ReadonlyArray[number]>, hookId: string, diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index c1212db5..f9df5507 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -84,6 +84,7 @@ export class PlannerAgent extends BaseAgent { hooks: memorySelection.activeHooks, chapterNumber: input.chapterNumber, targetChapters: input.book.targetChapters, + language: input.book.language ?? "zh", }); const directives = this.buildStructuredDirectives({ chapterNumber: input.chapterNumber, @@ -109,6 +110,7 @@ export class PlannerAgent extends BaseAgent { const runtimePath = join(runtimeDir, `chapter-${String(input.chapterNumber).padStart(4, "0")}.intent.md`); const intentMarkdown = this.renderIntentMarkdown( intent, + input.book.language ?? "zh", renderHookSnapshot(memorySelection.hooks, input.book.language ?? "zh"), renderSummarySnapshot(memorySelection.summaries, input.book.language ?? "zh"), ); @@ -658,6 +660,7 @@ export class PlannerAgent extends BaseAgent { private renderIntentMarkdown( intent: ChapterIntent, + language: "zh" | "en", pendingHooks: string, chapterSummaries: string, ): string { @@ -682,7 +685,28 @@ export class PlannerAgent extends BaseAgent { intent.moodDirective ? `- mood: ${intent.moodDirective}` : undefined, intent.titleDirective ? `- title: ${intent.titleDirective}` : undefined, ].filter(Boolean).join("\n") || "- none"; + const pressureMap = intent.hookAgenda.pressureMap.length > 0 + ? [ + "| hook_id | type | payoff_timing | phase | pressure | movement | reason | sibling_guard |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ...intent.hookAgenda.pressureMap + .map((item) => [ + item.hookId, + item.type, + item.payoffTiming ?? "unspecified", + item.phase, + item.pressure, + item.movement, + item.reason, + item.blockSiblingHooks ? "yes" : "no", + ].join(" | ")) + .map((row) => `| ${row} |`), + ].join("\n") + : "- none"; const hookAgenda = [ + "### Pressure Map", + pressureMap, + "", "### Must Advance", intent.hookAgenda.mustAdvance.length > 0 ? intent.hookAgenda.mustAdvance.map((item) => `- ${item}`).join("\n") diff --git a/packages/core/src/agents/writer-prompts.ts b/packages/core/src/agents/writer-prompts.ts index fcba6ca1..eac6327f 100644 --- a/packages/core/src/agents/writer-prompts.ts +++ b/packages/core/src/agents/writer-prompts.ts @@ -106,8 +106,10 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s - Keep hard guardrails compact: canon, continuity facts, and explicit prohibitions still win. - If an English Variance Brief is provided, obey it: avoid the listed phrase/opening/ending patterns and satisfy the scene obligation. - If Hook Debt Briefs are provided, treat them as the active memory of what the reader is still owed: preserve the original promise and change the on-page situation. +- If the explicit hook agenda includes a pressure map, follow the requested move for each target: full-payoff means concrete payoff, partial-payoff means a meaningful intermediate reveal, advance/refresh mean material movement, and quiet-hold means keep the promise visible without cashing it out early. - When the explicit hook agenda names an eligible resolve target, land a concrete payoff beat instead of merely mentioning the old thread. - When stale debt is present, do not open sibling hooks casually; clear pressure from old promises before minting fresh debt. +- When a hook brief says to suppress sibling hooks, do not fake progress by opening a parallel hook of the same family. - In multi-character scenes, include at least one resistance-bearing exchange instead of reducing the beat to summary or explanation.`; } @@ -119,8 +121,10 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s - 真正不能突破的只有硬护栏:世界设定、连续性事实、显式禁令。 - 如果提供了 English Variance Brief,必须主动避开其中列出的高频短语、重复开头和重复结尾模式,并完成 scene obligation。 - 如果提供了 Hook Debt 简报,把它当成读者仍在等待兑现的承诺记忆:保留原始 promise,并让本章在页上发生真实变化。 +- 如果显式 hook agenda 里带有 pressure map,逐条执行其中要求的动作:full-payoff 就是具体兑现,partial-payoff 就是给出中间层级的揭示或缩圈,advance / refresh 就是有分量的推进,quiet-hold 就是让承诺继续可见但不要过早消耗。 - 如果显式 hook agenda 里出现了可回收目标,本章必须写出具体兑现片段,不能只是重新提一句旧线索。 - 如果存在 stale debt,先消化旧承诺的压力,再决定是否开新坑;同类 sibling hook 不得随手再开。 +- 如果某条 hook 简报明确要求 suppress sibling hooks,就不能用再开一个同类平行坑来假装推进。 - 多角色场景里,至少给出一轮带阻力的直接交锋,不要把人物关系写成纯解释或纯总结。`; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c3c1ca3b..828f2919 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,6 +38,9 @@ export { } from "./models/runtime-state.js"; export { type ChapterConflict, + type HookMovement, + type HookPressureLevel, + type HookPressure, type ChapterIntent, type ContextSource, type ContextPackage, @@ -49,6 +52,9 @@ export { type RuleStack, type ChapterTrace, ChapterConflictSchema, + HookMovementSchema, + HookPressureLevelSchema, + HookPressureSchema, ChapterIntentSchema, ContextSourceSchema, ContextPackageSchema, diff --git a/packages/core/src/models/input-governance.ts b/packages/core/src/models/input-governance.ts index 461802a8..52b600d3 100644 --- a/packages/core/src/models/input-governance.ts +++ b/packages/core/src/models/input-governance.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { HookPayoffTimingSchema } from "./runtime-state.js"; export const ChapterConflictSchema = z.object({ type: z.string().min(1), @@ -8,7 +9,46 @@ export const ChapterConflictSchema = z.object({ export type ChapterConflict = z.infer; +export const HookPressurePhaseSchema = z.enum(["opening", "middle", "late"]); +export type HookPressurePhase = z.infer; + +export const HookMovementSchema = z.enum([ + "quiet-hold", + "refresh", + "advance", + "partial-payoff", + "full-payoff", +]); +export type HookMovement = z.infer; + +export const HookPressureLevelSchema = z.enum(["low", "medium", "high", "critical"]); +export type HookPressureLevel = z.infer; + +export const HookPressureReasonSchema = z.enum([ + "fresh-promise", + "building-debt", + "stale-promise", + "ripe-payoff", + "overdue-payoff", + "long-arc-hold", +]); +export type HookPressureReason = z.infer; + +export const HookPressureSchema = z.object({ + hookId: z.string().min(1), + type: z.string().min(1), + movement: HookMovementSchema, + pressure: HookPressureLevelSchema, + payoffTiming: HookPayoffTimingSchema.optional(), + phase: HookPressurePhaseSchema, + reason: HookPressureReasonSchema, + blockSiblingHooks: z.boolean().default(false), +}); + +export type HookPressure = z.infer; + export const HookAgendaSchema = z.object({ + pressureMap: z.array(HookPressureSchema).default([]), mustAdvance: z.array(z.string().min(1)).default([]), eligibleResolve: z.array(z.string().min(1)).default([]), staleDebt: z.array(z.string().min(1)).default([]), @@ -30,6 +70,7 @@ export const ChapterIntentSchema = z.object({ styleEmphasis: z.array(z.string()).default([]), conflicts: z.array(ChapterConflictSchema).default([]), hookAgenda: HookAgendaSchema.default({ + pressureMap: [], mustAdvance: [], eligibleResolve: [], staleDebt: [], diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index 19b5ce20..ee8a419a 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import type { HookAgenda } from "../models/input-governance.js"; +import type { HookAgenda, HookPressure } from "../models/input-governance.js"; import { ChapterSummariesStateSchema, CurrentStateStateSchema, @@ -223,6 +223,7 @@ export function buildPlannerHookAgenda(params: { readonly hooks: ReadonlyArray; readonly chapterNumber: number; readonly targetChapters?: number; + readonly language?: "zh" | "en"; readonly maxMustAdvance?: number; readonly maxEligibleResolve?: number; readonly maxStaleDebt?: number; @@ -281,8 +282,15 @@ export function buildPlannerHookAgenda(params: { ...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean), ...eligibleResolveHooks.map((hook) => hook.type.trim()).filter(Boolean), ])].slice(0, 3); + const pressureMap = buildHookPressureMap({ + lifecycleEntries, + mustAdvanceHooks, + eligibleResolveHooks, + staleDebtHooks, + }); return { + pressureMap, mustAdvance: mustAdvanceHooks.map((hook) => hook.hookId), eligibleResolve: eligibleResolveHooks.map((hook) => hook.hookId), staleDebt: staleDebtHooks.map((hook) => hook.hookId), @@ -290,6 +298,128 @@ export function buildPlannerHookAgenda(params: { }; } +function buildHookPressureMap(params: { + readonly lifecycleEntries: ReadonlyArray<{ + readonly hook: ReturnType; + readonly lifecycle: ReturnType; + }>; + readonly mustAdvanceHooks: ReadonlyArray>; + readonly eligibleResolveHooks: ReadonlyArray>; + readonly staleDebtHooks: ReadonlyArray>; +}): HookPressure[] { + const eligibleResolveIds = new Set(params.eligibleResolveHooks.map((hook) => hook.hookId)); + const staleDebtIds = new Set(params.staleDebtHooks.map((hook) => hook.hookId)); + const lifecycleById = new Map( + params.lifecycleEntries.map((entry) => [entry.hook.hookId, entry.lifecycle] as const), + ); + + const orderedIds = [...new Set([ + ...params.eligibleResolveHooks.map((hook) => hook.hookId), + ...params.staleDebtHooks.map((hook) => hook.hookId), + ...params.mustAdvanceHooks.map((hook) => hook.hookId), + ])]; + + return orderedIds.flatMap((hookId) => { + const hook = params.lifecycleEntries.find((entry) => entry.hook.hookId === hookId)?.hook; + const lifecycle = lifecycleById.get(hookId); + if (!hook || !lifecycle) { + return []; + } + + const movement = resolveHookMovement({ + hook, + lifecycle, + eligibleResolve: eligibleResolveIds.has(hookId), + staleDebt: staleDebtIds.has(hookId), + }); + const pressure = resolveHookPressureLevel({ lifecycle, movement }); + const reason = resolveHookPressureReason({ lifecycle, movement }); + + return [{ + hookId, + type: hook.type.trim() || "hook", + movement, + pressure, + payoffTiming: lifecycle.timing, + phase: lifecycle.phase, + reason, + blockSiblingHooks: staleDebtIds.has(hookId) || movement === "partial-payoff" || movement === "full-payoff", + }]; + }); +} + +function resolveHookMovement(params: { + readonly hook: ReturnType; + readonly lifecycle: ReturnType; + readonly eligibleResolve: boolean; + readonly staleDebt: boolean; +}): HookPressure["movement"] { + if (params.eligibleResolve) { + return "full-payoff"; + } + + const timing = params.lifecycle.timing; + const longArc = timing === "slow-burn" || timing === "endgame"; + + if (params.staleDebt && longArc) { + return "partial-payoff"; + } + + if (params.staleDebt) { + return "advance"; + } + + if (longArc && params.lifecycle.age <= 2 && params.lifecycle.dormancy <= 1) { + return "quiet-hold"; + } + + if (params.lifecycle.dormancy >= 2) { + return "refresh"; + } + + return "advance"; +} + +function resolveHookPressureLevel(params: { + readonly lifecycle: ReturnType; + readonly movement: HookPressure["movement"]; +}): HookPressure["pressure"] { + if (params.lifecycle.overdue || params.movement === "full-payoff") { + return params.lifecycle.overdue ? "critical" : "high"; + } + if (params.lifecycle.stale || params.movement === "partial-payoff") { + return "high"; + } + if (params.movement === "advance" || params.movement === "refresh") { + return "medium"; + } + return "low"; +} + +function resolveHookPressureReason(params: { + readonly lifecycle: ReturnType; + readonly movement: HookPressure["movement"]; +}): HookPressure["reason"] { + if (params.lifecycle.overdue && params.movement === "full-payoff") { + return "overdue-payoff"; + } + if (params.movement === "full-payoff") { + return "ripe-payoff"; + } + if (params.movement === "partial-payoff" || params.lifecycle.stale) { + return "stale-promise"; + } + if (params.movement === "quiet-hold") { + return params.lifecycle.timing === "slow-burn" || params.lifecycle.timing === "endgame" + ? "long-arc-hold" + : "fresh-promise"; + } + if (params.lifecycle.age <= 1) { + return "fresh-promise"; + } + return "building-debt"; +} + function openMemoryDB(bookDir: string): MemoryDB | null { try { return new MemoryDB(bookDir); From c608cd32377bb605501f35742fe722b143f94a3b Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 12:38:01 +0800 Subject: [PATCH 21/53] feat(pipeline): unify chapter cadence pressure analysis --- .../src/__tests__/chapter-cadence.test.ts | 87 ++++++++ .../src/__tests__/long-span-fatigue.test.ts | 36 ++++ packages/core/src/agents/planner.ts | 85 +++----- packages/core/src/utils/chapter-cadence.ts | 189 ++++++++++++++++++ packages/core/src/utils/long-span-fatigue.ts | 104 ++++++++-- 5 files changed, 418 insertions(+), 83 deletions(-) create mode 100644 packages/core/src/__tests__/chapter-cadence.test.ts create mode 100644 packages/core/src/utils/chapter-cadence.ts diff --git a/packages/core/src/__tests__/chapter-cadence.test.ts b/packages/core/src/__tests__/chapter-cadence.test.ts new file mode 100644 index 00000000..dde51413 --- /dev/null +++ b/packages/core/src/__tests__/chapter-cadence.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { analyzeChapterCadence } from "../utils/chapter-cadence.js"; + +describe("analyzeChapterCadence", () => { + it("surfaces stacked scene, mood, and title pressure from recent summary rows", () => { + const analysis = analyzeChapterCadence({ + language: "zh", + rows: [ + { + chapter: 1, + title: "名单之前", + mood: "紧张、压抑", + chapterType: "调查章", + }, + { + chapter: 2, + title: "名单之后", + mood: "冷硬、逼仄", + chapterType: "调查章", + }, + { + chapter: 3, + title: "名单还在", + mood: "压迫、窒息", + chapterType: "调查章", + }, + { + chapter: 4, + title: "名单未落", + mood: "肃杀、凝重", + chapterType: "调查章", + }, + ], + }); + + expect(analysis.scenePressure).toEqual(expect.objectContaining({ + repeatedType: "调查章", + pressure: "high", + streak: 4, + })); + expect(analysis.moodPressure).toEqual(expect.objectContaining({ + pressure: "high", + highTensionStreak: 4, + })); + expect(analysis.titlePressure).toEqual(expect.objectContaining({ + repeatedToken: "名单", + pressure: "high", + count: 4, + })); + }); + + it("stays quiet when scene, mood, and title cadence are varied", () => { + const analysis = analyzeChapterCadence({ + language: "en", + rows: [ + { + chapter: 1, + title: "Morning Harbor", + mood: "warm, gentle", + chapterType: "slice-of-life", + }, + { + chapter: 2, + title: "Sudden Rain", + mood: "tense, ominous", + chapterType: "tension", + }, + { + chapter: 3, + title: "Open Gate", + mood: "hopeful, light", + chapterType: "transition", + }, + { + chapter: 4, + title: "After the Letter", + mood: "melancholy, reflective", + chapterType: "introspection", + }, + ], + }); + + expect(analysis.scenePressure).toBeUndefined(); + expect(analysis.moodPressure).toBeUndefined(); + expect(analysis.titlePressure).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/long-span-fatigue.test.ts b/packages/core/src/__tests__/long-span-fatigue.test.ts index 1bc659db..d2c53e3f 100644 --- a/packages/core/src/__tests__/long-span-fatigue.test.ts +++ b/packages/core/src/__tests__/long-span-fatigue.test.ts @@ -121,4 +121,40 @@ describe("analyzeLongSpanFatigue", () => { await rm(join(bookDir, ".."), { recursive: true, force: true }); } }); + + it("warns when title focus collapses and high-tension mood never releases", async () => { + const bookDir = await createBookDir("inkos-long-span-cadence-test-"); + + await Promise.all([ + writeChapter(bookDir, 1, "名单之前", "风贴着走廊吹。周谨川没有停,手指一直压着那份发潮的薄册。"), + writeChapter(bookDir, 2, "名单之后", "楼道里只有脚步声。周谨川顺着裂灯往下走,肩背始终绷着。"), + writeFile( + join(bookDir, "story", "chapter_summaries.md"), + [ + "# 章节摘要", + "", + "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |", + "|------|------|----------|----------|----------|----------|----------|----------|", + "| 1 | 名单之前 | 周谨川 | 初次接触名单 | 压力上升 | 名单线继续发酵 | 紧张、压抑 | 调查章 |", + "| 2 | 名单之后 | 周谨川 | 顺着名单继续追查 | 目标未变 | 名单线继续发酵 | 冷硬、逼仄 | 调查章 |", + ].join("\n"), + "utf-8", + ), + ]); + + try { + const result = await analyzeLongSpanFatigue({ + bookDir, + chapterNumber: 3, + chapterContent: "墙角的灰一直没落定。周谨川盯着名单最后一行,喉结很轻地滚了一下,还是没有把气松出来。", + chapterSummary: "| 3 | 名单未落 | 周谨川 | 名单追查继续推进 | 目标未变 | 名单线继续发酵 | 压迫、窒息 | 调查章 |", + language: "zh", + }); + + expect(result.issues.some((issue) => issue.category === "标题重复")).toBe(true); + expect(result.issues.some((issue) => issue.category === "情绪单调")).toBe(true); + } finally { + await rm(join(bookDir, ".."), { recursive: true, force: true }); + } + }); }); diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index f9df5507..6a3f3968 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -11,6 +11,7 @@ import { renderSummarySnapshot, retrieveMemorySelection, } from "../utils/memory-retrieval.js"; +import { analyzeChapterCadence } from "../utils/chapter-cadence.js"; export interface PlanChapterInput { readonly book: BookConfig; @@ -138,8 +139,17 @@ export class PlannerAgent extends BaseAgent { }): Pick { const recentSummaries = parseChapterSummariesMarkdown(input.chapterSummaries) .filter((summary) => summary.chapter < input.chapterNumber) - .sort((left, right) => right.chapter - left.chapter) - .slice(0, 4); + .sort((left, right) => left.chapter - right.chapter) + .slice(-4); + const cadence = analyzeChapterCadence({ + language: this.isChineseLanguage(input.language) ? "zh" : "en", + rows: recentSummaries.map((summary) => ({ + chapter: summary.chapter, + title: summary.title, + mood: summary.mood, + chapterType: summary.chapterType, + })), + }); return { arcDirective: this.buildArcDirective( @@ -148,9 +158,9 @@ export class PlannerAgent extends BaseAgent { input.outlineNode, input.matchedOutlineAnchor, ), - sceneDirective: this.buildSceneDirective(input.language, recentSummaries), - moodDirective: this.buildMoodDirective(input.language, recentSummaries), - titleDirective: this.buildTitleDirective(input.language, recentSummaries), + sceneDirective: this.buildSceneDirective(input.language, cadence), + moodDirective: this.buildMoodDirective(input.language, cadence), + titleDirective: this.buildTitleDirective(input.language, cadence), }; } @@ -334,15 +344,12 @@ export class PlannerAgent extends BaseAgent { private buildSceneDirective( language: string | undefined, - recentSummaries: ReadonlyArray<{ chapterType: string }>, + cadence: ReturnType, ): string | undefined { - const repeatedType = this.findRepeatedValue( - recentSummaries.map((summary) => summary.chapterType), - 3, - ); - if (!repeatedType) { + if (cadence.scenePressure?.pressure !== "high") { return undefined; } + const repeatedType = cadence.scenePressure.repeatedType; return this.isChineseLanguage(language) ? `最近章节连续停留在“${repeatedType}”,本章必须更换场景容器、地点或行动方式。` @@ -351,76 +358,32 @@ export class PlannerAgent extends BaseAgent { private buildMoodDirective( language: string | undefined, - recentSummaries: ReadonlyArray<{ mood: string }>, + cadence: ReturnType, ): string | undefined { - if (recentSummaries.length < 3) { - return undefined; - } - - const moods = recentSummaries.map((summary) => summary.mood.trim()).filter(Boolean); - if (moods.length < 3) { - return undefined; - } - - const allHighTension = moods.every((mood) => this.isHighTensionMood(mood)); - if (!allHighTension) { + if (cadence.moodPressure?.pressure !== "high") { return undefined; } + const moods = cadence.moodPressure.recentMoods; return this.isChineseLanguage(language) ? `最近${moods.length}章情绪持续高压(${moods.slice(0, 3).join("、")}),本章必须降调——安排日常/喘息/温情/幽默场景,让读者呼吸。` : `The last ${moods.length} chapters have been relentlessly tense (${moods.slice(0, 3).join(", ")}). This chapter must downshift — write a quieter scene with warmth, humor, or breathing room.`; } - private isHighTensionMood(mood: string): boolean { - const tensionKeywords = [ - "紧张", "冷硬", "压抑", "逼仄", "肃杀", "沉重", "凝重", - "冷峻", "压迫", "阴沉", "焦灼", "窒息", "凛冽", "锋利", - "克制", "危机", "对峙", "绷紧", "僵持", "杀意", - "tense", "cold", "oppressive", "grim", "ominous", "dark", - "bleak", "hostile", "threatening", "heavy", "suffocating", - ]; - const lowerMood = mood.toLowerCase(); - return tensionKeywords.some((keyword) => lowerMood.includes(keyword)); - } - private buildTitleDirective( language: string | undefined, - recentSummaries: ReadonlyArray<{ title: string }>, + cadence: ReturnType, ): string | undefined { - const tokenCounts = new Map(); - - for (const summary of recentSummaries) { - for (const token of this.extractKeywords(summary.title)) { - tokenCounts.set(token, (tokenCounts.get(token) ?? 0) + 1); - } - } - - const repeatedToken = [...tokenCounts.entries()] - .sort((left, right) => right[1] - left[1]) - .find((entry) => entry[1] >= 3)?.[0]; - if (!repeatedToken) { + if (cadence.titlePressure?.pressure !== "high") { return undefined; } + const repeatedToken = cadence.titlePressure.repeatedToken; return this.isChineseLanguage(language) ? `标题不要再围绕“${repeatedToken}”重复命名,换一个新的意象或动作焦点。` : `Avoid another ${repeatedToken}-centric title. Pick a new image or action focus for this chapter title.`; } - private findRepeatedValue(values: ReadonlyArray, threshold: number): string | undefined { - const counts = new Map(); - - for (const value of values.map((value) => value.trim()).filter(Boolean)) { - counts.set(value, (counts.get(value) ?? 0) + 1); - if ((counts.get(value) ?? 0) >= threshold) { - return value; - } - } - - return undefined; - } - private extractSection(content: string, headings: ReadonlyArray): string | undefined { const targets = headings.map((heading) => this.normalizeHeading(heading)); const lines = content.split("\n"); diff --git a/packages/core/src/utils/chapter-cadence.ts b/packages/core/src/utils/chapter-cadence.ts new file mode 100644 index 00000000..b179ed93 --- /dev/null +++ b/packages/core/src/utils/chapter-cadence.ts @@ -0,0 +1,189 @@ +export interface CadenceSummaryRow { + readonly chapter: number; + readonly title: string; + readonly mood: string; + readonly chapterType: string; +} + +export interface SceneCadencePressure { + readonly pressure: "medium" | "high"; + readonly repeatedType: string; + readonly streak: number; +} + +export interface MoodCadencePressure { + readonly pressure: "medium" | "high"; + readonly highTensionStreak: number; + readonly recentMoods: ReadonlyArray; +} + +export interface TitleCadencePressure { + readonly pressure: "medium" | "high"; + readonly repeatedToken: string; + readonly count: number; + readonly recentTitles: ReadonlyArray; +} + +export interface ChapterCadenceAnalysis { + readonly scenePressure?: SceneCadencePressure; + readonly moodPressure?: MoodCadencePressure; + readonly titlePressure?: TitleCadencePressure; +} + +const HIGH_TENSION_KEYWORDS = [ + "紧张", "冷硬", "压抑", "逼仄", "肃杀", "沉重", "凝重", + "冷峻", "压迫", "阴沉", "焦灼", "窒息", "凛冽", "锋利", + "克制", "危机", "对峙", "绷紧", "僵持", "杀意", + "tense", "cold", "oppressive", "grim", "ominous", "dark", + "bleak", "hostile", "threatening", "heavy", "suffocating", +]; + +const ENGLISH_STOP_WORDS = new Set([ + "the", "and", "with", "from", "into", "after", "before", + "over", "under", "this", "that", "your", "their", +]); + +export function analyzeChapterCadence(params: { + readonly rows: ReadonlyArray; + readonly language: "zh" | "en"; +}): ChapterCadenceAnalysis { + const recentRows = [...params.rows] + .sort((left, right) => left.chapter - right.chapter) + .slice(-4); + + return { + scenePressure: analyzeScenePressure(recentRows), + moodPressure: analyzeMoodPressure(recentRows), + titlePressure: analyzeTitlePressure(recentRows, params.language), + }; +} + +export function isHighTensionMood(mood: string): boolean { + const lowerMood = mood.toLowerCase(); + return HIGH_TENSION_KEYWORDS.some((keyword) => lowerMood.includes(keyword)); +} + +function analyzeScenePressure( + rows: ReadonlyArray, +): SceneCadencePressure | undefined { + const types = rows + .map((row) => row.chapterType.trim()) + .filter((value) => isMeaningfulValue(value)); + if (types.length < 2) { + return undefined; + } + + const repeatedType = types.at(-1); + if (!repeatedType) { + return undefined; + } + + let streak = 0; + for (const type of [...types].reverse()) { + if (type.toLowerCase() !== repeatedType.toLowerCase()) { + break; + } + streak += 1; + } + + if (streak >= 3) { + return { pressure: "high", repeatedType, streak }; + } + if (streak >= 2 && types.length >= 4) { + return { pressure: "medium", repeatedType, streak }; + } + return undefined; +} + +function analyzeMoodPressure( + rows: ReadonlyArray, +): MoodCadencePressure | undefined { + const moods = rows + .map((row) => row.mood.trim()) + .filter((value) => isMeaningfulValue(value)); + if (moods.length < 2) { + return undefined; + } + + const recentMoods: string[] = []; + let highTensionStreak = 0; + for (const mood of [...moods].reverse()) { + if (!isHighTensionMood(mood)) { + break; + } + recentMoods.unshift(mood); + highTensionStreak += 1; + } + + if (highTensionStreak >= 3) { + return { pressure: "high", highTensionStreak, recentMoods }; + } + if (highTensionStreak >= 2 && moods.length >= 4) { + return { pressure: "medium", highTensionStreak, recentMoods }; + } + return undefined; +} + +function analyzeTitlePressure( + rows: ReadonlyArray, + language: "zh" | "en", +): TitleCadencePressure | undefined { + const titles = rows + .map((row) => row.title.trim()) + .filter((value) => isMeaningfulValue(value)); + if (titles.length < 2) { + return undefined; + } + + const counts = new Map(); + for (const title of titles) { + for (const token of extractTitleTokens(title, language)) { + counts.set(token, (counts.get(token) ?? 0) + 1); + } + } + + const repeated = [...counts.entries()] + .sort((left, right) => right[1] - left[1] || right[0].length - left[0].length || left[0].localeCompare(right[0])) + .find((entry) => entry[1] >= 2); + if (!repeated) { + return undefined; + } + + const [repeatedToken, count] = repeated; + if (count >= 3) { + return { pressure: "high", repeatedToken, count, recentTitles: titles }; + } + if (count >= 2 && titles.length >= 4) { + return { pressure: "medium", repeatedToken, count, recentTitles: titles }; + } + return undefined; +} + +function extractTitleTokens(title: string, language: "zh" | "en"): string[] { + if (language === "en") { + const words = title.match(/[a-z]{4,}/gi) ?? []; + return [...new Set( + words + .map((word) => word.toLowerCase()) + .filter((word) => !ENGLISH_STOP_WORDS.has(word)), + )]; + } + + const segments = title.match(/[\u4e00-\u9fff]{2,}/g) ?? []; + const tokens = new Set(); + for (const segment of segments) { + for (let size = 2; size <= Math.min(4, segment.length); size += 1) { + for (let index = 0; index <= segment.length - size; index += 1) { + tokens.add(segment.slice(index, index + size)); + } + } + } + + return [...tokens]; +} + +function isMeaningfulValue(value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (!normalized) return false; + return normalized !== "none" && normalized !== "(none)" && normalized !== "无"; +} diff --git a/packages/core/src/utils/long-span-fatigue.ts b/packages/core/src/utils/long-span-fatigue.ts index 5d88e49d..2dc64c58 100644 --- a/packages/core/src/utils/long-span-fatigue.ts +++ b/packages/core/src/utils/long-span-fatigue.ts @@ -1,5 +1,6 @@ import { readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; +import { analyzeChapterCadence } from "./chapter-cadence.js"; export interface LongSpanFatigueIssue { readonly severity: "warning"; @@ -48,12 +49,16 @@ export async function buildEnglishVarianceBrief(params: { const recentRows = summaryRows .filter((row) => row.chapter < params.chapterNumber) .sort((left, right) => left.chapter - right.chapter) - .slice(-3); + .slice(-4); const highFrequencyPhrases = collectRepeatedEnglishPhrases(chapterBodies); const repeatedOpeningPatterns = collectRepeatedBoundaryPatterns(chapterBodies, "opening"); const repeatedEndingShapes = collectRepeatedBoundaryPatterns(chapterBodies, "ending"); - const sceneObligation = chooseSceneObligation(recentRows, repeatedOpeningPatterns, repeatedEndingShapes); + const cadence = analyzeChapterCadence({ + rows: recentRows, + language: "en", + }); + const sceneObligation = chooseSceneObligation(cadence, repeatedOpeningPatterns, repeatedEndingShapes); const lines = [ "## English Variance Brief", @@ -84,13 +89,27 @@ export async function analyzeLongSpanFatigue( const recentRows = mergedRows .filter((row) => row.chapter <= input.chapterNumber) .sort((left, right) => left.chapter - right.chapter) - .slice(-3); + .slice(-4); + const cadence = analyzeChapterCadence({ + rows: recentRows, + language, + }); - const chapterTypeIssue = buildChapterTypeIssue(recentRows, language); + const chapterTypeIssue = buildChapterTypeIssue(cadence, language); if (chapterTypeIssue) { issues.push(chapterTypeIssue); } + const moodIssue = buildMoodIssue(cadence, language); + if (moodIssue) { + issues.push(moodIssue); + } + + const titleIssue = buildTitleIssue(cadence, language); + if (titleIssue) { + issues.push(titleIssue); + } + const recentChapterBodies = await loadRecentChapterBodies( input.bookDir, input.chapterNumber, @@ -181,26 +200,19 @@ function parseSummaryRow(line: string): SummaryRow | null { } function buildChapterTypeIssue( - rows: ReadonlyArray, + cadence: ReturnType, language: "zh" | "en", ): LongSpanFatigueIssue | null { - if (rows.length < 3) return null; - - const types = rows - .map((row) => row.chapterType.trim()) - .filter((value) => isMeaningfulValue(value)); - if (types.length < 3) return null; - - const normalized = types.map((value) => value.toLowerCase()); - if (!normalized.every((value) => value === normalized[0])) { + if (cadence.scenePressure?.pressure !== "high") { return null; } + const { repeatedType, streak } = cadence.scenePressure; if (language === "en") { return { severity: "warning", category: "Pacing Monotony", - description: `The last 3 chapter types are identical: ${types.join(" -> ")}, which suggests macro pacing monotony.`, + description: `The last ${streak} chapter types have stayed on ${repeatedType}, which suggests macro pacing monotony.`, suggestion: "Switch the next chapter's function instead of extending the same beat again. Rotate setup, payoff, reversal, and fallout more deliberately.", }; } @@ -208,11 +220,63 @@ function buildChapterTypeIssue( return { severity: "warning", category: "节奏单调", - description: `最近3章章节类型完全一致:${types.join(" -> ")},长篇节奏可能开始固化。`, + description: `最近${streak}章章节类型持续停留在“${repeatedType}”,长篇节奏可能开始固化。`, suggestion: "下一章应切换章节功能,不要连续重复同一种布局/推进节拍。", }; } +function buildMoodIssue( + cadence: ReturnType, + language: "zh" | "en", +): LongSpanFatigueIssue | null { + if (cadence.moodPressure?.pressure !== "high") { + return null; + } + const { highTensionStreak, recentMoods } = cadence.moodPressure; + + if (language === "en") { + return { + severity: "warning", + category: "Mood Monotony", + description: `High-tension mood has locked in for ${highTensionStreak} chapters (${recentMoods.join(" -> ")}), with no visible emotional release.`, + suggestion: "Insert a release beat, warmth, humor, intimacy, or reflective quiet before escalating again.", + }; + } + + return { + severity: "warning", + category: "情绪单调", + description: `最近${highTensionStreak}章持续高压(${recentMoods.join(" -> ")}),缺少明显的情绪释放。`, + suggestion: "下一章安排一次喘息、温情、幽默或静场释放,再继续加压。", + }; +} + +function buildTitleIssue( + cadence: ReturnType, + language: "zh" | "en", +): LongSpanFatigueIssue | null { + if (cadence.titlePressure?.pressure !== "high") { + return null; + } + const { repeatedToken, count } = cadence.titlePressure; + + if (language === "en") { + return { + severity: "warning", + category: "Title Collapse", + description: `Recent titles keep collapsing around "${repeatedToken}" (${count} hits in the current window), which makes chapter naming feel formulaic.`, + suggestion: "Change the next title anchor. Use a new image, action, consequence, or character vector instead of the same keyword shell.", + }; + } + + return { + severity: "warning", + category: "标题重复", + description: `最近标题持续围绕“${repeatedToken}”命名(当前窗口命中${count}次),命名开始坍缩。`, + suggestion: "下一章标题换一个新的意象、动作、后果或人物焦点,不要继续套同一个关键词壳。", + }; +} + async function loadRecentChapterBodies( bookDir: string, currentChapter: number, @@ -352,15 +416,11 @@ function collectRepeatedBoundaryPatterns( } function chooseSceneObligation( - rows: ReadonlyArray, + cadence: ReturnType, repeatedOpenings: ReadonlyArray, repeatedEndings: ReadonlyArray, ): string { - const recentTypes = rows - .map((row) => row.chapterType.trim().toLowerCase()) - .filter((type) => type.length > 0); - - if (recentTypes.length >= 3 && recentTypes.every((type) => type === recentTypes[0])) { + if (cadence.scenePressure?.pressure === "high") { return "confrontation under pressure"; } if (repeatedEndings.length > 0) { From 2638f2292717bf32fe5bd2e6c02c853697186073 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 12:43:38 +0800 Subject: [PATCH 22/53] fix(pipeline): isolate audit drift guidance from state --- packages/core/src/__tests__/composer.test.ts | 34 +++++ .../src/__tests__/pipeline-runner.test.ts | 24 ++-- packages/core/src/agents/composer.ts | 5 + packages/core/src/pipeline/runner.ts | 127 +++++++++++++----- 4 files changed, 147 insertions(+), 43 deletions(-) diff --git a/packages/core/src/__tests__/composer.test.ts b/packages/core/src/__tests__/composer.test.ts index 65faf0e1..715ee564 100644 --- a/packages/core/src/__tests__/composer.test.ts +++ b/packages/core/src/__tests__/composer.test.ts @@ -460,6 +460,40 @@ describe("ComposerAgent", () => { expect(fanficCanonEntry?.excerpt).toContain("oath debt logic"); }); + it("includes dedicated audit drift guidance instead of relying on current_state pollution", async () => { + await writeFile( + join(storyDir, "audit_drift.md"), + [ + "# Audit Drift", + "", + "## 审计纠偏(自动生成,下一章写作前参照)", + "", + "> - [warning] 节奏单调: 最近4章章节类型持续停留在“调查章”。", + ].join("\n"), + "utf-8", + ); + + const composer = new ComposerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await composer.composeChapter({ + book, + bookDir, + chapterNumber: 4, + plan, + }); + + const driftEntry = result.contextPackage.selectedContext.find((entry) => + entry.source === "story/audit_drift.md", + ); + expect(driftEntry).toBeDefined(); + expect(driftEntry?.excerpt).toContain("节奏单调"); + }); + it("emits hook debt briefs for agenda-targeted hooks", async () => { await Promise.all([ writeFile( diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts index 62de1d7f..bb41bfd6 100644 --- a/packages/core/src/__tests__/pipeline-runner.test.ts +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -1225,7 +1225,7 @@ describe("PipelineRunner", () => { } }); - it("writes English audit drift correction blocks for English books", async () => { + it("writes English audit drift guidance into a dedicated file without polluting current_state", async () => { const { root, runner, state, bookId } = await createRunnerFixture(); const englishBook = { ...(await state.loadBookConfig(bookId)), @@ -1279,11 +1279,13 @@ describe("PipelineRunner", () => { try { await runner.writeNextChapter(bookId, 220); + const driftFile = await readFile(join(state.bookDir(bookId), "story", "audit_drift.md"), "utf-8"); const currentState = await readFile(join(state.bookDir(bookId), "story", "current_state.md"), "utf-8"); - expect(currentState).toContain("## Audit Drift Correction"); - expect(currentState).toContain("> Chapter 1 audit found the following issues"); - expect(currentState).not.toContain("## 审计纠偏"); - expect(currentState).not.toContain("下一章写作前参照"); + expect(driftFile).toContain("## Audit Drift Correction"); + expect(driftFile).toContain("> Chapter 1 audit found the following issues"); + expect(driftFile).not.toContain("## 审计纠偏"); + expect(driftFile).not.toContain("下一章写作前参照"); + expect(currentState).not.toContain("Audit Drift Correction"); } finally { await rm(root, { recursive: true, force: true }); } @@ -3363,7 +3365,7 @@ describe("PipelineRunner", () => { } }); - it("feeds long-span fatigue warnings back into pipeline audit and drift correction", async () => { + it("feeds long-span fatigue warnings back into pipeline audit and dedicated drift guidance", async () => { const { root, runner, state, bookId } = await createRunnerFixture(); const storyDir = join(state.bookDir(bookId), "story"); const now = "2026-03-19T00:00:00.000Z"; @@ -3443,16 +3445,18 @@ describe("PipelineRunner", () => { try { const result = await runner.writeNextChapter(bookId); + const driftFile = await readFile(join(storyDir, "audit_drift.md"), "utf-8"); const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8"); expect(result.auditResult.issues.some((issue) => issue.category === "节奏单调")).toBe(true); - expect(currentState).toContain("节奏单调"); + expect(driftFile).toContain("节奏单调"); + expect(currentState).not.toContain("节奏单调"); } finally { await rm(root, { recursive: true, force: true }); } }); - it("feeds hook health warnings back into pipeline audit and drift correction", async () => { + it("feeds hook health warnings back into pipeline audit and dedicated drift guidance", async () => { const { root, runner, state, bookId } = await createRunnerFixture(); const storyDir = join(state.bookDir(bookId), "story"); @@ -3502,11 +3506,13 @@ describe("PipelineRunner", () => { try { const result = await runner.writeNextChapter(bookId); + const driftFile = await readFile(join(storyDir, "audit_drift.md"), "utf-8"); const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8"); const savedIndex = await state.loadChapterIndex(bookId); expect(result.auditResult.issues.some((issue) => issue.category === "伏笔债务")).toBe(true); - expect(currentState).toContain("伏笔债务"); + expect(driftFile).toContain("伏笔债务"); + expect(currentState).not.toContain("伏笔债务"); expect(savedIndex[0]?.auditIssues).toEqual( expect.arrayContaining([ expect.stringContaining("活跃伏笔过多"), diff --git a/packages/core/src/agents/composer.ts b/packages/core/src/agents/composer.ts index 048437a4..64975945 100644 --- a/packages/core/src/agents/composer.ts +++ b/packages/core/src/agents/composer.ts @@ -118,6 +118,11 @@ export class ComposerAgent extends BaseAgent { ): Promise { const entries = await Promise.all([ this.maybeContextSource(storyDir, "current_focus.md", "Current task focus for this chapter."), + this.maybeContextSource( + storyDir, + "audit_drift.md", + "Carry forward audit drift guidance from the previous chapter without polluting hard state facts.", + ), this.maybeContextSource( storyDir, "current_state.md", diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 122c9c60..f9123fcc 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -641,6 +641,15 @@ export class PipelineRunner { : ch, ); await this.state.saveChapterIndex(bookId, updated); + const latestChapter = index.length > 0 ? Math.max(...index.map((chapter) => chapter.number)) : targetChapter; + if (targetChapter === latestChapter) { + await this.persistAuditDriftGuidance({ + bookDir, + chapterNumber: targetChapter, + issues: result.issues.filter((issue) => issue.severity === "critical" || issue.severity === "warning"), + language, + }).catch(() => undefined); + } await this.emitWebhook( result.passed ? "audit-passed" : "audit-failed", @@ -877,6 +886,17 @@ export class PipelineRunner { : ch, ); await this.state.saveChapterIndex(bookId, updatedIndex); + const latestChapter = index.length > 0 ? Math.max(...index.map((chapter) => chapter.number)) : targetChapter; + if (targetChapter === latestChapter) { + await this.persistAuditDriftGuidance({ + bookDir, + chapterNumber: targetChapter, + issues: effectivePostRevision.auditResult.issues.filter( + (issue) => issue.severity === "critical" || issue.severity === "warning", + ), + language, + }).catch(() => undefined); + } // Re-snapshot this.logStage(stageLanguage, { @@ -1379,43 +1399,16 @@ export class PipelineRunner { await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]); await this.markBookActiveIfNeeded(bookId); - // 5.5 Audit drift correction — feed audit findings back into state - // This prevents the writer from repeating mistakes in the next chapter + // 5.5 Persist audit drift guidance without polluting current_state.md const driftIssues = auditResult.issues.filter( (i) => i.severity === "critical" || i.severity === "warning", ); - if (chapterStatus !== "state-degraded" && driftIssues.length > 0) { - const storyDir = join(bookDir, "story"); - try { - const statePath = join(storyDir, "current_state.md"); - const currentState = await readFile(statePath, "utf-8").catch(() => ""); - - // Append drift correction section (or replace existing one) - const correctionHeader = this.localize(stageLanguage, { - zh: "## 审计纠偏(自动生成,下一章写作前参照)", - en: "## Audit Drift Correction", - }); - const correctionBlock = [ - correctionHeader, - this.localize(stageLanguage, { - zh: `> 第${chapterNumber}章审计发现以下问题,下一章写作时必须避免:`, - en: `> Chapter ${chapterNumber} audit found the following issues to avoid in the next chapter:`, - }), - ...driftIssues.map((i) => `> - [${i.severity}] ${i.category}: ${i.description}`), - "", - ].join("\n"); - - // Replace existing correction block or append - const existingCorrectionIdx = currentState.indexOf(correctionHeader); - const updatedState = existingCorrectionIdx >= 0 - ? currentState.slice(0, existingCorrectionIdx) + correctionBlock - : currentState + "\n\n" + correctionBlock; - - await writeFile(statePath, updatedState, "utf-8"); - } catch { - // Non-critical — don't block pipeline if drift correction fails - } - } + await this.persistAuditDriftGuidance({ + bookDir, + chapterNumber, + issues: chapterStatus === "state-degraded" ? [] : driftIssues, + language: stageLanguage, + }).catch(() => undefined); // 5.6 Snapshot state for rollback support if (chapterStatus !== "state-degraded") { @@ -2712,6 +2705,72 @@ ${matrix}`, }; } + private async persistAuditDriftGuidance(params: { + readonly bookDir: string; + readonly chapterNumber: number; + readonly issues: ReadonlyArray; + readonly language: LengthLanguage; + }): Promise { + const storyDir = join(params.bookDir, "story"); + const driftPath = join(storyDir, "audit_drift.md"); + const statePath = join(storyDir, "current_state.md"); + const currentState = await readFile(statePath, "utf-8").catch(() => ""); + const sanitizedState = this.stripAuditDriftCorrectionBlock(currentState).trimEnd(); + + if (sanitizedState !== currentState) { + await writeFile(statePath, sanitizedState, "utf-8"); + } + + if (params.issues.length === 0) { + await rm(driftPath, { force: true }).catch(() => undefined); + return; + } + + const block = [ + this.localize(params.language, { + zh: "# 审计纠偏", + en: "# Audit Drift", + }), + "", + this.localize(params.language, { + zh: "## 审计纠偏(自动生成,下一章写作前参照)", + en: "## Audit Drift Correction", + }), + "", + this.localize(params.language, { + zh: `> 第${params.chapterNumber}章审计发现以下问题,下一章写作时必须避免:`, + en: `> Chapter ${params.chapterNumber} audit found the following issues to avoid in the next chapter:`, + }), + ...params.issues.map((issue) => `> - [${issue.severity}] ${issue.category}: ${issue.description}`), + "", + ].join("\n"); + + await writeFile(driftPath, block, "utf-8"); + } + + private stripAuditDriftCorrectionBlock(currentState: string): string { + const headers = [ + "## 审计纠偏(自动生成,下一章写作前参照)", + "## Audit Drift Correction", + "# 审计纠偏", + "# Audit Drift", + ]; + + let cutIndex = -1; + for (const header of headers) { + const index = currentState.indexOf(header); + if (index >= 0 && (cutIndex < 0 || index < cutIndex)) { + cutIndex = index; + } + } + + if (cutIndex < 0) { + return currentState; + } + + return currentState.slice(0, cutIndex).trimEnd(); + } + private logLengthWarnings(lengthWarnings: ReadonlyArray): void { for (const warning of lengthWarnings) { this.config.logger?.warn(warning); From f0110e8d7b48fbc8885680ace67f60ab2a3e413e Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 12:56:44 +0800 Subject: [PATCH 23/53] feat(pipeline): align hook audit with lifecycle semantics --- .../core/src/__tests__/continuity.test.ts | 6 + .../core/src/__tests__/hook-health.test.ts | 34 ++++- packages/core/src/agents/continuity.ts | 16 +-- packages/core/src/agents/writer.ts | 1 + packages/core/src/utils/hook-health.ts | 135 ++++++++++++++---- 5 files changed, 149 insertions(+), 43 deletions(-) diff --git a/packages/core/src/__tests__/continuity.test.ts b/packages/core/src/__tests__/continuity.test.ts index f1e985a2..45a98a4f 100644 --- a/packages/core/src/__tests__/continuity.test.ts +++ b/packages/core/src/__tests__/continuity.test.ts @@ -156,6 +156,12 @@ describe("ContinuityAuditor", () => { expect(systemPrompt).toContain("Hook Check"); expect(systemPrompt).toContain("Outline Drift Check"); + expect(systemPrompt).toContain("stays dormant long enough to feel abandoned"); + expect(systemPrompt).toContain("holds one pressure shape across a run"); + expect(systemPrompt).toContain("same mode long enough to flatten rhythm"); + expect(systemPrompt).not.toContain("more than 5 chapters"); + expect(systemPrompt).not.toContain("3 straight chapters"); + expect(systemPrompt).not.toContain("3+ consecutive chapters"); expect(systemPrompt).not.toContain("伏笔检查"); expect(systemPrompt).not.toContain("大纲偏离检测"); diff --git a/packages/core/src/__tests__/hook-health.test.ts b/packages/core/src/__tests__/hook-health.test.ts index f76f5b35..92868d73 100644 --- a/packages/core/src/__tests__/hook-health.test.ts +++ b/packages/core/src/__tests__/hook-health.test.ts @@ -10,6 +10,7 @@ function createHook(overrides: Partial = {}): HookRecord { status: overrides.status ?? "open", lastAdvancedChapter: overrides.lastAdvancedChapter ?? 1, expectedPayoff: overrides.expectedPayoff ?? "Reveal the hidden ledger", + payoffTiming: overrides.payoffTiming, notes: overrides.notes ?? "Still unresolved", }; } @@ -49,18 +50,41 @@ describe("analyzeHookHealth", () => { expect(issues.some((issue) => issue.category === "Hook Debt" && issue.description.includes("5 active hooks"))).toBe(true); }); - it("warns when no hook has materially advanced for several chapters", () => { + it("warns when a short-payoff hook is already under payoff pressure without real movement", () => { + const issues = analyzeHookHealth({ + language: "en", + chapterNumber: 4, + hooks: [ + createHook({ + hookId: "H001", + startChapter: 1, + lastAdvancedChapter: 1, + payoffTiming: "immediate", + expectedPayoff: "Reveal the hidden ledger immediately after the theft.", + }), + ], + }); + + expect(issues.some((issue) => issue.description.includes("payoff pressure"))).toBe(true); + }); + + it("does not warn when only endgame hooks are dormant before the story reaches late phase", () => { const issues = analyzeHookHealth({ language: "en", chapterNumber: 20, + targetChapters: 40, hooks: [ - createHook({ hookId: "H001", lastAdvancedChapter: 12 }), - createHook({ hookId: "H002", lastAdvancedChapter: 11 }), + createHook({ + hookId: "H001", + startChapter: 10, + lastAdvancedChapter: 15, + payoffTiming: "endgame", + expectedPayoff: "Final reveal in the endgame.", + }), ], - noAdvanceWindow: 5, }); - expect(issues.some((issue) => issue.description.includes("No real hook advancement"))).toBe(true); + expect(issues).toHaveLength(0); }); it("warns when stale hooks receive no disposition in the current chapter", () => { diff --git a/packages/core/src/agents/continuity.ts b/packages/core/src/agents/continuity.ts index 91d455ce..d38e9eda 100644 --- a/packages/core/src/agents/continuity.ts +++ b/packages/core/src/agents/continuity.ts @@ -170,16 +170,16 @@ function buildDimensionNote( : "检查视角切换是否有过渡、是否与设定视角一致"; case 24: return language === "en" - ? "Cross-check subplot_board and chapter_summaries: if any subplot goes unmentioned or unadvanced for more than 5 chapters -> warning. If subplots exist but none move in the last 3 chapters -> warning." - : "对照 subplot_board 和 chapter_summaries:如果任何支线超过5章未被提及或推进→warning。如果存在支线但近3章完全没有任何支线推进→warning"; + ? "Cross-check subplot_board and chapter_summaries: flag any subplot that stays dormant long enough to feel abandoned, or a recent run where every subplot is only restated instead of genuinely moving." + : "对照 subplot_board 和 chapter_summaries:标记那些沉寂到接近被遗忘的支线,或近期连续只被重复提及、没有真实推进的支线。"; case 25: return language === "en" - ? "Cross-check emotional_arcs and chapter_summaries: if a major character shows no emotional change for 3 straight chapters (no new pressure, release, or turn) -> warning. Distinguish unchanged circumstances from unchanged inner movement." - : "对照 emotional_arcs 和 chapter_summaries:如果主要角色连续3章情绪状态无变化(没有新的压力、释放、转变)→warning。注意区分'角色处境未变'和'角色内心未变'"; + ? "Cross-check emotional_arcs and chapter_summaries: flag any major character whose emotional line holds one pressure shape across a run instead of taking new pressure, release, reversal, or reinterpretation. Distinguish unchanged circumstances from unchanged inner movement." + : "对照 emotional_arcs 和 chapter_summaries:标记主要角色在一段时间内始终停留在同一种情绪压力形态、没有新压力、释放、转折或重估的情况。注意区分'处境未变'和'内心未变'。"; case 26: return language === "en" - ? "Cross-check chapter_summaries for chapter-type distribution: 3+ consecutive chapters of the same type -> warning. No payoff or climax chapter for 5+ chapters -> warning. Explicitly list the recent type sequence." - : "对照 chapter_summaries 的章节类型分布:连续≥3章相同类型(如连续3个事件章/战斗章/布局章)→warning。≥5章没有出现回收章或高潮章→warning。请明确列出最近章节的类型序列"; + ? "Cross-check chapter_summaries for chapter-type distribution: warn when the recent sequence stays in the same mode long enough to flatten rhythm, or when payoff / release beats disappear for too long. Explicitly list the recent type sequence." + : "对照 chapter_summaries 的章节类型分布:当近期章节长时间停留在同一种模式、把节奏压平,或回收/释放/高潮章节缺席过久时给出 warning。请明确列出最近章节的类型序列。"; case 28: return language === "en" ? "Check whether spinoff events contradict the mainline canon constraints." @@ -198,8 +198,8 @@ function buildDimensionNote( : "检查番外是否越权回收正传伏笔(warning级别)"; case 32: return language === "en" - ? "Check: does the chapter ending provide a hook? Has there been a meaningful payoff within the last 3-5 chapters? Is emotional pressure being suppressed for more than 3 chapters without release? Are reader expectation gaps accumulating or being satisfied?" - : "检查:章尾是否有钩子?最近3-5章内是否有爽点落地?是否存在超过3章的情绪压制无释放?读者的情绪缺口是否在积累或被满足?"; + ? "Check whether the ending renews curiosity, whether promised payoffs are landing on the cadence their hooks imply, whether pressure gets any release, and whether reader expectation gaps are accumulating faster than they are being satisfied." + : "检查:章尾是否重新点燃好奇心,已经承诺的回收是否按伏笔自身节奏落地,压力是否得到释放,读者期待缺口是在持续累积还是在被满足。"; case 33: return language === "en" ? "Cross-check volume_outline: does this chapter match the planned beat for the current chapter range? Did it skip planned nodes or consume later nodes too early? Does actual pacing match the planned chapter span? If a beat planned for N chapters is consumed in 1-2 chapters -> critical." diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index 28c9c6df..4f61449e 100644 --- a/packages/core/src/agents/writer.ts +++ b/packages/core/src/agents/writer.ts @@ -327,6 +327,7 @@ export class WriterAgent extends BaseAgent { ? analyzeHookHealth({ language: resolvedLanguage, chapterNumber, + targetChapters: book.targetChapters, hooks: (runtimeStateArtifacts?.snapshot ?? settlement.runtimeStateSnapshot)!.hooks.hooks, delta: resolvedRuntimeStateDelta, existingHookIds: [...priorHookIds], diff --git a/packages/core/src/utils/hook-health.ts b/packages/core/src/utils/hook-health.ts index 55cdb3be..decac341 100644 --- a/packages/core/src/utils/hook-health.ts +++ b/packages/core/src/utils/hook-health.ts @@ -1,10 +1,12 @@ import type { AuditIssue } from "../agents/continuity.js"; import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js"; import { classifyHookDisposition, collectStaleHookDebt } from "./hook-governance.js"; +import { describeHookLifecycle, localizeHookPayoffTiming } from "./hook-lifecycle.js"; export function analyzeHookHealth(params: { readonly language: "zh" | "en"; readonly chapterNumber: number; + readonly targetChapters?: number; readonly hooks: ReadonlyArray; readonly delta?: Pick; readonly existingHookIds?: ReadonlyArray; @@ -20,6 +22,19 @@ export function analyzeHookHealth(params: { const issues: AuditIssue[] = []; const activeHooks = params.hooks.filter((hook) => hook.status !== "resolved"); + const lifecycleEntries = activeHooks.map((hook) => ({ + hook, + lifecycle: describeHookLifecycle({ + payoffTiming: hook.payoffTiming, + expectedPayoff: hook.expectedPayoff, + notes: hook.notes, + startChapter: hook.startChapter, + lastAdvancedChapter: hook.lastAdvancedChapter, + status: hook.status, + chapterNumber: params.chapterNumber, + targetChapters: params.targetChapters, + }), + })); if (activeHooks.length > maxActiveHooks) { issues.push(warning( @@ -33,45 +48,58 @@ export function analyzeHookHealth(params: { )); } - const latestRealAdvance = activeHooks.reduce( - (max, hook) => Math.max(max, hook.lastAdvancedChapter), - 0, + const staleHookIds = new Set(collectStaleHookDebt({ + hooks: activeHooks, + chapterNumber: params.chapterNumber, + targetChapters: params.targetChapters, + staleAfterChapters, + }).map((hook) => hook.hookId)); + const pressuredHooks = lifecycleEntries.filter(({ hook, lifecycle }) => + staleHookIds.has(hook.hookId) + || lifecycle.readyToResolve + || lifecycle.overdue, ); - if (activeHooks.length > 0 && params.chapterNumber - latestRealAdvance >= noAdvanceWindow) { + const unresolvedPressure = pressuredHooks.filter(({ hook }) => { + if (!params.delta) { + return true; + } + + const disposition = classifyHookDisposition({ + hookId: hook.hookId, + delta: params.delta, + }); + return disposition === "none" || disposition === "mention"; + }); + if (unresolvedPressure.length > 0) { issues.push(warning( params.language, + buildPressureDescription({ + language: params.language, + entries: unresolvedPressure, + mentionsCurrentChapter: Boolean(params.delta), + }), params.language === "en" - ? `No real hook advancement has landed for ${params.chapterNumber - latestRealAdvance} chapters.` - : `已经连续 ${params.chapterNumber - latestRealAdvance} 章没有真实伏笔推进。`, - params.language === "en" - ? "Schedule one old hook for real movement instead of opening parallel restatements." - : "下一章优先让一个旧伏笔发生真实推进,而不是继续平行重述。", + ? "Move one pressured hook with a real payoff, escalation, or explicit defer before opening adjacent debt." + : "先让一个已进入压力区的伏笔发生真实推进、回收或明确延后,再继续扩展同类债务。", )); - } - - const staleHooks = collectStaleHookDebt({ - hooks: activeHooks, - chapterNumber: params.chapterNumber, - staleAfterChapters, - }); - if (params.delta && staleHooks.length > 0) { - const untouchedStale = staleHooks.filter((hook) => { - const disposition = classifyHookDisposition({ - hookId: hook.hookId, - delta: params.delta!, - }); - return disposition === "none" || disposition === "mention"; - }); - - if (untouchedStale.length > 0) { + } else { + const latestRealAdvance = activeHooks.reduce( + (max, hook) => Math.max(max, hook.lastAdvancedChapter), + 0, + ); + if ( + params.noAdvanceWindow !== undefined + && activeHooks.length > 0 + && params.chapterNumber - latestRealAdvance >= noAdvanceWindow + ) { issues.push(warning( params.language, params.language === "en" - ? `Stale hooks received no real disposition this chapter: ${untouchedStale.map((hook) => hook.hookId).join(", ")}.` - : `本章没有真正处理这些陈旧伏笔:${untouchedStale.map((hook) => hook.hookId).join("、")}。`, + ? `No real hook advancement has landed for ${params.chapterNumber - latestRealAdvance} chapters.` + : `已经连续 ${params.chapterNumber - latestRealAdvance} 章没有真实伏笔推进。`, params.language === "en" - ? "Advance, resolve, or explicitly defer at least one stale hook." - : "至少推进、回收或明确延后一个陈旧伏笔。", + ? "Schedule one old hook for real movement instead of opening parallel restatements." + : "下一章优先让一个旧伏笔发生真实推进,而不是继续平行重述。", )); } } @@ -99,6 +127,53 @@ export function analyzeHookHealth(params: { return issues; } +function buildPressureDescription(params: { + readonly language: "zh" | "en"; + readonly entries: ReadonlyArray<{ + readonly hook: HookRecord; + readonly lifecycle: ReturnType; + }>; + readonly mentionsCurrentChapter: boolean; +}): string { + const summarized = params.entries + .slice(0, 3) + .map(({ hook, lifecycle }) => { + const timing = localizeHookPayoffTiming(lifecycle.timing, params.language); + const pressure = localizePressureLabel(lifecycle, params.language); + return params.language === "en" + ? `${hook.hookId} (${timing}, ${pressure})` + : `${hook.hookId}(${timing},${pressure})`; + }); + const suffix = params.entries.length > summarized.length + ? params.language === "en" + ? `, +${params.entries.length - summarized.length} more` + : `,另有 ${params.entries.length - summarized.length} 条` + : ""; + + if (params.language === "en") { + return params.mentionsCurrentChapter + ? `Hooks are already under payoff pressure but this chapter left them untouched: ${summarized.join(", ")}${suffix}.` + : `Hooks are already under payoff pressure without recent movement: ${summarized.join(", ")}${suffix}.`; + } + + return params.mentionsCurrentChapter + ? `这些伏笔已经进入回收/推进压力,但本章没有真正处理:${summarized.join("、")}${suffix}。` + : `这些伏笔已经进入回收/推进压力,但近期没有真实推进:${summarized.join("、")}${suffix}。`; +} + +function localizePressureLabel( + lifecycle: ReturnType, + language: "zh" | "en", +): string { + if (lifecycle.overdue) { + return language === "en" ? "overdue" : "已逾期"; + } + if (lifecycle.readyToResolve) { + return language === "en" ? "ready to pay off" : "可回收"; + } + return language === "en" ? "stale" : "陈旧"; +} + function warning( language: "zh" | "en", description: string, From 198cc9485965071a7a59bfe6109593f4ddb47bac Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 13:08:42 +0800 Subject: [PATCH 24/53] feat(pipeline): make hook agenda pressure-aware --- .../src/__tests__/memory-retrieval.test.ts | 102 ++++++++++ packages/core/src/utils/memory-retrieval.ts | 192 ++++++++++++++++-- 2 files changed, 281 insertions(+), 13 deletions(-) diff --git a/packages/core/src/__tests__/memory-retrieval.test.ts b/packages/core/src/__tests__/memory-retrieval.test.ts index 7619216b..6de2f98d 100644 --- a/packages/core/src/__tests__/memory-retrieval.test.ts +++ b/packages/core/src/__tests__/memory-retrieval.test.ts @@ -1161,4 +1161,106 @@ describe("parsePendingHooksMarkdown", () => { }), ])); }); + + it("expands default resolve coverage when several short-payoff hooks mature together", () => { + const agenda = memoryRetrieval.buildPlannerHookAgenda({ + chapterNumber: 8, + targetChapters: 12, + hooks: [ + { + hookId: "packet-drop", + startChapter: 5, + type: "mystery", + status: "progressing", + lastAdvancedChapter: 7, + expectedPayoff: "Open the dropped packet and expose who planted it", + payoffTiming: "near-term", + notes: "The packet has been foregrounded for two chapters already.", + }, + { + hookId: "seal-crack", + startChapter: 4, + type: "artifact", + status: "progressing", + lastAdvancedChapter: 7, + expectedPayoff: "Reveal what the cracked seal is hiding", + payoffTiming: "immediate", + notes: "The cracked seal should pay off in the current local sequence.", + }, + { + hookId: "witness-turn", + startChapter: 5, + type: "relationship", + status: "progressing", + lastAdvancedChapter: 7, + expectedPayoff: "Force the silent witness to choose a side", + payoffTiming: "near-term", + notes: "The witness line is ready for a concrete turn now.", + }, + ] as never, + } as never); + + expect(agenda.eligibleResolve.length).toBeGreaterThan(1); + expect(agenda.eligibleResolve).toEqual(expect.arrayContaining([ + "packet-drop", + "seal-crack", + ])); + expect( + agenda.pressureMap.filter((entry) => entry.movement === "full-payoff").length, + ).toBeGreaterThan(1); + }); + + it("spreads default must-advance coverage across pressured hook families", () => { + const agenda = memoryRetrieval.buildPlannerHookAgenda({ + chapterNumber: 15, + targetChapters: 30, + hooks: [ + { + hookId: "mentor-oath-a", + startChapter: 1, + type: "relationship", + status: "open", + lastAdvancedChapter: 2, + expectedPayoff: "Explain the first layer of the mentor oath debt", + payoffTiming: "mid-arc", + notes: "Old relationship debt keeps surfacing without movement.", + }, + { + hookId: "mentor-oath-b", + startChapter: 2, + type: "relationship", + status: "open", + lastAdvancedChapter: 3, + expectedPayoff: "Show what the second oath witness is hiding", + payoffTiming: "mid-arc", + notes: "Another branch of the same relationship family is also stale.", + }, + { + hookId: "mentor-oath-c", + startChapter: 3, + type: "relationship", + status: "open", + lastAdvancedChapter: 4, + expectedPayoff: "Reveal why the oath cannot be spoken aloud", + payoffTiming: "mid-arc", + notes: "The third relationship branch is still hanging.", + }, + { + hookId: "kiln-key", + startChapter: 4, + type: "artifact", + status: "open", + lastAdvancedChapter: 5, + expectedPayoff: "Show what the kiln key unlocks", + payoffTiming: "mid-arc", + notes: "Artifact debt is also stale and should not vanish behind relationship debt.", + }, + ] as never, + } as never); + + expect(agenda.mustAdvance).toContain("kiln-key"); + expect(agenda.mustAdvance).toEqual(expect.arrayContaining([ + expect.stringMatching(/^mentor-oath-/), + ])); + }); }); diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index ee8a419a..bc6df45c 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -245,7 +245,8 @@ export function buildPlannerHookAgenda(params: { targetChapters: params.targetChapters, }), })); - const staleDebtHooks = lifecycleEntries + const agendaLoad = resolveHookAgendaLoad(lifecycleEntries); + const staleDebtCandidates = lifecycleEntries .filter((entry) => entry.lifecycle.stale) .sort((left, right) => ( Number(right.lifecycle.overdue) - Number(left.lifecycle.overdue) @@ -253,10 +254,18 @@ export function buildPlannerHookAgenda(params: { || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || left.hook.startChapter - right.hook.startChapter || left.hook.hookId.localeCompare(right.hook.hookId) - )) - .slice(0, params.maxStaleDebt ?? 2) - .map((entry) => entry.hook); - const mustAdvanceHooks = lifecycleEntries + )); + const staleDebtHooks = selectAgendaHooksWithTypeSpread({ + entries: staleDebtCandidates, + limit: resolveAgendaLimit({ + explicitLimit: params.maxStaleDebt, + candidateCount: staleDebtCandidates.length, + fallbackLimit: ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].staleDebt, + }), + forceInclude: (entry) => entry.lifecycle.overdue, + }).map((entry) => entry.hook); + const mustAdvancePool = lifecycleEntries.filter((entry) => isMustAdvanceCandidate(entry.lifecycle)); + const mustAdvanceCandidates = (mustAdvancePool.length > 0 ? mustAdvancePool : lifecycleEntries) .slice() .sort((left, right) => ( Number(right.lifecycle.stale) - Number(left.lifecycle.stale) @@ -264,24 +273,38 @@ export function buildPlannerHookAgenda(params: { || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || left.hook.startChapter - right.hook.startChapter || left.hook.hookId.localeCompare(right.hook.hookId) - )) - .slice(0, params.maxMustAdvance ?? 2) - .map((entry) => entry.hook); - const eligibleResolveHooks = lifecycleEntries + )); + const mustAdvanceHooks = selectAgendaHooksWithTypeSpread({ + entries: mustAdvanceCandidates, + limit: resolveAgendaLimit({ + explicitLimit: params.maxMustAdvance, + candidateCount: mustAdvanceCandidates.length, + fallbackLimit: ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].mustAdvance, + }), + forceInclude: (entry) => entry.lifecycle.overdue, + }).map((entry) => entry.hook); + const eligibleResolveCandidates = lifecycleEntries .filter((entry) => entry.lifecycle.readyToResolve) .sort((left, right) => ( right.lifecycle.resolvePressure - left.lifecycle.resolvePressure || Number(right.lifecycle.stale) - Number(left.lifecycle.stale) || left.hook.startChapter - right.hook.startChapter || left.hook.hookId.localeCompare(right.hook.hookId) - )) - .slice(0, params.maxEligibleResolve ?? 1) - .map((entry) => entry.hook); + )); + const eligibleResolveHooks = selectAgendaHooksWithTypeSpread({ + entries: eligibleResolveCandidates, + limit: resolveAgendaLimit({ + explicitLimit: params.maxEligibleResolve, + candidateCount: eligibleResolveCandidates.length, + fallbackLimit: ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].eligibleResolve, + }), + forceInclude: (entry) => entry.lifecycle.overdue || entry.lifecycle.resolvePressure >= 40, + }).map((entry) => entry.hook); const avoidNewHookFamilies = [...new Set([ ...staleDebtHooks.map((hook) => hook.type.trim()).filter(Boolean), ...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean), ...eligibleResolveHooks.map((hook) => hook.type.trim()).filter(Boolean), - ])].slice(0, 3); + ])].slice(0, ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].avoidFamilies); const pressureMap = buildHookPressureMap({ lifecycleEntries, mustAdvanceHooks, @@ -298,6 +321,149 @@ export function buildPlannerHookAgenda(params: { }; } +type HookAgendaLoad = "light" | "medium" | "heavy"; + +const ADAPTIVE_HOOK_AGENDA_LIMITS: Record = { + light: { + staleDebt: 1, + mustAdvance: 2, + eligibleResolve: 1, + avoidFamilies: 2, + }, + medium: { + staleDebt: 2, + mustAdvance: 2, + eligibleResolve: 1, + avoidFamilies: 3, + }, + heavy: { + staleDebt: 3, + mustAdvance: 3, + eligibleResolve: 2, + avoidFamilies: 4, + }, +}; + +function resolveHookAgendaLoad(entries: ReadonlyArray<{ + readonly hook: ReturnType; + readonly lifecycle: ReturnType; +}>): HookAgendaLoad { + const pressuredEntries = entries.filter((entry) => + entry.lifecycle.readyToResolve + || entry.lifecycle.stale + || entry.lifecycle.overdue, + ); + const staleCount = pressuredEntries.filter((entry) => entry.lifecycle.stale).length; + const readyCount = pressuredEntries.filter((entry) => entry.lifecycle.readyToResolve).length; + const criticalCount = pressuredEntries.filter((entry) => + entry.lifecycle.overdue || entry.lifecycle.resolvePressure >= 40, + ).length; + const pressuredFamilies = new Set( + pressuredEntries.map((entry) => normalizeHookType(entry.hook.type)), + ).size; + + if (readyCount >= 3 || staleCount >= 4 || criticalCount >= 3 || pressuredEntries.length >= 6) { + return "heavy"; + } + if (readyCount >= 2 || staleCount >= 2 || criticalCount >= 1 || pressuredFamilies >= 3) { + return "medium"; + } + return "light"; +} + +function resolveAgendaLimit(params: { + readonly explicitLimit?: number; + readonly candidateCount: number; + readonly fallbackLimit: number; +}): number { + if (params.candidateCount <= 0) { + return 0; + } + + const limit = params.explicitLimit ?? params.fallbackLimit; + return Math.max(1, Math.min(limit, params.candidateCount)); +} + +function selectAgendaHooksWithTypeSpread; + readonly lifecycle: ReturnType; +}>(params: { + readonly entries: ReadonlyArray; + readonly limit: number; + readonly forceInclude?: (entry: T) => boolean; +}): T[] { + if (params.limit <= 0 || params.entries.length === 0) { + return []; + } + + const selected: T[] = []; + const selectedIds = new Set(); + const selectedTypes = new Set(); + const forcedEntries = params.entries.filter((entry) => params.forceInclude?.(entry) ?? false); + const addEntry = (entry: T): void => { + if (selectedIds.has(entry.hook.hookId) || selected.length >= params.limit) { + return; + } + selected.push(entry); + selectedIds.add(entry.hook.hookId); + selectedTypes.add(normalizeHookType(entry.hook.type)); + }; + + for (const entry of forcedEntries) { + if (selected.length >= params.limit) { + break; + } + const normalizedType = normalizeHookType(entry.hook.type); + if (!selectedTypes.has(normalizedType)) { + addEntry(entry); + } + } + + for (const entry of forcedEntries) { + addEntry(entry); + } + + for (const entry of params.entries) { + if (selected.length >= params.limit) { + break; + } + if (selectedIds.has(entry.hook.hookId)) { + continue; + } + const normalizedType = normalizeHookType(entry.hook.type); + if (!selectedTypes.has(normalizedType)) { + addEntry(entry); + } + } + + for (const entry of params.entries) { + if (selected.length >= params.limit) { + break; + } + addEntry(entry); + } + + return selected; +} + +function normalizeHookType(type: string): string { + return type.trim().toLowerCase() || "hook"; +} + +function isMustAdvanceCandidate( + lifecycle: ReturnType, +): boolean { + return lifecycle.stale + || lifecycle.readyToResolve + || lifecycle.overdue + || lifecycle.advancePressure >= 8; +} + function buildHookPressureMap(params: { readonly lifecycleEntries: ReadonlyArray<{ readonly hook: ReturnType; From 3f8528798fd76b823f98b7a149d4a09f669e0f8a Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 13:15:38 +0800 Subject: [PATCH 25/53] feat(pipeline): make hook retrieval lifecycle-aware --- .../src/__tests__/memory-retrieval.test.ts | 102 ++++++++++++++++ packages/core/src/utils/memory-retrieval.ts | 113 +++++++++++++++--- 2 files changed, 196 insertions(+), 19 deletions(-) diff --git a/packages/core/src/__tests__/memory-retrieval.test.ts b/packages/core/src/__tests__/memory-retrieval.test.ts index 6de2f98d..98b28cf1 100644 --- a/packages/core/src/__tests__/memory-retrieval.test.ts +++ b/packages/core/src/__tests__/memory-retrieval.test.ts @@ -896,6 +896,108 @@ describe("retrieveMemorySelection", () => { expect(result.hooks.map((hook) => hook.hookId)).not.toContain("stale-resolved"); }); + it("surfaces multiple stale hook families when debt pressure clusters instead of only one stale extra", async () => { + root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-stale-cluster-test-")); + const bookDir = join(root, "book"); + const storyDir = join(bookDir, "story"); + const stateDir = join(storyDir, "state"); + await mkdir(stateDir, { recursive: true }); + + await Promise.all([ + writeFile( + join(stateDir, "manifest.json"), + JSON.stringify({ + schemaVersion: 2, + language: "en", + lastAppliedChapter: 50, + projectionVersion: 1, + migrationWarnings: [], + }, null, 2), + "utf-8", + ), + writeFile( + join(stateDir, "current_state.json"), + JSON.stringify({ + chapter: 50, + facts: [], + }, null, 2), + "utf-8", + ), + writeFile( + join(stateDir, "chapter_summaries.json"), + JSON.stringify({ + rows: [], + }, null, 2), + "utf-8", + ), + writeFile( + join(stateDir, "hooks.json"), + JSON.stringify({ + hooks: [ + { + hookId: "recent-route", + startChapter: 47, + type: "route", + status: "open", + lastAdvancedChapter: 49, + expectedPayoff: "Recent route payoff", + notes: "Recent route remains active.", + }, + { + hookId: "recent-guild", + startChapter: 46, + type: "politics", + status: "progressing", + lastAdvancedChapter: 48, + expectedPayoff: "Guild payoff", + notes: "Recent guild pressure remains active.", + }, + { + hookId: "recent-token", + startChapter: 45, + type: "artifact", + status: "open", + lastAdvancedChapter: 47, + expectedPayoff: "Token payoff", + notes: "Recent token route remains active.", + }, + { + hookId: "stale-omega", + startChapter: 6, + type: "relationship", + status: "open", + lastAdvancedChapter: 12, + expectedPayoff: "Old relic payoff", + notes: "Dormant unresolved relationship line.", + }, + { + hookId: "stale-sable", + startChapter: 8, + type: "mystery", + status: "open", + lastAdvancedChapter: 14, + expectedPayoff: "Archive payoff", + notes: "Dormant unresolved mystery line.", + }, + ], + }, null, 2), + "utf-8", + ), + ]); + + const result = await retrieveMemorySelection({ + bookDir, + chapterNumber: 51, + goal: "Keep the chapter on the debt cluster and route pressure together.", + mustKeep: ["The old debt cluster must stay legible."], + }); + + expect(result.hooks.map((hook) => hook.hookId)).toEqual(expect.arrayContaining([ + "stale-omega", + "stale-sable", + ])); + }); + it("does not surface far-future unstarted hooks in early chapter retrieval", async () => { root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-future-hook-gate-test-")); const bookDir = join(root, "book"); diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index bc6df45c..eea31864 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -390,7 +390,10 @@ function resolveAgendaLimit(params: { } function selectAgendaHooksWithTypeSpread; + readonly hook: { + readonly hookId: string; + readonly type: string; + }; readonly lifecycle: ReturnType; }>(params: { readonly entries: ReadonlyArray; @@ -455,6 +458,65 @@ function normalizeHookType(type: string): string { return type.trim().toLowerCase() || "hook"; } +function resolveRelevantHookPrimaryLimit(entries: ReadonlyArray<{ + readonly hook: { + readonly type: string; + }; + readonly lifecycle: ReturnType; +}>): number { + const pressuredCount = entries.filter((entry) => + entry.lifecycle.readyToResolve + || entry.lifecycle.stale + || entry.lifecycle.overdue, + ).length; + return pressuredCount >= 4 ? 4 : 3; +} + +function resolveRelevantHookStaleLimit( + entries: ReadonlyArray<{ + readonly hook: { + readonly hookId: string; + readonly type: string; + }; + readonly lifecycle: ReturnType; + }>, + selectedIds: ReadonlySet, +): number { + const staleCandidates = entries.filter((entry) => + !selectedIds.has(entry.hook.hookId) + && (entry.lifecycle.stale || entry.lifecycle.overdue), + ); + if (staleCandidates.length === 0) { + return 0; + } + + const staleFamilies = new Set( + staleCandidates.map((entry) => normalizeHookType(entry.hook.type)), + ).size; + const overdueCount = staleCandidates.filter((entry) => entry.lifecycle.overdue).length; + if (overdueCount >= 2 || staleFamilies >= 2) { + return Math.min(2, staleCandidates.length); + } + + return 1; +} + +function isHookWithinLifecycleWindow( + hook: StoredHook, + chapterNumber: number, + lifecycle: ReturnType, +): boolean { + const recentWindow = lifecycle.timing === "endgame" + ? 10 + : lifecycle.timing === "slow-burn" + ? 8 + : lifecycle.timing === "mid-arc" + ? 6 + : 5; + + return isHookWithinChapterWindow(hook, chapterNumber, recentWindow); +} + function isMustAdvanceCandidate( lifecycle: ReturnType, ): boolean { @@ -890,6 +952,15 @@ function selectRelevantHooks( const ranked = hooks .map((hook) => ({ hook, + lifecycle: describeHookLifecycle({ + payoffTiming: hook.payoffTiming, + expectedPayoff: hook.expectedPayoff, + notes: hook.notes, + startChapter: Math.max(0, hook.startChapter), + lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter), + status: hook.status, + chapterNumber, + }), score: scoreHook(hook, queryTerms, chapterNumber), matched: matchesAny( [hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" "), @@ -898,26 +969,30 @@ function selectRelevantHooks( })) .filter((entry) => entry.matched || isUnresolvedHook(entry.hook.status)); - const recentCutoff = Math.max(0, chapterNumber - 5); - const staleCutoff = Math.max(0, chapterNumber - 10); - const primary = ranked - .filter((entry) => ( - entry.matched - || isHookWithinChapterWindow(entry.hook, chapterNumber, 5) - )) - .sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter) - .slice(0, 3); + const primary = selectAgendaHooksWithTypeSpread({ + entries: ranked + .filter((entry) => ( + entry.matched + || isHookWithinLifecycleWindow(entry.hook, chapterNumber, entry.lifecycle) + )) + .sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter), + limit: resolveRelevantHookPrimaryLimit(ranked), + forceInclude: (entry) => entry.matched && entry.lifecycle.overdue, + }); const selectedIds = new Set(primary.map((entry) => entry.hook.hookId)); - const stale = ranked - .filter((entry) => ( - !selectedIds.has(entry.hook.hookId) - && !isFuturePlannedHook(entry.hook, chapterNumber) - && entry.hook.lastAdvancedChapter <= staleCutoff - && isUnresolvedHook(entry.hook.status) - )) - .sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score) - .slice(0, 1); + const stale = selectAgendaHooksWithTypeSpread({ + entries: ranked + .filter((entry) => ( + !selectedIds.has(entry.hook.hookId) + && !isFuturePlannedHook(entry.hook, chapterNumber) + && (entry.lifecycle.stale || entry.lifecycle.overdue) + && isUnresolvedHook(entry.hook.status) + )) + .sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score), + limit: resolveRelevantHookStaleLimit(ranked, selectedIds), + forceInclude: (entry) => entry.lifecycle.overdue, + }); return [...primary, ...stale].map((entry) => entry.hook); } From ba7f7c5f08d9019c8c4d4c87092646310f26bf32 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 13:24:55 +0800 Subject: [PATCH 26/53] refactor(pipeline): unify legacy context windows --- .../core/src/__tests__/context-filter.test.ts | 25 ++++++++++++++++-- .../__tests__/governed-working-set.test.ts | 26 +++++++++++++++++-- packages/core/src/utils/chapter-cadence.ts | 4 ++- packages/core/src/utils/context-filter.ts | 14 ++++++++-- .../core/src/utils/governed-working-set.ts | 20 +++++++++++++- packages/core/src/utils/memory-retrieval.ts | 2 +- 6 files changed, 82 insertions(+), 9 deletions(-) diff --git a/packages/core/src/__tests__/context-filter.test.ts b/packages/core/src/__tests__/context-filter.test.ts index 6326ff93..b5c62e3a 100644 --- a/packages/core/src/__tests__/context-filter.test.ts +++ b/packages/core/src/__tests__/context-filter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { filterSummaries } from "../utils/context-filter.js"; +import { filterEmotionalArcs, filterSummaries } from "../utils/context-filter.js"; describe("context-filter", () => { it("filters old chapter summary rows even when titles start with 'Chapter'", () => { @@ -8,13 +8,34 @@ describe("context-filter", () => { "", "| 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 |", + "| 98 | Chapter 98 | Lin Yue | New event | state-98 | side-quest-98 | 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).not.toContain("| 97 | Chapter 97 |"); + expect(filtered).toContain("| 98 | Chapter 98 |"); expect(filtered).toContain("| 100 | Chapter 100 |"); }); + + it("keeps only the cadence-sized recent emotional arc rows by default", () => { + const arcs = [ + "# Emotional Arcs", + "", + "| Character | Chapter | Emotional State | Trigger Event | Intensity (1-10) | Arc Direction |", + "| --- | --- | --- | --- | --- | --- |", + "| Lin Yue | 97 | guarded | old wound | 4 | holding |", + "| Lin Yue | 98 | tense | harbor clue | 6 | rising |", + "| Lin Yue | 99 | strained | mentor echo | 7 | tightening |", + "| Lin Yue | 100 | brittle | oath pressure | 8 | compressing |", + ].join("\n"); + + const filtered = filterEmotionalArcs(arcs, 101); + + expect(filtered).not.toContain("| Lin Yue | 97 |"); + expect(filtered).toContain("| Lin Yue | 98 |"); + expect(filtered).toContain("| Lin Yue | 100 |"); + }); }); diff --git a/packages/core/src/__tests__/governed-working-set.test.ts b/packages/core/src/__tests__/governed-working-set.test.ts index ecbf021e..8b6ab729 100644 --- a/packages/core/src/__tests__/governed-working-set.test.ts +++ b/packages/core/src/__tests__/governed-working-set.test.ts @@ -31,8 +31,8 @@ describe("governed-working-set", () => { language: "zh", }); - expect(filtered).toContain("| opening-call | 1 | mystery | open | 0 | 8 | 匿名来电开篇出现 |"); - expect(filtered).toContain("| nearby-ledger | 4 | evidence | open | 0 | 12 | 近期开启的账本线 |"); + expect(filtered).toContain("opening-call"); + expect(filtered).toContain("nearby-ledger"); expect(filtered).not.toContain("future-pr-machine"); expect(filtered).not.toContain("future-template"); }); @@ -83,6 +83,28 @@ describe("governed-working-set", () => { expect(filtered).not.toContain("future-pr-machine"); }); + it("keeps slow-burn hooks in the governed working set when they are still within lifecycle visibility", () => { + const hooks = [ + "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + "| river-oath | 8 | relationship | progressing | 13 | Reveal why the river oath was broken | slow-burn | Long debt should stay visible through the middle game |", + "| future-pr-machine | 45 | system | open | 0 | Future hook should stay hidden | endgame | Future hook should stay hidden |", + ].join("\n"); + + const filtered = buildGovernedHookWorkingSet({ + hooksMarkdown: hooks, + contextPackage: { + chapter: 20, + selectedContext: [], + }, + chapterNumber: 20, + language: "en", + }); + + expect(filtered).toContain("river-oath"); + expect(filtered).not.toContain("future-pr-machine"); + }); + it("filters character matrix by exact governed character mentions instead of broad capitalized tokens", () => { const matrix = [ "# Character Matrix", diff --git a/packages/core/src/utils/chapter-cadence.ts b/packages/core/src/utils/chapter-cadence.ts index b179ed93..41bfd2be 100644 --- a/packages/core/src/utils/chapter-cadence.ts +++ b/packages/core/src/utils/chapter-cadence.ts @@ -30,6 +30,8 @@ export interface ChapterCadenceAnalysis { readonly titlePressure?: TitleCadencePressure; } +export const DEFAULT_CHAPTER_CADENCE_WINDOW = 4; + const HIGH_TENSION_KEYWORDS = [ "紧张", "冷硬", "压抑", "逼仄", "肃杀", "沉重", "凝重", "冷峻", "压迫", "阴沉", "焦灼", "窒息", "凛冽", "锋利", @@ -49,7 +51,7 @@ export function analyzeChapterCadence(params: { }): ChapterCadenceAnalysis { const recentRows = [...params.rows] .sort((left, right) => left.chapter - right.chapter) - .slice(-4); + .slice(-DEFAULT_CHAPTER_CADENCE_WINDOW); return { scenePressure: analyzeScenePressure(recentRows), diff --git a/packages/core/src/utils/context-filter.ts b/packages/core/src/utils/context-filter.ts index 93ed70fb..dcafcf9d 100644 --- a/packages/core/src/utils/context-filter.ts +++ b/packages/core/src/utils/context-filter.ts @@ -5,6 +5,8 @@ * Every filter falls back to the full input if filtering would empty it. */ +import { DEFAULT_CHAPTER_CADENCE_WINDOW } from "./chapter-cadence.js"; + /** Filter pending_hooks: remove resolved/closed hooks. */ export function filterHooks(hooks: string): string { if (!hooks || hooks === "(文件尚未创建)") return hooks; @@ -15,7 +17,11 @@ export function filterHooks(hooks: string): string { } /** Filter chapter_summaries: keep only the most recent N chapters. */ -export function filterSummaries(summaries: string, currentChapter: number, keepRecent = 5): string { +export function filterSummaries( + summaries: string, + currentChapter: number, + keepRecent = DEFAULT_CHAPTER_CADENCE_WINDOW, +): string { if (!summaries || summaries === "(文件尚未创建)") return summaries; return filterTableRows(summaries, (row) => { const match = row.match(/\|\s*(\d+)\s*\|/); @@ -34,7 +40,11 @@ export function filterSubplots(board: string): string { } /** Filter emotional_arcs: keep only the most recent N chapters. */ -export function filterEmotionalArcs(arcs: string, currentChapter: number, keepRecent = 5): string { +export function filterEmotionalArcs( + arcs: string, + currentChapter: number, + keepRecent = DEFAULT_CHAPTER_CADENCE_WINDOW, +): string { if (!arcs || arcs === "(文件尚未创建)") return arcs; return filterTableRows(arcs, (row) => { const match = row.match(/\|\s*(\d+)\s*\|/); diff --git a/packages/core/src/utils/governed-working-set.ts b/packages/core/src/utils/governed-working-set.ts index 247076cd..8a1d638d 100644 --- a/packages/core/src/utils/governed-working-set.ts +++ b/packages/core/src/utils/governed-working-set.ts @@ -1,9 +1,11 @@ import type { ContextPackage } from "../models/input-governance.js"; import { isHookWithinChapterWindow, + isHookWithinLifecycleWindow, parsePendingHooksMarkdown, renderHookSnapshot, } from "./memory-retrieval.js"; +import { describeHookLifecycle } from "./hook-lifecycle.js"; export function buildGovernedHookWorkingSet(params: { readonly hooksMarkdown: string; @@ -33,7 +35,23 @@ export function buildGovernedHookWorkingSet(params: { const workingSet = hooks.filter((hook) => selectedIds.has(hook.hookId) || agendaIds.has(hook.hookId) - || isHookWithinChapterWindow(hook, params.chapterNumber, params.keepRecent ?? 5), + || ( + params.keepRecent !== undefined + ? isHookWithinChapterWindow(hook, params.chapterNumber, params.keepRecent) + : isHookWithinLifecycleWindow( + hook, + params.chapterNumber, + describeHookLifecycle({ + payoffTiming: hook.payoffTiming, + expectedPayoff: hook.expectedPayoff, + notes: hook.notes, + startChapter: Math.max(0, hook.startChapter), + lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter), + status: hook.status, + chapterNumber: params.chapterNumber, + }), + ) + ), ); if (workingSet.length === 0 || workingSet.length >= hooks.length) { diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index eea31864..5ab93ed4 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -501,7 +501,7 @@ function resolveRelevantHookStaleLimit( return 1; } -function isHookWithinLifecycleWindow( +export function isHookWithinLifecycleWindow( hook: StoredHook, chapterNumber: number, lifecycle: ReturnType, From 2b911816c6f44648fdc602860c434408336bd468 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 13:38:40 +0800 Subject: [PATCH 27/53] refactor(pipeline): centralize hook policy constants --- .../core/src/__tests__/hook-policy.test.ts | 33 ++++ packages/core/src/utils/hook-health.ts | 9 +- packages/core/src/utils/hook-lifecycle.ts | 86 +++-------- packages/core/src/utils/hook-policy.ts | 145 ++++++++++++++++++ packages/core/src/utils/memory-retrieval.ts | 95 ++++++------ 5 files changed, 252 insertions(+), 116 deletions(-) create mode 100644 packages/core/src/__tests__/hook-policy.test.ts create mode 100644 packages/core/src/utils/hook-policy.ts diff --git a/packages/core/src/__tests__/hook-policy.test.ts b/packages/core/src/__tests__/hook-policy.test.ts new file mode 100644 index 00000000..313c954e --- /dev/null +++ b/packages/core/src/__tests__/hook-policy.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + HOOK_AGENDA_LIMITS, + HOOK_HEALTH_DEFAULTS, + HOOK_PHASE_THRESHOLDS, + HOOK_PRESSURE_WEIGHTS, + HOOK_TIMING_PROFILES, + resolveHookVisibilityWindow, +} from "../utils/hook-policy.js"; + +describe("hook-policy", () => { + it("exposes shared lifecycle timing profiles from a single policy surface", () => { + expect(HOOK_TIMING_PROFILES.immediate.earliestResolveAge).toBe(1); + expect(HOOK_TIMING_PROFILES["slow-burn"].minimumPhase).toBe("middle"); + expect(HOOK_TIMING_PROFILES.endgame.overdueAge).toBeGreaterThan( + HOOK_TIMING_PROFILES["near-term"].overdueAge, + ); + }); + + it("widens visibility windows for longer-payoff hooks", () => { + expect(resolveHookVisibilityWindow("immediate")).toBe(5); + expect(resolveHookVisibilityWindow("mid-arc")).toBeGreaterThan(resolveHookVisibilityWindow("near-term")); + expect(resolveHookVisibilityWindow("endgame")).toBeGreaterThan(resolveHookVisibilityWindow("slow-burn")); + }); + + it("keeps shared agenda, phase, pressure, and health defaults together", () => { + expect(HOOK_AGENDA_LIMITS.light.mustAdvance).toBeLessThan(HOOK_AGENDA_LIMITS.heavy.mustAdvance); + expect(HOOK_AGENDA_LIMITS.heavy.eligibleResolve).toBeGreaterThan(HOOK_AGENDA_LIMITS.light.eligibleResolve); + expect(HOOK_HEALTH_DEFAULTS.maxActiveHooks).toBe(12); + expect(HOOK_PHASE_THRESHOLDS.lateProgress).toBeGreaterThan(HOOK_PHASE_THRESHOLDS.middleProgress); + expect(HOOK_PRESSURE_WEIGHTS.resolveBiasMultiplier).toBe(10); + }); +}); diff --git a/packages/core/src/utils/hook-health.ts b/packages/core/src/utils/hook-health.ts index decac341..1fe3924d 100644 --- a/packages/core/src/utils/hook-health.ts +++ b/packages/core/src/utils/hook-health.ts @@ -2,6 +2,7 @@ import type { AuditIssue } from "../agents/continuity.js"; import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js"; import { classifyHookDisposition, collectStaleHookDebt } from "./hook-governance.js"; import { describeHookLifecycle, localizeHookPayoffTiming } from "./hook-lifecycle.js"; +import { HOOK_HEALTH_DEFAULTS } from "./hook-policy.js"; export function analyzeHookHealth(params: { readonly language: "zh" | "en"; @@ -15,10 +16,10 @@ export function analyzeHookHealth(params: { readonly noAdvanceWindow?: number; readonly newHookBurstThreshold?: number; }): AuditIssue[] { - const maxActiveHooks = params.maxActiveHooks ?? 12; - const staleAfterChapters = params.staleAfterChapters ?? 10; - const noAdvanceWindow = params.noAdvanceWindow ?? 5; - const newHookBurstThreshold = params.newHookBurstThreshold ?? 2; + const maxActiveHooks = params.maxActiveHooks ?? HOOK_HEALTH_DEFAULTS.maxActiveHooks; + const staleAfterChapters = params.staleAfterChapters ?? HOOK_HEALTH_DEFAULTS.staleAfterChapters; + const noAdvanceWindow = params.noAdvanceWindow ?? HOOK_HEALTH_DEFAULTS.noAdvanceWindow; + const newHookBurstThreshold = params.newHookBurstThreshold ?? HOOK_HEALTH_DEFAULTS.newHookBurstThreshold; const issues: AuditIssue[] = []; const activeHooks = params.hooks.filter((hook) => hook.status !== "resolved"); diff --git a/packages/core/src/utils/hook-lifecycle.ts b/packages/core/src/utils/hook-lifecycle.ts index db98fbf4..c5688d52 100644 --- a/packages/core/src/utils/hook-lifecycle.ts +++ b/packages/core/src/utils/hook-lifecycle.ts @@ -1,58 +1,11 @@ import type { HookPayoffTiming } from "../models/runtime-state.js"; - -type HookPhase = "opening" | "middle" | "late"; - -interface LifecycleProfile { - readonly earliestResolveAge: number; - readonly staleDormancy: number; - readonly overdueAge: number; - readonly minimumPhase: HookPhase; - readonly resolveBias: number; -} - -const TIMING_PROFILES: Record = { - immediate: { - earliestResolveAge: 1, - staleDormancy: 1, - overdueAge: 3, - minimumPhase: "opening", - resolveBias: 5, - }, - "near-term": { - earliestResolveAge: 1, - staleDormancy: 2, - overdueAge: 5, - minimumPhase: "opening", - resolveBias: 4, - }, - "mid-arc": { - earliestResolveAge: 2, - staleDormancy: 4, - overdueAge: 8, - minimumPhase: "opening", - resolveBias: 3, - }, - "slow-burn": { - earliestResolveAge: 4, - staleDormancy: 5, - overdueAge: 12, - minimumPhase: "middle", - resolveBias: 2, - }, - endgame: { - earliestResolveAge: 6, - staleDormancy: 6, - overdueAge: 16, - minimumPhase: "late", - resolveBias: 1, - }, -}; - -const PHASE_WEIGHT: Record = { - opening: 0, - middle: 1, - late: 2, -}; +import { + HOOK_PHASE_THRESHOLDS, + HOOK_PHASE_WEIGHT, + HOOK_PRESSURE_WEIGHTS, + HOOK_TIMING_PROFILES, + type HookPhase, +} from "./hook-policy.js"; const LABELS: Record<"zh" | "en", Record> = { en: { @@ -159,13 +112,13 @@ export function describeHookLifecycle(params: { readonly resolvePressure: number; } { const timing = resolveHookPayoffTiming(params); - const profile = TIMING_PROFILES[timing]; + const profile = HOOK_TIMING_PROFILES[timing]; const phase = resolveHookPhase(params.chapterNumber, params.targetChapters); const age = Math.max(0, params.chapterNumber - Math.max(1, params.startChapter)); const lastTouchChapter = Math.max(params.startChapter, params.lastAdvancedChapter); const dormancy = Math.max(0, params.chapterNumber - Math.max(1, lastTouchChapter)); const explicitProgressing = /^(progressing|advanced|重大推进|持续推进)$/i.test(params.status.trim()); - const phaseReady = PHASE_WEIGHT[phase] >= PHASE_WEIGHT[profile.minimumPhase]; + const phaseReady = HOOK_PHASE_WEIGHT[phase] >= HOOK_PHASE_WEIGHT[profile.minimumPhase]; const recentlyTouched = dormancy <= 1; const overdue = phaseReady && age >= profile.overdueAge; const cadenceReady = timing === "slow-burn" @@ -191,9 +144,18 @@ export function describeHookLifecycle(params: { readyToResolve, stale, overdue, - advancePressure: age + dormancy + (stale ? 8 : 0) + (overdue ? 6 : 0), + advancePressure: age + + dormancy + + (stale ? HOOK_PRESSURE_WEIGHTS.staleAdvanceBonus : 0) + + (overdue ? HOOK_PRESSURE_WEIGHTS.overdueAdvanceBonus : 0), resolvePressure: readyToResolve - ? profile.resolveBias * 10 + (explicitProgressing ? 5 : 0) + Math.min(12, dormancy * 2) + (overdue ? 10 : 0) + ? profile.resolveBias * HOOK_PRESSURE_WEIGHTS.resolveBiasMultiplier + + (explicitProgressing ? HOOK_PRESSURE_WEIGHTS.progressingResolveBonus : 0) + + Math.min( + HOOK_PRESSURE_WEIGHTS.maxDormancyResolveBonus, + dormancy * HOOK_PRESSURE_WEIGHTS.dormancyResolveMultiplier, + ) + + (overdue ? HOOK_PRESSURE_WEIGHTS.overdueResolveBonus : 0) : 0, }; } @@ -201,12 +163,12 @@ export function describeHookLifecycle(params: { function resolveHookPhase(chapterNumber: number, targetChapters?: number): HookPhase { if (targetChapters && targetChapters > 0) { const progress = chapterNumber / targetChapters; - if (progress >= 0.72) return "late"; - if (progress >= 0.33) return "middle"; + if (progress >= HOOK_PHASE_THRESHOLDS.lateProgress) return "late"; + if (progress >= HOOK_PHASE_THRESHOLDS.middleProgress) return "middle"; return "opening"; } - if (chapterNumber >= 24) return "late"; - if (chapterNumber >= 8) return "middle"; + if (chapterNumber >= HOOK_PHASE_THRESHOLDS.lateChapter) return "late"; + if (chapterNumber >= HOOK_PHASE_THRESHOLDS.middleChapter) return "middle"; return "opening"; } diff --git a/packages/core/src/utils/hook-policy.ts b/packages/core/src/utils/hook-policy.ts new file mode 100644 index 00000000..f7d9d828 --- /dev/null +++ b/packages/core/src/utils/hook-policy.ts @@ -0,0 +1,145 @@ +import type { HookPayoffTiming } from "../models/runtime-state.js"; + +export type HookPhase = "opening" | "middle" | "late"; +export type HookAgendaLoad = "light" | "medium" | "heavy"; + +export interface HookLifecycleProfile { + readonly earliestResolveAge: number; + readonly staleDormancy: number; + readonly overdueAge: number; + readonly minimumPhase: HookPhase; + readonly resolveBias: number; +} + +export const HOOK_TIMING_PROFILES: Record = { + immediate: { + earliestResolveAge: 1, + staleDormancy: 1, + overdueAge: 3, + minimumPhase: "opening", + resolveBias: 5, + }, + "near-term": { + earliestResolveAge: 1, + staleDormancy: 2, + overdueAge: 5, + minimumPhase: "opening", + resolveBias: 4, + }, + "mid-arc": { + earliestResolveAge: 2, + staleDormancy: 4, + overdueAge: 8, + minimumPhase: "opening", + resolveBias: 3, + }, + "slow-burn": { + earliestResolveAge: 4, + staleDormancy: 5, + overdueAge: 12, + minimumPhase: "middle", + resolveBias: 2, + }, + endgame: { + earliestResolveAge: 6, + staleDormancy: 6, + overdueAge: 16, + minimumPhase: "late", + resolveBias: 1, + }, +}; + +export const HOOK_PHASE_WEIGHT: Record = { + opening: 0, + middle: 1, + late: 2, +}; + +export const HOOK_PHASE_THRESHOLDS = { + middleProgress: 0.33, + lateProgress: 0.72, + middleChapter: 8, + lateChapter: 24, +} as const; + +export const HOOK_PRESSURE_WEIGHTS = { + staleAdvanceBonus: 8, + overdueAdvanceBonus: 6, + resolveBiasMultiplier: 10, + progressingResolveBonus: 5, + dormancyResolveMultiplier: 2, + maxDormancyResolveBonus: 12, + overdueResolveBonus: 10, + mustAdvancePressureFloor: 8, + criticalResolvePressure: 40, +} as const; + +export const HOOK_AGENDA_LIMITS: Record = { + light: { + staleDebt: 1, + mustAdvance: 2, + eligibleResolve: 1, + avoidFamilies: 2, + }, + medium: { + staleDebt: 2, + mustAdvance: 2, + eligibleResolve: 1, + avoidFamilies: 3, + }, + heavy: { + staleDebt: 3, + mustAdvance: 3, + eligibleResolve: 2, + avoidFamilies: 4, + }, +}; + +export const HOOK_AGENDA_LOAD_THRESHOLDS = { + heavyReadyCount: 3, + heavyStaleCount: 4, + heavyCriticalCount: 3, + heavyPressuredCount: 6, + mediumReadyCount: 2, + mediumStaleCount: 2, + mediumCriticalCount: 1, + mediumPressuredFamilies: 3, +} as const; + +export const HOOK_VISIBILITY_WINDOWS: Record = { + immediate: 5, + "near-term": 5, + "mid-arc": 6, + "slow-burn": 8, + endgame: 10, +}; + +export const HOOK_RELEVANT_SELECTION_DEFAULTS = { + primary: { + baseLimit: 3, + pressuredExpansionLimit: 4, + pressuredThreshold: 4, + }, + stale: { + defaultLimit: 1, + expandedLimit: 2, + overdueThreshold: 2, + familySpreadThreshold: 2, + }, +} as const; + +export const HOOK_HEALTH_DEFAULTS = { + maxActiveHooks: 12, + staleAfterChapters: 10, + noAdvanceWindow: 5, + newHookBurstThreshold: 2, +} as const; + +export function resolveHookVisibilityWindow(timing: HookPayoffTiming): number { + return HOOK_VISIBILITY_WINDOWS[timing]; +} diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index 5ab93ed4..da5ebb03 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -16,6 +16,14 @@ import { resolveHookPayoffTiming, normalizeHookPayoffTiming, } from "./hook-lifecycle.js"; +import { + HOOK_AGENDA_LIMITS, + HOOK_AGENDA_LOAD_THRESHOLDS, + HOOK_PRESSURE_WEIGHTS, + HOOK_RELEVANT_SELECTION_DEFAULTS, + resolveHookVisibilityWindow, + type HookAgendaLoad, +} from "./hook-policy.js"; export interface MemorySelection { readonly summaries: ReadonlyArray; @@ -260,7 +268,7 @@ export function buildPlannerHookAgenda(params: { limit: resolveAgendaLimit({ explicitLimit: params.maxStaleDebt, candidateCount: staleDebtCandidates.length, - fallbackLimit: ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].staleDebt, + fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].staleDebt, }), forceInclude: (entry) => entry.lifecycle.overdue, }).map((entry) => entry.hook); @@ -279,7 +287,7 @@ export function buildPlannerHookAgenda(params: { limit: resolveAgendaLimit({ explicitLimit: params.maxMustAdvance, candidateCount: mustAdvanceCandidates.length, - fallbackLimit: ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].mustAdvance, + fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].mustAdvance, }), forceInclude: (entry) => entry.lifecycle.overdue, }).map((entry) => entry.hook); @@ -296,15 +304,18 @@ export function buildPlannerHookAgenda(params: { limit: resolveAgendaLimit({ explicitLimit: params.maxEligibleResolve, candidateCount: eligibleResolveCandidates.length, - fallbackLimit: ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].eligibleResolve, + fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].eligibleResolve, }), - forceInclude: (entry) => entry.lifecycle.overdue || entry.lifecycle.resolvePressure >= 40, + forceInclude: (entry) => ( + entry.lifecycle.overdue + || entry.lifecycle.resolvePressure >= HOOK_PRESSURE_WEIGHTS.criticalResolvePressure + ), }).map((entry) => entry.hook); const avoidNewHookFamilies = [...new Set([ ...staleDebtHooks.map((hook) => hook.type.trim()).filter(Boolean), ...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean), ...eligibleResolveHooks.map((hook) => hook.type.trim()).filter(Boolean), - ])].slice(0, ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].avoidFamilies); + ])].slice(0, HOOK_AGENDA_LIMITS[agendaLoad].avoidFamilies); const pressureMap = buildHookPressureMap({ lifecycleEntries, mustAdvanceHooks, @@ -321,34 +332,6 @@ export function buildPlannerHookAgenda(params: { }; } -type HookAgendaLoad = "light" | "medium" | "heavy"; - -const ADAPTIVE_HOOK_AGENDA_LIMITS: Record = { - light: { - staleDebt: 1, - mustAdvance: 2, - eligibleResolve: 1, - avoidFamilies: 2, - }, - medium: { - staleDebt: 2, - mustAdvance: 2, - eligibleResolve: 1, - avoidFamilies: 3, - }, - heavy: { - staleDebt: 3, - mustAdvance: 3, - eligibleResolve: 2, - avoidFamilies: 4, - }, -}; - function resolveHookAgendaLoad(entries: ReadonlyArray<{ readonly hook: ReturnType; readonly lifecycle: ReturnType; @@ -361,16 +344,27 @@ function resolveHookAgendaLoad(entries: ReadonlyArray<{ const staleCount = pressuredEntries.filter((entry) => entry.lifecycle.stale).length; const readyCount = pressuredEntries.filter((entry) => entry.lifecycle.readyToResolve).length; const criticalCount = pressuredEntries.filter((entry) => - entry.lifecycle.overdue || entry.lifecycle.resolvePressure >= 40, + entry.lifecycle.overdue + || entry.lifecycle.resolvePressure >= HOOK_PRESSURE_WEIGHTS.criticalResolvePressure, ).length; const pressuredFamilies = new Set( pressuredEntries.map((entry) => normalizeHookType(entry.hook.type)), ).size; - if (readyCount >= 3 || staleCount >= 4 || criticalCount >= 3 || pressuredEntries.length >= 6) { + if ( + readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyReadyCount + || staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyStaleCount + || criticalCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyCriticalCount + || pressuredEntries.length >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyPressuredCount + ) { return "heavy"; } - if (readyCount >= 2 || staleCount >= 2 || criticalCount >= 1 || pressuredFamilies >= 3) { + if ( + readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumReadyCount + || staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumStaleCount + || criticalCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumCriticalCount + || pressuredFamilies >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumPressuredFamilies + ) { return "medium"; } return "light"; @@ -469,7 +463,9 @@ function resolveRelevantHookPrimaryLimit(entries: ReadonlyArray<{ || entry.lifecycle.stale || entry.lifecycle.overdue, ).length; - return pressuredCount >= 4 ? 4 : 3; + return pressuredCount >= HOOK_RELEVANT_SELECTION_DEFAULTS.primary.pressuredThreshold + ? HOOK_RELEVANT_SELECTION_DEFAULTS.primary.pressuredExpansionLimit + : HOOK_RELEVANT_SELECTION_DEFAULTS.primary.baseLimit; } function resolveRelevantHookStaleLimit( @@ -494,11 +490,14 @@ function resolveRelevantHookStaleLimit( staleCandidates.map((entry) => normalizeHookType(entry.hook.type)), ).size; const overdueCount = staleCandidates.filter((entry) => entry.lifecycle.overdue).length; - if (overdueCount >= 2 || staleFamilies >= 2) { - return Math.min(2, staleCandidates.length); + if ( + overdueCount >= HOOK_RELEVANT_SELECTION_DEFAULTS.stale.overdueThreshold + || staleFamilies >= HOOK_RELEVANT_SELECTION_DEFAULTS.stale.familySpreadThreshold + ) { + return Math.min(HOOK_RELEVANT_SELECTION_DEFAULTS.stale.expandedLimit, staleCandidates.length); } - return 1; + return HOOK_RELEVANT_SELECTION_DEFAULTS.stale.defaultLimit; } export function isHookWithinLifecycleWindow( @@ -506,15 +505,11 @@ export function isHookWithinLifecycleWindow( chapterNumber: number, lifecycle: ReturnType, ): boolean { - const recentWindow = lifecycle.timing === "endgame" - ? 10 - : lifecycle.timing === "slow-burn" - ? 8 - : lifecycle.timing === "mid-arc" - ? 6 - : 5; - - return isHookWithinChapterWindow(hook, chapterNumber, recentWindow); + return isHookWithinChapterWindow( + hook, + chapterNumber, + resolveHookVisibilityWindow(lifecycle.timing), + ); } function isMustAdvanceCandidate( @@ -523,7 +518,7 @@ function isMustAdvanceCandidate( return lifecycle.stale || lifecycle.readyToResolve || lifecycle.overdue - || lifecycle.advancePressure >= 8; + || lifecycle.advancePressure >= HOOK_PRESSURE_WEIGHTS.mustAdvancePressureFloor; } function buildHookPressureMap(params: { From 82c28425a3cc238c523af4758ae99f903fa00ab4 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 13:41:21 +0800 Subject: [PATCH 28/53] refactor(pipeline): align hook activity thresholds --- packages/core/src/__tests__/hook-policy.test.ts | 11 +++++++++++ packages/core/src/utils/hook-lifecycle.ts | 3 ++- packages/core/src/utils/hook-policy.ts | 8 ++++++++ packages/core/src/utils/memory-retrieval.ts | 11 ++++++++--- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/core/src/__tests__/hook-policy.test.ts b/packages/core/src/__tests__/hook-policy.test.ts index 313c954e..dc04f025 100644 --- a/packages/core/src/__tests__/hook-policy.test.ts +++ b/packages/core/src/__tests__/hook-policy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + HOOK_ACTIVITY_THRESHOLDS, HOOK_AGENDA_LIMITS, HOOK_HEALTH_DEFAULTS, HOOK_PHASE_THRESHOLDS, @@ -30,4 +31,14 @@ describe("hook-policy", () => { expect(HOOK_PHASE_THRESHOLDS.lateProgress).toBeGreaterThan(HOOK_PHASE_THRESHOLDS.middleProgress); expect(HOOK_PRESSURE_WEIGHTS.resolveBiasMultiplier).toBe(10); }); + + it("keeps lifecycle activity and refresh thresholds on the same policy surface", () => { + expect(HOOK_ACTIVITY_THRESHOLDS.recentlyTouchedDormancy).toBe(1); + expect(HOOK_ACTIVITY_THRESHOLDS.longArcQuietHoldMaxAge).toBeGreaterThan( + HOOK_ACTIVITY_THRESHOLDS.freshPromiseAge, + ); + expect(HOOK_ACTIVITY_THRESHOLDS.refreshDormancy).toBeGreaterThan( + HOOK_ACTIVITY_THRESHOLDS.longArcQuietHoldMaxDormancy, + ); + }); }); diff --git a/packages/core/src/utils/hook-lifecycle.ts b/packages/core/src/utils/hook-lifecycle.ts index c5688d52..e8b8dba9 100644 --- a/packages/core/src/utils/hook-lifecycle.ts +++ b/packages/core/src/utils/hook-lifecycle.ts @@ -1,5 +1,6 @@ import type { HookPayoffTiming } from "../models/runtime-state.js"; import { + HOOK_ACTIVITY_THRESHOLDS, HOOK_PHASE_THRESHOLDS, HOOK_PHASE_WEIGHT, HOOK_PRESSURE_WEIGHTS, @@ -119,7 +120,7 @@ export function describeHookLifecycle(params: { const dormancy = Math.max(0, params.chapterNumber - Math.max(1, lastTouchChapter)); const explicitProgressing = /^(progressing|advanced|重大推进|持续推进)$/i.test(params.status.trim()); const phaseReady = HOOK_PHASE_WEIGHT[phase] >= HOOK_PHASE_WEIGHT[profile.minimumPhase]; - const recentlyTouched = dormancy <= 1; + const recentlyTouched = dormancy <= HOOK_ACTIVITY_THRESHOLDS.recentlyTouchedDormancy; const overdue = phaseReady && age >= profile.overdueAge; const cadenceReady = timing === "slow-burn" ? phase === "late" || overdue diff --git a/packages/core/src/utils/hook-policy.ts b/packages/core/src/utils/hook-policy.ts index f7d9d828..1582b850 100644 --- a/packages/core/src/utils/hook-policy.ts +++ b/packages/core/src/utils/hook-policy.ts @@ -74,6 +74,14 @@ export const HOOK_PRESSURE_WEIGHTS = { criticalResolvePressure: 40, } as const; +export const HOOK_ACTIVITY_THRESHOLDS = { + recentlyTouchedDormancy: 1, + longArcQuietHoldMaxAge: 2, + longArcQuietHoldMaxDormancy: 1, + refreshDormancy: 2, + freshPromiseAge: 1, +} as const; + export const HOOK_AGENDA_LIMITS: Record= 2) { + if (params.lifecycle.dormancy >= HOOK_ACTIVITY_THRESHOLDS.refreshDormancy) { return "refresh"; } @@ -637,7 +642,7 @@ function resolveHookPressureReason(params: { ? "long-arc-hold" : "fresh-promise"; } - if (params.lifecycle.age <= 1) { + if (params.lifecycle.age <= HOOK_ACTIVITY_THRESHOLDS.freshPromiseAge) { return "fresh-promise"; } return "building-debt"; From 822708e3caaaadbdc6ebf2b9fbe54ddde7b846e6 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 15:33:33 +0800 Subject: [PATCH 29/53] refactor(prompt): soften governed creative guidance --- packages/core/src/__tests__/planner.test.ts | 2 ++ packages/core/src/__tests__/writer.test.ts | 5 +++- .../core/src/agents/en-prompt-sections.ts | 2 +- packages/core/src/agents/planner.ts | 18 ++++++------- packages/core/src/agents/writer-prompts.ts | 26 +++++++++---------- packages/core/src/agents/writer.ts | 4 +-- 6 files changed, 31 insertions(+), 26 deletions(-) diff --git a/packages/core/src/__tests__/planner.test.ts b/packages/core/src/__tests__/planner.test.ts index 1f0de79d..9298dc23 100644 --- a/packages/core/src/__tests__/planner.test.ts +++ b/packages/core/src/__tests__/planner.test.ts @@ -370,6 +370,8 @@ describe("PlannerAgent", () => { expect(result.intent.moodDirective).toBeDefined(); expect(result.intent.moodDirective).toContain("降调"); expect(result.intent.moodDirective).toContain("日常"); + expect(result.intent.moodDirective).toContain("建议"); + expect(result.intent.moodDirective).not.toContain("必须"); }); it("does not emit a mood directive when recent moods are varied", async () => { diff --git a/packages/core/src/__tests__/writer.test.ts b/packages/core/src/__tests__/writer.test.ts index b997ec19..0070634e 100644 --- a/packages/core/src/__tests__/writer.test.ts +++ b/packages/core/src/__tests__/writer.test.ts @@ -1053,7 +1053,10 @@ describe("WriterAgent", () => { expect(systemPrompt).not.toContain("Hook-A / Hook-B"); expect(systemPrompt).toContain("真实 hook_id"); - expect(creativePrompt).toContain("## Explicit Hook Agenda"); + expect(systemPrompt).toContain("preferred narrative response"); + expect(systemPrompt).not.toContain("follow the requested move"); + expect(creativePrompt).toContain("## Hook Pressure and Debt"); + expect(creativePrompt).not.toContain("## Explicit Hook Agenda"); expect(creativePrompt).toContain("mentor-oath"); expect(creativePrompt).toContain("ledger-fragment"); expect(creativePrompt).toContain("stale-ledger"); diff --git a/packages/core/src/agents/en-prompt-sections.ts b/packages/core/src/agents/en-prompt-sections.ts index adc7847e..0a1c435a 100644 --- a/packages/core/src/agents/en-prompt-sections.ts +++ b/packages/core/src/agents/en-prompt-sections.ts @@ -29,7 +29,7 @@ export function buildEnglishCoreRules(_book: BookConfig): string { 15. **No reset buttons**: The world must change permanently in response to major events. ### Reader Psychology -16. **Promise and payoff**: Every planted hook must be resolved. Every mystery must have an answer. +16. **Promise and payoff**: Every planted hook should eventually earn a payoff, reframing, or explicit defer. Every mystery needs an answer path, not aimless drift. 17. **Escalation**: Each conflict should feel higher-stakes than the last—either externally or emotionally. 18. **Reader proxy**: One character should react with surprise/excitement/fear when remarkable things happen, giving readers permission to feel the same. 19. **Pacing breathing room**: After a high-intensity sequence, give 0.5-1 chapter of lower intensity before the next escalation.`; diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index 6a3f3968..26e6fe30 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -338,8 +338,8 @@ export class PlannerAgent extends BaseAgent { } return this.isChineseLanguage(language) - ? "不要继续依赖卷纲的 fallback 指令,必须把本章推进到新的弧线节点或地点变化。" - : "Do not keep leaning on the outline fallback. Force this chapter toward a fresh arc beat or location change."; + ? "不要继续停留在卷纲 fallback 的保守走法里,优先把本章推向新的弧线节点或地点变化。" + : "Do not keep drifting inside the outline fallback. Prefer a fresh arc beat or location change this chapter."; } private buildSceneDirective( @@ -352,8 +352,8 @@ export class PlannerAgent extends BaseAgent { const repeatedType = cadence.scenePressure.repeatedType; return this.isChineseLanguage(language) - ? `最近章节连续停留在“${repeatedType}”,本章必须更换场景容器、地点或行动方式。` - : `Recent chapters are stuck in repeated ${repeatedType} beats. Change the scene container, location, or action pattern this chapter.`; + ? `最近章节连续停留在“${repeatedType}”,本章最好更换场景容器、地点或行动方式,避免继续沿同一种节拍平移。` + : `Recent chapters are lingering in repeated ${repeatedType} beats. Change the scene container, location, or action pattern if possible instead of sliding forward on the same beat.`; } private buildMoodDirective( @@ -366,8 +366,8 @@ export class PlannerAgent extends BaseAgent { const moods = cadence.moodPressure.recentMoods; return this.isChineseLanguage(language) - ? `最近${moods.length}章情绪持续高压(${moods.slice(0, 3).join("、")}),本章必须降调——安排日常/喘息/温情/幽默场景,让读者呼吸。` - : `The last ${moods.length} chapters have been relentlessly tense (${moods.slice(0, 3).join(", ")}). This chapter must downshift — write a quieter scene with warmth, humor, or breathing room.`; + ? `最近${moods.length}章情绪持续高压(${moods.slice(0, 3).join("、")}),建议本章降调——安排日常/喘息/温情/幽默场景,让读者呼吸。` + : `The last ${moods.length} chapters have been relentlessly tense (${moods.slice(0, 3).join(", ")}). Consider downshifting this chapter with warmth, humor, or breathing room.`; } private buildTitleDirective( @@ -380,8 +380,8 @@ export class PlannerAgent extends BaseAgent { const repeatedToken = cadence.titlePressure.repeatedToken; return this.isChineseLanguage(language) - ? `标题不要再围绕“${repeatedToken}”重复命名,换一个新的意象或动作焦点。` - : `Avoid another ${repeatedToken}-centric title. Pick a new image or action focus for this chapter title.`; + ? `标题尽量不要再围绕“${repeatedToken}”重复命名,换一个新的意象或动作焦点。` + : `Try not to return to another ${repeatedToken}-centric title. Pick a new image or action focus for this chapter title.`; } private extractSection(content: string, headings: ReadonlyArray): string | undefined { @@ -709,7 +709,7 @@ export class PlannerAgent extends BaseAgent { "## Style Emphasis", styleEmphasis, "", - "## Structured Directives", + "## Creative Pressure", directives, "", "## Hook Agenda", diff --git a/packages/core/src/agents/writer-prompts.ts b/packages/core/src/agents/writer-prompts.ts index eac6327f..1c34a907 100644 --- a/packages/core/src/agents/writer-prompts.ts +++ b/packages/core/src/agents/writer-prompts.ts @@ -104,12 +104,12 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s - The outline is the default plan, not unconditional global supremacy. - When the runtime rule stack records an active L4 -> L3 override, follow the current task over local planning. - Keep hard guardrails compact: canon, continuity facts, and explicit prohibitions still win. -- If an English Variance Brief is provided, obey it: avoid the listed phrase/opening/ending patterns and satisfy the scene obligation. +- If an English Variance Brief is provided, use it as a variance cue: avoid the listed phrase/opening/ending patterns and satisfy the scene obligation without sounding mechanical. - If Hook Debt Briefs are provided, treat them as the active memory of what the reader is still owed: preserve the original promise and change the on-page situation. -- If the explicit hook agenda includes a pressure map, follow the requested move for each target: full-payoff means concrete payoff, partial-payoff means a meaningful intermediate reveal, advance/refresh mean material movement, and quiet-hold means keep the promise visible without cashing it out early. -- When the explicit hook agenda names an eligible resolve target, land a concrete payoff beat instead of merely mentioning the old thread. -- When stale debt is present, do not open sibling hooks casually; clear pressure from old promises before minting fresh debt. -- When a hook brief says to suppress sibling hooks, do not fake progress by opening a parallel hook of the same family. +- If the explicit hook agenda includes a pressure map, treat the requested move as the preferred narrative response for each target: full-payoff means concrete payoff, partial-payoff means a meaningful intermediate reveal, advance/refresh mean material movement, and quiet-hold means keep the promise visible without cashing it out early. +- When the explicit hook agenda names an eligible resolve target, try to land a concrete payoff beat instead of merely mentioning the old thread. +- When stale debt is present, be cautious about opening sibling hooks; relieve pressure from old promises before minting fresh debt unless the scene earns a new thread. +- When a hook brief says to suppress sibling hooks, avoid faking progress by opening a parallel hook of the same family. - In multi-character scenes, include at least one resistance-bearing exchange instead of reducing the beat to summary or explanation.`; } @@ -119,12 +119,12 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s - 卷纲是默认规划,不是全局最高规则。 - 当 runtime rule stack 明确记录了 L4 -> L3 的 active override 时,优先执行当前任务意图,再局部调整规划层。 - 真正不能突破的只有硬护栏:世界设定、连续性事实、显式禁令。 -- 如果提供了 English Variance Brief,必须主动避开其中列出的高频短语、重复开头和重复结尾模式,并完成 scene obligation。 +- 如果提供了 English Variance Brief,把它当成变奏提示:主动避开其中列出的高频短语、重复开头和重复结尾模式,同时自然完成 scene obligation。 - 如果提供了 Hook Debt 简报,把它当成读者仍在等待兑现的承诺记忆:保留原始 promise,并让本章在页上发生真实变化。 -- 如果显式 hook agenda 里带有 pressure map,逐条执行其中要求的动作:full-payoff 就是具体兑现,partial-payoff 就是给出中间层级的揭示或缩圈,advance / refresh 就是有分量的推进,quiet-hold 就是让承诺继续可见但不要过早消耗。 -- 如果显式 hook agenda 里出现了可回收目标,本章必须写出具体兑现片段,不能只是重新提一句旧线索。 -- 如果存在 stale debt,先消化旧承诺的压力,再决定是否开新坑;同类 sibling hook 不得随手再开。 -- 如果某条 hook 简报明确要求 suppress sibling hooks,就不能用再开一个同类平行坑来假装推进。 +- 如果显式 hook agenda 里带有 pressure map,把其中动作当成优先叙事响应:full-payoff 就是具体兑现,partial-payoff 就是给出中间层级的揭示或缩圈,advance / refresh 就是有分量的推进,quiet-hold 就是让承诺继续可见但不要过早消耗。 +- 如果显式 hook agenda 里出现了可回收目标,优先写出具体兑现片段,不要只是重新提一句旧线索。 +- 如果存在 stale debt,先消化旧承诺的压力,再决定是否开新坑;同类 sibling hook 要谨慎新增,除非场景真的挣到了新的线程。 +- 如果某条 hook 简报明确要求 suppress sibling hooks,避免靠再开一个同类平行坑来假装推进。 - 多角色场景里,至少给出一轮带阻力的直接交锋,不要把人物关系写成纯解释或纯总结。`; } @@ -153,7 +153,7 @@ function buildCoreRules(lengthSpec: LengthSpec): string { 1. 以简体中文工作,句子长短交替,段落适合手机阅读(3-5行/段) 2. 目标字数:${lengthSpec.target}字,允许区间:${lengthSpec.softMin}-${lengthSpec.softMax}字 -3. 伏笔前后呼应,不留悬空线;所有埋下的伏笔都必须在后续收回 +3. 伏笔前后呼应,不留悬空线;所有埋下的伏笔都应在后续得到兑现、转化或明确延后 4. 只读必要上下文,不机械重复已有内容 ## 人物塑造铁律 @@ -545,7 +545,7 @@ ${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) | ${preWriteTable} === CHAPTER_TITLE === -(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题;若提供了 recent title history 或高频标题词,必须主动避开重复词根和高频意象) +(章节标题,不含"第X章"。标题应与已有章节标题区分开,避免重复使用相同或相似的标题;若提供了 recent title history 或高频标题词,优先避开重复词根和高频意象) === CHAPTER_CONTENT === (正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字) @@ -598,7 +598,7 @@ ${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) | ${preWriteTable} === CHAPTER_TITLE === -(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题;若提供了 recent title history 或高频标题词,必须主动避开重复词根和高频意象) +(章节标题,不含"第X章"。标题应与已有章节标题区分开,避免重复使用相同或相似的标题;若提供了 recent title history 或高频标题词,优先避开重复词根和高频意象) === CHAPTER_CONTENT === (正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字) diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index 4f61449e..ae3478d4 100644 --- a/packages/core/src/agents/writer.ts +++ b/packages/core/src/agents/writer.ts @@ -843,8 +843,8 @@ ${lengthRequirementBlock} const explicitHookAgenda = this.extractMarkdownSection(params.chapterIntent, "## Hook Agenda"); const hookAgendaBlock = explicitHookAgenda ? params.language === "en" - ? `\n## Explicit Hook Agenda\n${explicitHookAgenda}\n` - : `\n## 显式 Hook Agenda\n${explicitHookAgenda}\n` + ? `\n## Hook Pressure and Debt\n${explicitHookAgenda}\n` + : `\n## 伏笔压力与旧债\n${explicitHookAgenda}\n` : ""; if (params.language === "en") { From 71a860e7d50f9359cb007e26e0ad92feb3d26eca Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 15:39:06 +0800 Subject: [PATCH 30/53] refactor(pipeline): centralize cadence pressure policy --- .../core/src/__tests__/cadence-policy.test.ts | 44 +++++++++++++++ packages/core/src/utils/cadence-policy.ts | 46 ++++++++++++++++ packages/core/src/utils/chapter-cadence.ts | 54 ++++++++++++------- packages/core/src/utils/long-span-fatigue.ts | 25 +++++---- 4 files changed, 142 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/__tests__/cadence-policy.test.ts create mode 100644 packages/core/src/utils/cadence-policy.ts diff --git a/packages/core/src/__tests__/cadence-policy.test.ts b/packages/core/src/__tests__/cadence-policy.test.ts new file mode 100644 index 00000000..a9680df1 --- /dev/null +++ b/packages/core/src/__tests__/cadence-policy.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + CADENCE_PRESSURE_THRESHOLDS, + CADENCE_WINDOW_DEFAULTS, + LONG_SPAN_FATIGUE_THRESHOLDS, + resolveCadencePressure, +} from "../utils/cadence-policy.js"; + +describe("cadence-policy", () => { + it("exposes shared cadence and fatigue defaults from one policy surface", () => { + expect(CADENCE_WINDOW_DEFAULTS.summaryLookback).toBe(4); + expect(CADENCE_WINDOW_DEFAULTS.englishVarianceLookback).toBeGreaterThan( + CADENCE_WINDOW_DEFAULTS.summaryLookback, + ); + expect(LONG_SPAN_FATIGUE_THRESHOLDS.boundarySimilarityFloor).toBe(0.72); + expect(LONG_SPAN_FATIGUE_THRESHOLDS.boundarySentenceMinLength).toBe(18); + }); + + it("resolves shared medium/high cadence pressure without duplicating threshold logic", () => { + expect(resolveCadencePressure({ + count: 3, + total: 4, + highThreshold: CADENCE_PRESSURE_THRESHOLDS.scene.highCount, + mediumThreshold: CADENCE_PRESSURE_THRESHOLDS.scene.mediumCount, + mediumWindowFloor: CADENCE_PRESSURE_THRESHOLDS.scene.mediumWindowFloor, + })).toBe("high"); + + expect(resolveCadencePressure({ + count: 2, + total: 4, + highThreshold: CADENCE_PRESSURE_THRESHOLDS.mood.highCount, + mediumThreshold: CADENCE_PRESSURE_THRESHOLDS.mood.mediumCount, + mediumWindowFloor: CADENCE_PRESSURE_THRESHOLDS.mood.mediumWindowFloor, + })).toBe("medium"); + + expect(resolveCadencePressure({ + count: 1, + total: 3, + highThreshold: CADENCE_PRESSURE_THRESHOLDS.title.highCount, + mediumThreshold: CADENCE_PRESSURE_THRESHOLDS.title.mediumCount, + mediumWindowFloor: CADENCE_PRESSURE_THRESHOLDS.title.mediumWindowFloor, + })).toBeUndefined(); + }); +}); diff --git a/packages/core/src/utils/cadence-policy.ts b/packages/core/src/utils/cadence-policy.ts new file mode 100644 index 00000000..59b7ae52 --- /dev/null +++ b/packages/core/src/utils/cadence-policy.ts @@ -0,0 +1,46 @@ +export const CADENCE_WINDOW_DEFAULTS = { + summaryLookback: 4, + englishVarianceLookback: 24, + recentBoundaryPatternBodies: 2, +} as const; + +export const CADENCE_PRESSURE_THRESHOLDS = { + scene: { + highCount: 3, + mediumCount: 2, + mediumWindowFloor: 4, + }, + mood: { + highCount: 3, + mediumCount: 2, + mediumWindowFloor: 4, + }, + title: { + minimumRepeatedCount: 2, + highCount: 3, + mediumCount: 2, + mediumWindowFloor: 4, + }, +} as const; + +export const LONG_SPAN_FATIGUE_THRESHOLDS = { + boundarySimilarityFloor: 0.72, + boundarySentenceMinLength: 18, + boundaryPatternMinBodies: 3, +} as const; + +export function resolveCadencePressure(params: { + readonly count: number; + readonly total: number; + readonly highThreshold: number; + readonly mediumThreshold: number; + readonly mediumWindowFloor: number; +}): "medium" | "high" | undefined { + if (params.count >= params.highThreshold) { + return "high"; + } + if (params.count >= params.mediumThreshold && params.total >= params.mediumWindowFloor) { + return "medium"; + } + return undefined; +} diff --git a/packages/core/src/utils/chapter-cadence.ts b/packages/core/src/utils/chapter-cadence.ts index 41bfd2be..ae34a3f2 100644 --- a/packages/core/src/utils/chapter-cadence.ts +++ b/packages/core/src/utils/chapter-cadence.ts @@ -1,3 +1,9 @@ +import { + CADENCE_PRESSURE_THRESHOLDS, + CADENCE_WINDOW_DEFAULTS, + resolveCadencePressure, +} from "./cadence-policy.js"; + export interface CadenceSummaryRow { readonly chapter: number; readonly title: string; @@ -30,7 +36,7 @@ export interface ChapterCadenceAnalysis { readonly titlePressure?: TitleCadencePressure; } -export const DEFAULT_CHAPTER_CADENCE_WINDOW = 4; +export const DEFAULT_CHAPTER_CADENCE_WINDOW = CADENCE_WINDOW_DEFAULTS.summaryLookback; const HIGH_TENSION_KEYWORDS = [ "紧张", "冷硬", "压抑", "逼仄", "肃杀", "沉重", "凝重", @@ -51,7 +57,7 @@ export function analyzeChapterCadence(params: { }): ChapterCadenceAnalysis { const recentRows = [...params.rows] .sort((left, right) => left.chapter - right.chapter) - .slice(-DEFAULT_CHAPTER_CADENCE_WINDOW); + .slice(-CADENCE_WINDOW_DEFAULTS.summaryLookback); return { scenePressure: analyzeScenePressure(recentRows), @@ -88,11 +94,15 @@ function analyzeScenePressure( streak += 1; } - if (streak >= 3) { - return { pressure: "high", repeatedType, streak }; - } - if (streak >= 2 && types.length >= 4) { - return { pressure: "medium", repeatedType, streak }; + const pressure = resolveCadencePressure({ + count: streak, + total: types.length, + highThreshold: CADENCE_PRESSURE_THRESHOLDS.scene.highCount, + mediumThreshold: CADENCE_PRESSURE_THRESHOLDS.scene.mediumCount, + mediumWindowFloor: CADENCE_PRESSURE_THRESHOLDS.scene.mediumWindowFloor, + }); + if (pressure) { + return { pressure, repeatedType, streak }; } return undefined; } @@ -117,11 +127,15 @@ function analyzeMoodPressure( highTensionStreak += 1; } - if (highTensionStreak >= 3) { - return { pressure: "high", highTensionStreak, recentMoods }; - } - if (highTensionStreak >= 2 && moods.length >= 4) { - return { pressure: "medium", highTensionStreak, recentMoods }; + const pressure = resolveCadencePressure({ + count: highTensionStreak, + total: moods.length, + highThreshold: CADENCE_PRESSURE_THRESHOLDS.mood.highCount, + mediumThreshold: CADENCE_PRESSURE_THRESHOLDS.mood.mediumCount, + mediumWindowFloor: CADENCE_PRESSURE_THRESHOLDS.mood.mediumWindowFloor, + }); + if (pressure) { + return { pressure, highTensionStreak, recentMoods }; } return undefined; } @@ -146,17 +160,21 @@ function analyzeTitlePressure( const repeated = [...counts.entries()] .sort((left, right) => right[1] - left[1] || right[0].length - left[0].length || left[0].localeCompare(right[0])) - .find((entry) => entry[1] >= 2); + .find((entry) => entry[1] >= CADENCE_PRESSURE_THRESHOLDS.title.minimumRepeatedCount); if (!repeated) { return undefined; } const [repeatedToken, count] = repeated; - if (count >= 3) { - return { pressure: "high", repeatedToken, count, recentTitles: titles }; - } - if (count >= 2 && titles.length >= 4) { - return { pressure: "medium", repeatedToken, count, recentTitles: titles }; + const pressure = resolveCadencePressure({ + count, + total: titles.length, + highThreshold: CADENCE_PRESSURE_THRESHOLDS.title.highCount, + mediumThreshold: CADENCE_PRESSURE_THRESHOLDS.title.mediumCount, + mediumWindowFloor: CADENCE_PRESSURE_THRESHOLDS.title.mediumWindowFloor, + }); + if (pressure) { + return { pressure, repeatedToken, count, recentTitles: titles }; } return undefined; } diff --git a/packages/core/src/utils/long-span-fatigue.ts b/packages/core/src/utils/long-span-fatigue.ts index 2dc64c58..9e83c174 100644 --- a/packages/core/src/utils/long-span-fatigue.ts +++ b/packages/core/src/utils/long-span-fatigue.ts @@ -1,6 +1,10 @@ import { readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; import { analyzeChapterCadence } from "./chapter-cadence.js"; +import { + CADENCE_WINDOW_DEFAULTS, + LONG_SPAN_FATIGUE_THRESHOLDS, +} from "./cadence-policy.js"; export interface LongSpanFatigueIssue { readonly severity: "warning"; @@ -32,7 +36,6 @@ interface SummaryRow { readonly chapterType: string; } -const SENTENCE_SIMILARITY_THRESHOLD = 0.72; const CHINESE_PUNCTUATION = /[,。!?;:“”‘’()《》、\s\-—…·]/g; const ENGLISH_PUNCTUATION = /[^a-z0-9]+/gi; @@ -40,7 +43,11 @@ export async function buildEnglishVarianceBrief(params: { readonly bookDir: string; readonly chapterNumber: number; }): Promise { - const chapterBodies = await loadPreviousChapterBodies(params.bookDir, params.chapterNumber, 24); + const chapterBodies = await loadPreviousChapterBodies( + params.bookDir, + params.chapterNumber, + CADENCE_WINDOW_DEFAULTS.englishVarianceLookback, + ); if (chapterBodies.length < 2) { return null; } @@ -49,7 +56,7 @@ export async function buildEnglishVarianceBrief(params: { const recentRows = summaryRows .filter((row) => row.chapter < params.chapterNumber) .sort((left, right) => left.chapter - right.chapter) - .slice(-4); + .slice(-CADENCE_WINDOW_DEFAULTS.summaryLookback); const highFrequencyPhrases = collectRepeatedEnglishPhrases(chapterBodies); const repeatedOpeningPatterns = collectRepeatedBoundaryPatterns(chapterBodies, "opening"); @@ -89,7 +96,7 @@ export async function analyzeLongSpanFatigue( const recentRows = mergedRows .filter((row) => row.chapter <= input.chapterNumber) .sort((left, right) => left.chapter - right.chapter) - .slice(-4); + .slice(-CADENCE_WINDOW_DEFAULTS.summaryLookback); const cadence = analyzeChapterCadence({ rows: recentRows, language, @@ -289,9 +296,9 @@ async function loadRecentChapterBodies( .map((file) => ({ file, chapter: Number.parseInt(file.slice(0, 4), 10) })) .filter((entry) => Number.isFinite(entry.chapter) && entry.chapter < currentChapter && entry.file.endsWith(".md")) .sort((left, right) => left.chapter - right.chapter) - .slice(-2); + .slice(-CADENCE_WINDOW_DEFAULTS.recentBoundaryPatternBodies); - if (previousFiles.length < 2) { + if (previousFiles.length < CADENCE_WINDOW_DEFAULTS.recentBoundaryPatternBodies) { return []; } @@ -310,7 +317,7 @@ function buildSentencePatternIssue( boundary: "opening" | "ending", language: "zh" | "en", ): LongSpanFatigueIssue | null { - if (chapterBodies.length < 3) return null; + if (chapterBodies.length < LONG_SPAN_FATIGUE_THRESHOLDS.boundaryPatternMinBodies) return null; const sentences = chapterBodies.map((body) => extractBoundarySentence(body, boundary)); if (sentences.some((sentence) => sentence === null)) { @@ -319,7 +326,7 @@ function buildSentencePatternIssue( const normalized = sentences .map((sentence) => normalizeSentence(sentence!, language)); - if (normalized.some((sentence) => sentence.length < 18)) { + if (normalized.some((sentence) => sentence.length < LONG_SPAN_FATIGUE_THRESHOLDS.boundarySentenceMinLength)) { return null; } @@ -327,7 +334,7 @@ function buildSentencePatternIssue( diceCoefficient(normalized[0]!, normalized[1]!), diceCoefficient(normalized[1]!, normalized[2]!), ]; - if (Math.min(...similarities) < SENTENCE_SIMILARITY_THRESHOLD) { + if (Math.min(...similarities) < LONG_SPAN_FATIGUE_THRESHOLDS.boundarySimilarityFloor) { return null; } From b80056eac5794f770dbaac90b84d801235739d90 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 15:44:26 +0800 Subject: [PATCH 31/53] refactor(prompt): naturalize hook debt briefs --- packages/core/src/__tests__/composer.test.ts | 7 ++++--- packages/core/src/agents/composer.ts | 13 +++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/core/src/__tests__/composer.test.ts b/packages/core/src/__tests__/composer.test.ts index 715ee564..99da6df5 100644 --- a/packages/core/src/__tests__/composer.test.ts +++ b/packages/core/src/__tests__/composer.test.ts @@ -563,9 +563,10 @@ describe("ComposerAgent", () => { const hookDebtEntry = result.contextPackage.selectedContext.find((entry) => entry.source === "runtime/hook_debt#mentor-oath"); expect(hookDebtEntry).toBeDefined(); expect(hookDebtEntry?.excerpt).toContain("mentor-oath"); - expect(hookDebtEntry?.excerpt).toContain("动作: 局部兑现"); - expect(hookDebtEntry?.excerpt).toContain("压力: 高"); - expect(hookDebtEntry?.excerpt).toContain("抑制同类开坑: 是"); + expect(hookDebtEntry?.excerpt).toContain("建议动作: 局部兑现"); + expect(hookDebtEntry?.excerpt).toContain("当前压力: 高"); + expect(hookDebtEntry?.excerpt).toContain("注意: 暂缓同类开坑"); + expect(hookDebtEntry?.excerpt).not.toContain("抑制同类开坑: 是"); expect(hookDebtEntry?.excerpt).toContain("River Camp"); expect(hookDebtEntry?.excerpt).toContain("Trial Echo"); }); diff --git a/packages/core/src/agents/composer.ts b/packages/core/src/agents/composer.ts index 64975945..dd2b0162 100644 --- a/packages/core/src/agents/composer.ts +++ b/packages/core/src/agents/composer.ts @@ -310,9 +310,6 @@ export class ComposerAgent extends BaseAgent { : (language === "en" ? "Keep the original promise legible and materially change the on-page situation." : "保持原始承诺清晰可见,并让页上局势发生实质变化。"); - const suppressSiblingHooks = guidance?.blockSiblingHooks - ? (language === "en" ? "yes" : "是") - : (language === "en" ? "no" : "否"); return [{ source: `runtime/hook_debt#${hook.hookId}`, @@ -320,8 +317,8 @@ export class ComposerAgent extends BaseAgent { ? "Narrative debt brief for an explicit hook agenda target." : "显式 hook agenda 目标的叙事债务简报。", excerpt: language === "en" - ? `${hook.hookId} | role: ${role} | movement: ${movement} | pressure: ${pressure} | cadence: ${cadence} | suppress siblings: ${suppressSiblingHooks} | reason: ${reason} | promise: ${promise} | seed: ${seedBeat} | latest: ${latestBeat}` - : `${hook.hookId} | 角色: ${role} | 动作: ${movement} | 压力: ${pressure} | 节奏: ${cadence} | 抑制同类开坑: ${suppressSiblingHooks} | 原因: ${reason} | 承诺: ${promise} | 种子: ${seedBeat} | 最近推进: ${latestBeat}`, + ? `${hook.hookId} | narrative debt: ${role} (${cadence}) | current pressure: ${pressure} (${reason}) | preferred move: ${movement} | reader promise: ${promise} | original seed: ${seedBeat} | latest turn: ${latestBeat}${guidance?.blockSiblingHooks ? " | caution: avoid opening sibling hooks" : ""}` + : `${hook.hookId} | 叙事债务: ${role}(${cadence}) | 当前压力: ${pressure}(${reason}) | 建议动作: ${movement} | 读者承诺: ${promise} | 最初种子: ${seedBeat} | 最近推进: ${latestBeat}${guidance?.blockSiblingHooks ? " | 注意: 暂缓同类开坑" : ""}`, }]; }); } @@ -377,12 +374,12 @@ export class ComposerAgent extends BaseAgent { language: "zh" | "en", ): string { if (plan.intent.hookAgenda.eligibleResolve.includes(hookId)) { - return language === "en" ? "payoff candidate" : "优先兑现"; + return language === "en" ? "payoff-ready debt" : "可兑现旧债"; } if (plan.intent.hookAgenda.staleDebt.includes(hookId)) { - return language === "en" ? "stale debt" : "高压旧债"; + return language === "en" ? "high-pressure debt" : "高压旧债"; } - return language === "en" ? "must advance" : "本章必须推进"; + return language === "en" ? "mainline debt" : "主要旧债"; } private findHookPressure( From f3044007d4191373112d4734b113aab19815ca89 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 16:05:41 +0800 Subject: [PATCH 32/53] feat(pipeline): auto-repair collapsed chapter titles --- .../__tests__/post-write-validator.test.ts | 15 +++ .../core/src/agents/post-write-validator.ts | 112 +++++++++++++++--- packages/core/src/pipeline/runner.ts | 4 +- 3 files changed, 114 insertions(+), 17 deletions(-) diff --git a/packages/core/src/__tests__/post-write-validator.test.ts b/packages/core/src/__tests__/post-write-validator.test.ts index 734de17c..812852f5 100644 --- a/packages/core/src/__tests__/post-write-validator.test.ts +++ b/packages/core/src/__tests__/post-write-validator.test.ts @@ -259,4 +259,19 @@ describe("validatePostWrite", () => { expect(result.title).toContain("塔楼"); expect(result.title).not.toBe("回声(2)"); }); + + it("regenerates a title when it continues a collapsed recent title shell", () => { + const result = resolveDuplicateTitle( + "名单未落", + ["名单之前", "名单之后", "名单还在"], + "zh", + { + content: "塔楼里的铜铃只响了一声,守夜人没有回头,风从缺口灌进来。", + }, + ); + + expect(result.issues.some((issue) => issue.rule === "title-collapse")).toBe(true); + expect(result.title).not.toContain("名单"); + expect(result.title).toContain("塔楼"); + }); }); diff --git a/packages/core/src/agents/post-write-validator.ts b/packages/core/src/agents/post-write-validator.ts index de1f295d..0571dfcb 100644 --- a/packages/core/src/agents/post-write-validator.ts +++ b/packages/core/src/agents/post-write-validator.ts @@ -5,6 +5,7 @@ * Catches violations that prompt-only rules cannot guarantee. */ +import { analyzeChapterCadence } from "../utils/chapter-cadence.js"; import type { BookRules } from "../models/book-rules.js"; import type { GenreProfile } from "../models/genre-profile.js"; @@ -636,28 +637,89 @@ export function resolveDuplicateTitle( return { title: newTitle, issues: [] }; } - const issues = detectDuplicateTitle(trimmed, existingTitles); - if (issues.length === 0) { + const duplicateIssues = detectDuplicateTitle(trimmed, existingTitles); + if (duplicateIssues.length > 0) { + const regenerated = regenerateDuplicateTitle(trimmed, existingTitles, language, options?.content); + if (regenerated && detectDuplicateTitle(regenerated, existingTitles).length === 0) { + return { title: regenerated, issues: duplicateIssues }; + } + + let counter = 2; + while (counter < 100) { + const candidate = language === "en" + ? `${trimmed} (${counter})` + : `${trimmed}(${counter})`; + if (detectDuplicateTitle(candidate, existingTitles).length === 0) { + return { title: candidate, issues: duplicateIssues }; + } + counter++; + } + + return { title: trimmed, issues: duplicateIssues }; + } + + const collapseIssues = detectTitleCollapse(trimmed, existingTitles, language); + if (collapseIssues.length === 0) { return { title: trimmed, issues: [] }; } - const regenerated = regenerateDuplicateTitle(trimmed, existingTitles, language, options?.content); - if (regenerated && detectDuplicateTitle(regenerated, existingTitles).length === 0) { - return { title: regenerated, issues }; + const regenerated = regenerateCollapsedTitle(trimmed, existingTitles, language, options?.content); + if ( + regenerated + && detectDuplicateTitle(regenerated, existingTitles).length === 0 + && detectTitleCollapse(regenerated, existingTitles, language).length === 0 + ) { + return { title: regenerated, issues: collapseIssues }; } - let counter = 2; - while (counter < 100) { - const candidate = language === "en" - ? `${trimmed} (${counter})` - : `${trimmed}(${counter})`; - if (detectDuplicateTitle(candidate, existingTitles).length === 0) { - return { title: candidate, issues }; - } - counter++; + return { title: trimmed, issues: collapseIssues }; +} + +function detectTitleCollapse( + newTitle: string, + existingTitles: ReadonlyArray, + language: "zh" | "en", +): ReadonlyArray { + const recentTitles = existingTitles + .map((title) => title.trim()) + .filter(Boolean) + .slice(-3); + if (recentTitles.length < 3) { + return []; } - return { title: trimmed, issues }; + const cadence = analyzeChapterCadence({ + language, + rows: [...recentTitles, newTitle].map((title, index) => ({ + chapter: index + 1, + title, + mood: "", + chapterType: "", + })), + }); + const titlePressure = cadence.titlePressure; + if (!titlePressure || titlePressure.pressure !== "high") { + return []; + } + if (!newTitle.includes(titlePressure.repeatedToken)) { + return []; + } + + return [ + language === "en" + ? { + rule: "title-collapse", + severity: "warning", + description: `Chapter title "${newTitle}" keeps leaning on the recent "${titlePressure.repeatedToken}" title shell.`, + suggestion: "Rename the chapter around a new image, action, consequence, or character focus.", + } + : { + rule: "title-collapse", + severity: "warning", + description: `章节标题"${newTitle}"仍在沿用近期围绕“${titlePressure.repeatedToken}”的命名壳。`, + suggestion: "换一个新的意象、动作、后果或人物焦点来命名。", + }, + ]; } function regenerateDuplicateTitle( @@ -682,6 +744,26 @@ function regenerateDuplicateTitle( : `${baseTitle}:${qualifier}`; } +function regenerateCollapsedTitle( + baseTitle: string, + existingTitles: ReadonlyArray, + language: "zh" | "en", + content?: string, +): string | undefined { + if (!content || !content.trim()) { + return undefined; + } + + const fresh = language === "en" + ? extractEnglishTitleQualifier(baseTitle, existingTitles, content) + : extractChineseTitleQualifier(baseTitle, existingTitles, content); + if (!fresh) { + return undefined; + } + + return fresh === baseTitle ? undefined : fresh; +} + function extractEnglishTitleQualifier( baseTitle: string, existingTitles: ReadonlyArray, diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index f9123fcc..b84e36c5 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -1217,8 +1217,8 @@ export class PipelineRunner { } if (persistenceOutput.title !== output.title) { const description = pipelineLang === "en" - ? `Duplicate chapter title "${output.title}" was auto-renamed to "${persistenceOutput.title}".` - : `章节标题"${output.title}"与已有标题重复,已自动改为"${persistenceOutput.title}"。`; + ? `Chapter title "${output.title}" was auto-adjusted to "${persistenceOutput.title}".` + : `章节标题"${output.title}"已自动调整为"${persistenceOutput.title}"。`; this.config.logger?.warn(`[title] ${description}`); auditResult = { ...auditResult, From ef7d4ebb6a0c931cbbceb68f46c321eebcfaa9e5 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 16:14:32 +0800 Subject: [PATCH 33/53] refactor(pipeline): extract hook agenda from memory retrieval --- .../core/src/__tests__/hook-agenda.test.ts | 67 +++ packages/core/src/agents/planner.ts | 2 +- .../core/src/utils/governed-working-set.ts | 6 +- packages/core/src/utils/hook-agenda.ts | 483 +++++++++++++++++ packages/core/src/utils/memory-retrieval.ts | 507 +----------------- 5 files changed, 570 insertions(+), 495 deletions(-) create mode 100644 packages/core/src/__tests__/hook-agenda.test.ts create mode 100644 packages/core/src/utils/hook-agenda.ts diff --git a/packages/core/src/__tests__/hook-agenda.test.ts b/packages/core/src/__tests__/hook-agenda.test.ts new file mode 100644 index 00000000..95bb2ab0 --- /dev/null +++ b/packages/core/src/__tests__/hook-agenda.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + buildPlannerHookAgenda, + isHookWithinLifecycleWindow, +} from "../utils/hook-agenda.js"; +import { describeHookLifecycle } from "../utils/hook-lifecycle.js"; +import type { StoredHook } from "../state/memory-db.js"; + +function createHook(overrides: Partial = {}): StoredHook { + return { + hookId: overrides.hookId ?? "mentor-oath", + startChapter: overrides.startChapter ?? 8, + type: overrides.type ?? "relationship", + status: overrides.status ?? "open", + lastAdvancedChapter: overrides.lastAdvancedChapter ?? 9, + expectedPayoff: overrides.expectedPayoff ?? "Reveal why the mentor broke the oath", + payoffTiming: overrides.payoffTiming ?? "slow-burn", + notes: overrides.notes ?? "Long debt should stay visible", + }; +} + +describe("hook-agenda", () => { + it("keeps lifecycle-aware windowing and pressure agenda behavior after extraction", () => { + const staleSlowBurn = createHook({ + hookId: "mentor-oath", + startChapter: 4, + lastAdvancedChapter: 7, + notes: "Long debt is stalling", + }); + const readyMystery = createHook({ + hookId: "ledger-fragment", + type: "mystery", + startChapter: 2, + lastAdvancedChapter: 10, + payoffTiming: "near-term", + expectedPayoff: "Reveal the ledger fragment's origin", + notes: "Ready to cash out", + }); + + const agenda = buildPlannerHookAgenda({ + hooks: [staleSlowBurn, readyMystery], + chapterNumber: 12, + targetChapters: 24, + language: "en", + }); + + expect(agenda.mustAdvance).toContain("mentor-oath"); + expect(agenda.pressureMap).toEqual(expect.arrayContaining([ + expect.objectContaining({ + hookId: "mentor-oath", + }), + ])); + + const lifecycle = describeHookLifecycle({ + payoffTiming: staleSlowBurn.payoffTiming, + expectedPayoff: staleSlowBurn.expectedPayoff, + notes: staleSlowBurn.notes, + startChapter: staleSlowBurn.startChapter, + lastAdvancedChapter: staleSlowBurn.lastAdvancedChapter, + status: staleSlowBurn.status, + chapterNumber: 12, + targetChapters: 24, + }); + + expect(isHookWithinLifecycleWindow(staleSlowBurn, 12, lifecycle)).toBe(true); + }); +}); diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index 26e6fe30..9020bc57 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -5,13 +5,13 @@ 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 { - buildPlannerHookAgenda, parseChapterSummariesMarkdown, renderHookSnapshot, renderSummarySnapshot, retrieveMemorySelection, } from "../utils/memory-retrieval.js"; import { analyzeChapterCadence } from "../utils/chapter-cadence.js"; +import { buildPlannerHookAgenda } from "../utils/hook-agenda.js"; export interface PlanChapterInput { readonly book: BookConfig; diff --git a/packages/core/src/utils/governed-working-set.ts b/packages/core/src/utils/governed-working-set.ts index 8a1d638d..6e7ed6fd 100644 --- a/packages/core/src/utils/governed-working-set.ts +++ b/packages/core/src/utils/governed-working-set.ts @@ -1,10 +1,12 @@ import type { ContextPackage } from "../models/input-governance.js"; import { - isHookWithinChapterWindow, - isHookWithinLifecycleWindow, parsePendingHooksMarkdown, renderHookSnapshot, } from "./memory-retrieval.js"; +import { + isHookWithinChapterWindow, + isHookWithinLifecycleWindow, +} from "./hook-agenda.js"; import { describeHookLifecycle } from "./hook-lifecycle.js"; export function buildGovernedHookWorkingSet(params: { diff --git a/packages/core/src/utils/hook-agenda.ts b/packages/core/src/utils/hook-agenda.ts new file mode 100644 index 00000000..f6f55709 --- /dev/null +++ b/packages/core/src/utils/hook-agenda.ts @@ -0,0 +1,483 @@ +import type { HookAgenda, HookPressure } from "../models/input-governance.js"; +import type { HookRecord, HookStatus } from "../models/runtime-state.js"; +import type { StoredHook } from "../state/memory-db.js"; +import { describeHookLifecycle, resolveHookPayoffTiming } from "./hook-lifecycle.js"; +import { + HOOK_ACTIVITY_THRESHOLDS, + HOOK_AGENDA_LIMITS, + HOOK_AGENDA_LOAD_THRESHOLDS, + HOOK_PRESSURE_WEIGHTS, + HOOK_RELEVANT_SELECTION_DEFAULTS, + resolveHookVisibilityWindow, + type HookAgendaLoad, +} from "./hook-policy.js"; + +type HookLifecycle = ReturnType; +type NormalizedStoredHook = HookRecord; + +interface HookAgendaEntry { + readonly hook: NormalizedStoredHook; + readonly lifecycle: HookLifecycle; +} + +interface HookSelectionEntry { + readonly hook: { + readonly hookId: string; + readonly type: string; + }; + readonly lifecycle: HookLifecycle; +} + +export const DEFAULT_HOOK_LOOKAHEAD_CHAPTERS = 3; + +export function buildPlannerHookAgenda(params: { + readonly hooks: ReadonlyArray; + readonly chapterNumber: number; + readonly targetChapters?: number; + readonly language?: "zh" | "en"; + readonly maxMustAdvance?: number; + readonly maxEligibleResolve?: number; + readonly maxStaleDebt?: number; +}): HookAgenda { + const agendaHooks = params.hooks + .map(normalizeStoredHook) + .filter((hook) => !isFuturePlannedHook(hook, params.chapterNumber, 0)) + .filter((hook) => hook.status !== "resolved" && hook.status !== "deferred"); + const lifecycleEntries = agendaHooks.map((hook) => ({ + hook, + lifecycle: describeHookLifecycle({ + payoffTiming: hook.payoffTiming, + expectedPayoff: hook.expectedPayoff, + notes: hook.notes, + startChapter: hook.startChapter, + lastAdvancedChapter: hook.lastAdvancedChapter, + status: hook.status, + chapterNumber: params.chapterNumber, + targetChapters: params.targetChapters, + }), + })); + const agendaLoad = resolveHookAgendaLoad(lifecycleEntries); + const staleDebtCandidates = lifecycleEntries + .filter((entry) => entry.lifecycle.stale) + .sort((left, right) => ( + Number(right.lifecycle.overdue) - Number(left.lifecycle.overdue) + || right.lifecycle.advancePressure - left.lifecycle.advancePressure + || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter + || left.hook.startChapter - right.hook.startChapter + || left.hook.hookId.localeCompare(right.hook.hookId) + )); + const staleDebtHooks = selectAgendaHooksWithTypeSpread({ + entries: staleDebtCandidates, + limit: resolveAgendaLimit({ + explicitLimit: params.maxStaleDebt, + candidateCount: staleDebtCandidates.length, + fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].staleDebt, + }), + forceInclude: (entry) => entry.lifecycle.overdue, + }).map((entry) => entry.hook); + const mustAdvancePool = lifecycleEntries.filter((entry) => isMustAdvanceCandidate(entry.lifecycle)); + const mustAdvanceCandidates = (mustAdvancePool.length > 0 ? mustAdvancePool : lifecycleEntries) + .slice() + .sort((left, right) => ( + Number(right.lifecycle.stale) - Number(left.lifecycle.stale) + || right.lifecycle.advancePressure - left.lifecycle.advancePressure + || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter + || left.hook.startChapter - right.hook.startChapter + || left.hook.hookId.localeCompare(right.hook.hookId) + )); + const mustAdvanceHooks = selectAgendaHooksWithTypeSpread({ + entries: mustAdvanceCandidates, + limit: resolveAgendaLimit({ + explicitLimit: params.maxMustAdvance, + candidateCount: mustAdvanceCandidates.length, + fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].mustAdvance, + }), + forceInclude: (entry) => entry.lifecycle.overdue, + }).map((entry) => entry.hook); + const eligibleResolveCandidates = lifecycleEntries + .filter((entry) => entry.lifecycle.readyToResolve) + .sort((left, right) => ( + right.lifecycle.resolvePressure - left.lifecycle.resolvePressure + || Number(right.lifecycle.stale) - Number(left.lifecycle.stale) + || left.hook.startChapter - right.hook.startChapter + || left.hook.hookId.localeCompare(right.hook.hookId) + )); + const eligibleResolveHooks = selectAgendaHooksWithTypeSpread({ + entries: eligibleResolveCandidates, + limit: resolveAgendaLimit({ + explicitLimit: params.maxEligibleResolve, + candidateCount: eligibleResolveCandidates.length, + fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].eligibleResolve, + }), + forceInclude: (entry) => ( + entry.lifecycle.overdue + || entry.lifecycle.resolvePressure >= HOOK_PRESSURE_WEIGHTS.criticalResolvePressure + ), + }).map((entry) => entry.hook); + const avoidNewHookFamilies = [...new Set([ + ...staleDebtHooks.map((hook) => hook.type.trim()).filter(Boolean), + ...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean), + ...eligibleResolveHooks.map((hook) => hook.type.trim()).filter(Boolean), + ])].slice(0, HOOK_AGENDA_LIMITS[agendaLoad].avoidFamilies); + const pressureMap = buildHookPressureMap({ + lifecycleEntries, + mustAdvanceHooks, + eligibleResolveHooks, + staleDebtHooks, + }); + + return { + pressureMap, + mustAdvance: mustAdvanceHooks.map((hook) => hook.hookId), + eligibleResolve: eligibleResolveHooks.map((hook) => hook.hookId), + staleDebt: staleDebtHooks.map((hook) => hook.hookId), + avoidNewHookFamilies, + }; +} + +function resolveHookAgendaLoad(entries: ReadonlyArray): HookAgendaLoad { + const pressuredEntries = entries.filter((entry) => + entry.lifecycle.readyToResolve + || entry.lifecycle.stale + || entry.lifecycle.overdue, + ); + const staleCount = pressuredEntries.filter((entry) => entry.lifecycle.stale).length; + const readyCount = pressuredEntries.filter((entry) => entry.lifecycle.readyToResolve).length; + const criticalCount = pressuredEntries.filter((entry) => + entry.lifecycle.overdue + || entry.lifecycle.resolvePressure >= HOOK_PRESSURE_WEIGHTS.criticalResolvePressure, + ).length; + const pressuredFamilies = new Set( + pressuredEntries.map((entry) => normalizeHookType(entry.hook.type)), + ).size; + + if ( + readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyReadyCount + || staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyStaleCount + || criticalCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyCriticalCount + || pressuredEntries.length >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyPressuredCount + ) { + return "heavy"; + } + if ( + readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumReadyCount + || staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumStaleCount + || criticalCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumCriticalCount + || pressuredFamilies >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumPressuredFamilies + ) { + return "medium"; + } + return "light"; +} + +function resolveAgendaLimit(params: { + readonly explicitLimit?: number; + readonly candidateCount: number; + readonly fallbackLimit: number; +}): number { + if (params.candidateCount <= 0) { + return 0; + } + + const limit = params.explicitLimit ?? params.fallbackLimit; + return Math.max(1, Math.min(limit, params.candidateCount)); +} + +export function selectAgendaHooksWithTypeSpread(params: { + readonly entries: ReadonlyArray; + readonly limit: number; + readonly forceInclude?: (entry: T) => boolean; +}): T[] { + if (params.limit <= 0 || params.entries.length === 0) { + return []; + } + + const selected: T[] = []; + const selectedIds = new Set(); + const selectedTypes = new Set(); + const forcedEntries = params.entries.filter((entry) => params.forceInclude?.(entry) ?? false); + const addEntry = (entry: T): void => { + if (selectedIds.has(entry.hook.hookId) || selected.length >= params.limit) { + return; + } + selected.push(entry); + selectedIds.add(entry.hook.hookId); + selectedTypes.add(normalizeHookType(entry.hook.type)); + }; + + for (const entry of forcedEntries) { + if (selected.length >= params.limit) { + break; + } + const normalizedType = normalizeHookType(entry.hook.type); + if (!selectedTypes.has(normalizedType)) { + addEntry(entry); + } + } + + for (const entry of forcedEntries) { + addEntry(entry); + } + + for (const entry of params.entries) { + if (selected.length >= params.limit) { + break; + } + if (selectedIds.has(entry.hook.hookId)) { + continue; + } + const normalizedType = normalizeHookType(entry.hook.type); + if (!selectedTypes.has(normalizedType)) { + addEntry(entry); + } + } + + for (const entry of params.entries) { + if (selected.length >= params.limit) { + break; + } + addEntry(entry); + } + + return selected; +} + +function normalizeHookType(type: string): string { + return type.trim().toLowerCase() || "hook"; +} + +export function resolveRelevantHookPrimaryLimit(entries: ReadonlyArray): number { + const pressuredCount = entries.filter((entry) => + entry.lifecycle.readyToResolve + || entry.lifecycle.stale + || entry.lifecycle.overdue, + ).length; + return pressuredCount >= HOOK_RELEVANT_SELECTION_DEFAULTS.primary.pressuredThreshold + ? HOOK_RELEVANT_SELECTION_DEFAULTS.primary.pressuredExpansionLimit + : HOOK_RELEVANT_SELECTION_DEFAULTS.primary.baseLimit; +} + +export function resolveRelevantHookStaleLimit( + entries: ReadonlyArray, + selectedIds: ReadonlySet, +): number { + const staleCandidates = entries.filter((entry) => + !selectedIds.has(entry.hook.hookId) + && (entry.lifecycle.stale || entry.lifecycle.overdue), + ); + if (staleCandidates.length === 0) { + return 0; + } + + const staleFamilies = new Set( + staleCandidates.map((entry) => normalizeHookType(entry.hook.type)), + ).size; + const overdueCount = staleCandidates.filter((entry) => entry.lifecycle.overdue).length; + if ( + overdueCount >= HOOK_RELEVANT_SELECTION_DEFAULTS.stale.overdueThreshold + || staleFamilies >= HOOK_RELEVANT_SELECTION_DEFAULTS.stale.familySpreadThreshold + ) { + return Math.min(HOOK_RELEVANT_SELECTION_DEFAULTS.stale.expandedLimit, staleCandidates.length); + } + + return HOOK_RELEVANT_SELECTION_DEFAULTS.stale.defaultLimit; +} + +export function isHookWithinLifecycleWindow( + hook: StoredHook, + chapterNumber: number, + lifecycle: HookLifecycle, +): boolean { + return isHookWithinChapterWindow( + hook, + chapterNumber, + resolveHookVisibilityWindow(lifecycle.timing), + ); +} + +function isMustAdvanceCandidate(lifecycle: HookLifecycle): boolean { + return lifecycle.stale + || lifecycle.readyToResolve + || lifecycle.overdue + || lifecycle.advancePressure >= HOOK_PRESSURE_WEIGHTS.mustAdvancePressureFloor; +} + +function buildHookPressureMap(params: { + readonly lifecycleEntries: ReadonlyArray; + readonly mustAdvanceHooks: ReadonlyArray; + readonly eligibleResolveHooks: ReadonlyArray; + readonly staleDebtHooks: ReadonlyArray; +}): HookPressure[] { + const eligibleResolveIds = new Set(params.eligibleResolveHooks.map((hook) => hook.hookId)); + const staleDebtIds = new Set(params.staleDebtHooks.map((hook) => hook.hookId)); + const lifecycleById = new Map( + params.lifecycleEntries.map((entry) => [entry.hook.hookId, entry.lifecycle] as const), + ); + + const orderedIds = [...new Set([ + ...params.eligibleResolveHooks.map((hook) => hook.hookId), + ...params.staleDebtHooks.map((hook) => hook.hookId), + ...params.mustAdvanceHooks.map((hook) => hook.hookId), + ])]; + + return orderedIds.flatMap((hookId) => { + const hook = params.lifecycleEntries.find((entry) => entry.hook.hookId === hookId)?.hook; + const lifecycle = lifecycleById.get(hookId); + if (!hook || !lifecycle) { + return []; + } + + const movement = resolveHookMovement({ + lifecycle, + eligibleResolve: eligibleResolveIds.has(hookId), + staleDebt: staleDebtIds.has(hookId), + }); + const pressure = resolveHookPressureLevel({ lifecycle, movement }); + const reason = resolveHookPressureReason({ lifecycle, movement }); + + return [{ + hookId, + type: hook.type.trim() || "hook", + movement, + pressure, + payoffTiming: lifecycle.timing, + phase: lifecycle.phase, + reason, + blockSiblingHooks: staleDebtIds.has(hookId) || movement === "partial-payoff" || movement === "full-payoff", + }]; + }); +} + +function resolveHookMovement(params: { + readonly lifecycle: HookLifecycle; + readonly eligibleResolve: boolean; + readonly staleDebt: boolean; +}): HookPressure["movement"] { + if (params.eligibleResolve) { + return "full-payoff"; + } + + const timing = params.lifecycle.timing; + const longArc = timing === "slow-burn" || timing === "endgame"; + + if (params.staleDebt && longArc) { + return "partial-payoff"; + } + + if (params.staleDebt) { + return "advance"; + } + + if ( + longArc + && params.lifecycle.age <= HOOK_ACTIVITY_THRESHOLDS.longArcQuietHoldMaxAge + && params.lifecycle.dormancy <= HOOK_ACTIVITY_THRESHOLDS.longArcQuietHoldMaxDormancy + ) { + return "quiet-hold"; + } + + if (params.lifecycle.dormancy >= HOOK_ACTIVITY_THRESHOLDS.refreshDormancy) { + return "refresh"; + } + + return "advance"; +} + +function resolveHookPressureLevel(params: { + readonly lifecycle: HookLifecycle; + readonly movement: HookPressure["movement"]; +}): HookPressure["pressure"] { + if (params.lifecycle.overdue || params.movement === "full-payoff") { + return params.lifecycle.overdue ? "critical" : "high"; + } + if (params.lifecycle.stale || params.movement === "partial-payoff") { + return "high"; + } + if (params.movement === "advance" || params.movement === "refresh") { + return "medium"; + } + return "low"; +} + +function resolveHookPressureReason(params: { + readonly lifecycle: HookLifecycle; + readonly movement: HookPressure["movement"]; +}): HookPressure["reason"] { + if (params.lifecycle.overdue && params.movement === "full-payoff") { + return "overdue-payoff"; + } + if (params.movement === "full-payoff") { + return "ripe-payoff"; + } + if (params.movement === "partial-payoff" || params.lifecycle.stale) { + return "stale-promise"; + } + if (params.movement === "quiet-hold") { + return params.lifecycle.timing === "slow-burn" || params.lifecycle.timing === "endgame" + ? "long-arc-hold" + : "fresh-promise"; + } + if (params.lifecycle.age <= HOOK_ACTIVITY_THRESHOLDS.freshPromiseAge) { + return "fresh-promise"; + } + return "building-debt"; +} + +function normalizeStoredHook(hook: StoredHook): HookRecord { + return { + hookId: hook.hookId, + startChapter: Math.max(0, hook.startChapter), + type: hook.type, + status: normalizeStoredHookStatus(hook.status), + lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter), + expectedPayoff: hook.expectedPayoff, + payoffTiming: resolveHookPayoffTiming(hook), + notes: hook.notes, + }; +} + +function normalizeStoredHookStatus(status: string): HookStatus { + if (/^(resolved|closed|done|已回收|已解决)$/i.test(status.trim())) return "resolved"; + if (/^(deferred|paused|hold|延后|延期|搁置|暂缓)$/i.test(status.trim())) return "deferred"; + if (/^(progressing|advanced|重大推进|持续推进)$/i.test(status.trim())) return "progressing"; + return "open"; +} + +export function filterActiveHooks(hooks: ReadonlyArray): StoredHook[] { + return hooks.filter((hook) => normalizeStoredHookStatus(hook.status) !== "resolved"); +} + +export function isFuturePlannedHook( + hook: StoredHook, + chapterNumber: number, + lookahead: number = DEFAULT_HOOK_LOOKAHEAD_CHAPTERS, +): boolean { + return hook.lastAdvancedChapter <= 0 && hook.startChapter > chapterNumber + lookahead; +} + +export function isHookWithinChapterWindow( + hook: StoredHook, + chapterNumber: number, + recentWindow: number = 5, + lookahead: number = DEFAULT_HOOK_LOOKAHEAD_CHAPTERS, +): boolean { + const recentCutoff = Math.max(0, chapterNumber - recentWindow); + + if (hook.lastAdvancedChapter > 0 && hook.lastAdvancedChapter >= recentCutoff) { + return true; + } + + if (hook.lastAdvancedChapter > 0) { + return false; + } + + if (hook.startChapter <= 0) { + return true; + } + + if (hook.startChapter >= recentCutoff && hook.startChapter <= chapterNumber) { + return true; + } + + return hook.startChapter > chapterNumber && hook.startChapter <= chapterNumber + lookahead; +} diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index 5647609b..927b6777 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -1,12 +1,9 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import type { HookAgenda, HookPressure } from "../models/input-governance.js"; import { ChapterSummariesStateSchema, CurrentStateStateSchema, HooksStateSchema, - type HookRecord, - type HookStatus, } from "../models/runtime-state.js"; import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js"; import { bootstrapStructuredStateFromMarkdown, normalizeHookId } from "../state/state-bootstrap.js"; @@ -17,14 +14,21 @@ import { normalizeHookPayoffTiming, } from "./hook-lifecycle.js"; import { - HOOK_ACTIVITY_THRESHOLDS, - HOOK_AGENDA_LIMITS, - HOOK_AGENDA_LOAD_THRESHOLDS, - HOOK_PRESSURE_WEIGHTS, - HOOK_RELEVANT_SELECTION_DEFAULTS, - resolveHookVisibilityWindow, - type HookAgendaLoad, -} from "./hook-policy.js"; + buildPlannerHookAgenda, + filterActiveHooks, + isFuturePlannedHook, + isHookWithinChapterWindow, + isHookWithinLifecycleWindow, + resolveRelevantHookPrimaryLimit, + resolveRelevantHookStaleLimit, + selectAgendaHooksWithTypeSpread, +} from "./hook-agenda.js"; +export { + buildPlannerHookAgenda, + isFuturePlannedHook, + isHookWithinChapterWindow, + isHookWithinLifecycleWindow, +} from "./hook-agenda.js"; export interface MemorySelection { readonly summaries: ReadonlyArray; @@ -41,8 +45,6 @@ export interface VolumeSummarySelection { readonly anchor: string; } -export const DEFAULT_HOOK_LOOKAHEAD_CHAPTERS = 3; - export async function retrieveMemorySelection(params: { readonly bookDir: string; readonly chapterNumber: number; @@ -228,426 +230,6 @@ export function renderHookSnapshot( ].join("\n"); } -export function buildPlannerHookAgenda(params: { - readonly hooks: ReadonlyArray; - readonly chapterNumber: number; - readonly targetChapters?: number; - readonly language?: "zh" | "en"; - readonly maxMustAdvance?: number; - readonly maxEligibleResolve?: number; - readonly maxStaleDebt?: number; -}): HookAgenda { - const agendaHooks = params.hooks - .map(normalizeStoredHook) - .filter((hook) => !isFuturePlannedHook(hook, params.chapterNumber, 0)) - .filter((hook) => hook.status !== "resolved" && hook.status !== "deferred"); - const lifecycleEntries = agendaHooks.map((hook) => ({ - hook, - lifecycle: describeHookLifecycle({ - payoffTiming: hook.payoffTiming, - expectedPayoff: hook.expectedPayoff, - notes: hook.notes, - startChapter: hook.startChapter, - lastAdvancedChapter: hook.lastAdvancedChapter, - status: hook.status, - chapterNumber: params.chapterNumber, - targetChapters: params.targetChapters, - }), - })); - const agendaLoad = resolveHookAgendaLoad(lifecycleEntries); - const staleDebtCandidates = lifecycleEntries - .filter((entry) => entry.lifecycle.stale) - .sort((left, right) => ( - Number(right.lifecycle.overdue) - Number(left.lifecycle.overdue) - || right.lifecycle.advancePressure - left.lifecycle.advancePressure - || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter - || left.hook.startChapter - right.hook.startChapter - || left.hook.hookId.localeCompare(right.hook.hookId) - )); - const staleDebtHooks = selectAgendaHooksWithTypeSpread({ - entries: staleDebtCandidates, - limit: resolveAgendaLimit({ - explicitLimit: params.maxStaleDebt, - candidateCount: staleDebtCandidates.length, - fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].staleDebt, - }), - forceInclude: (entry) => entry.lifecycle.overdue, - }).map((entry) => entry.hook); - const mustAdvancePool = lifecycleEntries.filter((entry) => isMustAdvanceCandidate(entry.lifecycle)); - const mustAdvanceCandidates = (mustAdvancePool.length > 0 ? mustAdvancePool : lifecycleEntries) - .slice() - .sort((left, right) => ( - Number(right.lifecycle.stale) - Number(left.lifecycle.stale) - || right.lifecycle.advancePressure - left.lifecycle.advancePressure - || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter - || left.hook.startChapter - right.hook.startChapter - || left.hook.hookId.localeCompare(right.hook.hookId) - )); - const mustAdvanceHooks = selectAgendaHooksWithTypeSpread({ - entries: mustAdvanceCandidates, - limit: resolveAgendaLimit({ - explicitLimit: params.maxMustAdvance, - candidateCount: mustAdvanceCandidates.length, - fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].mustAdvance, - }), - forceInclude: (entry) => entry.lifecycle.overdue, - }).map((entry) => entry.hook); - const eligibleResolveCandidates = lifecycleEntries - .filter((entry) => entry.lifecycle.readyToResolve) - .sort((left, right) => ( - right.lifecycle.resolvePressure - left.lifecycle.resolvePressure - || Number(right.lifecycle.stale) - Number(left.lifecycle.stale) - || left.hook.startChapter - right.hook.startChapter - || left.hook.hookId.localeCompare(right.hook.hookId) - )); - const eligibleResolveHooks = selectAgendaHooksWithTypeSpread({ - entries: eligibleResolveCandidates, - limit: resolveAgendaLimit({ - explicitLimit: params.maxEligibleResolve, - candidateCount: eligibleResolveCandidates.length, - fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].eligibleResolve, - }), - forceInclude: (entry) => ( - entry.lifecycle.overdue - || entry.lifecycle.resolvePressure >= HOOK_PRESSURE_WEIGHTS.criticalResolvePressure - ), - }).map((entry) => entry.hook); - const avoidNewHookFamilies = [...new Set([ - ...staleDebtHooks.map((hook) => hook.type.trim()).filter(Boolean), - ...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean), - ...eligibleResolveHooks.map((hook) => hook.type.trim()).filter(Boolean), - ])].slice(0, HOOK_AGENDA_LIMITS[agendaLoad].avoidFamilies); - const pressureMap = buildHookPressureMap({ - lifecycleEntries, - mustAdvanceHooks, - eligibleResolveHooks, - staleDebtHooks, - }); - - return { - pressureMap, - mustAdvance: mustAdvanceHooks.map((hook) => hook.hookId), - eligibleResolve: eligibleResolveHooks.map((hook) => hook.hookId), - staleDebt: staleDebtHooks.map((hook) => hook.hookId), - avoidNewHookFamilies, - }; -} - -function resolveHookAgendaLoad(entries: ReadonlyArray<{ - readonly hook: ReturnType; - readonly lifecycle: ReturnType; -}>): HookAgendaLoad { - const pressuredEntries = entries.filter((entry) => - entry.lifecycle.readyToResolve - || entry.lifecycle.stale - || entry.lifecycle.overdue, - ); - const staleCount = pressuredEntries.filter((entry) => entry.lifecycle.stale).length; - const readyCount = pressuredEntries.filter((entry) => entry.lifecycle.readyToResolve).length; - const criticalCount = pressuredEntries.filter((entry) => - entry.lifecycle.overdue - || entry.lifecycle.resolvePressure >= HOOK_PRESSURE_WEIGHTS.criticalResolvePressure, - ).length; - const pressuredFamilies = new Set( - pressuredEntries.map((entry) => normalizeHookType(entry.hook.type)), - ).size; - - if ( - readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyReadyCount - || staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyStaleCount - || criticalCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyCriticalCount - || pressuredEntries.length >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyPressuredCount - ) { - return "heavy"; - } - if ( - readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumReadyCount - || staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumStaleCount - || criticalCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumCriticalCount - || pressuredFamilies >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumPressuredFamilies - ) { - return "medium"; - } - return "light"; -} - -function resolveAgendaLimit(params: { - readonly explicitLimit?: number; - readonly candidateCount: number; - readonly fallbackLimit: number; -}): number { - if (params.candidateCount <= 0) { - return 0; - } - - const limit = params.explicitLimit ?? params.fallbackLimit; - return Math.max(1, Math.min(limit, params.candidateCount)); -} - -function selectAgendaHooksWithTypeSpread; -}>(params: { - readonly entries: ReadonlyArray; - readonly limit: number; - readonly forceInclude?: (entry: T) => boolean; -}): T[] { - if (params.limit <= 0 || params.entries.length === 0) { - return []; - } - - const selected: T[] = []; - const selectedIds = new Set(); - const selectedTypes = new Set(); - const forcedEntries = params.entries.filter((entry) => params.forceInclude?.(entry) ?? false); - const addEntry = (entry: T): void => { - if (selectedIds.has(entry.hook.hookId) || selected.length >= params.limit) { - return; - } - selected.push(entry); - selectedIds.add(entry.hook.hookId); - selectedTypes.add(normalizeHookType(entry.hook.type)); - }; - - for (const entry of forcedEntries) { - if (selected.length >= params.limit) { - break; - } - const normalizedType = normalizeHookType(entry.hook.type); - if (!selectedTypes.has(normalizedType)) { - addEntry(entry); - } - } - - for (const entry of forcedEntries) { - addEntry(entry); - } - - for (const entry of params.entries) { - if (selected.length >= params.limit) { - break; - } - if (selectedIds.has(entry.hook.hookId)) { - continue; - } - const normalizedType = normalizeHookType(entry.hook.type); - if (!selectedTypes.has(normalizedType)) { - addEntry(entry); - } - } - - for (const entry of params.entries) { - if (selected.length >= params.limit) { - break; - } - addEntry(entry); - } - - return selected; -} - -function normalizeHookType(type: string): string { - return type.trim().toLowerCase() || "hook"; -} - -function resolveRelevantHookPrimaryLimit(entries: ReadonlyArray<{ - readonly hook: { - readonly type: string; - }; - readonly lifecycle: ReturnType; -}>): number { - const pressuredCount = entries.filter((entry) => - entry.lifecycle.readyToResolve - || entry.lifecycle.stale - || entry.lifecycle.overdue, - ).length; - return pressuredCount >= HOOK_RELEVANT_SELECTION_DEFAULTS.primary.pressuredThreshold - ? HOOK_RELEVANT_SELECTION_DEFAULTS.primary.pressuredExpansionLimit - : HOOK_RELEVANT_SELECTION_DEFAULTS.primary.baseLimit; -} - -function resolveRelevantHookStaleLimit( - entries: ReadonlyArray<{ - readonly hook: { - readonly hookId: string; - readonly type: string; - }; - readonly lifecycle: ReturnType; - }>, - selectedIds: ReadonlySet, -): number { - const staleCandidates = entries.filter((entry) => - !selectedIds.has(entry.hook.hookId) - && (entry.lifecycle.stale || entry.lifecycle.overdue), - ); - if (staleCandidates.length === 0) { - return 0; - } - - const staleFamilies = new Set( - staleCandidates.map((entry) => normalizeHookType(entry.hook.type)), - ).size; - const overdueCount = staleCandidates.filter((entry) => entry.lifecycle.overdue).length; - if ( - overdueCount >= HOOK_RELEVANT_SELECTION_DEFAULTS.stale.overdueThreshold - || staleFamilies >= HOOK_RELEVANT_SELECTION_DEFAULTS.stale.familySpreadThreshold - ) { - return Math.min(HOOK_RELEVANT_SELECTION_DEFAULTS.stale.expandedLimit, staleCandidates.length); - } - - return HOOK_RELEVANT_SELECTION_DEFAULTS.stale.defaultLimit; -} - -export function isHookWithinLifecycleWindow( - hook: StoredHook, - chapterNumber: number, - lifecycle: ReturnType, -): boolean { - return isHookWithinChapterWindow( - hook, - chapterNumber, - resolveHookVisibilityWindow(lifecycle.timing), - ); -} - -function isMustAdvanceCandidate( - lifecycle: ReturnType, -): boolean { - return lifecycle.stale - || lifecycle.readyToResolve - || lifecycle.overdue - || lifecycle.advancePressure >= HOOK_PRESSURE_WEIGHTS.mustAdvancePressureFloor; -} - -function buildHookPressureMap(params: { - readonly lifecycleEntries: ReadonlyArray<{ - readonly hook: ReturnType; - readonly lifecycle: ReturnType; - }>; - readonly mustAdvanceHooks: ReadonlyArray>; - readonly eligibleResolveHooks: ReadonlyArray>; - readonly staleDebtHooks: ReadonlyArray>; -}): HookPressure[] { - const eligibleResolveIds = new Set(params.eligibleResolveHooks.map((hook) => hook.hookId)); - const staleDebtIds = new Set(params.staleDebtHooks.map((hook) => hook.hookId)); - const lifecycleById = new Map( - params.lifecycleEntries.map((entry) => [entry.hook.hookId, entry.lifecycle] as const), - ); - - const orderedIds = [...new Set([ - ...params.eligibleResolveHooks.map((hook) => hook.hookId), - ...params.staleDebtHooks.map((hook) => hook.hookId), - ...params.mustAdvanceHooks.map((hook) => hook.hookId), - ])]; - - return orderedIds.flatMap((hookId) => { - const hook = params.lifecycleEntries.find((entry) => entry.hook.hookId === hookId)?.hook; - const lifecycle = lifecycleById.get(hookId); - if (!hook || !lifecycle) { - return []; - } - - const movement = resolveHookMovement({ - hook, - lifecycle, - eligibleResolve: eligibleResolveIds.has(hookId), - staleDebt: staleDebtIds.has(hookId), - }); - const pressure = resolveHookPressureLevel({ lifecycle, movement }); - const reason = resolveHookPressureReason({ lifecycle, movement }); - - return [{ - hookId, - type: hook.type.trim() || "hook", - movement, - pressure, - payoffTiming: lifecycle.timing, - phase: lifecycle.phase, - reason, - blockSiblingHooks: staleDebtIds.has(hookId) || movement === "partial-payoff" || movement === "full-payoff", - }]; - }); -} - -function resolveHookMovement(params: { - readonly hook: ReturnType; - readonly lifecycle: ReturnType; - readonly eligibleResolve: boolean; - readonly staleDebt: boolean; -}): HookPressure["movement"] { - if (params.eligibleResolve) { - return "full-payoff"; - } - - const timing = params.lifecycle.timing; - const longArc = timing === "slow-burn" || timing === "endgame"; - - if (params.staleDebt && longArc) { - return "partial-payoff"; - } - - if (params.staleDebt) { - return "advance"; - } - - if ( - longArc - && params.lifecycle.age <= HOOK_ACTIVITY_THRESHOLDS.longArcQuietHoldMaxAge - && params.lifecycle.dormancy <= HOOK_ACTIVITY_THRESHOLDS.longArcQuietHoldMaxDormancy - ) { - return "quiet-hold"; - } - - if (params.lifecycle.dormancy >= HOOK_ACTIVITY_THRESHOLDS.refreshDormancy) { - return "refresh"; - } - - return "advance"; -} - -function resolveHookPressureLevel(params: { - readonly lifecycle: ReturnType; - readonly movement: HookPressure["movement"]; -}): HookPressure["pressure"] { - if (params.lifecycle.overdue || params.movement === "full-payoff") { - return params.lifecycle.overdue ? "critical" : "high"; - } - if (params.lifecycle.stale || params.movement === "partial-payoff") { - return "high"; - } - if (params.movement === "advance" || params.movement === "refresh") { - return "medium"; - } - return "low"; -} - -function resolveHookPressureReason(params: { - readonly lifecycle: ReturnType; - readonly movement: HookPressure["movement"]; -}): HookPressure["reason"] { - if (params.lifecycle.overdue && params.movement === "full-payoff") { - return "overdue-payoff"; - } - if (params.movement === "full-payoff") { - return "ripe-payoff"; - } - if (params.movement === "partial-payoff" || params.lifecycle.stale) { - return "stale-promise"; - } - if (params.movement === "quiet-hold") { - return params.lifecycle.timing === "slow-burn" || params.lifecycle.timing === "endgame" - ? "long-arc-hold" - : "fresh-promise"; - } - if (params.lifecycle.age <= HOOK_ACTIVITY_THRESHOLDS.freshPromiseAge) { - return "fresh-promise"; - } - return "building-debt"; -} - function openMemoryDB(bookDir: string): MemoryDB | null { try { return new MemoryDB(bookDir); @@ -1088,65 +670,6 @@ function scoreHook( return termScore + freshness; } -function normalizeStoredHook(hook: StoredHook): HookRecord { - return { - hookId: hook.hookId, - startChapter: Math.max(0, hook.startChapter), - type: hook.type, - status: normalizeStoredHookStatus(hook.status), - lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter), - expectedPayoff: hook.expectedPayoff, - payoffTiming: resolveHookPayoffTiming(hook), - notes: hook.notes, - }; -} - -function normalizeStoredHookStatus(status: string): HookStatus { - if (/^(resolved|closed|done|已回收|已解决)$/i.test(status.trim())) return "resolved"; - if (/^(deferred|paused|hold|延后|延期|搁置|暂缓)$/i.test(status.trim())) return "deferred"; - if (/^(progressing|advanced|重大推进|持续推进)$/i.test(status.trim())) return "progressing"; - return "open"; -} - -function filterActiveHooks(hooks: ReadonlyArray): StoredHook[] { - return hooks.filter((hook) => normalizeStoredHookStatus(hook.status) !== "resolved"); -} - -export function isFuturePlannedHook( - hook: StoredHook, - chapterNumber: number, - lookahead: number = DEFAULT_HOOK_LOOKAHEAD_CHAPTERS, -): boolean { - return hook.lastAdvancedChapter <= 0 && hook.startChapter > chapterNumber + lookahead; -} - -export function isHookWithinChapterWindow( - hook: StoredHook, - chapterNumber: number, - recentWindow: number = 5, - lookahead: number = DEFAULT_HOOK_LOOKAHEAD_CHAPTERS, -): boolean { - const recentCutoff = Math.max(0, chapterNumber - recentWindow); - - if (hook.lastAdvancedChapter > 0 && hook.lastAdvancedChapter >= recentCutoff) { - return true; - } - - if (hook.lastAdvancedChapter > 0) { - return false; - } - - if (hook.startChapter <= 0) { - return true; - } - - if (hook.startChapter >= recentCutoff && hook.startChapter <= chapterNumber) { - return true; - } - - return hook.startChapter > chapterNumber && hook.startChapter <= chapterNumber + lookahead; -} - function matchesAny(text: string, queryTerms: ReadonlyArray): boolean { return queryTerms.some((term) => includesTerm(text, term)); } From 616f807d6f519952f59478fc28bb8f0797397876 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 16:21:45 +0800 Subject: [PATCH 34/53] refactor(pipeline): extract chapter state recovery --- .../__tests__/chapter-state-recovery.test.ts | 232 +++++++++++++++++ .../src/pipeline/chapter-state-recovery.ts | 235 ++++++++++++++++++ packages/core/src/pipeline/runner.ts | 232 ++--------------- 3 files changed, 484 insertions(+), 215 deletions(-) create mode 100644 packages/core/src/__tests__/chapter-state-recovery.test.ts create mode 100644 packages/core/src/pipeline/chapter-state-recovery.ts diff --git a/packages/core/src/__tests__/chapter-state-recovery.test.ts b/packages/core/src/__tests__/chapter-state-recovery.test.ts new file mode 100644 index 00000000..b5b336da --- /dev/null +++ b/packages/core/src/__tests__/chapter-state-recovery.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AuditIssue } from "../agents/continuity.js"; +import type { + ValidationResult, + ValidationWarning, +} from "../agents/state-validator.js"; +import type { WriteChapterOutput } from "../agents/writer.js"; +import type { BookConfig } from "../models/book.js"; +import type { ChapterMeta } from "../models/chapter.js"; +import { + buildStateDegradedPersistenceOutput, + buildStateDegradedReviewNote, + parseStateDegradedReviewNote, + resolveStateDegradedBaseStatus, + retrySettlementAfterValidationFailure, +} from "../pipeline/chapter-state-recovery.js"; + +function createBook(): BookConfig { + return { + id: "test-book", + title: "Test Book", + platform: "tomato", + genre: "xuanhuan", + status: "active", + targetChapters: 10, + chapterWordCount: 3000, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }; +} + +function createValidationWarning( + overrides: Partial = {}, +): ValidationWarning { + return { + category: overrides.category ?? "current-state", + description: overrides.description ?? "铜牌位置与正文矛盾", + }; +} + +function createValidationResult( + overrides: Partial = {}, +): ValidationResult { + return { + passed: overrides.passed ?? false, + warnings: overrides.warnings ?? [createValidationWarning()], + }; +} + +function createWriteChapterOutput( + overrides: Partial = {}, +): WriteChapterOutput { + return { + chapterNumber: 3, + title: "第三章", + content: "铜牌贴在胸口。", + wordCount: "铜牌贴在胸口。".length, + preWriteCheck: "ok", + postSettlement: "ok", + updatedState: "new state", + updatedLedger: "new ledger", + updatedHooks: "new hooks", + chapterSummary: "| 3 | 第三章 |", + updatedSubplots: "new subplots", + updatedEmotionalArcs: "new emotional arcs", + updatedCharacterMatrix: "new character matrix", + postWriteErrors: [], + postWriteWarnings: [], + ...overrides, + }; +} + +function createChapterMeta( + overrides: Partial = {}, +): ChapterMeta { + return { + number: 3, + title: "第三章", + status: "state-degraded", + wordCount: 1200, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + auditIssues: [], + lengthWarnings: [], + ...overrides, + }; +} + +describe("chapter-state-recovery", () => { + it("retries settlement with localized validation feedback and recovers on a clean retry", async () => { + let capturedFeedback = ""; + const writer = { + settleChapterState: vi.fn(async (input: { validationFeedback?: string }) => { + capturedFeedback = input.validationFeedback ?? ""; + return createWriteChapterOutput({ + updatedState: "fixed state", + updatedHooks: "fixed hooks", + }); + }), + }; + const validator = { + validate: vi.fn(async () => createValidationResult({ + passed: true, + warnings: [], + })), + }; + const logWarn = vi.fn(); + const warn = vi.fn(); + + const result = await retrySettlementAfterValidationFailure({ + writer: writer as never, + validator: validator as never, + book: createBook(), + bookDir: "/tmp/test-book", + chapterNumber: 3, + title: "第三章", + content: "铜牌贴在胸口。", + oldState: "old state", + oldHooks: "old hooks", + originalValidation: createValidationResult(), + language: "zh", + logWarn, + logger: { warn } as never, + }); + + expect(result.kind).toBe("recovered"); + expect(capturedFeedback).toContain("上一次状态结算未通过校验"); + expect(capturedFeedback).toContain("铜牌位置与正文矛盾"); + expect(logWarn).toHaveBeenCalledWith(expect.objectContaining({ + zh: expect.stringContaining("仅重试结算层"), + })); + expect(warn).not.toHaveBeenCalled(); + }); + + it("returns localized degraded issues when settlement retry still fails", async () => { + const validatorWarning = createValidationWarning({ + description: "挂坠状态仍与正文冲突", + }); + const result = await retrySettlementAfterValidationFailure({ + writer: { + settleChapterState: vi.fn(async () => createWriteChapterOutput()), + } as never, + validator: { + validate: vi.fn(async () => createValidationResult({ + passed: false, + warnings: [validatorWarning], + })), + } as never, + book: createBook(), + bookDir: "/tmp/test-book", + chapterNumber: 3, + title: "第三章", + content: "铜牌贴在胸口。", + oldState: "old state", + oldHooks: "old hooks", + originalValidation: createValidationResult({ + warnings: [validatorWarning], + }), + language: "zh", + logWarn: vi.fn(), + logger: { warn: vi.fn() } as never, + }); + + expect(result.kind).toBe("degraded"); + if (result.kind === "degraded") { + expect(result.issues).toEqual([ + expect.objectContaining({ + category: "state-validation", + description: "挂坠状态仍与正文冲突", + suggestion: "请先基于已保存正文修复本章 state,再继续后续章节。", + }), + ]); + } + }); + + it("freezes truth outputs when degrading persisted settlement", () => { + const output = createWriteChapterOutput({ + runtimeStateDelta: { chapter: 3 } as never, + runtimeStateSnapshot: { + chapter: 3, + facts: [], + hooks: [], + chapterSummary: undefined, + } as never, + updatedChapterSummaries: "| 3 | 新摘要 |", + }); + + const degraded = buildStateDegradedPersistenceOutput({ + output, + oldState: "stable state", + oldHooks: "stable hooks", + oldLedger: "stable ledger", + }); + + expect(degraded.updatedState).toBe("stable state"); + expect(degraded.updatedHooks).toBe("stable hooks"); + expect(degraded.updatedLedger).toBe("stable ledger"); + expect(degraded.runtimeStateDelta).toBeUndefined(); + expect(degraded.runtimeStateSnapshot).toBeUndefined(); + expect(degraded.updatedChapterSummaries).toBeUndefined(); + }); + + it("round-trips degraded review metadata and resolves fallback base status", () => { + const issues: AuditIssue[] = [{ + severity: "warning", + category: "state-validation", + description: "状态结算重试后仍未通过校验。", + suggestion: "请先基于已保存正文修复本章 state,再继续后续章节。", + }]; + const note = buildStateDegradedReviewNote("audit-failed", issues); + + expect(parseStateDegradedReviewNote(note)).toEqual({ + kind: "state-degraded", + baseStatus: "audit-failed", + injectedIssues: ["[warning] 状态结算重试后仍未通过校验。"], + }); + + expect(resolveStateDegradedBaseStatus(createChapterMeta({ + reviewNote: note, + }))).toBe("audit-failed"); + + expect(resolveStateDegradedBaseStatus(createChapterMeta({ + reviewNote: "{bad json", + auditIssues: ["[critical] still broken"], + }))).toBe("audit-failed"); + + expect(resolveStateDegradedBaseStatus(createChapterMeta({ + reviewNote: "{bad json", + auditIssues: ["[warning] needs review"], + }))).toBe("ready-for-review"); + }); +}); diff --git a/packages/core/src/pipeline/chapter-state-recovery.ts b/packages/core/src/pipeline/chapter-state-recovery.ts new file mode 100644 index 00000000..505ba392 --- /dev/null +++ b/packages/core/src/pipeline/chapter-state-recovery.ts @@ -0,0 +1,235 @@ +import type { AuditIssue } from "../agents/continuity.js"; +import type { + ValidationResult, + ValidationWarning, +} from "../agents/state-validator.js"; +import type { StateValidatorAgent } from "../agents/state-validator.js"; +import type { WriteChapterOutput } from "../agents/writer.js"; +import type { WriterAgent } from "../agents/writer.js"; +import type { Logger } from "../utils/logger.js"; +import type { BookConfig } from "../models/book.js"; +import type { ChapterMeta } from "../models/chapter.js"; +import type { ContextPackage, RuleStack } from "../models/input-governance.js"; +import type { LengthLanguage } from "../utils/length-metrics.js"; + +export interface SettlementRetryParams { + readonly writer: Pick; + readonly validator: Pick; + readonly book: BookConfig; + readonly bookDir: string; + readonly chapterNumber: number; + readonly title: string; + readonly content: string; + readonly reducedControlInput?: { + chapterIntent: string; + contextPackage: ContextPackage; + ruleStack: RuleStack; + }; + readonly oldState: string; + readonly oldHooks: string; + readonly originalValidation: ValidationResult; + readonly language: LengthLanguage; + readonly logWarn?: (message: { zh: string; en: string }) => void; + readonly logger?: Pick; +} + +export type SettlementRetryResult = + | { + readonly kind: "recovered"; + readonly output: WriteChapterOutput; + readonly validation: ValidationResult; + } + | { + readonly kind: "degraded"; + readonly issues: ReadonlyArray; + }; + +export async function retrySettlementAfterValidationFailure( + params: SettlementRetryParams, +): Promise { + params.logWarn?.({ + zh: `状态校验失败,正在仅重试结算层(第${params.chapterNumber}章)`, + en: `State validation failed; retrying settlement only for chapter ${params.chapterNumber}`, + }); + + const retryOutput = await params.writer.settleChapterState({ + book: params.book, + bookDir: params.bookDir, + chapterNumber: params.chapterNumber, + title: params.title, + content: params.content, + chapterIntent: params.reducedControlInput?.chapterIntent, + contextPackage: params.reducedControlInput?.contextPackage, + ruleStack: params.reducedControlInput?.ruleStack, + validationFeedback: buildStateValidationFeedback( + params.originalValidation.warnings, + params.language, + ), + }); + + let retryValidation: ValidationResult; + try { + retryValidation = await params.validator.validate( + params.content, + params.chapterNumber, + params.oldState, + retryOutput.updatedState, + params.oldHooks, + retryOutput.updatedHooks, + params.language, + ); + } catch (error) { + throw new Error(`State validation retry failed for chapter ${params.chapterNumber}: ${String(error)}`); + } + + if (retryValidation.warnings.length > 0) { + params.logWarn?.({ + zh: `状态校验重试后,第${params.chapterNumber}章仍有 ${retryValidation.warnings.length} 条警告`, + en: `State validation retry still reports ${retryValidation.warnings.length} warning(s) for chapter ${params.chapterNumber}`, + }); + for (const warning of retryValidation.warnings) { + params.logger?.warn(` [${warning.category}] ${warning.description}`); + } + } + + if (retryValidation.passed) { + return { + kind: "recovered", + output: retryOutput, + validation: retryValidation, + }; + } + + return { + kind: "degraded", + issues: buildStateDegradedIssues(retryValidation.warnings, params.language), + }; +} + +export function buildStateValidationFeedback( + warnings: ReadonlyArray, + language: LengthLanguage, +): string { + if (warnings.length === 0) { + return language === "en" + ? "The previous settlement contradicted the chapter text. Reconcile truth files strictly to the body." + : "上一次状态结算与正文矛盾。请严格以正文为准修正 truth files。"; + } + + if (language === "en") { + return [ + "The previous settlement failed validation. Fix these contradictions against the chapter body:", + ...warnings.map((warning) => `- [${warning.category}] ${warning.description}`), + ].join("\n"); + } + + return [ + "上一次状态结算未通过校验。请对照正文修正以下矛盾:", + ...warnings.map((warning) => `- [${warning.category}] ${warning.description}`), + ].join("\n"); +} + +export function buildStateDegradedIssues( + warnings: ReadonlyArray, + language: LengthLanguage, +): ReadonlyArray { + if (warnings.length > 0) { + return warnings.map((warning) => ({ + severity: "warning" as const, + category: "state-validation", + description: warning.description, + suggestion: language === "en" + ? "Repair chapter state from the persisted body before continuing." + : "请先基于已保存正文修复本章 state,再继续后续章节。", + })); + } + + return [{ + severity: "warning", + category: "state-validation", + description: language === "en" + ? "State validation still failed after settlement retry." + : "状态结算重试后仍未通过校验。", + suggestion: language === "en" + ? "Repair chapter state from the persisted body before continuing." + : "请先基于已保存正文修复本章 state,再继续后续章节。", + }]; +} + +export function buildStateDegradedPersistenceOutput(params: { + readonly output: WriteChapterOutput; + readonly oldState: string; + readonly oldHooks: string; + readonly oldLedger: string; +}): WriteChapterOutput { + return { + ...params.output, + runtimeStateDelta: undefined, + runtimeStateSnapshot: undefined, + updatedState: params.oldState, + updatedLedger: params.oldLedger, + updatedHooks: params.oldHooks, + updatedChapterSummaries: undefined, + }; +} + +export interface StateDegradedReviewNote { + readonly kind: "state-degraded"; + readonly baseStatus: "ready-for-review" | "audit-failed"; + readonly injectedIssues: ReadonlyArray; +} + +export function buildStateDegradedReviewNote( + baseStatus: "ready-for-review" | "audit-failed", + issues: ReadonlyArray, +): string { + return JSON.stringify({ + kind: "state-degraded", + baseStatus, + injectedIssues: issues.map((issue) => `[${issue.severity}] ${issue.description}`), + } satisfies StateDegradedReviewNote); +} + +export function parseStateDegradedReviewNote( + reviewNote?: string, +): StateDegradedReviewNote | null { + if (!reviewNote) { + return null; + } + + try { + const parsed = JSON.parse(reviewNote) as { + kind?: unknown; + baseStatus?: unknown; + injectedIssues?: unknown; + }; + if ( + parsed.kind !== "state-degraded" + || (parsed.baseStatus !== "ready-for-review" && parsed.baseStatus !== "audit-failed") + || !Array.isArray(parsed.injectedIssues) + ) { + return null; + } + + return { + kind: "state-degraded", + baseStatus: parsed.baseStatus, + injectedIssues: parsed.injectedIssues.filter((issue): issue is string => typeof issue === "string"), + }; + } catch { + return null; + } +} + +export function resolveStateDegradedBaseStatus( + chapter: Pick, +): "ready-for-review" | "audit-failed" { + const metadata = parseStateDegradedReviewNote(chapter.reviewNote); + if (metadata) { + return metadata.baseStatus; + } + + return chapter.auditIssues.some((issue) => issue.startsWith("[critical]")) + ? "audit-failed" + : "ready-for-review"; +} diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index b84e36c5..8adef485 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -34,6 +34,13 @@ import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js"; import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/promises"; import { join, relative } from "node:path"; +import { + buildStateDegradedPersistenceOutput, + buildStateDegradedReviewNote, + parseStateDegradedReviewNote, + resolveStateDegradedBaseStatus, + retrySettlementAfterValidationFailure, +} from "./chapter-state-recovery.js"; export interface PipelineConfig { readonly client: LLMClient; @@ -1297,7 +1304,7 @@ export class PipelineRunner { } } if (!validation.passed) { - const recovery = await this.retrySettlementAfterValidationFailure({ + const recovery = await retrySettlementAfterValidationFailure({ writer, validator, book, @@ -1310,6 +1317,8 @@ export class PipelineRunner { oldHooks, originalValidation: validation, language: pipelineLang, + logWarn: (message) => this.logWarn(pipelineLang, message), + logger: this.config.logger, }); if (recovery.kind === "recovered") { @@ -1318,7 +1327,7 @@ export class PipelineRunner { } else { chapterStatus = "state-degraded"; degradedIssues = recovery.issues; - persistenceOutput = this.buildStateDegradedPersistenceOutput({ + persistenceOutput = buildStateDegradedPersistenceOutput({ output: persistenceOutput, oldState, oldHooks, @@ -1388,7 +1397,7 @@ export class PipelineRunner { ), lengthWarnings, reviewNote: chapterStatus === "state-degraded" - ? this.buildStateDegradedReviewNote( + ? buildStateDegradedReviewNote( auditResult.passed ? "ready-for-review" : "audit-failed", degradedIssues, ) @@ -1514,7 +1523,7 @@ export class PipelineRunner { ); if (!validation.passed) { - const recovery = await this.retrySettlementAfterValidationFailure({ + const recovery = await retrySettlementAfterValidationFailure({ writer, validator, book, @@ -1526,6 +1535,8 @@ export class PipelineRunner { oldHooks, originalValidation: validation, language: pipelineLang, + logWarn: (message) => this.logWarn(pipelineLang, message), + logger: this.config.logger, }); if (recovery.kind !== "recovered") { throw new Error( @@ -1548,8 +1559,8 @@ export class PipelineRunner { await this.state.snapshotState(bookId, targetChapter); await this.syncCurrentStateFactHistory(bookId, targetChapter); - const baseStatus = this.resolveStateDegradedBaseStatus(targetMeta); - const degradedMetadata = this.parseStateDegradedReviewNote(targetMeta.reviewNote); + const baseStatus = resolveStateDegradedBaseStatus(targetMeta); + const degradedMetadata = parseStateDegradedReviewNote(targetMeta.reviewNote); const injectedIssues = new Set(degradedMetadata?.injectedIssues ?? []); index[targetIndex] = { ...targetMeta, @@ -2031,215 +2042,6 @@ ${matrix}`, ); } - private async retrySettlementAfterValidationFailure(params: { - readonly writer: WriterAgent; - readonly validator: StateValidatorAgent; - readonly book: BookConfig; - readonly bookDir: string; - readonly chapterNumber: number; - readonly title: string; - readonly content: string; - readonly reducedControlInput?: { - chapterIntent: string; - contextPackage: ContextPackage; - ruleStack: RuleStack; - }; - readonly oldState: string; - readonly oldHooks: string; - readonly originalValidation: ValidationResult; - readonly language: LengthLanguage; - }): Promise< - | { - readonly kind: "recovered"; - readonly output: WriteChapterOutput; - readonly validation: ValidationResult; - } - | { - readonly kind: "degraded"; - readonly issues: ReadonlyArray; - } - > { - this.logWarn(params.language, { - zh: `状态校验失败,正在仅重试结算层(第${params.chapterNumber}章)`, - en: `State validation failed; retrying settlement only for chapter ${params.chapterNumber}`, - }); - - const retryOutput = await params.writer.settleChapterState({ - book: params.book, - bookDir: params.bookDir, - chapterNumber: params.chapterNumber, - title: params.title, - content: params.content, - chapterIntent: params.reducedControlInput?.chapterIntent, - contextPackage: params.reducedControlInput?.contextPackage, - ruleStack: params.reducedControlInput?.ruleStack, - validationFeedback: this.buildStateValidationFeedback( - params.originalValidation.warnings, - params.language, - ), - }); - - let retryValidation: ValidationResult; - try { - retryValidation = await params.validator.validate( - params.content, - params.chapterNumber, - params.oldState, - retryOutput.updatedState, - params.oldHooks, - retryOutput.updatedHooks, - params.language, - ); - } catch (error) { - throw new Error(`State validation retry failed for chapter ${params.chapterNumber}: ${String(error)}`); - } - - if (retryValidation.warnings.length > 0) { - this.logWarn(params.language, { - zh: `状态校验重试后,第${params.chapterNumber}章仍有 ${retryValidation.warnings.length} 条警告`, - en: `State validation retry still reports ${retryValidation.warnings.length} warning(s) for chapter ${params.chapterNumber}`, - }); - for (const warning of retryValidation.warnings) { - this.config.logger?.warn(` [${warning.category}] ${warning.description}`); - } - } - - if (retryValidation.passed) { - return { - kind: "recovered", - output: retryOutput, - validation: retryValidation, - }; - } - - return { - kind: "degraded", - issues: this.buildStateDegradedIssues(retryValidation.warnings, params.language), - }; - } - - private buildStateValidationFeedback( - warnings: ReadonlyArray, - language: LengthLanguage, - ): string { - if (warnings.length === 0) { - return language === "en" - ? "The previous settlement contradicted the chapter text. Reconcile truth files strictly to the body." - : "上一次状态结算与正文矛盾。请严格以正文为准修正 truth files。"; - } - - if (language === "en") { - return [ - "The previous settlement failed validation. Fix these contradictions against the chapter body:", - ...warnings.map((warning) => `- [${warning.category}] ${warning.description}`), - ].join("\n"); - } - - return [ - "上一次状态结算未通过校验。请对照正文修正以下矛盾:", - ...warnings.map((warning) => `- [${warning.category}] ${warning.description}`), - ].join("\n"); - } - - private buildStateDegradedIssues( - warnings: ReadonlyArray, - language: LengthLanguage, - ): ReadonlyArray { - if (warnings.length > 0) { - return warnings.map((warning) => ({ - severity: "warning" as const, - category: "state-validation", - description: warning.description, - suggestion: language === "en" - ? "Repair chapter state from the persisted body before continuing." - : "请先基于已保存正文修复本章 state,再继续后续章节。", - })); - } - - return [{ - severity: "warning", - category: "state-validation", - description: language === "en" - ? "State validation still failed after settlement retry." - : "状态结算重试后仍未通过校验。", - suggestion: language === "en" - ? "Repair chapter state from the persisted body before continuing." - : "请先基于已保存正文修复本章 state,再继续后续章节。", - }]; - } - - private buildStateDegradedPersistenceOutput(params: { - readonly output: WriteChapterOutput; - readonly oldState: string; - readonly oldHooks: string; - readonly oldLedger: string; - }): WriteChapterOutput { - return { - ...params.output, - runtimeStateDelta: undefined, - runtimeStateSnapshot: undefined, - updatedState: params.oldState, - updatedLedger: params.oldLedger, - updatedHooks: params.oldHooks, - updatedChapterSummaries: undefined, - }; - } - - private buildStateDegradedReviewNote( - baseStatus: "ready-for-review" | "audit-failed", - issues: ReadonlyArray, - ): string { - return JSON.stringify({ - kind: "state-degraded", - baseStatus, - injectedIssues: issues.map((issue) => `[${issue.severity}] ${issue.description}`), - }); - } - - private parseStateDegradedReviewNote(reviewNote?: string): { - readonly kind: "state-degraded"; - readonly baseStatus: "ready-for-review" | "audit-failed"; - readonly injectedIssues: ReadonlyArray; - } | null { - if (!reviewNote) { - return null; - } - - try { - const parsed = JSON.parse(reviewNote) as { - kind?: unknown; - baseStatus?: unknown; - injectedIssues?: unknown; - }; - if ( - parsed.kind !== "state-degraded" - || (parsed.baseStatus !== "ready-for-review" && parsed.baseStatus !== "audit-failed") - || !Array.isArray(parsed.injectedIssues) - ) { - return null; - } - - return { - kind: "state-degraded", - baseStatus: parsed.baseStatus, - injectedIssues: parsed.injectedIssues.filter((issue): issue is string => typeof issue === "string"), - }; - } catch { - return null; - } - } - - private resolveStateDegradedBaseStatus(chapter: ChapterMeta): "ready-for-review" | "audit-failed" { - const metadata = this.parseStateDegradedReviewNote(chapter.reviewNote); - if (metadata) { - return metadata.baseStatus; - } - - return chapter.auditIssues.some((issue) => issue.startsWith("[critical]")) - ? "audit-failed" - : "ready-for-review"; - } - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- From 4248e1db561608de6b3871ae8963925b236c56ba Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 16:40:15 +0800 Subject: [PATCH 35/53] refactor(prompt): compact governed writer control surface --- packages/core/src/__tests__/writer.test.ts | 20 +- packages/core/src/agents/writer.ts | 317 ++++++++++++++------- 2 files changed, 234 insertions(+), 103 deletions(-) diff --git a/packages/core/src/__tests__/writer.test.ts b/packages/core/src/__tests__/writer.test.ts index 0070634e..d7fa9011 100644 --- a/packages/core/src/__tests__/writer.test.ts +++ b/packages/core/src/__tests__/writer.test.ts @@ -226,8 +226,9 @@ describe("WriterAgent", () => { }); const settlePrompt = (chatSpy.mock.calls[2]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? ""; - expect(settlePrompt).toContain("## 本章控制输入"); - expect(settlePrompt).toContain("story/chapter_summaries.md#99"); + expect(settlePrompt).toContain("## 结算焦点"); + expect(settlePrompt).not.toContain("### 已选上下文"); + expect(settlePrompt).not.toContain("## Hook Agenda"); expect(settlePrompt).toContain("| 99 | Locked Gate |"); expect(settlePrompt).toContain("## Hook Debt Briefs"); expect(settlePrompt).toContain("mentor-oath | cadence: slow-burn"); @@ -903,11 +904,12 @@ describe("WriterAgent", () => { }); const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? ""; - expect(creativePrompt).toContain("## Recent Title History"); + expect(creativePrompt).toContain("## Story Brief"); + expect(creativePrompt).not.toContain("## Recent Title History"); expect(creativePrompt).toContain("Ledger in Rain"); - expect(creativePrompt).toContain("## Recent Mood / Chapter Type Trail"); + expect(creativePrompt).not.toContain("## Recent Mood / Chapter Type Trail"); expect(creativePrompt).toContain("tight / investigation"); - expect(creativePrompt).toContain("## Canon Evidence"); + expect(creativePrompt).not.toContain("## Canon Evidence"); expect(creativePrompt).toContain("archive fire until volume two"); expect(creativePrompt).toContain("oath debt logic must stay intact"); } finally { @@ -1055,8 +1057,12 @@ describe("WriterAgent", () => { expect(systemPrompt).toContain("真实 hook_id"); expect(systemPrompt).toContain("preferred narrative response"); expect(systemPrompt).not.toContain("follow the requested move"); - expect(creativePrompt).toContain("## Hook Pressure and Debt"); - expect(creativePrompt).not.toContain("## Explicit Hook Agenda"); + expect(creativePrompt).toContain("## Story Brief"); + expect(creativePrompt).not.toContain("## Hook Pressure and Debt"); + expect(creativePrompt).not.toContain("## Selected Context"); + expect(creativePrompt).not.toContain("### Pressure Map"); + expect(creativePrompt).not.toContain("## Pending Hooks Snapshot"); + expect(creativePrompt).not.toContain("## Chapter Summaries Snapshot"); expect(creativePrompt).toContain("mentor-oath"); expect(creativePrompt).toContain("ledger-fragment"); expect(creativePrompt).toContain("stale-ledger"); diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index ae3478d4..65ac78bb 100644 --- a/packages/core/src/agents/writer.ts +++ b/packages/core/src/agents/writer.ts @@ -195,7 +195,6 @@ export class WriterAgent extends BaseAgent { lengthSpec: resolvedLengthSpec, language: book.language ?? genreProfile.language, varianceBrief: englishVarianceBrief?.text, - selectedEvidenceBlock: this.joinGovernedEvidenceBlocks(governedMemoryBlocks), }) : (() => { // Smart context filtering: inject only relevant parts of truth files @@ -810,64 +809,23 @@ ${lengthRequirementBlock} readonly lengthSpec: LengthSpec; readonly language?: "zh" | "en"; readonly varianceBrief?: string; - readonly selectedEvidenceBlock?: string; }): string { - const contextSections = params.contextPackage.selectedContext - .map((entry) => [ - `### ${entry.source}`, - `- reason: ${entry.reason}`, - entry.excerpt ? `- excerpt: ${entry.excerpt}` : "", - ].filter(Boolean).join("\n")) - .join("\n\n"); - - const overrideLines = params.ruleStack.activeOverrides.length > 0 - ? params.ruleStack.activeOverrides - .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`) - .join("\n") - : "- none"; - - const diagnosticLines = params.ruleStack.sections.diagnostic.length > 0 - ? params.ruleStack.sections.diagnostic.join(", ") - : "none"; - - const traceNotes = params.trace && params.trace.notes.length > 0 - ? params.trace.notes.map((note) => `- ${note}`).join("\n") - : "- none"; const lengthRequirementBlock = this.buildLengthRequirementBlock(params.lengthSpec, params.language ?? "zh"); const varianceBlock = params.varianceBrief ? `\n${params.varianceBrief}\n` : ""; - const selectedEvidenceBlock = params.selectedEvidenceBlock - ? `\n${params.selectedEvidenceBlock}\n` - : ""; - const explicitHookAgenda = this.extractMarkdownSection(params.chapterIntent, "## Hook Agenda"); - const hookAgendaBlock = explicitHookAgenda - ? params.language === "en" - ? `\n## Hook Pressure and Debt\n${explicitHookAgenda}\n` - : `\n## 伏笔压力与旧债\n${explicitHookAgenda}\n` - : ""; + const storyBrief = this.buildGovernedStoryBrief({ + chapterIntent: params.chapterIntent, + contextPackage: params.contextPackage, + ruleStack: params.ruleStack, + trace: params.trace, + language: params.language ?? "zh", + }); if (params.language === "en") { return `Write chapter ${params.chapterNumber}. -## Chapter Intent -${params.chapterIntent} - -## Selected Context -${contextSections || "(none)"} -${selectedEvidenceBlock} -${hookAgendaBlock} - -## Rule Stack -- Hard: ${params.ruleStack.sections.hard.join(", ") || "(none)"} -- Soft: ${params.ruleStack.sections.soft.join(", ") || "(none)"} -- Diagnostic: ${diagnosticLines} - -## Active Overrides -${overrideLines} - -## Trace Notes -${traceNotes} +${storyBrief} ${varianceBlock} ${lengthRequirementBlock} @@ -877,24 +835,7 @@ ${lengthRequirementBlock} return `请续写第${params.chapterNumber}章。 -## 本章意图 -${params.chapterIntent} - -## 已选上下文 -${contextSections || "(无)"} -${selectedEvidenceBlock} -${hookAgendaBlock} - -## 规则栈 -- 硬护栏:${params.ruleStack.sections.hard.join("、") || "(无)"} -- 软约束:${params.ruleStack.sections.soft.join("、") || "(无)"} -- 诊断规则:${diagnosticLines} - -## 当前覆盖 -${overrideLines} - -## 追踪说明 -${traceNotes} +${storyBrief} ${varianceBlock} ${lengthRequirementBlock} @@ -922,6 +863,73 @@ ${lengthRequirementBlock} return joined || undefined; } + private buildGovernedStoryBrief(params: { + readonly chapterIntent: string; + readonly contextPackage: ContextPackage; + readonly ruleStack: RuleStack; + readonly trace?: ChapterTrace; + readonly language: "zh" | "en"; + }): string { + const goal = this.readIntentScalar(params.chapterIntent, "## Goal"); + const mustKeep = this.readIntentList(params.chapterIntent, "## Must Keep"); + const mustAvoid = this.readIntentList(params.chapterIntent, "## Must Avoid"); + const styleEmphasis = this.readIntentList(params.chapterIntent, "## Style Emphasis"); + const creativePressure = this.readIntentList(params.chapterIntent, "## Creative Pressure"); + const hookFocus = this.buildHookFocusLines(params.chapterIntent, params.contextPackage, params.language); + const continuityAnchors = this.collectContextExcerpts(params.contextPackage, (entry) => + entry.source.startsWith("story/current_state.md") + || entry.source === "story/story_bible.md", + ); + const canonGuardrails = this.collectContextExcerpts(params.contextPackage, (entry) => + entry.source === "story/parent_canon.md" || entry.source === "story/fanfic_canon.md", + ); + const titleHistory = this.findContextExcerpt( + params.contextPackage, + "story/chapter_summaries.md#recent_titles", + ); + const moodTrail = this.findContextExcerpt( + params.contextPackage, + "story/chapter_summaries.md#recent_mood_type_trail", + ); + const overrideLines = params.ruleStack.activeOverrides + .map((override) => `${override.reason} (${override.target})`); + const traceNotes = params.trace?.notes.filter(Boolean) ?? []; + + if (params.language === "en") { + return [ + "## Story Brief", + goal ? `Goal: ${goal}` : "", + this.renderCompactList("Keep continuity on", mustKeep), + this.renderCompactList("Avoid this chapter", mustAvoid), + this.renderCompactList("Style emphasis", styleEmphasis), + this.renderCompactList("Creative pressure", creativePressure), + this.renderCompactList("Hook focus", hookFocus), + this.renderCompactList("Continuity anchors", continuityAnchors), + this.renderCompactList("Canon guardrails", canonGuardrails), + titleHistory ? `Recent title history: ${titleHistory}` : "", + moodTrail ? `Recent mood/type trail: ${moodTrail}` : "", + this.renderCompactList("Local overrides", overrideLines), + this.renderCompactList("Trace notes", traceNotes), + ].filter(Boolean).join("\n\n"); + } + + return [ + "## Story Brief", + goal ? `目标:${goal}` : "", + this.renderCompactList("本章必须守住", mustKeep), + this.renderCompactList("本章避免", mustAvoid), + this.renderCompactList("文风强调", styleEmphasis), + this.renderCompactList("创作压力", creativePressure), + this.renderCompactList("伏笔焦点", hookFocus), + this.renderCompactList("连续性锚点", continuityAnchors), + this.renderCompactList("正典护栏", canonGuardrails), + titleHistory ? `近期标题历史:${titleHistory}` : "", + moodTrail ? `近期情绪/章节类型轨迹:${moodTrail}` : "", + this.renderCompactList("局部覆盖", overrideLines), + this.renderCompactList("追踪备注", traceNotes), + ].filter(Boolean).join("\n\n"); + } + private extractMarkdownSection(content: string, heading: string): string | undefined { const lines = content.split("\n"); let buffer: string[] | null = null; @@ -951,44 +959,161 @@ ${lengthRequirementBlock} ruleStack: RuleStack, language: "zh" | "en", ): string { - const selectedContext = contextPackage.selectedContext - .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`) - .join("\n"); - const overrides = ruleStack.activeOverrides.length > 0 - ? ruleStack.activeOverrides - .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`) - .join("\n") - : "- none"; + const goal = this.readIntentScalar(chapterIntent, "## Goal"); + const mustKeep = this.readIntentList(chapterIntent, "## Must Keep"); + const hookFocus = this.buildHookFocusLines(chapterIntent, contextPackage, language); + const continuityAnchors = this.collectContextExcerpts(contextPackage, (entry) => + entry.source.startsWith("story/current_state.md") + || entry.source === "story/story_bible.md" + || entry.source === "story/parent_canon.md" + || entry.source === "story/fanfic_canon.md", + ); + const overrides = ruleStack.activeOverrides + .map((override) => `${override.reason} (${override.target})`); if (language === "en") { - return `\n## Chapter Control Inputs -${chapterIntent} + return `\n## Settlement Focus +${goal ? `Goal: ${goal}\n` : ""}${this.renderCompactList("Keep synced with", mustKeep)}${this.renderCompactList("Hook settlement cues", hookFocus)}${this.renderCompactList("Continuity anchors", continuityAnchors)}${this.renderCompactList("Local overrides", overrides)}\n`; + } -### Selected Context -${selectedContext || "- none"} + return `\n## 结算焦点 +${goal ? `目标:${goal}\n` : ""}${this.renderCompactList("结算时守住", mustKeep)}${this.renderCompactList("伏笔结算提示", hookFocus)}${this.renderCompactList("连续性锚点", continuityAnchors)}${this.renderCompactList("局部覆盖", overrides)}\n`; + } -### Rule Stack -- Hard guardrails: ${ruleStack.sections.hard.join(", ") || "(none)"} -- Soft constraints: ${ruleStack.sections.soft.join(", ") || "(none)"} -- Diagnostic rules: ${ruleStack.sections.diagnostic.join(", ") || "(none)"} + private readIntentScalar(chapterIntent: string, heading: string): string | undefined { + const section = this.extractMarkdownSection(chapterIntent, heading); + if (!section) { + return undefined; + } + + return section + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0 && !line.startsWith("-") && line.toLowerCase() !== "(not found)"); + } -### Active Overrides -${overrides}\n`; + private readIntentList(chapterIntent: string, heading: string): string[] { + const section = this.extractMarkdownSection(chapterIntent, heading); + if (!section) { + return []; } - return `\n## 本章控制输入 -${chapterIntent} + return section + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("- ")) + .map((line) => line.slice(2).trim()) + .filter((line) => line.length > 0 && line.toLowerCase() !== "none"); + } -### 已选上下文 -${selectedContext || "- none"} + private buildHookFocusLines( + chapterIntent: string, + contextPackage: ContextPackage, + language: "zh" | "en", + ): string[] { + const hookDebtLines = this.collectContextExcerpts( + contextPackage, + (entry) => entry.source.startsWith("runtime/hook_debt#"), + ); + const fallbackLines = [ + ...this.formatAgendaLines(chapterIntent, "Must Advance", language === "en" ? "Must advance" : "必须推进"), + ...this.formatAgendaLines(chapterIntent, "Eligible Resolve", language === "en" ? "Eligible payoff" : "可兑现"), + ...this.formatAgendaLines(chapterIntent, "Stale Debt", language === "en" ? "Stale debt" : "旧债"), + ...this.formatAgendaLines( + chapterIntent, + "Avoid New Hook Families", + language === "en" ? "Avoid new hook families" : "避免新增同类 hook", + ), + ]; + + return this.uniqueContextLines([ + ...(hookDebtLines.length > 0 ? hookDebtLines : []), + ...fallbackLines, + ]).slice(0, 6); + } + + private formatAgendaLines( + chapterIntent: string, + subheading: string, + label: string, + ): string[] { + const hookAgenda = this.extractMarkdownSection(chapterIntent, "## Hook Agenda"); + if (!hookAgenda) { + return []; + } + + const lines = hookAgenda.split("\n"); + const values: string[] = []; + let capture = false; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line === `### ${subheading}`) { + capture = true; + continue; + } + if (capture && line.startsWith("### ")) { + break; + } + if (capture && line.startsWith("- ")) { + const value = line.slice(2).trim(); + if (value && value.toLowerCase() !== "none") { + values.push(`${label}: ${value}`); + } + } + } + + return values; + } -### 规则栈 -- 硬护栏:${ruleStack.sections.hard.join("、") || "(无)"} -- 软约束:${ruleStack.sections.soft.join("、") || "(无)"} -- 诊断规则:${ruleStack.sections.diagnostic.join("、") || "(无)"} + private collectContextExcerpts( + contextPackage: ContextPackage, + predicate: (entry: ContextPackage["selectedContext"][number]) => boolean, + ): string[] { + return this.uniqueContextLines( + contextPackage.selectedContext + .filter(predicate) + .map((entry) => entry.excerpt ?? entry.reason) + .filter(Boolean), + ); + } + + private findContextExcerpt( + contextPackage: ContextPackage, + source: string, + ): string | undefined { + return contextPackage.selectedContext.find((entry) => entry.source === source)?.excerpt; + } + + private renderCompactList( + heading: string, + items: ReadonlyArray, + ): string { + if (items.length === 0) { + return ""; + } + + return `${heading}:\n${items.map((item) => `- ${item}`).join("\n")}`; + } + + private uniqueContextLines(items: ReadonlyArray): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const item of items) { + const trimmed = item.trim(); + if (!trimmed) { + continue; + } + const normalized = trimmed.toLowerCase(); + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(trimmed); + } -### 当前覆盖 -${overrides}\n`; + return result; } private buildLengthRequirementBlock(lengthSpec: LengthSpec, language: "zh" | "en"): string { From eb288b31f82d68a59dc6f23985728cdbe5457ae9 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 17:12:15 +0800 Subject: [PATCH 36/53] refactor(prompt): share compact governed process briefs --- .../src/__tests__/chapter-analyzer.test.ts | 3 +- .../core/src/__tests__/continuity.test.ts | 2 + packages/core/src/__tests__/reviser.test.ts | 8 +- packages/core/src/agents/chapter-analyzer.ts | 53 +-- packages/core/src/agents/continuity.ts | 53 +-- packages/core/src/agents/reviser.ts | 37 +- packages/core/src/agents/writer.ts | 242 +----------- .../core/src/utils/compiled-control-brief.ts | 360 ++++++++++++++++++ 8 files changed, 401 insertions(+), 357 deletions(-) create mode 100644 packages/core/src/utils/compiled-control-brief.ts diff --git a/packages/core/src/__tests__/chapter-analyzer.test.ts b/packages/core/src/__tests__/chapter-analyzer.test.ts index fc8337d8..21247980 100644 --- a/packages/core/src/__tests__/chapter-analyzer.test.ts +++ b/packages/core/src/__tests__/chapter-analyzer.test.ts @@ -561,7 +561,8 @@ describe("ChapterAnalyzerAgent", () => { const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>; const userPrompt = messages[1]?.content ?? ""; - expect(userPrompt).toContain("## Chapter Control Inputs (compiled by Planner/Composer)"); + expect(userPrompt).toContain("## Analysis Brief"); + expect(userPrompt).not.toContain("## Chapter Control Inputs (compiled by Planner/Composer)"); expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath"); expect(userPrompt).toContain("Selected Hook Evidence"); expect(userPrompt).not.toContain("## Story Bible"); diff --git a/packages/core/src/__tests__/continuity.test.ts b/packages/core/src/__tests__/continuity.test.ts index 45a98a4f..93022b27 100644 --- a/packages/core/src/__tests__/continuity.test.ts +++ b/packages/core/src/__tests__/continuity.test.ts @@ -281,6 +281,8 @@ describe("ContinuityAuditor", () => { | undefined; const userPrompt = messages?.[1]?.content ?? ""; + expect(userPrompt).toContain("## Review Brief"); + expect(userPrompt).not.toContain("## Chapter Control Inputs (compiled by Planner/Composer)"); expect(userPrompt).toContain("story/chapter_summaries.md#99"); expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath"); expect(userPrompt).not.toContain("| 1 | Guild Trail |"); diff --git a/packages/core/src/__tests__/reviser.test.ts b/packages/core/src/__tests__/reviser.test.ts index 66bf92b9..983ece62 100644 --- a/packages/core/src/__tests__/reviser.test.ts +++ b/packages/core/src/__tests__/reviser.test.ts @@ -453,10 +453,14 @@ describe("ReviserAgent", () => { | undefined; const userPrompt = messages?.[1]?.content ?? ""; + expect(userPrompt).toContain("## Revision Brief"); + expect(userPrompt).not.toContain("## 本章控制输入(由 Planner/Composer 编译)"); expect(userPrompt).toContain("story/chapter_summaries.md#99"); expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath"); - expect(userPrompt).toContain("story/story_bible.md"); - expect(userPrompt).toContain("story/volume_outline.md"); + expect(userPrompt).toContain("The jade seal cannot be destroyed."); + expect(userPrompt).toContain("Track the mentor oath fallout."); + expect(userPrompt).not.toContain("story/story_bible.md"); + expect(userPrompt).not.toContain("story/volume_outline.md"); expect(userPrompt).not.toContain("| 1 | Guild Trail |"); expect(userPrompt).not.toContain("guild-route | 1 | mystery"); expect(userPrompt).not.toContain("Guildmaster Ren secretly forged the harbor roster in chapter 140."); diff --git a/packages/core/src/agents/chapter-analyzer.ts b/packages/core/src/agents/chapter-analyzer.ts index bce83406..61d54a63 100644 --- a/packages/core/src/agents/chapter-analyzer.ts +++ b/packages/core/src/agents/chapter-analyzer.ts @@ -12,6 +12,7 @@ import { import { filterEmotionalArcs, filterSubplots } from "../utils/context-filter.js"; import { countChapterLength, resolveLengthCountingMode } from "../utils/length-metrics.js"; import { retrieveMemorySelection } from "../utils/memory-retrieval.js"; +import { buildProcessBrief } from "../utils/compiled-control-brief.js"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; @@ -95,7 +96,13 @@ export class ChapterAnalyzerAgent extends BaseAgent { }) : characterMatrix; const reducedControlBlock = governedMode && input.chapterIntent && input.contextPackage && input.ruleStack - ? this.buildReducedControlBlock(input.chapterIntent, input.contextPackage, input.ruleStack, resolvedLanguage) + ? `\n${buildProcessBrief({ + kind: "analysis", + chapterIntent: input.chapterIntent, + contextPackage: input.contextPackage, + ruleStack: input.ruleStack, + language: resolvedLanguage, + })}\n` : ""; const systemPrompt = this.buildSystemPrompt( @@ -461,50 +468,6 @@ ${params.hooksBlock}${params.volumeSummariesBlock}${params.subplotBlock}${params 请严格按照 === TAG === 格式输出分析结果。`; } - private buildReducedControlBlock( - chapterIntent: string, - contextPackage: ContextPackage, - ruleStack: RuleStack, - language: "zh" | "en", - ): string { - const selectedContext = contextPackage.selectedContext - .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`) - .join("\n"); - const overrides = ruleStack.activeOverrides.length > 0 - ? ruleStack.activeOverrides - .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`) - .join("\n") - : "- none"; - - return language === "en" - ? `\n## Chapter Control Inputs (compiled by Planner/Composer) -${chapterIntent} - -### Selected Context -${selectedContext || "- none"} - -### Rule Stack -- Hard guardrails: ${ruleStack.sections.hard.join(", ") || "(none)"} -- Soft constraints: ${ruleStack.sections.soft.join(", ") || "(none)"} -- Diagnostic rules: ${ruleStack.sections.diagnostic.join(", ") || "(none)"} - -### Active Overrides -${overrides}\n` - : `\n## 本章控制输入(由 Planner/Composer 编译) -${chapterIntent} - -### 已选上下文 -${selectedContext || "- none"} - -### 规则栈 -- 硬护栏:${ruleStack.sections.hard.join("、") || "(无)"} -- 软约束:${ruleStack.sections.soft.join("、") || "(无)"} -- 诊断规则:${ruleStack.sections.diagnostic.join("、") || "(无)"} - -### 当前覆盖 -${overrides}\n`; - } - private buildMemoryGoal(chapterTitle: string | undefined, chapterContent: string): string { return [chapterTitle ?? "", chapterContent.slice(0, 1500)] .filter((part) => part.trim().length > 0) diff --git a/packages/core/src/agents/continuity.ts b/packages/core/src/agents/continuity.ts index d38e9eda..fa846da4 100644 --- a/packages/core/src/agents/continuity.ts +++ b/packages/core/src/agents/continuity.ts @@ -8,6 +8,7 @@ import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions 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 { buildProcessBrief } from "../utils/compiled-control-brief.js"; import { join } from "node:path"; export interface AuditResult { @@ -498,7 +499,13 @@ ${dimList} : `\n## 卷纲(用于大纲偏离检测)\n${volumeOutline}\n` : ""; const reducedControlBlock = options?.chapterIntent && options.contextPackage && options.ruleStack - ? this.buildReducedControlBlock(options.chapterIntent, options.contextPackage, options.ruleStack, resolvedLanguage) + ? `\n${buildProcessBrief({ + kind: "review", + chapterIntent: options.chapterIntent, + contextPackage: options.contextPackage, + ruleStack: options.ruleStack, + language: resolvedLanguage, + })}\n` : ""; const styleGuideBlock = reducedControlBlock.length === 0 ? isEnglish @@ -618,50 +625,6 @@ ${chapterContent}`; }; } - private buildReducedControlBlock( - chapterIntent: string, - contextPackage: ContextPackage, - ruleStack: RuleStack, - language: PromptLanguage, - ): string { - const selectedContext = contextPackage.selectedContext - .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`) - .join("\n"); - const overrides = ruleStack.activeOverrides.length > 0 - ? ruleStack.activeOverrides - .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`) - .join("\n") - : "- none"; - - return language === "en" - ? `\n## Chapter Control Inputs (compiled by Planner/Composer) -${chapterIntent} - -### Selected Context -${selectedContext || "- none"} - -### Rule Stack -- Hard guardrails: ${ruleStack.sections.hard.join(", ") || "(none)"} -- Soft constraints: ${ruleStack.sections.soft.join(", ") || "(none)"} -- Diagnostic rules: ${ruleStack.sections.diagnostic.join(", ") || "(none)"} - -### Active Overrides -${overrides}\n` - : `\n## 本章控制输入(由 Planner/Composer 编译) -${chapterIntent} - -### 已选上下文 -${selectedContext || "- none"} - -### 规则栈 -- 硬护栏:${ruleStack.sections.hard.join("、") || "(无)"} -- 软约束:${ruleStack.sections.soft.join("、") || "(无)"} -- 诊断规则:${ruleStack.sections.diagnostic.join("、") || "(无)"} - -### 当前覆盖 -${overrides}\n`; - } - private extractBalancedJson(text: string): string | null { const start = text.indexOf("{"); if (start === -1) return null; diff --git a/packages/core/src/agents/reviser.ts b/packages/core/src/agents/reviser.ts index 765a14b0..bc28f9e0 100644 --- a/packages/core/src/agents/reviser.ts +++ b/packages/core/src/agents/reviser.ts @@ -13,6 +13,7 @@ import { buildGovernedHookWorkingSet, mergeTableMarkdownByKey, } from "../utils/governed-working-set.js"; +import { buildProcessBrief } from "../utils/compiled-control-brief.js"; import { applySpotFixPatches, parseSpotFixPatches } from "../utils/spot-fix-patches.js"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; @@ -225,7 +226,13 @@ ${outputFormat}`; ? `\n## 同人正典参照(修稿专用)\n本书为同人作品。修改时参照正典角色档案和世界规则,不可违反正典事实。角色对话必须保留原作语癖。\n${fanficCanon}\n` : ""; const reducedControlBlock = options?.chapterIntent && options.contextPackage && options.ruleStack - ? this.buildReducedControlBlock(options.chapterIntent, options.contextPackage, options.ruleStack) + ? `\n${buildProcessBrief({ + kind: "revision", + chapterIntent: options.chapterIntent, + contextPackage: options.contextPackage, + ruleStack: options.ruleStack, + language: resolvedLanguage, + })}\n` : ""; const lengthGuidanceBlock = options?.lengthSpec ? `\n## 字数护栏\n目标字数:${options.lengthSpec.target}\n允许区间:${options.lengthSpec.softMin}-${options.lengthSpec.softMax}\n极限区间:${options.lengthSpec.hardMin}-${options.lengthSpec.hardMax}\n如果修正后超出允许区间,请优先压缩冗余解释、重复动作和弱信息句,不得新增支线或删掉核心事实。\n` @@ -328,32 +335,4 @@ ${chapterContent}`; } } - private buildReducedControlBlock( - chapterIntent: string, - contextPackage: ContextPackage, - ruleStack: RuleStack, - ): string { - const selectedContext = contextPackage.selectedContext - .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`) - .join("\n"); - const overrides = ruleStack.activeOverrides.length > 0 - ? ruleStack.activeOverrides - .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`) - .join("\n") - : "- none"; - - return `\n## 本章控制输入(由 Planner/Composer 编译) -${chapterIntent} - -### 已选上下文 -${selectedContext || "- none"} - -### 规则栈 -- 硬护栏:${ruleStack.sections.hard.join("、") || "(无)"} -- 软约束:${ruleStack.sections.soft.join("、") || "(无)"} -- 诊断规则:${ruleStack.sections.diagnostic.join("、") || "(无)"} - -### 当前覆盖 -${overrides}\n`; - } } diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index 65ac78bb..d0b705b8 100644 --- a/packages/core/src/agents/writer.ts +++ b/packages/core/src/agents/writer.ts @@ -34,6 +34,7 @@ import type { RuntimeStateSnapshot } from "../state/state-reducer.js"; import { parsePendingHooksMarkdown } from "../utils/memory-retrieval.js"; import { analyzeHookHealth } from "../utils/hook-health.js"; import { buildEnglishVarianceBrief } from "../utils/long-span-fatigue.js"; +import { buildSettlementFocus, buildStoryBrief } from "../utils/compiled-control-brief.js"; import { readFile, writeFile, mkdir, readdir } from "node:fs/promises"; import { join } from "node:path"; @@ -870,87 +871,7 @@ ${lengthRequirementBlock} readonly trace?: ChapterTrace; readonly language: "zh" | "en"; }): string { - const goal = this.readIntentScalar(params.chapterIntent, "## Goal"); - const mustKeep = this.readIntentList(params.chapterIntent, "## Must Keep"); - const mustAvoid = this.readIntentList(params.chapterIntent, "## Must Avoid"); - const styleEmphasis = this.readIntentList(params.chapterIntent, "## Style Emphasis"); - const creativePressure = this.readIntentList(params.chapterIntent, "## Creative Pressure"); - const hookFocus = this.buildHookFocusLines(params.chapterIntent, params.contextPackage, params.language); - const continuityAnchors = this.collectContextExcerpts(params.contextPackage, (entry) => - entry.source.startsWith("story/current_state.md") - || entry.source === "story/story_bible.md", - ); - const canonGuardrails = this.collectContextExcerpts(params.contextPackage, (entry) => - entry.source === "story/parent_canon.md" || entry.source === "story/fanfic_canon.md", - ); - const titleHistory = this.findContextExcerpt( - params.contextPackage, - "story/chapter_summaries.md#recent_titles", - ); - const moodTrail = this.findContextExcerpt( - params.contextPackage, - "story/chapter_summaries.md#recent_mood_type_trail", - ); - const overrideLines = params.ruleStack.activeOverrides - .map((override) => `${override.reason} (${override.target})`); - const traceNotes = params.trace?.notes.filter(Boolean) ?? []; - - if (params.language === "en") { - return [ - "## Story Brief", - goal ? `Goal: ${goal}` : "", - this.renderCompactList("Keep continuity on", mustKeep), - this.renderCompactList("Avoid this chapter", mustAvoid), - this.renderCompactList("Style emphasis", styleEmphasis), - this.renderCompactList("Creative pressure", creativePressure), - this.renderCompactList("Hook focus", hookFocus), - this.renderCompactList("Continuity anchors", continuityAnchors), - this.renderCompactList("Canon guardrails", canonGuardrails), - titleHistory ? `Recent title history: ${titleHistory}` : "", - moodTrail ? `Recent mood/type trail: ${moodTrail}` : "", - this.renderCompactList("Local overrides", overrideLines), - this.renderCompactList("Trace notes", traceNotes), - ].filter(Boolean).join("\n\n"); - } - - return [ - "## Story Brief", - goal ? `目标:${goal}` : "", - this.renderCompactList("本章必须守住", mustKeep), - this.renderCompactList("本章避免", mustAvoid), - this.renderCompactList("文风强调", styleEmphasis), - this.renderCompactList("创作压力", creativePressure), - this.renderCompactList("伏笔焦点", hookFocus), - this.renderCompactList("连续性锚点", continuityAnchors), - this.renderCompactList("正典护栏", canonGuardrails), - titleHistory ? `近期标题历史:${titleHistory}` : "", - moodTrail ? `近期情绪/章节类型轨迹:${moodTrail}` : "", - this.renderCompactList("局部覆盖", overrideLines), - this.renderCompactList("追踪备注", traceNotes), - ].filter(Boolean).join("\n\n"); - } - - private extractMarkdownSection(content: string, heading: string): string | undefined { - const lines = content.split("\n"); - let buffer: string[] | null = null; - - for (const line of lines) { - if (line.trim() === heading) { - buffer = []; - continue; - } - - if (buffer && line.startsWith("## ") && line.trim() !== heading) { - break; - } - - if (buffer) { - buffer.push(line); - } - } - - const section = buffer?.join("\n").trim(); - return section && section.length > 0 ? section : undefined; + return buildStoryBrief(params); } private buildSettlerGovernedControlBlock( @@ -959,161 +880,12 @@ ${lengthRequirementBlock} ruleStack: RuleStack, language: "zh" | "en", ): string { - const goal = this.readIntentScalar(chapterIntent, "## Goal"); - const mustKeep = this.readIntentList(chapterIntent, "## Must Keep"); - const hookFocus = this.buildHookFocusLines(chapterIntent, contextPackage, language); - const continuityAnchors = this.collectContextExcerpts(contextPackage, (entry) => - entry.source.startsWith("story/current_state.md") - || entry.source === "story/story_bible.md" - || entry.source === "story/parent_canon.md" - || entry.source === "story/fanfic_canon.md", - ); - const overrides = ruleStack.activeOverrides - .map((override) => `${override.reason} (${override.target})`); - - if (language === "en") { - return `\n## Settlement Focus -${goal ? `Goal: ${goal}\n` : ""}${this.renderCompactList("Keep synced with", mustKeep)}${this.renderCompactList("Hook settlement cues", hookFocus)}${this.renderCompactList("Continuity anchors", continuityAnchors)}${this.renderCompactList("Local overrides", overrides)}\n`; - } - - return `\n## 结算焦点 -${goal ? `目标:${goal}\n` : ""}${this.renderCompactList("结算时守住", mustKeep)}${this.renderCompactList("伏笔结算提示", hookFocus)}${this.renderCompactList("连续性锚点", continuityAnchors)}${this.renderCompactList("局部覆盖", overrides)}\n`; - } - - private readIntentScalar(chapterIntent: string, heading: string): string | undefined { - const section = this.extractMarkdownSection(chapterIntent, heading); - if (!section) { - return undefined; - } - - return section - .split("\n") - .map((line) => line.trim()) - .find((line) => line.length > 0 && !line.startsWith("-") && line.toLowerCase() !== "(not found)"); - } - - private readIntentList(chapterIntent: string, heading: string): string[] { - const section = this.extractMarkdownSection(chapterIntent, heading); - if (!section) { - return []; - } - - return section - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("- ")) - .map((line) => line.slice(2).trim()) - .filter((line) => line.length > 0 && line.toLowerCase() !== "none"); - } - - private buildHookFocusLines( - chapterIntent: string, - contextPackage: ContextPackage, - language: "zh" | "en", - ): string[] { - const hookDebtLines = this.collectContextExcerpts( + return buildSettlementFocus({ + chapterIntent, contextPackage, - (entry) => entry.source.startsWith("runtime/hook_debt#"), - ); - const fallbackLines = [ - ...this.formatAgendaLines(chapterIntent, "Must Advance", language === "en" ? "Must advance" : "必须推进"), - ...this.formatAgendaLines(chapterIntent, "Eligible Resolve", language === "en" ? "Eligible payoff" : "可兑现"), - ...this.formatAgendaLines(chapterIntent, "Stale Debt", language === "en" ? "Stale debt" : "旧债"), - ...this.formatAgendaLines( - chapterIntent, - "Avoid New Hook Families", - language === "en" ? "Avoid new hook families" : "避免新增同类 hook", - ), - ]; - - return this.uniqueContextLines([ - ...(hookDebtLines.length > 0 ? hookDebtLines : []), - ...fallbackLines, - ]).slice(0, 6); - } - - private formatAgendaLines( - chapterIntent: string, - subheading: string, - label: string, - ): string[] { - const hookAgenda = this.extractMarkdownSection(chapterIntent, "## Hook Agenda"); - if (!hookAgenda) { - return []; - } - - const lines = hookAgenda.split("\n"); - const values: string[] = []; - let capture = false; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (line === `### ${subheading}`) { - capture = true; - continue; - } - if (capture && line.startsWith("### ")) { - break; - } - if (capture && line.startsWith("- ")) { - const value = line.slice(2).trim(); - if (value && value.toLowerCase() !== "none") { - values.push(`${label}: ${value}`); - } - } - } - - return values; - } - - private collectContextExcerpts( - contextPackage: ContextPackage, - predicate: (entry: ContextPackage["selectedContext"][number]) => boolean, - ): string[] { - return this.uniqueContextLines( - contextPackage.selectedContext - .filter(predicate) - .map((entry) => entry.excerpt ?? entry.reason) - .filter(Boolean), - ); - } - - private findContextExcerpt( - contextPackage: ContextPackage, - source: string, - ): string | undefined { - return contextPackage.selectedContext.find((entry) => entry.source === source)?.excerpt; - } - - private renderCompactList( - heading: string, - items: ReadonlyArray, - ): string { - if (items.length === 0) { - return ""; - } - - return `${heading}:\n${items.map((item) => `- ${item}`).join("\n")}`; - } - - private uniqueContextLines(items: ReadonlyArray): string[] { - const seen = new Set(); - const result: string[] = []; - - for (const item of items) { - const trimmed = item.trim(); - if (!trimmed) { - continue; - } - const normalized = trimmed.toLowerCase(); - if (seen.has(normalized)) { - continue; - } - seen.add(normalized); - result.push(trimmed); - } - - return result; + ruleStack, + language, + }); } private buildLengthRequirementBlock(lengthSpec: LengthSpec, language: "zh" | "en"): string { diff --git a/packages/core/src/utils/compiled-control-brief.ts b/packages/core/src/utils/compiled-control-brief.ts new file mode 100644 index 00000000..6a5b3968 --- /dev/null +++ b/packages/core/src/utils/compiled-control-brief.ts @@ -0,0 +1,360 @@ +import type { ChapterTrace, ContextPackage, RuleStack } from "../models/input-governance.js"; + +type PromptLanguage = "zh" | "en"; +type ProcessBriefKind = "analysis" | "review" | "revision"; + +interface BriefInput { + readonly chapterIntent: string; + readonly contextPackage: ContextPackage; + readonly ruleStack: RuleStack; + readonly language: PromptLanguage; + readonly trace?: ChapterTrace; +} + +interface CompiledControlSummary { + readonly goal?: string; + readonly mustKeep: string[]; + readonly mustAvoid: string[]; + readonly styleEmphasis: string[]; + readonly creativePressure: string[]; + readonly hookFocus: string[]; + readonly stateAnchors: string[]; + readonly storyGuardrails: string[]; + readonly outlineAnchors: string[]; + readonly canonGuardrails: string[]; + readonly titleHistory?: string; + readonly moodTrail?: string; + readonly overrideLines: string[]; + readonly traceNotes: string[]; +} + +export function buildStoryBrief(params: BriefInput): string { + const summary = compileControlSummary(params); + const continuityAnchors = uniqueContextLines([ + ...summary.stateAnchors, + ...summary.storyGuardrails, + ...summary.outlineAnchors, + ]); + + if (params.language === "en") { + return [ + "## Story Brief", + summary.goal ? `Goal: ${summary.goal}` : "", + renderCompactList("Keep continuity on", summary.mustKeep), + renderCompactList("Avoid this chapter", summary.mustAvoid), + renderCompactList("Style emphasis", summary.styleEmphasis), + renderCompactList("Creative pressure", summary.creativePressure), + renderCompactList("Hook focus", summary.hookFocus), + renderCompactList("Continuity anchors", continuityAnchors), + renderCompactList("Canon guardrails", summary.canonGuardrails), + summary.titleHistory ? `Recent title history: ${summary.titleHistory}` : "", + summary.moodTrail ? `Recent mood/type trail: ${summary.moodTrail}` : "", + renderCompactList("Local overrides", summary.overrideLines), + renderCompactList("Trace notes", summary.traceNotes), + ].filter(Boolean).join("\n\n"); + } + + return [ + "## Story Brief", + summary.goal ? `目标:${summary.goal}` : "", + renderCompactList("本章必须守住", summary.mustKeep), + renderCompactList("本章避免", summary.mustAvoid), + renderCompactList("文风强调", summary.styleEmphasis), + renderCompactList("创作压力", summary.creativePressure), + renderCompactList("伏笔焦点", summary.hookFocus), + renderCompactList("连续性锚点", continuityAnchors), + renderCompactList("正典护栏", summary.canonGuardrails), + summary.titleHistory ? `近期标题历史:${summary.titleHistory}` : "", + summary.moodTrail ? `近期情绪/章节类型轨迹:${summary.moodTrail}` : "", + renderCompactList("局部覆盖", summary.overrideLines), + renderCompactList("追踪备注", summary.traceNotes), + ].filter(Boolean).join("\n\n"); +} + +export function buildSettlementFocus(params: Omit): string { + const summary = compileControlSummary(params); + const continuityAnchors = uniqueContextLines([ + ...summary.stateAnchors, + ...summary.storyGuardrails, + ...summary.outlineAnchors, + ...summary.canonGuardrails, + ]); + + if (params.language === "en") { + return `\n## Settlement Focus +${summary.goal ? `Goal: ${summary.goal}\n` : ""}${renderCompactList("Keep synced with", summary.mustKeep)}${renderCompactList("Hook settlement cues", summary.hookFocus)}${renderCompactList("Continuity anchors", continuityAnchors)}${renderCompactList("Local overrides", summary.overrideLines)}\n`; + } + + return `\n## 结算焦点 +${summary.goal ? `目标:${summary.goal}\n` : ""}${renderCompactList("结算时守住", summary.mustKeep)}${renderCompactList("伏笔结算提示", summary.hookFocus)}${renderCompactList("连续性锚点", continuityAnchors)}${renderCompactList("局部覆盖", summary.overrideLines)}\n`; +} + +export function buildProcessBrief( + params: BriefInput & { readonly kind: ProcessBriefKind }, +): string { + const summary = compileControlSummary(params); + const heading = params.kind === "analysis" + ? "## Analysis Brief" + : params.kind === "review" + ? "## Review Brief" + : "## Revision Brief"; + + if (params.language === "en") { + return [ + heading, + summary.goal ? `Goal: ${summary.goal}` : "", + renderCompactList( + params.kind === "analysis" + ? "Keep analysis aligned with" + : params.kind === "review" + ? "Review against" + : "Preserve while revising", + summary.mustKeep, + ), + renderCompactList( + params.kind === "analysis" + ? "Avoid misreading" + : params.kind === "review" + ? "Flag if chapter drifts into" + : "Avoid while revising", + summary.mustAvoid, + ), + renderCompactList( + params.kind === "analysis" + ? "Update hook state around" + : params.kind === "review" + ? "Hook focus" + : "Hook details to preserve", + summary.hookFocus, + ), + renderCompactList("Current-state anchors", summary.stateAnchors), + renderCompactList("Outline anchor", summary.outlineAnchors), + renderCompactList("World rules", summary.storyGuardrails), + renderCompactList("Canon guardrails", summary.canonGuardrails), + renderCompactList("Local overrides", summary.overrideLines), + params.kind === "analysis" ? renderCompactList("Trace notes", summary.traceNotes) : "", + ].filter(Boolean).join("\n\n"); + } + + return [ + heading, + summary.goal ? `目标:${summary.goal}` : "", + renderCompactList( + params.kind === "analysis" + ? "更新时守住" + : params.kind === "review" + ? "重点核对" + : "修订时守住", + summary.mustKeep, + ), + renderCompactList( + params.kind === "analysis" + ? "避免误读" + : params.kind === "review" + ? "若出现则重点标记" + : "修订时避免", + summary.mustAvoid, + ), + renderCompactList( + params.kind === "analysis" + ? "更新伏笔时参考" + : params.kind === "review" + ? "伏笔焦点" + : "伏笔细节保留", + summary.hookFocus, + ), + renderCompactList("当前状态锚点", summary.stateAnchors), + renderCompactList("卷纲锚点", summary.outlineAnchors), + renderCompactList("世界规则", summary.storyGuardrails), + renderCompactList("正典护栏", summary.canonGuardrails), + renderCompactList("局部覆盖", summary.overrideLines), + params.kind === "analysis" ? renderCompactList("追踪备注", summary.traceNotes) : "", + ].filter(Boolean).join("\n\n"); +} + +function compileControlSummary(params: BriefInput): CompiledControlSummary { + return { + goal: readIntentScalar(params.chapterIntent, "## Goal"), + mustKeep: readIntentList(params.chapterIntent, "## Must Keep"), + mustAvoid: readIntentList(params.chapterIntent, "## Must Avoid"), + styleEmphasis: readIntentList(params.chapterIntent, "## Style Emphasis"), + creativePressure: readIntentList(params.chapterIntent, "## Creative Pressure"), + hookFocus: buildHookFocusLines(params.chapterIntent, params.contextPackage, params.language), + stateAnchors: collectContextExcerpts(params.contextPackage, (entry) => + entry.source.startsWith("story/current_state.md"), + ), + storyGuardrails: collectContextExcerpts(params.contextPackage, (entry) => + entry.source === "story/story_bible.md", + ), + outlineAnchors: collectContextExcerpts(params.contextPackage, (entry) => + entry.source === "story/volume_outline.md", + ), + canonGuardrails: collectContextExcerpts(params.contextPackage, (entry) => + entry.source === "story/parent_canon.md" || entry.source === "story/fanfic_canon.md", + ), + titleHistory: findContextExcerpt(params.contextPackage, "story/chapter_summaries.md#recent_titles"), + moodTrail: findContextExcerpt( + params.contextPackage, + "story/chapter_summaries.md#recent_mood_type_trail", + ), + overrideLines: params.ruleStack.activeOverrides + .map((override) => `${override.reason} (${override.target})`), + traceNotes: params.trace?.notes.filter(Boolean) ?? [], + }; +} + +function readIntentScalar(chapterIntent: string, heading: string): string | undefined { + const section = extractMarkdownSection(chapterIntent, heading); + if (!section) { + return undefined; + } + + return section + .split("\n") + .map((value) => value.trim()) + .find((value) => value.length > 0 && !value.startsWith("-") && value.toLowerCase() !== "(not found)"); +} + +function readIntentList(chapterIntent: string, heading: string): string[] { + const section = extractMarkdownSection(chapterIntent, heading); + if (!section) { + return []; + } + + return section + .split("\n") + .map((value) => value.trim()) + .filter((value) => value.startsWith("- ")) + .map((value) => value.slice(2).trim()) + .filter((value) => value.length > 0 && value.toLowerCase() !== "none"); +} + +function extractMarkdownSection(content: string, heading: string): string | undefined { + const lines = content.split("\n"); + let buffer: string[] | null = null; + + for (const line of lines) { + if (line.trim() === heading) { + buffer = []; + continue; + } + + if (buffer && line.startsWith("## ") && line.trim() !== heading) { + break; + } + + if (buffer) { + buffer.push(line); + } + } + + const section = buffer?.join("\n").trim(); + return section && section.length > 0 ? section : undefined; +} + +function buildHookFocusLines( + chapterIntent: string, + contextPackage: ContextPackage, + language: PromptLanguage, +): string[] { + const hookDebtLines = collectContextExcerpts( + contextPackage, + (entry) => entry.source.startsWith("runtime/hook_debt#"), + ); + const fallbackLines = [ + ...formatAgendaLines(chapterIntent, "Must Advance", language === "en" ? "Must advance" : "必须推进"), + ...formatAgendaLines(chapterIntent, "Eligible Resolve", language === "en" ? "Eligible payoff" : "可兑现"), + ...formatAgendaLines(chapterIntent, "Stale Debt", language === "en" ? "Stale debt" : "旧债"), + ...formatAgendaLines( + chapterIntent, + "Avoid New Hook Families", + language === "en" ? "Avoid new hook families" : "避免新增同类 hook", + ), + ]; + + return uniqueContextLines([ + ...(hookDebtLines.length > 0 ? hookDebtLines : []), + ...fallbackLines, + ]).slice(0, 6); +} + +function formatAgendaLines( + chapterIntent: string, + subheading: string, + label: string, +): string[] { + const hookAgenda = extractMarkdownSection(chapterIntent, "## Hook Agenda"); + if (!hookAgenda) { + return []; + } + + const lines = hookAgenda.split("\n"); + const values: string[] = []; + let capture = false; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line === `### ${subheading}`) { + capture = true; + continue; + } + if (capture && line.startsWith("### ")) { + break; + } + if (capture && line.startsWith("- ")) { + const value = line.slice(2).trim(); + if (value && value.toLowerCase() !== "none") { + values.push(`${label}: ${value}`); + } + } + } + + return values; +} + +function collectContextExcerpts( + contextPackage: ContextPackage, + predicate: (entry: ContextPackage["selectedContext"][number]) => boolean, +): string[] { + return uniqueContextLines( + contextPackage.selectedContext + .filter(predicate) + .map((entry) => entry.excerpt ?? entry.reason) + .filter(Boolean), + ); +} + +function findContextExcerpt( + contextPackage: ContextPackage, + source: string, +): string | undefined { + return contextPackage.selectedContext.find((entry) => entry.source === source)?.excerpt; +} + +function renderCompactList(heading: string, items: ReadonlyArray): string { + if (items.length === 0) { + return ""; + } + + return `${heading}:\n${items.map((item) => `- ${item}`).join("\n")}`; +} + +function uniqueContextLines(items: ReadonlyArray): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const item of items) { + const trimmed = item.trim(); + if (!trimmed) { + continue; + } + const normalized = trimmed.toLowerCase(); + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(trimmed); + } + + return result; +} From 06070ca4be27bebc2c0392c9a4df4e8c93fb6c45 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 17:22:11 +0800 Subject: [PATCH 37/53] refactor(state): share story markdown parsers --- packages/core/src/state/state-bootstrap.ts | 135 ++--------- packages/core/src/utils/memory-retrieval.ts | 234 ++----------------- packages/core/src/utils/story-markdown.ts | 235 ++++++++++++++++++++ 3 files changed, 268 insertions(+), 336 deletions(-) create mode 100644 packages/core/src/utils/story-markdown.ts diff --git a/packages/core/src/state/state-bootstrap.ts b/packages/core/src/state/state-bootstrap.ts index 932396f0..f576254e 100644 --- a/packages/core/src/state/state-bootstrap.ts +++ b/packages/core/src/state/state-bootstrap.ts @@ -10,8 +10,24 @@ import { type HookStatus, type StateManifest, } from "../models/runtime-state.js"; -import type { Fact, StoredHook, StoredSummary } from "./memory-db.js"; +import type { Fact, StoredHook } from "./memory-db.js"; import { normalizeHookPayoffTiming } from "../utils/hook-lifecycle.js"; +import { + inferFactSubject, + isCurrentChapterLabel, + isStateTableHeaderRow, + normalizeHookId, + parseChapterSummariesMarkdown, + parseInteger, + parseMarkdownTableRows, +} from "../utils/story-markdown.js"; + +export { + normalizeHookId, + parseChapterSummariesMarkdown, + parseCurrentStateFacts, + parsePendingHooksMarkdown, +} from "../utils/story-markdown.js"; export interface BootstrapStructuredStateResult { readonly createdFiles: ReadonlyArray; @@ -164,69 +180,6 @@ export async function rewriteStructuredStateFromMarkdown(params: { }; } -export 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] ?? "", - })); -} - -export function parsePendingHooksMarkdown(markdown: string): StoredHook[] { - const tableRows = parseMarkdownTableRows(markdown) - .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id"); - - if (tableRows.length > 0) { - return tableRows - .filter((row) => normalizeHookId(row[0]).length > 0) - .map((row) => { - const legacyShape = row.length < 8; - return { - hookId: normalizeHookId(row[0]), - startChapter: parseInteger(row[1]), - type: row[2] ?? "", - status: row[3] ?? "open", - lastAdvancedChapter: parseInteger(row[4]), - expectedPayoff: row[5] ?? "", - payoffTiming: legacyShape ? undefined : normalizeHookPayoffTiming(row[6]), - notes: legacyShape ? (row[6] ?? "") : (row[7] ?? ""), - }; - }); - } - - 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: "", - payoffTiming: undefined, - notes: line, - })); -} - -export function parseCurrentStateFacts( - markdown: string, - fallbackChapter: number, -): Fact[] { - return parseCurrentStateStateMarkdown(markdown, fallbackChapter, []).facts; -} - async function loadOrBootstrapCurrentState(params: { readonly storyDir: string; readonly statePath: string; @@ -590,24 +543,6 @@ function maxHookChapter(hooks: ReadonlyArray): number { ); } -export function normalizeHookId(value: string | undefined): string { - let normalized = (value ?? "").trim(); - let previous = ""; - while (normalized && normalized !== previous) { - previous = normalized; - normalized = normalized - .replace(/^\[(.+?)\]\([^)]+\)$/u, "$1") - .replace(/^\*\*(.+)\*\*$/u, "$1") - .replace(/^__(.+)__$/u, "$1") - .replace(/^\*(.+)\*$/u, "$1") - .replace(/^_(.+)_$/u, "$1") - .replace(/^`(.+)`$/u, "$1") - .replace(/^~~(.+)~~$/u, "$1") - .trim(); - } - return normalized; -} - function normalizeHookStatus(value: string | undefined, warnings: string[], hookId: string): HookStatus { const normalized = (value ?? "").trim().toLowerCase(); if (!normalized) return "open"; @@ -644,42 +579,6 @@ function parseIntegerWithFallback( return parseInt(match[0], 10); } -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): 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 parseInteger(value: string | undefined): number { - if (!value) return 0; - const match = value.match(/\d+/); - return match ? parseInt(match[0], 10) : 0; -} - function appendWarning(warnings: string[], warning: string): void { if (!warnings.includes(warning)) { warnings.push(warning); diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index 927b6777..5109430e 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -6,13 +6,8 @@ import { HooksStateSchema, } from "../models/runtime-state.js"; import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js"; -import { bootstrapStructuredStateFromMarkdown, normalizeHookId } from "../state/state-bootstrap.js"; -import { - describeHookLifecycle, - localizeHookPayoffTiming, - resolveHookPayoffTiming, - normalizeHookPayoffTiming, -} from "./hook-lifecycle.js"; +import { bootstrapStructuredStateFromMarkdown } from "../state/state-bootstrap.js"; +import { describeHookLifecycle } from "./hook-lifecycle.js"; import { buildPlannerHookAgenda, filterActiveHooks, @@ -23,12 +18,26 @@ import { resolveRelevantHookStaleLimit, selectAgendaHooksWithTypeSpread, } from "./hook-agenda.js"; +import { + parseChapterSummariesMarkdown, + parseCurrentStateFacts, + parsePendingHooksMarkdown, + renderHookSnapshot, + renderSummarySnapshot, +} from "./story-markdown.js"; export { buildPlannerHookAgenda, isFuturePlannedHook, isHookWithinChapterWindow, isHookWithinLifecycleWindow, } from "./hook-agenda.js"; +export { + parseChapterSummariesMarkdown, + parseCurrentStateFacts, + parsePendingHooksMarkdown, + renderHookSnapshot, + renderSummarySnapshot, +} from "./story-markdown.js"; export interface MemorySelection { readonly summaries: ReadonlyArray; @@ -168,68 +177,6 @@ export function extractQueryTerms(goal: string, outlineNode: string | undefined, ]).slice(0, 12); } -export function renderSummarySnapshot( - summaries: ReadonlyArray, - language: "zh" | "en" = "zh", -): string { - if (summaries.length === 0) return "- none"; - - const headers = language === "en" - ? [ - "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", - ] - : [ - "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", - ]; - - return [ - ...headers, - ...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, - language: "zh" | "en" = "zh", -): string { - if (hooks.length === 0) return "- none"; - - const headers = language === "en" - ? [ - "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", - ] - : [ - "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", - ]; - - return [ - ...headers, - ...hooks.map((hook) => [ - hook.hookId, - hook.startChapter, - hook.type, - hook.status, - hook.lastAdvancedChapter, - hook.expectedPayoff, - localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language), - hook.notes, - ].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`), - ].join("\n"); -} - function openMemoryDB(bookDir: string): MemoryDB | null { try { return new MemoryDB(bookDir); @@ -336,125 +283,6 @@ function uniqueTerms(terms: ReadonlyArray): string[] { return result; } -export 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] ?? "", - })); -} - -export function parsePendingHooksMarkdown(markdown: string): StoredHook[] { - const tableRows = parseMarkdownTableRows(markdown) - .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id"); - - if (tableRows.length > 0) { - return tableRows - .filter((row) => normalizeHookId(row[0]).length > 0) - .map((row) => parsePendingHookRow(row)); - } - - 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: "", - payoffTiming: undefined, - notes: line, - })); -} - -function parsePendingHookRow(row: ReadonlyArray): StoredHook { - const legacyShape = row.length < 8; - const payoffTiming = legacyShape ? undefined : normalizeHookPayoffTiming(row[6]); - const notes = legacyShape ? (row[6] ?? "") : (row[7] ?? ""); - - return { - hookId: normalizeHookId(row[0]), - startChapter: parseInteger(row[1]), - type: row[2] ?? "", - status: row[3] ?? "open", - lastAdvancedChapter: parseInteger(row[4]), - expectedPayoff: row[5] ?? "", - payoffTiming, - notes, - }; -} - -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 parseVolumeSummariesMarkdown(markdown: string): VolumeSummarySelection[] { if (!markdown.trim()) return []; @@ -476,26 +304,6 @@ function parseVolumeSummariesMarkdown(markdown: string): VolumeSummarySelection[ }).filter((section) => section.heading.length > 0 && section.content.length > 0); } -function isStateTableHeaderRow(row: ReadonlyArray): 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 isUnresolvedHook(status: string): boolean { return status.trim().length === 0 || /open|待定|推进|active|progressing/i.test(status); } @@ -678,16 +486,6 @@ 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(); -} - function slugifyAnchor(value: string): string { return value .trim() diff --git a/packages/core/src/utils/story-markdown.ts b/packages/core/src/utils/story-markdown.ts new file mode 100644 index 00000000..31b826f2 --- /dev/null +++ b/packages/core/src/utils/story-markdown.ts @@ -0,0 +1,235 @@ +import type { Fact, StoredHook, StoredSummary } from "../state/memory-db.js"; +import { + localizeHookPayoffTiming, + normalizeHookPayoffTiming, + resolveHookPayoffTiming, +} from "./hook-lifecycle.js"; + +export function renderSummarySnapshot( + summaries: ReadonlyArray, + language: "zh" | "en" = "zh", +): string { + if (summaries.length === 0) return "- none"; + + const headers = language === "en" + ? [ + "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + : [ + "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ]; + + return [ + ...headers, + ...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, + language: "zh" | "en" = "zh", +): string { + if (hooks.length === 0) return "- none"; + + const headers = language === "en" + ? [ + "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + : [ + "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ]; + + return [ + ...headers, + ...hooks.map((hook) => [ + hook.hookId, + hook.startChapter, + hook.type, + hook.status, + hook.lastAdvancedChapter, + hook.expectedPayoff, + localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language), + hook.notes, + ].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`), + ].join("\n"); +} + +export 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] ?? "", + })); +} + +export function parsePendingHooksMarkdown(markdown: string): StoredHook[] { + const tableRows = parseMarkdownTableRows(markdown) + .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id"); + + if (tableRows.length > 0) { + return tableRows + .filter((row) => normalizeHookId(row[0]).length > 0) + .map((row) => parsePendingHookRow(row)); + } + + 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: "", + payoffTiming: undefined, + 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, + })); +} + +export 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)); +} + +export function isStateTableHeaderRow(row: ReadonlyArray): boolean { + const first = (row[0] ?? "").trim().toLowerCase(); + const second = (row[1] ?? "").trim().toLowerCase(); + return (first === "字段" && second === "值") || (first === "field" && second === "value"); +} + +export function isCurrentChapterLabel(label: string): boolean { + return /^(当前章节|current chapter)$/i.test(label.trim()); +} + +export 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"; +} + +export function parseInteger(value: string | undefined): number { + if (!value) return 0; + const match = value.match(/\d+/); + return match ? parseInt(match[0], 10) : 0; +} + +export function normalizeHookId(value: string | undefined): string { + let normalized = (value ?? "").trim(); + let previous = ""; + while (normalized && normalized !== previous) { + previous = normalized; + normalized = normalized + .replace(/^\[(.+?)\]\([^)]+\)$/u, "$1") + .replace(/^\*\*(.+)\*\*$/u, "$1") + .replace(/^__(.+)__$/u, "$1") + .replace(/^\*(.+)\*$/u, "$1") + .replace(/^_(.+)_$/u, "$1") + .replace(/^`(.+)`$/u, "$1") + .replace(/^~~(.+)~~$/u, "$1") + .trim(); + } + return normalized; +} + +function parsePendingHookRow(row: ReadonlyArray): StoredHook { + const legacyShape = row.length < 8; + const payoffTiming = legacyShape ? undefined : normalizeHookPayoffTiming(row[6]); + const notes = legacyShape ? (row[6] ?? "") : (row[7] ?? ""); + + return { + hookId: normalizeHookId(row[0]), + startChapter: parseInteger(row[1]), + type: row[2] ?? "", + status: row[3] ?? "open", + lastAdvancedChapter: parseInteger(row[4]), + expectedPayoff: row[5] ?? "", + payoffTiming, + notes, + }; +} + +function escapeTableCell(value: string | number): string { + return String(value).replace(/\|/g, "\\|").trim(); +} From 2a5ab2cbc0e97f6d1fadfede952ae5e902c653ce Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 17:27:20 +0800 Subject: [PATCH 38/53] refactor(pipeline): extract persisted governed plan loader --- .../__tests__/persisted-governed-plan.test.ts | 96 +++++++++++++++ .../src/pipeline/persisted-governed-plan.ts | 101 ++++++++++++++++ packages/core/src/pipeline/runner.ts | 111 ++---------------- 3 files changed, 206 insertions(+), 102 deletions(-) create mode 100644 packages/core/src/__tests__/persisted-governed-plan.test.ts create mode 100644 packages/core/src/pipeline/persisted-governed-plan.ts diff --git a/packages/core/src/__tests__/persisted-governed-plan.test.ts b/packages/core/src/__tests__/persisted-governed-plan.test.ts new file mode 100644 index 00000000..f35ffdb9 --- /dev/null +++ b/packages/core/src/__tests__/persisted-governed-plan.test.ts @@ -0,0 +1,96 @@ +import { 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 { loadPersistedPlan, relativeToBookDir } from "../pipeline/persisted-governed-plan.js"; + +describe("persisted governed plan helpers", () => { + it("parses a persisted intent markdown file into a reusable plan", async () => { + const bookDir = await mkdtemp(join(tmpdir(), "inkos-persisted-plan-")); + const runtimeDir = join(bookDir, "story", "runtime"); + await mkdir(runtimeDir, { recursive: true }); + + const runtimePath = join(runtimeDir, "chapter-0007.intent.md"); + await writeFile( + runtimePath, + [ + "# Chapter Intent", + "", + "## Goal", + "Bring the focus back to the mentor oath conflict.", + "", + "## Outline Node", + "Track the mentor oath fallout.", + "", + "## Must Keep", + "- Lin Yue keeps the oath token hidden.", + "- Mentor debt stays unresolved.", + "", + "## Must Avoid", + "- Open a new guild-route mystery.", + "", + "## Style Emphasis", + "- restrained prose", + "", + "## Conflicts", + "- duty: repay the oath without exposing the token", + "- trust: keep the mentor debt personal", + "", + ].join("\n"), + "utf-8", + ); + + try { + const plan = await loadPersistedPlan(bookDir, 7); + + expect(plan).not.toBeNull(); + expect(plan?.runtimePath).toBe(runtimePath); + expect(plan?.intent.goal).toBe("Bring the focus back to the mentor oath conflict."); + expect(plan?.intent.outlineNode).toBe("Track the mentor oath fallout."); + expect(plan?.intent.mustKeep).toEqual([ + "Lin Yue keeps the oath token hidden.", + "Mentor debt stays unresolved.", + ]); + expect(plan?.intent.mustAvoid).toEqual(["Open a new guild-route mystery."]); + expect(plan?.intent.styleEmphasis).toEqual(["restrained prose"]); + expect(plan?.intent.conflicts).toEqual([ + { type: "duty", resolution: "repay the oath without exposing the token" }, + { type: "trust", resolution: "keep the mentor debt personal" }, + ]); + expect(plan?.plannerInputs).toEqual([runtimePath]); + } finally { + await rm(bookDir, { recursive: true, force: true }); + } + }); + + it("rejects persisted intents whose goal is still a placeholder", async () => { + const bookDir = await mkdtemp(join(tmpdir(), "inkos-persisted-plan-invalid-")); + const runtimeDir = join(bookDir, "story", "runtime"); + await mkdir(runtimeDir, { recursive: true }); + + await writeFile( + join(runtimeDir, "chapter-0003.intent.md"), + [ + "# Chapter Intent", + "", + "## Goal", + "(describe the goal here)", + "", + ].join("\n"), + "utf-8", + ); + + try { + await expect(loadPersistedPlan(bookDir, 3)).resolves.toBeNull(); + } finally { + await rm(bookDir, { recursive: true, force: true }); + } + }); + + it("normalizes persisted artifact paths relative to the book directory", () => { + expect(relativeToBookDir( + "/tmp/book", + "/tmp/book/story/runtime/chapter-0001.intent.md", + )).toBe("story/runtime/chapter-0001.intent.md"); + }); +}); diff --git a/packages/core/src/pipeline/persisted-governed-plan.ts b/packages/core/src/pipeline/persisted-governed-plan.ts new file mode 100644 index 00000000..49cab1cb --- /dev/null +++ b/packages/core/src/pipeline/persisted-governed-plan.ts @@ -0,0 +1,101 @@ +import { readFile } from "node:fs/promises"; +import { join, relative } from "node:path"; +import type { PlanChapterOutput } from "../agents/planner.js"; +import { ChapterIntentSchema } from "../models/input-governance.js"; + +export async function loadPersistedPlan( + bookDir: string, + chapterNumber: number, +): Promise { + const runtimePath = join( + bookDir, + "story", + "runtime", + `chapter-${String(chapterNumber).padStart(4, "0")}.intent.md`, + ); + + try { + const intentMarkdown = await readFile(runtimePath, "utf-8"); + const sections = parseIntentSections(intentMarkdown); + const goal = readIntentScalar(sections, "Goal"); + if (!goal || isInvalidPersistedIntentScalar(goal)) return null; + + const outlineNode = readIntentScalar(sections, "Outline Node"); + if (outlineNode && outlineNode !== "(not found)" && isInvalidPersistedIntentScalar(outlineNode)) { + return null; + } + const conflicts = readIntentList(sections, "Conflicts") + .map((line) => { + const separator = line.indexOf(":"); + if (separator < 0) return null; + + const type = line.slice(0, separator).trim(); + const resolution = line.slice(separator + 1).trim(); + if (!type || !resolution) return null; + return { type, resolution }; + }) + .filter((conflict): conflict is { type: string; resolution: string } => conflict !== null); + + return { + intent: ChapterIntentSchema.parse({ + chapter: chapterNumber, + goal, + outlineNode: outlineNode && outlineNode !== "(not found)" ? outlineNode : undefined, + mustKeep: readIntentList(sections, "Must Keep"), + mustAvoid: readIntentList(sections, "Must Avoid"), + styleEmphasis: readIntentList(sections, "Style Emphasis"), + conflicts, + }), + intentMarkdown, + plannerInputs: [runtimePath], + runtimePath, + }; + } catch { + return null; + } +} + +export function relativeToBookDir(bookDir: string, absolutePath: string): string { + return relative(bookDir, absolutePath).replaceAll("\\", "/"); +} + +function parseIntentSections(markdown: string): Map { + const sections = new Map(); + let current: string | null = null; + + for (const line of markdown.split("\n")) { + if (line.startsWith("## ")) { + current = line.slice(3).trim(); + sections.set(current, []); + continue; + } + + if (!current) continue; + sections.get(current)?.push(line); + } + + return sections; +} + +function readIntentScalar(sections: Map, name: string): string | undefined { + const lines = sections.get(name) ?? []; + const value = lines.map((line) => line.trim()).find((line) => line.length > 0); + return value && value !== "- none" ? value : undefined; +} + +function readIntentList(sections: Map, name: string): string[] { + return (sections.get(name) ?? []) + .map((line) => line.trim()) + .filter((line) => line.startsWith("-") && line !== "- none") + .map((line) => line.replace(/^-\s*/, "")); +} + +function isInvalidPersistedIntentScalar(value: string): boolean { + const normalized = value.trim(); + if (!normalized) return true; + if (/^[*_`~::|.-]+$/.test(normalized)) return true; + return ( + /^\((describe|briefly describe|write)\b[\s\S]*\)$/i.test(normalized) + || /^((?:在这里描述|描述|填写|写下)[\s\S]*)$/u.test(normalized) + ); +} diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 8adef485..a437ae92 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -27,13 +27,13 @@ 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 { ChapterIntentSchema, type ContextPackage, type RuleStack } from "../models/input-governance.js"; +import type { ContextPackage, RuleStack } from "../models/input-governance.js"; import { buildLengthSpec, countChapterLength, formatLengthCount, isOutsideHardRange, isOutsideSoftRange, resolveLengthCountingMode, type LengthLanguage } from "../utils/length-metrics.js"; import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js"; import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state/runtime-state-store.js"; import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js"; import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/promises"; -import { join, relative } from "node:path"; +import { join } from "node:path"; import { buildStateDegradedPersistenceOutput, buildStateDegradedReviewNote, @@ -41,6 +41,7 @@ import { resolveStateDegradedBaseStatus, retrySettlementAfterValidationFailure, } from "./chapter-state-recovery.js"; +import { loadPersistedPlan, relativeToBookDir } from "./persisted-governed-plan.js"; export interface PipelineConfig { readonly client: LLMClient; @@ -575,7 +576,7 @@ export class PipelineRunner { return { bookId, chapterNumber, - intentPath: this.relativeToBookDir(bookDir, plan.runtimePath), + intentPath: relativeToBookDir(bookDir, plan.runtimePath), goal: plan.intent.goal, conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`), }; @@ -599,12 +600,12 @@ export class PipelineRunner { return { bookId, chapterNumber, - intentPath: this.relativeToBookDir(bookDir, plan.runtimePath), + intentPath: relativeToBookDir(bookDir, plan.runtimePath), goal: plan.intent.goal, conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`), - contextPath: this.relativeToBookDir(bookDir, composed.contextPath), - ruleStackPath: this.relativeToBookDir(bookDir, composed.ruleStackPath), - tracePath: this.relativeToBookDir(bookDir, composed.tracePath), + contextPath: relativeToBookDir(bookDir, composed.contextPath), + ruleStackPath: relativeToBookDir(bookDir, composed.ruleStackPath), + tracePath: relativeToBookDir(bookDir, composed.tracePath), }; } @@ -2732,7 +2733,7 @@ ${matrix}`, options?.reuseExistingIntentWhenContextMissing && (!externalContext || externalContext.trim().length === 0) ) { - const persisted = await this.loadPersistedPlan(bookDir, chapterNumber); + const persisted = await loadPersistedPlan(bookDir, chapterNumber); if (persisted) return persisted; } @@ -2745,100 +2746,6 @@ ${matrix}`, }); } - private async loadPersistedPlan(bookDir: string, chapterNumber: number): Promise { - const runtimePath = join( - bookDir, - "story", - "runtime", - `chapter-${String(chapterNumber).padStart(4, "0")}.intent.md`, - ); - - try { - const intentMarkdown = await readFile(runtimePath, "utf-8"); - const sections = this.parseIntentSections(intentMarkdown); - const goal = this.readIntentScalar(sections, "Goal"); - if (!goal || this.isInvalidPersistedIntentScalar(goal)) return null; - - const outlineNode = this.readIntentScalar(sections, "Outline Node"); - if (outlineNode && outlineNode !== "(not found)" && this.isInvalidPersistedIntentScalar(outlineNode)) { - return null; - } - const conflicts = this.readIntentList(sections, "Conflicts") - .map((line) => { - const separator = line.indexOf(":"); - if (separator < 0) return null; - - const type = line.slice(0, separator).trim(); - const resolution = line.slice(separator + 1).trim(); - if (!type || !resolution) return null; - return { type, resolution }; - }) - .filter((conflict): conflict is { type: string; resolution: string } => conflict !== null); - - return { - intent: ChapterIntentSchema.parse({ - chapter: chapterNumber, - goal, - outlineNode: outlineNode && outlineNode !== "(not found)" ? outlineNode : undefined, - mustKeep: this.readIntentList(sections, "Must Keep"), - mustAvoid: this.readIntentList(sections, "Must Avoid"), - styleEmphasis: this.readIntentList(sections, "Style Emphasis"), - conflicts, - }), - intentMarkdown, - plannerInputs: [runtimePath], - runtimePath, - }; - } catch { - return null; - } - } - - private parseIntentSections(markdown: string): Map { - const sections = new Map(); - let current: string | null = null; - - for (const line of markdown.split("\n")) { - if (line.startsWith("## ")) { - current = line.slice(3).trim(); - sections.set(current, []); - continue; - } - - if (!current) continue; - sections.get(current)?.push(line); - } - - return sections; - } - - private readIntentScalar(sections: Map, name: string): string | undefined { - const lines = sections.get(name) ?? []; - const value = lines.map((line) => line.trim()).find((line) => line.length > 0); - return value && value !== "- none" ? value : undefined; - } - - private readIntentList(sections: Map, name: string): string[] { - return (sections.get(name) ?? []) - .map((line) => line.trim()) - .filter((line) => line.startsWith("-") && line !== "- none") - .map((line) => line.replace(/^-\s*/, "")); - } - - private isInvalidPersistedIntentScalar(value: string): boolean { - const normalized = value.trim(); - if (!normalized) return true; - if (/^[*_`~::|.-]+$/.test(normalized)) return true; - return ( - /^\((describe|briefly describe|write)\b[\s\S]*\)$/i.test(normalized) - || /^((?:在这里描述|描述|填写|写下)[\s\S]*)$/u.test(normalized) - ); - } - - private relativeToBookDir(bookDir: string, absolutePath: string): string { - return relative(bookDir, absolutePath).replaceAll("\\", "/"); - } - private async emitWebhook( event: WebhookEvent, bookId: string, From 6759867285577641c7b207890fcc2bc6b703642c Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 18:14:26 +0800 Subject: [PATCH 39/53] fix(test): update hook table assertions for payoff_timing column Three tests expected the old 7-column hook table format but the schema now includes a payoff_timing column between expected_payoff and notes. --- packages/core/src/__tests__/architect.test.ts | 4 ++-- packages/core/src/__tests__/state-projections.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/__tests__/architect.test.ts b/packages/core/src/__tests__/architect.test.ts index 2515e56f..1aa4c277 100644 --- a/packages/core/src/__tests__/architect.test.ts +++ b/packages/core/src/__tests__/architect.test.ts @@ -206,7 +206,7 @@ describe("ArchitectAgent", () => { const result = await agent.generateFoundation(book); - expect(result.pendingHooks).toContain("| H01 | 1 | 主线 | 未开启 | 0 | 10章 | 主线核心钩子 |"); + expect(result.pendingHooks).toContain("| H01 | 1 | 主线 | 未开启 | 0 | 10章 | 中程 | 主线核心钩子 |"); expect(result.pendingHooks).not.toContain("如果你愿意"); expect(result.pendingHooks).not.toContain("前10章逐章细纲"); }); @@ -268,7 +268,7 @@ describe("ArchitectAgent", () => { const result = await agent.generateFoundation(book); - expect(result.pendingHooks).toContain("| H13 | 22 | 舆情操盘 | 待推进 | 0 | 51-60章 | 庄蔓出场后逐步揭露(初始线索:一家自媒体公司在多个旧案节点同步接单) |"); + expect(result.pendingHooks).toContain("| H13 | 22 | 舆情操盘 | 待推进 | 0 | 51-60章 | 中程 | 庄蔓出场后逐步揭露(初始线索:一家自媒体公司在多个旧案节点同步接单) |"); }); it("throws when a required foundation section is missing", async () => { diff --git a/packages/core/src/__tests__/state-projections.test.ts b/packages/core/src/__tests__/state-projections.test.ts index d8fb3883..d35ca35c 100644 --- a/packages/core/src/__tests__/state-projections.test.ts +++ b/packages/core/src/__tests__/state-projections.test.ts @@ -33,10 +33,10 @@ describe("state projections", () => { expect(markdown).toBe([ "# Pending Hooks", "", - "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |", - "| --- | --- | --- | --- | --- | --- | --- |", - "| a-debt | 4 | relationship | progressing | 11 | Reveal the debt. | Old oath token resurfaces. |", - "| b-courier | 12 | mystery | open | 13 | Identify the courier. | The seal is still broken. |", + "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + "| a-debt | 4 | relationship | progressing | 11 | Reveal the debt. | mid-arc | Old oath token resurfaces. |", + "| b-courier | 12 | mystery | open | 13 | Identify the courier. | mid-arc | The seal is still broken. |", "", ].join("\n")); }); From e2bf6cba8139ce598608824b1485a50788788f0d Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 19:38:16 +0800 Subject: [PATCH 40/53] refactor(pipeline): extract chapter review cycle --- .../__tests__/chapter-review-cycle.test.ts | 174 ++++++++++++++ .../core/src/pipeline/chapter-review-cycle.ts | 218 ++++++++++++++++++ packages/core/src/pipeline/runner.ts | 171 +++----------- 3 files changed, 424 insertions(+), 139 deletions(-) create mode 100644 packages/core/src/__tests__/chapter-review-cycle.test.ts create mode 100644 packages/core/src/pipeline/chapter-review-cycle.ts diff --git a/packages/core/src/__tests__/chapter-review-cycle.test.ts b/packages/core/src/__tests__/chapter-review-cycle.test.ts new file mode 100644 index 00000000..ae469098 --- /dev/null +++ b/packages/core/src/__tests__/chapter-review-cycle.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from "vitest"; +import { runChapterReviewCycle } from "../pipeline/chapter-review-cycle.js"; +import type { AuditResult, AuditIssue } from "../agents/continuity.js"; +import type { LengthSpec } from "../models/length-governance.js"; + +const LENGTH_SPEC: LengthSpec = { + target: 220, + softMin: 190, + softMax: 250, + hardMin: 160, + hardMax: 280, + countingMode: "zh_chars", + normalizeMode: "none", +}; + +const ZERO_USAGE = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, +} as const; + +function createAuditResult(overrides?: Partial): AuditResult { + return { + passed: true, + issues: [], + summary: "clean", + ...overrides, + }; +} + +describe("runChapterReviewCycle", () => { + it("applies post-write spot-fix before the first audit pass", async () => { + const auditChapter = vi.fn() + .mockResolvedValue(createAuditResult()); + const reviseChapter = vi.fn().mockResolvedValue({ + revisedContent: "fixed draft", + wordCount: 10, + fixedIssues: ["fixed"], + updatedState: "", + updatedLedger: "", + updatedHooks: "", + tokenUsage: ZERO_USAGE, + }); + const normalizeDraftLengthIfNeeded = vi.fn() + .mockResolvedValue({ + content: "fixed draft", + wordCount: 10, + applied: false, + tokenUsage: ZERO_USAGE, + }); + + const result = await runChapterReviewCycle({ + book: { genre: "xuanhuan" }, + bookDir: "/tmp/book", + chapterNumber: 1, + initialOutput: { + content: "raw draft", + wordCount: 9, + postWriteErrors: [{ + rule: "paragraph-shape", + description: "too fragmented", + suggestion: "merge short fragments", + severity: "error", + }], + }, + lengthSpec: LENGTH_SPEC, + reducedControlInput: undefined, + initialUsage: ZERO_USAGE, + createReviser: () => ({ reviseChapter }), + auditor: { auditChapter }, + normalizeDraftLengthIfNeeded, + assertChapterContentNotEmpty: () => undefined, + addUsage: (left, right) => ({ + promptTokens: left.promptTokens + (right?.promptTokens ?? 0), + completionTokens: left.completionTokens + (right?.completionTokens ?? 0), + totalTokens: left.totalTokens + (right?.totalTokens ?? 0), + }), + restoreLostAuditIssues: (_previous, next) => next, + analyzeAITells: () => ({ issues: [] as AuditIssue[] }), + analyzeSensitiveWords: () => ({ found: [] as Array<{ severity: "warn" | "block" }>, issues: [] as AuditIssue[] }), + logWarn: () => undefined, + logStage: () => undefined, + }); + + expect(reviseChapter).toHaveBeenCalledTimes(1); + expect(auditChapter).toHaveBeenCalledTimes(1); + expect(auditChapter).toHaveBeenCalledWith( + "/tmp/book", + "fixed draft", + 1, + "xuanhuan", + undefined, + ); + expect(result.finalContent).toBe("fixed draft"); + expect(result.revised).toBe(true); + }); + + it("drops auto-revision when it increases AI tells and re-audits the original draft", async () => { + const failingAudit = createAuditResult({ + passed: false, + issues: [{ + severity: "critical", + category: "continuity", + description: "broken continuity", + suggestion: "fix it", + }], + summary: "bad", + }); + const auditChapter = vi.fn() + .mockResolvedValueOnce(failingAudit) + .mockResolvedValueOnce(createAuditResult()); + const reviseChapter = vi.fn().mockResolvedValue({ + revisedContent: "rewritten draft", + wordCount: 15, + fixedIssues: ["fixed"], + updatedState: "", + updatedLedger: "", + updatedHooks: "", + tokenUsage: ZERO_USAGE, + }); + const normalizeDraftLengthIfNeeded = vi.fn() + .mockResolvedValueOnce({ + content: "original draft", + wordCount: 13, + applied: false, + tokenUsage: ZERO_USAGE, + }) + .mockResolvedValueOnce({ + content: "rewritten draft", + wordCount: 15, + applied: false, + tokenUsage: ZERO_USAGE, + }); + const analyzeAITells = vi.fn((content: string) => ({ + issues: content === "rewritten draft" + ? [{ severity: "warning", category: "ai", description: "more ai", suggestion: "reduce" } satisfies AuditIssue] + : [], + })); + + const result = await runChapterReviewCycle({ + book: { genre: "xuanhuan" }, + bookDir: "/tmp/book", + chapterNumber: 1, + initialOutput: { + content: "original draft", + wordCount: 13, + postWriteErrors: [], + }, + lengthSpec: LENGTH_SPEC, + reducedControlInput: undefined, + initialUsage: ZERO_USAGE, + createReviser: () => ({ reviseChapter }), + auditor: { auditChapter }, + normalizeDraftLengthIfNeeded, + assertChapterContentNotEmpty: () => undefined, + addUsage: (left, right) => ({ + promptTokens: left.promptTokens + (right?.promptTokens ?? 0), + completionTokens: left.completionTokens + (right?.completionTokens ?? 0), + totalTokens: left.totalTokens + (right?.totalTokens ?? 0), + }), + restoreLostAuditIssues: (_previous, next) => next, + analyzeAITells, + analyzeSensitiveWords: () => ({ found: [] as Array<{ severity: "warn" | "block" }>, issues: [] as AuditIssue[] }), + logWarn: () => undefined, + logStage: () => undefined, + }); + + expect(reviseChapter).toHaveBeenCalledTimes(1); + expect(auditChapter).toHaveBeenNthCalledWith(1, "/tmp/book", "original draft", 1, "xuanhuan", undefined); + expect(auditChapter).toHaveBeenNthCalledWith(2, "/tmp/book", "original draft", 1, "xuanhuan", { temperature: 0 }); + expect(result.finalContent).toBe("original draft"); + expect(result.revised).toBe(false); + }); +}); diff --git a/packages/core/src/pipeline/chapter-review-cycle.ts b/packages/core/src/pipeline/chapter-review-cycle.ts new file mode 100644 index 00000000..9352aa28 --- /dev/null +++ b/packages/core/src/pipeline/chapter-review-cycle.ts @@ -0,0 +1,218 @@ +import type { AuditIssue, AuditResult } from "../agents/continuity.js"; +import type { ReviseOutput } from "../agents/reviser.js"; +import type { WriteChapterOutput } from "../agents/writer.js"; +import type { ContextPackage, RuleStack } from "../models/input-governance.js"; +import type { LengthSpec } from "../models/length-governance.js"; + +export interface ChapterReviewCycleUsage { + readonly promptTokens: number; + readonly completionTokens: number; + readonly totalTokens: number; +} + +export interface ChapterReviewCycleControlInput { + readonly chapterIntent: string; + readonly contextPackage: ContextPackage; + readonly ruleStack: RuleStack; +} + +export interface ChapterReviewCycleResult { + readonly finalContent: string; + readonly finalWordCount: number; + readonly preAuditNormalizedWordCount: number; + readonly revised: boolean; + readonly auditResult: AuditResult; + readonly totalUsage: ChapterReviewCycleUsage; + readonly postReviseCount: number; + readonly normalizeApplied: boolean; +} + +export async function runChapterReviewCycle(params: { + readonly book: Pick<{ genre: string }, "genre">; + readonly bookDir: string; + readonly chapterNumber: number; + readonly initialOutput: Pick; + readonly reducedControlInput?: ChapterReviewCycleControlInput; + readonly lengthSpec: LengthSpec; + readonly initialUsage: ChapterReviewCycleUsage; + readonly createReviser: () => { + reviseChapter: ( + bookDir: string, + chapterContent: string, + chapterNumber: number, + issues: ReadonlyArray, + mode: "spot-fix", + genre?: string, + options?: { + chapterIntent?: string; + contextPackage?: ContextPackage; + ruleStack?: RuleStack; + lengthSpec?: LengthSpec; + }, + ) => Promise; + }; + readonly auditor: { + auditChapter: ( + bookDir: string, + chapterContent: string, + chapterNumber: number, + genre?: string, + options?: { + temperature?: number; + chapterIntent?: string; + contextPackage?: ContextPackage; + ruleStack?: RuleStack; + }, + ) => Promise; + }; + readonly normalizeDraftLengthIfNeeded: (chapterContent: string) => Promise<{ + content: string; + wordCount: number; + applied: boolean; + tokenUsage?: ChapterReviewCycleUsage; + }>; + readonly assertChapterContentNotEmpty: (content: string, stage: string) => void; + readonly addUsage: ( + left: ChapterReviewCycleUsage, + right?: ChapterReviewCycleUsage, + ) => ChapterReviewCycleUsage; + readonly restoreLostAuditIssues: (previous: AuditResult, next: AuditResult) => AuditResult; + readonly analyzeAITells: (content: string) => { issues: ReadonlyArray }; + readonly analyzeSensitiveWords: (content: string) => { + found: ReadonlyArray<{ severity: string }>; + issues: ReadonlyArray; + }; + readonly logWarn: (message: { zh: string; en: string }) => void; + readonly logStage: (message: { zh: string; en: string }) => void; +}): Promise { + let totalUsage = params.initialUsage; + let postReviseCount = 0; + let normalizeApplied = false; + let finalContent = params.initialOutput.content; + let finalWordCount = params.initialOutput.wordCount; + let revised = false; + + if (params.initialOutput.postWriteErrors.length > 0) { + params.logWarn({ + zh: `检测到 ${params.initialOutput.postWriteErrors.length} 个后写错误,审计前触发 spot-fix 修补`, + en: `${params.initialOutput.postWriteErrors.length} post-write errors detected, triggering spot-fix before audit`, + }); + const reviser = params.createReviser(); + const spotFixIssues = params.initialOutput.postWriteErrors.map((violation) => ({ + severity: "critical" as const, + category: violation.rule, + description: violation.description, + suggestion: violation.suggestion, + })); + const fixResult = await reviser.reviseChapter( + params.bookDir, + finalContent, + params.chapterNumber, + spotFixIssues, + "spot-fix", + params.book.genre, + { + ...params.reducedControlInput, + lengthSpec: params.lengthSpec, + }, + ); + totalUsage = params.addUsage(totalUsage, fixResult.tokenUsage); + if (fixResult.revisedContent.length > 0) { + finalContent = fixResult.revisedContent; + finalWordCount = fixResult.wordCount; + revised = true; + } + } + + const normalizedBeforeAudit = await params.normalizeDraftLengthIfNeeded(finalContent); + totalUsage = params.addUsage(totalUsage, normalizedBeforeAudit.tokenUsage); + finalContent = normalizedBeforeAudit.content; + finalWordCount = normalizedBeforeAudit.wordCount; + normalizeApplied = normalizeApplied || normalizedBeforeAudit.applied; + params.assertChapterContentNotEmpty(finalContent, "draft generation"); + + params.logStage({ zh: "审计草稿", en: "auditing draft" }); + const llmAudit = await params.auditor.auditChapter( + params.bookDir, + finalContent, + params.chapterNumber, + params.book.genre, + params.reducedControlInput, + ); + totalUsage = params.addUsage(totalUsage, llmAudit.tokenUsage); + const aiTellsResult = params.analyzeAITells(finalContent); + const sensitiveWriteResult = params.analyzeSensitiveWords(finalContent); + const hasBlockedWriteWords = sensitiveWriteResult.found.some((item) => item.severity === "block"); + let auditResult: AuditResult = { + passed: hasBlockedWriteWords ? false : llmAudit.passed, + issues: [...llmAudit.issues, ...aiTellsResult.issues, ...sensitiveWriteResult.issues], + summary: llmAudit.summary, + }; + + if (!auditResult.passed) { + const criticalIssues = auditResult.issues.filter((issue) => issue.severity === "critical"); + if (criticalIssues.length > 0) { + const reviser = params.createReviser(); + params.logStage({ zh: "自动修复关键问题", en: "auto-revising critical issues" }); + const reviseOutput = await reviser.reviseChapter( + params.bookDir, + finalContent, + params.chapterNumber, + auditResult.issues, + "spot-fix", + params.book.genre, + { + ...params.reducedControlInput, + lengthSpec: params.lengthSpec, + }, + ); + totalUsage = params.addUsage(totalUsage, reviseOutput.tokenUsage); + + if (reviseOutput.revisedContent.length > 0) { + const normalizedRevision = await params.normalizeDraftLengthIfNeeded(reviseOutput.revisedContent); + totalUsage = params.addUsage(totalUsage, normalizedRevision.tokenUsage); + postReviseCount = normalizedRevision.wordCount; + normalizeApplied = normalizeApplied || normalizedRevision.applied; + + const preMarkers = params.analyzeAITells(finalContent); + const postMarkers = params.analyzeAITells(normalizedRevision.content); + if (postMarkers.issues.length <= preMarkers.issues.length) { + finalContent = normalizedRevision.content; + finalWordCount = normalizedRevision.wordCount; + revised = true; + params.assertChapterContentNotEmpty(finalContent, "revision"); + } + + const reAudit = await params.auditor.auditChapter( + params.bookDir, + finalContent, + params.chapterNumber, + params.book.genre, + params.reducedControlInput + ? { ...params.reducedControlInput, temperature: 0 } + : { temperature: 0 }, + ); + totalUsage = params.addUsage(totalUsage, reAudit.tokenUsage); + const reAITells = params.analyzeAITells(finalContent); + const reSensitive = params.analyzeSensitiveWords(finalContent); + const reHasBlocked = reSensitive.found.some((item) => item.severity === "block"); + auditResult = params.restoreLostAuditIssues(auditResult, { + passed: reHasBlocked ? false : reAudit.passed, + issues: [...reAudit.issues, ...reAITells.issues, ...reSensitive.issues], + summary: reAudit.summary, + }); + } + } + } + + return { + finalContent, + finalWordCount, + preAuditNormalizedWordCount: normalizedBeforeAudit.wordCount, + revised, + auditResult, + totalUsage, + postReviseCount, + normalizeApplied, + }; +} diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index a437ae92..5adbbf2d 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -41,6 +41,7 @@ import { resolveStateDegradedBaseStatus, retrySettlementAfterValidationFailure, } from "./chapter-state-recovery.js"; +import { runChapterReviewCycle } from "./chapter-review-cycle.js"; import { loadPersistedPlan, relativeToBookDir } from "./persisted-governed-plan.js"; export interface PipelineConfig { @@ -1045,148 +1046,40 @@ export class PipelineRunner { // Token usage accumulator let totalUsage: TokenUsageSummary = output.tokenUsage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; - let postReviseCount = 0; - let normalizeApplied = false; - - // 2a. Post-write error gate: if deterministic rules found errors, auto-fix before LLM audit - let finalContent = output.content; - let finalWordCount = output.wordCount; - let revised = false; - - if (output.postWriteErrors.length > 0) { - this.logWarn(pipelineLang, { - zh: `检测到 ${output.postWriteErrors.length} 个后写错误,审计前触发 spot-fix 修补`, - en: `${output.postWriteErrors.length} post-write errors detected, triggering spot-fix before audit`, - }); - const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId)); - const spotFixIssues = output.postWriteErrors.map((v) => ({ - severity: "critical" as const, - category: v.rule, - description: v.description, - suggestion: v.suggestion, - })); - const fixResult = await reviser.reviseChapter( - bookDir, - finalContent, - chapterNumber, - spotFixIssues, - "spot-fix", - book.genre, - { - ...reducedControlInput, - lengthSpec, - }, - ); - totalUsage = PipelineRunner.addUsage(totalUsage, fixResult.tokenUsage); - if (fixResult.revisedContent.length > 0) { - finalContent = fixResult.revisedContent; - finalWordCount = fixResult.wordCount; - revised = true; - } - } - - const normalizedBeforeAudit = await this.normalizeDraftLengthIfNeeded({ - bookId, - chapterNumber, - chapterContent: finalContent, - lengthSpec, - chapterIntent: writeInput.chapterIntent, - }); - totalUsage = PipelineRunner.addUsage(totalUsage, normalizedBeforeAudit.tokenUsage); - finalContent = normalizedBeforeAudit.content; - finalWordCount = normalizedBeforeAudit.wordCount; - normalizeApplied = normalizeApplied || normalizedBeforeAudit.applied; - this.assertChapterContentNotEmpty(finalContent, chapterNumber, "draft generation"); - - // 2b. LLM audit const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId)); - this.logStage(stageLanguage, { zh: "审计草稿", en: "auditing draft" }); - const llmAudit = await auditor.auditChapter( + const reviewResult = await runChapterReviewCycle({ + book: { genre: book.genre }, bookDir, - finalContent, chapterNumber, - book.genre, + initialOutput: output, reducedControlInput, - ); - totalUsage = PipelineRunner.addUsage(totalUsage, llmAudit.tokenUsage); - const aiTellsResult = analyzeAITells(finalContent); - const sensitiveWriteResult = analyzeSensitiveWords(finalContent); - const hasBlockedWriteWords = sensitiveWriteResult.found.some((f) => f.severity === "block"); - let auditResult: AuditResult = { - passed: hasBlockedWriteWords ? false : llmAudit.passed, - issues: [...llmAudit.issues, ...aiTellsResult.issues, ...sensitiveWriteResult.issues], - summary: llmAudit.summary, - }; - - // 3. If audit fails, try auto-revise once - if (!auditResult.passed) { - const criticalIssues = auditResult.issues.filter( - (i) => i.severity === "critical", - ); - if (criticalIssues.length > 0) { - const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId)); - this.logStage(stageLanguage, { zh: "自动修复关键问题", en: "auto-revising critical issues" }); - const reviseOutput = await reviser.reviseChapter( - bookDir, - finalContent, - chapterNumber, - auditResult.issues, - "spot-fix", - book.genre, - { - ...reducedControlInput, - lengthSpec, - }, - ); - totalUsage = PipelineRunner.addUsage(totalUsage, reviseOutput.tokenUsage); - - if (reviseOutput.revisedContent.length > 0) { - const normalizedRevision = await this.normalizeDraftLengthIfNeeded({ - bookId, - chapterNumber, - chapterContent: reviseOutput.revisedContent, - lengthSpec, - chapterIntent: writeInput.chapterIntent, - }); - totalUsage = PipelineRunner.addUsage(totalUsage, normalizedRevision.tokenUsage); - postReviseCount = normalizedRevision.wordCount; - normalizeApplied = normalizeApplied || normalizedRevision.applied; - - // Guard: reject revision if AI markers increased - const preMarkers = analyzeAITells(finalContent); - const postMarkers = analyzeAITells(normalizedRevision.content); - const preCount = preMarkers.issues.length; - const postCount = postMarkers.issues.length; - - if (postCount > preCount) { - // Revision made text MORE AI-like — discard it, keep original - } else { - finalContent = normalizedRevision.content; - finalWordCount = normalizedRevision.wordCount; - revised = true; - this.assertChapterContentNotEmpty(finalContent, chapterNumber, "revision"); - } - - // Re-audit the (possibly revised) content - const reAudit = await auditor.auditChapter( - bookDir, - finalContent, - chapterNumber, - book.genre, - { ...reducedControlInput, temperature: 0 }, - ); - totalUsage = PipelineRunner.addUsage(totalUsage, reAudit.tokenUsage); - const reAITells = analyzeAITells(finalContent); - const reSensitive = analyzeSensitiveWords(finalContent); - const reHasBlocked = reSensitive.found.some((f) => f.severity === "block"); - auditResult = this.restoreLostAuditIssues(auditResult, { - passed: reHasBlocked ? false : reAudit.passed, - issues: [...reAudit.issues, ...reAITells.issues, ...reSensitive.issues], - summary: reAudit.summary, - }); - } - } - } + lengthSpec, + initialUsage: totalUsage, + createReviser: () => new ReviserAgent(this.agentCtxFor("reviser", bookId)), + auditor, + normalizeDraftLengthIfNeeded: (chapterContent) => this.normalizeDraftLengthIfNeeded({ + bookId, + chapterNumber, + chapterContent, + lengthSpec, + chapterIntent: writeInput.chapterIntent, + }), + assertChapterContentNotEmpty: (content, stage) => + this.assertChapterContentNotEmpty(content, chapterNumber, stage), + addUsage: PipelineRunner.addUsage, + restoreLostAuditIssues: (previous, next) => this.restoreLostAuditIssues(previous, next), + analyzeAITells, + analyzeSensitiveWords, + logWarn: (message) => this.logWarn(pipelineLang, message), + logStage: (message) => this.logStage(stageLanguage, message), + }); + totalUsage = reviewResult.totalUsage; + let finalContent = reviewResult.finalContent; + let finalWordCount = reviewResult.finalWordCount; + let revised = reviewResult.revised; + let auditResult = reviewResult.auditResult; + const postReviseCount = reviewResult.postReviseCount; + const normalizeApplied = reviewResult.normalizeApplied; // 4. Save the final chapter and truth files from a single persistence source this.logStage(stageLanguage, { zh: "落盘最终章节", en: "persisting final chapter" }); @@ -1264,7 +1157,7 @@ export class PipelineRunner { const lengthTelemetry = this.buildLengthTelemetry({ lengthSpec, writerCount, - postWriterNormalizeCount: normalizedBeforeAudit.wordCount, + postWriterNormalizeCount: reviewResult.preAuditNormalizedWordCount, postReviseCount, finalCount: finalWordCount, normalizeApplied, From f989204e6c10cf9382d8b01754a074c38d8b3867 Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 19:48:07 +0800 Subject: [PATCH 41/53] refactor(pipeline): extract chapter persistence tail --- .../src/__tests__/chapter-persistence.test.ts | 152 ++++++++++++++++++ .../core/src/pipeline/chapter-persistence.ts | 75 +++++++++ packages/core/src/pipeline/runner.ts | 87 ++++------ 3 files changed, 261 insertions(+), 53 deletions(-) create mode 100644 packages/core/src/__tests__/chapter-persistence.test.ts create mode 100644 packages/core/src/pipeline/chapter-persistence.ts diff --git a/packages/core/src/__tests__/chapter-persistence.test.ts b/packages/core/src/__tests__/chapter-persistence.test.ts new file mode 100644 index 00000000..c2c37195 --- /dev/null +++ b/packages/core/src/__tests__/chapter-persistence.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AuditIssue, AuditResult } from "../agents/continuity.js"; +import type { ChapterMeta } from "../models/chapter.js"; +import { persistChapterArtifacts } from "../pipeline/chapter-persistence.js"; + +const ZERO_USAGE = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, +} as const; + +function createIssue(overrides?: Partial): AuditIssue { + return { + severity: "warning", + category: "continuity", + description: "issue", + suggestion: "fix", + ...overrides, + }; +} + +function createAuditResult(overrides?: Partial): AuditResult { + return { + passed: true, + issues: [], + summary: "clean", + ...overrides, + }; +} + +describe("persistChapterArtifacts", () => { + it("persists truth files, index, drift guidance, and snapshots for reviewable chapters", async () => { + const saveChapter = vi.fn().mockResolvedValue(undefined); + const saveTruthFiles = vi.fn().mockResolvedValue(undefined); + const saveChapterIndex = vi.fn().mockResolvedValue(undefined); + const markBookActiveIfNeeded = vi.fn().mockResolvedValue(undefined); + const persistAuditDriftGuidance = vi.fn().mockResolvedValue(undefined); + const snapshotState = vi.fn().mockResolvedValue(undefined); + const syncCurrentStateFactHistory = vi.fn().mockResolvedValue(undefined); + const logSnapshotStage = vi.fn(); + + await persistChapterArtifacts({ + chapterNumber: 3, + chapterTitle: "Chapter Title", + status: "ready-for-review", + auditResult: createAuditResult({ + issues: [ + createIssue({ severity: "info", description: "ignore me" }), + createIssue({ severity: "warning", description: "keep me" }), + createIssue({ severity: "critical", description: "keep me too" }), + ], + }), + finalWordCount: 888, + lengthWarnings: ["warn"], + degradedIssues: [], + tokenUsage: ZERO_USAGE, + loadChapterIndex: async () => [] satisfies ReadonlyArray, + saveChapter, + saveTruthFiles, + saveChapterIndex, + markBookActiveIfNeeded, + persistAuditDriftGuidance, + snapshotState, + syncCurrentStateFactHistory, + logSnapshotStage, + now: () => "2026-04-01T00:00:00.000Z", + }); + + expect(saveChapter).toHaveBeenCalledTimes(1); + expect(saveTruthFiles).toHaveBeenCalledTimes(1); + expect(saveChapterIndex).toHaveBeenCalledWith([ + expect.objectContaining({ + number: 3, + title: "Chapter Title", + status: "ready-for-review", + wordCount: 888, + auditIssues: [ + "[info] ignore me", + "[warning] keep me", + "[critical] keep me too", + ], + reviewNote: undefined, + tokenUsage: ZERO_USAGE, + }), + ]); + expect(markBookActiveIfNeeded).toHaveBeenCalledTimes(1); + expect(persistAuditDriftGuidance).toHaveBeenCalledWith([ + expect.objectContaining({ severity: "warning", description: "keep me" }), + expect.objectContaining({ severity: "critical", description: "keep me too" }), + ]); + expect(logSnapshotStage).toHaveBeenCalledTimes(1); + expect(snapshotState).toHaveBeenCalledTimes(1); + expect(syncCurrentStateFactHistory).toHaveBeenCalledTimes(1); + }); + + it("skips truth persistence and snapshots for state-degraded chapters while preserving review note", async () => { + const saveChapter = vi.fn().mockResolvedValue(undefined); + const saveTruthFiles = vi.fn().mockResolvedValue(undefined); + const saveChapterIndex = vi.fn().mockResolvedValue(undefined); + const markBookActiveIfNeeded = vi.fn().mockResolvedValue(undefined); + const persistAuditDriftGuidance = vi.fn().mockResolvedValue(undefined); + const snapshotState = vi.fn().mockResolvedValue(undefined); + const syncCurrentStateFactHistory = vi.fn().mockResolvedValue(undefined); + const logSnapshotStage = vi.fn(); + + await persistChapterArtifacts({ + chapterNumber: 4, + chapterTitle: "Degraded Chapter", + status: "state-degraded", + auditResult: createAuditResult({ + passed: false, + issues: [createIssue({ description: "audit issue" })], + summary: "needs review", + }), + finalWordCount: 512, + lengthWarnings: [], + degradedIssues: [createIssue({ description: "state mismatch" })], + tokenUsage: ZERO_USAGE, + loadChapterIndex: async () => [] satisfies ReadonlyArray, + saveChapter, + saveTruthFiles, + saveChapterIndex, + markBookActiveIfNeeded, + persistAuditDriftGuidance, + snapshotState, + syncCurrentStateFactHistory, + logSnapshotStage, + now: () => "2026-04-01T00:00:00.000Z", + }); + + expect(saveChapter).toHaveBeenCalledTimes(1); + expect(saveTruthFiles).not.toHaveBeenCalled(); + expect(saveChapterIndex).toHaveBeenCalledWith([ + expect.objectContaining({ + number: 4, + title: "Degraded Chapter", + status: "state-degraded", + reviewNote: expect.any(String), + }), + ]); + const reviewNote = saveChapterIndex.mock.calls[0]?.[0]?.[0]?.reviewNote as string; + expect(JSON.parse(reviewNote)).toMatchObject({ + kind: "state-degraded", + baseStatus: "audit-failed", + injectedIssues: ["[warning] state mismatch"], + }); + expect(persistAuditDriftGuidance).toHaveBeenCalledWith([]); + expect(logSnapshotStage).not.toHaveBeenCalled(); + expect(snapshotState).not.toHaveBeenCalled(); + expect(syncCurrentStateFactHistory).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/pipeline/chapter-persistence.ts b/packages/core/src/pipeline/chapter-persistence.ts new file mode 100644 index 00000000..f7badf2c --- /dev/null +++ b/packages/core/src/pipeline/chapter-persistence.ts @@ -0,0 +1,75 @@ +import type { AuditIssue, AuditResult } from "../agents/continuity.js"; +import type { ChapterMeta } from "../models/chapter.js"; +import type { LengthTelemetry } from "../models/length-governance.js"; +import { buildStateDegradedReviewNote } from "./chapter-state-recovery.js"; + +export interface ChapterPersistenceUsage { + readonly promptTokens: number; + readonly completionTokens: number; + readonly totalTokens: number; +} + +export type ChapterPersistenceStatus = "ready-for-review" | "audit-failed" | "state-degraded"; + +export async function persistChapterArtifacts(params: { + readonly chapterNumber: number; + readonly chapterTitle: string; + readonly status: ChapterPersistenceStatus; + readonly auditResult: AuditResult; + readonly finalWordCount: number; + readonly lengthWarnings: ReadonlyArray; + readonly lengthTelemetry?: LengthTelemetry; + readonly degradedIssues: ReadonlyArray; + readonly tokenUsage?: ChapterPersistenceUsage; + readonly loadChapterIndex: () => Promise>; + readonly saveChapter: () => Promise; + readonly saveTruthFiles: () => Promise; + readonly saveChapterIndex: (index: ReadonlyArray) => Promise; + readonly markBookActiveIfNeeded: () => Promise; + readonly persistAuditDriftGuidance: (issues: ReadonlyArray) => Promise; + readonly snapshotState: () => Promise; + readonly syncCurrentStateFactHistory: () => Promise; + readonly logSnapshotStage: () => void; + readonly now?: () => string; +}): Promise<{ readonly entry: ChapterMeta }> { + await params.saveChapter(); + if (params.status !== "state-degraded") { + await params.saveTruthFiles(); + } + + const existingIndex = await params.loadChapterIndex(); + const now = params.now?.() ?? new Date().toISOString(); + const entry: ChapterMeta = { + number: params.chapterNumber, + title: params.chapterTitle, + status: params.status, + wordCount: params.finalWordCount, + createdAt: now, + updatedAt: now, + auditIssues: params.auditResult.issues.map((issue) => `[${issue.severity}] ${issue.description}`), + lengthWarnings: [...params.lengthWarnings], + reviewNote: params.status === "state-degraded" + ? buildStateDegradedReviewNote( + params.auditResult.passed ? "ready-for-review" : "audit-failed", + params.degradedIssues, + ) + : undefined, + lengthTelemetry: params.lengthTelemetry, + tokenUsage: params.tokenUsage, + }; + await params.saveChapterIndex([...existingIndex, entry]); + await params.markBookActiveIfNeeded(); + + const driftIssues = params.auditResult.issues.filter( + (issue) => issue.severity === "critical" || issue.severity === "warning", + ); + await params.persistAuditDriftGuidance(params.status === "state-degraded" ? [] : driftIssues); + + if (params.status !== "state-degraded") { + params.logSnapshotStage(); + await params.snapshotState(); + await params.syncCurrentStateFactHistory(); + } + + return { entry }; +} diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 5adbbf2d..ce2f6cde 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -36,11 +36,11 @@ import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/p import { join } from "node:path"; import { buildStateDegradedPersistenceOutput, - buildStateDegradedReviewNote, parseStateDegradedReviewNote, resolveStateDegradedBaseStatus, retrySettlementAfterValidationFailure, } from "./chapter-state-recovery.js"; +import { persistChapterArtifacts } from "./chapter-persistence.js"; import { runChapterReviewCycle } from "./chapter-review-cycle.js"; import { loadPersistedPlan, relativeToBookDir } from "./persisted-governed-plan.js"; @@ -1268,61 +1268,42 @@ export class PipelineRunner { } } - await writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang); - if (chapterStatus !== "state-degraded") { - await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang); - await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput); - this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" }); - await this.syncNarrativeMemoryIndex(bookId); - } - - // 5. Update chapter index - const existingIndex = await this.state.loadChapterIndex(bookId); - const now = new Date().toISOString(); - const newEntry: ChapterMeta = { - number: chapterNumber, - title: persistenceOutput.title, - status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"), - wordCount: finalWordCount, - createdAt: now, - updatedAt: now, - auditIssues: auditResult.issues.map( - (i) => `[${i.severity}] ${i.description}`, - ), + const resolvedStatus = chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"); + await persistChapterArtifacts({ + chapterNumber, + chapterTitle: persistenceOutput.title, + status: resolvedStatus, + auditResult, + finalWordCount, lengthWarnings, - reviewNote: chapterStatus === "state-degraded" - ? buildStateDegradedReviewNote( - auditResult.passed ? "ready-for-review" : "audit-failed", - degradedIssues, - ) - : undefined, lengthTelemetry, + degradedIssues, tokenUsage: totalUsage, - }; - await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]); - await this.markBookActiveIfNeeded(bookId); - - // 5.5 Persist audit drift guidance without polluting current_state.md - const driftIssues = auditResult.issues.filter( - (i) => i.severity === "critical" || i.severity === "warning", - ); - await this.persistAuditDriftGuidance({ - bookDir, - chapterNumber, - issues: chapterStatus === "state-degraded" ? [] : driftIssues, - language: stageLanguage, - }).catch(() => undefined); - - // 5.6 Snapshot state for rollback support - if (chapterStatus !== "state-degraded") { - this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" }); - await this.state.snapshotState(bookId, chapterNumber); - await this.syncCurrentStateFactHistory(bookId, chapterNumber); - } + loadChapterIndex: () => this.state.loadChapterIndex(bookId), + saveChapter: () => writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang), + saveTruthFiles: async () => { + await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang); + await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput); + this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" }); + await this.syncNarrativeMemoryIndex(bookId); + }, + saveChapterIndex: (index) => this.state.saveChapterIndex(bookId, index), + markBookActiveIfNeeded: () => this.markBookActiveIfNeeded(bookId), + persistAuditDriftGuidance: (issues) => this.persistAuditDriftGuidance({ + bookDir, + chapterNumber, + issues, + language: stageLanguage, + }).catch(() => undefined), + snapshotState: () => this.state.snapshotState(bookId, chapterNumber), + syncCurrentStateFactHistory: () => this.syncCurrentStateFactHistory(bookId, chapterNumber), + logSnapshotStage: () => + this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" }), + }); // 6. Send notification if (this.config.notifyChannels && this.config.notifyChannels.length > 0) { - const statusEmoji = chapterStatus === "state-degraded" + const statusEmoji = resolvedStatus === "state-degraded" ? "🧯" : auditResult.passed ? "✅" : "⚠️"; const chapterLength = formatLengthCount(finalWordCount, lengthSpec.countingMode); @@ -1331,7 +1312,7 @@ export class PipelineRunner { body: [ `**${persistenceOutput.title}** | ${chapterLength}`, revised ? "📝 已自动修正" : "", - chapterStatus === "state-degraded" + resolvedStatus === "state-degraded" ? "状态结算: 已降级保存,需先修复 state 再继续" : `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`, ...auditResult.issues @@ -1348,7 +1329,7 @@ export class PipelineRunner { wordCount: finalWordCount, passed: auditResult.passed, revised, - status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"), + status: resolvedStatus, }); return { @@ -1357,7 +1338,7 @@ export class PipelineRunner { wordCount: finalWordCount, auditResult, revised, - status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"), + status: resolvedStatus, lengthWarnings, lengthTelemetry, tokenUsage: totalUsage, From 16cbf271f7295fb9d890fea63272f652b1aa7fda Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 1 Apr 2026 19:52:06 +0800 Subject: [PATCH 42/53] refactor(pipeline): extract chapter truth validation --- .../chapter-truth-validation.test.ts | 205 ++++++++++++++++++ .../src/pipeline/chapter-truth-validation.ts | 117 ++++++++++ packages/core/src/pipeline/runner.ts | 81 ++----- 3 files changed, 345 insertions(+), 58 deletions(-) create mode 100644 packages/core/src/__tests__/chapter-truth-validation.test.ts create mode 100644 packages/core/src/pipeline/chapter-truth-validation.ts diff --git a/packages/core/src/__tests__/chapter-truth-validation.test.ts b/packages/core/src/__tests__/chapter-truth-validation.test.ts new file mode 100644 index 00000000..5bd47dc4 --- /dev/null +++ b/packages/core/src/__tests__/chapter-truth-validation.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AuditIssue, AuditResult } from "../agents/continuity.js"; +import type { ValidationResult } from "../agents/state-validator.js"; +import type { WriteChapterOutput } from "../agents/writer.js"; +import type { BookConfig } from "../models/book.js"; +import { validateChapterTruthPersistence } from "../pipeline/chapter-truth-validation.js"; + +const ZERO_USAGE = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, +} as const; + +function createAuditResult(overrides?: Partial): AuditResult { + return { + passed: true, + issues: [], + summary: "clean", + tokenUsage: ZERO_USAGE, + ...overrides, + }; +} + +function createValidationResult(overrides?: Partial): ValidationResult { + return { + passed: true, + warnings: [], + ...overrides, + }; +} + +function createWriterOutput(overrides: Partial = {}): WriteChapterOutput { + return { + chapterNumber: 1, + title: "Test Chapter", + content: "Healthy chapter body with the copper token in his coat.", + wordCount: "Healthy chapter body with the copper token in his coat.".length, + preWriteCheck: "check", + postSettlement: "settled", + updatedState: "writer state", + updatedLedger: "writer ledger", + updatedHooks: "writer hooks", + chapterSummary: "| 1 | Original summary |", + updatedSubplots: "writer subplots", + updatedEmotionalArcs: "writer emotions", + updatedCharacterMatrix: "writer matrix", + postWriteErrors: [], + postWriteWarnings: [], + tokenUsage: ZERO_USAGE, + ...overrides, + }; +} + +const BOOK: BookConfig = { + id: "book-1", + title: "Book", + platform: "other", + genre: "xuanhuan", + status: "active", + targetChapters: 10, + chapterWordCount: 2000, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", +}; + +describe("validateChapterTruthPersistence", () => { + it("uses recovered settlement output when retry succeeds", async () => { + const validator = { + validate: vi.fn() + .mockResolvedValueOnce(createValidationResult({ + passed: false, + warnings: [{ + category: "unsupported_change", + description: "正文写铜牌在怀里,但 state 说未携带。", + }], + })) + .mockResolvedValueOnce(createValidationResult()), + }; + const writer = { + settleChapterState: vi.fn().mockResolvedValue( + createWriterOutput({ + updatedState: "fixed state", + updatedHooks: "fixed hooks", + updatedLedger: "fixed ledger", + }), + ), + }; + const logWarn = vi.fn(); + const logger = { warn: vi.fn() }; + + const result = await validateChapterTruthPersistence({ + writer, + validator, + book: BOOK, + bookDir: "/tmp/book", + chapterNumber: 3, + title: "Test Chapter", + content: "Healthy chapter body with the copper token in his coat.", + persistenceOutput: createWriterOutput({ + updatedState: "broken state", + updatedHooks: "broken hooks", + updatedLedger: "broken ledger", + }), + auditResult: createAuditResult(), + previousTruth: { + oldState: "stable state", + oldHooks: "stable hooks", + oldLedger: "stable ledger", + }, + language: "zh", + logWarn, + logger, + }); + + expect(writer.settleChapterState).toHaveBeenCalledTimes(1); + expect(writer.settleChapterState).toHaveBeenCalledWith(expect.objectContaining({ + chapterNumber: 3, + title: "Test Chapter", + validationFeedback: expect.stringContaining("铜牌在怀里"), + })); + expect(result.chapterStatus).toBeNull(); + expect(result.persistenceOutput.updatedState).toBe("fixed state"); + expect(result.persistenceOutput.updatedHooks).toBe("fixed hooks"); + expect(result.auditResult.issues).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith(" [unsupported_change] 正文写铜牌在怀里,但 state 说未携带。"); + }); + + it("degrades persistence output and appends audit issues when retry still fails", async () => { + const validator = { + validate: vi.fn() + .mockResolvedValueOnce(createValidationResult({ + passed: false, + warnings: [{ + category: "unsupported_change", + description: "第一次校验失败。", + }], + })) + .mockResolvedValueOnce(createValidationResult({ + passed: false, + warnings: [{ + category: "unsupported_change", + description: "重试后仍然失败。", + }], + })), + }; + const writer = { + settleChapterState: vi.fn().mockResolvedValue( + createWriterOutput({ + updatedState: "still broken state", + updatedHooks: "still broken hooks", + updatedLedger: "still broken ledger", + }), + ), + }; + const baseIssue: AuditIssue = { + severity: "warning", + category: "title-dedup", + description: "title adjusted", + suggestion: "check title", + }; + + const result = await validateChapterTruthPersistence({ + writer, + validator, + book: BOOK, + bookDir: "/tmp/book", + chapterNumber: 4, + title: "Test Chapter", + content: "Healthy chapter body with the copper token in his coat.", + persistenceOutput: createWriterOutput({ + updatedState: "broken state", + updatedHooks: "broken hooks", + updatedLedger: "broken ledger", + }), + auditResult: createAuditResult({ issues: [baseIssue] }), + previousTruth: { + oldState: "stable state", + oldHooks: "stable hooks", + oldLedger: "stable ledger", + }, + language: "zh", + logWarn: vi.fn(), + logger: { warn: vi.fn() }, + }); + + expect(result.chapterStatus).toBe("state-degraded"); + expect(result.degradedIssues).toEqual([ + expect.objectContaining({ + severity: "warning", + category: "state-validation", + description: "重试后仍然失败。", + }), + ]); + expect(result.persistenceOutput.updatedState).toBe("stable state"); + expect(result.persistenceOutput.updatedHooks).toBe("stable hooks"); + expect(result.persistenceOutput.updatedLedger).toBe("stable ledger"); + expect(result.auditResult.issues).toEqual([ + baseIssue, + expect.objectContaining({ + category: "state-validation", + description: "重试后仍然失败。", + }), + ]); + }); +}); diff --git a/packages/core/src/pipeline/chapter-truth-validation.ts b/packages/core/src/pipeline/chapter-truth-validation.ts new file mode 100644 index 00000000..aec77b2b --- /dev/null +++ b/packages/core/src/pipeline/chapter-truth-validation.ts @@ -0,0 +1,117 @@ +import type { AuditIssue, AuditResult } from "../agents/continuity.js"; +import type { ValidationResult, StateValidatorAgent } from "../agents/state-validator.js"; +import type { WriteChapterOutput, WriterAgent } from "../agents/writer.js"; +import type { BookConfig } from "../models/book.js"; +import type { ContextPackage, RuleStack } from "../models/input-governance.js"; +import type { Logger } from "../utils/logger.js"; +import type { LengthLanguage } from "../utils/length-metrics.js"; +import { + buildStateDegradedPersistenceOutput, + retrySettlementAfterValidationFailure, +} from "./chapter-state-recovery.js"; + +export async function validateChapterTruthPersistence(params: { + readonly writer: Pick; + readonly validator: Pick; + readonly book: BookConfig; + readonly bookDir: string; + readonly chapterNumber: number; + readonly title: string; + readonly content: string; + readonly persistenceOutput: WriteChapterOutput; + readonly auditResult: AuditResult; + readonly previousTruth: { + readonly oldState: string; + readonly oldHooks: string; + readonly oldLedger: string; + }; + readonly reducedControlInput?: { + chapterIntent: string; + contextPackage: ContextPackage; + ruleStack: RuleStack; + }; + readonly language: LengthLanguage; + readonly logWarn: (message: { zh: string; en: string }) => void; + readonly logger?: Pick; +}): Promise<{ + readonly validation: ValidationResult; + readonly chapterStatus: "state-degraded" | null; + readonly degradedIssues: ReadonlyArray; + readonly persistenceOutput: WriteChapterOutput; + readonly auditResult: AuditResult; +}> { + let validation: ValidationResult; + let chapterStatus: "state-degraded" | null = null; + let degradedIssues: ReadonlyArray = []; + let persistenceOutput = params.persistenceOutput; + let auditResult = params.auditResult; + + try { + validation = await params.validator.validate( + params.content, + params.chapterNumber, + params.previousTruth.oldState, + persistenceOutput.updatedState, + params.previousTruth.oldHooks, + persistenceOutput.updatedHooks, + params.language, + ); + } catch (error) { + throw new Error(`State validation failed for chapter ${params.chapterNumber}: ${String(error)}`); + } + + if (validation.warnings.length > 0) { + params.logWarn({ + zh: `状态校验:第${params.chapterNumber}章发现 ${validation.warnings.length} 条警告`, + en: `State validation: ${validation.warnings.length} warning(s) for chapter ${params.chapterNumber}`, + }); + for (const warning of validation.warnings) { + params.logger?.warn(` [${warning.category}] ${warning.description}`); + } + } + + if (!validation.passed) { + const recovery = await retrySettlementAfterValidationFailure({ + writer: params.writer, + validator: params.validator, + book: params.book, + bookDir: params.bookDir, + chapterNumber: params.chapterNumber, + title: params.title, + content: params.content, + reducedControlInput: params.reducedControlInput, + oldState: params.previousTruth.oldState, + oldHooks: params.previousTruth.oldHooks, + originalValidation: validation, + language: params.language, + logWarn: params.logWarn, + logger: params.logger, + }); + + if (recovery.kind === "recovered") { + persistenceOutput = recovery.output; + validation = recovery.validation; + } else { + chapterStatus = "state-degraded"; + degradedIssues = recovery.issues; + persistenceOutput = buildStateDegradedPersistenceOutput({ + output: persistenceOutput, + oldState: params.previousTruth.oldState, + oldHooks: params.previousTruth.oldHooks, + oldLedger: params.previousTruth.oldLedger, + }); + auditResult = { + ...auditResult, + issues: [...auditResult.issues, ...recovery.issues], + }; + } + } + + return { + validation, + chapterStatus, + degradedIssues, + persistenceOutput, + auditResult, + }; +} diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index ce2f6cde..c7ab7313 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -35,13 +35,13 @@ import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js" import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/promises"; import { join } from "node:path"; import { - buildStateDegradedPersistenceOutput, parseStateDegradedReviewNote, resolveStateDegradedBaseStatus, retrySettlementAfterValidationFailure, } from "./chapter-state-recovery.js"; import { persistChapterArtifacts } from "./chapter-persistence.js"; import { runChapterReviewCycle } from "./chapter-review-cycle.js"; +import { validateChapterTruthPersistence } from "./chapter-truth-validation.js"; import { loadPersistedPlan, relativeToBookDir } from "./persisted-governed-plan.js"; export interface PipelineConfig { @@ -1174,65 +1174,30 @@ export class PipelineRunner { readFile(join(storyDir, "particle_ledger.md"), "utf-8").catch(() => ""), ]); const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId)); - let validation: ValidationResult; - let chapterStatus: ChapterPipelineResult["status"] | null = null; - let degradedIssues: ReadonlyArray = []; - try { - validation = await validator.validate( - finalContent, chapterNumber, - oldState, persistenceOutput.updatedState, - oldHooks, persistenceOutput.updatedHooks, - pipelineLang, - ); - } catch (error) { - throw new Error(`State validation failed for chapter ${chapterNumber}: ${String(error)}`); - } - - if (validation.warnings.length > 0) { - this.logWarn(pipelineLang, { - zh: `状态校验:第${chapterNumber}章发现 ${validation.warnings.length} 条警告`, - en: `State validation: ${validation.warnings.length} warning(s) for chapter ${chapterNumber}`, - }); - for (const w of validation.warnings) { - this.config.logger?.warn(` [${w.category}] ${w.description}`); - } - } - if (!validation.passed) { - const recovery = await retrySettlementAfterValidationFailure({ - writer, - validator, - book, - bookDir, - chapterNumber, - title: persistenceOutput.title, - content: finalContent, - reducedControlInput, + const truthValidation = await validateChapterTruthPersistence({ + writer, + validator, + book, + bookDir, + chapterNumber, + title: persistenceOutput.title, + content: finalContent, + persistenceOutput, + auditResult, + previousTruth: { oldState, oldHooks, - originalValidation: validation, - language: pipelineLang, - logWarn: (message) => this.logWarn(pipelineLang, message), - logger: this.config.logger, - }); - - if (recovery.kind === "recovered") { - persistenceOutput = recovery.output; - validation = recovery.validation; - } else { - chapterStatus = "state-degraded"; - degradedIssues = recovery.issues; - persistenceOutput = buildStateDegradedPersistenceOutput({ - output: persistenceOutput, - oldState, - oldHooks, - oldLedger, - }); - auditResult = { - ...auditResult, - issues: [...auditResult.issues, ...recovery.issues], - }; - } - } + oldLedger, + }, + reducedControlInput, + language: pipelineLang, + logWarn: (message) => this.logWarn(pipelineLang, message), + logger: this.config.logger, + }); + let chapterStatus: ChapterPipelineResult["status"] | null = truthValidation.chapterStatus; + let degradedIssues: ReadonlyArray = truthValidation.degradedIssues; + persistenceOutput = truthValidation.persistenceOutput; + auditResult = truthValidation.auditResult; // 4.2 Final paragraph shape check on persisted content (post-normalize, post-revise) { From 86ea01a2a35150782d665bf56da5450f01d1a546 Mon Sep 17 00:00:00 2001 From: Ma Date: Thu, 2 Apr 2026 12:48:02 +0800 Subject: [PATCH 43/53] revert: undo prompt softening and compaction (iter-002 regression fix) Reverts 822708e, 4248e1d, eb288b3 which: - Softened all directive language from "must/do not" to "try/prefer" - Compressed writer context into compact Story Briefs - Removed selectedEvidenceBlock from governed mode - Extended compact briefs to auditor/reviser Control experiment showed these 3 commits are the primary regression suspects: iter-001 build + gpt-5.4 scored 75.5/66.5, while iter-002 (with these commits) scored 71.5/37.5 on the same model. Restores imperative directive language and full context sections. --- .../src/__tests__/chapter-analyzer.test.ts | 3 +- .../core/src/__tests__/continuity.test.ts | 2 - packages/core/src/__tests__/planner.test.ts | 2 - packages/core/src/__tests__/reviser.test.ts | 8 +- packages/core/src/__tests__/writer.test.ts | 21 +- packages/core/src/agents/chapter-analyzer.ts | 53 ++- packages/core/src/agents/continuity.ts | 53 ++- .../core/src/agents/en-prompt-sections.ts | 2 +- packages/core/src/agents/planner.ts | 18 +- packages/core/src/agents/reviser.ts | 37 +- packages/core/src/agents/writer-prompts.ts | 26 +- packages/core/src/agents/writer.ts | 151 ++++++-- .../core/src/utils/compiled-control-brief.ts | 360 ------------------ 13 files changed, 278 insertions(+), 458 deletions(-) delete mode 100644 packages/core/src/utils/compiled-control-brief.ts diff --git a/packages/core/src/__tests__/chapter-analyzer.test.ts b/packages/core/src/__tests__/chapter-analyzer.test.ts index 21247980..fc8337d8 100644 --- a/packages/core/src/__tests__/chapter-analyzer.test.ts +++ b/packages/core/src/__tests__/chapter-analyzer.test.ts @@ -561,8 +561,7 @@ describe("ChapterAnalyzerAgent", () => { const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>; const userPrompt = messages[1]?.content ?? ""; - expect(userPrompt).toContain("## Analysis Brief"); - expect(userPrompt).not.toContain("## Chapter Control Inputs (compiled by Planner/Composer)"); + expect(userPrompt).toContain("## Chapter Control Inputs (compiled by Planner/Composer)"); expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath"); expect(userPrompt).toContain("Selected Hook Evidence"); expect(userPrompt).not.toContain("## Story Bible"); diff --git a/packages/core/src/__tests__/continuity.test.ts b/packages/core/src/__tests__/continuity.test.ts index 93022b27..45a98a4f 100644 --- a/packages/core/src/__tests__/continuity.test.ts +++ b/packages/core/src/__tests__/continuity.test.ts @@ -281,8 +281,6 @@ describe("ContinuityAuditor", () => { | undefined; const userPrompt = messages?.[1]?.content ?? ""; - expect(userPrompt).toContain("## Review Brief"); - expect(userPrompt).not.toContain("## Chapter Control Inputs (compiled by Planner/Composer)"); expect(userPrompt).toContain("story/chapter_summaries.md#99"); expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath"); expect(userPrompt).not.toContain("| 1 | Guild Trail |"); diff --git a/packages/core/src/__tests__/planner.test.ts b/packages/core/src/__tests__/planner.test.ts index 9298dc23..1f0de79d 100644 --- a/packages/core/src/__tests__/planner.test.ts +++ b/packages/core/src/__tests__/planner.test.ts @@ -370,8 +370,6 @@ describe("PlannerAgent", () => { expect(result.intent.moodDirective).toBeDefined(); expect(result.intent.moodDirective).toContain("降调"); expect(result.intent.moodDirective).toContain("日常"); - expect(result.intent.moodDirective).toContain("建议"); - expect(result.intent.moodDirective).not.toContain("必须"); }); it("does not emit a mood directive when recent moods are varied", async () => { diff --git a/packages/core/src/__tests__/reviser.test.ts b/packages/core/src/__tests__/reviser.test.ts index 983ece62..66bf92b9 100644 --- a/packages/core/src/__tests__/reviser.test.ts +++ b/packages/core/src/__tests__/reviser.test.ts @@ -453,14 +453,10 @@ describe("ReviserAgent", () => { | undefined; const userPrompt = messages?.[1]?.content ?? ""; - expect(userPrompt).toContain("## Revision Brief"); - expect(userPrompt).not.toContain("## 本章控制输入(由 Planner/Composer 编译)"); expect(userPrompt).toContain("story/chapter_summaries.md#99"); expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath"); - expect(userPrompt).toContain("The jade seal cannot be destroyed."); - expect(userPrompt).toContain("Track the mentor oath fallout."); - expect(userPrompt).not.toContain("story/story_bible.md"); - expect(userPrompt).not.toContain("story/volume_outline.md"); + expect(userPrompt).toContain("story/story_bible.md"); + expect(userPrompt).toContain("story/volume_outline.md"); expect(userPrompt).not.toContain("| 1 | Guild Trail |"); expect(userPrompt).not.toContain("guild-route | 1 | mystery"); expect(userPrompt).not.toContain("Guildmaster Ren secretly forged the harbor roster in chapter 140."); diff --git a/packages/core/src/__tests__/writer.test.ts b/packages/core/src/__tests__/writer.test.ts index d7fa9011..b997ec19 100644 --- a/packages/core/src/__tests__/writer.test.ts +++ b/packages/core/src/__tests__/writer.test.ts @@ -226,9 +226,8 @@ describe("WriterAgent", () => { }); const settlePrompt = (chatSpy.mock.calls[2]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? ""; - expect(settlePrompt).toContain("## 结算焦点"); - expect(settlePrompt).not.toContain("### 已选上下文"); - expect(settlePrompt).not.toContain("## Hook Agenda"); + expect(settlePrompt).toContain("## 本章控制输入"); + expect(settlePrompt).toContain("story/chapter_summaries.md#99"); expect(settlePrompt).toContain("| 99 | Locked Gate |"); expect(settlePrompt).toContain("## Hook Debt Briefs"); expect(settlePrompt).toContain("mentor-oath | cadence: slow-burn"); @@ -904,12 +903,11 @@ describe("WriterAgent", () => { }); const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? ""; - expect(creativePrompt).toContain("## Story Brief"); - expect(creativePrompt).not.toContain("## Recent Title History"); + expect(creativePrompt).toContain("## Recent Title History"); expect(creativePrompt).toContain("Ledger in Rain"); - expect(creativePrompt).not.toContain("## Recent Mood / Chapter Type Trail"); + expect(creativePrompt).toContain("## Recent Mood / Chapter Type Trail"); expect(creativePrompt).toContain("tight / investigation"); - expect(creativePrompt).not.toContain("## Canon Evidence"); + expect(creativePrompt).toContain("## Canon Evidence"); expect(creativePrompt).toContain("archive fire until volume two"); expect(creativePrompt).toContain("oath debt logic must stay intact"); } finally { @@ -1055,14 +1053,7 @@ describe("WriterAgent", () => { expect(systemPrompt).not.toContain("Hook-A / Hook-B"); expect(systemPrompt).toContain("真实 hook_id"); - expect(systemPrompt).toContain("preferred narrative response"); - expect(systemPrompt).not.toContain("follow the requested move"); - expect(creativePrompt).toContain("## Story Brief"); - expect(creativePrompt).not.toContain("## Hook Pressure and Debt"); - expect(creativePrompt).not.toContain("## Selected Context"); - expect(creativePrompt).not.toContain("### Pressure Map"); - expect(creativePrompt).not.toContain("## Pending Hooks Snapshot"); - expect(creativePrompt).not.toContain("## Chapter Summaries Snapshot"); + expect(creativePrompt).toContain("## Explicit Hook Agenda"); expect(creativePrompt).toContain("mentor-oath"); expect(creativePrompt).toContain("ledger-fragment"); expect(creativePrompt).toContain("stale-ledger"); diff --git a/packages/core/src/agents/chapter-analyzer.ts b/packages/core/src/agents/chapter-analyzer.ts index 61d54a63..bce83406 100644 --- a/packages/core/src/agents/chapter-analyzer.ts +++ b/packages/core/src/agents/chapter-analyzer.ts @@ -12,7 +12,6 @@ import { import { filterEmotionalArcs, filterSubplots } from "../utils/context-filter.js"; import { countChapterLength, resolveLengthCountingMode } from "../utils/length-metrics.js"; import { retrieveMemorySelection } from "../utils/memory-retrieval.js"; -import { buildProcessBrief } from "../utils/compiled-control-brief.js"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; @@ -96,13 +95,7 @@ export class ChapterAnalyzerAgent extends BaseAgent { }) : characterMatrix; const reducedControlBlock = governedMode && input.chapterIntent && input.contextPackage && input.ruleStack - ? `\n${buildProcessBrief({ - kind: "analysis", - chapterIntent: input.chapterIntent, - contextPackage: input.contextPackage, - ruleStack: input.ruleStack, - language: resolvedLanguage, - })}\n` + ? this.buildReducedControlBlock(input.chapterIntent, input.contextPackage, input.ruleStack, resolvedLanguage) : ""; const systemPrompt = this.buildSystemPrompt( @@ -468,6 +461,50 @@ ${params.hooksBlock}${params.volumeSummariesBlock}${params.subplotBlock}${params 请严格按照 === TAG === 格式输出分析结果。`; } + private buildReducedControlBlock( + chapterIntent: string, + contextPackage: ContextPackage, + ruleStack: RuleStack, + language: "zh" | "en", + ): string { + const selectedContext = contextPackage.selectedContext + .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`) + .join("\n"); + const overrides = ruleStack.activeOverrides.length > 0 + ? ruleStack.activeOverrides + .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`) + .join("\n") + : "- none"; + + return language === "en" + ? `\n## Chapter Control Inputs (compiled by Planner/Composer) +${chapterIntent} + +### Selected Context +${selectedContext || "- none"} + +### Rule Stack +- Hard guardrails: ${ruleStack.sections.hard.join(", ") || "(none)"} +- Soft constraints: ${ruleStack.sections.soft.join(", ") || "(none)"} +- Diagnostic rules: ${ruleStack.sections.diagnostic.join(", ") || "(none)"} + +### Active Overrides +${overrides}\n` + : `\n## 本章控制输入(由 Planner/Composer 编译) +${chapterIntent} + +### 已选上下文 +${selectedContext || "- none"} + +### 规则栈 +- 硬护栏:${ruleStack.sections.hard.join("、") || "(无)"} +- 软约束:${ruleStack.sections.soft.join("、") || "(无)"} +- 诊断规则:${ruleStack.sections.diagnostic.join("、") || "(无)"} + +### 当前覆盖 +${overrides}\n`; + } + private buildMemoryGoal(chapterTitle: string | undefined, chapterContent: string): string { return [chapterTitle ?? "", chapterContent.slice(0, 1500)] .filter((part) => part.trim().length > 0) diff --git a/packages/core/src/agents/continuity.ts b/packages/core/src/agents/continuity.ts index fa846da4..d38e9eda 100644 --- a/packages/core/src/agents/continuity.ts +++ b/packages/core/src/agents/continuity.ts @@ -8,7 +8,6 @@ import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions 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 { buildProcessBrief } from "../utils/compiled-control-brief.js"; import { join } from "node:path"; export interface AuditResult { @@ -499,13 +498,7 @@ ${dimList} : `\n## 卷纲(用于大纲偏离检测)\n${volumeOutline}\n` : ""; const reducedControlBlock = options?.chapterIntent && options.contextPackage && options.ruleStack - ? `\n${buildProcessBrief({ - kind: "review", - chapterIntent: options.chapterIntent, - contextPackage: options.contextPackage, - ruleStack: options.ruleStack, - language: resolvedLanguage, - })}\n` + ? this.buildReducedControlBlock(options.chapterIntent, options.contextPackage, options.ruleStack, resolvedLanguage) : ""; const styleGuideBlock = reducedControlBlock.length === 0 ? isEnglish @@ -625,6 +618,50 @@ ${chapterContent}`; }; } + private buildReducedControlBlock( + chapterIntent: string, + contextPackage: ContextPackage, + ruleStack: RuleStack, + language: PromptLanguage, + ): string { + const selectedContext = contextPackage.selectedContext + .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`) + .join("\n"); + const overrides = ruleStack.activeOverrides.length > 0 + ? ruleStack.activeOverrides + .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`) + .join("\n") + : "- none"; + + return language === "en" + ? `\n## Chapter Control Inputs (compiled by Planner/Composer) +${chapterIntent} + +### Selected Context +${selectedContext || "- none"} + +### Rule Stack +- Hard guardrails: ${ruleStack.sections.hard.join(", ") || "(none)"} +- Soft constraints: ${ruleStack.sections.soft.join(", ") || "(none)"} +- Diagnostic rules: ${ruleStack.sections.diagnostic.join(", ") || "(none)"} + +### Active Overrides +${overrides}\n` + : `\n## 本章控制输入(由 Planner/Composer 编译) +${chapterIntent} + +### 已选上下文 +${selectedContext || "- none"} + +### 规则栈 +- 硬护栏:${ruleStack.sections.hard.join("、") || "(无)"} +- 软约束:${ruleStack.sections.soft.join("、") || "(无)"} +- 诊断规则:${ruleStack.sections.diagnostic.join("、") || "(无)"} + +### 当前覆盖 +${overrides}\n`; + } + private extractBalancedJson(text: string): string | null { const start = text.indexOf("{"); if (start === -1) return null; diff --git a/packages/core/src/agents/en-prompt-sections.ts b/packages/core/src/agents/en-prompt-sections.ts index 0a1c435a..adc7847e 100644 --- a/packages/core/src/agents/en-prompt-sections.ts +++ b/packages/core/src/agents/en-prompt-sections.ts @@ -29,7 +29,7 @@ export function buildEnglishCoreRules(_book: BookConfig): string { 15. **No reset buttons**: The world must change permanently in response to major events. ### Reader Psychology -16. **Promise and payoff**: Every planted hook should eventually earn a payoff, reframing, or explicit defer. Every mystery needs an answer path, not aimless drift. +16. **Promise and payoff**: Every planted hook must be resolved. Every mystery must have an answer. 17. **Escalation**: Each conflict should feel higher-stakes than the last—either externally or emotionally. 18. **Reader proxy**: One character should react with surprise/excitement/fear when remarkable things happen, giving readers permission to feel the same. 19. **Pacing breathing room**: After a high-intensity sequence, give 0.5-1 chapter of lower intensity before the next escalation.`; diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index 9020bc57..53f22288 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -338,8 +338,8 @@ export class PlannerAgent extends BaseAgent { } return this.isChineseLanguage(language) - ? "不要继续停留在卷纲 fallback 的保守走法里,优先把本章推向新的弧线节点或地点变化。" - : "Do not keep drifting inside the outline fallback. Prefer a fresh arc beat or location change this chapter."; + ? "不要继续依赖卷纲的 fallback 指令,必须把本章推进到新的弧线节点或地点变化。" + : "Do not keep leaning on the outline fallback. Force this chapter toward a fresh arc beat or location change."; } private buildSceneDirective( @@ -352,8 +352,8 @@ export class PlannerAgent extends BaseAgent { const repeatedType = cadence.scenePressure.repeatedType; return this.isChineseLanguage(language) - ? `最近章节连续停留在“${repeatedType}”,本章最好更换场景容器、地点或行动方式,避免继续沿同一种节拍平移。` - : `Recent chapters are lingering in repeated ${repeatedType} beats. Change the scene container, location, or action pattern if possible instead of sliding forward on the same beat.`; + ? `最近章节连续停留在“${repeatedType}”,本章必须更换场景容器、地点或行动方式。` + : `Recent chapters are stuck in repeated ${repeatedType} beats. Change the scene container, location, or action pattern this chapter.`; } private buildMoodDirective( @@ -366,8 +366,8 @@ export class PlannerAgent extends BaseAgent { const moods = cadence.moodPressure.recentMoods; return this.isChineseLanguage(language) - ? `最近${moods.length}章情绪持续高压(${moods.slice(0, 3).join("、")}),建议本章降调——安排日常/喘息/温情/幽默场景,让读者呼吸。` - : `The last ${moods.length} chapters have been relentlessly tense (${moods.slice(0, 3).join(", ")}). Consider downshifting this chapter with warmth, humor, or breathing room.`; + ? `最近${moods.length}章情绪持续高压(${moods.slice(0, 3).join("、")}),本章必须降调——安排日常/喘息/温情/幽默场景,让读者呼吸。` + : `The last ${moods.length} chapters have been relentlessly tense (${moods.slice(0, 3).join(", ")}). This chapter must downshift — write a quieter scene with warmth, humor, or breathing room.`; } private buildTitleDirective( @@ -380,8 +380,8 @@ export class PlannerAgent extends BaseAgent { const repeatedToken = cadence.titlePressure.repeatedToken; return this.isChineseLanguage(language) - ? `标题尽量不要再围绕“${repeatedToken}”重复命名,换一个新的意象或动作焦点。` - : `Try not to return to another ${repeatedToken}-centric title. Pick a new image or action focus for this chapter title.`; + ? `标题不要再围绕“${repeatedToken}”重复命名,换一个新的意象或动作焦点。` + : `Avoid another ${repeatedToken}-centric title. Pick a new image or action focus for this chapter title.`; } private extractSection(content: string, headings: ReadonlyArray): string | undefined { @@ -709,7 +709,7 @@ export class PlannerAgent extends BaseAgent { "## Style Emphasis", styleEmphasis, "", - "## Creative Pressure", + "## Structured Directives", directives, "", "## Hook Agenda", diff --git a/packages/core/src/agents/reviser.ts b/packages/core/src/agents/reviser.ts index bc28f9e0..765a14b0 100644 --- a/packages/core/src/agents/reviser.ts +++ b/packages/core/src/agents/reviser.ts @@ -13,7 +13,6 @@ import { buildGovernedHookWorkingSet, mergeTableMarkdownByKey, } from "../utils/governed-working-set.js"; -import { buildProcessBrief } from "../utils/compiled-control-brief.js"; import { applySpotFixPatches, parseSpotFixPatches } from "../utils/spot-fix-patches.js"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; @@ -226,13 +225,7 @@ ${outputFormat}`; ? `\n## 同人正典参照(修稿专用)\n本书为同人作品。修改时参照正典角色档案和世界规则,不可违反正典事实。角色对话必须保留原作语癖。\n${fanficCanon}\n` : ""; const reducedControlBlock = options?.chapterIntent && options.contextPackage && options.ruleStack - ? `\n${buildProcessBrief({ - kind: "revision", - chapterIntent: options.chapterIntent, - contextPackage: options.contextPackage, - ruleStack: options.ruleStack, - language: resolvedLanguage, - })}\n` + ? this.buildReducedControlBlock(options.chapterIntent, options.contextPackage, options.ruleStack) : ""; const lengthGuidanceBlock = options?.lengthSpec ? `\n## 字数护栏\n目标字数:${options.lengthSpec.target}\n允许区间:${options.lengthSpec.softMin}-${options.lengthSpec.softMax}\n极限区间:${options.lengthSpec.hardMin}-${options.lengthSpec.hardMax}\n如果修正后超出允许区间,请优先压缩冗余解释、重复动作和弱信息句,不得新增支线或删掉核心事实。\n` @@ -335,4 +328,32 @@ ${chapterContent}`; } } + private buildReducedControlBlock( + chapterIntent: string, + contextPackage: ContextPackage, + ruleStack: RuleStack, + ): string { + const selectedContext = contextPackage.selectedContext + .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`) + .join("\n"); + const overrides = ruleStack.activeOverrides.length > 0 + ? ruleStack.activeOverrides + .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`) + .join("\n") + : "- none"; + + return `\n## 本章控制输入(由 Planner/Composer 编译) +${chapterIntent} + +### 已选上下文 +${selectedContext || "- none"} + +### 规则栈 +- 硬护栏:${ruleStack.sections.hard.join("、") || "(无)"} +- 软约束:${ruleStack.sections.soft.join("、") || "(无)"} +- 诊断规则:${ruleStack.sections.diagnostic.join("、") || "(无)"} + +### 当前覆盖 +${overrides}\n`; + } } diff --git a/packages/core/src/agents/writer-prompts.ts b/packages/core/src/agents/writer-prompts.ts index 1c34a907..eac6327f 100644 --- a/packages/core/src/agents/writer-prompts.ts +++ b/packages/core/src/agents/writer-prompts.ts @@ -104,12 +104,12 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s - The outline is the default plan, not unconditional global supremacy. - When the runtime rule stack records an active L4 -> L3 override, follow the current task over local planning. - Keep hard guardrails compact: canon, continuity facts, and explicit prohibitions still win. -- If an English Variance Brief is provided, use it as a variance cue: avoid the listed phrase/opening/ending patterns and satisfy the scene obligation without sounding mechanical. +- If an English Variance Brief is provided, obey it: avoid the listed phrase/opening/ending patterns and satisfy the scene obligation. - If Hook Debt Briefs are provided, treat them as the active memory of what the reader is still owed: preserve the original promise and change the on-page situation. -- If the explicit hook agenda includes a pressure map, treat the requested move as the preferred narrative response for each target: full-payoff means concrete payoff, partial-payoff means a meaningful intermediate reveal, advance/refresh mean material movement, and quiet-hold means keep the promise visible without cashing it out early. -- When the explicit hook agenda names an eligible resolve target, try to land a concrete payoff beat instead of merely mentioning the old thread. -- When stale debt is present, be cautious about opening sibling hooks; relieve pressure from old promises before minting fresh debt unless the scene earns a new thread. -- When a hook brief says to suppress sibling hooks, avoid faking progress by opening a parallel hook of the same family. +- If the explicit hook agenda includes a pressure map, follow the requested move for each target: full-payoff means concrete payoff, partial-payoff means a meaningful intermediate reveal, advance/refresh mean material movement, and quiet-hold means keep the promise visible without cashing it out early. +- When the explicit hook agenda names an eligible resolve target, land a concrete payoff beat instead of merely mentioning the old thread. +- When stale debt is present, do not open sibling hooks casually; clear pressure from old promises before minting fresh debt. +- When a hook brief says to suppress sibling hooks, do not fake progress by opening a parallel hook of the same family. - In multi-character scenes, include at least one resistance-bearing exchange instead of reducing the beat to summary or explanation.`; } @@ -119,12 +119,12 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s - 卷纲是默认规划,不是全局最高规则。 - 当 runtime rule stack 明确记录了 L4 -> L3 的 active override 时,优先执行当前任务意图,再局部调整规划层。 - 真正不能突破的只有硬护栏:世界设定、连续性事实、显式禁令。 -- 如果提供了 English Variance Brief,把它当成变奏提示:主动避开其中列出的高频短语、重复开头和重复结尾模式,同时自然完成 scene obligation。 +- 如果提供了 English Variance Brief,必须主动避开其中列出的高频短语、重复开头和重复结尾模式,并完成 scene obligation。 - 如果提供了 Hook Debt 简报,把它当成读者仍在等待兑现的承诺记忆:保留原始 promise,并让本章在页上发生真实变化。 -- 如果显式 hook agenda 里带有 pressure map,把其中动作当成优先叙事响应:full-payoff 就是具体兑现,partial-payoff 就是给出中间层级的揭示或缩圈,advance / refresh 就是有分量的推进,quiet-hold 就是让承诺继续可见但不要过早消耗。 -- 如果显式 hook agenda 里出现了可回收目标,优先写出具体兑现片段,不要只是重新提一句旧线索。 -- 如果存在 stale debt,先消化旧承诺的压力,再决定是否开新坑;同类 sibling hook 要谨慎新增,除非场景真的挣到了新的线程。 -- 如果某条 hook 简报明确要求 suppress sibling hooks,避免靠再开一个同类平行坑来假装推进。 +- 如果显式 hook agenda 里带有 pressure map,逐条执行其中要求的动作:full-payoff 就是具体兑现,partial-payoff 就是给出中间层级的揭示或缩圈,advance / refresh 就是有分量的推进,quiet-hold 就是让承诺继续可见但不要过早消耗。 +- 如果显式 hook agenda 里出现了可回收目标,本章必须写出具体兑现片段,不能只是重新提一句旧线索。 +- 如果存在 stale debt,先消化旧承诺的压力,再决定是否开新坑;同类 sibling hook 不得随手再开。 +- 如果某条 hook 简报明确要求 suppress sibling hooks,就不能用再开一个同类平行坑来假装推进。 - 多角色场景里,至少给出一轮带阻力的直接交锋,不要把人物关系写成纯解释或纯总结。`; } @@ -153,7 +153,7 @@ function buildCoreRules(lengthSpec: LengthSpec): string { 1. 以简体中文工作,句子长短交替,段落适合手机阅读(3-5行/段) 2. 目标字数:${lengthSpec.target}字,允许区间:${lengthSpec.softMin}-${lengthSpec.softMax}字 -3. 伏笔前后呼应,不留悬空线;所有埋下的伏笔都应在后续得到兑现、转化或明确延后 +3. 伏笔前后呼应,不留悬空线;所有埋下的伏笔都必须在后续收回 4. 只读必要上下文,不机械重复已有内容 ## 人物塑造铁律 @@ -545,7 +545,7 @@ ${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) | ${preWriteTable} === CHAPTER_TITLE === -(章节标题,不含"第X章"。标题应与已有章节标题区分开,避免重复使用相同或相似的标题;若提供了 recent title history 或高频标题词,优先避开重复词根和高频意象) +(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题;若提供了 recent title history 或高频标题词,必须主动避开重复词根和高频意象) === CHAPTER_CONTENT === (正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字) @@ -598,7 +598,7 @@ ${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) | ${preWriteTable} === CHAPTER_TITLE === -(章节标题,不含"第X章"。标题应与已有章节标题区分开,避免重复使用相同或相似的标题;若提供了 recent title history 或高频标题词,优先避开重复词根和高频意象) +(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题;若提供了 recent title history 或高频标题词,必须主动避开重复词根和高频意象) === CHAPTER_CONTENT === (正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字) diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index d0b705b8..4f61449e 100644 --- a/packages/core/src/agents/writer.ts +++ b/packages/core/src/agents/writer.ts @@ -34,7 +34,6 @@ import type { RuntimeStateSnapshot } from "../state/state-reducer.js"; import { parsePendingHooksMarkdown } from "../utils/memory-retrieval.js"; import { analyzeHookHealth } from "../utils/hook-health.js"; import { buildEnglishVarianceBrief } from "../utils/long-span-fatigue.js"; -import { buildSettlementFocus, buildStoryBrief } from "../utils/compiled-control-brief.js"; import { readFile, writeFile, mkdir, readdir } from "node:fs/promises"; import { join } from "node:path"; @@ -196,6 +195,7 @@ export class WriterAgent extends BaseAgent { lengthSpec: resolvedLengthSpec, language: book.language ?? genreProfile.language, varianceBrief: englishVarianceBrief?.text, + selectedEvidenceBlock: this.joinGovernedEvidenceBlocks(governedMemoryBlocks), }) : (() => { // Smart context filtering: inject only relevant parts of truth files @@ -810,23 +810,64 @@ ${lengthRequirementBlock} readonly lengthSpec: LengthSpec; readonly language?: "zh" | "en"; readonly varianceBrief?: string; + readonly selectedEvidenceBlock?: string; }): string { + const contextSections = params.contextPackage.selectedContext + .map((entry) => [ + `### ${entry.source}`, + `- reason: ${entry.reason}`, + entry.excerpt ? `- excerpt: ${entry.excerpt}` : "", + ].filter(Boolean).join("\n")) + .join("\n\n"); + + const overrideLines = params.ruleStack.activeOverrides.length > 0 + ? params.ruleStack.activeOverrides + .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`) + .join("\n") + : "- none"; + + const diagnosticLines = params.ruleStack.sections.diagnostic.length > 0 + ? params.ruleStack.sections.diagnostic.join(", ") + : "none"; + + const traceNotes = params.trace && params.trace.notes.length > 0 + ? params.trace.notes.map((note) => `- ${note}`).join("\n") + : "- none"; const lengthRequirementBlock = this.buildLengthRequirementBlock(params.lengthSpec, params.language ?? "zh"); const varianceBlock = params.varianceBrief ? `\n${params.varianceBrief}\n` : ""; - const storyBrief = this.buildGovernedStoryBrief({ - chapterIntent: params.chapterIntent, - contextPackage: params.contextPackage, - ruleStack: params.ruleStack, - trace: params.trace, - language: params.language ?? "zh", - }); + const selectedEvidenceBlock = params.selectedEvidenceBlock + ? `\n${params.selectedEvidenceBlock}\n` + : ""; + const explicitHookAgenda = this.extractMarkdownSection(params.chapterIntent, "## Hook Agenda"); + const hookAgendaBlock = explicitHookAgenda + ? params.language === "en" + ? `\n## Explicit Hook Agenda\n${explicitHookAgenda}\n` + : `\n## 显式 Hook Agenda\n${explicitHookAgenda}\n` + : ""; if (params.language === "en") { return `Write chapter ${params.chapterNumber}. -${storyBrief} +## Chapter Intent +${params.chapterIntent} + +## Selected Context +${contextSections || "(none)"} +${selectedEvidenceBlock} +${hookAgendaBlock} + +## Rule Stack +- Hard: ${params.ruleStack.sections.hard.join(", ") || "(none)"} +- Soft: ${params.ruleStack.sections.soft.join(", ") || "(none)"} +- Diagnostic: ${diagnosticLines} + +## Active Overrides +${overrideLines} + +## Trace Notes +${traceNotes} ${varianceBlock} ${lengthRequirementBlock} @@ -836,7 +877,24 @@ ${lengthRequirementBlock} return `请续写第${params.chapterNumber}章。 -${storyBrief} +## 本章意图 +${params.chapterIntent} + +## 已选上下文 +${contextSections || "(无)"} +${selectedEvidenceBlock} +${hookAgendaBlock} + +## 规则栈 +- 硬护栏:${params.ruleStack.sections.hard.join("、") || "(无)"} +- 软约束:${params.ruleStack.sections.soft.join("、") || "(无)"} +- 诊断规则:${diagnosticLines} + +## 当前覆盖 +${overrideLines} + +## 追踪说明 +${traceNotes} ${varianceBlock} ${lengthRequirementBlock} @@ -864,14 +922,27 @@ ${lengthRequirementBlock} return joined || undefined; } - private buildGovernedStoryBrief(params: { - readonly chapterIntent: string; - readonly contextPackage: ContextPackage; - readonly ruleStack: RuleStack; - readonly trace?: ChapterTrace; - readonly language: "zh" | "en"; - }): string { - return buildStoryBrief(params); + private extractMarkdownSection(content: string, heading: string): string | undefined { + const lines = content.split("\n"); + let buffer: string[] | null = null; + + for (const line of lines) { + if (line.trim() === heading) { + buffer = []; + continue; + } + + if (buffer && line.startsWith("## ") && line.trim() !== heading) { + break; + } + + if (buffer) { + buffer.push(line); + } + } + + const section = buffer?.join("\n").trim(); + return section && section.length > 0 ? section : undefined; } private buildSettlerGovernedControlBlock( @@ -880,12 +951,44 @@ ${lengthRequirementBlock} ruleStack: RuleStack, language: "zh" | "en", ): string { - return buildSettlementFocus({ - chapterIntent, - contextPackage, - ruleStack, - language, - }); + const selectedContext = contextPackage.selectedContext + .map((entry) => `- ${entry.source}: ${entry.reason}${entry.excerpt ? ` | ${entry.excerpt}` : ""}`) + .join("\n"); + const overrides = ruleStack.activeOverrides.length > 0 + ? ruleStack.activeOverrides + .map((override) => `- ${override.from} -> ${override.to}: ${override.reason} (${override.target})`) + .join("\n") + : "- none"; + + if (language === "en") { + return `\n## Chapter Control Inputs +${chapterIntent} + +### Selected Context +${selectedContext || "- none"} + +### Rule Stack +- Hard guardrails: ${ruleStack.sections.hard.join(", ") || "(none)"} +- Soft constraints: ${ruleStack.sections.soft.join(", ") || "(none)"} +- Diagnostic rules: ${ruleStack.sections.diagnostic.join(", ") || "(none)"} + +### Active Overrides +${overrides}\n`; + } + + return `\n## 本章控制输入 +${chapterIntent} + +### 已选上下文 +${selectedContext || "- none"} + +### 规则栈 +- 硬护栏:${ruleStack.sections.hard.join("、") || "(无)"} +- 软约束:${ruleStack.sections.soft.join("、") || "(无)"} +- 诊断规则:${ruleStack.sections.diagnostic.join("、") || "(无)"} + +### 当前覆盖 +${overrides}\n`; } private buildLengthRequirementBlock(lengthSpec: LengthSpec, language: "zh" | "en"): string { diff --git a/packages/core/src/utils/compiled-control-brief.ts b/packages/core/src/utils/compiled-control-brief.ts deleted file mode 100644 index 6a5b3968..00000000 --- a/packages/core/src/utils/compiled-control-brief.ts +++ /dev/null @@ -1,360 +0,0 @@ -import type { ChapterTrace, ContextPackage, RuleStack } from "../models/input-governance.js"; - -type PromptLanguage = "zh" | "en"; -type ProcessBriefKind = "analysis" | "review" | "revision"; - -interface BriefInput { - readonly chapterIntent: string; - readonly contextPackage: ContextPackage; - readonly ruleStack: RuleStack; - readonly language: PromptLanguage; - readonly trace?: ChapterTrace; -} - -interface CompiledControlSummary { - readonly goal?: string; - readonly mustKeep: string[]; - readonly mustAvoid: string[]; - readonly styleEmphasis: string[]; - readonly creativePressure: string[]; - readonly hookFocus: string[]; - readonly stateAnchors: string[]; - readonly storyGuardrails: string[]; - readonly outlineAnchors: string[]; - readonly canonGuardrails: string[]; - readonly titleHistory?: string; - readonly moodTrail?: string; - readonly overrideLines: string[]; - readonly traceNotes: string[]; -} - -export function buildStoryBrief(params: BriefInput): string { - const summary = compileControlSummary(params); - const continuityAnchors = uniqueContextLines([ - ...summary.stateAnchors, - ...summary.storyGuardrails, - ...summary.outlineAnchors, - ]); - - if (params.language === "en") { - return [ - "## Story Brief", - summary.goal ? `Goal: ${summary.goal}` : "", - renderCompactList("Keep continuity on", summary.mustKeep), - renderCompactList("Avoid this chapter", summary.mustAvoid), - renderCompactList("Style emphasis", summary.styleEmphasis), - renderCompactList("Creative pressure", summary.creativePressure), - renderCompactList("Hook focus", summary.hookFocus), - renderCompactList("Continuity anchors", continuityAnchors), - renderCompactList("Canon guardrails", summary.canonGuardrails), - summary.titleHistory ? `Recent title history: ${summary.titleHistory}` : "", - summary.moodTrail ? `Recent mood/type trail: ${summary.moodTrail}` : "", - renderCompactList("Local overrides", summary.overrideLines), - renderCompactList("Trace notes", summary.traceNotes), - ].filter(Boolean).join("\n\n"); - } - - return [ - "## Story Brief", - summary.goal ? `目标:${summary.goal}` : "", - renderCompactList("本章必须守住", summary.mustKeep), - renderCompactList("本章避免", summary.mustAvoid), - renderCompactList("文风强调", summary.styleEmphasis), - renderCompactList("创作压力", summary.creativePressure), - renderCompactList("伏笔焦点", summary.hookFocus), - renderCompactList("连续性锚点", continuityAnchors), - renderCompactList("正典护栏", summary.canonGuardrails), - summary.titleHistory ? `近期标题历史:${summary.titleHistory}` : "", - summary.moodTrail ? `近期情绪/章节类型轨迹:${summary.moodTrail}` : "", - renderCompactList("局部覆盖", summary.overrideLines), - renderCompactList("追踪备注", summary.traceNotes), - ].filter(Boolean).join("\n\n"); -} - -export function buildSettlementFocus(params: Omit): string { - const summary = compileControlSummary(params); - const continuityAnchors = uniqueContextLines([ - ...summary.stateAnchors, - ...summary.storyGuardrails, - ...summary.outlineAnchors, - ...summary.canonGuardrails, - ]); - - if (params.language === "en") { - return `\n## Settlement Focus -${summary.goal ? `Goal: ${summary.goal}\n` : ""}${renderCompactList("Keep synced with", summary.mustKeep)}${renderCompactList("Hook settlement cues", summary.hookFocus)}${renderCompactList("Continuity anchors", continuityAnchors)}${renderCompactList("Local overrides", summary.overrideLines)}\n`; - } - - return `\n## 结算焦点 -${summary.goal ? `目标:${summary.goal}\n` : ""}${renderCompactList("结算时守住", summary.mustKeep)}${renderCompactList("伏笔结算提示", summary.hookFocus)}${renderCompactList("连续性锚点", continuityAnchors)}${renderCompactList("局部覆盖", summary.overrideLines)}\n`; -} - -export function buildProcessBrief( - params: BriefInput & { readonly kind: ProcessBriefKind }, -): string { - const summary = compileControlSummary(params); - const heading = params.kind === "analysis" - ? "## Analysis Brief" - : params.kind === "review" - ? "## Review Brief" - : "## Revision Brief"; - - if (params.language === "en") { - return [ - heading, - summary.goal ? `Goal: ${summary.goal}` : "", - renderCompactList( - params.kind === "analysis" - ? "Keep analysis aligned with" - : params.kind === "review" - ? "Review against" - : "Preserve while revising", - summary.mustKeep, - ), - renderCompactList( - params.kind === "analysis" - ? "Avoid misreading" - : params.kind === "review" - ? "Flag if chapter drifts into" - : "Avoid while revising", - summary.mustAvoid, - ), - renderCompactList( - params.kind === "analysis" - ? "Update hook state around" - : params.kind === "review" - ? "Hook focus" - : "Hook details to preserve", - summary.hookFocus, - ), - renderCompactList("Current-state anchors", summary.stateAnchors), - renderCompactList("Outline anchor", summary.outlineAnchors), - renderCompactList("World rules", summary.storyGuardrails), - renderCompactList("Canon guardrails", summary.canonGuardrails), - renderCompactList("Local overrides", summary.overrideLines), - params.kind === "analysis" ? renderCompactList("Trace notes", summary.traceNotes) : "", - ].filter(Boolean).join("\n\n"); - } - - return [ - heading, - summary.goal ? `目标:${summary.goal}` : "", - renderCompactList( - params.kind === "analysis" - ? "更新时守住" - : params.kind === "review" - ? "重点核对" - : "修订时守住", - summary.mustKeep, - ), - renderCompactList( - params.kind === "analysis" - ? "避免误读" - : params.kind === "review" - ? "若出现则重点标记" - : "修订时避免", - summary.mustAvoid, - ), - renderCompactList( - params.kind === "analysis" - ? "更新伏笔时参考" - : params.kind === "review" - ? "伏笔焦点" - : "伏笔细节保留", - summary.hookFocus, - ), - renderCompactList("当前状态锚点", summary.stateAnchors), - renderCompactList("卷纲锚点", summary.outlineAnchors), - renderCompactList("世界规则", summary.storyGuardrails), - renderCompactList("正典护栏", summary.canonGuardrails), - renderCompactList("局部覆盖", summary.overrideLines), - params.kind === "analysis" ? renderCompactList("追踪备注", summary.traceNotes) : "", - ].filter(Boolean).join("\n\n"); -} - -function compileControlSummary(params: BriefInput): CompiledControlSummary { - return { - goal: readIntentScalar(params.chapterIntent, "## Goal"), - mustKeep: readIntentList(params.chapterIntent, "## Must Keep"), - mustAvoid: readIntentList(params.chapterIntent, "## Must Avoid"), - styleEmphasis: readIntentList(params.chapterIntent, "## Style Emphasis"), - creativePressure: readIntentList(params.chapterIntent, "## Creative Pressure"), - hookFocus: buildHookFocusLines(params.chapterIntent, params.contextPackage, params.language), - stateAnchors: collectContextExcerpts(params.contextPackage, (entry) => - entry.source.startsWith("story/current_state.md"), - ), - storyGuardrails: collectContextExcerpts(params.contextPackage, (entry) => - entry.source === "story/story_bible.md", - ), - outlineAnchors: collectContextExcerpts(params.contextPackage, (entry) => - entry.source === "story/volume_outline.md", - ), - canonGuardrails: collectContextExcerpts(params.contextPackage, (entry) => - entry.source === "story/parent_canon.md" || entry.source === "story/fanfic_canon.md", - ), - titleHistory: findContextExcerpt(params.contextPackage, "story/chapter_summaries.md#recent_titles"), - moodTrail: findContextExcerpt( - params.contextPackage, - "story/chapter_summaries.md#recent_mood_type_trail", - ), - overrideLines: params.ruleStack.activeOverrides - .map((override) => `${override.reason} (${override.target})`), - traceNotes: params.trace?.notes.filter(Boolean) ?? [], - }; -} - -function readIntentScalar(chapterIntent: string, heading: string): string | undefined { - const section = extractMarkdownSection(chapterIntent, heading); - if (!section) { - return undefined; - } - - return section - .split("\n") - .map((value) => value.trim()) - .find((value) => value.length > 0 && !value.startsWith("-") && value.toLowerCase() !== "(not found)"); -} - -function readIntentList(chapterIntent: string, heading: string): string[] { - const section = extractMarkdownSection(chapterIntent, heading); - if (!section) { - return []; - } - - return section - .split("\n") - .map((value) => value.trim()) - .filter((value) => value.startsWith("- ")) - .map((value) => value.slice(2).trim()) - .filter((value) => value.length > 0 && value.toLowerCase() !== "none"); -} - -function extractMarkdownSection(content: string, heading: string): string | undefined { - const lines = content.split("\n"); - let buffer: string[] | null = null; - - for (const line of lines) { - if (line.trim() === heading) { - buffer = []; - continue; - } - - if (buffer && line.startsWith("## ") && line.trim() !== heading) { - break; - } - - if (buffer) { - buffer.push(line); - } - } - - const section = buffer?.join("\n").trim(); - return section && section.length > 0 ? section : undefined; -} - -function buildHookFocusLines( - chapterIntent: string, - contextPackage: ContextPackage, - language: PromptLanguage, -): string[] { - const hookDebtLines = collectContextExcerpts( - contextPackage, - (entry) => entry.source.startsWith("runtime/hook_debt#"), - ); - const fallbackLines = [ - ...formatAgendaLines(chapterIntent, "Must Advance", language === "en" ? "Must advance" : "必须推进"), - ...formatAgendaLines(chapterIntent, "Eligible Resolve", language === "en" ? "Eligible payoff" : "可兑现"), - ...formatAgendaLines(chapterIntent, "Stale Debt", language === "en" ? "Stale debt" : "旧债"), - ...formatAgendaLines( - chapterIntent, - "Avoid New Hook Families", - language === "en" ? "Avoid new hook families" : "避免新增同类 hook", - ), - ]; - - return uniqueContextLines([ - ...(hookDebtLines.length > 0 ? hookDebtLines : []), - ...fallbackLines, - ]).slice(0, 6); -} - -function formatAgendaLines( - chapterIntent: string, - subheading: string, - label: string, -): string[] { - const hookAgenda = extractMarkdownSection(chapterIntent, "## Hook Agenda"); - if (!hookAgenda) { - return []; - } - - const lines = hookAgenda.split("\n"); - const values: string[] = []; - let capture = false; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (line === `### ${subheading}`) { - capture = true; - continue; - } - if (capture && line.startsWith("### ")) { - break; - } - if (capture && line.startsWith("- ")) { - const value = line.slice(2).trim(); - if (value && value.toLowerCase() !== "none") { - values.push(`${label}: ${value}`); - } - } - } - - return values; -} - -function collectContextExcerpts( - contextPackage: ContextPackage, - predicate: (entry: ContextPackage["selectedContext"][number]) => boolean, -): string[] { - return uniqueContextLines( - contextPackage.selectedContext - .filter(predicate) - .map((entry) => entry.excerpt ?? entry.reason) - .filter(Boolean), - ); -} - -function findContextExcerpt( - contextPackage: ContextPackage, - source: string, -): string | undefined { - return contextPackage.selectedContext.find((entry) => entry.source === source)?.excerpt; -} - -function renderCompactList(heading: string, items: ReadonlyArray): string { - if (items.length === 0) { - return ""; - } - - return `${heading}:\n${items.map((item) => `- ${item}`).join("\n")}`; -} - -function uniqueContextLines(items: ReadonlyArray): string[] { - const seen = new Set(); - const result: string[] = []; - - for (const item of items) { - const trimmed = item.trim(); - if (!trimmed) { - continue; - } - const normalized = trimmed.toLowerCase(); - if (seen.has(normalized)) { - continue; - } - seen.add(normalized); - result.push(trimmed); - } - - return result; -} From fb8e1aa5230b970ddbeec5ea7a6f9a68e8a5572c Mon Sep 17 00:00:00 2001 From: Ma Date: Thu, 2 Apr 2026 16:00:48 +0800 Subject: [PATCH 44/53] fix(pipeline): exclude sequence-level fatigue from revision blockingCount Long-span-fatigue warnings (mood monotony, title clustering, pacing repetition, opening/ending pattern similarity) are sequence-level properties that cannot be fixed by revising the current chapter's text. Previously these warnings inflated blockingCount in evaluateMergedAudit, causing the revision gate to trigger or reject revisions for issues that no single-chapter edit can resolve. Now blockingCount only counts chapter-level issues (LLM audit, AI tells, sensitive words). Sequence-level warnings still appear in the audit report but no longer drive revision decisions. --- packages/core/src/pipeline/runner.ts | 33 ++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index c7ab7313..f2f15f8c 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -44,6 +44,19 @@ import { runChapterReviewCycle } from "./chapter-review-cycle.js"; import { validateChapterTruthPersistence } from "./chapter-truth-validation.js"; import { loadPersistedPlan, relativeToBookDir } from "./persisted-governed-plan.js"; +const SEQUENCE_LEVEL_CATEGORIES = new Set([ + "Pacing Monotony", "节奏单调", + "Mood Monotony", "情绪单调", + "Title Collapse", "标题重复", + "Title Clustering", "标题聚集", + "Opening Pattern Repetition", "开头同构", + "Ending Pattern Repetition", "结尾同构", +]); + +function isSequenceLevelCategory(category: string): boolean { + return SEQUENCE_LEVEL_CATEGORIES.has(category); +} + export interface PipelineConfig { readonly client: LLMClient; readonly model: string; @@ -2455,11 +2468,14 @@ ${matrix}`, return next; } + const revisionRelevant = auditResult.issues.filter( + (issue) => !isSequenceLevelCategory(issue.category), + ); return { ...next, auditResult, - blockingCount: auditResult.issues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length, - criticalCount: auditResult.issues.filter((issue) => issue.severity === "critical").length, + blockingCount: revisionRelevant.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length, + criticalCount: revisionRelevant.filter((issue) => issue.severity === "critical").length, }; } @@ -2510,6 +2526,15 @@ ${matrix}`, ...longSpanFatigue.issues, ]; + // Long-span-fatigue issues (mood monotony, title clustering, pacing) are + // sequence-level properties that cannot be fixed by revising the current + // chapter. Exclude them from blockingCount so they don't inflate the + // revision gate or reject otherwise-valid revisions. + const longSpanCategories = new Set(longSpanFatigue.issues.map((issue) => issue.category)); + const revisionRelevantIssues = issues.filter( + (issue) => !longSpanCategories.has(issue.category), + ); + return { auditResult: { passed: hasBlockedWords ? false : llmAudit.passed, @@ -2518,8 +2543,8 @@ ${matrix}`, tokenUsage: llmAudit.tokenUsage, }, aiTellCount: aiTells.issues.length, - blockingCount: issues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length, - criticalCount: issues.filter((issue) => issue.severity === "critical").length, + blockingCount: revisionRelevantIssues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length, + criticalCount: revisionRelevantIssues.filter((issue) => issue.severity === "critical").length, }; } From f4ee25b1fe32186ad685a6d6802c588d43d24aa5 Mon Sep 17 00:00:00 2001 From: Ma Date: Thu, 2 Apr 2026 16:15:13 +0800 Subject: [PATCH 45/53] fix(writer): override LLM-hallucinated chapter numbers in state delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settler may extract a chapter number from story content rather than using the pipeline's authoritative value (e.g. parsing year "1988" from a contemporary fiction novel as "第1988章"). This corrupts the manifest's lastAppliedChapter, causing all subsequent chapters to use absurd numbers. buildRuntimeStateArtifactsIfPresent now accepts an authoritative chapter number and replaces the delta's chapter field when it disagrees. Root cause of 旧城暗号v4's ch4→ch1988 numbering jump. --- packages/core/src/agents/writer.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index 4f61449e..d1589192 100644 --- a/packages/core/src/agents/writer.ts +++ b/packages/core/src/agents/writer.ts @@ -319,6 +319,7 @@ export class WriterAgent extends BaseAgent { bookDir, settlement.runtimeStateDelta, resolvedLanguage, + chapterNumber, ); const resolvedRuntimeStateDelta = runtimeStateArtifacts?.resolvedDelta ?? settlement.runtimeStateDelta; const priorHookIds = new Set(parsePendingHooksMarkdown(hooks).map((hook) => hook.hookId)); @@ -467,6 +468,7 @@ export class WriterAgent extends BaseAgent { input.bookDir, settlement.runtimeStateDelta, resolvedLanguage, + input.chapterNumber, ); return { @@ -1098,11 +1100,17 @@ ${overrides}\n`; bookDir: string, delta: RuntimeStateDelta | undefined, language: "zh" | "en", + authorativeChapterNumber?: number, ): Promise { if (!delta) return null; + // The LLM may hallucinate a chapter number from story content (e.g. year + // 1988 parsed as "第1988章"). Always trust the pipeline's chapter number. + const safeDelta = authorativeChapterNumber !== undefined && delta.chapter !== authorativeChapterNumber + ? { ...delta, chapter: authorativeChapterNumber } + : delta; return buildRuntimeStateArtifacts({ bookDir, - delta, + delta: safeDelta, language, }); } From b4f5cc6d968581aed4cb4c3f4c6e5f255bffed16 Mon Sep 17 00:00:00 2001 From: Ma Date: Thu, 2 Apr 2026 16:54:17 +0800 Subject: [PATCH 46/53] fix(pipeline): scope revision blockers and normalize delta chapters --- .../src/__tests__/pipeline-runner.test.ts | 133 +++++++++++++++- packages/core/src/__tests__/writer.test.ts | 144 ++++++++++++++++++ packages/core/src/agents/writer.ts | 68 ++++++++- packages/core/src/pipeline/runner.ts | 39 +++-- 4 files changed, 357 insertions(+), 27 deletions(-) diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts index b4a65d0b..41df270f 100644 --- a/packages/core/src/__tests__/pipeline-runner.test.ts +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -1962,7 +1962,7 @@ describe("PipelineRunner", () => { await rm(root, { recursive: true, force: true }); }); - it("does not corrupt persisted runtime state when writer delta is invalid", async () => { + it("repairs chapter-number drift in writer delta before persisting runtime state", async () => { const { root, runner, state, bookId } = await createRunnerFixture({ inputGovernanceMode: "legacy", }); @@ -1997,9 +1997,6 @@ describe("PipelineRunner", () => { }, null, 2), "utf-8"), ]); - const beforeState = await readFile(join(storyDir, "current_state.md"), "utf-8"); - const beforeManifest = await readFile(join(storyDir, "state", "manifest.json"), "utf-8"); - vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue( createWriterOutput({ content: "Broken chapter body.", @@ -2025,10 +2022,13 @@ describe("PipelineRunner", () => { }), ); - await expect(runner.writeNextChapter(bookId)).rejects.toThrow(); + const result = await runner.writeNextChapter(bookId); - await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe(beforeState); - await expect(readFile(join(storyDir, "state", "manifest.json"), "utf-8")).resolves.toBe(beforeManifest); + expect(result.status).toBe("ready-for-review"); + await expect(readFile(join(storyDir, "current_state.md"), "utf-8")) + .resolves.toMatch(/\|\s*(Current Chapter|当前章节)\s*\|\s*1\s*\|/); + await expect(readFile(join(storyDir, "state", "manifest.json"), "utf-8")) + .resolves.toContain("\"lastAppliedChapter\": 1"); await rm(root, { recursive: true, force: true }); }); @@ -3707,6 +3707,125 @@ describe("PipelineRunner", () => { } }); + it("excludes pure sequence-level fatigue from revision blocker counts", async () => { + const { root, runner, state, bookId } = await createRunnerFixture(); + const bookDir = state.bookDir(bookId); + const storyDir = join(bookDir, "story"); + const book = await state.loadBookConfig(bookId); + + await writeFile(join(storyDir, "chapter_summaries.md"), [ + "# 章节摘要", + "", + "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + "| 1 | 旧门 | 林越 | 进入旧门 | 压力升高 | none | 冷峻 | 调查 |", + "| 2 | 灰灯 | 林越 | 检查灰灯 | 压力升高 | none | 冷峻 | 调查 |", + "| 3 | 纸页 | 林越 | 对照纸页 | 压力升高 | none | 冷峻 | 调查 |", + "", + ].join("\n"), "utf-8"); + + const result = await ( + runner as unknown as { + evaluateMergedAudit: (params: { + auditor: Pick; + book: BookConfig; + bookDir: string; + chapterContent: string; + chapterNumber: number; + language: "zh" | "en"; + }) => Promise<{ + auditResult: AuditResult; + aiTellCount: number; + blockingCount: number; + criticalCount: number; + }>; + } + ).evaluateMergedAudit({ + auditor: { + auditChapter: vi.fn().mockResolvedValue( + createAuditResult({ + passed: true, + issues: [], + summary: "clean", + }), + ), + }, + book, + bookDir, + chapterContent: "林越把纸页摊平,先看角上的水痕,再看最末那道被抹掉的签名。", + chapterNumber: 3, + language: "zh", + }); + + expect(result.auditResult.issues.some((issue) => issue.category === "节奏单调")).toBe(true); + expect(result.blockingCount).toBe(0); + expect(result.criticalCount).toBe(0); + + await rm(root, { recursive: true, force: true }); + }); + + it("keeps chapter-level blockers even when sequence-level fatigue shares the same category label", async () => { + const { root, runner, state, bookId } = await createRunnerFixture(); + const bookDir = state.bookDir(bookId); + const storyDir = join(bookDir, "story"); + const book = await state.loadBookConfig(bookId); + + await writeFile(join(storyDir, "chapter_summaries.md"), [ + "# 章节摘要", + "", + "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + "| 1 | 旧门 | 林越 | 进入旧门 | 压力升高 | none | 冷峻 | 调查 |", + "| 2 | 灰灯 | 林越 | 检查灰灯 | 压力升高 | none | 冷峻 | 调查 |", + "| 3 | 纸页 | 林越 | 对照纸页 | 压力升高 | none | 冷峻 | 调查 |", + "", + ].join("\n"), "utf-8"); + + const result = await ( + runner as unknown as { + evaluateMergedAudit: (params: { + auditor: Pick; + book: BookConfig; + bookDir: string; + chapterContent: string; + chapterNumber: number; + language: "zh" | "en"; + }) => Promise<{ + auditResult: AuditResult; + aiTellCount: number; + blockingCount: number; + criticalCount: number; + }>; + } + ).evaluateMergedAudit({ + auditor: { + auditChapter: vi.fn().mockResolvedValue( + createAuditResult({ + passed: false, + issues: [{ + severity: "warning", + category: "节奏单调", + description: "这一章的推进依然原地打转,没有完成当前场景应有的落点。", + suggestion: "让当前章把既定动作落下,不要继续停在同一观察节拍。", + }], + summary: "needs revision", + }), + ), + }, + book, + bookDir, + chapterContent: "林越把纸页摊平,先看角上的水痕,再看最末那道被抹掉的签名。", + chapterNumber: 3, + language: "zh", + }); + + expect(result.auditResult.issues.filter((issue) => issue.category === "节奏单调")).toHaveLength(2); + expect(result.blockingCount).toBe(1); + expect(result.criticalCount).toBe(0); + + await rm(root, { recursive: true, force: true }); + }); + it("uses chapter length telemetry target for manual revise when available", async () => { const { root, runner, state, bookId } = await createRunnerFixture(); const storyDir = join(state.bookDir(bookId), "story"); diff --git a/packages/core/src/__tests__/writer.test.ts b/packages/core/src/__tests__/writer.test.ts index d2d54de0..9f482fbe 100644 --- a/packages/core/src/__tests__/writer.test.ts +++ b/packages/core/src/__tests__/writer.test.ts @@ -377,6 +377,150 @@ describe("WriterAgent", () => { } }); + it("overrides hallucinated chapter numbers across both delta and summary row", async () => { + const root = await mkdtemp(join(tmpdir(), "inkos-writer-runtime-state-hallucinated-chapter-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 city still remembers 1988.\n", "utf-8"), + writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nTrace the debt through the river-port ledger.\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", + "", + "| Field | Value |", + "| --- | --- |", + "| Current Chapter | 2 |", + "| Current Goal | Find the vanished mentor |", + "| Current Conflict | Guild pressure keeps colliding with the debt trail |", + "", + ].join("\n"), "utf-8"), + writeFile(join(storyDir, "pending_hooks.md"), [ + "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |", + "| --- | --- | --- | --- | --- | --- | --- |", + "| mentor-debt | 1 | relationship | open | 2 | 6 | Still unresolved |", + "", + ].join("\n"), "utf-8"), + writeFile(join(storyDir, "chapter_summaries.md"), [ + "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + "| 2 | Old Ledger | Lin Yue | Lin Yue finds the old ledger | Debt sharpens | mentor-debt advanced | tense | mainline |", + "", + ].join("\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, + }); + + vi.spyOn(WriterAgent.prototype as never, "chat" as never) + .mockResolvedValueOnce({ + content: [ + "=== CHAPTER_TITLE ===", + "River Ledger", + "", + "=== CHAPTER_CONTENT ===", + "Lin Yue follows the debt into the river-port ledger. The old wall still carries the year 1988.", + "", + "=== PRE_WRITE_CHECK ===", + "- ok", + ].join("\n"), + usage: ZERO_USAGE, + }) + .mockResolvedValueOnce({ + content: "=== OBSERVATIONS ===\n- observed", + usage: ZERO_USAGE, + }) + .mockResolvedValueOnce({ + content: [ + "=== POST_SETTLEMENT ===", + "- mentor-debt advanced", + "", + "=== RUNTIME_STATE_DELTA ===", + "```json", + JSON.stringify({ + chapter: 1988, + currentStatePatch: { + currentGoal: "Trace the debt through the river-port ledger.", + currentConflict: "Guild pressure keeps colliding with the debt trail.", + }, + hookOps: { + upsert: [ + { + hookId: "mentor-debt", + startChapter: 1, + type: "relationship", + status: "progressing", + lastAdvancedChapter: 1988, + expectedPayoff: "Reveal the debt.", + notes: "The ledger clue sharpens the line.", + }, + ], + resolve: [], + defer: [], + }, + chapterSummary: { + chapter: 1988, + title: "River Ledger", + characters: "Lin Yue", + events: "Lin Yue follows the debt into the river-port ledger.", + stateChanges: "The debt line sharpens.", + hookActivity: "mentor-debt advanced", + mood: "tense", + chapterType: "investigation", + }, + notes: [], + }, null, 2), + "```", + ].join("\n"), + usage: ZERO_USAGE, + }); + + try { + const output = await agent.writeChapter({ + book: { + id: "writer-book", + title: "Writer Book", + platform: "tomato", + genre: "xuanhuan", + status: "active", + targetChapters: 20, + chapterWordCount: 2200, + language: "en", + createdAt: "2026-03-25T00:00:00.000Z", + updatedAt: "2026-03-25T00:00:00.000Z", + }, + bookDir, + chapterNumber: 3, + lengthSpec: buildLengthSpec(2200, "en"), + }); + + expect(output.runtimeStateDelta?.chapter).toBe(3); + expect(output.runtimeStateDelta?.chapterSummary?.chapter).toBe(3); + expect(output.runtimeStateSnapshot?.manifest.lastAppliedChapter).toBe(3); + expect(output.runtimeStateSnapshot?.hooks.hooks[0]?.lastAdvancedChapter).toBe(3); + expect(output.updatedHooks).toContain("| mentor-debt | 1 | relationship | progressing | 3 |"); + expect(output.updatedChapterSummaries).toContain("| 3 | River Ledger |"); + expect(output.chapterSummary).toContain("| 3 | River Ledger |"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("returns the arbiter-resolved delta instead of raw new-hook candidates", async () => { const root = await mkdtemp(join(tmpdir(), "inkos-writer-arbiter-test-")); const bookDir = join(root, "book"); diff --git a/packages/core/src/agents/writer.ts b/packages/core/src/agents/writer.ts index 5faf8c0c..3f25c979 100644 --- a/packages/core/src/agents/writer.ts +++ b/packages/core/src/agents/writer.ts @@ -311,6 +311,7 @@ export class WriterAgent extends BaseAgent { bookDir, settlement.runtimeStateDelta, resolvedLanguage, + chapterNumber, ); const resolvedRuntimeStateDelta = runtimeStateArtifacts?.resolvedDelta ?? settlement.runtimeStateDelta; const priorHookIds = new Set(parsePendingHooksMarkdown(hooks).map((hook) => hook.hookId)); @@ -935,15 +936,69 @@ ${overrides}\n`; return `| ${row} |`; } + private normalizeRuntimeStateDeltaChapter( + delta: RuntimeStateDelta, + authoritativeChapterNumber: number, + ): RuntimeStateDelta { + const hookOps = delta.hookOps ?? { + upsert: [], + mention: [], + resolve: [], + defer: [], + }; + let changed = delta.chapter !== authoritativeChapterNumber; + const normalizedUpserts = hookOps.upsert.map((hook) => { + const startChapter = Math.min(hook.startChapter, authoritativeChapterNumber); + const lastAdvancedChapter = Math.min(hook.lastAdvancedChapter, authoritativeChapterNumber); + if (startChapter !== hook.startChapter || lastAdvancedChapter !== hook.lastAdvancedChapter) { + changed = true; + } + if (startChapter === hook.startChapter && lastAdvancedChapter === hook.lastAdvancedChapter) { + return hook; + } + return { + ...hook, + startChapter, + lastAdvancedChapter, + }; + }); + + if (delta.chapterSummary?.chapter !== undefined && delta.chapterSummary.chapter !== authoritativeChapterNumber) { + changed = true; + } + if (!changed) { + return delta; + } + + return { + ...delta, + chapter: authoritativeChapterNumber, + hookOps: { + ...hookOps, + upsert: normalizedUpserts, + }, + chapterSummary: delta.chapterSummary + ? { + ...delta.chapterSummary, + chapter: authoritativeChapterNumber, + } + : undefined, + }; + } + private async buildRuntimeStateArtifactsIfPresent( bookDir: string, delta: RuntimeStateDelta | undefined, language: "zh" | "en", + authoritativeChapterNumber?: number, ): Promise { if (!delta) return null; + const safeDelta = authoritativeChapterNumber === undefined + ? delta + : this.normalizeRuntimeStateDeltaChapter(delta, authoritativeChapterNumber); return buildRuntimeStateArtifacts({ bookDir, - delta, + delta: safeDelta, language, }); } @@ -954,15 +1009,20 @@ ${overrides}\n`; language: "zh" | "en", ): Promise { if (!output.runtimeStateDelta) return null; + const safeDelta = this.normalizeRuntimeStateDeltaChapter( + output.runtimeStateDelta, + output.chapterNumber, + ); if ( - output.runtimeStateSnapshot + safeDelta === output.runtimeStateDelta + && output.runtimeStateSnapshot && output.updatedChapterSummaries && output.updatedState && output.updatedHooks ) { return { snapshot: output.runtimeStateSnapshot, - resolvedDelta: output.runtimeStateDelta, + resolvedDelta: safeDelta, currentStateMarkdown: output.updatedState, hooksMarkdown: output.updatedHooks, chapterSummariesMarkdown: output.updatedChapterSummaries, @@ -971,7 +1031,7 @@ ${overrides}\n`; return buildRuntimeStateArtifacts({ bookDir, - delta: output.runtimeStateDelta, + delta: safeDelta, language, }); } diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 48910617..c30c3489 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -124,6 +124,14 @@ export interface BookStatusInfo { readonly chapters: ReadonlyArray; } +interface MergedAuditEvaluation { + readonly auditResult: AuditResult; + readonly aiTellCount: number; + readonly blockingCount: number; + readonly criticalCount: number; + readonly revisionBlockingIssues: ReadonlyArray; +} + export interface ImportChaptersInput { readonly bookId: string; readonly chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>; @@ -2275,19 +2283,16 @@ ${matrix}`, aiTellCount: number; blockingCount: number; criticalCount: number; + revisionBlockingIssues: ReadonlyArray; }, next: { auditResult: AuditResult; aiTellCount: number; blockingCount: number; criticalCount: number; + revisionBlockingIssues: ReadonlyArray; }, - ): { - auditResult: AuditResult; - aiTellCount: number; - blockingCount: number; - criticalCount: number; - } { + ): MergedAuditEvaluation { const auditResult = this.restoreLostAuditIssues(previous.auditResult, next.auditResult); if (auditResult === next.auditResult) { return next; @@ -2296,8 +2301,9 @@ ${matrix}`, return { ...next, auditResult, - blockingCount: auditResult.issues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length, - criticalCount: auditResult.issues.filter((issue) => issue.severity === "critical").length, + revisionBlockingIssues: previous.revisionBlockingIssues, + blockingCount: previous.blockingCount, + criticalCount: previous.criticalCount, }; } @@ -2319,12 +2325,7 @@ ${matrix}`, hooks?: string; }; }; - }): Promise<{ - auditResult: AuditResult; - aiTellCount: number; - blockingCount: number; - criticalCount: number; - }> { + }): Promise { const llmAudit = await params.auditor.auditChapter( params.bookDir, params.chapterContent, @@ -2347,6 +2348,11 @@ ${matrix}`, ...sensitiveResult.issues, ...longSpanFatigue.issues, ]; + const revisionBlockingIssues: ReadonlyArray = [ + ...llmAudit.issues, + ...aiTells.issues, + ...sensitiveResult.issues, + ]; return { auditResult: { @@ -2356,8 +2362,9 @@ ${matrix}`, tokenUsage: llmAudit.tokenUsage, }, aiTellCount: aiTells.issues.length, - blockingCount: issues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length, - criticalCount: issues.filter((issue) => issue.severity === "critical").length, + blockingCount: revisionBlockingIssues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length, + criticalCount: revisionBlockingIssues.filter((issue) => issue.severity === "critical").length, + revisionBlockingIssues, }; } From 66bb890e5c8f25a9d4e902e2472316c3d88d5b68 Mon Sep 17 00:00:00 2001 From: Ma Date: Thu, 2 Apr 2026 17:20:13 +0800 Subject: [PATCH 47/53] feat(hooks): replace lifecycle pressure system with seed excerpt approach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the hook lifecycle/pressure scoring system (5-level timing, pressure formulas, pressure map tables) and replaces it with: 1. Simple stalest-first sorting for mustAdvance (same as iter-001) 2. Hook seed excerpts in composer — for each hookAgenda target, finds the original chapter where it was planted and the latest advancement chapter, extracts the hookActivity text, and injects it as an evidence block the writer can directly build on. The writer now sees concrete narrative material ("original seed (ch2): 萧炎右手碰到戒面时,有一丝温热渗出") instead of abstract metadata ("pressure: high, movement: partial-payoff, reason: stale-promise"). This addresses the hook execution problem: writer knew WHICH hooks to advance but not HOW. Seed excerpts provide the "how" material. --- packages/core/src/__tests__/composer.test.ts | 6 +- .../__tests__/governed-working-set.test.ts | 4 +- .../core/src/__tests__/hook-agenda.test.ts | 24 +- .../src/__tests__/memory-retrieval.test.ts | 40 +- packages/core/src/__tests__/planner.test.ts | 34 +- packages/core/src/agents/composer.ts | 105 +---- packages/core/src/agents/planner.ts | 21 - packages/core/src/agents/writer-prompts.ts | 12 +- .../core/src/utils/governed-working-set.ts | 24 +- packages/core/src/utils/hook-agenda.ts | 433 ++---------------- packages/core/src/utils/memory-retrieval.ts | 65 +-- 11 files changed, 109 insertions(+), 659 deletions(-) diff --git a/packages/core/src/__tests__/composer.test.ts b/packages/core/src/__tests__/composer.test.ts index 99da6df5..11ef5493 100644 --- a/packages/core/src/__tests__/composer.test.ts +++ b/packages/core/src/__tests__/composer.test.ts @@ -563,10 +563,8 @@ describe("ComposerAgent", () => { const hookDebtEntry = result.contextPackage.selectedContext.find((entry) => entry.source === "runtime/hook_debt#mentor-oath"); expect(hookDebtEntry).toBeDefined(); expect(hookDebtEntry?.excerpt).toContain("mentor-oath"); - expect(hookDebtEntry?.excerpt).toContain("建议动作: 局部兑现"); - expect(hookDebtEntry?.excerpt).toContain("当前压力: 高"); - expect(hookDebtEntry?.excerpt).toContain("注意: 暂缓同类开坑"); - expect(hookDebtEntry?.excerpt).not.toContain("抑制同类开坑: 是"); + expect(hookDebtEntry?.excerpt).toContain("主要旧债"); + expect(hookDebtEntry?.excerpt).toContain("读者承诺"); expect(hookDebtEntry?.excerpt).toContain("River Camp"); expect(hookDebtEntry?.excerpt).toContain("Trial Echo"); }); diff --git a/packages/core/src/__tests__/governed-working-set.test.ts b/packages/core/src/__tests__/governed-working-set.test.ts index 8b6ab729..efafbce9 100644 --- a/packages/core/src/__tests__/governed-working-set.test.ts +++ b/packages/core/src/__tests__/governed-working-set.test.ts @@ -83,11 +83,11 @@ describe("governed-working-set", () => { expect(filtered).not.toContain("future-pr-machine"); }); - it("keeps slow-burn hooks in the governed working set when they are still within lifecycle visibility", () => { + it("keeps recently-advanced hooks in the governed working set while filtering far-future hooks", () => { const hooks = [ "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |", "| --- | --- | --- | --- | --- | --- | --- | --- |", - "| river-oath | 8 | relationship | progressing | 13 | Reveal why the river oath was broken | slow-burn | Long debt should stay visible through the middle game |", + "| river-oath | 8 | relationship | progressing | 16 | Reveal why the river oath was broken | slow-burn | Long debt should stay visible through the middle game |", "| future-pr-machine | 45 | system | open | 0 | Future hook should stay hidden | endgame | Future hook should stay hidden |", ].join("\n"); diff --git a/packages/core/src/__tests__/hook-agenda.test.ts b/packages/core/src/__tests__/hook-agenda.test.ts index 95bb2ab0..ae7d9cdf 100644 --- a/packages/core/src/__tests__/hook-agenda.test.ts +++ b/packages/core/src/__tests__/hook-agenda.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from "vitest"; import { buildPlannerHookAgenda, - isHookWithinLifecycleWindow, + isHookWithinChapterWindow, } from "../utils/hook-agenda.js"; -import { describeHookLifecycle } from "../utils/hook-lifecycle.js"; import type { StoredHook } from "../state/memory-db.js"; function createHook(overrides: Partial = {}): StoredHook { @@ -20,7 +19,7 @@ function createHook(overrides: Partial = {}): StoredHook { } describe("hook-agenda", () => { - it("keeps lifecycle-aware windowing and pressure agenda behavior after extraction", () => { + it("builds agenda with stalest-first sorting and chapter-window filtering", () => { const staleSlowBurn = createHook({ hookId: "mentor-oath", startChapter: 4, @@ -45,23 +44,8 @@ describe("hook-agenda", () => { }); expect(agenda.mustAdvance).toContain("mentor-oath"); - expect(agenda.pressureMap).toEqual(expect.arrayContaining([ - expect.objectContaining({ - hookId: "mentor-oath", - }), - ])); - const lifecycle = describeHookLifecycle({ - payoffTiming: staleSlowBurn.payoffTiming, - expectedPayoff: staleSlowBurn.expectedPayoff, - notes: staleSlowBurn.notes, - startChapter: staleSlowBurn.startChapter, - lastAdvancedChapter: staleSlowBurn.lastAdvancedChapter, - status: staleSlowBurn.status, - chapterNumber: 12, - targetChapters: 24, - }); - - expect(isHookWithinLifecycleWindow(staleSlowBurn, 12, lifecycle)).toBe(true); + expect(isHookWithinChapterWindow(staleSlowBurn, 12, 5)).toBe(true); + expect(isHookWithinChapterWindow(readyMystery, 12, 5)).toBe(true); }); }); diff --git a/packages/core/src/__tests__/memory-retrieval.test.ts b/packages/core/src/__tests__/memory-retrieval.test.ts index 98b28cf1..cce67490 100644 --- a/packages/core/src/__tests__/memory-retrieval.test.ts +++ b/packages/core/src/__tests__/memory-retrieval.test.ts @@ -1211,7 +1211,7 @@ describe("parsePendingHooksMarkdown", () => { ]); }); - it("keeps slow-burn hooks out of early resolve slots while still advancing them", () => { + it("sorts must-advance by stalest-first and resolve by earliest-started", () => { const agenda = memoryRetrieval.buildPlannerHookAgenda({ chapterNumber: 18, hooks: [ @@ -1242,29 +1242,13 @@ describe("parsePendingHooksMarkdown", () => { } as never); expect(agenda.mustAdvance).toContain("slow-oath"); + expect(agenda.mustAdvance).toContain("ready-packet"); + expect(agenda.eligibleResolve).toContain("slow-oath"); expect(agenda.eligibleResolve).toContain("ready-packet"); - expect(agenda.eligibleResolve).not.toContain("slow-oath"); - expect(agenda.pressureMap).toEqual(expect.arrayContaining([ - expect.objectContaining({ - hookId: "slow-oath", - movement: "advance", - pressure: "medium", - type: "relationship", - payoffTiming: "slow-burn", - reason: "building-debt", - }), - expect.objectContaining({ - hookId: "ready-packet", - movement: "full-payoff", - pressure: "high", - type: "mystery", - payoffTiming: "near-term", - reason: "ripe-payoff", - }), - ])); + expect(agenda.pressureMap).toEqual([]); }); - it("expands default resolve coverage when several short-payoff hooks mature together", () => { + it("limits eligible resolve to default max of 1 when not overridden", () => { const agenda = memoryRetrieval.buildPlannerHookAgenda({ chapterNumber: 8, targetChapters: 12, @@ -1302,17 +1286,11 @@ describe("parsePendingHooksMarkdown", () => { ] as never, } as never); - expect(agenda.eligibleResolve.length).toBeGreaterThan(1); - expect(agenda.eligibleResolve).toEqual(expect.arrayContaining([ - "packet-drop", - "seal-crack", - ])); - expect( - agenda.pressureMap.filter((entry) => entry.movement === "full-payoff").length, - ).toBeGreaterThan(1); + expect(agenda.eligibleResolve.length).toBe(1); + expect(agenda.pressureMap).toEqual([]); }); - it("spreads default must-advance coverage across pressured hook families", () => { + it("picks stalest hooks for must-advance regardless of type family", () => { const agenda = memoryRetrieval.buildPlannerHookAgenda({ chapterNumber: 15, targetChapters: 30, @@ -1360,7 +1338,7 @@ describe("parsePendingHooksMarkdown", () => { ] as never, } as never); - expect(agenda.mustAdvance).toContain("kiln-key"); + expect(agenda.mustAdvance).toEqual(["mentor-oath-a", "mentor-oath-b"]); expect(agenda.mustAdvance).toEqual(expect.arrayContaining([ expect.stringMatching(/^mentor-oath-/), ])); diff --git a/packages/core/src/__tests__/planner.test.ts b/packages/core/src/__tests__/planner.test.ts index 1f0de79d..21cd15fc 100644 --- a/packages/core/src/__tests__/planner.test.ts +++ b/packages/core/src/__tests__/planner.test.ts @@ -1207,27 +1207,10 @@ describe("PlannerAgent", () => { expect(result.intent.hookAgenda.eligibleResolve).toEqual(["ready-payoff"]); expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-debt"]); expect(result.intent.hookAgenda.avoidNewHookFamilies).toContain("relationship"); - expect(result.intent.hookAgenda.pressureMap).toEqual(expect.arrayContaining([ - expect.objectContaining({ - hookId: "ready-payoff", - movement: "full-payoff", - pressure: "critical", - type: "mystery", - reason: "overdue-payoff", - }), - expect.objectContaining({ - hookId: "stale-debt", - movement: "advance", - pressure: "critical", - type: "relationship", - reason: "stale-promise", - }), - ])); + expect(result.intent.hookAgenda.pressureMap).toEqual([]); const intentMarkdown = await readFile(result.runtimePath, "utf-8"); expect(intentMarkdown).toContain("## Hook Agenda"); - expect(intentMarkdown).toContain("### Pressure Map"); - expect(intentMarkdown).toContain("recent-route"); expect(intentMarkdown).toContain("ready-payoff"); expect(intentMarkdown).toContain("stale-debt"); }); @@ -1344,19 +1327,6 @@ describe("PlannerAgent", () => { "relationship", "mystery", ])); - expect(result.intent.hookAgenda.pressureMap).toEqual(expect.arrayContaining([ - expect.objectContaining({ - hookId: "stale-omega", - movement: "advance", - pressure: "critical", - reason: "stale-promise", - }), - expect.objectContaining({ - hookId: "stale-sable", - movement: "advance", - pressure: "critical", - reason: "stale-promise", - }), - ])); + expect(result.intent.hookAgenda.pressureMap).toEqual([]); }); }); diff --git a/packages/core/src/agents/composer.ts b/packages/core/src/agents/composer.ts index dd2b0162..36197da9 100644 --- a/packages/core/src/agents/composer.ts +++ b/packages/core/src/agents/composer.ts @@ -16,10 +16,6 @@ import { parseChapterSummariesMarkdown, retrieveMemorySelection, } from "../utils/memory-retrieval.js"; -import { - localizeHookPayoffTiming, - resolveHookPayoffTiming, -} from "../utils/hook-lifecycle.js"; export interface ComposeChapterInput { readonly book: BookConfig; @@ -289,36 +285,34 @@ export class ComposerAgent extends BaseAgent { const seedSummary = this.findHookSummary(summaries, hook.hookId, hook.startChapter, "seed"); const latestSummary = this.findHookSummary(summaries, hook.hookId, hook.lastAdvancedChapter, "latest"); - const cadence = localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language); - const guidance = this.findHookPressure(plan, hook.hookId); const role = this.describeHookAgendaRole(plan, hook.hookId, language); - const movement = guidance - ? this.describeHookMovement(guidance.movement, language) - : role; - const pressure = guidance - ? this.describeHookPressure(guidance.pressure, language) - : (language === "en" ? "medium" : "中"); const promise = hook.expectedPayoff || (language === "en" ? "(unspecified)" : "(未写明)"); const seedBeat = seedSummary ? this.renderHookDebtBeat(seedSummary) : (hook.notes || promise); - const latestBeat = latestSummary + const latestBeat = latestSummary && latestSummary !== seedSummary ? this.renderHookDebtBeat(latestSummary) - : (hook.notes || promise); - const reason = guidance - ? this.describeHookReason(guidance.reason, language) - : (language === "en" - ? "Keep the original promise legible and materially change the on-page situation." - : "保持原始承诺清晰可见,并让页上局势发生实质变化。"); + : undefined; + const age = Math.max(0, plan.intent.chapter - Math.max(1, hook.startChapter)); return [{ source: `runtime/hook_debt#${hook.hookId}`, reason: language === "en" - ? "Narrative debt brief for an explicit hook agenda target." - : "显式 hook agenda 目标的叙事债务简报。", + ? "Narrative debt brief with original seed text for this hook agenda target." + : "含原始种子文本的叙事债务简报。", excerpt: language === "en" - ? `${hook.hookId} | narrative debt: ${role} (${cadence}) | current pressure: ${pressure} (${reason}) | preferred move: ${movement} | reader promise: ${promise} | original seed: ${seedBeat} | latest turn: ${latestBeat}${guidance?.blockSiblingHooks ? " | caution: avoid opening sibling hooks" : ""}` - : `${hook.hookId} | 叙事债务: ${role}(${cadence}) | 当前压力: ${pressure}(${reason}) | 建议动作: ${movement} | 读者承诺: ${promise} | 最初种子: ${seedBeat} | 最近推进: ${latestBeat}${guidance?.blockSiblingHooks ? " | 注意: 暂缓同类开坑" : ""}`, + ? [ + `${hook.hookId} (${hook.type}, ${role}, open ${age} chapters)`, + `reader promise: ${promise}`, + `original seed (ch${hook.startChapter}): ${seedBeat}`, + latestBeat ? `latest turn (ch${hook.lastAdvancedChapter}): ${latestBeat}` : undefined, + ].filter(Boolean).join(" | ") + : [ + `${hook.hookId}(${hook.type},${role},已开${age}章)`, + `读者承诺:${promise}`, + `种于第${hook.startChapter}章:${seedBeat}`, + latestBeat ? `推进于第${hook.lastAdvancedChapter}章:${latestBeat}` : undefined, + ].filter(Boolean).join(" | "), }]; }); } @@ -382,71 +376,6 @@ export class ComposerAgent extends BaseAgent { return language === "en" ? "mainline debt" : "主要旧债"; } - private findHookPressure( - plan: PlanChapterOutput, - hookId: string, - ): PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number] | undefined { - return plan.intent.hookAgenda.pressureMap.find((entry) => entry.hookId === hookId); - } - - private describeHookMovement( - movement: PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number]["movement"], - language: "zh" | "en", - ): string { - if (language === "en") { - return movement.replace(/-/g, " "); - } - - return { - "quiet-hold": "轻压保温", - refresh: "重新点亮", - advance: "推进", - "partial-payoff": "局部兑现", - "full-payoff": "完整兑现", - }[movement]; - } - - private describeHookPressure( - pressure: PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number]["pressure"], - language: "zh" | "en", - ): string { - if (language === "en") { - return pressure; - } - - return { - low: "低", - medium: "中", - high: "高", - critical: "极高", - }[pressure]; - } - - private describeHookReason( - reason: PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number]["reason"], - language: "zh" | "en", - ): string { - if (language === "en") { - return { - "fresh-promise": "fresh promise", - "building-debt": "building debt", - "stale-promise": "stale promise", - "ripe-payoff": "ripe payoff", - "overdue-payoff": "overdue payoff", - "long-arc-hold": "long arc hold", - }[reason]; - } - - return { - "fresh-promise": "新近承诺,先保持清晰存在", - "building-debt": "债务正在累积,需要继续加码", - "stale-promise": "旧承诺已停滞,需要重新推动或缩圈", - "ripe-payoff": "已经进入可兑现窗口", - "overdue-payoff": "已经拖过理想兑现窗口", - "long-arc-hold": "长线承诺仍应保温,不宜提前兑付", - }[reason]; - } - private findHookSummary( summaries: ReadonlyArray[number]>, hookId: string, diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index 53f22288..4eb381f8 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -648,28 +648,7 @@ export class PlannerAgent extends BaseAgent { intent.moodDirective ? `- mood: ${intent.moodDirective}` : undefined, intent.titleDirective ? `- title: ${intent.titleDirective}` : undefined, ].filter(Boolean).join("\n") || "- none"; - const pressureMap = intent.hookAgenda.pressureMap.length > 0 - ? [ - "| hook_id | type | payoff_timing | phase | pressure | movement | reason | sibling_guard |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", - ...intent.hookAgenda.pressureMap - .map((item) => [ - item.hookId, - item.type, - item.payoffTiming ?? "unspecified", - item.phase, - item.pressure, - item.movement, - item.reason, - item.blockSiblingHooks ? "yes" : "no", - ].join(" | ")) - .map((row) => `| ${row} |`), - ].join("\n") - : "- none"; const hookAgenda = [ - "### Pressure Map", - pressureMap, - "", "### Must Advance", intent.hookAgenda.mustAdvance.length > 0 ? intent.hookAgenda.mustAdvance.map((item) => `- ${item}`).join("\n") diff --git a/packages/core/src/agents/writer-prompts.ts b/packages/core/src/agents/writer-prompts.ts index eac6327f..53ed3161 100644 --- a/packages/core/src/agents/writer-prompts.ts +++ b/packages/core/src/agents/writer-prompts.ts @@ -105,11 +105,9 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s - When the runtime rule stack records an active L4 -> L3 override, follow the current task over local planning. - Keep hard guardrails compact: canon, continuity facts, and explicit prohibitions still win. - If an English Variance Brief is provided, obey it: avoid the listed phrase/opening/ending patterns and satisfy the scene obligation. -- If Hook Debt Briefs are provided, treat them as the active memory of what the reader is still owed: preserve the original promise and change the on-page situation. -- If the explicit hook agenda includes a pressure map, follow the requested move for each target: full-payoff means concrete payoff, partial-payoff means a meaningful intermediate reveal, advance/refresh mean material movement, and quiet-hold means keep the promise visible without cashing it out early. -- When the explicit hook agenda names an eligible resolve target, land a concrete payoff beat instead of merely mentioning the old thread. +- If Hook Debt Briefs are provided, they contain the ORIGINAL SEED TEXT from the chapter where each hook was planted. Use this text to write a continuation or payoff that feels connected to what the reader already saw — not a vague mention, but a scene that builds on the specific promise. +- When the explicit hook agenda names an eligible resolve target, land a concrete payoff beat that answers the reader's original question from the seed chapter. - When stale debt is present, do not open sibling hooks casually; clear pressure from old promises before minting fresh debt. -- When a hook brief says to suppress sibling hooks, do not fake progress by opening a parallel hook of the same family. - In multi-character scenes, include at least one resistance-bearing exchange instead of reducing the beat to summary or explanation.`; } @@ -120,11 +118,9 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s - 当 runtime rule stack 明确记录了 L4 -> L3 的 active override 时,优先执行当前任务意图,再局部调整规划层。 - 真正不能突破的只有硬护栏:世界设定、连续性事实、显式禁令。 - 如果提供了 English Variance Brief,必须主动避开其中列出的高频短语、重复开头和重复结尾模式,并完成 scene obligation。 -- 如果提供了 Hook Debt 简报,把它当成读者仍在等待兑现的承诺记忆:保留原始 promise,并让本章在页上发生真实变化。 -- 如果显式 hook agenda 里带有 pressure map,逐条执行其中要求的动作:full-payoff 就是具体兑现,partial-payoff 就是给出中间层级的揭示或缩圈,advance / refresh 就是有分量的推进,quiet-hold 就是让承诺继续可见但不要过早消耗。 -- 如果显式 hook agenda 里出现了可回收目标,本章必须写出具体兑现片段,不能只是重新提一句旧线索。 +- 如果提供了 Hook Debt 简报,里面包含每个伏笔种下时的**原始文本片段**。用这些原文来写延续或兑现场景——不是模糊地提一嘴,而是接着读者已经看到的具体承诺来写。 +- 如果显式 hook agenda 里出现了可回收目标,本章必须写出具体兑现片段,回答种子章节中读者的原始疑问。 - 如果存在 stale debt,先消化旧承诺的压力,再决定是否开新坑;同类 sibling hook 不得随手再开。 -- 如果某条 hook 简报明确要求 suppress sibling hooks,就不能用再开一个同类平行坑来假装推进。 - 多角色场景里,至少给出一轮带阻力的直接交锋,不要把人物关系写成纯解释或纯总结。`; } diff --git a/packages/core/src/utils/governed-working-set.ts b/packages/core/src/utils/governed-working-set.ts index 6e7ed6fd..98a6b2f1 100644 --- a/packages/core/src/utils/governed-working-set.ts +++ b/packages/core/src/utils/governed-working-set.ts @@ -5,9 +5,7 @@ import { } from "./memory-retrieval.js"; import { isHookWithinChapterWindow, - isHookWithinLifecycleWindow, } from "./hook-agenda.js"; -import { describeHookLifecycle } from "./hook-lifecycle.js"; export function buildGovernedHookWorkingSet(params: { readonly hooksMarkdown: string; @@ -37,23 +35,11 @@ export function buildGovernedHookWorkingSet(params: { const workingSet = hooks.filter((hook) => selectedIds.has(hook.hookId) || agendaIds.has(hook.hookId) - || ( - params.keepRecent !== undefined - ? isHookWithinChapterWindow(hook, params.chapterNumber, params.keepRecent) - : isHookWithinLifecycleWindow( - hook, - params.chapterNumber, - describeHookLifecycle({ - payoffTiming: hook.payoffTiming, - expectedPayoff: hook.expectedPayoff, - notes: hook.notes, - startChapter: Math.max(0, hook.startChapter), - lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter), - status: hook.status, - chapterNumber: params.chapterNumber, - }), - ) - ), + || isHookWithinChapterWindow( + hook, + params.chapterNumber, + params.keepRecent ?? 5, + ), ); if (workingSet.length === 0 || workingSet.length >= hooks.length) { diff --git a/packages/core/src/utils/hook-agenda.ts b/packages/core/src/utils/hook-agenda.ts index f6f55709..1d074bbb 100644 --- a/packages/core/src/utils/hook-agenda.ts +++ b/packages/core/src/utils/hook-agenda.ts @@ -1,35 +1,15 @@ -import type { HookAgenda, HookPressure } from "../models/input-governance.js"; +import type { HookAgenda } from "../models/input-governance.js"; import type { HookRecord, HookStatus } from "../models/runtime-state.js"; import type { StoredHook } from "../state/memory-db.js"; -import { describeHookLifecycle, resolveHookPayoffTiming } from "./hook-lifecycle.js"; -import { - HOOK_ACTIVITY_THRESHOLDS, - HOOK_AGENDA_LIMITS, - HOOK_AGENDA_LOAD_THRESHOLDS, - HOOK_PRESSURE_WEIGHTS, - HOOK_RELEVANT_SELECTION_DEFAULTS, - resolveHookVisibilityWindow, - type HookAgendaLoad, -} from "./hook-policy.js"; - -type HookLifecycle = ReturnType; -type NormalizedStoredHook = HookRecord; - -interface HookAgendaEntry { - readonly hook: NormalizedStoredHook; - readonly lifecycle: HookLifecycle; -} - -interface HookSelectionEntry { - readonly hook: { - readonly hookId: string; - readonly type: string; - }; - readonly lifecycle: HookLifecycle; -} +import { resolveHookPayoffTiming } from "./hook-lifecycle.js"; export const DEFAULT_HOOK_LOOKAHEAD_CHAPTERS = 3; +/** + * Build the hook agenda using simple stalest-first sorting. + * No lifecycle pressure formulas — just pick the hooks that have been + * dormant the longest and the ones that are ripe for resolution. + */ export function buildPlannerHookAgenda(params: { readonly hooks: ReadonlyArray; readonly chapterNumber: number; @@ -43,91 +23,50 @@ export function buildPlannerHookAgenda(params: { .map(normalizeStoredHook) .filter((hook) => !isFuturePlannedHook(hook, params.chapterNumber, 0)) .filter((hook) => hook.status !== "resolved" && hook.status !== "deferred"); - const lifecycleEntries = agendaHooks.map((hook) => ({ - hook, - lifecycle: describeHookLifecycle({ - payoffTiming: hook.payoffTiming, - expectedPayoff: hook.expectedPayoff, - notes: hook.notes, - startChapter: hook.startChapter, - lastAdvancedChapter: hook.lastAdvancedChapter, - status: hook.status, - chapterNumber: params.chapterNumber, - targetChapters: params.targetChapters, - }), - })); - const agendaLoad = resolveHookAgendaLoad(lifecycleEntries); - const staleDebtCandidates = lifecycleEntries - .filter((entry) => entry.lifecycle.stale) - .sort((left, right) => ( - Number(right.lifecycle.overdue) - Number(left.lifecycle.overdue) - || right.lifecycle.advancePressure - left.lifecycle.advancePressure - || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter - || left.hook.startChapter - right.hook.startChapter - || left.hook.hookId.localeCompare(right.hook.hookId) - )); - const staleDebtHooks = selectAgendaHooksWithTypeSpread({ - entries: staleDebtCandidates, - limit: resolveAgendaLimit({ - explicitLimit: params.maxStaleDebt, - candidateCount: staleDebtCandidates.length, - fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].staleDebt, - }), - forceInclude: (entry) => entry.lifecycle.overdue, - }).map((entry) => entry.hook); - const mustAdvancePool = lifecycleEntries.filter((entry) => isMustAdvanceCandidate(entry.lifecycle)); - const mustAdvanceCandidates = (mustAdvancePool.length > 0 ? mustAdvancePool : lifecycleEntries) + + // mustAdvance: stalest first (lowest lastAdvancedChapter) + const mustAdvanceHooks = agendaHooks .slice() .sort((left, right) => ( - Number(right.lifecycle.stale) - Number(left.lifecycle.stale) - || right.lifecycle.advancePressure - left.lifecycle.advancePressure - || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter - || left.hook.startChapter - right.hook.startChapter - || left.hook.hookId.localeCompare(right.hook.hookId) - )); - const mustAdvanceHooks = selectAgendaHooksWithTypeSpread({ - entries: mustAdvanceCandidates, - limit: resolveAgendaLimit({ - explicitLimit: params.maxMustAdvance, - candidateCount: mustAdvanceCandidates.length, - fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].mustAdvance, - }), - forceInclude: (entry) => entry.lifecycle.overdue, - }).map((entry) => entry.hook); - const eligibleResolveCandidates = lifecycleEntries - .filter((entry) => entry.lifecycle.readyToResolve) + left.lastAdvancedChapter - right.lastAdvancedChapter + || left.startChapter - right.startChapter + || left.hookId.localeCompare(right.hookId) + )) + .slice(0, params.maxMustAdvance ?? 2); + + // staleDebt: hooks not advanced for 10+ chapters + const staleThreshold = params.chapterNumber - 10; + const staleDebtHooks = agendaHooks + .filter((hook) => { + const lastTouch = Math.max(hook.startChapter, hook.lastAdvancedChapter); + return lastTouch > 0 && lastTouch <= staleThreshold; + }) + .sort((left, right) => ( + left.lastAdvancedChapter - right.lastAdvancedChapter + || left.startChapter - right.startChapter + || left.hookId.localeCompare(right.hookId) + )) + .slice(0, params.maxStaleDebt ?? 2); + + // eligibleResolve: started 3+ chapters ago AND recently advanced + const eligibleResolveHooks = agendaHooks + .filter((hook) => hook.startChapter <= params.chapterNumber - 3) + .filter((hook) => hook.lastAdvancedChapter >= params.chapterNumber - 2) .sort((left, right) => ( - right.lifecycle.resolvePressure - left.lifecycle.resolvePressure - || Number(right.lifecycle.stale) - Number(left.lifecycle.stale) - || left.hook.startChapter - right.hook.startChapter - || left.hook.hookId.localeCompare(right.hook.hookId) - )); - const eligibleResolveHooks = selectAgendaHooksWithTypeSpread({ - entries: eligibleResolveCandidates, - limit: resolveAgendaLimit({ - explicitLimit: params.maxEligibleResolve, - candidateCount: eligibleResolveCandidates.length, - fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].eligibleResolve, - }), - forceInclude: (entry) => ( - entry.lifecycle.overdue - || entry.lifecycle.resolvePressure >= HOOK_PRESSURE_WEIGHTS.criticalResolvePressure - ), - }).map((entry) => entry.hook); + left.startChapter - right.startChapter + || right.lastAdvancedChapter - left.lastAdvancedChapter + || left.hookId.localeCompare(right.hookId) + )) + .slice(0, params.maxEligibleResolve ?? 1); + const avoidNewHookFamilies = [...new Set([ ...staleDebtHooks.map((hook) => hook.type.trim()).filter(Boolean), ...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean), ...eligibleResolveHooks.map((hook) => hook.type.trim()).filter(Boolean), - ])].slice(0, HOOK_AGENDA_LIMITS[agendaLoad].avoidFamilies); - const pressureMap = buildHookPressureMap({ - lifecycleEntries, - mustAdvanceHooks, - eligibleResolveHooks, - staleDebtHooks, - }); + ])].slice(0, 3); return { - pressureMap, + pressureMap: [], mustAdvance: mustAdvanceHooks.map((hook) => hook.hookId), eligibleResolve: eligibleResolveHooks.map((hook) => hook.hookId), staleDebt: staleDebtHooks.map((hook) => hook.hookId), @@ -135,294 +74,6 @@ export function buildPlannerHookAgenda(params: { }; } -function resolveHookAgendaLoad(entries: ReadonlyArray): HookAgendaLoad { - const pressuredEntries = entries.filter((entry) => - entry.lifecycle.readyToResolve - || entry.lifecycle.stale - || entry.lifecycle.overdue, - ); - const staleCount = pressuredEntries.filter((entry) => entry.lifecycle.stale).length; - const readyCount = pressuredEntries.filter((entry) => entry.lifecycle.readyToResolve).length; - const criticalCount = pressuredEntries.filter((entry) => - entry.lifecycle.overdue - || entry.lifecycle.resolvePressure >= HOOK_PRESSURE_WEIGHTS.criticalResolvePressure, - ).length; - const pressuredFamilies = new Set( - pressuredEntries.map((entry) => normalizeHookType(entry.hook.type)), - ).size; - - if ( - readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyReadyCount - || staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyStaleCount - || criticalCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyCriticalCount - || pressuredEntries.length >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyPressuredCount - ) { - return "heavy"; - } - if ( - readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumReadyCount - || staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumStaleCount - || criticalCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumCriticalCount - || pressuredFamilies >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumPressuredFamilies - ) { - return "medium"; - } - return "light"; -} - -function resolveAgendaLimit(params: { - readonly explicitLimit?: number; - readonly candidateCount: number; - readonly fallbackLimit: number; -}): number { - if (params.candidateCount <= 0) { - return 0; - } - - const limit = params.explicitLimit ?? params.fallbackLimit; - return Math.max(1, Math.min(limit, params.candidateCount)); -} - -export function selectAgendaHooksWithTypeSpread(params: { - readonly entries: ReadonlyArray; - readonly limit: number; - readonly forceInclude?: (entry: T) => boolean; -}): T[] { - if (params.limit <= 0 || params.entries.length === 0) { - return []; - } - - const selected: T[] = []; - const selectedIds = new Set(); - const selectedTypes = new Set(); - const forcedEntries = params.entries.filter((entry) => params.forceInclude?.(entry) ?? false); - const addEntry = (entry: T): void => { - if (selectedIds.has(entry.hook.hookId) || selected.length >= params.limit) { - return; - } - selected.push(entry); - selectedIds.add(entry.hook.hookId); - selectedTypes.add(normalizeHookType(entry.hook.type)); - }; - - for (const entry of forcedEntries) { - if (selected.length >= params.limit) { - break; - } - const normalizedType = normalizeHookType(entry.hook.type); - if (!selectedTypes.has(normalizedType)) { - addEntry(entry); - } - } - - for (const entry of forcedEntries) { - addEntry(entry); - } - - for (const entry of params.entries) { - if (selected.length >= params.limit) { - break; - } - if (selectedIds.has(entry.hook.hookId)) { - continue; - } - const normalizedType = normalizeHookType(entry.hook.type); - if (!selectedTypes.has(normalizedType)) { - addEntry(entry); - } - } - - for (const entry of params.entries) { - if (selected.length >= params.limit) { - break; - } - addEntry(entry); - } - - return selected; -} - -function normalizeHookType(type: string): string { - return type.trim().toLowerCase() || "hook"; -} - -export function resolveRelevantHookPrimaryLimit(entries: ReadonlyArray): number { - const pressuredCount = entries.filter((entry) => - entry.lifecycle.readyToResolve - || entry.lifecycle.stale - || entry.lifecycle.overdue, - ).length; - return pressuredCount >= HOOK_RELEVANT_SELECTION_DEFAULTS.primary.pressuredThreshold - ? HOOK_RELEVANT_SELECTION_DEFAULTS.primary.pressuredExpansionLimit - : HOOK_RELEVANT_SELECTION_DEFAULTS.primary.baseLimit; -} - -export function resolveRelevantHookStaleLimit( - entries: ReadonlyArray, - selectedIds: ReadonlySet, -): number { - const staleCandidates = entries.filter((entry) => - !selectedIds.has(entry.hook.hookId) - && (entry.lifecycle.stale || entry.lifecycle.overdue), - ); - if (staleCandidates.length === 0) { - return 0; - } - - const staleFamilies = new Set( - staleCandidates.map((entry) => normalizeHookType(entry.hook.type)), - ).size; - const overdueCount = staleCandidates.filter((entry) => entry.lifecycle.overdue).length; - if ( - overdueCount >= HOOK_RELEVANT_SELECTION_DEFAULTS.stale.overdueThreshold - || staleFamilies >= HOOK_RELEVANT_SELECTION_DEFAULTS.stale.familySpreadThreshold - ) { - return Math.min(HOOK_RELEVANT_SELECTION_DEFAULTS.stale.expandedLimit, staleCandidates.length); - } - - return HOOK_RELEVANT_SELECTION_DEFAULTS.stale.defaultLimit; -} - -export function isHookWithinLifecycleWindow( - hook: StoredHook, - chapterNumber: number, - lifecycle: HookLifecycle, -): boolean { - return isHookWithinChapterWindow( - hook, - chapterNumber, - resolveHookVisibilityWindow(lifecycle.timing), - ); -} - -function isMustAdvanceCandidate(lifecycle: HookLifecycle): boolean { - return lifecycle.stale - || lifecycle.readyToResolve - || lifecycle.overdue - || lifecycle.advancePressure >= HOOK_PRESSURE_WEIGHTS.mustAdvancePressureFloor; -} - -function buildHookPressureMap(params: { - readonly lifecycleEntries: ReadonlyArray; - readonly mustAdvanceHooks: ReadonlyArray; - readonly eligibleResolveHooks: ReadonlyArray; - readonly staleDebtHooks: ReadonlyArray; -}): HookPressure[] { - const eligibleResolveIds = new Set(params.eligibleResolveHooks.map((hook) => hook.hookId)); - const staleDebtIds = new Set(params.staleDebtHooks.map((hook) => hook.hookId)); - const lifecycleById = new Map( - params.lifecycleEntries.map((entry) => [entry.hook.hookId, entry.lifecycle] as const), - ); - - const orderedIds = [...new Set([ - ...params.eligibleResolveHooks.map((hook) => hook.hookId), - ...params.staleDebtHooks.map((hook) => hook.hookId), - ...params.mustAdvanceHooks.map((hook) => hook.hookId), - ])]; - - return orderedIds.flatMap((hookId) => { - const hook = params.lifecycleEntries.find((entry) => entry.hook.hookId === hookId)?.hook; - const lifecycle = lifecycleById.get(hookId); - if (!hook || !lifecycle) { - return []; - } - - const movement = resolveHookMovement({ - lifecycle, - eligibleResolve: eligibleResolveIds.has(hookId), - staleDebt: staleDebtIds.has(hookId), - }); - const pressure = resolveHookPressureLevel({ lifecycle, movement }); - const reason = resolveHookPressureReason({ lifecycle, movement }); - - return [{ - hookId, - type: hook.type.trim() || "hook", - movement, - pressure, - payoffTiming: lifecycle.timing, - phase: lifecycle.phase, - reason, - blockSiblingHooks: staleDebtIds.has(hookId) || movement === "partial-payoff" || movement === "full-payoff", - }]; - }); -} - -function resolveHookMovement(params: { - readonly lifecycle: HookLifecycle; - readonly eligibleResolve: boolean; - readonly staleDebt: boolean; -}): HookPressure["movement"] { - if (params.eligibleResolve) { - return "full-payoff"; - } - - const timing = params.lifecycle.timing; - const longArc = timing === "slow-burn" || timing === "endgame"; - - if (params.staleDebt && longArc) { - return "partial-payoff"; - } - - if (params.staleDebt) { - return "advance"; - } - - if ( - longArc - && params.lifecycle.age <= HOOK_ACTIVITY_THRESHOLDS.longArcQuietHoldMaxAge - && params.lifecycle.dormancy <= HOOK_ACTIVITY_THRESHOLDS.longArcQuietHoldMaxDormancy - ) { - return "quiet-hold"; - } - - if (params.lifecycle.dormancy >= HOOK_ACTIVITY_THRESHOLDS.refreshDormancy) { - return "refresh"; - } - - return "advance"; -} - -function resolveHookPressureLevel(params: { - readonly lifecycle: HookLifecycle; - readonly movement: HookPressure["movement"]; -}): HookPressure["pressure"] { - if (params.lifecycle.overdue || params.movement === "full-payoff") { - return params.lifecycle.overdue ? "critical" : "high"; - } - if (params.lifecycle.stale || params.movement === "partial-payoff") { - return "high"; - } - if (params.movement === "advance" || params.movement === "refresh") { - return "medium"; - } - return "low"; -} - -function resolveHookPressureReason(params: { - readonly lifecycle: HookLifecycle; - readonly movement: HookPressure["movement"]; -}): HookPressure["reason"] { - if (params.lifecycle.overdue && params.movement === "full-payoff") { - return "overdue-payoff"; - } - if (params.movement === "full-payoff") { - return "ripe-payoff"; - } - if (params.movement === "partial-payoff" || params.lifecycle.stale) { - return "stale-promise"; - } - if (params.movement === "quiet-hold") { - return params.lifecycle.timing === "slow-burn" || params.lifecycle.timing === "endgame" - ? "long-arc-hold" - : "fresh-promise"; - } - if (params.lifecycle.age <= HOOK_ACTIVITY_THRESHOLDS.freshPromiseAge) { - return "fresh-promise"; - } - return "building-debt"; -} - function normalizeStoredHook(hook: StoredHook): HookRecord { return { hookId: hook.hookId, diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index 5109430e..88d5e113 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -7,16 +7,11 @@ import { } from "../models/runtime-state.js"; import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js"; import { bootstrapStructuredStateFromMarkdown } from "../state/state-bootstrap.js"; -import { describeHookLifecycle } from "./hook-lifecycle.js"; import { buildPlannerHookAgenda, filterActiveHooks, isFuturePlannedHook, isHookWithinChapterWindow, - isHookWithinLifecycleWindow, - resolveRelevantHookPrimaryLimit, - resolveRelevantHookStaleLimit, - selectAgendaHooksWithTypeSpread, } from "./hook-agenda.js"; import { parseChapterSummariesMarkdown, @@ -29,7 +24,6 @@ export { buildPlannerHookAgenda, isFuturePlannedHook, isHookWithinChapterWindow, - isHookWithinLifecycleWindow, } from "./hook-agenda.js"; export { parseChapterSummariesMarkdown, @@ -342,49 +336,34 @@ function selectRelevantHooks( const ranked = hooks .map((hook) => ({ hook, - lifecycle: describeHookLifecycle({ - payoffTiming: hook.payoffTiming, - expectedPayoff: hook.expectedPayoff, - notes: hook.notes, - startChapter: Math.max(0, hook.startChapter), - lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter), - status: hook.status, - chapterNumber, - }), score: scoreHook(hook, queryTerms, chapterNumber), matched: matchesAny( [hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" "), queryTerms, ), })) - .filter((entry) => entry.matched || isUnresolvedHook(entry.hook.status)); - - const primary = selectAgendaHooksWithTypeSpread({ - entries: ranked - .filter((entry) => ( - entry.matched - || isHookWithinLifecycleWindow(entry.hook, chapterNumber, entry.lifecycle) - )) - .sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter), - limit: resolveRelevantHookPrimaryLimit(ranked), - forceInclude: (entry) => entry.matched && entry.lifecycle.overdue, - }); - - const selectedIds = new Set(primary.map((entry) => entry.hook.hookId)); - const stale = selectAgendaHooksWithTypeSpread({ - entries: ranked - .filter((entry) => ( - !selectedIds.has(entry.hook.hookId) - && !isFuturePlannedHook(entry.hook, chapterNumber) - && (entry.lifecycle.stale || entry.lifecycle.overdue) - && isUnresolvedHook(entry.hook.status) - )) - .sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score), - limit: resolveRelevantHookStaleLimit(ranked, selectedIds), - forceInclude: (entry) => entry.lifecycle.overdue, - }); - - return [...primary, ...stale].map((entry) => entry.hook); + .filter((entry: { hook: StoredHook; score: number; matched: boolean }) => + entry.matched || isUnresolvedHook(entry.hook.status), + ); + + const primary = ranked + .filter((entry: { hook: StoredHook; score: number; matched: boolean }) => + entry.matched || isHookWithinChapterWindow(entry.hook, chapterNumber, 5), + ) + .sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter) + .slice(0, 6); + + const selectedIds = new Set(primary.map((entry: { hook: StoredHook; score: number; matched: boolean }) => entry.hook.hookId)); + const stale = ranked + .filter((entry: { hook: StoredHook; score: number; matched: boolean }) => + !selectedIds.has(entry.hook.hookId) + && !isFuturePlannedHook(entry.hook, chapterNumber) + && isUnresolvedHook(entry.hook.status), + ) + .sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score) + .slice(0, 2); + + return [...primary, ...stale].map((entry: { hook: StoredHook; score: number; matched: boolean }) => entry.hook); } function selectRelevantFacts( From 2e6405592749ca6183576aab7fa8080263ccb642 Mon Sep 17 00:00:00 2001 From: Ma Date: Thu, 2 Apr 2026 21:33:57 +0800 Subject: [PATCH 48/53] fix(state): anchor chapter progress to contiguous durable chapters --- .../core/src/__tests__/state-manager.test.ts | 152 +++++++++++++++++- packages/core/src/state/manager.ts | 13 +- packages/core/src/state/state-bootstrap.ts | 131 +++++++-------- packages/core/src/utils/memory-retrieval.ts | 38 +---- 4 files changed, 225 insertions(+), 109 deletions(-) diff --git a/packages/core/src/__tests__/state-manager.test.ts b/packages/core/src/__tests__/state-manager.test.ts index 4cebef14..a4272b6c 100644 --- a/packages/core/src/__tests__/state-manager.test.ts +++ b/packages/core/src/__tests__/state-manager.test.ts @@ -114,7 +114,7 @@ describe("StateManager", () => { expect(next).toBe(1); }); - it("returns max+1 when chapters exist", async () => { + it("returns the first missing chapter when the chapter index has gaps", async () => { const chapters: ReadonlyArray = [ { number: 1, @@ -149,7 +149,7 @@ describe("StateManager", () => { ]; await manager.saveChapterIndex("book-x", chapters); const next = await manager.getNextChapterNumber("book-x"); - expect(next).toBe(6); + expect(next).toBe(2); }); it("returns 2 when only chapter 1 exists", async () => { @@ -224,6 +224,99 @@ describe("StateManager", () => { expect(next).toBe(4); }); + + it("ignores non-contiguous poisoned chapter numbers when calculating the next chapter", async () => { + const bookId = "poisoned-next-chapter-book"; + const bookDir = manager.bookDir(bookId); + const chaptersDir = join(bookDir, "chapters"); + const storyDir = join(bookDir, "story"); + const stateDir = join(storyDir, "state"); + await mkdir(chaptersDir, { recursive: true }); + await mkdir(stateDir, { recursive: true }); + + const indexedChapters: ReadonlyArray = [ + ...Array.from({ length: 12 }, (_, index) => ({ + number: index + 1, + title: `Ch${index + 1}`, + status: "ready-for-review" as const, + wordCount: 3000, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + auditIssues: [], + lengthWarnings: [], + })), + { + number: 142, + title: "Poisoned Ch142", + status: "audit-failed", + wordCount: 3200, + createdAt: "2026-01-13T00:00:00Z", + updatedAt: "2026-01-13T00:00:00Z", + auditIssues: [], + lengthWarnings: [], + }, + ]; + + await manager.saveChapterIndex(bookId, indexedChapters); + await Promise.all([ + ...Array.from({ length: 12 }, (_, index) => writeFile( + join(chaptersDir, `${String(index + 1).padStart(4, "0")}_Ch${index + 1}.md`), + `# Chapter ${index + 1}\n\nStable body.`, + "utf-8", + )), + writeFile( + join(chaptersDir, "0142_Poisoned.md"), + "# Chapter 142\n\nPoisoned body.", + "utf-8", + ), + writeFile( + join(storyDir, "current_state.md"), + [ + "# Current State", + "", + "| Field | Value |", + "| --- | --- |", + "| Current Chapter | 12 |", + "| Current Goal | Enter the next true chapter cleanly |", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "pending_hooks.md"), + [ + "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |", + "| --- | --- | --- | --- | --- | --- | --- |", + "| H001 | 1 | mystery | progressing | 《三体》游戏内第141号文明继续展开 | Reveal the true enemy | Narrative text must not drive chapter progress |", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "chapter_summaries.md"), + [ + "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ...Array.from({ length: 12 }, (_, index) => + `| ${index + 1} | Ch${index + 1} | Lin Yue | Event ${index + 1} | Shift ${index + 1} | Hook ${index + 1} | tense | mainline |`), + "| 142 | Poisoned Ch142 | Lin Yue | Poisoned event | Poisoned shift | Poisoned hook | tense | mainline |", + "", + ].join("\n"), + "utf-8", + ), + writeFile(join(stateDir, "manifest.json"), JSON.stringify({ + schemaVersion: 2, + language: "en", + lastAppliedChapter: 141, + projectionVersion: 1, + migrationWarnings: [], + }, null, 2), "utf-8"), + ]); + + const next = await manager.getNextChapterNumber(bookId); + + expect(next).toBe(13); + }); }); // ------------------------------------------------------------------------- @@ -792,6 +885,61 @@ describe("StateManager", () => { expect(manifest.lastAppliedChapter).toBe(1); }); + it("does not treat narrative digits inside hook markdown as runtime chapter progress during bootstrap", async () => { + const bookId = "runtime-state-narrative-digit-book"; + const storyDir = join(manager.bookDir(bookId), "story"); + await mkdir(storyDir, { recursive: true }); + await Promise.all([ + writeFile( + join(storyDir, "current_state.md"), + [ + "# Current State", + "", + "| Field | Value |", + "| --- | --- |", + "| Current Chapter | 12 |", + "| Current Goal | Continue after the imported twelfth chapter |", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "pending_hooks.md"), + [ + "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |", + "| --- | --- | --- | --- | --- | --- | --- |", + "| H001 | 1 | mystery | progressing | 《三体》游戏内第141号文明展开到墨子时代 | Reveal the threat | Narrative prose, not chapter metadata |", + "", + ].join("\n"), + "utf-8", + ), + writeFile( + join(storyDir, "chapter_summaries.md"), + [ + "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ...Array.from({ length: 12 }, (_, index) => + `| ${index + 1} | Ch${index + 1} | Lin Yue | Event ${index + 1} | Shift ${index + 1} | Hook ${index + 1} | tense | mainline |`), + "", + ].join("\n"), + "utf-8", + ), + ]); + + await manager.ensureRuntimeState(bookId, 12); + + const manifest = JSON.parse( + await readFile(join(manager.stateDir(bookId), "manifest.json"), "utf-8"), + ) as { lastAppliedChapter: number }; + const hooks = JSON.parse( + await readFile(join(manager.stateDir(bookId), "hooks.json"), "utf-8"), + ) as { hooks: Array<{ hookId: string; lastAdvancedChapter: number }> }; + + expect(manifest.lastAppliedChapter).toBe(12); + expect(hooks.hooks[0]?.hookId).toBe("H001"); + expect(hooks.hooks[0]?.lastAdvancedChapter).toBe(0); + }); + it("repairs poisoned manifest chapter when it runs ahead of persisted runtime state", async () => { const bookId = "runtime-state-poisoned-book"; const storyDir = join(manager.bookDir(bookId), "story"); diff --git a/packages/core/src/state/manager.ts b/packages/core/src/state/manager.ts index 702f3ef5..bf1a09f4 100644 --- a/packages/core/src/state/manager.ts +++ b/packages/core/src/state/manager.ts @@ -205,19 +205,14 @@ export class StateManager { } async getNextChapterNumber(bookId: string): Promise { - const index = await this.loadChapterIndex(bookId); - const indexedChapter = index.length > 0 - ? Math.max(...index.map((ch) => ch.number)) - : 0; - const runtimeState = await bootstrapStructuredStateFromMarkdown({ + const durableChapter = await resolveDurableStoryProgress({ bookDir: this.bookDir(bookId), - fallbackChapter: indexedChapter, }); - const durableChapter = await resolveDurableStoryProgress({ + await bootstrapStructuredStateFromMarkdown({ bookDir: this.bookDir(bookId), - fallbackChapter: indexedChapter, + fallbackChapter: durableChapter, }); - return Math.max(indexedChapter, durableChapter, runtimeState.manifest.lastAppliedChapter) + 1; + return durableChapter + 1; } async getPersistedChapterCount(bookId: string): Promise { diff --git a/packages/core/src/state/state-bootstrap.ts b/packages/core/src/state/state-bootstrap.ts index 1b32b21f..74013ac9 100644 --- a/packages/core/src/state/state-bootstrap.ts +++ b/packages/core/src/state/state-bootstrap.ts @@ -71,12 +71,7 @@ export async function bootstrapStructuredStateFromMarkdown(params: { warnings, bootstrapState: markdownState.currentState, }); - const derivedProgress = Math.max( - markdownState.durableStoryProgress, - currentState.chapter, - maxSummaryChapter(summariesState), - maxHookChapter(hooksState.hooks), - ); + const derivedProgress = markdownState.durableStoryProgress; if ((existingManifest?.lastAppliedChapter ?? 0) > derivedProgress) { appendWarning( warnings, @@ -136,12 +131,7 @@ export async function rewriteStructuredStateFromMarkdown(params: { const manifest = StateManifestSchema.parse({ schemaVersion: 2, language, - lastAppliedChapter: Math.max( - markdownState.durableStoryProgress, - currentState.chapter, - maxSummaryChapter(summariesState), - maxHookChapter(hooksState.hooks), - ), + lastAppliedChapter: markdownState.durableStoryProgress, projectionVersion: existingManifest?.projectionVersion ?? 1, migrationWarnings: uniqueStrings([ ...(existingManifest?.migrationWarnings ?? []), @@ -188,10 +178,10 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] { .filter((row) => normalizeHookId(row[0]).length > 0) .map((row) => ({ hookId: normalizeHookId(row[0]), - startChapter: parseInteger(row[1]), + startChapter: parseStrictInteger(row[1]), type: row[2] ?? "", status: row[3] ?? "open", - lastAdvancedChapter: parseInteger(row[4]), + lastAdvancedChapter: parseStrictInteger(row[4]), expectedPayoff: row[5] ?? "", notes: row[6] ?? "", })); @@ -335,10 +325,10 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) { const hookId = normalizeHookId(row[0]); return { hookId, - startChapter: parseIntegerWithWarning(row[1], warnings, `${hookId}:startChapter`), + startChapter: parseStrictIntegerWithWarning(row[1], warnings, `${hookId}:startChapter`), type: row[2] ?? "unspecified", status: normalizeHookStatus(row[3], warnings, hookId), - lastAdvancedChapter: parseIntegerWithWarning(row[4], warnings, `${hookId}:lastAdvancedChapter`), + lastAdvancedChapter: parseStrictIntegerWithWarning(row[4], warnings, `${hookId}:lastAdvancedChapter`), expectedPayoff: row[5] ?? "", notes: row[6] ?? "", }; @@ -439,14 +429,9 @@ export async function resolveDurableStoryProgress(params: { readonly bookDir: string; readonly fallbackChapter?: number; }): Promise { - const storyDir = join(params.bookDir, "story"); - const state = await loadMarkdownBootstrapState({ - bookDir: params.bookDir, - storyDir, - fallbackChapter: params.fallbackChapter ?? 0, - warnings: [], - }); - return state.durableStoryProgress; + const explicitFallback = normalizeExplicitChapter(params.fallbackChapter); + const durableArtifactProgress = await resolveContiguousArtifactChapterProgress(params.bookDir); + return Math.max(durableArtifactProgress, explicitFallback); } async function loadJsonIfValid( @@ -478,16 +463,12 @@ async function loadMarkdownBootstrapState(params: { storyDir: params.storyDir, warnings: params.warnings, }); - const durableArtifactProgress = await maxDurableArtifactChapter(params.bookDir); - const inferredFallbackChapter = Math.max( - params.fallbackChapter, - durableArtifactProgress, - maxSummaryChapter(summariesState), - maxHookChapter(hooksState.hooks), - ); + const explicitFallback = normalizeExplicitChapter(params.fallbackChapter); + const durableArtifactProgress = await resolveContiguousArtifactChapterProgress(params.bookDir); + const authoritativeProgress = Math.max(explicitFallback, durableArtifactProgress); const currentState = await loadMarkdownCurrentState({ storyDir: params.storyDir, - fallbackChapter: inferredFallbackChapter, + fallbackChapter: authoritativeProgress, warnings: params.warnings, }); @@ -495,10 +476,9 @@ async function loadMarkdownBootstrapState(params: { summariesState, hooksState, currentState, - durableStoryProgress: Math.max( - inferredFallbackChapter, - currentState.chapter, - ), + durableStoryProgress: authoritativeProgress > 0 + ? authoritativeProgress + : currentState.chapter, }; } @@ -527,28 +507,31 @@ async function loadMarkdownCurrentState(params: { return parseCurrentStateStateMarkdown(markdown, params.fallbackChapter, params.warnings); } -async function maxDurableArtifactChapter(bookDir: string): Promise { +async function resolveContiguousArtifactChapterProgress(bookDir: string): Promise { + const chapterNumbers = await loadDurableArtifactChapterNumbers(bookDir); + return resolveContiguousChapterPrefix(chapterNumbers); +} + +async function loadDurableArtifactChapterNumbers(bookDir: string): Promise { const chaptersDir = join(bookDir, "chapters"); const indexPath = join(chaptersDir, "index.json"); - const [indexChapter, fileChapter] = await Promise.all([ + const [indexChapters, fileChapters] = await Promise.all([ readFile(indexPath, "utf-8") .then((raw) => { const parsed = JSON.parse(raw) as Array<{ number?: unknown }>; - return parsed.reduce((max, entry) => ( - typeof entry?.number === "number" - ? Math.max(max, entry.number) - : max - ), 0); + return parsed + .map((entry) => entry?.number) + .filter((entry): entry is number => typeof entry === "number" && Number.isInteger(entry) && entry > 0); }) - .catch(() => 0), + .catch(() => [] as number[]), readdir(chaptersDir) - .then((entries) => entries.reduce((max, entry) => { + .then((entries) => entries.flatMap((entry) => { const match = entry.match(/^(\d+)_/); - return match ? Math.max(max, parseInt(match[1]!, 10)) : max; - }, 0)) - .catch(() => 0), + return match ? [parseInt(match[1]!, 10)] : []; + })) + .catch(() => [] as number[]), ]); - return Math.max(indexChapter, fileChapter); + return [...indexChapters, ...fileChapters]; } async function pathExists(path: string): Promise { @@ -568,17 +551,15 @@ function deduplicateSummaryRows(rows: ReadonlyArr return [...byChapter.values()].sort((a, b) => a.chapter - b.chapter); } -function maxSummaryChapter(state: ChapterSummariesState): number { - return state.rows.reduce((max, row) => Math.max(max, row.chapter), 0); -} - -function maxHookChapter(hooks: ReadonlyArray): number { - // Only count lastAdvancedChapter — startChapter is a future plan marker, - // not an indication that state has been applied up to that chapter. - return hooks.reduce( - (max, hook) => Math.max(max, hook.lastAdvancedChapter), - 0, +export function resolveContiguousChapterPrefix(chapterNumbers: ReadonlyArray): number { + const chapters = new Set( + chapterNumbers.filter((chapter): chapter is number => Number.isInteger(chapter) && chapter > 0), ); + let contiguousChapter = 0; + while (chapters.has(contiguousChapter + 1)) { + contiguousChapter += 1; + } + return contiguousChapter; } export function normalizeHookId(value: string | undefined): string { @@ -610,14 +591,14 @@ function normalizeHookStatus(value: string | undefined, warnings: string[], hook return "open"; } -function parseIntegerWithWarning(value: string | undefined, warnings: string[], fieldLabel: string): number { +function parseStrictIntegerWithWarning(value: string | undefined, warnings: string[], fieldLabel: string): number { if (!value) return 0; - const match = value.match(/\d+/); - if (!match) { - appendWarning(warnings, `${fieldLabel} normalized from "${value}" to 0`); - return 0; + const parsed = parseStrictIntegerCell(value); + if (parsed !== null) { + return parsed; } - return parseInt(match[0], 10); + appendWarning(warnings, `${fieldLabel} normalized from "${value}" to 0`); + return 0; } function parseIntegerWithFallback( @@ -671,6 +652,26 @@ function parseInteger(value: string | undefined): number { return match ? parseInt(match[0], 10) : 0; } +function parseStrictInteger(value: string | undefined): number { + return parseStrictIntegerCell(value) ?? 0; +} + +function parseStrictIntegerCell(value: string | undefined): number | null { + if (!value) return null; + const normalized = normalizeHookId(value); + if (!/^\d+$/.test(normalized)) { + return null; + } + return parseInt(normalized, 10); +} + +function normalizeExplicitChapter(value: number | undefined): number { + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { + return 0; + } + return value; +} + function appendWarning(warnings: string[], warning: string): void { if (!warnings.includes(warning)) { warnings.push(warning); diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index d86e803d..0b7a73ae 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -9,7 +9,10 @@ import { type HookStatus, } from "../models/runtime-state.js"; import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js"; -import { bootstrapStructuredStateFromMarkdown, normalizeHookId } from "../state/state-bootstrap.js"; +import { + bootstrapStructuredStateFromMarkdown, + parsePendingHooksMarkdown as parseStatePendingHooksMarkdown, +} from "../state/state-bootstrap.js"; import { collectStaleHookDebt } from "./hook-governance.js"; export interface MemorySelection { @@ -381,38 +384,7 @@ export function parseChapterSummariesMarkdown(markdown: string): StoredSummary[] } export function parsePendingHooksMarkdown(markdown: string): StoredHook[] { - const tableRows = parseMarkdownTableRows(markdown) - .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id"); - - if (tableRows.length > 0) { - return tableRows - .filter((row) => normalizeHookId(row[0]).length > 0) - .map((row) => ({ - hookId: normalizeHookId(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, - })); + return parseStatePendingHooksMarkdown(markdown); } export function parseCurrentStateFacts( From 54d090c3514bafbec82f215631eca4772711dd71 Mon Sep 17 00:00:00 2001 From: Ma Date: Thu, 2 Apr 2026 23:08:50 +0800 Subject: [PATCH 49/53] fix(state): close remaining chapter-number pollution paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge regression: bootstrapStructuredStateFromMarkdown still included currentState.chapter in lastAppliedChapter, allowing a hallucinated chapter number in current_state.md to override the contiguous durable progress. Changes: - state-bootstrap.ts: derivedProgress and lastAppliedChapter now come exclusively from durableStoryProgress (contiguous artifact chain), currentState.chapter is no longer trusted - manager.ts: getNextChapterNumber returns durableChapter + 1 without consulting manifest - story-markdown.ts: hook startChapter/lastAdvancedChapter parsed with parseStrictChapterInteger (rejects "第141号文明" style prose) - Tests updated to provide durable chapter artifacts matching the chapter numbers they expect --- .../core/src/__tests__/pipeline-agent.test.ts | 22 ++++++-------- .../src/__tests__/pipeline-runner.test.ts | 10 +++++++ .../src/__tests__/runtime-state-store.test.ts | 29 +++++++++++++++++++ packages/core/src/__tests__/writer.test.ts | 18 ++++++++++++ packages/core/src/state/manager.ts | 7 +++-- packages/core/src/state/state-bootstrap.ts | 17 ++++------- packages/core/src/utils/story-markdown.ts | 16 ++++++++-- 7 files changed, 91 insertions(+), 28 deletions(-) diff --git a/packages/core/src/__tests__/pipeline-agent.test.ts b/packages/core/src/__tests__/pipeline-agent.test.ts index ec652eb9..88f06fad 100644 --- a/packages/core/src/__tests__/pipeline-agent.test.ts +++ b/packages/core/src/__tests__/pipeline-agent.test.ts @@ -151,8 +151,10 @@ describe("agent pipeline tools", () => { }); it("blocks write_full_pipeline when runtime progress is ahead of the chapter index", async () => { - const stateDir = join(state.bookDir(bookId), "story", "state"); - await mkdir(stateDir, { recursive: true }); + const chaptersDir = join(state.bookDir(bookId), "chapters"); + // Create durable chapter files for 1-3 but only index chapter 1. + // This produces durableChapter=3, nextNum=4 while lastIndexedChapter=1, + // triggering the sequential write guard. await state.saveChapterIndex(bookId, [{ number: 1, title: "Existing Chapter", @@ -163,17 +165,11 @@ describe("agent pipeline tools", () => { auditIssues: [], lengthWarnings: [], }]); - await writeFile(join(stateDir, "manifest.json"), JSON.stringify({ - schemaVersion: 2, - language: "zh", - lastAppliedChapter: 3, - projectionVersion: 1, - migrationWarnings: [], - }, null, 2), "utf-8"); - await writeFile(join(stateDir, "current_state.json"), JSON.stringify({ - chapter: 3, - facts: [], - }, null, 2), "utf-8"); + await Promise.all([ + writeFile(join(chaptersDir, "0001_Existing.md"), "# Chapter 1\n", "utf-8"), + writeFile(join(chaptersDir, "0002_Second.md"), "# Chapter 2\n", "utf-8"), + writeFile(join(chaptersDir, "0003_Third.md"), "# Chapter 3\n", "utf-8"), + ]); const writeNextChapter = vi.spyOn(pipeline, "writeNextChapter").mockResolvedValue({ bookId, diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts index d2d1a1c2..7287be56 100644 --- a/packages/core/src/__tests__/pipeline-runner.test.ts +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -604,7 +604,17 @@ describe("PipelineRunner", () => { const { root, runner, state, bookId } = await createRunnerFixture(); const storyDir = join(state.bookDir(bookId), "story"); const stateDir = join(storyDir, "state"); + const chaptersDir = join(state.bookDir(bookId), "chapters"); await mkdir(stateDir, { recursive: true }); + await writeFile( + join(chaptersDir, "index.json"), + JSON.stringify([ + { number: 1, title: "Ch1", status: "approved" }, + { number: 2, title: "Ch2", status: "approved" }, + { number: 3, title: "Ch3", status: "approved" }, + ]), + "utf-8", + ); await Promise.all([ writeFile( diff --git a/packages/core/src/__tests__/runtime-state-store.test.ts b/packages/core/src/__tests__/runtime-state-store.test.ts index 555e8718..d730eb06 100644 --- a/packages/core/src/__tests__/runtime-state-store.test.ts +++ b/packages/core/src/__tests__/runtime-state-store.test.ts @@ -24,7 +24,18 @@ describe("runtime-state-store memory helpers", () => { const bookDir = join(root, "book"); const storyDir = join(bookDir, "story"); const stateDir = join(storyDir, "state"); + const chaptersDir = join(bookDir, "chapters"); await mkdir(stateDir, { recursive: true }); + await mkdir(chaptersDir, { recursive: true }); + await writeFile( + join(chaptersDir, "index.json"), + JSON.stringify([ + { number: 1, title: "Ch1", status: "approved" }, + { number: 2, title: "Ch2", status: "approved" }, + { number: 3, title: "Ch3", status: "approved" }, + ]), + "utf-8", + ); await Promise.all([ writeFile( @@ -168,7 +179,16 @@ describe("runtime-state-store memory helpers", () => { const bookDir = join(root, "book"); const storyDir = join(bookDir, "story"); const stateDir = join(storyDir, "state"); + const chaptersDir = join(bookDir, "chapters"); await mkdir(stateDir, { recursive: true }); + await mkdir(chaptersDir, { recursive: true }); + await writeFile( + join(chaptersDir, "index.json"), + JSON.stringify( + Array.from({ length: 12 }, (_, i) => ({ number: i + 1, title: `Ch${i + 1}`, status: "approved" })), + ), + "utf-8", + ); await Promise.all([ writeFile(join(stateDir, "manifest.json"), JSON.stringify({ @@ -222,7 +242,16 @@ describe("runtime-state-store memory helpers", () => { const bookDir = join(root, "book"); const storyDir = join(bookDir, "story"); const stateDir = join(storyDir, "state"); + const chaptersDir = join(bookDir, "chapters"); await mkdir(stateDir, { recursive: true }); + await mkdir(chaptersDir, { recursive: true }); + await writeFile( + join(chaptersDir, "index.json"), + JSON.stringify( + Array.from({ length: 11 }, (_, i) => ({ number: i + 1, title: `Ch${i + 1}`, status: "approved" })), + ), + "utf-8", + ); await Promise.all([ writeFile(join(stateDir, "manifest.json"), JSON.stringify({ diff --git a/packages/core/src/__tests__/writer.test.ts b/packages/core/src/__tests__/writer.test.ts index 6e4de4aa..b1ad8e42 100644 --- a/packages/core/src/__tests__/writer.test.ts +++ b/packages/core/src/__tests__/writer.test.ts @@ -245,9 +245,15 @@ describe("WriterAgent", () => { const root = await mkdtemp(join(tmpdir(), "inkos-writer-runtime-state-test-")); const bookDir = join(root, "book"); const storyDir = join(bookDir, "story"); + const chaptersDir = join(bookDir, "chapters"); await mkdir(storyDir, { recursive: true }); + await mkdir(chaptersDir, { recursive: true }); await Promise.all([ + writeFile(join(chaptersDir, "index.json"), JSON.stringify([ + { number: 1, title: "Ch1", status: "approved" }, + { number: 2, title: "Ch2", status: "approved" }, + ]), "utf-8"), 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 3\nTrace the debt through the river-port ledger.\n", "utf-8"), writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"), @@ -388,9 +394,15 @@ describe("WriterAgent", () => { const root = await mkdtemp(join(tmpdir(), "inkos-writer-runtime-state-hallucinated-chapter-test-")); const bookDir = join(root, "book"); const storyDir = join(bookDir, "story"); + const chaptersDir = join(bookDir, "chapters"); await mkdir(storyDir, { recursive: true }); + await mkdir(chaptersDir, { recursive: true }); await Promise.all([ + writeFile(join(chaptersDir, "index.json"), JSON.stringify([ + { number: 1, title: "Ch1", status: "approved" }, + { number: 2, title: "Ch2", status: "approved" }, + ]), "utf-8"), writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The city still remembers 1988.\n", "utf-8"), writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nTrace the debt through the river-port ledger.\n", "utf-8"), writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"), @@ -532,9 +544,15 @@ describe("WriterAgent", () => { const root = await mkdtemp(join(tmpdir(), "inkos-writer-arbiter-test-")); const bookDir = join(root, "book"); const storyDir = join(bookDir, "story"); + const chaptersDir = join(bookDir, "chapters"); await mkdir(storyDir, { recursive: true }); + await mkdir(chaptersDir, { recursive: true }); await Promise.all([ + writeFile(join(chaptersDir, "index.json"), JSON.stringify([ + { number: 1, title: "Ch1", status: "approved" }, + { number: 2, title: "Ch2", status: "approved" }, + ]), "utf-8"), writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Anonymous messages keep steering the debt trail.\n", "utf-8"), writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nThe anonymous source widens from route to address.\n", "utf-8"), writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"), diff --git a/packages/core/src/state/manager.ts b/packages/core/src/state/manager.ts index 627b966e..783775b7 100644 --- a/packages/core/src/state/manager.ts +++ b/packages/core/src/state/manager.ts @@ -208,11 +208,14 @@ export class StateManager { const durableChapter = await resolveDurableStoryProgress({ bookDir: this.bookDir(bookId), }); - const runtimeState = await bootstrapStructuredStateFromMarkdown({ + // Ensure structured state is bootstrapped (side-effect: creates missing + // JSON files), but do NOT trust its chapter number for progress — only + // the contiguous durable artifact chain is authoritative. + await bootstrapStructuredStateFromMarkdown({ bookDir: this.bookDir(bookId), fallbackChapter: durableChapter, }); - return Math.max(durableChapter, runtimeState.manifest.lastAppliedChapter) + 1; + return durableChapter + 1; } async getPersistedChapterCount(bookId: string): Promise { diff --git a/packages/core/src/state/state-bootstrap.ts b/packages/core/src/state/state-bootstrap.ts index 1cffb95b..2c5697d2 100644 --- a/packages/core/src/state/state-bootstrap.ts +++ b/packages/core/src/state/state-bootstrap.ts @@ -88,10 +88,10 @@ export async function bootstrapStructuredStateFromMarkdown(params: { warnings, bootstrapState: markdownState.currentState, }); - const derivedProgress = Math.max( - markdownState.durableStoryProgress, - currentState.chapter, - ); + // Only trust durable artifact progress (chapter files + index). + // currentState.chapter comes from markdown which can contain + // hallucinated numbers (e.g. year 1988 parsed as chapter 1988). + const derivedProgress = markdownState.durableStoryProgress; if ((existingManifest?.lastAppliedChapter ?? 0) > derivedProgress) { appendWarning( warnings, @@ -151,10 +151,7 @@ export async function rewriteStructuredStateFromMarkdown(params: { const manifest = StateManifestSchema.parse({ schemaVersion: 2, language, - lastAppliedChapter: Math.max( - markdownState.durableStoryProgress, - currentState.chapter, - ), + lastAppliedChapter: markdownState.durableStoryProgress, projectionVersion: existingManifest?.projectionVersion ?? 1, migrationWarnings: uniqueStrings([ ...(existingManifest?.migrationWarnings ?? []), @@ -444,9 +441,7 @@ async function loadMarkdownBootstrapState(params: { summariesState, hooksState, currentState, - durableStoryProgress: authoritativeProgress > 0 - ? authoritativeProgress - : currentState.chapter, + durableStoryProgress: authoritativeProgress, }; } diff --git a/packages/core/src/utils/story-markdown.ts b/packages/core/src/utils/story-markdown.ts index 31b826f2..2e80ca07 100644 --- a/packages/core/src/utils/story-markdown.ts +++ b/packages/core/src/utils/story-markdown.ts @@ -195,6 +195,18 @@ export function parseInteger(value: string | undefined): number { return match ? parseInt(match[0], 10) : 0; } +/** + * Strict integer parse — only accepts cells that are purely numeric + * (after stripping markdown formatting). Returns 0 for cells containing + * prose like "第141号文明" to prevent narrative numbers from being + * mistaken for chapter/progress values. + */ +function parseStrictChapterInteger(value: string | undefined): number { + if (!value) return 0; + const stripped = normalizeHookId(value); + return /^\d+$/.test(stripped) ? parseInt(stripped, 10) : 0; +} + export function normalizeHookId(value: string | undefined): string { let normalized = (value ?? "").trim(); let previous = ""; @@ -220,10 +232,10 @@ function parsePendingHookRow(row: ReadonlyArray): StoredHook return { hookId: normalizeHookId(row[0]), - startChapter: parseInteger(row[1]), + startChapter: parseStrictChapterInteger(row[1]), type: row[2] ?? "", status: row[3] ?? "open", - lastAdvancedChapter: parseInteger(row[4]), + lastAdvancedChapter: parseStrictChapterInteger(row[4]), expectedPayoff: row[5] ?? "", payoffTiming, notes, From b4f8e2df82993330bff2212fedc7c83be9fd696b Mon Sep 17 00:00:00 2001 From: Ma Date: Fri, 3 Apr 2026 09:45:55 +0800 Subject: [PATCH 50/53] feat(pipeline): add hook budget hint and chapter ending trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook budget: planner intent now shows "N/12 active hooks" in the Hook Agenda section. When N >= 10, adds explicit guidance to prioritize resolving old debt over opening new threads. Soft hint only — no hard cap. Ending trail: composer now extracts the last meaningful sentence from the 3 most recent chapter files and injects it as context, so the writer can see "ch3: buried under rubble | ch4: buried under rubble" and know to vary the chapter ending structure. --- packages/core/src/agents/composer.ts | 48 +++++++++++++++++++++++++++- packages/core/src/agents/planner.ts | 22 +++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agents/composer.ts b/packages/core/src/agents/composer.ts index 36197da9..6c795cd0 100644 --- a/packages/core/src/agents/composer.ts +++ b/packages/core/src/agents/composer.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { readFile, readdir, writeFile, mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import yaml from "js-yaml"; import { BaseAgent } from "./base.js"; @@ -243,9 +243,55 @@ export class ComposerAgent extends BaseAgent { }); } + const endingTrail = await this.buildRecentEndingTrail(storyDir, chapterNumber); + if (endingTrail) { + entries.push({ + source: "story/chapters#recent_endings", + reason: "Show how recent chapters ended so the writer avoids structural repetition (e.g. 3 consecutive collapse endings).", + excerpt: endingTrail, + }); + } + return entries; } + private async buildRecentEndingTrail( + storyDir: string, + chapterNumber: number, + ): Promise { + const chaptersDir = join(dirname(storyDir), "chapters"); + try { + const files = await readdir(chaptersDir); + const chapterFiles = files + .filter((file) => file.endsWith(".md")) + .map((file) => ({ file, num: parseInt(file.slice(0, 4), 10) })) + .filter((entry) => Number.isFinite(entry.num) && entry.num < chapterNumber) + .sort((a, b) => b.num - a.num) + .slice(0, 3); + + const endings: string[] = []; + for (const entry of chapterFiles.reverse()) { + const content = await readFile(join(chaptersDir, entry.file), "utf-8"); + const lastLine = this.extractLastMeaningfulSentence(content); + if (lastLine) { + endings.push(`ch${entry.num}: ${lastLine}`); + } + } + return endings.length >= 2 ? endings.join(" | ") : undefined; + } catch { + return undefined; + } + } + + private extractLastMeaningfulSentence(content: string): string | undefined { + const lines = content.split("\n").map((line) => line.trim()).filter((line) => + line.length > 5 && !line.startsWith("#") && !line.startsWith("|") && !line.startsWith("==="), + ); + const last = lines.at(-1); + if (!last) return undefined; + return last.length > 60 ? last.slice(0, 57) + "..." : last; + } + private async buildHookDebtEntries( storyDir: string, plan: PlanChapterOutput, diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index 4eb381f8..2112df0e 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -81,6 +81,9 @@ export class PlannerAgent extends BaseAgent { outlineNode: planningAnchor, mustKeep, }); + const activeHookCount = memorySelection.activeHooks.filter( + (hook) => hook.status !== "resolved" && hook.status !== "deferred", + ).length; const hookAgenda = buildPlannerHookAgenda({ hooks: memorySelection.activeHooks, chapterNumber: input.chapterNumber, @@ -94,6 +97,7 @@ export class PlannerAgent extends BaseAgent { outlineNode, matchedOutlineAnchor, chapterSummaries, + activeHookCount, }); const intent = ChapterIntentSchema.parse({ @@ -136,6 +140,7 @@ export class PlannerAgent extends BaseAgent { readonly outlineNode: string | undefined; readonly matchedOutlineAnchor: boolean; readonly chapterSummaries: string; + readonly activeHookCount?: number; }): Pick { const recentSummaries = parseChapterSummariesMarkdown(input.chapterSummaries) .filter((summary) => summary.chapter < input.chapterNumber) @@ -384,6 +389,21 @@ export class PlannerAgent extends BaseAgent { : `Avoid another ${repeatedToken}-centric title. Pick a new image or action focus for this chapter title.`; } + private renderHookBudget(pendingHooks: string, language: "zh" | "en"): string { + const hookLines = pendingHooks.split("\n").filter((line) => line.startsWith("|") && /^\|\s*[A-Za-z\u4e00-\u9fff]/.test(line)); + const activeCount = hookLines.length; + const cap = 12; + if (activeCount < 10) { + return language === "en" + ? `### Hook Budget\n- ${activeCount} active hooks (capacity: ${cap})` + : `### 伏笔预算\n- 当前 ${activeCount} 条活跃伏笔(容量:${cap})`; + } + const remaining = Math.max(0, cap - activeCount); + return language === "en" + ? `### Hook Budget\n- ${activeCount} active hooks — approaching capacity (${cap}). Only ${remaining} new hook(s) allowed. Prioritize resolving existing debt over opening new threads.` + : `### 伏笔预算\n- 当前 ${activeCount} 条活跃伏笔——接近容量上限(${cap})。仅剩 ${remaining} 个新坑位。优先回收旧债,不要轻易开新线。`; + } + private extractSection(content: string, headings: ReadonlyArray): string | undefined { const targets = headings.map((heading) => this.normalizeHeading(heading)); const lines = content.split("\n"); @@ -668,6 +688,8 @@ export class PlannerAgent extends BaseAgent { intent.hookAgenda.avoidNewHookFamilies.length > 0 ? intent.hookAgenda.avoidNewHookFamilies.map((item) => `- ${item}`).join("\n") : "- none", + "", + this.renderHookBudget(pendingHooks, language), ].join("\n"); return [ From 160f3a2704b5172fe53ac793bf4157d015bfa071 Mon Sep 17 00:00:00 2001 From: Ma Date: Fri, 3 Apr 2026 09:55:33 +0800 Subject: [PATCH 51/53] feat(architect): add foundation reviewer and new-spacetime requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes: 1. Architect fanfic prompt now requires a "new spacetime": - Explicit divergence point from source material - Original core conflict (not retelling original plot) - 5-chapter ignition constraint - 50% fresh scene locations 2. New FoundationReviewerAgent reviews architect output before writing begins. Scores 5 dimensions (source DNA, new space, core conflict, opening momentum, pacing feasibility). Threshold: 80 total, 60 floor per dimension. Rejects with specific feedback, architect regenerates. Max 2 retries before accepting best effort. 3. Review loop integrated into initBook (original mode) and initFanficBook (fanfic mode). Both now go through generate → review → (reject → regenerate) cycle. This addresses the #2 problem (斗破同人 75.5): fanfic architect was retelling original plot because nothing required it to create new narrative space. --- .../src/__tests__/pipeline-runner.test.ts | 7 + packages/core/src/agents/architect.ts | 11 +- .../core/src/agents/foundation-reviewer.ts | 204 ++++++++++++++++++ packages/core/src/pipeline/runner.ts | 89 +++++++- 4 files changed, 305 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/agents/foundation-reviewer.ts diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts index 7287be56..1ab1233e 100644 --- a/packages/core/src/__tests__/pipeline-runner.test.ts +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -15,6 +15,7 @@ import { ContinuityAuditor, type AuditIssue, type AuditResult } from "../agents/ import { ReviserAgent, type ReviseOutput } from "../agents/reviser.js"; import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js"; import { StateValidatorAgent } from "../agents/state-validator.js"; +import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js"; import type { BookConfig } from "../models/book.js"; import type { ChapterMeta } from "../models/chapter.js"; import { MemoryDB } from "../state/memory-db.js"; @@ -199,6 +200,12 @@ async function createRunnerFixture( describe("PipelineRunner", () => { beforeEach(() => { + vi.spyOn(FoundationReviewerAgent.prototype, "review").mockResolvedValue({ + passed: true, + totalScore: 85, + dimensions: [], + overallFeedback: "auto-pass for test", + }); vi.spyOn(LengthNormalizerAgent.prototype, "normalizeChapter").mockImplementation( async ({ chapterContent, lengthSpec }) => ({ normalizedContent: chapterContent, diff --git a/packages/core/src/agents/architect.ts b/packages/core/src/agents/architect.ts index 9c944a10..55d32e68 100644 --- a/packages/core/src/agents/architect.ts +++ b/packages/core/src/agents/architect.ts @@ -597,6 +597,13 @@ ${keyPrinciplesPrompt}`; ## 同人模式:${fanficMode} ${MODE_INSTRUCTIONS[fanficMode]} +## 新时空要求(关键) +你必须为这本同人设计一个**原创的叙事空间**,而不是复述原作剧情。具体要求: +1. **明确分岔点**:story_bible 必须标注"本作从原作的哪个节点分岔",或"本作发生在原作未涉及的什么时空" +2. **独立核心冲突**:volume_outline 的核心冲突必须是原创的,不是原作情节的翻版。原作角色可以出现,但他们面对的是新问题 +3. **5章内引爆**:volume_outline 的第1卷必须在前5章内建立核心悬念,不允许用3章做铺垫才到引爆点 +4. **场景新鲜度**:至少50%的关键场景发生在原作未出现的地点或情境中 + ## 原作正典 ${fanficCanon} @@ -606,8 +613,8 @@ ${genreBody} ## 关键原则 1. **不发明主要角色** — 主要角色必须来自原作正典的角色档案 2. 可以添加原创配角,但必须在 story_bible 中标注为"原创角色" -3. story_bible 保留原作世界观,标注同人的改动/扩展部分 -4. volume_outline 以原作事件为锚点,标注哪些是原作事件、哪些是同人原创 +3. story_bible 保留原作世界观,标注同人的改动/扩展部分,并明确写出**分岔点**和**新时空设定** +4. volume_outline 不得复述原作剧情节拍。每卷的核心事件必须是原创的,标注"原创" 5. book_rules 的 fanficMode 必须设为 "${fanficMode}" 6. 主角设定来自原作角色档案中的第一个角色(或用户在标题中暗示的角色) diff --git a/packages/core/src/agents/foundation-reviewer.ts b/packages/core/src/agents/foundation-reviewer.ts new file mode 100644 index 00000000..8508446c --- /dev/null +++ b/packages/core/src/agents/foundation-reviewer.ts @@ -0,0 +1,204 @@ +import { BaseAgent } from "./base.js"; +import type { ArchitectOutput } from "./architect.js"; + +export interface FoundationReviewResult { + readonly passed: boolean; + readonly totalScore: number; + readonly dimensions: ReadonlyArray<{ + readonly name: string; + readonly score: number; + readonly feedback: string; + }>; + readonly overallFeedback: string; +} + +const PASS_THRESHOLD = 80; +const DIMENSION_FLOOR = 60; + +export class FoundationReviewerAgent extends BaseAgent { + get name(): string { + return "foundation-reviewer"; + } + + async review(params: { + readonly foundation: ArchitectOutput; + readonly mode: "original" | "fanfic" | "series"; + readonly sourceCanon?: string; + readonly styleGuide?: string; + readonly language: "zh" | "en"; + }): Promise { + const canonBlock = params.sourceCanon + ? `\n## 原作正典参照\n${params.sourceCanon.slice(0, 8000)}\n` + : ""; + const styleBlock = params.styleGuide + ? `\n## 原作风格参照\n${params.styleGuide.slice(0, 2000)}\n` + : ""; + + const dimensions = params.mode === "original" + ? this.originalDimensions(params.language) + : this.derivativeDimensions(params.language, params.mode); + + const systemPrompt = params.language === "en" + ? this.buildEnglishReviewPrompt(dimensions, canonBlock, styleBlock) + : this.buildChineseReviewPrompt(dimensions, canonBlock, styleBlock); + + const userPrompt = this.buildFoundationExcerpt(params.foundation, params.language); + + const response = await this.chat([ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], { maxTokens: 4096, temperature: 0.3 }); + + return this.parseReviewResult(response.content, dimensions); + } + + private originalDimensions(language: "zh" | "en"): ReadonlyArray { + return language === "en" + ? [ + "Core Conflict (Is there a clear, compelling central conflict that can sustain 40 chapters?)", + "Opening Momentum (Can the first 5 chapters create a page-turning hook?)", + "World Coherence (Is the worldbuilding internally consistent and specific?)", + "Character Differentiation (Are the main characters distinct in voice and motivation?)", + "Pacing Feasibility (Does the volume outline have enough variety — not the same beat for 10 chapters?)", + ] + : [ + "核心冲突(是否有清晰且有足够张力的核心冲突支撑40章?)", + "开篇节奏(前5章能否形成翻页驱动力?)", + "世界一致性(世界观是否内洽且具体?)", + "角色区分度(主要角色的声音和动机是否各不相同?)", + "节奏可行性(卷纲是否有足够变化——不会连续10章同一种节拍?)", + ]; + } + + private derivativeDimensions(language: "zh" | "en", mode: "fanfic" | "series"): ReadonlyArray { + const modeLabel = mode === "fanfic" + ? (language === "en" ? "Fan Fiction" : "同人") + : (language === "en" ? "Series" : "系列"); + + return language === "en" + ? [ + `Source DNA Preservation (Does the ${modeLabel} respect the original's world rules, character personalities, and established facts?)`, + `New Narrative Space (Is there a clear divergence point or new territory that gives the story room to be ORIGINAL, not a retelling?)`, + "Core Conflict (Is the new story's central conflict compelling and distinct from the original?)", + "Opening Momentum (Can the first 5 chapters create a page-turning hook without requiring 3 chapters of setup?)", + `Pacing Feasibility (Does the outline avoid the trap of re-walking the original's plot beats?)`, + ] + : [ + `原作DNA保留(${modeLabel}是否尊重原作的世界规则、角色性格、已确立事实?)`, + `新叙事空间(是否有明确的分岔点或新领域,让故事有原创空间,而非复述原作?)`, + "核心冲突(新故事的核心冲突是否有足够张力且区别于原作?)", + "开篇节奏(前5章能否形成翻页驱动力,不需要3章铺垫?)", + `节奏可行性(卷纲是否避免了重走原作剧情节拍的陷阱?)`, + ]; + } + + private buildChineseReviewPrompt( + dimensions: ReadonlyArray, + canonBlock: string, + styleBlock: string, + ): string { + return `你是一位资深小说编辑,正在审核一本新书的基础设定(世界观 + 大纲 + 规则)。 + +你需要从以下维度逐项打分(0-100),并给出具体意见: + +${dimensions.map((dim, i) => `${i + 1}. ${dim}`).join("\n")} + +## 评分标准 +- 80+ 通过,可以开始写作 +- 60-79 有明显问题,需要修改 +- <60 方向性错误,需要重新设计 + +## 输出格式(严格遵守) +=== DIMENSION: 1 === +分数:{0-100} +意见:{具体反馈} + +=== DIMENSION: 2 === +分数:{0-100} +意见:{具体反馈} + +...(每个维度一个 block) + +=== OVERALL === +总分:{加权平均} +通过:{是/否} +总评:{1-2段总结,指出最大的问题和最值得保留的优点} +${canonBlock}${styleBlock} + +审核时要严格。不要因为"还行"就给高分。80分意味着"可以直接开写,不需要改"。`; + } + + private buildEnglishReviewPrompt( + dimensions: ReadonlyArray, + canonBlock: string, + styleBlock: string, + ): string { + return `You are a senior fiction editor reviewing a new book's foundation (worldbuilding + outline + rules). + +Score each dimension (0-100) with specific feedback: + +${dimensions.map((dim, i) => `${i + 1}. ${dim}`).join("\n")} + +## Scoring +- 80+ Pass — ready to write +- 60-79 Needs revision +- <60 Fundamental direction problem + +## Output format (strict) +=== DIMENSION: 1 === +Score: {0-100} +Feedback: {specific feedback} + +=== DIMENSION: 2 === +Score: {0-100} +Feedback: {specific feedback} + +... + +=== OVERALL === +Total: {weighted average} +Passed: {yes/no} +Summary: {1-2 paragraphs — biggest problem and best quality} +${canonBlock}${styleBlock} + +Be strict. 80 means "ready to write without changes."`; + } + + private buildFoundationExcerpt(foundation: ArchitectOutput, language: "zh" | "en"): string { + return language === "en" + ? `## Story Bible\n${foundation.storyBible.slice(0, 3000)}\n\n## Volume Outline\n${foundation.volumeOutline.slice(0, 3000)}\n\n## Book Rules\n${foundation.bookRules.slice(0, 1500)}\n\n## Initial State\n${foundation.currentState.slice(0, 1000)}\n\n## Initial Hooks\n${foundation.pendingHooks.slice(0, 1000)}` + : `## 世界设定\n${foundation.storyBible.slice(0, 3000)}\n\n## 卷纲\n${foundation.volumeOutline.slice(0, 3000)}\n\n## 规则\n${foundation.bookRules.slice(0, 1500)}\n\n## 初始状态\n${foundation.currentState.slice(0, 1000)}\n\n## 初始伏笔\n${foundation.pendingHooks.slice(0, 1000)}`; + } + + private parseReviewResult( + content: string, + dimensions: ReadonlyArray, + ): FoundationReviewResult { + const parsedDimensions: Array<{ readonly name: string; readonly score: number; readonly feedback: string }> = []; + + for (let i = 0; i < dimensions.length; i++) { + const regex = new RegExp( + `=== DIMENSION: ${i + 1} ===\\s*[\\s\\S]*?(?:分数|Score)[::]\\s*(\\d+)[\\s\\S]*?(?:意见|Feedback)[::]\\s*([\\s\\S]*?)(?==== |$)`, + ); + const match = content.match(regex); + parsedDimensions.push({ + name: dimensions[i]!, + score: match ? parseInt(match[1]!, 10) : 50, + feedback: match ? match[2]!.trim() : "(parse failed)", + }); + } + + const totalScore = parsedDimensions.length > 0 + ? Math.round(parsedDimensions.reduce((sum, d) => sum + d.score, 0) / parsedDimensions.length) + : 0; + const anyBelowFloor = parsedDimensions.some((d) => d.score < DIMENSION_FLOOR); + const passed = totalScore >= PASS_THRESHOLD && !anyBelowFloor; + + const overallMatch = content.match( + /=== OVERALL ===[\s\S]*?(?:总评|Summary)[::]\s*([\s\S]*?)$/, + ); + const overallFeedback = overallMatch ? overallMatch[1]!.trim() : "(parse failed)"; + + return { passed, totalScore, dimensions: parsedDimensions, overallFeedback }; + } +} diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index f8aceac4..9304e201 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -5,7 +5,8 @@ import type { BookConfig, FanficMode } from "../models/book.js"; import type { ChapterMeta } from "../models/chapter.js"; import type { NotifyChannel, LLMConfig, AgentLLMOverride, InputGovernanceMode } from "../models/project.js"; import type { GenreProfile } from "../models/genre-profile.js"; -import { ArchitectAgent } from "../agents/architect.js"; +import { ArchitectAgent, type ArchitectOutput } from "../agents/architect.js"; +import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js"; import { PlannerAgent, type PlanChapterOutput } from "../agents/planner.js"; import { ComposerAgent } from "../agents/composer.js"; import { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "../agents/writer.js"; @@ -242,6 +243,69 @@ export class PipelineRunner { } } + private async generateAndReviewFoundation(params: { + readonly generate: () => Promise; + readonly reviewer: FoundationReviewerAgent; + readonly mode: "original" | "fanfic" | "series"; + readonly sourceCanon?: string; + readonly styleGuide?: string; + readonly language: "zh" | "en"; + readonly stageLanguage: LengthLanguage; + readonly maxRetries?: number; + }): Promise { + const maxRetries = params.maxRetries ?? 2; + let foundation = await params.generate(); + + for (let attempt = 0; attempt < maxRetries; attempt++) { + this.logStage(params.stageLanguage, { + zh: `审核基础设定(第${attempt + 1}轮)`, + en: `reviewing foundation (round ${attempt + 1})`, + }); + + const review = await params.reviewer.review({ + foundation, + mode: params.mode, + sourceCanon: params.sourceCanon, + styleGuide: params.styleGuide, + language: params.language, + }); + + this.config.logger?.info( + `Foundation review: ${review.totalScore}/100 ${review.passed ? "PASSED" : "REJECTED"}`, + ); + for (const dim of review.dimensions) { + this.config.logger?.info(` [${dim.score}] ${dim.name.slice(0, 40)}`); + } + + if (review.passed) { + return foundation; + } + + this.logWarn(params.stageLanguage, { + zh: `基础设定未通过审核(${review.totalScore}分),正在重新生成...`, + en: `Foundation rejected (${review.totalScore}/100), regenerating...`, + }); + + // Feed review feedback back to architect via the next generation + // (the architect will see different random seed due to temperature) + foundation = await params.generate(); + } + + // Final review + const finalReview = await params.reviewer.review({ + foundation, + mode: params.mode, + sourceCanon: params.sourceCanon, + styleGuide: params.styleGuide, + language: params.language, + }); + this.config.logger?.info( + `Foundation final review: ${finalReview.totalScore}/100 ${finalReview.passed ? "PASSED" : "ACCEPTED (max retries)"}`, + ); + + return foundation; + } + private agentCtx(bookId?: string): AgentContext { return { client: this.config.client, @@ -346,7 +410,15 @@ export class PipelineRunner { this.logStage(stageLanguage, { zh: "生成基础设定", en: "generating foundation" }); const { profile: gp } = await this.loadGenreProfile(book.genre); - const foundation = await architect.generateFoundation(book, this.config.externalContext); + const reviewer = new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", book.id)); + const resolvedLanguage = (book.language ?? gp.language) === "en" ? "en" as const : "zh" as const; + const foundation = await this.generateAndReviewFoundation({ + generate: () => architect.generateFoundation(book, this.config.externalContext), + reviewer, + mode: "original", + language: resolvedLanguage, + stageLanguage, + }); try { this.logStage(stageLanguage, { zh: "保存书籍配置", en: "saving book config" }); await this.state.saveBookConfigAt(stagingBookDir, book); @@ -421,11 +493,20 @@ export class PipelineRunner { this.logStage(stageLanguage, { zh: "导入同人正典", en: "importing fanfic canon" }); const fanficCanon = await this.importFanficCanon(book.id, sourceText, sourceName, fanficMode); - // Step 2: Generate foundation from fanfic canon (not from scratch) + // Step 2: Generate foundation with review loop const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id)); + const reviewer = new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", book.id)); this.logStage(stageLanguage, { zh: "生成同人基础设定", en: "generating fanfic foundation" }); const { profile: gp } = await this.loadGenreProfile(book.genre); - const foundation = await architect.generateFanficFoundation(book, fanficCanon, fanficMode); + const resolvedLanguage = (book.language ?? gp.language) === "en" ? "en" as const : "zh" as const; + const foundation = await this.generateAndReviewFoundation({ + generate: () => architect.generateFanficFoundation(book, fanficCanon, fanficMode), + reviewer, + mode: "fanfic", + sourceCanon: fanficCanon, + language: resolvedLanguage, + stageLanguage, + }); this.logStage(stageLanguage, { zh: "写入基础设定文件", en: "writing foundation files" }); await architect.writeFoundationFiles( bookDir, From e771dee73a1a12e05f22ebe5cbd4f003d9da1525 Mon Sep 17 00:00:00 2001 From: Ma Date: Fri, 3 Apr 2026 10:13:48 +0800 Subject: [PATCH 52/53] fix(pipeline): wire foundation review retries and hook budget --- packages/core/src/__tests__/architect.test.ts | 131 ++++++++++++++++++ .../src/__tests__/pipeline-runner.test.ts | 84 +++++++++++ packages/core/src/__tests__/planner.test.ts | 76 ++++++++++ packages/core/src/agents/architect.ts | 33 ++++- packages/core/src/agents/planner.ts | 10 +- packages/core/src/pipeline/runner.ts | 55 +++++++- 6 files changed, 375 insertions(+), 14 deletions(-) diff --git a/packages/core/src/__tests__/architect.test.ts b/packages/core/src/__tests__/architect.test.ts index 91e0b85e..0adb8311 100644 --- a/packages/core/src/__tests__/architect.test.ts +++ b/packages/core/src/__tests__/architect.test.ts @@ -146,6 +146,137 @@ describe("ArchitectAgent", () => { expect(messages[0]?.content).not.toContain("## 叙事视角"); }); + it("embeds reviewer feedback into original foundation regeneration prompts", async () => { + const agent = new ArchitectAgent({ + client: { + provider: "openai", + apiFormat: "chat", + stream: false, + defaults: { + temperature: 0.7, + maxTokens: 4096, + thinkingBudget: 0, maxTokensCap: null, + extra: {}, + }, + }, + model: "test-model", + projectRoot: process.cwd(), + }); + + const book: BookConfig = { + id: "review-feedback-book", + title: "雾港回灯", + platform: "tomato", + genre: "urban", + status: "active", + targetChapters: 60, + chapterWordCount: 2200, + language: "zh", + createdAt: "2026-04-03T00:00:00.000Z", + updatedAt: "2026-04-03T00:00:00.000Z", + }; + + const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise }, "chat") + .mockResolvedValue({ + content: [ + "=== SECTION: story_bible ===", + "# 故事圣经", + "", + "=== SECTION: volume_outline ===", + "# 卷纲", + "", + "=== SECTION: book_rules ===", + "---", + "version: \"1.0\"", + "---", + "", + "=== SECTION: current_state ===", + "# 当前状态", + "", + "=== SECTION: pending_hooks ===", + "# 待回收伏笔", + ].join("\n"), + usage: ZERO_USAGE, + }); + + await agent.generateFoundation( + book, + undefined, + "请把核心冲突收紧,并明确新空间不是旧案重演。", + ); + + const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>; + expect(messages[0]?.content).toContain("上一轮审核反馈"); + expect(messages[0]?.content).toContain("请把核心冲突收紧"); + expect(messages[0]?.content).toContain("明确新空间不是旧案重演"); + }); + + it("embeds reviewer feedback into fanfic foundation regeneration prompts", async () => { + const agent = new ArchitectAgent({ + client: { + provider: "openai", + apiFormat: "chat", + stream: false, + defaults: { + temperature: 0.7, + maxTokens: 4096, + thinkingBudget: 0, maxTokensCap: null, + extra: {}, + }, + }, + model: "test-model", + projectRoot: process.cwd(), + }); + + const book: BookConfig = { + id: "fanfic-review-feedback-book", + title: "三体:回声舱", + platform: "tomato", + genre: "other", + status: "active", + targetChapters: 60, + chapterWordCount: 2200, + language: "zh", + createdAt: "2026-04-03T00:00:00.000Z", + updatedAt: "2026-04-03T00:00:00.000Z", + }; + + const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise }, "chat") + .mockResolvedValue({ + content: [ + "=== SECTION: story_bible ===", + "# 故事圣经", + "", + "=== SECTION: volume_outline ===", + "# 卷纲", + "", + "=== SECTION: book_rules ===", + "---", + "version: \"1.0\"", + "---", + "", + "=== SECTION: current_state ===", + "# 当前状态", + "", + "=== SECTION: pending_hooks ===", + "# 待回收伏笔", + ].join("\n"), + usage: ZERO_USAGE, + }); + + await agent.generateFanficFoundation( + book, + "# 原作正典\n- 罗辑在面壁计划中留下了一处空档。", + "canon", + "请明确分岔点,并用原创冲突替代原作重走。", + ); + + const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>; + expect(messages[0]?.content).toContain("上一轮审核反馈"); + expect(messages[0]?.content).toContain("请明确分岔点"); + expect(messages[0]?.content).toContain("原创冲突替代原作重走"); + }); + it("strips assistant-style trailing coda from the final pending hooks section", async () => { const agent = new ArchitectAgent({ client: { diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts index 1ab1233e..36621452 100644 --- a/packages/core/src/__tests__/pipeline-runner.test.ts +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -348,6 +348,90 @@ describe("PipelineRunner", () => { } }); + it("feeds foundation review feedback into the regeneration call after a rejection", async () => { + const { root, runner, bookId } = await createRunnerFixture(); + const reviewer = new FoundationReviewerAgent({ + client: { + provider: "openai", + apiFormat: "chat", + stream: false, + defaults: { + temperature: 0.7, + maxTokens: 4096, + thinkingBudget: 0, maxTokensCap: null, + }, + } as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId, + }); + const foundation = { + storyBible: "# Story Bible", + volumeOutline: "# Volume Outline", + bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules", + currentState: "# Current State", + pendingHooks: "# Pending Hooks", + }; + const generate = vi.fn(async (_reviewFeedback?: string) => foundation); + const reviewMock = vi.mocked(FoundationReviewerAgent.prototype.review); + + reviewMock.mockReset(); + reviewMock + .mockResolvedValueOnce({ + passed: false, + totalScore: 68, + dimensions: [ + { + name: "核心冲突", + score: 58, + feedback: "核心冲突不够集中,主线悬念没有站稳。", + }, + { + name: "开篇节奏", + score: 76, + feedback: "前五章起势偏慢,爆点不够前置。", + }, + ], + overallFeedback: "请把冲突收紧,并在更早的位置建立爆点。", + }) + .mockResolvedValueOnce({ + passed: true, + totalScore: 88, + dimensions: [], + overallFeedback: "通过", + }); + + try { + const result = await (runner as unknown as { + generateAndReviewFoundation: (params: { + readonly generate: (reviewFeedback?: string) => Promise; + readonly reviewer: FoundationReviewerAgent; + readonly mode: "original"; + readonly language: "zh"; + readonly stageLanguage: "zh"; + readonly maxRetries: number; + }) => Promise; + }).generateAndReviewFoundation({ + generate, + reviewer, + mode: "original", + language: "zh", + stageLanguage: "zh", + maxRetries: 2, + }); + + expect(result).toEqual(foundation); + expect(generate).toHaveBeenCalledTimes(2); + expect(generate.mock.calls[0]?.[0]).toBeUndefined(); + expect(generate.mock.calls[1]?.[0]).toContain("请把冲突收紧,并在更早的位置建立爆点。"); + expect(generate.mock.calls[1]?.[0]).toContain("核心冲突"); + expect(generate.mock.calls[1]?.[0]).toContain("核心冲突不够集中"); + expect(generate.mock.calls[1]?.[0]).toContain("开篇节奏"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("bootstraps missing control documents for legacy books before writing", async () => { const { root, runner, bookId } = await createRunnerFixture(); diff --git a/packages/core/src/__tests__/planner.test.ts b/packages/core/src/__tests__/planner.test.ts index 21cd15fc..298434e0 100644 --- a/packages/core/src/__tests__/planner.test.ts +++ b/packages/core/src/__tests__/planner.test.ts @@ -1329,4 +1329,80 @@ describe("PlannerAgent", () => { ])); expect(result.intent.hookAgenda.pressureMap).toEqual([]); }); + + it("renders hook budget from total active hooks instead of the selected hook snapshot", async () => { + const stateDir = join(storyDir, "state"); + await mkdir(stateDir, { recursive: true }); + + const hooks = Array.from({ length: 12 }, (_, index) => ({ + hookId: `hook-${index + 1}`, + startChapter: index + 1, + type: index < 6 ? "route" : "mystery", + status: "open", + lastAdvancedChapter: index < 6 ? 25 - index : 12 - index, + expectedPayoff: index < 6 ? "Route debt payoff" : "Dormant mystery payoff", + notes: index < 6 + ? `Route pressure thread ${index + 1} stays relevant.` + : `Dormant thread ${index + 1} should not be selected into the primary context.`, + })); + + await Promise.all([ + writeFile( + join(stateDir, "manifest.json"), + JSON.stringify({ + schemaVersion: 2, + language: "en", + lastAppliedChapter: 25, + projectionVersion: 1, + migrationWarnings: [], + }, null, 2), + "utf-8", + ), + writeFile( + join(stateDir, "current_state.json"), + JSON.stringify({ + chapter: 25, + facts: [], + }, null, 2), + "utf-8", + ), + writeFile( + join(stateDir, "chapter_summaries.json"), + JSON.stringify({ + rows: [], + }, null, 2), + "utf-8", + ), + writeFile( + join(stateDir, "hooks.json"), + JSON.stringify({ hooks }, null, 2), + "utf-8", + ), + ]); + + book = { + ...book, + genre: "other", + language: "en", + targetChapters: 40, + }; + + const planner = new PlannerAgent({ + client: {} as ConstructorParameters[0]["client"], + model: "test-model", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 26, + externalContext: "Keep the chapter on the route pressure.", + }); + + const intentMarkdown = await readFile(result.runtimePath, "utf-8"); + expect(intentMarkdown).toContain("12 active hooks"); + expect(intentMarkdown).not.toContain("8 active hooks"); + }); }); diff --git a/packages/core/src/agents/architect.ts b/packages/core/src/agents/architect.ts index 55d32e68..0e8b50d2 100644 --- a/packages/core/src/agents/architect.ts +++ b/packages/core/src/agents/architect.ts @@ -19,7 +19,11 @@ export class ArchitectAgent extends BaseAgent { return "architect"; } - async generateFoundation(book: BookConfig, externalContext?: string): Promise { + async generateFoundation( + book: BookConfig, + externalContext?: string, + reviewFeedback?: string, + ): Promise { const { profile: gp, body: genreBody } = await readGenreProfile(this.ctx.projectRoot, book.genre); const resolvedLanguage = book.language ?? gp.language; @@ -27,6 +31,7 @@ export class ArchitectAgent extends BaseAgent { const contextBlock = externalContext ? `\n\n## 外部指令\n以下是来自外部系统的创作指令,请将其融入设定中:\n\n${externalContext}\n` : ""; + const reviewFeedbackBlock = this.buildReviewFeedbackBlock(reviewFeedback, resolvedLanguage); const numericalBlock = gp.numericalSystem ? `- 有明确的数值/资源体系可追踪 @@ -231,7 +236,7 @@ ${eraBlock} 4. 伏笔前后呼应,不留悬空线 5. 配角有独立动机,不是工具人`; - const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新的${gp.name}小说生成完整的基础设定。${contextBlock} + const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新的${gp.name}小说生成完整的基础设定。${contextBlock}${reviewFeedbackBlock} 要求: - 平台:${book.platform} @@ -581,9 +586,11 @@ ${keyPrinciplesPrompt}`; book: BookConfig, fanficCanon: string, fanficMode: FanficMode, + reviewFeedback?: string, ): Promise { const { profile: gp, body: genreBody } = await readGenreProfile(this.ctx.projectRoot, book.genre); + const reviewFeedbackBlock = this.buildReviewFeedbackBlock(reviewFeedback, book.language ?? "zh"); const MODE_INSTRUCTIONS: Record = { canon: "剧情发生在原作空白期或未详述的角度。不可改变原作已确立的事实。", @@ -604,6 +611,8 @@ ${MODE_INSTRUCTIONS[fanficMode]} 3. **5章内引爆**:volume_outline 的第1卷必须在前5章内建立核心悬念,不允许用3章做铺垫才到引爆点 4. **场景新鲜度**:至少50%的关键场景发生在原作未出现的地点或情境中 +${reviewFeedbackBlock} + ## 原作正典 ${fanficCanon} @@ -662,6 +671,26 @@ prohibitions: return this.parseSections(response.content); } + private buildReviewFeedbackBlock( + reviewFeedback: string | undefined, + language: "zh" | "en", + ): string { + const trimmed = reviewFeedback?.trim(); + if (!trimmed) return ""; + + if (language === "en") { + return `\n\n## Previous Review Feedback +The previous foundation draft was rejected. You must explicitly fix the following issues in this regeneration instead of paraphrasing the same design: + +${trimmed}\n`; + } + + return `\n\n## 上一轮审核反馈 +上一轮基础设定未通过审核。你必须在这次重生中明确修复以下问题,不能只换措辞重写同一套方案: + +${trimmed}\n`; + } + private parseSections(content: string): ArchitectOutput { const parsedSections = new Map(); const sectionPattern = /^\s*===\s*SECTION\s*[::]\s*([^\n=]+?)\s*===\s*$/gim; diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts index 2112df0e..e41870f4 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -97,7 +97,6 @@ export class PlannerAgent extends BaseAgent { outlineNode, matchedOutlineAnchor, chapterSummaries, - activeHookCount, }); const intent = ChapterIntentSchema.parse({ @@ -118,6 +117,7 @@ export class PlannerAgent extends BaseAgent { input.book.language ?? "zh", renderHookSnapshot(memorySelection.hooks, input.book.language ?? "zh"), renderSummarySnapshot(memorySelection.summaries, input.book.language ?? "zh"), + activeHookCount, ); await writeFile(runtimePath, intentMarkdown, "utf-8"); @@ -140,7 +140,6 @@ export class PlannerAgent extends BaseAgent { readonly outlineNode: string | undefined; readonly matchedOutlineAnchor: boolean; readonly chapterSummaries: string; - readonly activeHookCount?: number; }): Pick { const recentSummaries = parseChapterSummariesMarkdown(input.chapterSummaries) .filter((summary) => summary.chapter < input.chapterNumber) @@ -389,9 +388,7 @@ export class PlannerAgent extends BaseAgent { : `Avoid another ${repeatedToken}-centric title. Pick a new image or action focus for this chapter title.`; } - private renderHookBudget(pendingHooks: string, language: "zh" | "en"): string { - const hookLines = pendingHooks.split("\n").filter((line) => line.startsWith("|") && /^\|\s*[A-Za-z\u4e00-\u9fff]/.test(line)); - const activeCount = hookLines.length; + private renderHookBudget(activeCount: number, language: "zh" | "en"): string { const cap = 12; if (activeCount < 10) { return language === "en" @@ -646,6 +643,7 @@ export class PlannerAgent extends BaseAgent { language: "zh" | "en", pendingHooks: string, chapterSummaries: string, + activeHookCount: number, ): string { const conflictLines = intent.conflicts.length > 0 ? intent.conflicts.map((conflict) => `- ${conflict.type}: ${conflict.resolution}`).join("\n") @@ -689,7 +687,7 @@ export class PlannerAgent extends BaseAgent { ? intent.hookAgenda.avoidNewHookFamilies.map((item) => `- ${item}`).join("\n") : "- none", "", - this.renderHookBudget(pendingHooks, language), + this.renderHookBudget(activeHookCount, language), ].join("\n"); return [ diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 9304e201..0fa3ddca 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -244,7 +244,7 @@ export class PipelineRunner { } private async generateAndReviewFoundation(params: { - readonly generate: () => Promise; + readonly generate: (reviewFeedback?: string) => Promise; readonly reviewer: FoundationReviewerAgent; readonly mode: "original" | "fanfic" | "series"; readonly sourceCanon?: string; @@ -286,9 +286,7 @@ export class PipelineRunner { en: `Foundation rejected (${review.totalScore}/100), regenerating...`, }); - // Feed review feedback back to architect via the next generation - // (the architect will see different random seed due to temperature) - foundation = await params.generate(); + foundation = await params.generate(this.buildFoundationReviewFeedback(review, params.language)); } // Final review @@ -306,6 +304,42 @@ export class PipelineRunner { return foundation; } + private buildFoundationReviewFeedback( + review: { + readonly dimensions: ReadonlyArray<{ + readonly name: string; + readonly score: number; + readonly feedback: string; + }>; + readonly overallFeedback: string; + }, + language: "zh" | "en", + ): string { + const dimensionLines = review.dimensions + .map((dimension) => ( + language === "en" + ? `- ${dimension.name} [${dimension.score}]: ${dimension.feedback}` + : `- ${dimension.name}(${dimension.score}分):${dimension.feedback}` + )) + .join("\n"); + + return language === "en" + ? [ + "## Overall Feedback", + review.overallFeedback, + "", + "## Dimension Notes", + dimensionLines || "- none", + ].join("\n") + : [ + "## 总评", + review.overallFeedback, + "", + "## 分项问题", + dimensionLines || "- 无", + ].join("\n"); + } + private agentCtx(bookId?: string): AgentContext { return { client: this.config.client, @@ -413,7 +447,11 @@ export class PipelineRunner { const reviewer = new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", book.id)); const resolvedLanguage = (book.language ?? gp.language) === "en" ? "en" as const : "zh" as const; const foundation = await this.generateAndReviewFoundation({ - generate: () => architect.generateFoundation(book, this.config.externalContext), + generate: (reviewFeedback) => architect.generateFoundation( + book, + this.config.externalContext, + reviewFeedback, + ), reviewer, mode: "original", language: resolvedLanguage, @@ -500,7 +538,12 @@ export class PipelineRunner { const { profile: gp } = await this.loadGenreProfile(book.genre); const resolvedLanguage = (book.language ?? gp.language) === "en" ? "en" as const : "zh" as const; const foundation = await this.generateAndReviewFoundation({ - generate: () => architect.generateFanficFoundation(book, fanficCanon, fanficMode), + generate: (reviewFeedback) => architect.generateFanficFoundation( + book, + fanficCanon, + fanficMode, + reviewFeedback, + ), reviewer, mode: "fanfic", sourceCanon: fanficCanon, From 90a26ea5d8607fefa1e446dc5e085a6ae1989a41 Mon Sep 17 00:00:00 2001 From: Ma Date: Fri, 3 Apr 2026 13:45:21 +0800 Subject: [PATCH 53/53] chore: bump version to 1.1.0, update CHANGELOG and README Writing pipeline overhaul driven by Meta-Harness autoresearch: - Foundation Reviewer with 5-dimension scoring and reject/retry loop - New-spacetime requirements for fanfic mode - Hook seed excerpt system (replacing lifecycle pressure) - Review reject state rollback - State validation recovery with settler retry - Chapter number anchoring to contiguous durable artifacts - Audit drift isolation - Title collapse auto-repair - Hook budget hints and chapter ending trail - Mood/title/scene fatigue detection Benchmark: from-scratch novels improved from 75 to 92/100. --- CHANGELOG.md | 39 ++++++++++++++++++++++++++++++++++++ README.md | 20 ++++++++++++++---- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/studio/package.json | 2 +- 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f36de84f..4231e0bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## v1.1.0 + +写作管线全面升级。通过 Meta-Harness 方法论驱动的多轮 autoresearch 实验,从零模式质量从 75 分提升至 92 分,同人模式从 39 分提升至 82+ 分。 + +### 新功能 + +- **Foundation Reviewer**:建书时新增独立审核 Agent,5 维度百分制打分(原作 DNA 保留、新叙事空间、核心冲突、开篇节奏、节奏可行性),不达 80 分自动驳回并将审核意见反馈给 Architect 重新生成 +- **新时空要求**:同人模式(canon/au/ooc/cp)必须设计原创分岔点,不允许复述原作剧情。要求明确分岔点、独立核心冲突、5 章内引爆、50% 新鲜场景 +- **Hook Seed Excerpt**:伏笔回收时,Composer 从 chapter_summaries 提取原始种子场景的原文片段注入 Writer 上下文,Writer 基于具体叙事素材写回收场景,而非对着 hook ID 干猜。替代了复杂的 lifecycle pressure 系统 +- **Review Reject 回滚**:`inkos review reject` 现在回滚 state 到被拒章节之前的快照,丢弃下游章节和记忆索引,防止坏草稿污染后续生成 +- **State Validation Recovery**:state 校验失败时自动重试 settler(带 validation 反馈),仍失败则降级保存(正文保留,state 不推进),支持 `inkos write repair-state` 手动修复 +- **Audit Drift 隔离**:审计纠偏写入独立的 `audit_drift.md`,不再追加到 `current_state.md`,防止 settler 把审计元数据当叙事事实复述到正文 +- **标题坍缩修复**:检测近期标题的主题聚集(如连续 3 个标题含"盘"),尝试从章节正文提取新关键词重生标题 +- **Hook 预算提示**:活跃伏笔 ≥10 时在 Hook Agenda 中显示预算警告,引导 Writer 优先回收旧债 +- **章节结尾摘要**:Composer 提取最近 3 章的结尾句注入上下文,防止结构性重复(如连续 4 章以"被埋在废墟下"结尾) +- **情绪/节奏检测**:long-span-fatigue 新增 mood 单调和标题聚集检测,序列级 warning 不计入修订 blockingCount +- **同人风格提取**:`fanfic init` 和 `import chapters` 自动生成 `style_guide.md` + `style_profile.json` +- **Governed 路径补全**:续写/同人的 `parent_canon.md` 和 `fanfic_canon.md` 现在通过 Governed 路径注入 Writer + +### Bug Fixes + +- **章节号污染修复**:叙事文本中的数字(如"第 141 号文明"、"1988 年")不再被误解析为章节进度。章节号唯一权威来源为连续的章节文件 + index.json,markdown 数字不参与进度计算 +- **hook 排序修复**:`mustAdvance` 从降序(选最近推进的)修正为升序(选最久未推进的) +- **Outline 匹配修复**:`findOutlineNode` 支持章节范围格式(如"Chapter 1-20"、"第 1-20 章"),防止 Chapter 1 误匹配 Chapter 10 +- **deriveGoal 优先级修正**:outline 节点优先于 current_focus,用户可通过 `## Local Override` 段显式覆盖 +- **approve 不覆盖快照**:`review approve` 不再重新 snapshot,保护 reject 回滚的目标快照 +- **style 提取 graceful degrade**:风格指纹提取失败不中断建书/导入流程 +- **LLM Headers 支持**:`INKOS_LLM_HEADERS` 环境变量注入自定义 HTTP 头(如 User-Agent),解决部分 API 提供方的 403 问题 + +### 内部改进 + +- Planner structured directives:arc/scene/mood/title 四维预写指令 +- Chapter cadence 统一分析模块 +- Runner 大文件拆分:chapter-state-recovery、chapter-review-cycle、chapter-persistence、chapter-truth-validation、persisted-governed-plan 独立模块 +- story-markdown 共享解析器 +- Hook agenda 独立模块(从 memory-retrieval 提取) + +--- + ## v1.0.2 ### Bug Fixes diff --git a/README.md b/README.md index 37f07086..9d80b42c 100644 --- a/README.md +++ b/README.md @@ -135,9 +135,21 @@ inkos export 吞天魔帝 --format epub # 导出 EPUB(手机/Kindle 阅读) ## 核心特性 +### 基础设定审核 (v1.1.0) + +建书时新增独立的 Foundation Reviewer Agent。Architect 生成基础设定后,Reviewer 从 5 个维度百分制评审(原作 DNA 保留、新叙事空间、核心冲突、开篇节奏、节奏可行性),不达 80 分自动驳回,将审核意见反馈给 Architect 重新生成。同人/系列模式强制要求原创分岔点——不允许复述原作剧情。 + +### 伏笔种子回收 (v1.1.0) + +伏笔回收时,系统从 chapter_summaries 提取每个待回收伏笔的**原始种子场景原文**注入 Writer 上下文。Writer 看到"种于第 2 章:萧炎右手碰到戒面时,有一丝温热渗出",就能写出接续性的回收场景,而非对着 hook ID 敷衍。 + +### 审核后回写 (v1.1.0) + +`inkos review reject` 回滚 state 到被拒章节之前的快照,丢弃下游章节和记忆索引。审计不通过的章节阻止继续写下一章,防止坏草稿污染后续生成。`inkos write repair-state` 可手动修复降级章节。 + ### 多维度审计 + 去 AI 味 -连续性审计员从 33 个维度检查每一章草稿:角色记忆、物资连续性、伏笔回收、大纲偏离、叙事节奏、情感弧线等。内置 AI 痕迹检测维度,自动识别"LLM 味"表达(高频词、句式单调、过度总结),审计不通过自动进入修订循环。 +连续性审计员从 33 个维度检查每一章草稿:角色记忆、物资连续性、伏笔回收、大纲偏离、叙事节奏、情感弧线等。内置 AI 痕迹检测维度,自动识别"LLM 味"表达(高频词、句式单调、过度总结),审计不通过自动进入修订循环。新增跨章情绪单调、标题聚集、章节结尾重复检测。 去 AI 味规则内置于写手 agent 的 prompt 层——词汇疲劳词表、禁用句式、文风指纹注入,从源头减少 AI 生成痕迹。`revise --mode anti-detect` 可对已有章节做专门的反检测改写。 @@ -174,13 +186,13 @@ inkos compose chapter 吞天魔帝 - 如果正文超出允许区间,InkOS 最多只会追加 1 次纠偏归一化(压缩或补足),不会直接硬截断正文 - 如果 1 次纠偏后仍然超出 hard range,章节照常保存,但会在结果和 chapter index 里留下长度 warning / telemetry -### 续写已有作品 +### 续写已有作品 / 系列 -`inkos import chapters` 从已有小说文本导入章节,自动逆向工程 7 个真相文件(世界状态、角色矩阵、资源账本、伏笔钩子等),支持 `第X章` 和自定义分割模式、断点续导。导入后 `inkos write next` 无缝接续创作。 +`inkos import chapters` 从已有小说文本导入章节,自动逆向工程 7 个真相文件(世界状态、角色矩阵、资源账本、伏笔钩子等),支持 `第X章` 和自定义分割模式、断点续导。导入后自动生成原作风格指纹(`style_guide.md`),`inkos write next` 无缝接续创作。续写/系列/前传均可——基于同一世界观写独立新故事。 ### 同人创作 -`inkos fanfic init --from source.txt --mode canon` 从原作素材创建同人书。支持四种模式:canon(正典延续)、au(架空世界)、ooc(性格重塑)、cp(CP 向)。内置正典导入器、同人专属审计维度和信息边界管控——确保设定不矛盾。 +`inkos fanfic init --from source.txt --mode canon` 从原作素材创建同人书。支持四种模式:canon(正典延续)、au(架空世界)、ooc(性格重塑)、cp(CP 向)。v1.1.0 起强制要求**新时空设定**——必须设计原创分岔点和独立核心冲突,不允许复述原作剧情。内置正典导入器、同人专属审计维度、信息边界管控和自动风格仿写。 ### 多模型路由 diff --git a/packages/cli/package.json b/packages/cli/package.json index cd3c49f7..ec0abb8d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@actalk/inkos", - "version": "1.0.2", + "version": "1.1.0", "description": "Autonomous AI novel writing CLI agent — 10-agent pipeline that writes, audits, and revises novels with continuity tracking. Supports LitRPG, Progression Fantasy, Isekai, Romantasy, Sci-Fi and more.", "keywords": [ "ai-novel-writing", diff --git a/packages/core/package.json b/packages/core/package.json index 107ba114..358feb0d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@actalk/inkos-core", - "version": "1.0.2", + "version": "1.1.0", "description": "InkOS core engine — multi-agent novel writing pipeline with 33-dimension continuity audit, style cloning, and de-AI-ification", "keywords": [ "ai-novel-writing", diff --git a/packages/studio/package.json b/packages/studio/package.json index d41b1aa5..054df82c 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -1,6 +1,6 @@ { "name": "@actalk/inkos-studio", - "version": "1.0.2", + "version": "1.1.0", "description": "InkOS Studio — Web workbench for novel writing", "type": "module", "main": "dist/api/index.js",