composer.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. import { readFile, readdir, mkdir } from "node:fs/promises";
  2. import { dirname, join } from "node:path";
  3. import { BaseAgent } from "./base.js";
  4. import type { BookConfig } from "../models/book.js";
  5. import {
  6. ContextPackageSchema,
  7. type ChapterTrace,
  8. type ContextPackage,
  9. type RuleStack,
  10. } from "../models/input-governance.js";
  11. import type { PlanChapterOutput } from "./planner.js";
  12. import {
  13. parseChapterSummariesMarkdown,
  14. retrieveMemorySelection,
  15. } from "../utils/memory-retrieval.js";
  16. import {
  17. buildGovernedRuleStack,
  18. buildGovernedTrace,
  19. } from "../utils/context-assembly.js";
  20. import { writeGovernedRuntimeArtifacts } from "../utils/runtime-writer.js";
  21. export interface ComposeChapterInput {
  22. readonly book: BookConfig;
  23. readonly bookDir: string;
  24. readonly chapterNumber: number;
  25. readonly plan: PlanChapterOutput;
  26. }
  27. export interface ComposeChapterOutput {
  28. readonly contextPackage: ContextPackage;
  29. readonly ruleStack: RuleStack;
  30. readonly trace: ChapterTrace;
  31. readonly contextPath: string;
  32. readonly ruleStackPath: string;
  33. readonly tracePath: string;
  34. }
  35. export async function composeGovernedChapter(input: ComposeChapterInput): Promise<ComposeChapterOutput> {
  36. const storyDir = join(input.bookDir, "story");
  37. const runtimeDir = join(storyDir, "runtime");
  38. await mkdir(runtimeDir, { recursive: true });
  39. const selectedContext = await collectSelectedContext(
  40. storyDir,
  41. input.plan,
  42. input.book.language ?? "zh",
  43. );
  44. const contextPackage = ContextPackageSchema.parse({
  45. chapter: input.chapterNumber,
  46. selectedContext,
  47. });
  48. const ruleStack = buildGovernedRuleStack(input.plan, input.chapterNumber);
  49. const trace = buildGovernedTrace({
  50. chapterNumber: input.chapterNumber,
  51. plan: input.plan,
  52. contextPackage,
  53. composerInputs: [input.plan.runtimePath],
  54. });
  55. const {
  56. contextPath,
  57. ruleStackPath,
  58. tracePath,
  59. } = await writeGovernedRuntimeArtifacts({
  60. runtimeDir,
  61. chapterNumber: input.chapterNumber,
  62. contextPackage,
  63. ruleStack,
  64. trace,
  65. });
  66. return {
  67. contextPackage,
  68. ruleStack,
  69. trace,
  70. contextPath,
  71. ruleStackPath,
  72. tracePath,
  73. };
  74. }
  75. export class ComposerAgent extends BaseAgent {
  76. get name(): string {
  77. return "composer";
  78. }
  79. async composeChapter(input: ComposeChapterInput): Promise<ComposeChapterOutput> {
  80. return composeGovernedChapter(input);
  81. }
  82. }
  83. async function collectSelectedContext(
  84. storyDir: string,
  85. plan: PlanChapterOutput,
  86. language: "zh" | "en",
  87. ): Promise<ContextPackage["selectedContext"]> {
  88. const retrievalHints = deriveRetrievalHints(plan);
  89. const memoBodyExcerpt = plan.memo.body.trim();
  90. const chapterMemoEntry = memoBodyExcerpt.length > 0
  91. ? [{
  92. source: "runtime/chapter_memo",
  93. reason: "Carry the planner's chapter memo into governed writing.",
  94. excerpt: [
  95. `goal=${plan.memo.goal}`,
  96. plan.memo.isGoldenOpening ? "golden-opening=true" : undefined,
  97. memoBodyExcerpt,
  98. ].filter(Boolean).join(" | "),
  99. }]
  100. : [{
  101. source: "runtime/chapter_memo",
  102. reason: "Carry the planner's chapter memo into governed writing.",
  103. excerpt: `goal=${plan.memo.goal}`,
  104. }];
  105. const entries = await Promise.all([
  106. maybeContextSource(storyDir, "current_focus.md", "Current task focus for this chapter."),
  107. maybeContextSource(
  108. storyDir,
  109. "audit_drift.md",
  110. "Carry forward audit drift guidance from the previous chapter without polluting hard state facts.",
  111. ),
  112. maybeContextSource(
  113. storyDir,
  114. "current_state.md",
  115. "Preserve hard state facts referenced by the active chapter brief or hard constraints.",
  116. retrievalHints,
  117. ),
  118. maybeContextSource(
  119. storyDir,
  120. "story_bible.md",
  121. "Preserve canon constraints referenced by the active chapter brief or hard constraints.",
  122. retrievalHints,
  123. ),
  124. maybeContextSource(
  125. storyDir,
  126. "volume_outline.md",
  127. "Anchor the default planning node for this chapter.",
  128. plan.intent.outlineNode ? [plan.intent.outlineNode] : [],
  129. ),
  130. maybeContextSource(
  131. storyDir,
  132. "parent_canon.md",
  133. "Preserve parent canon constraints for governed continuation or fanfic writing.",
  134. ),
  135. maybeContextSource(
  136. storyDir,
  137. "fanfic_canon.md",
  138. "Preserve extracted fanfic canon constraints for governed writing.",
  139. ),
  140. ]);
  141. const trailEntries = await buildRecentChapterTrailEntries(storyDir, plan.intent.chapter);
  142. const memorySelection = await retrieveMemorySelection({
  143. bookDir: dirname(storyDir),
  144. chapterNumber: plan.intent.chapter,
  145. goal: plan.intent.goal,
  146. outlineNode: plan.intent.outlineNode,
  147. mustKeep: retrievalHints,
  148. });
  149. const hookDebtEntries = await buildHookDebtEntries(
  150. storyDir,
  151. plan,
  152. memorySelection.activeHooks,
  153. language,
  154. );
  155. const summaryEntries = memorySelection.summaries.map((summary) => ({
  156. source: `story/chapter_summaries.md#${summary.chapter}`,
  157. reason: "Relevant episodic memory retrieved for the current chapter goal.",
  158. excerpt: [summary.title, summary.events, summary.stateChanges, summary.hookActivity]
  159. .filter(Boolean)
  160. .join(" | "),
  161. }));
  162. const factEntries = memorySelection.facts.map((fact) => ({
  163. source: `story/current_state.md#${toFactAnchor(fact.predicate)}`,
  164. reason: "Relevant current-state fact retrieved for the current chapter goal.",
  165. excerpt: `${fact.predicate} | ${fact.object}`,
  166. }));
  167. const hookEntries = memorySelection.hooks.map((hook) => ({
  168. source: `story/pending_hooks.md#${hook.hookId}`,
  169. reason: "Carry forward unresolved hooks that match the chapter focus.",
  170. excerpt: [hook.type, hook.status, hook.expectedPayoff, hook.payoffTiming, hook.notes]
  171. .filter(Boolean)
  172. .join(" | "),
  173. }));
  174. const volumeSummaryEntries = memorySelection.volumeSummaries.map((summary) => ({
  175. source: `story/volume_summaries.md#${summary.anchor}`,
  176. reason: "Carry forward long-span arc memory compressed from earlier volumes.",
  177. excerpt: `${summary.heading} | ${summary.content}`,
  178. }));
  179. return [
  180. ...chapterMemoEntry,
  181. ...entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
  182. ...trailEntries,
  183. ...hookDebtEntries,
  184. ...factEntries,
  185. ...summaryEntries,
  186. ...volumeSummaryEntries,
  187. ...hookEntries,
  188. ];
  189. }
  190. function deriveRetrievalHints(plan: PlanChapterOutput): string[] {
  191. return [
  192. plan.intent.goal,
  193. plan.intent.outlineNode,
  194. ...plan.memo.hookRefs,
  195. ].filter((value): value is string => Boolean(value));
  196. }
  197. async function buildRecentChapterTrailEntries(
  198. storyDir: string,
  199. chapterNumber: number,
  200. ): Promise<ContextPackage["selectedContext"]> {
  201. const content = await readFileOrDefault(join(storyDir, "chapter_summaries.md"));
  202. if (!content || content === "(文件尚未创建)") {
  203. return [];
  204. }
  205. const recentSummaries = parseChapterSummariesMarkdown(content)
  206. .filter((summary) => summary.chapter < chapterNumber)
  207. .sort((left, right) => right.chapter - left.chapter)
  208. .slice(0, 5);
  209. if (recentSummaries.length === 0) {
  210. return [];
  211. }
  212. const entries: ContextPackage["selectedContext"] = [];
  213. const recentTitles = recentSummaries
  214. .map((summary) => [summary.chapter, summary.title].filter(Boolean).join(": "))
  215. .filter(Boolean)
  216. .join(" | ");
  217. if (recentTitles) {
  218. entries.push({
  219. source: "story/chapter_summaries.md#recent_titles",
  220. reason: "Keep recent title history visible to avoid repetitive chapter naming.",
  221. excerpt: recentTitles,
  222. });
  223. }
  224. const moodTrail = recentSummaries
  225. .filter((summary) => summary.mood || summary.chapterType)
  226. .map((summary) => `${summary.chapter}: ${summary.mood || "(none)"} / ${summary.chapterType || "(none)"}`)
  227. .join(" | ");
  228. if (moodTrail) {
  229. entries.push({
  230. source: "story/chapter_summaries.md#recent_mood_type_trail",
  231. reason: "Keep recent mood and chapter-type cadence visible before writing the next chapter.",
  232. excerpt: moodTrail,
  233. });
  234. }
  235. const endingTrail = await buildRecentEndingTrail(storyDir, chapterNumber);
  236. if (endingTrail) {
  237. entries.push({
  238. source: "story/chapters#recent_endings",
  239. reason: "Show how recent chapters ended so the writer avoids structural repetition (e.g. 3 consecutive collapse endings).",
  240. excerpt: endingTrail,
  241. });
  242. }
  243. return entries;
  244. }
  245. async function buildRecentEndingTrail(
  246. storyDir: string,
  247. chapterNumber: number,
  248. ): Promise<string | undefined> {
  249. const chaptersDir = join(dirname(storyDir), "chapters");
  250. try {
  251. const files = await readdir(chaptersDir);
  252. const chapterFiles = files
  253. .filter((file) => file.endsWith(".md"))
  254. .map((file) => ({ file, num: parseInt(file.slice(0, 4), 10) }))
  255. .filter((entry) => Number.isFinite(entry.num) && entry.num < chapterNumber)
  256. .sort((a, b) => b.num - a.num)
  257. .slice(0, 3);
  258. const endings: string[] = [];
  259. for (const entry of chapterFiles.reverse()) {
  260. const content = await readFile(join(chaptersDir, entry.file), "utf-8");
  261. const lastLine = extractLastMeaningfulSentence(content);
  262. if (lastLine) {
  263. endings.push(`ch${entry.num}: ${lastLine}`);
  264. }
  265. }
  266. return endings.length >= 2 ? endings.join(" | ") : undefined;
  267. } catch {
  268. return undefined;
  269. }
  270. }
  271. function extractLastMeaningfulSentence(content: string): string | undefined {
  272. const lines = content.split("\n").map((line) => line.trim()).filter((line) =>
  273. line.length > 5 && !line.startsWith("#") && !line.startsWith("|") && !line.startsWith("==="),
  274. );
  275. const last = lines.at(-1);
  276. if (!last) return undefined;
  277. return last.length > 60 ? last.slice(0, 57) + "..." : last;
  278. }
  279. async function buildHookDebtEntries(
  280. storyDir: string,
  281. plan: PlanChapterOutput,
  282. activeHooks: ReadonlyArray<{
  283. readonly hookId: string;
  284. readonly startChapter: number;
  285. readonly type: string;
  286. readonly status: string;
  287. readonly lastAdvancedChapter: number;
  288. readonly expectedPayoff: string;
  289. readonly payoffTiming?: string;
  290. readonly notes: string;
  291. }>,
  292. language: "zh" | "en",
  293. ): Promise<ContextPackage["selectedContext"]> {
  294. const targetHookIds = [...new Set(plan.memo.hookRefs)];
  295. if (targetHookIds.length === 0) {
  296. return [];
  297. }
  298. const summaries = parseChapterSummariesMarkdown(
  299. await readFileOrDefault(join(storyDir, "chapter_summaries.md")),
  300. );
  301. return targetHookIds.flatMap((hookId) => {
  302. const hook = activeHooks.find((entry) => entry.hookId === hookId);
  303. if (!hook) {
  304. return [];
  305. }
  306. const seedSummary = findHookSummary(summaries, hook.hookId, hook.startChapter, "seed");
  307. const latestSummary = findHookSummary(summaries, hook.hookId, hook.lastAdvancedChapter, "latest");
  308. const role = language === "en" ? "memo-referenced debt" : "备忘引用旧债";
  309. const promise = hook.expectedPayoff || (language === "en" ? "(unspecified)" : "(未写明)");
  310. const seedBeat = seedSummary
  311. ? renderHookDebtBeat(seedSummary)
  312. : (hook.notes || promise);
  313. const latestBeat = latestSummary && latestSummary !== seedSummary
  314. ? renderHookDebtBeat(latestSummary)
  315. : undefined;
  316. const age = Math.max(0, plan.intent.chapter - Math.max(1, hook.startChapter));
  317. return [{
  318. source: `runtime/hook_debt#${hook.hookId}`,
  319. reason: language === "en"
  320. ? "Narrative debt brief with original seed text for this hook agenda target."
  321. : "含原始种子文本的叙事债务简报。",
  322. excerpt: language === "en"
  323. ? [
  324. `${hook.hookId} (${hook.type}, ${role}, open ${age} chapters)`,
  325. `reader promise: ${promise}`,
  326. `original seed (ch${hook.startChapter}): ${seedBeat}`,
  327. latestBeat ? `latest turn (ch${hook.lastAdvancedChapter}): ${latestBeat}` : undefined,
  328. ].filter(Boolean).join(" | ")
  329. : [
  330. `${hook.hookId}(${hook.type},${role},已开${age}章)`,
  331. `读者承诺:${promise}`,
  332. `种于第${hook.startChapter}章:${seedBeat}`,
  333. latestBeat ? `推进于第${hook.lastAdvancedChapter}章:${latestBeat}` : undefined,
  334. ].filter(Boolean).join(" | "),
  335. }];
  336. });
  337. }
  338. async function maybeContextSource(
  339. storyDir: string,
  340. fileName: string,
  341. reason: string,
  342. preferredExcerpts: ReadonlyArray<string> = [],
  343. ): Promise<ContextPackage["selectedContext"][number] | null> {
  344. const path = join(storyDir, fileName);
  345. const content = await readFileOrDefault(path);
  346. if (!content || content === "(文件尚未创建)") return null;
  347. return {
  348. source: `story/${fileName}`,
  349. reason,
  350. excerpt: pickExcerpt(content, preferredExcerpts),
  351. };
  352. }
  353. function pickExcerpt(content: string, preferredExcerpts: ReadonlyArray<string>): string | undefined {
  354. for (const preferred of preferredExcerpts) {
  355. if (preferred && content.includes(preferred)) return preferred;
  356. }
  357. return content
  358. .split("\n")
  359. .map((line) => line.trim())
  360. .find((line) => line.length > 0 && !line.startsWith("#"));
  361. }
  362. function toFactAnchor(predicate: string): string {
  363. return predicate
  364. .trim()
  365. .toLowerCase()
  366. .replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
  367. .replace(/^-+|-+$/g, "")
  368. || "fact";
  369. }
  370. async function readFileOrDefault(path: string): Promise<string> {
  371. try {
  372. return await readFile(path, "utf-8");
  373. } catch {
  374. return "(文件尚未创建)";
  375. }
  376. }
  377. function findHookSummary(
  378. summaries: ReadonlyArray<ReturnType<typeof parseChapterSummariesMarkdown>[number]>,
  379. hookId: string,
  380. chapter: number,
  381. mode: "seed" | "latest",
  382. ) {
  383. const directChapterHit = summaries.find((summary) => summary.chapter === chapter);
  384. const hookMentions = summaries.filter((summary) => summaryMentionsHook(summary, hookId));
  385. if (mode === "seed") {
  386. return hookMentions.find((summary) => summary.chapter === chapter)
  387. ?? hookMentions.at(0)
  388. ?? directChapterHit;
  389. }
  390. return [...hookMentions].reverse().find((summary) => summary.chapter === chapter)
  391. ?? hookMentions.at(-1)
  392. ?? directChapterHit;
  393. }
  394. function summaryMentionsHook(
  395. summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
  396. hookId: string,
  397. ): boolean {
  398. return [
  399. summary.title,
  400. summary.events,
  401. summary.stateChanges,
  402. summary.hookActivity,
  403. ].some((text) => text.includes(hookId));
  404. }
  405. function renderHookDebtBeat(
  406. summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
  407. ): string {
  408. return `ch${summary.chapter} ${summary.title} - ${summary.events || summary.hookActivity || summary.stateChanges || "(none)"}`;
  409. }