ソースを参照

feat(v13): planner produces prose chapter_memo per new.txt methodology [phase 3/8 part 2]

Replace the deterministic stub planner with a real LLM call that produces
a YAML frontmatter + markdown body memo. The 7 body sections follow the
new.txt methodology:
- 当前任务
- 读者此刻在等什么
- 该兑现的 / 暂不掀的
- 日常/过渡承担什么任务
- 关键抉择过三连问
- 章尾必须发生的改变
- 不要做

Key design choices:
- YAML frontmatter + markdown body (NOT JSON wrapping a markdown string)
  — easier for mid-size models, natural format for LLMs, human-readable
- Parser is strict on core fields (chapter integer + matches expected,
  goal ≤ 50 chars, all seven section headings present) and lenient on aux
  fields (threadRefs coerced to string[]). NEVER silently truncates or
  renames — the v12 normalizeBriefJson lesson is not repeated.
- isGoldenOpening is caller-authoritative: if the LLM frontmatter claims
  true when the caller computed false, the caller's value wins. Golden
  opening is chapter ≤ 3 for zh, ≤ 5 for en.
- 3 retries with error feedback injected into the user message. On the
  third failure we throw PlannerParseError with the underlying cause.
- current_arc_prose is temporarily composed from subplot_board.md active
  rows + emotional_arcs.md recent rows (Phase 8 will replace this source
  with a dedicated tier2_current_arc.md file).
- book_rules_relevant currently passes the full book_rules.md — filtering
  is a future optimization.

New modules:
- agents/planner-prompts.ts — PLANNER_MEMO_SYSTEM_PROMPT (verbatim
  Chinese methodology prompt) + user message template + builder.
- agents/planner-context.ts — helpers that slice rows out of
  character_matrix / subplot_board / emotional_arcs / pending_hooks.
- utils/chapter-memo-parser.ts — parseMemo + PlannerParseError. Splits
  frontmatter from body, validates core fields, enforces required
  section headings, returns a Zod-validated ChapterMemo.

index.ts re-exports the new prompt symbols and drops the legacy
buildPlannerSystemPrompt / buildPlannerUserPrompt exports.
Ma 6 ヶ月 前
親
コミット
b70ca60b54

+ 187 - 0
packages/core/src/agents/planner-context.ts

