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/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 b6731a4b..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"); @@ -620,6 +664,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/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/review.ts b/packages/cli/src/commands/review.ts index e4a601c2..8d2a9ced 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) => { @@ -132,7 +132,7 @@ reviewCommand 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 +186,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 +198,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/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); + } + }); 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/core/src/__tests__/architect.test.ts b/packages/core/src/__tests__/architect.test.ts index 2515e56f..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: { @@ -206,7 +337,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 +399,71 @@ 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("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 () => { 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/__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__/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/__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/__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/__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/__tests__/composer.test.ts b/packages/core/src/__tests__/composer.test.ts index 381d4df9..11ef5493 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: [], @@ -391,4 +392,180 @@ 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"); + }); + + 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( + 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: { + 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: [], + 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("主要旧债"); + expect(hookDebtEntry?.excerpt).toContain("读者承诺"); + expect(hookDebtEntry?.excerpt).toContain("River Camp"); + expect(hookDebtEntry?.excerpt).toContain("Trial Echo"); + }); }); 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__/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__/governed-working-set.test.ts b/packages/core/src/__tests__/governed-working-set.test.ts index ecbf021e..efafbce9 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 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 | 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"); + + 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/__tests__/hook-agenda.test.ts b/packages/core/src/__tests__/hook-agenda.test.ts new file mode 100644 index 00000000..ae7d9cdf --- /dev/null +++ b/packages/core/src/__tests__/hook-agenda.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + buildPlannerHookAgenda, + isHookWithinChapterWindow, +} from "../utils/hook-agenda.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("builds agenda with stalest-first sorting and chapter-window filtering", () => { + 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(isHookWithinChapterWindow(staleSlowBurn, 12, 5)).toBe(true); + expect(isHookWithinChapterWindow(readyMystery, 12, 5)).toBe(true); + }); +}); 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/__tests__/hook-policy.test.ts b/packages/core/src/__tests__/hook-policy.test.ts new file mode 100644 index 00000000..dc04f025 --- /dev/null +++ b/packages/core/src/__tests__/hook-policy.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + HOOK_ACTIVITY_THRESHOLDS, + 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); + }); + + 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/__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/__tests__/memory-retrieval.test.ts b/packages/core/src/__tests__/memory-retrieval.test.ts index 63eb2ed7..cce67490 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"); @@ -1085,4 +1187,160 @@ 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("sorts must-advance by stalest-first and resolve by earliest-started", () => { + 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.mustAdvance).toContain("ready-packet"); + expect(agenda.eligibleResolve).toContain("slow-oath"); + expect(agenda.eligibleResolve).toContain("ready-packet"); + expect(agenda.pressureMap).toEqual([]); + }); + + it("limits eligible resolve to default max of 1 when not overridden", () => { + 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).toBe(1); + expect(agenda.pressureMap).toEqual([]); + }); + + it("picks stalest hooks for must-advance regardless of type family", () => { + 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).toEqual(["mentor-oath-a", "mentor-oath-b"]); + expect(agenda.mustAdvance).toEqual(expect.arrayContaining([ + expect.stringMatching(/^mentor-oath-/), + ])); + }); }); diff --git a/packages/core/src/__tests__/models.test.ts b/packages/core/src/__tests__/models.test.ts index ac48e0b1..f92351cc 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", () => { @@ -495,6 +497,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"], @@ -505,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"], @@ -517,7 +535,22 @@ 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.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"]); @@ -533,10 +566,15 @@ 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([]); 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__/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/__tests__/pipeline-agent.test.ts b/packages/core/src/__tests__/pipeline-agent.test.ts index dd38c3f4..88f06fad 100644 --- a/packages/core/src/__tests__/pipeline-agent.test.ts +++ b/packages/core/src/__tests__/pipeline-agent.test.ts @@ -117,9 +117,44 @@ 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 }); + 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", @@ -130,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 b4a65d0b..36621452 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"; @@ -14,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"; @@ -198,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, @@ -340,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(); @@ -603,7 +695,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( @@ -1224,7 +1326,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)), @@ -1278,11 +1380,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 }); } @@ -1962,7 +2066,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 +2101,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 +2126,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 }); }); @@ -2144,6 +2248,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( @@ -2321,6 +2676,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(); @@ -2967,7 +3466,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"; @@ -3047,16 +3546,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"); @@ -3106,11 +3607,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("活跃伏笔过多"), @@ -3243,8 +3746,8 @@ describe("PipelineRunner", () => { createWriterOutput({ chapterNumber: 2, title: "回声", - content: "这次的正文完全不同,只是标题碰巧重复了。", - wordCount: "这次的正文完全不同,只是标题碰巧重复了。".length, + content: "啊。", + wordCount: "啊。".length, }), ); vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue( @@ -3266,6 +3769,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"); @@ -3707,6 +4266,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__/planner.test.ts b/packages/core/src/__tests__/planner.test.ts index c6833d04..298434e0 100644 --- a/packages/core/src/__tests__/planner.test.ts +++ b/packages/core/src/__tests__/planner.test.ts @@ -77,7 +77,475 @@ 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", + projectRoot: root, + bookId: book.id, + }); + + const result = await planner.planChapter({ + book, + bookDir, + chapterNumber: 3, + }); + + expect(result.intent.goal).toContain("mentor conflict"); + 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("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("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( + 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\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.goal).toContain("mentor-student bond"); + expect(result.intent.goal).not.toContain("Describe what the next 1-3 chapters should prioritize"); + }); + + it("uses bullet-style volume outline chapter nodes as the fallback goal when control docs are placeholders", 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 1", + "**Chapter range:** 1-8", + "", + "**Key turning points:**", + "- **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("Advance chapter 3 with clear narrative focus."); + }); + + it("uses the next paragraph for bold standalone English chapter labels instead of capturing markdown markers", 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 1 - The Dead Examiner", + "**Chapter Range:** 1-12", + "", + "**Key Turning Points:**", + "- Ch1: Renn dies after summoning Taryn to review irregular treaty folios.", + "", + "### Golden First Three Chapters Rule", + "", + "**Chapter 2:**", + "Show Taryn's edge through action, not exposition. He uses registry numbering logic to identify which folios are decoys and which conceal a ledger fragment.", + "", + ].join("\n"), + "utf-8", + ), + ]); + const planner = new PlannerAgent({ client: {} as ConstructorParameters[0]["client"], model: "test-model", @@ -88,14 +556,22 @@ describe("PlannerAgent", () => { const result = await planner.planChapter({ book, bookDir, - chapterNumber: 3, + chapterNumber: 2, }); - expect(result.intent.goal).toContain("mentor conflict"); - await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("mentor conflict"); + expect(result.intent.outlineNode).toContain("Show Taryn's edge through action"); + expect(result.intent.outlineNode).not.toBe("**"); + expect(result.intent.goal).toContain("Show Taryn's edge through action"); + expect(result.intent.goal).not.toBe("**"); }); - it("ignores the default current_focus placeholder and falls back to author intent when no chapter outline is available", 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"), @@ -111,7 +587,16 @@ describe("PlannerAgent", () => { ), writeFile( join(storyDir, "volume_outline.md"), - "# Volume Outline\n", + [ + "# 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", ), ]); @@ -126,14 +611,15 @@ describe("PlannerAgent", () => { const result = await planner.planChapter({ book, bookDir, - chapterNumber: 3, + chapterNumber: 1, }); - expect(result.intent.goal).toContain("mentor-student bond"); - expect(result.intent.goal).not.toContain("Describe what the next 1-3 chapters should prioritize"); + expect(result.intent.outlineNode).toContain("dead examiner"); + expect(result.intent.outlineNode).not.toContain("late-volume"); + expect(result.intent.goal).toContain("dead examiner"); }); - it("uses bullet-style volume outline chapter nodes as the fallback goal when control docs are placeholders", async () => { + it("uses inline Chinese exact chapter labels with a title suffix", async () => { await Promise.all([ writeFile( join(storyDir, "author_intent.md"), @@ -155,11 +641,9 @@ describe("PlannerAgent", () => { writeFile( join(storyDir, "volume_outline.md"), [ - "## Volume 1", - "**Chapter range:** 1-8", + "# Volume Outline", "", - "**Key turning points:**", - "- **Chapter 3:** Track the merchant guild's escape route through the western canal.", + "第 7 章:在码头接头并截住逃跑账房。", "", ].join("\n"), "utf-8", @@ -176,15 +660,68 @@ describe("PlannerAgent", () => { const result = await planner.planChapter({ book, bookDir, - chapterNumber: 3, + 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章", + "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("Advance chapter 3 with clear narrative focus."); + expect(result.intent.goal).not.toContain("Describe the long-horizon vision"); }); - it("uses the next paragraph for bold standalone English chapter labels instead of capturing markdown markers", async () => { + it("uses standalone English chapter-range labels at the start of the range", async () => { book = { ...book, genre: "other", @@ -192,6 +729,11 @@ describe("PlannerAgent", () => { }; 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"), [ @@ -207,16 +749,76 @@ describe("PlannerAgent", () => { writeFile( join(storyDir, "volume_outline.md"), [ - "## Volume 1 - The Dead Examiner", - "**Chapter Range:** 1-12", + "# Volume Outline", "", - "**Key Turning Points:**", - "- Ch1: Renn dies after summoning Taryn to review irregular treaty folios.", + "Chapter 1-3", + "Keep the opening pressure on the first examiner.", "", - "### Golden First Three Chapters Rule", + "Chapter 4-6", + "Recover the sealed ledger before dawn.", "", - "**Chapter 2:**", - "Show Taryn's edge through action, not exposition. He uses registry numbering logic to identify which folios are decoys and which conceal a ledger fragment.", + ].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.goal).toContain("sealed ledger"); + expect(result.intent.outlineNode).not.toContain("6"); + expect(result.intent.goal).not.toContain("Describe the long-horizon vision"); + }); + + 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", @@ -233,13 +835,66 @@ describe("PlannerAgent", () => { const result = await planner.planChapter({ book, bookDir, - chapterNumber: 2, + chapterNumber: 4, }); - expect(result.intent.outlineNode).toContain("Show Taryn's edge through action"); - expect(result.intent.outlineNode).not.toBe("**"); - expect(result.intent.goal).toContain("Show Taryn's edge through action"); - expect(result.intent.goal).not.toBe("**"); + 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"), + "# 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.outlineNode).not.toBe("第1-6章"); + expect(result.intent.goal).not.toContain("merchant guild's escape route"); }); it("preserves hard facts from state and canon in mustKeep", async () => { @@ -386,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("| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |"); @@ -424,6 +1079,11 @@ describe("PlannerAgent", () => { ].join("\n"), "utf-8", ), + writeFile( + join(storyDir, "volume_outline.md"), + "# Volume Outline\n", + "utf-8", + ), ]); const planner = new PlannerAgent({ @@ -436,7 +1096,7 @@ describe("PlannerAgent", () => { const result = await planner.planChapter({ book, bookDir, - chapterNumber: 3, + chapterNumber: 2, }); expect(result.intent.goal).toContain("private confrontation"); @@ -543,13 +1203,14 @@ 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"); + expect(result.intent.hookAgenda.pressureMap).toEqual([]); const intentMarkdown = await readFile(result.runtimePath, "utf-8"); expect(intentMarkdown).toContain("## Hook Agenda"); - expect(intentMarkdown).toContain("recent-route"); expect(intentMarkdown).toContain("ready-payoff"); expect(intentMarkdown).toContain("stale-debt"); }); @@ -660,7 +1321,88 @@ 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", + ])); + 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/__tests__/post-write-validator.test.ts b/packages/core/src/__tests__/post-write-validator.test.ts index 8e82d11c..812852f5 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,33 @@ 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)"); + }); + + 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/__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__/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/__tests__/state-manager.test.ts b/packages/core/src/__tests__/state-manager.test.ts index 4cebef14..1c40f401 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"); @@ -963,4 +1111,143 @@ 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"); + }); + + 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/__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")); }); diff --git a/packages/core/src/__tests__/writer.test.ts b/packages/core/src/__tests__/writer.test.ts index d2d54de0..b1ad8e42 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"); @@ -238,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"), @@ -377,13 +390,169 @@ 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"); + 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"), + 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"); 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"), @@ -764,4 +933,295 @@ 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 }); + } + }); + + 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/architect.ts b/packages/core/src/agents/architect.ts index 22a36a5a..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 ? `- 有明确的数值/资源体系可追踪 @@ -195,18 +200,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" @@ -229,7 +236,7 @@ ${eraBlock} 4. 伏笔前后呼应,不留悬空线 5. 配角有独立动机,不是工具人`; - const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新的${gp.name}小说生成完整的基础设定。${contextBlock} + const systemPrompt = `你是一个专业的网络小说架构师。你的任务是为一本新的${gp.name}小说生成完整的基础设定。${contextBlock}${reviewFeedbackBlock} 要求: - 平台:${book.platform} @@ -489,9 +496,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 @@ -579,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: "剧情发生在原作空白期或未详述的角度。不可改变原作已确立的事实。", @@ -595,6 +604,15 @@ ${keyPrinciplesPrompt}`; ## 同人模式:${fanficMode} ${MODE_INSTRUCTIONS[fanficMode]} +## 新时空要求(关键) +你必须为这本同人设计一个**原创的叙事空间**,而不是复述原作剧情。具体要求: +1. **明确分岔点**:story_bible 必须标注"本作从原作的哪个节点分岔",或"本作发生在原作未涉及的什么时空" +2. **独立核心冲突**:volume_outline 的核心冲突必须是原创的,不是原作情节的翻版。原作角色可以出现,但他们面对的是新问题 +3. **5章内引爆**:volume_outline 的第1卷必须在前5章内建立核心悬念,不允许用3章做铺垫才到引爆点 +4. **场景新鲜度**:至少50%的关键场景发生在原作未出现的地点或情境中 + +${reviewFeedbackBlock} + ## 原作正典 ${fanficCanon} @@ -604,8 +622,8 @@ ${genreBody} ## 关键原则 1. **不发明主要角色** — 主要角色必须来自原作正典的角色档案 2. 可以添加原创配角,但必须在 story_bible 中标注为"原创角色" -3. story_bible 保留原作世界观,标注同人的改动/扩展部分 -4. volume_outline 以原作事件为锚点,标注哪些是原作事件、哪些是同人原创 +3. story_bible 保留原作世界观,标注同人的改动/扩展部分,并明确写出**分岔点**和**新时空设定** +4. volume_outline 不得复述原作剧情节拍。每卷的核心事件必须是原创的,标注"原创" 5. book_rules 的 fanficMode 必须设为 "${fanficMode}" 6. 主角设定来自原作角色档案中的第一个角色(或用户在标题中暗示的角色) @@ -653,13 +671,42 @@ 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; + 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 +725,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) => { @@ -727,7 +783,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 e7fce50e..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"; @@ -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; @@ -40,7 +43,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, @@ -100,9 +107,18 @@ 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( + storyDir, + "audit_drift.md", + "Carry forward audit drift guidance from the previous chapter without polluting hard state facts.", + ), this.maybeContextSource( storyDir, "current_state.md", @@ -121,7 +137,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({ @@ -131,6 +158,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}`, @@ -147,7 +180,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(" | "), })); @@ -159,6 +192,8 @@ export class ComposerAgent extends BaseAgent { return [ ...entries.filter((entry): entry is NonNullable => entry !== null), + ...trailEntries, + ...hookDebtEntries, ...factEntries, ...summaryEntries, ...volumeSummaryEntries, @@ -166,6 +201,168 @@ 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, + }); + } + + 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, + 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.pressureMap.map((entry) => entry.hookId), + ...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 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 && latestSummary !== seedSummary + ? this.renderHookDebtBeat(latestSummary) + : 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 with original seed text for this hook agenda target." + : "含原始种子文本的叙事债务简报。", + excerpt: language === "en" + ? [ + `${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(" | "), + }]; + }); + } + private async maybeContextSource( storyDir: string, fileName: string, @@ -210,4 +407,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-ready debt" : "可兑现旧债"; + } + if (plan.intent.hookAgenda.staleDebt.includes(hookId)) { + return language === "en" ? "high-pressure debt" : "高压旧债"; + } + return language === "en" ? "mainline debt" : "主要旧债"; + } + + 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/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/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/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/agents/planner.ts b/packages/core/src/agents/planner.ts index b1c152ee..e41870f4 100644 --- a/packages/core/src/agents/planner.ts +++ b/packages/core/src/agents/planner.ts @@ -5,11 +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; @@ -40,6 +42,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 +52,7 @@ export class PlannerAgent extends BaseAgent { currentFocus, storyBible, volumeOutline, + chapterSummaries, bookRulesRaw, currentState, ] = await Promise.all([ @@ -56,17 +60,19 @@ 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); 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, @@ -75,15 +81,29 @@ 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, + targetChapters: input.book.targetChapters, + language: input.book.language ?? "zh", + }); + 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, @@ -94,8 +114,10 @@ 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"), + activeHookCount, ); await writeFile(runtimePath, intentMarkdown, "utf-8"); @@ -105,13 +127,47 @@ 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) => 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( + input.language, + input.volumeOutline, + input.outlineNode, + input.matchedOutlineAnchor, + ), + sceneDirective: this.buildSceneDirective(input.language, cadence), + moodDirective: this.buildMoodDirective(input.language, cadence), + titleDirective: this.buildTitleDirective(input.language, cadence), + }; + } + private deriveGoal( externalContext: string | undefined, currentFocus: string, @@ -121,10 +177,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 +227,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 +297,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", @@ -235,6 +331,76 @@ 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, + cadence: ReturnType, + ): string | undefined { + if (cadence.scenePressure?.pressure !== "high") { + return undefined; + } + 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.`; + } + + private buildMoodDirective( + language: string | undefined, + cadence: ReturnType, + ): string | undefined { + 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 buildTitleDirective( + language: string | undefined, + cadence: ReturnType, + ): string | undefined { + 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 renderHookBudget(activeCount: number, language: "zh" | "en"): string { + 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"); @@ -299,20 +465,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 chapterPatterns = [ - new RegExp(`^#+\\s*Chapter\\s*${chapterNumber}\\b`, "i"), - new RegExp(`^#+\\s*第\\s*${chapterNumber}\\s*章`), - ]; - const inlinePatterns = [ - 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 - .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,12 +482,51 @@ export class PlannerAgent extends BaseAgent { } } - const heading = lines.find((line) => chapterPatterns.some((pattern) => pattern.test(line))); - if (!heading) return this.extractFirstDirective(volumeOutline); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + const match = this.matchRangeOutlineLine(line, chapterNumber); + if (!match) continue; - const headingIndex = lines.indexOf(heading); - const nextLine = lines[headingIndex + 1]; - return nextLine && !nextLine.startsWith("#") ? nextLine : heading.replace(/^#+\s*/, ""); + const inlineContent = this.cleanOutlineContent(match[3]); + if (inlineContent) { + return inlineContent; + } + + const nextContent = this.findNextOutlineContent(lines, index + 1); + if (nextContent) { + return nextContent; + } + } + + 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); } private cleanOutlineContent(content?: string): string | undefined { @@ -344,17 +539,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; @@ -364,6 +560,71 @@ 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"), + new RegExp(`^(?:#+\\s*)?(?:[-*]\\s+)?(?:\\*\\*)?第\\s*${chapterNumber}\\s*章(?!\\d|\\s*[-~–—]\\s*\\d)(?:[::-])?(?:\\*\\*)?\\s*(.*)$`), + ]; + + 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 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 { + 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; @@ -379,8 +640,10 @@ export class PlannerAgent extends BaseAgent { private renderIntentMarkdown( intent: ChapterIntent, + 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") @@ -397,6 +660,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 @@ -417,6 +686,8 @@ export class PlannerAgent extends BaseAgent { intent.hookAgenda.avoidNewHookFamilies.length > 0 ? intent.hookAgenda.avoidNewHookFamilies.map((item) => `- ${item}`).join("\n") : "- none", + "", + this.renderHookBudget(activeHookCount, language), ].join("\n"); return [ @@ -437,6 +708,9 @@ export class PlannerAgent extends BaseAgent { "## Style Emphasis", styleEmphasis, "", + "## Structured Directives", + directives, + "", "## Hook Agenda", hookAgenda, "", @@ -456,6 +730,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/agents/post-write-validator.ts b/packages/core/src/agents/post-write-validator.ts index 3573d912..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"; @@ -561,6 +562,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 +625,9 @@ export function resolveDuplicateTitle( newTitle: string, existingTitles: ReadonlyArray, language: "zh" | "en" = "zh", + options?: { + readonly content?: string; + }, ): { readonly title: string; readonly issues: ReadonlyArray; @@ -618,21 +637,200 @@ 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: [] }; } - let counter = 2; - while (counter < 100) { - const candidate = language === "en" - ? `${trimmed} (${counter})` - : `${trimmed}(${counter})`; - if (detectDuplicateTitle(candidate, existingTitles).length === 0) { - return { title: candidate, 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 }; + } + + 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 []; + } + + 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( + 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 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, + 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); + } } - counter++; } - return { title: trimmed, issues }; + 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/settler-prompts.ts b/packages/core/src/agents/settler-prompts.ts index 2f1c62d5..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": "本章为什么会形成新的未解问题" } ], @@ -171,6 +174,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 +206,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-prompts.ts b/packages/core/src/agents/writer-prompts.ts index ddf82db0..53ed3161 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, 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. - 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 简报,里面包含每个伏笔种下时的**原始文本片段**。用这些原文来写延续或兑现场景——不是模糊地提一嘴,而是接着读者已经看到的具体承诺来写。 +- 如果显式 hook agenda 里出现了可回收目标,本章必须写出具体兑现片段,回答种子章节中读者的原始疑问。 +- 如果存在 stale debt,先消化旧承诺的压力,再决定是否开新坑;同类 sibling hook 不得随手再开。 - 多角色场景里,至少给出一轮带阻力的直接交锋,不要把人物关系写成纯解释或纯总结。`; } @@ -525,7 +531,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 ? "/战力崩坏" : ""}/节奏/词汇疲劳 | |`; @@ -535,7 +541,7 @@ ${resourceRow}| 待回收伏笔 | Hook-A / Hook-B | 与伏笔池一致 | ${preWriteTable} === CHAPTER_TITLE === -(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题) +(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题;若提供了 recent title history 或高频标题词,必须主动避开重复词根和高频意象) === CHAPTER_CONTENT === (正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字) @@ -560,7 +566,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 ? "/战力崩坏" : ""}/节奏/词汇疲劳 | |`; @@ -588,7 +594,7 @@ ${resourceRow}| 待回收伏笔 | Hook-A / Hook-B | 与伏笔池一致 | ${preWriteTable} === CHAPTER_TITLE === -(章节标题,不含"第X章"。标题必须与已有章节标题不同,不要重复使用相同或相似的标题) +(章节标题,不含"第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 5faf8c0c..caaeb986 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; @@ -147,7 +159,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 +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 @@ -289,17 +302,12 @@ 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, ruleStack: input.ruleStack, + validationFeedback: undefined, originalHooks: hooks, originalSubplots: subplotBoard, originalEmotionalArcs: emotionalArcs, @@ -311,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)); @@ -319,6 +328,7 @@ export class WriterAgent extends BaseAgent { ? analyzeHookHealth({ language: resolvedLanguage, chapterNumber, + targetChapters: book.targetChapters, hooks: (runtimeStateArtifacts?.snapshot ?? settlement.runtimeStateSnapshot)!.hooks.hooks, delta: resolvedRuntimeStateDelta, existingHookIds: [...priorHookIds], @@ -397,6 +407,98 @@ 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, + input.chapterNumber, + ); + + 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; @@ -416,6 +518,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; @@ -477,6 +580,7 @@ export class WriterAgent extends BaseAgent { observations, selectedEvidenceBlock: params.selectedEvidenceBlock, governedControlBlock, + validationFeedback: params.validationFeedback, }); // Settler outputs all truth files — scale with content size @@ -708,6 +812,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 +839,15 @@ ${lengthRequirementBlock} 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## Explicit Hook Agenda\n${explicitHookAgenda}\n` + : `\n## 显式 Hook Agenda\n${explicitHookAgenda}\n` + : ""; if (params.language === "en") { return `Write chapter ${params.chapterNumber}. @@ -743,6 +857,8 @@ ${params.chapterIntent} ## Selected Context ${contextSections || "(none)"} +${selectedEvidenceBlock} +${hookAgendaBlock} ## Rule Stack - Hard: ${params.ruleStack.sections.hard.join(", ") || "(none)"} @@ -768,6 +884,8 @@ ${params.chapterIntent} ## 已选上下文 ${contextSections || "(无)"} +${selectedEvidenceBlock} +${hookAgendaBlock} ## 规则栈 - 硬护栏:${params.ruleStack.sections.hard.join("、") || "(无)"} @@ -786,6 +904,49 @@ ${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.hookDebtBlock, + blocks.hooksBlock, + blocks.summariesBlock, + blocks.volumeSummariesBlock, + ] + .filter((block): block is string => Boolean(block)) + .join("\n"); + + 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, @@ -935,15 +1096,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 +1169,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 +1191,7 @@ ${overrides}\n`; return buildRuntimeStateArtifacts({ bookDir, - delta: output.runtimeStateDelta, + delta: safeDelta, language, }); } 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/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/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/models/input-governance.ts b/packages/core/src/models/input-governance.ts index 180d1c9c..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([]), @@ -21,11 +61,16 @@ 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([]), conflicts: z.array(ChapterConflictSchema).default([]), hookAgenda: HookAgendaSchema.default({ + pressureMap: [], mustAdvance: [], eligibleResolve: [], staleDebt: [], 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), }); 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/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/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/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/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/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 48910617..0fa3ddca 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"; @@ -13,7 +14,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"; @@ -27,13 +28,35 @@ 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 { + 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"; + +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; @@ -61,7 +84,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; @@ -124,6 +147,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 }>; @@ -194,6 +225,121 @@ 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 async generateAndReviewFoundation(params: { + readonly generate: (reviewFeedback?: string) => 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...`, + }); + + foundation = await params.generate(this.buildFoundationReviewFeedback(review, params.language)); + } + + // 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 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, @@ -298,7 +444,19 @@ 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: (reviewFeedback) => architect.generateFoundation( + book, + this.config.externalContext, + reviewFeedback, + ), + reviewer, + mode: "original", + language: resolvedLanguage, + stageLanguage, + }); try { this.logStage(stageLanguage, { zh: "保存书籍配置", en: "saving book config" }); await this.state.saveBookConfigAt(stagingBookDir, book); @@ -373,11 +531,25 @@ 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: (reviewFeedback) => architect.generateFanficFoundation( + book, + fanficCanon, + fanficMode, + reviewFeedback, + ), + reviewer, + mode: "fanfic", + sourceCanon: fanficCanon, + language: resolvedLanguage, + stageLanguage, + }); this.logStage(stageLanguage, { zh: "写入基础设定文件", en: "writing foundation files" }); await architect.writeFoundationFiles( bookDir, @@ -388,7 +560,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.tryGenerateStyleGuide(book.id, sourceText, sourceName, stageLanguage); + } + + // 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, []); @@ -544,7 +722,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}`), }; @@ -568,12 +746,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), }; } @@ -617,6 +795,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", @@ -853,6 +1040,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, { @@ -940,10 +1138,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" }); @@ -983,148 +1191,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" }); @@ -1135,6 +1235,7 @@ export class PipelineRunner { output.title, chapterIndexBeforePersist.map((chapter) => chapter.title), pipelineLang, + { content: finalContent }, ); let persistenceOutput = await this.buildPersistenceOutput( bookId, @@ -1152,6 +1253,7 @@ export class PipelineRunner { persistenceOutput.title, chapterIndexBeforePersist.map((chapter) => chapter.title), pipelineLang, + { content: finalContent }, ); if (finalTitleResolution.title !== persistenceOutput.title) { persistenceOutput = { @@ -1161,8 +1263,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, @@ -1200,7 +1302,7 @@ export class PipelineRunner { const lengthTelemetry = this.buildLengthTelemetry({ lengthSpec, writerCount, - postWriterNormalizeCount: normalizedBeforeAudit.wordCount, + postWriterNormalizeCount: reviewResult.preAuditNormalizedWordCount, postReviseCount, finalCount: finalWordCount, normalizeApplied, @@ -1211,36 +1313,36 @@ 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; - 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 reason = validation.warnings[0]?.description ?? "validator reported contradictions"; - throw new Error(`State validation failed for chapter ${chapterNumber}: ${reason}`); - } + const truthValidation = await validateChapterTruthPersistence({ + writer, + validator, + book, + bookDir, + chapterNumber, + title: persistenceOutput.title, + content: finalContent, + persistenceOutput, + auditResult, + previousTruth: { + oldState, + oldHooks, + 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) { @@ -1276,85 +1378,53 @@ 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); - - // 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: 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, lengthTelemetry, + degradedIssues, tokenUsage: totalUsage, - }; - 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 - const driftIssues = auditResult.issues.filter( - (i) => i.severity === "critical" || i.severity === "warning", - ); - if (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 - } - } - - // 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); + 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 = auditResult.passed ? "✅" : "⚠️"; + const statusEmoji = resolvedStatus === "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 ? "通过" : "需人工审核"}`, + resolvedStatus === "state-degraded" + ? "状态结算: 已降级保存,需先修复 state 再继续" + : `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`, ...auditResult.issues .filter((i) => i.severity !== "info") .map((i) => `- [${i.severity}] ${i.description}`), @@ -1369,6 +1439,7 @@ export class PipelineRunner { wordCount: finalWordCount, passed: auditResult.passed, revised, + status: resolvedStatus, }); return { @@ -1377,13 +1448,132 @@ export class PipelineRunner { wordCount: finalWordCount, auditResult, revised, - status: auditResult.passed ? "ready-for-review" : "audit-failed", + status: resolvedStatus, 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 retrySettlementAfterValidationFailure({ + writer, + validator, + book, + bookDir, + chapterNumber: targetChapter, + title: targetMeta.title, + content, + oldState, + oldHooks, + originalValidation: validation, + language: pipelineLang, + logWarn: (message) => this.logWarn(pipelineLang, message), + logger: this.config.logger, + }); + 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 = resolveStateDegradedBaseStatus(targetMeta); + const degradedMetadata = 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) // --------------------------------------------------------------------------- @@ -1579,9 +1769,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.tryGenerateStyleGuide(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) // --------------------------------------------------------------------------- @@ -1628,6 +1847,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.tryGenerateStyleGuide(input.bookId, allText, book.title, resolvedLanguage); + } + log?.info(this.localize(resolvedLanguage, { zh: "基础设定已生成。", en: "Foundation generated.", @@ -1786,6 +2015,18 @@ ${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.`, + ); + } + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -2251,6 +2492,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); @@ -2275,19 +2582,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 +2600,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 +2624,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 +2647,14 @@ ${matrix}`, ...sensitiveResult.issues, ...longSpanFatigue.issues, ]; + // revisionBlockingIssues excludes long-span-fatigue issues by + // construction (not by category name) so that an LLM-reported issue + // sharing a category label with a long-span issue is still counted. + const revisionBlockingIssues: ReadonlyArray = [ + ...llmAudit.issues, + ...aiTells.issues, + ...sensitiveResult.issues, + ]; return { auditResult: { @@ -2356,8 +2664,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, }; } @@ -2410,7 +2719,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; } @@ -2423,100 +2732,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, diff --git a/packages/core/src/state/manager.ts b/packages/core/src/state/manager.ts index 702f3ef5..783775b7 100644 --- a/packages/core/src/state/manager.ts +++ b/packages/core/src/state/manager.ts @@ -205,19 +205,17 @@ 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({ + // 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: indexedChapter, + fallbackChapter: durableChapter, }); - return Math.max(indexedChapter, durableChapter, runtimeState.manifest.lastAppliedChapter) + 1; + return durableChapter + 1; } async getPersistedChapterCount(bookId: string): Promise { @@ -393,6 +391,110 @@ 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 + } + + // 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; + } + private async writeIfMissing(path: string, content: string): Promise { try { await stat(path); 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..2c5697d2 100644 --- a/packages/core/src/state/state-bootstrap.ts +++ b/packages/core/src/state/state-bootstrap.ts @@ -10,7 +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; @@ -71,12 +88,10 @@ export async function bootstrapStructuredStateFromMarkdown(params: { warnings, bootstrapState: markdownState.currentState, }); - const derivedProgress = Math.max( - markdownState.durableStoryProgress, - currentState.chapter, - maxSummaryChapter(summariesState), - maxHookChapter(hooksState.hooks), - ); + // 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, @@ -136,12 +151,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 ?? []), @@ -163,64 +173,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) => ({ - 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, - })); -} - -export function parseCurrentStateFacts( - markdown: string, - fallbackChapter: number, -): Fact[] { - return parseCurrentStateStateMarkdown(markdown, fallbackChapter, []).facts; -} - async function loadOrBootstrapCurrentState(params: { readonly storyDir: string; readonly statePath: string; @@ -333,14 +285,16 @@ 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`), + 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] ?? "", + payoffTiming: legacyShape ? undefined : normalizeHookPayoffTiming(row[6]), + notes: legacyShape ? (row[6] ?? "") : (row[7] ?? ""), }; }), }); @@ -360,6 +314,7 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) { status: "open" as HookStatus, lastAdvancedChapter: 0, expectedPayoff: "", + payoffTiming: undefined, notes: line, })), }); @@ -439,14 +394,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 +428,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 +441,7 @@ async function loadMarkdownBootstrapState(params: { summariesState, hooksState, currentState, - durableStoryProgress: Math.max( - inferredFallbackChapter, - currentState.chapter, - ), + durableStoryProgress: authoritativeProgress, }; } @@ -527,28 +470,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,35 +514,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), ); -} - -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(); + let contiguousChapter = 0; + while (chapters.has(contiguousChapter + 1)) { + contiguousChapter += 1; } - return normalized; + return contiguousChapter; } function normalizeHookStatus(value: string | undefined, warnings: string[], hookId: string): HookStatus { @@ -610,14 +536,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( @@ -635,41 +561,22 @@ 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 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 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 normalizeExplicitChapter(value: number | undefined): number { + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { + return 0; + } + return value; } -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)) { 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/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 new file mode 100644 index 00000000..ae34a3f2 --- /dev/null +++ b/packages/core/src/utils/chapter-cadence.ts @@ -0,0 +1,209 @@ +import { + CADENCE_PRESSURE_THRESHOLDS, + CADENCE_WINDOW_DEFAULTS, + resolveCadencePressure, +} from "./cadence-policy.js"; + +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; +} + +export const DEFAULT_CHAPTER_CADENCE_WINDOW = CADENCE_WINDOW_DEFAULTS.summaryLookback; + +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(-CADENCE_WINDOW_DEFAULTS.summaryLookback); + + 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; + } + + 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; +} + +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; + } + + 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; +} + +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] >= CADENCE_PRESSURE_THRESHOLDS.title.minimumRepeatedCount); + if (!repeated) { + return undefined; + } + + const [repeatedToken, count] = repeated; + 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; +} + +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/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-context.ts b/packages/core/src/utils/governed-context.ts index 9990a946..38db1755 100644 --- a/packages/core/src/utils/governed-context.ts +++ b/packages/core/src/utils/governed-context.ts @@ -4,22 +4,45 @@ export function buildGovernedMemoryEvidenceBlocks( contextPackage: ContextPackage, language?: "zh" | "en", ): { + readonly hookDebtBlock?: string; 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) => 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#"), ); 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 { + 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" : "已选伏笔证据", @@ -38,9 +61,34 @@ 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, }; } +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/governed-working-set.ts b/packages/core/src/utils/governed-working-set.ts index 247076cd..98a6b2f1 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, parsePendingHooksMarkdown, renderHookSnapshot, } from "./memory-retrieval.js"; +import { + isHookWithinChapterWindow, +} from "./hook-agenda.js"; export function buildGovernedHookWorkingSet(params: { readonly hooksMarkdown: string; @@ -33,7 +35,11 @@ export function buildGovernedHookWorkingSet(params: { const workingSet = hooks.filter((hook) => selectedIds.has(hook.hookId) || agendaIds.has(hook.hookId) - || isHookWithinChapterWindow(hook, params.chapterNumber, params.keepRecent ?? 5), + || 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 new file mode 100644 index 00000000..1d074bbb --- /dev/null +++ b/packages/core/src/utils/hook-agenda.ts @@ -0,0 +1,134 @@ +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 { 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; + 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"); + + // mustAdvance: stalest first (lowest lastAdvancedChapter) + const mustAdvanceHooks = agendaHooks + .slice() + .sort((left, right) => ( + 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) => ( + 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, 3); + + return { + pressureMap: [], + mustAdvance: mustAdvanceHooks.map((hook) => hook.hookId), + eligibleResolve: eligibleResolveHooks.map((hook) => hook.hookId), + staleDebt: staleDebtHooks.map((hook) => hook.hookId), + avoidNewHookFamilies, + }; +} + +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/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-health.ts b/packages/core/src/utils/hook-health.ts index 55cdb3be..1fe3924d 100644 --- a/packages/core/src/utils/hook-health.ts +++ b/packages/core/src/utils/hook-health.ts @@ -1,10 +1,13 @@ 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"; readonly chapterNumber: number; + readonly targetChapters?: number; readonly hooks: ReadonlyArray; readonly delta?: Pick; readonly existingHookIds?: ReadonlyArray; @@ -13,13 +16,26 @@ 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"); + 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 +49,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 +128,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, diff --git a/packages/core/src/utils/hook-lifecycle.ts b/packages/core/src/utils/hook-lifecycle.ts new file mode 100644 index 00000000..e8b8dba9 --- /dev/null +++ b/packages/core/src/utils/hook-lifecycle.ts @@ -0,0 +1,175 @@ +import type { HookPayoffTiming } from "../models/runtime-state.js"; +import { + HOOK_ACTIVITY_THRESHOLDS, + 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: { + 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 = 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 = HOOK_PHASE_WEIGHT[phase] >= HOOK_PHASE_WEIGHT[profile.minimumPhase]; + const recentlyTouched = dormancy <= HOOK_ACTIVITY_THRESHOLDS.recentlyTouchedDormancy; + 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 ? HOOK_PRESSURE_WEIGHTS.staleAdvanceBonus : 0) + + (overdue ? HOOK_PRESSURE_WEIGHTS.overdueAdvanceBonus : 0), + resolvePressure: readyToResolve + ? 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, + }; +} + +function resolveHookPhase(chapterNumber: number, targetChapters?: number): HookPhase { + if (targetChapters && targetChapters > 0) { + const progress = chapterNumber / targetChapters; + if (progress >= HOOK_PHASE_THRESHOLDS.lateProgress) return "late"; + if (progress >= HOOK_PHASE_THRESHOLDS.middleProgress) return "middle"; + return "opening"; + } + + 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..1582b850 --- /dev/null +++ b/packages/core/src/utils/hook-policy.ts @@ -0,0 +1,153 @@ +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_ACTIVITY_THRESHOLDS = { + recentlyTouchedDormancy: 1, + longArcQuietHoldMaxAge: 2, + longArcQuietHoldMaxDormancy: 1, + refreshDormancy: 2, + freshPromiseAge: 1, +} 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/long-span-fatigue.ts b/packages/core/src/utils/long-span-fatigue.ts index 5d88e49d..9e83c174 100644 --- a/packages/core/src/utils/long-span-fatigue.ts +++ b/packages/core/src/utils/long-span-fatigue.ts @@ -1,5 +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"; @@ -31,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; @@ -39,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; } @@ -48,12 +56,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(-CADENCE_WINDOW_DEFAULTS.summaryLookback); 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 +96,27 @@ export async function analyzeLongSpanFatigue( const recentRows = mergedRows .filter((row) => row.chapter <= input.chapterNumber) .sort((left, right) => left.chapter - right.chapter) - .slice(-3); + .slice(-CADENCE_WINDOW_DEFAULTS.summaryLookback); + 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 +207,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 +227,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, @@ -225,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 []; } @@ -246,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)) { @@ -255,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; } @@ -263,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; } @@ -352,15 +423,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) { diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index d86e803d..88d5e113 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -1,16 +1,37 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import type { HookAgenda } 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"; -import { collectStaleHookDebt } from "./hook-governance.js"; +import { bootstrapStructuredStateFromMarkdown } from "../state/state-bootstrap.js"; +import { + buildPlannerHookAgenda, + filterActiveHooks, + isFuturePlannedHook, + isHookWithinChapterWindow, +} from "./hook-agenda.js"; +import { + parseChapterSummariesMarkdown, + parseCurrentStateFacts, + parsePendingHooksMarkdown, + renderHookSnapshot, + renderSummarySnapshot, +} from "./story-markdown.js"; +export { + buildPlannerHookAgenda, + isFuturePlannedHook, + isHookWithinChapterWindow, +} from "./hook-agenda.js"; +export { + parseChapterSummariesMarkdown, + parseCurrentStateFacts, + parsePendingHooksMarkdown, + renderHookSnapshot, + renderSummarySnapshot, +} from "./story-markdown.js"; export interface MemorySelection { readonly summaries: ReadonlyArray; @@ -27,8 +48,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; @@ -152,112 +171,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 | notes |", - "| --- | --- | --- | --- | --- | --- | --- |", - ] - : [ - "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |", - "| --- | --- | --- | --- | --- | --- | --- |", - ]; - - return [ - ...headers, - ...hooks.map((hook) => [ - hook.hookId, - hook.startChapter, - hook.type, - hook.status, - hook.lastAdvancedChapter, - hook.expectedPayoff, - hook.notes, - ].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`), - ].join("\n"); -} - -export function buildPlannerHookAgenda(params: { - readonly hooks: ReadonlyArray; - readonly chapterNumber: number; - 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 mustAdvance = agendaHooks - .slice() - .sort((left, right) => ( - right.lastAdvancedChapter - left.lastAdvancedChapter - || left.startChapter - right.startChapter - || left.hookId.localeCompare(right.hookId) - )) - .slice(0, params.maxMustAdvance ?? 2) - .map((hook) => hook.hookId); - const staleDebt = collectStaleHookDebt({ - hooks: agendaHooks, - chapterNumber: params.chapterNumber, - }) - .slice(0, params.maxStaleDebt ?? 2) - .map((hook) => hook.hookId); - const eligibleResolve = agendaHooks - .filter((hook) => hook.startChapter <= params.chapterNumber - 3) - .filter((hook) => hook.lastAdvancedChapter >= params.chapterNumber - 2) - .sort((left, right) => ( - left.startChapter - right.startChapter - || right.lastAdvancedChapter - left.lastAdvancedChapter - || left.hookId.localeCompare(right.hookId) - )) - .slice(0, params.maxEligibleResolve ?? 1) - .map((hook) => hook.hookId); - - return { - mustAdvance, - eligibleResolve, - staleDebt, - avoidNewHookFamilies: [], - }; -} - function openMemoryDB(bookDir: string): MemoryDB | null { try { return new MemoryDB(bookDir); @@ -364,115 +277,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) => ({ - 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, - })); -} - -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 []; @@ -494,26 +298,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); } @@ -552,36 +336,34 @@ 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, ), })) - .filter((entry) => entry.matched || isUnresolvedHook(entry.hook.status)); + .filter((entry: { hook: StoredHook; score: number; matched: boolean }) => + 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) - )) + .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, 3); + .slice(0, 6); - const selectedIds = new Set(primary.map((entry) => entry.hook.hookId)); + const selectedIds = new Set(primary.map((entry: { hook: StoredHook; score: number; matched: boolean }) => entry.hook.hookId)); const stale = ranked - .filter((entry) => ( + .filter((entry: { hook: StoredHook; score: number; matched: boolean }) => !selectedIds.has(entry.hook.hookId) && !isFuturePlannedHook(entry.hook, chapterNumber) - && entry.hook.lastAdvancedChapter <= staleCutoff - && isUnresolvedHook(entry.hook.status) - )) + && isUnresolvedHook(entry.hook.status), + ) .sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score) - .slice(0, 1); + .slice(0, 2); - return [...primary, ...stale].map((entry) => entry.hook); + return [...primary, ...stale].map((entry: { hook: StoredHook; score: number; matched: boolean }) => entry.hook); } function selectRelevantFacts( @@ -664,71 +446,17 @@ 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; } -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, - 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)); } @@ -737,16 +465,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..2e80ca07 --- /dev/null +++ b/packages/core/src/utils/story-markdown.ts @@ -0,0 +1,247 @@ +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; +} + +/** + * 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 = ""; + 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: parseStrictChapterInteger(row[1]), + type: row[2] ?? "", + status: row[3] ?? "open", + lastAdvancedChapter: parseStrictChapterInteger(row[4]), + expectedPayoff: row[5] ?? "", + payoffTiming, + notes, + }; +} + +function escapeTableCell(value: string | number): string { + return String(value).replace(/\|/g, "\\|").trim(); +} 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", 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); 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 }; }