Sfoglia il codice sorgente

refactor(v13): readers derive initial state from story_frame+roles+hooks [phase 5 consolidation 3]

Add readCurrentStateWithFallback() helper in utils/outline-paths.ts:
when current_state.md is still the architect's seed placeholder (no
consolidator run yet), derive an initial-state block from roles/*.
Current_State sections + pending_hooks rows with startChapter=0. Once
the consolidator has appended real chapter state, the file is used as-is.

Readers migrated to the fallback helper:
  - writer.ts (writeChapter + settlement path)
  - chapter-analyzer.ts
  - continuity.ts
  - reviser.ts
  - utils/planning-materials.ts

Legacy readers (composer.ts, memory-retrieval.ts, pipeline/runner.ts)
consume current_state.md directly for snapshotting / diffing runtime
state — they do NOT need the fallback because they operate on post-
chapter-1 state that consolidator has already populated.
Ma 6 mesi fa
parent
commit
edeca0967f

+ 4 - 1
packages/core/src/agents/chapter-analyzer.ts

@@ -18,6 +18,7 @@ import {
   readStoryFrame,
   readVolumeMap,
   readCharacterContext,
+  readCurrentStateWithFallback,
 } from "../utils/outline-paths.js";
 
 export interface AnalyzeChapterInput {
@@ -52,7 +53,9 @@ export class ChapterAnalyzerAgent extends BaseAgent {
       subplotBoard, emotionalArcs, characterMatrix,
       storyBible, volumeOutline,
     ] = await Promise.all([
-      this.readFileOrDefault(join(bookDir, "story/current_state.md"), resolvedLanguage),
+      // Phase 5 consolidation: derive initial state from roles + seed hooks
+      // when current_state.md is still the architect seed placeholder.
+      readCurrentStateWithFallback(bookDir, placeholder),
       this.readFileOrDefault(join(bookDir, "story/particle_ledger.md"), resolvedLanguage),
       this.readFileOrDefault(join(bookDir, "story/pending_hooks.md"), resolvedLanguage),
       this.readFileOrDefault(join(bookDir, "story/subplot_board.md"), resolvedLanguage),

+ 8 - 2
packages/core/src/agents/continuity.ts

@@ -8,7 +8,11 @@ import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions
 import { readFile, readdir } from "node:fs/promises";
 import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
 import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
-import { readVolumeMap, readCharacterContext } from "../utils/outline-paths.js";
+import {
+  readVolumeMap,
+  readCharacterContext,
+  readCurrentStateWithFallback,
+} from "../utils/outline-paths.js";
 import { join } from "node:path";
 
 export interface AuditResult {
@@ -385,7 +389,9 @@ export class ContinuityAuditor extends BaseAgent {
   ): Promise<AuditResult> {
     const [diskCurrentState, diskLedger, diskHooks, styleGuideRaw, subplotBoard, emotionalArcs, characterMatrix, chapterSummaries, parentCanon, fanficCanon, volumeOutline] =
       await Promise.all([
-        this.readFileSafe(join(bookDir, "story/current_state.md")),
+        // Phase 5 consolidation: derive initial state from roles + seed hooks
+        // when current_state.md is still the architect seed placeholder.
+        readCurrentStateWithFallback(bookDir, "(文件不存在)"),
         this.readFileSafe(join(bookDir, "story/particle_ledger.md")),
         this.readFileSafe(join(bookDir, "story/pending_hooks.md")),
         this.readFileSafe(join(bookDir, "story/style_guide.md")),

+ 4 - 1
packages/core/src/agents/reviser.ts

@@ -26,6 +26,7 @@ import {
   readStoryFrame,
   readVolumeMap,
   readCharacterContext,
+  readCurrentStateWithFallback,
 } from "../utils/outline-paths.js";
 
 export type ReviseMode = "auto" | "polish" | "rewrite" | "rework" | "anti-detect" | "spot-fix";
@@ -129,7 +130,9 @@ export class ReviserAgent extends BaseAgent {
     },
   ): Promise<ReviseOutput> {
     const [currentState, ledger, hooks, styleGuideRaw, volumeOutline, storyBible, characterMatrix, chapterSummaries, parentCanon, fanficCanon] = await Promise.all([
-      this.readFileSafe(join(bookDir, "story/current_state.md")),
+      // Phase 5 consolidation: derive initial state from roles + seed hooks
+      // when current_state.md is still the architect seed placeholder.
+      readCurrentStateWithFallback(bookDir, "(文件不存在)"),
       this.readFileSafe(join(bookDir, "story/particle_ledger.md")),
       this.readFileSafe(join(bookDir, "story/pending_hooks.md")),
       this.readFileSafe(join(bookDir, "story/style_guide.md")),

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

@@ -46,6 +46,7 @@ import {
   readStoryFrame,
   readVolumeMap,
   readCharacterContext,
+  readCurrentStateWithFallback,
 } from "../utils/outline-paths.js";
 
 export interface WriteChapterInput {
@@ -138,7 +139,11 @@ export class WriterAgent extends BaseAgent {
         readStoryFrame(bookDir, placeholder),
         readVolumeMap(bookDir, placeholder),
         this.readFileOrDefault(join(bookDir, "story/style_guide.md")),
-        this.readFileOrDefault(join(bookDir, "story/current_state.md")),
+        // Phase 5 consolidation: architect no longer emits an initial current_state
+        // section. When the file is only a seed placeholder, derive initial state
+        // from roles/*.Current_State + pending_hooks startChapter=0 rows so the
+        // writer still sees substantive content instead of a runtime-append note.
+        readCurrentStateWithFallback(bookDir, placeholder),
         this.readFileOrDefault(join(bookDir, "story/particle_ledger.md")),
         this.readFileOrDefault(join(bookDir, "story/pending_hooks.md")),
         this.readFileOrDefault(join(bookDir, "story/chapter_summaries.md")),
@@ -437,7 +442,8 @@ export class WriterAgent extends BaseAgent {
       characterMatrix,
       volumeOutline,
     ] = await Promise.all([
-      this.readFileOrDefault(join(input.bookDir, "story/current_state.md")),
+      // Phase 5 consolidation fallback: derive initial state when only seed on disk.
+      readCurrentStateWithFallback(input.bookDir, "(文件尚未创建)"),
       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")),

+ 113 - 0
packages/core/src/utils/outline-paths.ts

@@ -160,3 +160,116 @@ export async function readCharacterContext(
   const legacyPath = join(bookDir, "story", "character_matrix.md");
   return readOr(legacyPath, fallbackPlaceholder);
 }
+
+// ---------------------------------------------------------------------------
+// Phase 5 consolidation: current_state.md initial fallback
+//
+// After architect consolidation (7→5 sections), current_state.md is seeded
+// with a tiny placeholder at book creation. Real content only arrives once
+// the consolidator has appended output from chapter 1 onward. Readers that
+// previously relied on architect-provided initial state (writer phase-1
+// creative prompt, continuity, chapter-analyzer, reviser, composer) should
+// substitute a derived initial-state block when the seed placeholder is all
+// that's on disk — otherwise the "## 当前状态卡" block in prompts degenerates
+// into a meta note about runtime append behaviour.
+// ---------------------------------------------------------------------------
+
+/**
+ * Marker substring emitted by architect.writeFoundationFiles when seeding
+ * current_state.md. Its presence is how readers detect "nothing real yet".
+ */
+const CURRENT_STATE_SEED_MARKERS = [
+  "建书时占位",
+  "Seeded at book creation",
+];
+
+export function isCurrentStateSeedPlaceholder(raw: string): boolean {
+  const trimmed = raw.trim();
+  if (!trimmed) return true;
+  // Heuristic: short file AND contains one of the seed markers.
+  if (trimmed.length > 600) return false;
+  return CURRENT_STATE_SEED_MARKERS.some((marker) => trimmed.includes(marker));
+}
+
+function extractCurrentStateFromRole(content: string): string | null {
+  // Accept both zh (`## 当前现状`) and en (`## Current_State` / `## Current State`).
+  const pattern = /^##\s*(?:当前现状|Current[_\s]?State)[^\n]*$/im;
+  const match = content.match(pattern);
+  if (!match || match.index === undefined) return null;
+  const after = content.slice(match.index + match[0].length);
+  // Cut at next `## ` heading (same or higher level).
+  const nextHeading = after.search(/^##\s/m);
+  const raw = nextHeading >= 0 ? after.slice(0, nextHeading) : after;
+  const text = raw.trim();
+  return text.length > 0 ? text : null;
+}
+
+function extractSeedHooksFromPendingHooks(raw: string): string[] {
+  if (!raw.trim()) return [];
+  const lines = raw.split("\n").map((line) => line.trim()).filter(Boolean);
+  const seedRows: string[] = [];
+  for (const line of lines) {
+    if (!line.startsWith("|")) continue;
+    if (/^\|\s*-/.test(line)) continue;
+    const cells = line.split("|").slice(1, -1).map((cell) => cell.trim());
+    if (cells.length < 2) continue;
+    if (cells[0]?.toLowerCase() === "hook_id" || cells[0] === "hookId") continue;
+    const startCh = Number.parseInt(cells[1] ?? "", 10);
+    if (!Number.isFinite(startCh) || startCh !== 0) continue;
+    // cells[2] type, cells[5] expected payoff, last cell notes
+    const notes = cells[cells.length - 1] ?? "";
+    const summary = [cells[0], cells[2], notes].filter(Boolean).join(" · ");
+    if (summary) seedRows.push(summary);
+  }
+  return seedRows;
+}
+
+/**
+ * Read current_state.md; when the file is only a seed placeholder (chapter 0,
+ * before consolidator has appended anything), derive an initial-state block
+ * from roles/*.Current_State + pending_hooks startChapter=0 rows so callers
+ * still have substantive content to feed into writer / analyzer prompts.
+ */
+export async function readCurrentStateWithFallback(
+  bookDir: string,
+  fallbackPlaceholder: string = "",
+): Promise<string> {
+  const storyDir = join(bookDir, "story");
+  const currentStatePath = join(storyDir, "current_state.md");
+  const raw = await readOr(currentStatePath, "");
+
+  if (!isCurrentStateSeedPlaceholder(raw)) {
+    return raw;
+  }
+
+  const [cards, pendingHooks] = await Promise.all([
+    readRoleCards(bookDir),
+    readOr(join(storyDir, "pending_hooks.md"), ""),
+  ]);
+
+  const roleLines = cards
+    .map((card) => {
+      const state = extractCurrentStateFromRole(card.content);
+      if (!state) return null;
+      const tierLabel = card.tier === "major" ? "主要" : "次要";
+      return `- ${card.name}(${tierLabel}):${state.replace(/\s+/g, " ")}`;
+    })
+    .filter((line): line is string => line !== null);
+
+  const hookLines = extractSeedHooksFromPendingHooks(pendingHooks);
+
+  if (roleLines.length === 0 && hookLines.length === 0) {
+    return raw.trim() ? raw : fallbackPlaceholder;
+  }
+
+  const parts: string[] = ["# 初始状态(第 0 章,由 roles + 种子伏笔派生)"];
+  if (roleLines.length > 0) {
+    parts.push("\n## 角色初始位置 / 处境");
+    parts.push(...roleLines);
+  }
+  if (hookLines.length > 0) {
+    parts.push("\n## 种子伏笔(startChapter = 0)");
+    parts.push(...hookLines.map((line) => `- ${line}`));
+  }
+  return parts.join("\n");
+}

+ 8 - 2
packages/core/src/utils/planning-materials.ts

@@ -6,7 +6,11 @@ import {
   retrieveMemorySelection,
   type MemorySelection,
 } from "./memory-retrieval.js";
-import { readStoryFrame, readVolumeMap } from "./outline-paths.js";
+import {
+  readStoryFrame,
+  readVolumeMap,
+  readCurrentStateWithFallback,
+} from "./outline-paths.js";
 
 export interface PlanningSeedMaterials {
   readonly storyDir: string;
@@ -102,7 +106,9 @@ export async function loadPlanningSeedMaterials(params: {
     readVolumeMap(params.bookDir, placeholder),
     readFileOrDefault(sourcePaths.chapterSummaries),
     readFileOrDefault(sourcePaths.bookRules),
-    readFileOrDefault(sourcePaths.currentState),
+    // Phase 5 consolidation: derive initial state from roles + pending_hooks
+    // seed rows when current_state.md is still just the architect's placeholder.
+    readCurrentStateWithFallback(params.bookDir, placeholder),
     readPreviousEndingExcerpt(params.bookDir, params.chapterNumber),
   ]);