@@ -0,0 +1,187 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { parseMarkdownTableRows } from "../utils/story-markdown.js";
+
+async function readOrEmpty(path: string): Promise<string> {
+  try {
+    return await readFile(path, "utf-8");
+  } catch {
+    return "";
+  }
+}
+
+export async function readCharacterMatrix(storyDir: string): Promise<string> {
+  return readOrEmpty(join(storyDir, "character_matrix.md"));
+}
+
+export async function readSubplotBoard(storyDir: string): Promise<string> {
+  return readOrEmpty(join(storyDir, "subplot_board.md"));
+}
+
+export async function readEmotionalArcs(storyDir: string): Promise<string> {
+  return readOrEmpty(join(storyDir, "emotional_arcs.md"));
+}
+
+export async function readPendingHooks(storyDir: string): Promise<string> {
+  return readOrEmpty(join(storyDir, "pending_hooks.md"));
+}
+
+export async function readBookRules(storyDir: string): Promise<string> {
+  return readOrEmpty(join(storyDir, "book_rules.md"));
+}
+
+/**
+ * Grab the last N row(s) from chapter_summaries.md formatted as markdown
+ * table. Returns original table slice (with header) so the planner gets
+ * column meaning implicitly.
+ */
+export function formatRecentSummaries(
+  chapterSummariesRaw: string,
+  chapterNumber: number,
+  limit: number,
+): string {
+  const rows = parseMarkdownTableRows(chapterSummariesRaw)
+    .filter((row) => /^\d+$/.test(row[0] ?? ""))
+    .filter((row) => parseInt(row[0]!, 10) < chapterNumber)
+    .sort((a, b) => parseInt(a[0]!, 10) - parseInt(b[0]!, 10));
+
+  const recent = rows.slice(-limit);
+  if (recent.length === 0) {
+    return "(暂无前章摘要)";
+  }
+
+  const header = "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |";
+  const divider = "| --- | --- | --- | --- | --- | --- | --- | --- |";
+  const body = recent.map((row) => `| ${row.join(" | ")} |`).join("\n");
+  return [header, divider, body].join("\n");
+}
+
+/**
+ * Option A: temporarily compose current_arc prose from subplot_board.md
+ * active rows + emotional_arcs.md recent rows. Phase 8 will replace this
+ * source with a dedicated tier2_current_arc.md file.
+ */
+export function composeCurrentArcProse(
+  subplotBoardRaw: string,
+  emotionalArcsRaw: string,
+  chapterNumber: number,
+): string {
+  const activeSubplots = extractActiveSubplotLines(subplotBoardRaw);
+  const recentArcs = extractRecentEmotionalArcLines(emotionalArcsRaw, chapterNumber, 3);
+
+  const parts: string[] = [];
+  if (activeSubplots.length > 0) {
+    parts.push("活跃支线:\n" + activeSubplots.map((line) => `- ${line}`).join("\n"));
+  }
+  if (recentArcs.length > 0) {
+    parts.push("近期情感线:\n" + recentArcs.map((line) => `- ${line}`).join("\n"));
+  }
+  if (parts.length === 0) {
+    return "(暂无 arc 数据——可能是新书起始阶段)";
+  }
+  return parts.join("\n\n");
+}
+
+function extractActiveSubplotLines(raw: string): string[] {
+  const rows = parseMarkdownTableRows(raw);
+  if (rows.length === 0) {
+    return raw
+      .split("\n")
+      .map((line) => line.trim())
+      .filter((line) => line.startsWith("-"))
+      .map((line) => line.replace(/^-\s*/, ""))
+      .filter(Boolean)
+      .slice(0, 6);
+  }
+  return rows
+    .filter((row) => !/^(id|subplot_id|subplot|status|状态)$/i.test(row[0] ?? ""))
+    .filter((row) => {
+      const status = (row.find((cell) => /进行|推进|高压|激活|activ|progress|partial/i.test(cell)) ?? "");
+      const dormant = row.find((cell) => /暂稳待续|暂挂|dormant|paused/i.test(cell));
+      return Boolean(status) && !dormant;
+    })
+    .map((row) => row.filter(Boolean).join(" | "))
+    .slice(0, 6);
+}
+
+function extractRecentEmotionalArcLines(raw: string, chapterNumber: number, limit: number): string[] {
+  const rows = parseMarkdownTableRows(raw);
+  if (rows.length === 0) {
+    return raw
+      .split("\n")
+      .map((line) => line.trim())
+      .filter((line) => line.startsWith("-"))
+      .slice(-limit)
+      .map((line) => line.replace(/^-\s*/, ""));
+  }
+  return rows
+    .filter((row) => /^\d+$/.test(row[0] ?? ""))
+    .filter((row) => parseInt(row[0]!, 10) < chapterNumber)
+    .slice(-limit)
+    .map((row) => row.filter(Boolean).join(" | "));
+}
+
+/**
+ * Extract the protagonist row from character_matrix.md. Protagonist is
+ * detected by relation column containing "主角" or "protagonist".
+ */
+export function extractProtagonistRow(characterMatrixRaw: string): string {
+  const rows = parseMarkdownTableRows(characterMatrixRaw);
+  const protagonist = rows.find((row) =>
+    row.some((cell) => /^(主角|protagonist)$/i.test(cell.trim())),
+  );
+  if (protagonist) {
+    return `| ${protagonist.join(" | ")} |`;
+  }
+  return "(未找到主角行——请检查 character_matrix.md)";
+}
+
+const OPPONENT_PATTERNS = /敌对|对手|阻力|opponent|antagonist|foe/i;
+const COLLABORATOR_PATTERNS = /协力|盟友|临时助力|ally|collaborator|mentor/i;
+
+export function extractOpponentRows(characterMatrixRaw: string, limit: number): string {
+  return extractRowsByRelation(characterMatrixRaw, OPPONENT_PATTERNS, limit, "(暂无明确对手登场)");
+}
+
+export function extractCollaboratorRows(characterMatrixRaw: string, limit: number): string {
+  return extractRowsByRelation(characterMatrixRaw, COLLABORATOR_PATTERNS, limit, "(暂无明确协作者登场)");
+}
+
+function extractRowsByRelation(
+  characterMatrixRaw: string,
+  pattern: RegExp,
+  limit: number,
+  emptyText: string,
+): string {
+  const rows = parseMarkdownTableRows(characterMatrixRaw)
+    .filter((row) => row.some((cell) => pattern.test(cell)))
+    .filter((row) => !row.some((cell) => /^(主角|protagonist)$/i.test(cell.trim())))
+    .slice(0, limit);
+  if (rows.length === 0) {
+    return emptyText;
+  }
+  return rows.map((row) => `| ${row.join(" | ")} |`).join("\n");
+}
+
+const RELEVANT_THREAD_STATUS_PATTERN = /activat|partial_payoff|推进|高压|open|progress/i;
+const STALE_STATUS_PATTERN = /resolved|deferred|dormant|暂稳待续|暂挂|已回收/i;
+
+export function extractRelevantThreads(pendingHooksRaw: string, subplotBoardRaw: string): string {
+  const hookRows = parseMarkdownTableRows(pendingHooksRaw)
+    .filter((row) => !/^(hook_id)$/i.test(row[0] ?? ""))
+    .filter((row) => row.some((cell) => RELEVANT_THREAD_STATUS_PATTERN.test(cell)))
+    .filter((row) => !row.some((cell) => STALE_STATUS_PATTERN.test(cell)))
+    .map((row) => `- ${row[0]}: ${row.slice(1).filter(Boolean).join(" | ")}`);
+
+  const subplotRows = parseMarkdownTableRows(subplotBoardRaw)
+    .filter((row) => !/^(id|subplot_id|subplot)$/i.test(row[0] ?? ""))
+    .filter((row) => row.some((cell) => RELEVANT_THREAD_STATUS_PATTERN.test(cell)))
+    .filter((row) => !row.some((cell) => STALE_STATUS_PATTERN.test(cell)))
+    .map((row) => `- ${row[0]}: ${row.slice(1).filter(Boolean).join(" | ")}`);
+
+  const lines = [...hookRows, ...subplotRows];
+  if (lines.length === 0) {
+    return "(暂无活跃线索)";
+  }
+  return lines.join("\n");
+}

