| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447 |
- import { readFile, readdir, mkdir } from "node:fs/promises";
- import { dirname, join } from "node:path";
- import { BaseAgent } from "./base.js";
- import type { BookConfig } from "../models/book.js";
- import {
- ContextPackageSchema,
- type ChapterTrace,
- type ContextPackage,
- type RuleStack,
- } from "../models/input-governance.js";
- import type { PlanChapterOutput } from "./planner.js";
- import {
- parseChapterSummariesMarkdown,
- retrieveMemorySelection,
- } from "../utils/memory-retrieval.js";
- import {
- buildGovernedRuleStack,
- buildGovernedTrace,
- } from "../utils/context-assembly.js";
- import { writeGovernedRuntimeArtifacts } from "../utils/runtime-writer.js";
- export interface ComposeChapterInput {
- readonly book: BookConfig;
- readonly bookDir: string;
- readonly chapterNumber: number;
- readonly plan: PlanChapterOutput;
- }
- export interface ComposeChapterOutput {
- readonly contextPackage: ContextPackage;
- readonly ruleStack: RuleStack;
- readonly trace: ChapterTrace;
- readonly contextPath: string;
- readonly ruleStackPath: string;
- readonly tracePath: string;
- }
- export async function composeGovernedChapter(input: ComposeChapterInput): Promise<ComposeChapterOutput> {
- const storyDir = join(input.bookDir, "story");
- const runtimeDir = join(storyDir, "runtime");
- await mkdir(runtimeDir, { recursive: true });
- const selectedContext = await collectSelectedContext(
- storyDir,
- input.plan,
- input.book.language ?? "zh",
- );
- const contextPackage = ContextPackageSchema.parse({
- chapter: input.chapterNumber,
- selectedContext,
- });
- const ruleStack = buildGovernedRuleStack(input.plan, input.chapterNumber);
- const trace = buildGovernedTrace({
- chapterNumber: input.chapterNumber,
- plan: input.plan,
- contextPackage,
- composerInputs: [input.plan.runtimePath],
- });
- const {
- contextPath,
- ruleStackPath,
- tracePath,
- } = await writeGovernedRuntimeArtifacts({
- runtimeDir,
- chapterNumber: input.chapterNumber,
- contextPackage,
- ruleStack,
- trace,
- });
- return {
- contextPackage,
- ruleStack,
- trace,
- contextPath,
- ruleStackPath,
- tracePath,
- };
- }
- export class ComposerAgent extends BaseAgent {
- get name(): string {
- return "composer";
- }
- async composeChapter(input: ComposeChapterInput): Promise<ComposeChapterOutput> {
- return composeGovernedChapter(input);
- }
- }
- async function collectSelectedContext(
- storyDir: string,
- plan: PlanChapterOutput,
- language: "zh" | "en",
- ): Promise<ContextPackage["selectedContext"]> {
- const retrievalHints = deriveRetrievalHints(plan);
- const memoBodyExcerpt = plan.memo.body.trim();
- const chapterMemoEntry = memoBodyExcerpt.length > 0
- ? [{
- source: "runtime/chapter_memo",
- reason: "Carry the planner's chapter memo into governed writing.",
- excerpt: [
- `goal=${plan.memo.goal}`,
- plan.memo.isGoldenOpening ? "golden-opening=true" : undefined,
- memoBodyExcerpt,
- ].filter(Boolean).join(" | "),
- }]
- : [{
- source: "runtime/chapter_memo",
- reason: "Carry the planner's chapter memo into governed writing.",
- excerpt: `goal=${plan.memo.goal}`,
- }];
- const entries = await Promise.all([
- maybeContextSource(storyDir, "current_focus.md", "Current task focus for this chapter."),
- maybeContextSource(
- storyDir,
- "audit_drift.md",
- "Carry forward audit drift guidance from the previous chapter without polluting hard state facts.",
- ),
- maybeContextSource(
- storyDir,
- "current_state.md",
- "Preserve hard state facts referenced by the active chapter brief or hard constraints.",
- retrievalHints,
- ),
- maybeContextSource(
- storyDir,
- "story_bible.md",
- "Preserve canon constraints referenced by the active chapter brief or hard constraints.",
- retrievalHints,
- ),
- maybeContextSource(
- storyDir,
- "volume_outline.md",
- "Anchor the default planning node for this chapter.",
- plan.intent.outlineNode ? [plan.intent.outlineNode] : [],
- ),
- maybeContextSource(
- storyDir,
- "parent_canon.md",
- "Preserve parent canon constraints for governed continuation or fanfic writing.",
- ),
- maybeContextSource(
- storyDir,
- "fanfic_canon.md",
- "Preserve extracted fanfic canon constraints for governed writing.",
- ),
- ]);
- const trailEntries = await buildRecentChapterTrailEntries(storyDir, plan.intent.chapter);
- const memorySelection = await retrieveMemorySelection({
- bookDir: dirname(storyDir),
- chapterNumber: plan.intent.chapter,
- goal: plan.intent.goal,
- outlineNode: plan.intent.outlineNode,
- mustKeep: retrievalHints,
- });
- const hookDebtEntries = await buildHookDebtEntries(
- storyDir,
- plan,
- memorySelection.activeHooks,
- language,
- );
- const summaryEntries = memorySelection.summaries.map((summary) => ({
- source: `story/chapter_summaries.md#${summary.chapter}`,
- reason: "Relevant episodic memory retrieved for the current chapter goal.",
- excerpt: [summary.title, summary.events, summary.stateChanges, summary.hookActivity]
- .filter(Boolean)
- .join(" | "),
- }));
- const factEntries = memorySelection.facts.map((fact) => ({
- source: `story/current_state.md#${toFactAnchor(fact.predicate)}`,
- reason: "Relevant current-state fact retrieved for the current chapter goal.",
- excerpt: `${fact.predicate} | ${fact.object}`,
- }));
- const hookEntries = memorySelection.hooks.map((hook) => ({
- source: `story/pending_hooks.md#${hook.hookId}`,
- reason: "Carry forward unresolved hooks that match the chapter focus.",
- excerpt: [hook.type, hook.status, hook.expectedPayoff, hook.payoffTiming, hook.notes]
- .filter(Boolean)
- .join(" | "),
- }));
- const volumeSummaryEntries = memorySelection.volumeSummaries.map((summary) => ({
- source: `story/volume_summaries.md#${summary.anchor}`,
- reason: "Carry forward long-span arc memory compressed from earlier volumes.",
- excerpt: `${summary.heading} | ${summary.content}`,
- }));
- return [
- ...chapterMemoEntry,
- ...entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
- ...trailEntries,
- ...hookDebtEntries,
- ...factEntries,
- ...summaryEntries,
- ...volumeSummaryEntries,
- ...hookEntries,
- ];
- }
- function deriveRetrievalHints(plan: PlanChapterOutput): string[] {
- return [
- plan.intent.goal,
- plan.intent.outlineNode,
- ...plan.memo.hookRefs,
- ].filter((value): value is string => Boolean(value));
- }
- async function buildRecentChapterTrailEntries(
- storyDir: string,
- chapterNumber: number,
- ): Promise<ContextPackage["selectedContext"]> {
- const content = await readFileOrDefault(join(storyDir, "chapter_summaries.md"));
- if (!content || content === "(文件尚未创建)") {
- return [];
- }
- const recentSummaries = parseChapterSummariesMarkdown(content)
- .filter((summary) => summary.chapter < chapterNumber)
- .sort((left, right) => right.chapter - left.chapter)
- .slice(0, 5);
- if (recentSummaries.length === 0) {
- return [];
- }
- const entries: ContextPackage["selectedContext"] = [];
- const recentTitles = recentSummaries
- .map((summary) => [summary.chapter, summary.title].filter(Boolean).join(": "))
- .filter(Boolean)
- .join(" | ");
- if (recentTitles) {
- entries.push({
- source: "story/chapter_summaries.md#recent_titles",
- reason: "Keep recent title history visible to avoid repetitive chapter naming.",
- excerpt: recentTitles,
- });
- }
- const moodTrail = recentSummaries
- .filter((summary) => summary.mood || summary.chapterType)
- .map((summary) => `${summary.chapter}: ${summary.mood || "(none)"} / ${summary.chapterType || "(none)"}`)
- .join(" | ");
- if (moodTrail) {
- entries.push({
- source: "story/chapter_summaries.md#recent_mood_type_trail",
- reason: "Keep recent mood and chapter-type cadence visible before writing the next chapter.",
- excerpt: moodTrail,
- });
- }
- const endingTrail = await 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;
- }
- async function 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 = extractLastMeaningfulSentence(content);
- if (lastLine) {
- endings.push(`ch${entry.num}: ${lastLine}`);
- }
- }
- return endings.length >= 2 ? endings.join(" | ") : undefined;
- } catch {
- return undefined;
- }
- }
- function 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;
- }
- async function buildHookDebtEntries(
- storyDir: string,
- plan: PlanChapterOutput,
- activeHooks: ReadonlyArray<{
- readonly hookId: string;
- readonly startChapter: number;
- readonly type: string;
- readonly status: string;
- readonly lastAdvancedChapter: number;
- readonly expectedPayoff: string;
- readonly payoffTiming?: string;
- readonly notes: string;
- }>,
- language: "zh" | "en",
- ): Promise<ContextPackage["selectedContext"]> {
- const targetHookIds = [...new Set(plan.memo.hookRefs)];
- if (targetHookIds.length === 0) {
- return [];
- }
- const summaries = parseChapterSummariesMarkdown(
- await readFileOrDefault(join(storyDir, "chapter_summaries.md")),
- );
- return targetHookIds.flatMap((hookId) => {
- const hook = activeHooks.find((entry) => entry.hookId === hookId);
- if (!hook) {
- return [];
- }
- const seedSummary = findHookSummary(summaries, hook.hookId, hook.startChapter, "seed");
- const latestSummary = findHookSummary(summaries, hook.hookId, hook.lastAdvancedChapter, "latest");
- const role = language === "en" ? "memo-referenced debt" : "备忘引用旧债";
- const promise = hook.expectedPayoff || (language === "en" ? "(unspecified)" : "(未写明)");
- const seedBeat = seedSummary
- ? renderHookDebtBeat(seedSummary)
- : (hook.notes || promise);
- const latestBeat = latestSummary && latestSummary !== seedSummary
- ? renderHookDebtBeat(latestSummary)
- : undefined;
- const age = Math.max(0, plan.intent.chapter - Math.max(1, hook.startChapter));
- return [{
- source: `runtime/hook_debt#${hook.hookId}`,
- reason: language === "en"
- ? "Narrative debt brief with original seed text for this hook agenda target."
- : "含原始种子文本的叙事债务简报。",
- excerpt: language === "en"
- ? [
- `${hook.hookId} (${hook.type}, ${role}, open ${age} chapters)`,
- `reader promise: ${promise}`,
- `original seed (ch${hook.startChapter}): ${seedBeat}`,
- latestBeat ? `latest turn (ch${hook.lastAdvancedChapter}): ${latestBeat}` : undefined,
- ].filter(Boolean).join(" | ")
- : [
- `${hook.hookId}(${hook.type},${role},已开${age}章)`,
- `读者承诺:${promise}`,
- `种于第${hook.startChapter}章:${seedBeat}`,
- latestBeat ? `推进于第${hook.lastAdvancedChapter}章:${latestBeat}` : undefined,
- ].filter(Boolean).join(" | "),
- }];
- });
- }
- async function maybeContextSource(
- storyDir: string,
- fileName: string,
- reason: string,
- preferredExcerpts: ReadonlyArray<string> = [],
- ): Promise<ContextPackage["selectedContext"][number] | null> {
- const path = join(storyDir, fileName);
- const content = await readFileOrDefault(path);
- if (!content || content === "(文件尚未创建)") return null;
- return {
- source: `story/${fileName}`,
- reason,
- excerpt: pickExcerpt(content, preferredExcerpts),
- };
- }
- function pickExcerpt(content: string, preferredExcerpts: ReadonlyArray<string>): string | undefined {
- for (const preferred of preferredExcerpts) {
- if (preferred && content.includes(preferred)) return preferred;
- }
- return content
- .split("\n")
- .map((line) => line.trim())
- .find((line) => line.length > 0 && !line.startsWith("#"));
- }
- function toFactAnchor(predicate: string): string {
- return predicate
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
- .replace(/^-+|-+$/g, "")
- || "fact";
- }
- async function readFileOrDefault(path: string): Promise<string> {
- try {
- return await readFile(path, "utf-8");
- } catch {
- return "(文件尚未创建)";
- }
- }
- function findHookSummary(
- summaries: ReadonlyArray<ReturnType<typeof parseChapterSummariesMarkdown>[number]>,
- hookId: string,
- chapter: number,
- mode: "seed" | "latest",
- ) {
- const directChapterHit = summaries.find((summary) => summary.chapter === chapter);
- const hookMentions = summaries.filter((summary) => summaryMentionsHook(summary, hookId));
- if (mode === "seed") {
- return hookMentions.find((summary) => summary.chapter === chapter)
- ?? hookMentions.at(0)
- ?? directChapterHit;
- }
- return [...hookMentions].reverse().find((summary) => summary.chapter === chapter)
- ?? hookMentions.at(-1)
- ?? directChapterHit;
- }
- function summaryMentionsHook(
- summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
- hookId: string,
- ): boolean {
- return [
- summary.title,
- summary.events,
- summary.stateChanges,
- summary.hookActivity,
- ].some((text) => text.includes(hookId));
- }
- function renderHookDebtBeat(
- summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
- ): string {
- return `ch${summary.chapter} ${summary.title} - ${summary.events || summary.hookActivity || summary.stateChanges || "(none)"}`;
- }
|