Просмотр исходного кода

feat(pipeline): add hook budget hint and chapter ending trail

Hook budget: planner intent now shows "N/12 active hooks" in the
Hook Agenda section. When N >= 10, adds explicit guidance to
prioritize resolving old debt over opening new threads.
Soft hint only — no hard cap.

Ending trail: composer now extracts the last meaningful sentence
from the 3 most recent chapter files and injects it as context,
so the writer can see "ch3: buried under rubble | ch4: buried
under rubble" and know to vary the chapter ending structure.
Ma 7 месяцев назад
Родитель
Сommit
b4f8e2df82
2 измененных файлов с 69 добавлено и 1 удалено
  1. 47 1
      packages/core/src/agents/composer.ts
  2. 22 0
      packages/core/src/agents/planner.ts

+ 47 - 1
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";
@@ -243,9 +243,55 @@ export class ComposerAgent extends BaseAgent {
       });
     }
 
+    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<string | undefined> {
+    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,

+ 22 - 0
packages/core/src/agents/planner.ts

@@ -81,6 +81,9 @@ 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,
@@ -94,6 +97,7 @@ export class PlannerAgent extends BaseAgent {
       outlineNode,
       matchedOutlineAnchor,
       chapterSummaries,
+      activeHookCount,
     });
 
     const intent = ChapterIntentSchema.parse({
@@ -136,6 +140,7 @@ export class PlannerAgent extends BaseAgent {
     readonly outlineNode: string | undefined;
     readonly matchedOutlineAnchor: boolean;
     readonly chapterSummaries: string;
+    readonly activeHookCount?: number;
   }): Pick<ChapterIntent, "sceneDirective" | "arcDirective" | "moodDirective" | "titleDirective"> {
     const recentSummaries = parseChapterSummariesMarkdown(input.chapterSummaries)
       .filter((summary) => summary.chapter < input.chapterNumber)
@@ -384,6 +389,21 @@ export class PlannerAgent extends BaseAgent {
       : `Avoid another ${repeatedToken}-centric title. Pick a new image or action focus for this chapter title.`;
   }
 
+  private renderHookBudget(pendingHooks: string, language: "zh" | "en"): string {
+    const hookLines = pendingHooks.split("\n").filter((line) => line.startsWith("|") && /^\|\s*[A-Za-z\u4e00-\u9fff]/.test(line));
+    const activeCount = hookLines.length;
+    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>): string | undefined {
     const targets = headings.map((heading) => this.normalizeHeading(heading));
     const lines = content.split("\n");
@@ -668,6 +688,8 @@ export class PlannerAgent extends BaseAgent {
       intent.hookAgenda.avoidNewHookFamilies.length > 0
         ? intent.hookAgenda.avoidNewHookFamilies.map((item) => `- ${item}`).join("\n")
         : "- none",
+      "",
+      this.renderHookBudget(pendingHooks, language),
     ].join("\n");
 
     return [