+ 130 - 46
packages/core/src/agents/planner-prompts.ts

@@ -1,52 +1,136 @@
-import type { PlanningMaterials } from "../utils/planning-materials.js";
-
-export interface PlannerPromptInput {
-  readonly chapterNumber: number;
-  readonly targetChapters: number;
-  readonly genreName: string;
-  readonly language: "zh" | "en";
-  readonly materials: PlanningMaterials;
-}
-
 /**
- * Phase 1 transitional planner prompts.
+ * Planner prompts for Phase 3 (new.txt methodology).
  *
- * The previous prompts instructed the LLM to output ChapterBrief JSON with
- * beat outlines, cycle phases, and hook agendas. Phase 3 (new.txt) will
- * replace these with a seven-section memo prompt. For now the planner runs
- * deterministically without invoking the LLM, so these prompts are unused —
- * kept only as stable exports for external tooling and future Phase 3 work.
+ * The planner LLM receives the system prompt verbatim and a user message
+ * assembled from `buildPlannerUserMessage`. Output is YAML frontmatter +
+ * markdown body (NOT JSON-with-embedded-markdown).
  */
-export function buildPlannerSystemPrompt(language: "zh" | "en"): string {
-  if (language === "en") {
-    return "You are the InkOS chapter planner. Produce a structured chapter memo.";
-  }
-  return "你是 InkOS 的章节规划器,产出结构化的章节备忘。";
+
+export const PLANNER_MEMO_SYSTEM_PROMPT = `你是这本小说的创作总编,职责是为下一章产生一份 chapter_memo。你不写正文——你只规划这章要完成什么、兑现什么、不要做什么。下游写手(writer)会按你的 memo 扩写正文。
+
+你的工作原则(内化,不要在 memo 里引用条目号):
+
+1. 3-5 章一个小目标周期:每 3-5 章必须有一个小目标达成或悬念升级,主线持续推进
+2. 主动塑造读者期待:作者刻意制造"还没兑现但快要兑现"的缺口,兑现时必须超过读者预期 70%
+3. 万物皆饵:日常/过渡章节的每一笔都要是未来剧情的伏笔或钩子
+4. 人设防崩:角色行为由"过往经历 + 当前利益 + 性格底色"共同驱动。禁止反派突然降智、主角突然圣母
+5. 1 主线 + 1 支线:支线必须为主线服务,不同时推 3 条以上支线
+6. 爽点密集化:每 3-5 章一个小爽点(小冲突→快解决→强反馈),全员智商在线
+7. 高潮前铺垫:大高潮前 3-5 章必须有线索埋设
+8. 高潮后影响:爆发章之后 1-2 章必须写出改变(主线推进、人设成长、关系变化)
+9. 人物立体化:核心标签 + 反差细节 = 活人
+10. 五感具体化:场景描写必须有具体可视化感官细节
+11. 钩子承接:每章章尾留钩
+
+## 输出格式(严格遵守)
+
+输出 YAML frontmatter + markdown body,不要用 JSON 对象包 markdown 字符串,不要加代码块标记。
+
+结构如下:
+
+---
+chapter: 12
+goal: 把七号门被动过手脚从猜测钉成现场实证
+isGoldenOpening: false
+threadRefs:
+  - H03
+  - S004
+---
+
+## 当前任务
+<一句话:本章主角要完成的具体动作,不要抽象描述>
+
+## 读者此刻在等什么
+<两行:
+1) 读者现在期待什么(基于前几章的埋伏)
+2) 本章对这个期待做什么——制造更强缺口 / 部分兑现 / 完全兑现 / 暂不兑现但给暗示>
+
+## 该兑现的 / 暂不掀的
+- 该兑现:X → 兑现到什么程度
+- 暂不掀:Y → 先压住,留到第 N 章
+
+## 日常/过渡承担什么任务
+<如果本章是非高压章节,每段非冲突段落说明功能。格式:[段落位置] → [承担功能]
+如果本章是高压/冲突章节,写"不适用 - 本章无日常过渡">
+
+## 关键抉择过三连问
+- 主角本章最关键的一次选择:
+  - 为什么这么做?
+  - 符合当前利益吗?
+  - 符合他的人设吗?
+- 对手/配角本章最关键的一次选择:
+  - 为什么这么做?
+  - 符合当前利益吗?
+  - 符合他的人设吗?
+
+## 章尾必须发生的改变
+<1-3 条,从以下维度选:信息改变 / 关系改变 / 物理改变 / 权力改变>
+
+## 不要做
+<2-4 条硬约束>
+
+## 输出要求
+
+- goal 字段不超过 50 字
+- threadRefs 是 YAML 数组,内容是从输入的 pending_hooks/subplot_board 中挑出的 id
+- 每个二级标题(##)必须出现,内容不能为空
+- 不要在 memo 里提方法论术语("情绪缺口"、"cyclePhase"、"蓄压"等)——直接用这本书的人物、地点、事件说事
+- 不要产生正文片段或对话片段
+- 如果卷纲和上章摘要冲突,信上章摘要(剧情已实际发生)`;
+
+export const PLANNER_MEMO_USER_TEMPLATE = `# 第 {{chapterNumber}} 章 memo 请求
+
+## 上一章最后一屏(原文节选)
+{{previous_chapter_ending_excerpt}}
+
+## 最近 3 章摘要
+{{recent_summaries}}
+
+## 当前 arc 正在推进什么
+{{current_arc_prose}}
+
+## 主角当前状态
+{{protagonist_matrix_row}}
+
+## 本章主要对手/阻力方
+{{opponent_rows}}
+
+## 本章主要协作者
+{{collaborator_rows}}
+
+## 可能被牵动的 thread(伏笔 + 支线)
+{{relevant_threads}}
+
+## 本章卷外约束
+- 是否黄金三章:{{isGoldenOpening}}
+- 硬约束(摘取本章可能触碰的条目):
+{{book_rules_relevant}}
+
+请为第 {{chapterNumber}} 章产生 memo。严格按 YAML frontmatter + markdown 格式输出。`;
+
+export interface PlannerUserMessageInput {
+  readonly chapterNumber: number;
+  readonly previousChapterEndingExcerpt: string;
+  readonly recentSummaries: string;
+  readonly currentArcProse: string;
+  readonly protagonistMatrixRow: string;
+  readonly opponentRows: string;
+  readonly collaboratorRows: string;
+  readonly relevantThreads: string;
+  readonly isGoldenOpening: boolean;
+  readonly bookRulesRelevant: string;
 }
 
-export function buildPlannerUserPrompt(input: PlannerPromptInput): string {
-  const { materials } = input;
-  return [
-    `## Chapter`,
-    `${input.chapterNumber}/${input.targetChapters}`,
-    "",
-    "## Genre",
-    input.genreName,
-    "",
-    "## Goal Seed",
-    materials.outlineNode ?? "(no matched outline slice)",
-    "",
-    "## Author Intent",
-    materials.authorIntent,
-    "",
-    "## Current Focus",
-    materials.currentFocus,
-    "",
-    "## Current State",
-    materials.currentState,
-    "",
-    "## Story Bible",
-    materials.storyBible,
-    "",
-  ].join("\n");
+export function buildPlannerUserMessage(input: PlannerUserMessageInput): string {
+  return PLANNER_MEMO_USER_TEMPLATE
+    .replaceAll("{{chapterNumber}}", String(input.chapterNumber))
+    .replaceAll("{{previous_chapter_ending_excerpt}}", input.previousChapterEndingExcerpt)
+    .replaceAll("{{recent_summaries}}", input.recentSummaries)
+    .replaceAll("{{current_arc_prose}}", input.currentArcProse)
+    .replaceAll("{{protagonist_matrix_row}}", input.protagonistMatrixRow)
+    .replaceAll("{{opponent_rows}}", input.opponentRows)
+    .replaceAll("{{collaborator_rows}}", input.collaboratorRows)
+    .replaceAll("{{relevant_threads}}", input.relevantThreads)
+    .replaceAll("{{isGoldenOpening}}", input.isGoldenOpening ? "是" : "否")
+    .replaceAll("{{book_rules_relevant}}", input.bookRulesRelevant);
 }

+ 114 - 17
packages/core/src/agents/planner.ts

@@ -5,7 +5,6 @@ import type { BookConfig } from "../models/book.js";
 import { parseBookRules } from "../models/book-rules.js";
 import {
   ChapterIntentSchema,
-  ChapterMemoSchema,
   type ChapterIntent,
   type ChapterMemo,
 } from "../models/input-governance.js";
@@ -17,6 +16,24 @@ import {
   gatherPlanningMaterials,
   loadPlanningSeedMaterials,
 } from "../utils/planning-materials.js";
+import { parseMemo, PlannerParseError } from "../utils/chapter-memo-parser.js";
+import {
+  PLANNER_MEMO_SYSTEM_PROMPT,
+  buildPlannerUserMessage,
+} from "./planner-prompts.js";
+import {
+  composeCurrentArcProse,
+  extractCollaboratorRows,
+  extractOpponentRows,
+  extractProtagonistRow,
+  extractRelevantThreads,
+  formatRecentSummaries,
+  readBookRules,
+  readCharacterMatrix,
+  readEmotionalArcs,
+  readPendingHooks,
+  readSubplotBoard,
+} from "./planner-context.js";
 
 export interface PlanChapterInput {
   readonly book: BookConfig;
@@ -33,17 +50,21 @@ export interface PlanChapterOutput {
   readonly runtimePath: string;
 }
 
+const MEMO_RETRY_LIMIT = 3;
+
 /**
- * Phase 1 transitional planner.
+ * Phase 3 planner.
  *
- * Phase 3 (new.txt) will replace this with an LLM-driven memo generator that
- * fills `ChapterMemo.body` with prose across the seven narrative sections.
- * For now the planner produces:
- *   - a simplified ChapterIntent (goal + outline + keep/avoid/style)
- *   - a stub ChapterMemo with empty body
+ * Produces:
+ *   - a simplified ChapterIntent (goal + outline + keep/avoid/style) —
+ *     still deterministic, used for retrieval hints and the intent markdown.
+ *   - a full ChapterMemo (YAML frontmatter + 7-section markdown body) via
+ *     LLM call + strict parser.
  *
- * The pipeline still runs end-to-end; the writer simply reads the minimal
- * intent until the memo rewrite lands.
+ * Retry policy: up to 3 attempts. Each failed parse appends an error
+ * feedback block to the user message and re-invokes the LLM. On the third
+ * failure we surface `PlannerParseError` — never silently truncate or
+ * rename fields.
  */
 export class PlannerAgent extends BaseAgent {
   get name(): string {
@@ -100,14 +121,15 @@ export class PlannerAgent extends BaseAgent {
       styleEmphasis,
     });
 
-    // Phase 1 stub memo. Phase 3 rewrites this with full prose.
-    const isGoldenOpening = input.chapterNumber <= 3;
-    const memo = ChapterMemoSchema.parse({
-      chapter: input.chapterNumber,
-      goal,
+    const isGoldenOpening = this.isGoldenOpeningChapter(input.book.language, input.chapterNumber);
+    const memo = await this.planChapterMemo({
+      storyDir,
+      bookDir: input.bookDir,
+      chapterNumber: input.chapterNumber,
       isGoldenOpening,
-      body: "",
-      hookRefs: [],
+      fallbackGoal: goal,
+      chapterSummariesRaw: seedMaterials.chapterSummariesRaw,
+      previousEndingExcerpt: seedMaterials.previousEndingExcerpt,
     });
 
     const runtimePath = join(runtimeDir, `chapter-${String(input.chapterNumber).padStart(4, "0")}.intent.md`);
@@ -130,6 +152,75 @@ export class PlannerAgent extends BaseAgent {
     };
   }
 
+  /**
+   * Invoke the LLM to produce a 7-section memo and parse it. Retries up to
+   * 3 times on parse failure, injecting the error message back into the user
+   * prompt so the LLM can correct itself.
+   */
+  async planChapterMemo(input: {
+    readonly storyDir: string;
+    readonly bookDir: string;
+    readonly chapterNumber: number;
+    readonly isGoldenOpening: boolean;
+    readonly fallbackGoal: string;
+    readonly chapterSummariesRaw: string;
+    readonly previousEndingExcerpt?: string;
+  }): Promise<ChapterMemo> {
+    const [characterMatrix, subplotBoard, emotionalArcs, pendingHooks, bookRulesRaw] = await Promise.all([
+      readCharacterMatrix(input.storyDir),
+      readSubplotBoard(input.storyDir),
+      readEmotionalArcs(input.storyDir),
+      readPendingHooks(input.storyDir),
+      readBookRules(input.storyDir),
+    ]);
+
+    const userMessage = buildPlannerUserMessage({
+      chapterNumber: input.chapterNumber,
+      previousChapterEndingExcerpt: input.previousEndingExcerpt?.trim()
+        ? input.previousEndingExcerpt.trim()
+        : "(本章为起始章,无前章)",
+      recentSummaries: formatRecentSummaries(input.chapterSummariesRaw, input.chapterNumber, 3),
+      currentArcProse: composeCurrentArcProse(subplotBoard, emotionalArcs, input.chapterNumber),
+      protagonistMatrixRow: extractProtagonistRow(characterMatrix),
+      opponentRows: extractOpponentRows(characterMatrix, 3),
+      collaboratorRows: extractCollaboratorRows(characterMatrix, 3),
+      relevantThreads: extractRelevantThreads(pendingHooks, subplotBoard),
+      isGoldenOpening: input.isGoldenOpening,
+      bookRulesRelevant: bookRulesRaw.trim().length > 0 ? bookRulesRaw.trim() : "(暂无 book_rules 条目)",
+    });
+
+    let currentUserMessage = userMessage;
+    let lastError: PlannerParseError | undefined;
+
+    for (let attempt = 0; attempt < MEMO_RETRY_LIMIT; attempt += 1) {
+      const response = await this.chat(
+        [
+          { role: "system", content: PLANNER_MEMO_SYSTEM_PROMPT },
+          { role: "user", content: currentUserMessage },
+        ],
+        { temperature: 0.7, maxTokens: 2000 },
+      );
+
+      try {
+        return parseMemo(response.content, input.chapterNumber, input.isGoldenOpening);
+      } catch (error) {
+        if (!(error instanceof PlannerParseError)) {
+          throw error;
+        }
+        lastError = error;
+        this.log?.warn(`[planner] memo parse failed (attempt ${attempt + 1}/${MEMO_RETRY_LIMIT}): ${error.message}`);
+        currentUserMessage = `${userMessage}\n\n## 上次输出的错误\n${error.message}\n请修正后重新输出。`;
+      }
+    }
+
+    throw lastError ?? new PlannerParseError("memo planner exhausted retries without a specific error");
+  }
+
+  private isGoldenOpeningChapter(language: string | undefined, chapterNumber: number): boolean {
+    const isZh = (language ?? "zh").toLowerCase().startsWith("zh");
+    return isZh ? chapterNumber <= 3 : chapterNumber <= 5;
+  }
+
   private buildArcContext(
     language: string | undefined,
     volumeOutline: string,
@@ -579,7 +670,10 @@ export class PlannerAgent extends BaseAgent {
       ? intent.styleEmphasis.map((item) => `- ${item}`).join("\n")
       : "- none";
 
-    const memoBody = memo.body.trim() ? memo.body : (language === "en" ? "(empty — Phase 3 fills this)" : "(空——待 Phase 3 填充)");
+    const memoBody = memo.body.trim();
+    const threadRefsLine = memo.threadRefs.length > 0
+      ? memo.threadRefs.map((id) => `- ${id}`).join("\n")
+      : "- (none)";
 
     return [
       "# Chapter Intent",
@@ -605,6 +699,9 @@ export class PlannerAgent extends BaseAgent {
       "## Chapter Memo",
       `- isGoldenOpening: ${memo.isGoldenOpening ? "true" : "false"}`,
       "",
+      "### Thread Refs",
+      threadRefsLine,
+      "",
       "### Body",
       memoBody,
       "",

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

@@ -68,9 +68,10 @@ export {
   type ComposeChapterOutput,
 } from "./agents/composer.js";
 export {
-  buildPlannerSystemPrompt,
-  buildPlannerUserPrompt,
-  type PlannerPromptInput,
+  PLANNER_MEMO_SYSTEM_PROMPT,
+  PLANNER_MEMO_USER_TEMPLATE,
+  buildPlannerUserMessage,
+  type PlannerUserMessageInput,
 } from "./agents/planner-prompts.js";
 export {
   gatherPlanningMaterials,

+ 94 - 0
packages/core/src/utils/chapter-memo-parser.ts

@@ -0,0 +1,94 @@
+import YAML from "js-yaml";
+import { ChapterMemoSchema, type ChapterMemo } from "../models/input-governance.js";
+
+export class PlannerParseError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = "PlannerParseError";
+  }
+}
+
+const REQUIRED_SECTIONS: ReadonlyArray<string> = [
+  "## 当前任务",
+  "## 读者此刻在等什么",
+  "## 该兑现的 / 暂不掀的",
+  "## 日常/过渡承担什么任务",
+  "## 关键抉择过三连问",
+  "## 章尾必须发生的改变",
+  "## 不要做",
+];
+
+/**
+ * Parse a planner memo produced by the LLM.
+ *
+ * Format: YAML frontmatter delimited by `---\n...\n---\n` followed by a
+ * markdown body containing the seven required section headings.
+ *
+ * Strict on core fields (chapter integer + matches expected, goal non-empty
+ * and ≤ 50 chars, required section headings present). Lenient on aux fields
+ * (threadRefs coerced to string[], defaults to []).
+ *
+ * `isGoldenOpening` is authoritative from the caller — any value the LLM
+ * includes in the frontmatter is ignored.
+ */
+export function parseMemo(
+  raw: string,
+  expectedChapter: number,
+  isGoldenOpening: boolean,
+): ChapterMemo {
+  const trimmed = raw.trim();
+  const match = trimmed.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
+  if (!match) {
+    throw new PlannerParseError("missing YAML frontmatter delimiters");
+  }
+
+  const yamlText = match[1]!;
+  const body = match[2]!.trim();
+
+  let fm: unknown;
+  try {
+    fm = YAML.load(yamlText);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : String(error);
+    throw new PlannerParseError(`invalid YAML in frontmatter: ${message}`);
+  }
+  if (!fm || typeof fm !== "object" || Array.isArray(fm)) {
+    throw new PlannerParseError("frontmatter is not an object");
+  }
+  const f = fm as Record<string, unknown>;
+
+  if (typeof f.chapter !== "number" || !Number.isInteger(f.chapter)) {
+    throw new PlannerParseError("chapter must be an integer");
+  }
+  if (f.chapter !== expectedChapter) {
+    throw new PlannerParseError(
+      `chapter mismatch: expected ${expectedChapter}, got ${f.chapter}`,
+    );
+  }
+
+  if (typeof f.goal !== "string" || f.goal.length === 0) {
+    throw new PlannerParseError("goal must be a non-empty string");
+  }
+  if (f.goal.length > 50) {
+    throw new PlannerParseError(
+      `goal too long: ${f.goal.length} chars (max 50)`,
+    );
+  }
+
+  const missing = REQUIRED_SECTIONS.filter((heading) => !body.includes(heading));
+  if (missing.length > 0) {
+    throw new PlannerParseError(`missing sections: ${missing.join(", ")}`);
+  }
+
+  const threadRefs = Array.isArray(f.threadRefs)
+    ? f.threadRefs.filter((value): value is string => typeof value === "string")
+    : [];
+
+  return ChapterMemoSchema.parse({
+    chapter: f.chapter,
+    goal: f.goal,
+    isGoldenOpening,
+    body,
+    threadRefs,
+  });
+}