From 979b55f802570e882b8dab536719b55c32947f31 Mon Sep 17 00:00:00 2001 From: Nerdless-ship-it Date: Mon, 30 Mar 2026 15:44:12 +0800 Subject: [PATCH] fix: preserve pending_hooks.md and tighten JSON extraction boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG 1 — state-validator: extractBalancedJsonObject would accept a candidate that ended at '}' but had non-structural trailing content (e.g. '{...} more text here'). tryParseJson would then fail on the full candidate string, causing 'State validator returned invalid JSON'. Fix: after finding the matching '}', verify the immediately following character is nothing, whitespace, or a structural JSON token before accepting the candidate. BUG 2 — import replay: resetImportReplayTruthFiles overwrote pending_hooks.md with an empty seed before chapter replay, wiping out all existing hooks (35 → 0, then replay generates ~16 from scratch). Fix: remove the writeFile for pending_hooks.md in resetImportReplayTruthFiles. Hooks are chapter-content-specific; preserving them during import avoids data loss. The replay will incrementally add new hooks without destroying existing ones. --- packages/core/src/agents/state-validator.ts | 25 +++++++++++++++++++-- packages/core/src/pipeline/runner.ts | 9 ++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/core/src/agents/state-validator.ts b/packages/core/src/agents/state-validator.ts index 50d48a71..01571dd1 100644 --- a/packages/core/src/agents/state-validator.ts +++ b/packages/core/src/agents/state-validator.ts @@ -174,6 +174,7 @@ function extractBalancedJsonObject(text: string, start: number): string | null { let depth = 0; let inString = false; let escaped = false; + let endIndex = -1; for (let index = start; index < text.length; index += 1) { const char = text[index]!; @@ -206,7 +207,8 @@ function extractBalancedJsonObject(text: string, start: number): string | null { if (char === "}") { depth -= 1; if (depth === 0) { - return text.slice(start, index + 1); + endIndex = index; + break; } if (depth < 0) { return null; @@ -214,5 +216,24 @@ function extractBalancedJsonObject(text: string, start: number): string | null { } } - return null; + if (endIndex < 0) return null; + + // Only accept the candidate if what follows the closing brace is + // nothing, whitespace, or a structural JSON terminator. + // This rejects trailing content like "{...} more text here" + const followingChar = text[endIndex + 1]; + if ( + followingChar !== undefined && + followingChar !== "\n" && + followingChar !== "\r" && + followingChar !== "\t" && + followingChar !== " " && + followingChar !== "," && + followingChar !== "]" && + followingChar !== "}" + ) { + return null; + } + + return text.slice(start, endIndex + 1); } diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 48910617..d05482d9 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -1828,11 +1828,10 @@ ${matrix}`, this.buildImportReplayStateSeed(language), "utf-8", ), - writeFile( - join(storyDir, "pending_hooks.md"), - this.buildImportReplayHooksSeed(language), - "utf-8", - ), + // NOTE: pending_hooks.md intentionally NOT reset here — hooks are + // chapter-content-specific and user may have invested significant + // effort in tuning them. The replay will incrementally add new hooks + // detected from the imported chapters without destroying existing ones. rm(join(storyDir, "chapter_summaries.md"), { force: true }), rm(join(storyDir, "subplot_board.md"), { force: true }), rm(join(storyDir, "emotional_arcs.md"), { force: true }),