runner.ts 48 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319
  1. import type { LLMClient, OnStreamProgress } from "../llm/provider.js";
  2. import { chatCompletion, createLLMClient } from "../llm/provider.js";
  3. import type { Logger } from "../utils/logger.js";
  4. import type { BookConfig, FanficMode } from "../models/book.js";
  5. import type { ChapterMeta } from "../models/chapter.js";
  6. import type { NotifyChannel, LLMConfig, AgentLLMOverride, InputGovernanceMode } from "../models/project.js";
  7. import type { GenreProfile } from "../models/genre-profile.js";
  8. import { ArchitectAgent } from "../agents/architect.js";
  9. import { PlannerAgent } from "../agents/planner.js";
  10. import { ComposerAgent } from "../agents/composer.js";
  11. import { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "../agents/writer.js";
  12. import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
  13. import { ContinuityAuditor } from "../agents/continuity.js";
  14. import { ReviserAgent, type ReviseMode } from "../agents/reviser.js";
  15. import { StateValidatorAgent } from "../agents/state-validator.js";
  16. import { RadarAgent } from "../agents/radar.js";
  17. import type { RadarSource } from "../agents/radar-source.js";
  18. import { readGenreProfile } from "../agents/rules-reader.js";
  19. import { analyzeAITells } from "../agents/ai-tells.js";
  20. import { analyzeSensitiveWords } from "../agents/sensitive-words.js";
  21. import { StateManager } from "../state/manager.js";
  22. import { dispatchNotification, dispatchWebhookEvent } from "../notify/dispatcher.js";
  23. import type { WebhookEvent } from "../notify/webhook.js";
  24. import type { AgentContext } from "../agents/base.js";
  25. import type { AuditResult, AuditIssue } from "../agents/continuity.js";
  26. import type { RadarResult } from "../agents/radar.js";
  27. import { readFile, readdir, writeFile, mkdir } from "node:fs/promises";
  28. import { join } from "node:path";
  29. export interface PipelineConfig {
  30. readonly client: LLMClient;
  31. readonly model: string;
  32. readonly projectRoot: string;
  33. readonly defaultLLMConfig?: LLMConfig;
  34. readonly notifyChannels?: ReadonlyArray<NotifyChannel>;
  35. readonly radarSources?: ReadonlyArray<RadarSource>;
  36. readonly externalContext?: string;
  37. readonly modelOverrides?: Record<string, string | AgentLLMOverride>;
  38. readonly inputGovernanceMode?: InputGovernanceMode;
  39. readonly logger?: Logger;
  40. readonly onStreamProgress?: OnStreamProgress;
  41. }
  42. export interface TokenUsageSummary {
  43. readonly promptTokens: number;
  44. readonly completionTokens: number;
  45. readonly totalTokens: number;
  46. }
  47. export interface ChapterPipelineResult {
  48. readonly chapterNumber: number;
  49. readonly title: string;
  50. readonly wordCount: number;
  51. readonly auditResult: AuditResult;
  52. readonly revised: boolean;
  53. readonly status: "ready-for-review" | "audit-failed";
  54. readonly tokenUsage?: TokenUsageSummary;
  55. }
  56. // Atomic operation results
  57. export interface DraftResult {
  58. readonly chapterNumber: number;
  59. readonly title: string;
  60. readonly wordCount: number;
  61. readonly filePath: string;
  62. readonly tokenUsage?: TokenUsageSummary;
  63. }
  64. export interface PlanChapterResult {
  65. readonly bookId: string;
  66. readonly chapterNumber: number;
  67. readonly intentPath: string;
  68. readonly goal: string;
  69. readonly conflicts: ReadonlyArray<string>;
  70. }
  71. export interface ComposeChapterResult extends PlanChapterResult {
  72. readonly contextPath: string;
  73. readonly ruleStackPath: string;
  74. readonly tracePath: string;
  75. }
  76. export interface ReviseResult {
  77. readonly chapterNumber: number;
  78. readonly wordCount: number;
  79. readonly fixedIssues: ReadonlyArray<string>;
  80. }
  81. export interface TruthFiles {
  82. readonly currentState: string;
  83. readonly particleLedger: string;
  84. readonly pendingHooks: string;
  85. readonly storyBible: string;
  86. readonly volumeOutline: string;
  87. readonly bookRules: string;
  88. }
  89. export interface BookStatusInfo {
  90. readonly bookId: string;
  91. readonly title: string;
  92. readonly genre: string;
  93. readonly platform: string;
  94. readonly status: string;
  95. readonly chaptersWritten: number;
  96. readonly totalWords: number;
  97. readonly nextChapter: number;
  98. readonly chapters: ReadonlyArray<ChapterMeta>;
  99. }
  100. export interface ImportChaptersInput {
  101. readonly bookId: string;
  102. readonly chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>;
  103. readonly resumeFrom?: number;
  104. }
  105. export interface ImportChaptersResult {
  106. readonly bookId: string;
  107. readonly importedCount: number;
  108. readonly totalWords: number;
  109. readonly nextChapter: number;
  110. }
  111. export class PipelineRunner {
  112. private readonly state: StateManager;
  113. private readonly config: PipelineConfig;
  114. private readonly agentClients = new Map<string, LLMClient>();
  115. constructor(config: PipelineConfig) {
  116. this.config = config;
  117. this.state = new StateManager(config.projectRoot);
  118. }
  119. private agentCtx(bookId?: string): AgentContext {
  120. return {
  121. client: this.config.client,
  122. model: this.config.model,
  123. projectRoot: this.config.projectRoot,
  124. bookId,
  125. logger: this.config.logger,
  126. onStreamProgress: this.config.onStreamProgress,
  127. };
  128. }
  129. private resolveOverride(agentName: string): { model: string; client: LLMClient } {
  130. const override = this.config.modelOverrides?.[agentName];
  131. if (!override) {
  132. return { model: this.config.model, client: this.config.client };
  133. }
  134. if (typeof override === "string") {
  135. return { model: override, client: this.config.client };
  136. }
  137. // Full override — needs its own client if baseUrl differs
  138. if (!override.baseUrl) {
  139. return { model: override.model, client: this.config.client };
  140. }
  141. const base = this.config.defaultLLMConfig;
  142. const provider = override.provider ?? base?.provider ?? "custom";
  143. const apiKeySource = override.apiKeyEnv
  144. ? `env:${override.apiKeyEnv}`
  145. : `base:${base?.apiKey ?? ""}`;
  146. const stream = override.stream ?? base?.stream ?? true;
  147. const apiFormat = base?.apiFormat ?? "chat";
  148. const cacheKey = [
  149. provider,
  150. override.baseUrl,
  151. apiKeySource,
  152. `stream:${stream}`,
  153. `format:${apiFormat}`,
  154. ].join("|");
  155. let client = this.agentClients.get(cacheKey);
  156. if (!client) {
  157. const apiKey = override.apiKeyEnv
  158. ? process.env[override.apiKeyEnv] ?? ""
  159. : base?.apiKey ?? "";
  160. client = createLLMClient({
  161. provider,
  162. baseUrl: override.baseUrl,
  163. apiKey,
  164. model: override.model,
  165. temperature: base?.temperature ?? 0.7,
  166. maxTokens: base?.maxTokens ?? 8192,
  167. thinkingBudget: base?.thinkingBudget ?? 0,
  168. apiFormat,
  169. stream,
  170. });
  171. this.agentClients.set(cacheKey, client);
  172. }
  173. return { model: override.model, client };
  174. }
  175. private agentCtxFor(agent: string, bookId?: string): AgentContext {
  176. const { model, client } = this.resolveOverride(agent);
  177. return {
  178. client,
  179. model,
  180. projectRoot: this.config.projectRoot,
  181. bookId,
  182. logger: this.config.logger?.child(agent),
  183. onStreamProgress: this.config.onStreamProgress,
  184. };
  185. }
  186. private async loadGenreProfile(genre: string): Promise<{ profile: GenreProfile }> {
  187. const parsed = await readGenreProfile(this.config.projectRoot, genre);
  188. return { profile: parsed.profile };
  189. }
  190. // ---------------------------------------------------------------------------
  191. // Atomic operations (composable by OpenClaw or agent mode)
  192. // ---------------------------------------------------------------------------
  193. async runRadar(): Promise<RadarResult> {
  194. const radar = new RadarAgent(this.agentCtxFor("radar"), this.config.radarSources);
  195. return radar.scan();
  196. }
  197. async initBook(book: BookConfig): Promise<void> {
  198. const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
  199. const bookDir = this.state.bookDir(book.id);
  200. await this.state.saveBookConfig(book.id, book);
  201. const { profile: gp } = await this.loadGenreProfile(book.genre);
  202. const foundation = await architect.generateFoundation(book, this.config.externalContext);
  203. await architect.writeFoundationFiles(bookDir, foundation, gp.numericalSystem);
  204. await this.state.ensureControlDocuments(book.id, this.config.externalContext);
  205. // Ensure chapters directory exists (prevents ENOENT if init was previously interrupted)
  206. await mkdir(join(bookDir, "chapters"), { recursive: true });
  207. await this.state.saveChapterIndex(book.id, []);
  208. // Snapshot initial state so rewrite of chapter 1 can restore to pre-chapter state
  209. await this.state.snapshotState(book.id, 0);
  210. }
  211. /** Import external source material and generate fanfic_canon.md */
  212. async importFanficCanon(
  213. bookId: string,
  214. sourceText: string,
  215. sourceName: string,
  216. fanficMode: FanficMode,
  217. ): Promise<string> {
  218. const { FanficCanonImporter } = await import("../agents/fanfic-canon-importer.js");
  219. const importer = new FanficCanonImporter(this.agentCtxFor("fanfic-canon-importer", bookId));
  220. const result = await importer.importFromText(sourceText, sourceName, fanficMode);
  221. const bookDir = this.state.bookDir(bookId);
  222. const storyDir = join(bookDir, "story");
  223. await mkdir(storyDir, { recursive: true });
  224. await writeFile(join(storyDir, "fanfic_canon.md"), result.fullDocument, "utf-8");
  225. return result.fullDocument;
  226. }
  227. /** One-step fanfic book creation: create book + import canon + generate foundation */
  228. async initFanficBook(
  229. book: BookConfig,
  230. sourceText: string,
  231. sourceName: string,
  232. fanficMode: FanficMode,
  233. ): Promise<void> {
  234. const bookDir = this.state.bookDir(book.id);
  235. await this.state.saveBookConfig(book.id, book);
  236. // Step 1: Import source material → fanfic_canon.md
  237. const fanficCanon = await this.importFanficCanon(book.id, sourceText, sourceName, fanficMode);
  238. // Step 2: Generate foundation from fanfic canon (not from scratch)
  239. const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
  240. const { profile: gp } = await this.loadGenreProfile(book.genre);
  241. const foundation = await architect.generateFanficFoundation(book, fanficCanon, fanficMode);
  242. await architect.writeFoundationFiles(bookDir, foundation, gp.numericalSystem);
  243. await this.state.ensureControlDocuments(book.id, this.config.externalContext);
  244. // Step 3: Initialize chapters directory + snapshot
  245. await mkdir(join(bookDir, "chapters"), { recursive: true });
  246. await this.state.saveChapterIndex(book.id, []);
  247. await this.state.snapshotState(book.id, 0);
  248. }
  249. /** Write a single draft chapter. Saves chapter file + truth files + index + snapshot. */
  250. async writeDraft(bookId: string, context?: string, wordCount?: number): Promise<DraftResult> {
  251. const releaseLock = await this.state.acquireBookLock(bookId);
  252. try {
  253. await this.state.ensureControlDocuments(bookId);
  254. const book = await this.state.loadBookConfig(bookId);
  255. const bookDir = this.state.bookDir(bookId);
  256. const chapterNumber = await this.state.getNextChapterNumber(bookId);
  257. const writeInput = await this.prepareWriteInput(
  258. book,
  259. bookDir,
  260. chapterNumber,
  261. context ?? this.config.externalContext,
  262. );
  263. const { profile: gp } = await this.loadGenreProfile(book.genre);
  264. const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
  265. const output = await writer.writeChapter({
  266. book,
  267. bookDir,
  268. chapterNumber,
  269. ...writeInput,
  270. ...(wordCount ? { wordCountOverride: wordCount } : {}),
  271. });
  272. // Save chapter file
  273. const chaptersDir = join(bookDir, "chapters");
  274. const paddedNum = String(chapterNumber).padStart(4, "0");
  275. const sanitized = output.title.replace(/[/\\?%*:|"<>]/g, "").replace(/\s+/g, "_").slice(0, 50);
  276. const filename = `${paddedNum}_${sanitized}.md`;
  277. const filePath = join(chaptersDir, filename);
  278. const resolvedLang = book.language ?? gp.language;
  279. const heading = resolvedLang === "en"
  280. ? `# Chapter ${chapterNumber}: ${output.title}`
  281. : `# 第${chapterNumber}章 ${output.title}`;
  282. await writeFile(filePath, `${heading}\n\n${output.content}`, "utf-8");
  283. // Save truth files
  284. await writer.saveChapter(bookDir, output, gp.numericalSystem, resolvedLang);
  285. await writer.saveNewTruthFiles(bookDir, output);
  286. // Update index
  287. const existingIndex = await this.state.loadChapterIndex(bookId);
  288. const now = new Date().toISOString();
  289. const newEntry: ChapterMeta = {
  290. number: chapterNumber,
  291. title: output.title,
  292. status: "drafted",
  293. wordCount: output.wordCount,
  294. createdAt: now,
  295. updatedAt: now,
  296. auditIssues: [],
  297. ...(output.tokenUsage ? { tokenUsage: output.tokenUsage } : {}),
  298. };
  299. await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
  300. // Snapshot
  301. await this.state.snapshotState(bookId, chapterNumber);
  302. await this.emitWebhook("chapter-complete", bookId, chapterNumber, {
  303. title: output.title,
  304. wordCount: output.wordCount,
  305. });
  306. return { chapterNumber, title: output.title, wordCount: output.wordCount, filePath, tokenUsage: output.tokenUsage };
  307. } finally {
  308. await releaseLock();
  309. }
  310. }
  311. async planChapter(bookId: string, context?: string): Promise<PlanChapterResult> {
  312. await this.state.ensureControlDocuments(bookId);
  313. const book = await this.state.loadBookConfig(bookId);
  314. const bookDir = this.state.bookDir(bookId);
  315. const chapterNumber = await this.state.getNextChapterNumber(bookId);
  316. const { plan } = await this.createGovernedArtifacts(
  317. book,
  318. bookDir,
  319. chapterNumber,
  320. context ?? this.config.externalContext,
  321. );
  322. return {
  323. bookId,
  324. chapterNumber,
  325. intentPath: this.relativeToBookDir(bookDir, plan.runtimePath),
  326. goal: plan.intent.goal,
  327. conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`),
  328. };
  329. }
  330. async composeChapter(bookId: string, context?: string): Promise<ComposeChapterResult> {
  331. await this.state.ensureControlDocuments(bookId);
  332. const book = await this.state.loadBookConfig(bookId);
  333. const bookDir = this.state.bookDir(bookId);
  334. const chapterNumber = await this.state.getNextChapterNumber(bookId);
  335. const { plan, composed } = await this.createGovernedArtifacts(
  336. book,
  337. bookDir,
  338. chapterNumber,
  339. context ?? this.config.externalContext,
  340. );
  341. return {
  342. bookId,
  343. chapterNumber,
  344. intentPath: this.relativeToBookDir(bookDir, plan.runtimePath),
  345. goal: plan.intent.goal,
  346. conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`),
  347. contextPath: this.relativeToBookDir(bookDir, composed.contextPath),
  348. ruleStackPath: this.relativeToBookDir(bookDir, composed.ruleStackPath),
  349. tracePath: this.relativeToBookDir(bookDir, composed.tracePath),
  350. };
  351. }
  352. /** Audit the latest (or specified) chapter. Read-only, no lock needed. */
  353. async auditDraft(bookId: string, chapterNumber?: number): Promise<AuditResult & { readonly chapterNumber: number }> {
  354. const book = await this.state.loadBookConfig(bookId);
  355. const bookDir = this.state.bookDir(bookId);
  356. const targetChapter = chapterNumber ?? (await this.state.getNextChapterNumber(bookId)) - 1;
  357. if (targetChapter < 1) {
  358. throw new Error(`No chapters to audit for "${bookId}"`);
  359. }
  360. const content = await this.readChapterContent(bookDir, targetChapter);
  361. const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
  362. const llmResult = await auditor.auditChapter(bookDir, content, targetChapter, book.genre);
  363. // Merge rule-based AI-tell detection
  364. const aiTells = analyzeAITells(content);
  365. // Merge sensitive word detection
  366. const sensitiveResult = analyzeSensitiveWords(content);
  367. const hasBlockedWords = sensitiveResult.found.some((f) => f.severity === "block");
  368. const mergedIssues: ReadonlyArray<AuditIssue> = [
  369. ...llmResult.issues,
  370. ...aiTells.issues,
  371. ...sensitiveResult.issues,
  372. ];
  373. const result: AuditResult = {
  374. passed: hasBlockedWords ? false : llmResult.passed,
  375. issues: mergedIssues,
  376. summary: llmResult.summary,
  377. };
  378. // Update index with audit result
  379. const index = await this.state.loadChapterIndex(bookId);
  380. const updated = index.map((ch) =>
  381. ch.number === targetChapter
  382. ? {
  383. ...ch,
  384. status: (result.passed ? "ready-for-review" : "audit-failed") as ChapterMeta["status"],
  385. updatedAt: new Date().toISOString(),
  386. auditIssues: result.issues.map((i) => `[${i.severity}] ${i.description}`),
  387. }
  388. : ch,
  389. );
  390. await this.state.saveChapterIndex(bookId, updated);
  391. await this.emitWebhook(
  392. result.passed ? "audit-passed" : "audit-failed",
  393. bookId,
  394. targetChapter,
  395. { summary: result.summary, issueCount: result.issues.length },
  396. );
  397. return { ...result, chapterNumber: targetChapter };
  398. }
  399. /** Revise the latest (or specified) chapter based on audit issues. */
  400. async reviseDraft(bookId: string, chapterNumber?: number, mode: ReviseMode = "rewrite"): Promise<ReviseResult> {
  401. const releaseLock = await this.state.acquireBookLock(bookId);
  402. try {
  403. const book = await this.state.loadBookConfig(bookId);
  404. const bookDir = this.state.bookDir(bookId);
  405. const targetChapter = chapterNumber ?? (await this.state.getNextChapterNumber(bookId)) - 1;
  406. if (targetChapter < 1) {
  407. throw new Error(`No chapters to revise for "${bookId}"`);
  408. }
  409. // Read the current audit issues from index
  410. const index = await this.state.loadChapterIndex(bookId);
  411. const chapterMeta = index.find((ch) => ch.number === targetChapter);
  412. if (!chapterMeta) {
  413. throw new Error(`Chapter ${targetChapter} not found in index`);
  414. }
  415. // Re-audit to get structured issues (index only stores strings)
  416. const content = await this.readChapterContent(bookDir, targetChapter);
  417. const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
  418. const auditResult = await auditor.auditChapter(bookDir, content, targetChapter, book.genre);
  419. if (auditResult.passed && auditResult.issues.filter(i => i.severity === "warning" || i.severity === "critical").length === 0) {
  420. return { chapterNumber: targetChapter, wordCount: content.length, fixedIssues: [] };
  421. }
  422. const { profile: gp } = await this.loadGenreProfile(book.genre);
  423. const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
  424. const reviseOutput = await reviser.reviseChapter(
  425. bookDir, content, targetChapter, auditResult.issues, mode, book.genre,
  426. );
  427. if (reviseOutput.revisedContent.length === 0) {
  428. throw new Error("Reviser returned empty content");
  429. }
  430. // Save revised chapter file
  431. const chaptersDir = join(bookDir, "chapters");
  432. const files = await readdir(chaptersDir);
  433. const paddedNum = String(targetChapter).padStart(4, "0");
  434. const existingFile = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  435. if (!existingFile) {
  436. throw new Error(`Chapter ${targetChapter} file not found in ${chaptersDir} (expected filename starting with ${paddedNum})`);
  437. }
  438. const reviseLang = book.language ?? gp.language;
  439. const reviseHeading = reviseLang === "en"
  440. ? `# Chapter ${targetChapter}: ${chapterMeta.title}`
  441. : `# 第${targetChapter}章 ${chapterMeta.title}`;
  442. await writeFile(
  443. join(chaptersDir, existingFile),
  444. `${reviseHeading}\n\n${reviseOutput.revisedContent}`,
  445. "utf-8",
  446. );
  447. // Update truth files
  448. const storyDir = join(bookDir, "story");
  449. if (reviseOutput.updatedState !== "(状态卡未更新)") {
  450. await writeFile(join(storyDir, "current_state.md"), reviseOutput.updatedState, "utf-8");
  451. }
  452. if (gp.numericalSystem && reviseOutput.updatedLedger && reviseOutput.updatedLedger !== "(账本未更新)") {
  453. await writeFile(join(storyDir, "particle_ledger.md"), reviseOutput.updatedLedger, "utf-8");
  454. }
  455. if (reviseOutput.updatedHooks !== "(伏笔池未更新)") {
  456. await writeFile(join(storyDir, "pending_hooks.md"), reviseOutput.updatedHooks, "utf-8");
  457. }
  458. // Update index
  459. const updatedIndex = index.map((ch) =>
  460. ch.number === targetChapter
  461. ? {
  462. ...ch,
  463. status: "ready-for-review" as ChapterMeta["status"],
  464. wordCount: reviseOutput.wordCount,
  465. updatedAt: new Date().toISOString(),
  466. }
  467. : ch,
  468. );
  469. await this.state.saveChapterIndex(bookId, updatedIndex);
  470. // Re-snapshot
  471. await this.state.snapshotState(bookId, targetChapter);
  472. await this.emitWebhook("revision-complete", bookId, targetChapter, {
  473. wordCount: reviseOutput.wordCount,
  474. fixedCount: reviseOutput.fixedIssues.length,
  475. });
  476. return {
  477. chapterNumber: targetChapter,
  478. wordCount: reviseOutput.wordCount,
  479. fixedIssues: reviseOutput.fixedIssues,
  480. };
  481. } finally {
  482. await releaseLock();
  483. }
  484. }
  485. /** Read all truth files for a book. */
  486. async readTruthFiles(bookId: string): Promise<TruthFiles> {
  487. const bookDir = this.state.bookDir(bookId);
  488. const storyDir = join(bookDir, "story");
  489. const readSafe = async (path: string): Promise<string> => {
  490. try {
  491. return await readFile(path, "utf-8");
  492. } catch {
  493. return "(文件不存在)";
  494. }
  495. };
  496. const [currentState, particleLedger, pendingHooks, storyBible, volumeOutline, bookRules] =
  497. await Promise.all([
  498. readSafe(join(storyDir, "current_state.md")),
  499. readSafe(join(storyDir, "particle_ledger.md")),
  500. readSafe(join(storyDir, "pending_hooks.md")),
  501. readSafe(join(storyDir, "story_bible.md")),
  502. readSafe(join(storyDir, "volume_outline.md")),
  503. readSafe(join(storyDir, "book_rules.md")),
  504. ]);
  505. return { currentState, particleLedger, pendingHooks, storyBible, volumeOutline, bookRules };
  506. }
  507. /** Get book status overview. */
  508. async getBookStatus(bookId: string): Promise<BookStatusInfo> {
  509. const book = await this.state.loadBookConfig(bookId);
  510. const chapters = await this.state.loadChapterIndex(bookId);
  511. const nextChapter = await this.state.getNextChapterNumber(bookId);
  512. const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
  513. return {
  514. bookId,
  515. title: book.title,
  516. genre: book.genre,
  517. platform: book.platform,
  518. status: book.status,
  519. chaptersWritten: chapters.length,
  520. totalWords,
  521. nextChapter,
  522. chapters: [...chapters],
  523. };
  524. }
  525. // ---------------------------------------------------------------------------
  526. // Full pipeline (convenience — runs draft + audit + revise in one shot)
  527. // ---------------------------------------------------------------------------
  528. async writeNextChapter(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
  529. const releaseLock = await this.state.acquireBookLock(bookId);
  530. try {
  531. return await this._writeNextChapterLocked(bookId, wordCount, temperatureOverride);
  532. } finally {
  533. await releaseLock();
  534. }
  535. }
  536. private async _writeNextChapterLocked(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
  537. await this.state.ensureControlDocuments(bookId);
  538. const book = await this.state.loadBookConfig(bookId);
  539. const bookDir = this.state.bookDir(bookId);
  540. const chapterNumber = await this.state.getNextChapterNumber(bookId);
  541. const writeInput = await this.prepareWriteInput(
  542. book,
  543. bookDir,
  544. chapterNumber,
  545. this.config.externalContext,
  546. );
  547. const reducedControlInput = writeInput.chapterIntent && writeInput.contextPackage && writeInput.ruleStack
  548. ? {
  549. chapterIntent: writeInput.chapterIntent,
  550. contextPackage: writeInput.contextPackage,
  551. ruleStack: writeInput.ruleStack,
  552. }
  553. : undefined;
  554. const { profile: gp } = await this.loadGenreProfile(book.genre);
  555. // 1. Write chapter
  556. const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
  557. const output = await writer.writeChapter({
  558. book,
  559. bookDir,
  560. chapterNumber,
  561. ...writeInput,
  562. ...(wordCount ? { wordCountOverride: wordCount } : {}),
  563. ...(temperatureOverride ? { temperatureOverride } : {}),
  564. });
  565. // Token usage accumulator
  566. let totalUsage: TokenUsageSummary = output.tokenUsage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
  567. // 2a. Post-write error gate: if deterministic rules found errors, auto-fix before LLM audit
  568. let finalContent = output.content;
  569. let finalWordCount = output.wordCount;
  570. let revised = false;
  571. if (output.postWriteErrors.length > 0) {
  572. this.config.logger?.warn(
  573. `${output.postWriteErrors.length} post-write errors detected, triggering spot-fix before audit`,
  574. );
  575. const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
  576. const spotFixIssues = output.postWriteErrors.map((v) => ({
  577. severity: "critical" as const,
  578. category: v.rule,
  579. description: v.description,
  580. suggestion: v.suggestion,
  581. }));
  582. const fixResult = await reviser.reviseChapter(
  583. bookDir,
  584. finalContent,
  585. chapterNumber,
  586. spotFixIssues,
  587. "spot-fix",
  588. book.genre,
  589. reducedControlInput,
  590. );
  591. totalUsage = PipelineRunner.addUsage(totalUsage, fixResult.tokenUsage);
  592. if (fixResult.revisedContent.length > 0) {
  593. finalContent = fixResult.revisedContent;
  594. finalWordCount = fixResult.wordCount;
  595. revised = true;
  596. }
  597. }
  598. // 2b. LLM audit
  599. const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
  600. const llmAudit = await auditor.auditChapter(
  601. bookDir,
  602. finalContent,
  603. chapterNumber,
  604. book.genre,
  605. reducedControlInput,
  606. );
  607. totalUsage = PipelineRunner.addUsage(totalUsage, llmAudit.tokenUsage);
  608. const aiTellsResult = analyzeAITells(finalContent);
  609. const sensitiveWriteResult = analyzeSensitiveWords(finalContent);
  610. const hasBlockedWriteWords = sensitiveWriteResult.found.some((f) => f.severity === "block");
  611. let auditResult: AuditResult = {
  612. passed: hasBlockedWriteWords ? false : llmAudit.passed,
  613. issues: [...llmAudit.issues, ...aiTellsResult.issues, ...sensitiveWriteResult.issues],
  614. summary: llmAudit.summary,
  615. };
  616. // 3. If audit fails, try auto-revise once
  617. if (!auditResult.passed) {
  618. const criticalIssues = auditResult.issues.filter(
  619. (i) => i.severity === "critical",
  620. );
  621. if (criticalIssues.length > 0) {
  622. const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
  623. const reviseOutput = await reviser.reviseChapter(
  624. bookDir,
  625. finalContent,
  626. chapterNumber,
  627. auditResult.issues,
  628. "spot-fix",
  629. book.genre,
  630. reducedControlInput,
  631. );
  632. totalUsage = PipelineRunner.addUsage(totalUsage, reviseOutput.tokenUsage);
  633. if (reviseOutput.revisedContent.length > 0) {
  634. // Guard: reject revision if AI markers increased
  635. const preMarkers = analyzeAITells(finalContent);
  636. const postMarkers = analyzeAITells(reviseOutput.revisedContent);
  637. const preCount = preMarkers.issues.length;
  638. const postCount = postMarkers.issues.length;
  639. if (postCount > preCount) {
  640. // Revision made text MORE AI-like — discard it, keep original
  641. } else {
  642. finalContent = reviseOutput.revisedContent;
  643. finalWordCount = reviseOutput.wordCount;
  644. revised = true;
  645. }
  646. // Re-audit the (possibly revised) content
  647. const reAudit = await auditor.auditChapter(
  648. bookDir,
  649. finalContent,
  650. chapterNumber,
  651. book.genre,
  652. { ...reducedControlInput, temperature: 0 },
  653. );
  654. totalUsage = PipelineRunner.addUsage(totalUsage, reAudit.tokenUsage);
  655. const reAITells = analyzeAITells(finalContent);
  656. const reSensitive = analyzeSensitiveWords(finalContent);
  657. const reHasBlocked = reSensitive.found.some((f) => f.severity === "block");
  658. auditResult = {
  659. passed: reHasBlocked ? false : reAudit.passed,
  660. issues: [...reAudit.issues, ...reAITells.issues, ...reSensitive.issues],
  661. summary: reAudit.summary,
  662. };
  663. }
  664. }
  665. }
  666. // 4. Save the final chapter and truth files from a single persistence source
  667. const persistenceOutput = await this.buildPersistenceOutput(
  668. bookId,
  669. book,
  670. bookDir,
  671. chapterNumber,
  672. output,
  673. finalContent,
  674. );
  675. finalWordCount = persistenceOutput.wordCount;
  676. const pipelineLang = book.language ?? gp.language;
  677. // 4.1 Validate settler output before writing (non-blocking)
  678. try {
  679. const storyDir = join(bookDir, "story");
  680. const [oldState, oldHooks] = await Promise.all([
  681. readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
  682. readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
  683. ]);
  684. const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
  685. const validation = await validator.validate(
  686. finalContent, chapterNumber,
  687. oldState, persistenceOutput.updatedState,
  688. oldHooks, persistenceOutput.updatedHooks,
  689. pipelineLang,
  690. );
  691. if (validation.warnings.length > 0) {
  692. this.config.logger?.warn(`State validation: ${validation.warnings.length} warning(s) for chapter ${chapterNumber}`);
  693. for (const w of validation.warnings) {
  694. this.config.logger?.warn(` [${w.category}] ${w.description}`);
  695. }
  696. }
  697. } catch (e) {
  698. this.config.logger?.warn(`State validation skipped: ${e}`);
  699. }
  700. await writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang);
  701. await writer.saveNewTruthFiles(bookDir, persistenceOutput);
  702. // 5. Update chapter index
  703. const existingIndex = await this.state.loadChapterIndex(bookId);
  704. const now = new Date().toISOString();
  705. const newEntry: ChapterMeta = {
  706. number: chapterNumber,
  707. title: persistenceOutput.title,
  708. status: auditResult.passed ? "ready-for-review" : "audit-failed",
  709. wordCount: finalWordCount,
  710. createdAt: now,
  711. updatedAt: now,
  712. auditIssues: auditResult.issues.map(
  713. (i) => `[${i.severity}] ${i.description}`,
  714. ),
  715. tokenUsage: totalUsage,
  716. };
  717. await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
  718. // 5.5 Audit drift correction — feed audit findings back into state
  719. // This prevents the writer from repeating mistakes in the next chapter
  720. const driftIssues = auditResult.issues.filter(
  721. (i) => i.severity === "critical" || i.severity === "warning",
  722. );
  723. if (driftIssues.length > 0) {
  724. const storyDir = join(bookDir, "story");
  725. try {
  726. const statePath = join(storyDir, "current_state.md");
  727. const currentState = await readFile(statePath, "utf-8").catch(() => "");
  728. // Append drift correction section (or replace existing one)
  729. const correctionHeader = "## 审计纠偏(自动生成,下一章写作前参照)";
  730. const correctionBlock = [
  731. correctionHeader,
  732. `> 第${chapterNumber}章审计发现以下问题,下一章写作时必须避免:`,
  733. ...driftIssues.map((i) => `> - [${i.severity}] ${i.category}: ${i.description}`),
  734. "",
  735. ].join("\n");
  736. // Replace existing correction block or append
  737. const existingCorrectionIdx = currentState.indexOf(correctionHeader);
  738. const updatedState = existingCorrectionIdx >= 0
  739. ? currentState.slice(0, existingCorrectionIdx) + correctionBlock
  740. : currentState + "\n\n" + correctionBlock;
  741. await writeFile(statePath, updatedState, "utf-8");
  742. } catch {
  743. // Non-critical — don't block pipeline if drift correction fails
  744. }
  745. }
  746. // 5.6 Snapshot state for rollback support
  747. await this.state.snapshotState(bookId, chapterNumber);
  748. // 6. Send notification
  749. if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
  750. const statusEmoji = auditResult.passed ? "✅" : "⚠️";
  751. await dispatchNotification(this.config.notifyChannels, {
  752. title: `${statusEmoji} ${book.title} 第${chapterNumber}章`,
  753. body: [
  754. `**${persistenceOutput.title}** | ${finalWordCount}字`,
  755. revised ? "📝 已自动修正" : "",
  756. `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`,
  757. ...auditResult.issues
  758. .filter((i) => i.severity !== "info")
  759. .map((i) => `- [${i.severity}] ${i.description}`),
  760. ]
  761. .filter(Boolean)
  762. .join("\n"),
  763. });
  764. }
  765. await this.emitWebhook("pipeline-complete", bookId, chapterNumber, {
  766. title: persistenceOutput.title,
  767. wordCount: finalWordCount,
  768. passed: auditResult.passed,
  769. revised,
  770. });
  771. return {
  772. chapterNumber,
  773. title: persistenceOutput.title,
  774. wordCount: finalWordCount,
  775. auditResult,
  776. revised,
  777. status: auditResult.passed ? "ready-for-review" : "audit-failed",
  778. tokenUsage: totalUsage,
  779. };
  780. }
  781. // ---------------------------------------------------------------------------
  782. // Import operations (style imitation + canon for spinoff)
  783. // ---------------------------------------------------------------------------
  784. /**
  785. * Generate a qualitative style guide from reference text via LLM.
  786. * Also saves the statistical style_profile.json.
  787. */
  788. async generateStyleGuide(bookId: string, referenceText: string, sourceName?: string): Promise<string> {
  789. if (referenceText.length < 500) {
  790. throw new Error(`Reference text too short (${referenceText.length} chars, minimum 500). Provide at least 2000 chars for reliable style extraction.`);
  791. }
  792. const { analyzeStyle } = await import("../agents/style-analyzer.js");
  793. const bookDir = this.state.bookDir(bookId);
  794. const storyDir = join(bookDir, "story");
  795. await mkdir(storyDir, { recursive: true });
  796. // Statistical fingerprint
  797. const profile = analyzeStyle(referenceText, sourceName);
  798. await writeFile(join(storyDir, "style_profile.json"), JSON.stringify(profile, null, 2), "utf-8");
  799. // LLM qualitative extraction
  800. const response = await chatCompletion(this.config.client, this.config.model, [
  801. {
  802. role: "system",
  803. content: `你是一位文学风格分析专家。分析参考文本的写作风格,提取可供模仿的定性特征。
  804. 输出格式(Markdown):
  805. ## 叙事声音与语气
  806. (冷峻/热烈/讽刺/温情/...,附1-2个原文例句)
  807. ## 对话风格
  808. (角色说话的共性特征:句子长短、口头禅倾向、方言痕迹、对话节奏)
  809. ## 场景描写特征
  810. (五感偏好、意象选择、描写密度、环境与情绪的关联方式)
  811. ## 转折与衔接手法
  812. (场景如何切换、时间跳跃的处理方式、段落间的过渡特征)
  813. ## 节奏特征
  814. (长短句分布、段落长度偏好、高潮/舒缓的交替方式)
  815. ## 词汇偏好
  816. (高频特色用词、比喻/修辞倾向、口语化程度)
  817. ## 情绪表达方式
  818. (直白抒情 vs 动作外化、内心独白的频率和风格)
  819. ## 独特习惯
  820. (任何值得模仿的个人写作习惯)
  821. 分析必须基于原文实际特征,不要泛泛而谈。每个部分用1-2个原文例句佐证。`,
  822. },
  823. {
  824. role: "user",
  825. content: `分析以下参考文本的写作风格:\n\n${referenceText.slice(0, 20000)}`,
  826. },
  827. ], { temperature: 0.3, maxTokens: 4096 });
  828. await writeFile(join(storyDir, "style_guide.md"), response.content, "utf-8");
  829. return response.content;
  830. }
  831. /**
  832. * Import canon from parent book for spinoff writing.
  833. * Reads parent's truth files, uses LLM to generate parent_canon.md in target book.
  834. */
  835. async importCanon(targetBookId: string, parentBookId: string): Promise<string> {
  836. // Validate both books exist
  837. const bookIds = await this.state.listBooks();
  838. if (!bookIds.includes(parentBookId)) {
  839. throw new Error(`Parent book "${parentBookId}" not found. Available: ${bookIds.join(", ") || "(none)"}`);
  840. }
  841. if (!bookIds.includes(targetBookId)) {
  842. throw new Error(`Target book "${targetBookId}" not found. Available: ${bookIds.join(", ") || "(none)"}`);
  843. }
  844. const parentDir = this.state.bookDir(parentBookId);
  845. const targetDir = this.state.bookDir(targetBookId);
  846. const storyDir = join(targetDir, "story");
  847. await mkdir(storyDir, { recursive: true });
  848. const readSafe = async (path: string): Promise<string> => {
  849. try { return await readFile(path, "utf-8"); } catch { return "(无)"; }
  850. };
  851. const parentBook = await this.state.loadBookConfig(parentBookId);
  852. const [storyBible, currentState, ledger, hooks, summaries, subplots, emotions, matrix] =
  853. await Promise.all([
  854. readSafe(join(parentDir, "story/story_bible.md")),
  855. readSafe(join(parentDir, "story/current_state.md")),
  856. readSafe(join(parentDir, "story/particle_ledger.md")),
  857. readSafe(join(parentDir, "story/pending_hooks.md")),
  858. readSafe(join(parentDir, "story/chapter_summaries.md")),
  859. readSafe(join(parentDir, "story/subplot_board.md")),
  860. readSafe(join(parentDir, "story/emotional_arcs.md")),
  861. readSafe(join(parentDir, "story/character_matrix.md")),
  862. ]);
  863. const response = await chatCompletion(this.config.client, this.config.model, [
  864. {
  865. role: "system",
  866. content: `你是一位网络小说架构师。基于正传的全部设定和状态文件,生成一份完整的"正传正典参照"文档,供番外写作和审计使用。
  867. 输出格式(Markdown):
  868. # 正传正典(《{正传书名}》)
  869. ## 世界规则(完整,来自正传设定)
  870. (力量体系、地理设定、阵营关系、核心规则——完整复制,不压缩)
  871. ## 正典约束(不可违反的事实)
  872. | 约束ID | 类型 | 约束内容 | 严重性 |
  873. |---|---|---|---|
  874. | C01 | 人物存亡 | ... | critical |
  875. (列出所有硬性约束:谁活着、谁死了、什么事件已经发生、什么规则不可违反)
  876. ## 角色快照
  877. | 角色 | 当前状态 | 性格底色 | 对话特征 | 已知信息 | 未知信息 |
  878. |---|---|---|---|---|---|
  879. (从状态卡和角色矩阵中提取每个重要角色的完整快照)
  880. ## 角色双态处理原则
  881. - 未来会变强的角色:写潜力暗示
  882. - 未来会黑化的角色:写微小裂痕
  883. - 未来会死的角色:写导致死亡的性格底色
  884. ## 关键事件时间线
  885. | 章节 | 事件 | 涉及角色 | 对番外的约束 |
  886. |---|---|---|---|
  887. (从章节摘要中提取关键事件)
  888. ## 伏笔状态
  889. | Hook ID | 类型 | 状态 | 内容 | 预期回收 |
  890. |---|---|---|---|---|
  891. ## 资源账本快照
  892. (当前资源状态)
  893. ---
  894. meta:
  895. parentBookId: "{parentBookId}"
  896. parentTitle: "{正传书名}"
  897. generatedAt: "{ISO timestamp}"
  898. 要求:
  899. 1. 世界规则完整复制,不压缩——准确性优先
  900. 2. 正典约束必须穷尽,遗漏会导致番外与正传矛盾
  901. 3. 角色快照必须包含信息边界(已知/未知),防止番外中角色引用不该知道的信息`,
  902. },
  903. {
  904. role: "user",
  905. content: `正传书名:${parentBook.title}
  906. 正传ID:${parentBookId}
  907. ## 正传世界设定
  908. ${storyBible}
  909. ## 正传当前状态卡
  910. ${currentState}
  911. ## 正传资源账本
  912. ${ledger}
  913. ## 正传伏笔池
  914. ${hooks}
  915. ## 正传章节摘要
  916. ${summaries}
  917. ## 正传支线进度
  918. ${subplots}
  919. ## 正传情感弧线
  920. ${emotions}
  921. ## 正传角色矩阵
  922. ${matrix}`,
  923. },
  924. ], { temperature: 0.3, maxTokens: 16384 });
  925. // Append deterministic meta block (LLM may hallucinate timestamps)
  926. const metaBlock = [
  927. "",
  928. "---",
  929. "meta:",
  930. ` parentBookId: "${parentBookId}"`,
  931. ` parentTitle: "${parentBook.title}"`,
  932. ` generatedAt: "${new Date().toISOString()}"`,
  933. ].join("\n");
  934. const canon = response.content + metaBlock;
  935. await writeFile(join(storyDir, "parent_canon.md"), canon, "utf-8");
  936. return canon;
  937. }
  938. // ---------------------------------------------------------------------------
  939. // Chapter import (for continuation writing from existing chapters)
  940. // ---------------------------------------------------------------------------
  941. /**
  942. * Import existing chapters into a book. Reverse-engineers all truth files
  943. * via sequential replay so the Writer and Auditor can continue naturally.
  944. *
  945. * Step 1: Generate foundation (story_bible, volume_outline, book_rules) from all chapters.
  946. * Step 2: Sequentially replay each chapter through ChapterAnalyzer to build truth files.
  947. */
  948. async importChapters(input: ImportChaptersInput): Promise<ImportChaptersResult> {
  949. const releaseLock = await this.state.acquireBookLock(input.bookId);
  950. try {
  951. const book = await this.state.loadBookConfig(input.bookId);
  952. const bookDir = this.state.bookDir(input.bookId);
  953. const { profile: gp } = await this.loadGenreProfile(book.genre);
  954. const startFrom = input.resumeFrom ?? 1;
  955. const log = this.config.logger?.child("import");
  956. // Step 1: Generate foundation on first run (not on resume)
  957. if (startFrom === 1) {
  958. log?.info(`Step 1: Generating foundation from ${input.chapters.length} chapters...`);
  959. const allText = input.chapters.map((c, i) =>
  960. `第${i + 1}章 ${c.title}\n\n${c.content}`,
  961. ).join("\n\n---\n\n");
  962. const architect = new ArchitectAgent(this.agentCtxFor("architect", input.bookId));
  963. const foundation = await architect.generateFoundationFromImport(book, allText);
  964. await architect.writeFoundationFiles(bookDir, foundation, gp.numericalSystem);
  965. await this.state.saveChapterIndex(input.bookId, []);
  966. log?.info("Foundation generated.");
  967. }
  968. // Step 2: Sequential replay
  969. log?.info(`Step 2: Sequential replay from chapter ${startFrom}...`);
  970. const analyzer = new ChapterAnalyzerAgent(this.agentCtxFor("chapter-analyzer", input.bookId));
  971. const writer = new WriterAgent(this.agentCtxFor("writer", input.bookId));
  972. let totalWords = 0;
  973. let importedCount = 0;
  974. for (let i = startFrom - 1; i < input.chapters.length; i++) {
  975. const ch = input.chapters[i]!;
  976. const chapterNumber = i + 1;
  977. log?.info(`Analyzing chapter ${chapterNumber}/${input.chapters.length}: ${ch.title}...`);
  978. // Analyze chapter to get truth file updates
  979. const output = await analyzer.analyzeChapter({
  980. book,
  981. bookDir,
  982. chapterNumber,
  983. chapterContent: ch.content,
  984. chapterTitle: ch.title,
  985. });
  986. // Save chapter file + core truth files (state, ledger, hooks)
  987. await writer.saveChapter(bookDir, {
  988. ...output,
  989. postWriteErrors: [],
  990. postWriteWarnings: [],
  991. }, gp.numericalSystem);
  992. // Save extended truth files (summaries, subplots, emotional arcs, character matrix)
  993. await writer.saveNewTruthFiles(bookDir, {
  994. ...output,
  995. postWriteErrors: [],
  996. postWriteWarnings: [],
  997. });
  998. // Update chapter index
  999. const existingIndex = await this.state.loadChapterIndex(input.bookId);
  1000. const now = new Date().toISOString();
  1001. const newEntry: ChapterMeta = {
  1002. number: chapterNumber,
  1003. title: output.title,
  1004. status: "imported",
  1005. wordCount: ch.content.length,
  1006. createdAt: now,
  1007. updatedAt: now,
  1008. auditIssues: [],
  1009. };
  1010. // Replace if exists (resume case), otherwise append
  1011. const existingIdx = existingIndex.findIndex((e) => e.number === chapterNumber);
  1012. const updatedIndex = existingIdx >= 0
  1013. ? existingIndex.map((e, idx) => idx === existingIdx ? newEntry : e)
  1014. : [...existingIndex, newEntry];
  1015. await this.state.saveChapterIndex(input.bookId, updatedIndex);
  1016. // Snapshot state after each chapter for rollback + resume support
  1017. await this.state.snapshotState(input.bookId, chapterNumber);
  1018. importedCount++;
  1019. totalWords += ch.content.length;
  1020. }
  1021. const nextChapter = input.chapters.length + 1;
  1022. log?.info(`Done. ${importedCount} chapters imported, ${totalWords} chars. Next chapter: ${nextChapter}`);
  1023. return {
  1024. bookId: input.bookId,
  1025. importedCount,
  1026. totalWords,
  1027. nextChapter,
  1028. };
  1029. } finally {
  1030. await releaseLock();
  1031. }
  1032. }
  1033. private static addUsage(
  1034. a: TokenUsageSummary,
  1035. b?: { readonly promptTokens: number; readonly completionTokens: number; readonly totalTokens: number },
  1036. ): TokenUsageSummary {
  1037. if (!b) return a;
  1038. return {
  1039. promptTokens: a.promptTokens + b.promptTokens,
  1040. completionTokens: a.completionTokens + b.completionTokens,
  1041. totalTokens: a.totalTokens + b.totalTokens,
  1042. };
  1043. }
  1044. private async buildPersistenceOutput(
  1045. bookId: string,
  1046. book: BookConfig,
  1047. bookDir: string,
  1048. chapterNumber: number,
  1049. output: WriteChapterOutput,
  1050. finalContent: string,
  1051. ): Promise<WriteChapterOutput> {
  1052. if (finalContent === output.content) {
  1053. return output;
  1054. }
  1055. const analyzer = new ChapterAnalyzerAgent(this.agentCtxFor("chapter-analyzer", bookId));
  1056. const analyzed = await analyzer.analyzeChapter({
  1057. book,
  1058. bookDir,
  1059. chapterNumber,
  1060. chapterContent: finalContent,
  1061. chapterTitle: output.title,
  1062. });
  1063. return {
  1064. ...analyzed,
  1065. postWriteErrors: [],
  1066. postWriteWarnings: [],
  1067. tokenUsage: output.tokenUsage,
  1068. };
  1069. }
  1070. // ---------------------------------------------------------------------------
  1071. // Helpers
  1072. // ---------------------------------------------------------------------------
  1073. private async prepareWriteInput(
  1074. book: BookConfig,
  1075. bookDir: string,
  1076. chapterNumber: number,
  1077. externalContext?: string,
  1078. ): Promise<Pick<WriteChapterInput, "externalContext" | "chapterIntent" | "contextPackage" | "ruleStack" | "trace">> {
  1079. if ((this.config.inputGovernanceMode ?? "legacy") === "legacy") {
  1080. return { externalContext };
  1081. }
  1082. const { plan, composed } = await this.createGovernedArtifacts(
  1083. book,
  1084. bookDir,
  1085. chapterNumber,
  1086. externalContext,
  1087. );
  1088. return {
  1089. chapterIntent: plan.intentMarkdown,
  1090. contextPackage: composed.contextPackage,
  1091. ruleStack: composed.ruleStack,
  1092. trace: composed.trace,
  1093. };
  1094. }
  1095. private async createGovernedArtifacts(
  1096. book: BookConfig,
  1097. bookDir: string,
  1098. chapterNumber: number,
  1099. externalContext?: string,
  1100. ): Promise<{
  1101. plan: Awaited<ReturnType<PlannerAgent["planChapter"]>>;
  1102. composed: Awaited<ReturnType<ComposerAgent["composeChapter"]>>;
  1103. }> {
  1104. const planner = new PlannerAgent(this.agentCtxFor("planner", book.id));
  1105. const plan = await planner.planChapter({
  1106. book,
  1107. bookDir,
  1108. chapterNumber,
  1109. externalContext,
  1110. });
  1111. const composer = new ComposerAgent(this.agentCtxFor("composer", book.id));
  1112. const composed = await composer.composeChapter({
  1113. book,
  1114. bookDir,
  1115. chapterNumber,
  1116. plan,
  1117. });
  1118. return { plan, composed };
  1119. }
  1120. private relativeToBookDir(bookDir: string, absolutePath: string): string {
  1121. const prefix = `${bookDir}/`;
  1122. return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : absolutePath;
  1123. }
  1124. private async emitWebhook(
  1125. event: WebhookEvent,
  1126. bookId: string,
  1127. chapterNumber?: number,
  1128. data?: Record<string, unknown>,
  1129. ): Promise<void> {
  1130. if (!this.config.notifyChannels || this.config.notifyChannels.length === 0) return;
  1131. await dispatchWebhookEvent(this.config.notifyChannels, {
  1132. event,
  1133. bookId,
  1134. chapterNumber,
  1135. timestamp: new Date().toISOString(),
  1136. data,
  1137. });
  1138. }
  1139. private async readChapterContent(bookDir: string, chapterNumber: number): Promise<string> {
  1140. const chaptersDir = join(bookDir, "chapters");
  1141. const files = await readdir(chaptersDir);
  1142. const paddedNum = String(chapterNumber).padStart(4, "0");
  1143. const chapterFile = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  1144. if (!chapterFile) {
  1145. throw new Error(`Chapter ${chapterNumber} file not found in ${chaptersDir}`);
  1146. }
  1147. const raw = await readFile(join(chaptersDir, chapterFile), "utf-8");
  1148. // Strip the title line
  1149. const lines = raw.split("\n");
  1150. const contentStart = lines.findIndex((l, i) => i > 0 && l.trim().length > 0);
  1151. return contentStart >= 0 ? lines.slice(contentStart).join("\n") : raw;
  1152. }
  1153. }