|
@@ -160,3 +160,116 @@ export async function readCharacterContext(
|
|
|
const legacyPath = join(bookDir, "story", "character_matrix.md");
|
|
const legacyPath = join(bookDir, "story", "character_matrix.md");
|
|
|
return readOr(legacyPath, fallbackPlaceholder);
|
|
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");
|
|
|
|
|
+}
|