runner.ts 101 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779
  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, type PlanChapterOutput } from "../agents/planner.js";
  10. import { ComposerAgent } from "../agents/composer.js";
  11. import { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "../agents/writer.js";
  12. import { LengthNormalizerAgent } from "../agents/length-normalizer.js";
  13. import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
  14. import { ContinuityAuditor } from "../agents/continuity.js";
  15. import { ReviserAgent, DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js";
  16. import { StateValidatorAgent, type ValidationResult, type ValidationWarning } from "../agents/state-validator.js";
  17. import { RadarAgent } from "../agents/radar.js";
  18. import type { RadarSource } from "../agents/radar-source.js";
  19. import { readGenreProfile } from "../agents/rules-reader.js";
  20. import { analyzeAITells } from "../agents/ai-tells.js";
  21. import { analyzeSensitiveWords } from "../agents/sensitive-words.js";
  22. import { StateManager } from "../state/manager.js";
  23. import { MemoryDB, type Fact } from "../state/memory-db.js";
  24. import { dispatchNotification, dispatchWebhookEvent } from "../notify/dispatcher.js";
  25. import type { WebhookEvent } from "../notify/webhook.js";
  26. import type { AgentContext } from "../agents/base.js";
  27. import type { AuditResult, AuditIssue } from "../agents/continuity.js";
  28. import type { RadarResult } from "../agents/radar.js";
  29. import type { LengthSpec, LengthTelemetry } from "../models/length-governance.js";
  30. import type { ContextPackage, RuleStack } from "../models/input-governance.js";
  31. import { buildLengthSpec, countChapterLength, formatLengthCount, isOutsideHardRange, isOutsideSoftRange, resolveLengthCountingMode, type LengthLanguage } from "../utils/length-metrics.js";
  32. import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js";
  33. import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state/runtime-state-store.js";
  34. import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
  35. import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/promises";
  36. import { join } from "node:path";
  37. import {
  38. buildStateDegradedPersistenceOutput,
  39. buildStateDegradedReviewNote,
  40. parseStateDegradedReviewNote,
  41. resolveStateDegradedBaseStatus,
  42. retrySettlementAfterValidationFailure,
  43. } from "./chapter-state-recovery.js";
  44. import { loadPersistedPlan, relativeToBookDir } from "./persisted-governed-plan.js";
  45. export interface PipelineConfig {
  46. readonly client: LLMClient;
  47. readonly model: string;
  48. readonly projectRoot: string;
  49. readonly defaultLLMConfig?: LLMConfig;
  50. readonly notifyChannels?: ReadonlyArray<NotifyChannel>;
  51. readonly radarSources?: ReadonlyArray<RadarSource>;
  52. readonly externalContext?: string;
  53. readonly modelOverrides?: Record<string, string | AgentLLMOverride>;
  54. readonly inputGovernanceMode?: InputGovernanceMode;
  55. readonly logger?: Logger;
  56. readonly onStreamProgress?: OnStreamProgress;
  57. }
  58. export interface TokenUsageSummary {
  59. readonly promptTokens: number;
  60. readonly completionTokens: number;
  61. readonly totalTokens: number;
  62. }
  63. export interface ChapterPipelineResult {
  64. readonly chapterNumber: number;
  65. readonly title: string;
  66. readonly wordCount: number;
  67. readonly auditResult: AuditResult;
  68. readonly revised: boolean;
  69. readonly status: "ready-for-review" | "audit-failed" | "state-degraded";
  70. readonly lengthWarnings?: ReadonlyArray<string>;
  71. readonly lengthTelemetry?: LengthTelemetry;
  72. readonly tokenUsage?: TokenUsageSummary;
  73. }
  74. // Atomic operation results
  75. export interface DraftResult {
  76. readonly chapterNumber: number;
  77. readonly title: string;
  78. readonly wordCount: number;
  79. readonly filePath: string;
  80. readonly lengthWarnings?: ReadonlyArray<string>;
  81. readonly lengthTelemetry?: LengthTelemetry;
  82. readonly tokenUsage?: TokenUsageSummary;
  83. }
  84. export interface PlanChapterResult {
  85. readonly bookId: string;
  86. readonly chapterNumber: number;
  87. readonly intentPath: string;
  88. readonly goal: string;
  89. readonly conflicts: ReadonlyArray<string>;
  90. }
  91. export interface ComposeChapterResult extends PlanChapterResult {
  92. readonly contextPath: string;
  93. readonly ruleStackPath: string;
  94. readonly tracePath: string;
  95. }
  96. export interface ReviseResult {
  97. readonly chapterNumber: number;
  98. readonly wordCount: number;
  99. readonly fixedIssues: ReadonlyArray<string>;
  100. readonly applied: boolean;
  101. readonly status: "unchanged" | "ready-for-review" | "audit-failed";
  102. readonly skippedReason?: string;
  103. readonly lengthWarnings?: ReadonlyArray<string>;
  104. readonly lengthTelemetry?: LengthTelemetry;
  105. }
  106. export interface TruthFiles {
  107. readonly currentState: string;
  108. readonly particleLedger: string;
  109. readonly pendingHooks: string;
  110. readonly storyBible: string;
  111. readonly volumeOutline: string;
  112. readonly bookRules: string;
  113. }
  114. export interface BookStatusInfo {
  115. readonly bookId: string;
  116. readonly title: string;
  117. readonly genre: string;
  118. readonly platform: string;
  119. readonly status: string;
  120. readonly chaptersWritten: number;
  121. readonly totalWords: number;
  122. readonly nextChapter: number;
  123. readonly chapters: ReadonlyArray<ChapterMeta>;
  124. }
  125. export interface ImportChaptersInput {
  126. readonly bookId: string;
  127. readonly chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>;
  128. readonly resumeFrom?: number;
  129. }
  130. export interface ImportChaptersResult {
  131. readonly bookId: string;
  132. readonly importedCount: number;
  133. readonly totalWords: number;
  134. readonly nextChapter: number;
  135. }
  136. export class PipelineRunner {
  137. private readonly state: StateManager;
  138. private readonly config: PipelineConfig;
  139. private readonly agentClients = new Map<string, LLMClient>();
  140. private memoryIndexFallbackWarned = false;
  141. constructor(config: PipelineConfig) {
  142. this.config = config;
  143. this.state = new StateManager(config.projectRoot);
  144. }
  145. private localize(language: LengthLanguage, messages: { zh: string; en: string }): string {
  146. return language === "en" ? messages.en : messages.zh;
  147. }
  148. private async resolveBookLanguage(
  149. book: Pick<BookConfig, "genre" | "language">,
  150. ): Promise<LengthLanguage> {
  151. if (book.language) {
  152. return book.language;
  153. }
  154. try {
  155. const { profile } = await this.loadGenreProfile(book.genre);
  156. return profile.language;
  157. } catch {
  158. return "zh";
  159. }
  160. }
  161. private async resolveBookLanguageById(bookId: string): Promise<LengthLanguage> {
  162. try {
  163. const book = await this.state.loadBookConfig(bookId);
  164. return await this.resolveBookLanguage(book);
  165. } catch {
  166. return "zh";
  167. }
  168. }
  169. private languageFromLengthSpec(lengthSpec: Pick<LengthSpec, "countingMode">): LengthLanguage {
  170. return lengthSpec.countingMode === "en_words" ? "en" : "zh";
  171. }
  172. private logStage(language: LengthLanguage, message: { zh: string; en: string }): void {
  173. this.config.logger?.info(
  174. `${this.localize(language, { zh: "阶段:", en: "Stage: " })}${this.localize(language, message)}`,
  175. );
  176. }
  177. private logInfo(language: LengthLanguage, message: { zh: string; en: string }): void {
  178. this.config.logger?.info(this.localize(language, message));
  179. }
  180. private logWarn(language: LengthLanguage, message: { zh: string; en: string }): void {
  181. this.config.logger?.warn(this.localize(language, message));
  182. }
  183. private async tryGenerateStyleGuide(
  184. bookId: string,
  185. referenceText: string,
  186. sourceName: string | undefined,
  187. language?: LengthLanguage,
  188. ): Promise<void> {
  189. try {
  190. await this.generateStyleGuide(bookId, referenceText, sourceName);
  191. } catch (error) {
  192. const resolvedLanguage = language ?? await this.resolveBookLanguageById(bookId);
  193. const detail = error instanceof Error ? error.message : String(error);
  194. this.logWarn(resolvedLanguage, {
  195. zh: `风格指纹提取失败,已跳过:${detail}`,
  196. en: `Style fingerprint extraction failed and was skipped: ${detail}`,
  197. });
  198. }
  199. }
  200. private agentCtx(bookId?: string): AgentContext {
  201. return {
  202. client: this.config.client,
  203. model: this.config.model,
  204. projectRoot: this.config.projectRoot,
  205. bookId,
  206. logger: this.config.logger,
  207. onStreamProgress: this.config.onStreamProgress,
  208. };
  209. }
  210. private resolveOverride(agentName: string): { model: string; client: LLMClient } {
  211. const override = this.config.modelOverrides?.[agentName];
  212. if (!override) {
  213. return { model: this.config.model, client: this.config.client };
  214. }
  215. if (typeof override === "string") {
  216. return { model: override, client: this.config.client };
  217. }
  218. // Full override — needs its own client if baseUrl differs
  219. if (!override.baseUrl) {
  220. return { model: override.model, client: this.config.client };
  221. }
  222. const base = this.config.defaultLLMConfig;
  223. const provider = override.provider ?? base?.provider ?? "custom";
  224. const apiKeySource = override.apiKeyEnv
  225. ? `env:${override.apiKeyEnv}`
  226. : `base:${base?.apiKey ?? ""}`;
  227. const stream = override.stream ?? base?.stream ?? true;
  228. const apiFormat = base?.apiFormat ?? "chat";
  229. const cacheKey = [
  230. provider,
  231. override.baseUrl,
  232. apiKeySource,
  233. `stream:${stream}`,
  234. `format:${apiFormat}`,
  235. ].join("|");
  236. let client = this.agentClients.get(cacheKey);
  237. if (!client) {
  238. const apiKey = override.apiKeyEnv
  239. ? process.env[override.apiKeyEnv] ?? ""
  240. : base?.apiKey ?? "";
  241. client = createLLMClient({
  242. provider,
  243. baseUrl: override.baseUrl,
  244. apiKey,
  245. model: override.model,
  246. temperature: base?.temperature ?? 0.7,
  247. maxTokens: base?.maxTokens ?? 8192,
  248. thinkingBudget: base?.thinkingBudget ?? 0,
  249. apiFormat,
  250. stream,
  251. });
  252. this.agentClients.set(cacheKey, client);
  253. }
  254. return { model: override.model, client };
  255. }
  256. private agentCtxFor(agent: string, bookId?: string): AgentContext {
  257. const { model, client } = this.resolveOverride(agent);
  258. return {
  259. client,
  260. model,
  261. projectRoot: this.config.projectRoot,
  262. bookId,
  263. logger: this.config.logger?.child(agent),
  264. onStreamProgress: this.config.onStreamProgress,
  265. };
  266. }
  267. private async pathExists(path: string): Promise<boolean> {
  268. try {
  269. await stat(path);
  270. return true;
  271. } catch {
  272. return false;
  273. }
  274. }
  275. private async loadGenreProfile(genre: string): Promise<{ profile: GenreProfile }> {
  276. const parsed = await readGenreProfile(this.config.projectRoot, genre);
  277. return { profile: parsed.profile };
  278. }
  279. // ---------------------------------------------------------------------------
  280. // Atomic operations (composable by OpenClaw or agent mode)
  281. // ---------------------------------------------------------------------------
  282. async runRadar(): Promise<RadarResult> {
  283. const radar = new RadarAgent(this.agentCtxFor("radar"), this.config.radarSources);
  284. return radar.scan();
  285. }
  286. async initBook(book: BookConfig): Promise<void> {
  287. const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
  288. const bookDir = this.state.bookDir(book.id);
  289. const stagingBookDir = join(
  290. this.state.booksDir,
  291. `.tmp-book-create-${book.id}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
  292. );
  293. const stageLanguage = await this.resolveBookLanguage(book);
  294. this.logStage(stageLanguage, { zh: "生成基础设定", en: "generating foundation" });
  295. const { profile: gp } = await this.loadGenreProfile(book.genre);
  296. const foundation = await architect.generateFoundation(book, this.config.externalContext);
  297. try {
  298. this.logStage(stageLanguage, { zh: "保存书籍配置", en: "saving book config" });
  299. await this.state.saveBookConfigAt(stagingBookDir, book);
  300. this.logStage(stageLanguage, { zh: "写入基础设定文件", en: "writing foundation files" });
  301. await architect.writeFoundationFiles(
  302. stagingBookDir,
  303. foundation,
  304. gp.numericalSystem,
  305. book.language ?? gp.language,
  306. );
  307. this.logStage(stageLanguage, { zh: "初始化控制文档", en: "initializing control documents" });
  308. await this.state.ensureControlDocumentsAt(
  309. stagingBookDir,
  310. book.language ?? gp.language,
  311. this.config.externalContext,
  312. );
  313. await this.state.saveChapterIndexAt(stagingBookDir, []);
  314. this.logStage(stageLanguage, { zh: "创建初始快照", en: "creating initial snapshot" });
  315. await this.state.snapshotStateAt(stagingBookDir, 0);
  316. if (await this.pathExists(bookDir)) {
  317. if (await this.state.isCompleteBookDirectory(bookDir)) {
  318. throw new Error(`Book "${book.id}" already exists at books/${book.id}/. Use a different title or delete the existing book first.`);
  319. }
  320. await rm(bookDir, { recursive: true, force: true });
  321. }
  322. await rename(stagingBookDir, bookDir);
  323. } catch (error) {
  324. await rm(stagingBookDir, { recursive: true, force: true }).catch(() => undefined);
  325. throw error;
  326. }
  327. }
  328. /** Import external source material and generate fanfic_canon.md */
  329. async importFanficCanon(
  330. bookId: string,
  331. sourceText: string,
  332. sourceName: string,
  333. fanficMode: FanficMode,
  334. ): Promise<string> {
  335. const { FanficCanonImporter } = await import("../agents/fanfic-canon-importer.js");
  336. const importer = new FanficCanonImporter(this.agentCtxFor("fanfic-canon-importer", bookId));
  337. const result = await importer.importFromText(sourceText, sourceName, fanficMode);
  338. const bookDir = this.state.bookDir(bookId);
  339. const storyDir = join(bookDir, "story");
  340. await mkdir(storyDir, { recursive: true });
  341. await writeFile(join(storyDir, "fanfic_canon.md"), result.fullDocument, "utf-8");
  342. return result.fullDocument;
  343. }
  344. /** One-step fanfic book creation: create book + import canon + generate foundation */
  345. async initFanficBook(
  346. book: BookConfig,
  347. sourceText: string,
  348. sourceName: string,
  349. fanficMode: FanficMode,
  350. ): Promise<void> {
  351. const bookDir = this.state.bookDir(book.id);
  352. const stageLanguage = await this.resolveBookLanguage(book);
  353. this.logStage(stageLanguage, { zh: "保存书籍配置", en: "saving book config" });
  354. await this.state.saveBookConfig(book.id, book);
  355. // Step 1: Import source material → fanfic_canon.md
  356. this.logStage(stageLanguage, { zh: "导入同人正典", en: "importing fanfic canon" });
  357. const fanficCanon = await this.importFanficCanon(book.id, sourceText, sourceName, fanficMode);
  358. // Step 2: Generate foundation from fanfic canon (not from scratch)
  359. const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
  360. this.logStage(stageLanguage, { zh: "生成同人基础设定", en: "generating fanfic foundation" });
  361. const { profile: gp } = await this.loadGenreProfile(book.genre);
  362. const foundation = await architect.generateFanficFoundation(book, fanficCanon, fanficMode);
  363. this.logStage(stageLanguage, { zh: "写入基础设定文件", en: "writing foundation files" });
  364. await architect.writeFoundationFiles(
  365. bookDir,
  366. foundation,
  367. gp.numericalSystem,
  368. book.language ?? gp.language,
  369. );
  370. this.logStage(stageLanguage, { zh: "初始化控制文档", en: "initializing control documents" });
  371. await this.state.ensureControlDocuments(book.id, this.config.externalContext);
  372. // Step 3: Generate style guide from source material
  373. if (sourceText.length >= 500) {
  374. this.logStage(stageLanguage, { zh: "提取原作风格指纹", en: "extracting source style fingerprint" });
  375. await this.tryGenerateStyleGuide(book.id, sourceText, sourceName, stageLanguage);
  376. }
  377. // Step 4: Initialize chapters directory + snapshot
  378. this.logStage(stageLanguage, { zh: "创建初始快照", en: "creating initial snapshot" });
  379. await mkdir(join(bookDir, "chapters"), { recursive: true });
  380. await this.state.saveChapterIndex(book.id, []);
  381. await this.state.snapshotState(book.id, 0);
  382. }
  383. /** Write a single draft chapter. Saves chapter file + truth files + index + snapshot. */
  384. async writeDraft(bookId: string, context?: string, wordCount?: number): Promise<DraftResult> {
  385. const releaseLock = await this.state.acquireBookLock(bookId);
  386. try {
  387. await this.state.ensureControlDocuments(bookId);
  388. const book = await this.state.loadBookConfig(bookId);
  389. const bookDir = this.state.bookDir(bookId);
  390. const chapterNumber = await this.state.getNextChapterNumber(bookId);
  391. const stageLanguage = await this.resolveBookLanguage(book);
  392. this.logStage(stageLanguage, { zh: "准备章节输入", en: "preparing chapter inputs" });
  393. const writeInput = await this.prepareWriteInput(
  394. book,
  395. bookDir,
  396. chapterNumber,
  397. context ?? this.config.externalContext,
  398. );
  399. const { profile: gp } = await this.loadGenreProfile(book.genre);
  400. const lengthSpec = buildLengthSpec(
  401. wordCount ?? book.chapterWordCount,
  402. book.language ?? gp.language,
  403. );
  404. const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
  405. this.logStage(stageLanguage, { zh: "撰写章节草稿", en: "writing chapter draft" });
  406. const output = await writer.writeChapter({
  407. book,
  408. bookDir,
  409. chapterNumber,
  410. ...writeInput,
  411. lengthSpec,
  412. ...(wordCount ? { wordCountOverride: wordCount } : {}),
  413. });
  414. const writerCount = countChapterLength(output.content, lengthSpec.countingMode);
  415. let totalUsage: TokenUsageSummary = output.tokenUsage ?? {
  416. promptTokens: 0,
  417. completionTokens: 0,
  418. totalTokens: 0,
  419. };
  420. const normalizedDraft = await this.normalizeDraftLengthIfNeeded({
  421. bookId,
  422. chapterNumber,
  423. chapterContent: output.content,
  424. lengthSpec,
  425. chapterIntent: writeInput.chapterIntent,
  426. });
  427. totalUsage = PipelineRunner.addUsage(totalUsage, normalizedDraft.tokenUsage);
  428. const draftOutput: WriteChapterOutput = {
  429. ...output,
  430. content: normalizedDraft.content,
  431. wordCount: normalizedDraft.wordCount,
  432. tokenUsage: totalUsage,
  433. };
  434. const lengthWarnings = this.buildLengthWarnings(
  435. chapterNumber,
  436. draftOutput.wordCount,
  437. lengthSpec,
  438. );
  439. const lengthTelemetry = this.buildLengthTelemetry({
  440. lengthSpec,
  441. writerCount,
  442. postWriterNormalizeCount: normalizedDraft.wordCount,
  443. postReviseCount: 0,
  444. finalCount: draftOutput.wordCount,
  445. normalizeApplied: normalizedDraft.applied,
  446. lengthWarning: lengthWarnings.length > 0,
  447. });
  448. this.logLengthWarnings(lengthWarnings);
  449. // Save chapter file
  450. const chaptersDir = join(bookDir, "chapters");
  451. const paddedNum = String(chapterNumber).padStart(4, "0");
  452. const sanitized = draftOutput.title.replace(/[/\\?%*:|"<>]/g, "").replace(/\s+/g, "_").slice(0, 50);
  453. const filename = `${paddedNum}_${sanitized}.md`;
  454. const filePath = join(chaptersDir, filename);
  455. const resolvedLang = book.language ?? gp.language;
  456. const heading = resolvedLang === "en"
  457. ? `# Chapter ${chapterNumber}: ${draftOutput.title}`
  458. : `# 第${chapterNumber}章 ${draftOutput.title}`;
  459. await writeFile(filePath, `${heading}\n\n${draftOutput.content}`, "utf-8");
  460. // Save truth files
  461. this.logStage(stageLanguage, { zh: "落盘草稿与真相文件", en: "persisting draft and truth files" });
  462. await writer.saveChapter(bookDir, draftOutput, gp.numericalSystem, resolvedLang);
  463. await writer.saveNewTruthFiles(bookDir, draftOutput, resolvedLang);
  464. await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, draftOutput);
  465. await this.syncNarrativeMemoryIndex(bookId);
  466. // Update index
  467. const existingIndex = await this.state.loadChapterIndex(bookId);
  468. const now = new Date().toISOString();
  469. const newEntry: ChapterMeta = {
  470. number: chapterNumber,
  471. title: draftOutput.title,
  472. status: "drafted",
  473. wordCount: draftOutput.wordCount,
  474. createdAt: now,
  475. updatedAt: now,
  476. auditIssues: [],
  477. lengthWarnings,
  478. lengthTelemetry,
  479. ...(draftOutput.tokenUsage ? { tokenUsage: draftOutput.tokenUsage } : {}),
  480. };
  481. await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
  482. await this.markBookActiveIfNeeded(bookId);
  483. // Snapshot
  484. this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" });
  485. await this.state.snapshotState(bookId, chapterNumber);
  486. await this.syncCurrentStateFactHistory(bookId, chapterNumber);
  487. await this.emitWebhook("chapter-complete", bookId, chapterNumber, {
  488. title: draftOutput.title,
  489. wordCount: draftOutput.wordCount,
  490. });
  491. return {
  492. chapterNumber,
  493. title: draftOutput.title,
  494. wordCount: draftOutput.wordCount,
  495. filePath,
  496. lengthWarnings,
  497. lengthTelemetry,
  498. tokenUsage: draftOutput.tokenUsage,
  499. };
  500. } finally {
  501. await releaseLock();
  502. }
  503. }
  504. async planChapter(bookId: string, context?: string): Promise<PlanChapterResult> {
  505. await this.state.ensureControlDocuments(bookId);
  506. const book = await this.state.loadBookConfig(bookId);
  507. const bookDir = this.state.bookDir(bookId);
  508. const chapterNumber = await this.state.getNextChapterNumber(bookId);
  509. const stageLanguage = await this.resolveBookLanguage(book);
  510. this.logStage(stageLanguage, { zh: "规划下一章意图", en: "planning next chapter intent" });
  511. const { plan } = await this.createGovernedArtifacts(
  512. book,
  513. bookDir,
  514. chapterNumber,
  515. context ?? this.config.externalContext,
  516. { reuseExistingIntentWhenContextMissing: false },
  517. );
  518. return {
  519. bookId,
  520. chapterNumber,
  521. intentPath: relativeToBookDir(bookDir, plan.runtimePath),
  522. goal: plan.intent.goal,
  523. conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`),
  524. };
  525. }
  526. async composeChapter(bookId: string, context?: string): Promise<ComposeChapterResult> {
  527. await this.state.ensureControlDocuments(bookId);
  528. const book = await this.state.loadBookConfig(bookId);
  529. const bookDir = this.state.bookDir(bookId);
  530. const chapterNumber = await this.state.getNextChapterNumber(bookId);
  531. const stageLanguage = await this.resolveBookLanguage(book);
  532. this.logStage(stageLanguage, { zh: "组装章节运行时上下文", en: "composing chapter runtime context" });
  533. const { plan, composed } = await this.createGovernedArtifacts(
  534. book,
  535. bookDir,
  536. chapterNumber,
  537. context ?? this.config.externalContext,
  538. { reuseExistingIntentWhenContextMissing: true },
  539. );
  540. return {
  541. bookId,
  542. chapterNumber,
  543. intentPath: relativeToBookDir(bookDir, plan.runtimePath),
  544. goal: plan.intent.goal,
  545. conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`),
  546. contextPath: relativeToBookDir(bookDir, composed.contextPath),
  547. ruleStackPath: relativeToBookDir(bookDir, composed.ruleStackPath),
  548. tracePath: relativeToBookDir(bookDir, composed.tracePath),
  549. };
  550. }
  551. /** Audit the latest (or specified) chapter. Read-only, no lock needed. */
  552. async auditDraft(bookId: string, chapterNumber?: number): Promise<AuditResult & { readonly chapterNumber: number }> {
  553. const book = await this.state.loadBookConfig(bookId);
  554. const bookDir = this.state.bookDir(bookId);
  555. const targetChapter = chapterNumber ?? (await this.state.getNextChapterNumber(bookId)) - 1;
  556. if (targetChapter < 1) {
  557. throw new Error(`No chapters to audit for "${bookId}"`);
  558. }
  559. const content = await this.readChapterContent(bookDir, targetChapter);
  560. const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
  561. const { profile: gp } = await this.loadGenreProfile(book.genre);
  562. const language = book.language ?? gp.language;
  563. this.logStage(language, {
  564. zh: `审计第${targetChapter}章`,
  565. en: `auditing chapter ${targetChapter}`,
  566. });
  567. const evaluation = await this.evaluateMergedAudit({
  568. auditor,
  569. book,
  570. bookDir,
  571. chapterContent: content,
  572. chapterNumber: targetChapter,
  573. language,
  574. });
  575. const result = evaluation.auditResult;
  576. // Update index with audit result
  577. const index = await this.state.loadChapterIndex(bookId);
  578. const updated = index.map((ch) =>
  579. ch.number === targetChapter
  580. ? {
  581. ...ch,
  582. status: (result.passed ? "ready-for-review" : "audit-failed") as ChapterMeta["status"],
  583. updatedAt: new Date().toISOString(),
  584. auditIssues: result.issues.map((i) => `[${i.severity}] ${i.description}`),
  585. }
  586. : ch,
  587. );
  588. await this.state.saveChapterIndex(bookId, updated);
  589. const latestChapter = index.length > 0 ? Math.max(...index.map((chapter) => chapter.number)) : targetChapter;
  590. if (targetChapter === latestChapter) {
  591. await this.persistAuditDriftGuidance({
  592. bookDir,
  593. chapterNumber: targetChapter,
  594. issues: result.issues.filter((issue) => issue.severity === "critical" || issue.severity === "warning"),
  595. language,
  596. }).catch(() => undefined);
  597. }
  598. await this.emitWebhook(
  599. result.passed ? "audit-passed" : "audit-failed",
  600. bookId,
  601. targetChapter,
  602. { summary: result.summary, issueCount: result.issues.length },
  603. );
  604. return { ...result, chapterNumber: targetChapter };
  605. }
  606. /** Revise the latest (or specified) chapter based on audit issues. */
  607. async reviseDraft(bookId: string, chapterNumber?: number, mode: ReviseMode = DEFAULT_REVISE_MODE): Promise<ReviseResult> {
  608. const releaseLock = await this.state.acquireBookLock(bookId);
  609. try {
  610. const book = await this.state.loadBookConfig(bookId);
  611. const bookDir = this.state.bookDir(bookId);
  612. const targetChapter = chapterNumber ?? (await this.state.getNextChapterNumber(bookId)) - 1;
  613. if (targetChapter < 1) {
  614. throw new Error(`No chapters to revise for "${bookId}"`);
  615. }
  616. const stageLanguage = await this.resolveBookLanguage(book);
  617. // Read the current audit issues from index
  618. this.logStage(stageLanguage, {
  619. zh: `加载第${targetChapter}章修订上下文`,
  620. en: `loading revision context for chapter ${targetChapter}`,
  621. });
  622. const index = await this.state.loadChapterIndex(bookId);
  623. const chapterMeta = index.find((ch) => ch.number === targetChapter);
  624. if (!chapterMeta) {
  625. throw new Error(`Chapter ${targetChapter} not found in index`);
  626. }
  627. // Re-audit to get structured issues (index only stores strings)
  628. const content = await this.readChapterContent(bookDir, targetChapter);
  629. const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
  630. const { profile: gp } = await this.loadGenreProfile(book.genre);
  631. const language = book.language ?? gp.language;
  632. const countingMode = resolveLengthCountingMode(language);
  633. const reviseControlInput = (this.config.inputGovernanceMode ?? "v2") === "legacy"
  634. ? undefined
  635. : await this.createGovernedArtifacts(
  636. book,
  637. bookDir,
  638. targetChapter,
  639. undefined,
  640. { reuseExistingIntentWhenContextMissing: true },
  641. );
  642. const preRevision = await this.evaluateMergedAudit({
  643. auditor,
  644. book,
  645. bookDir,
  646. chapterContent: content,
  647. chapterNumber: targetChapter,
  648. language,
  649. auditOptions: reviseControlInput
  650. ? {
  651. chapterIntent: reviseControlInput.plan.intentMarkdown,
  652. contextPackage: reviseControlInput.composed.contextPackage,
  653. ruleStack: reviseControlInput.composed.ruleStack,
  654. }
  655. : undefined,
  656. });
  657. if (preRevision.blockingCount === 0 && preRevision.aiTellCount === 0) {
  658. return {
  659. chapterNumber: targetChapter,
  660. wordCount: countChapterLength(content, countingMode),
  661. fixedIssues: [],
  662. applied: false,
  663. status: "unchanged",
  664. skippedReason: "No warning, critical, or AI-tell issues to fix.",
  665. };
  666. }
  667. const chapterLengthTarget = chapterMeta.lengthTelemetry?.target ?? book.chapterWordCount;
  668. const lengthLanguage = chapterMeta.lengthTelemetry?.countingMode === "en_words"
  669. ? "en"
  670. : language;
  671. const lengthSpec = buildLengthSpec(
  672. chapterLengthTarget,
  673. lengthLanguage,
  674. );
  675. const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
  676. this.logStage(stageLanguage, {
  677. zh: `修订第${targetChapter}章`,
  678. en: `revising chapter ${targetChapter}`,
  679. });
  680. const reviseOutput = await reviser.reviseChapter(
  681. bookDir,
  682. content,
  683. targetChapter,
  684. preRevision.auditResult.issues,
  685. mode,
  686. book.genre,
  687. reviseControlInput
  688. ? {
  689. chapterIntent: reviseControlInput.plan.intentMarkdown,
  690. contextPackage: reviseControlInput.composed.contextPackage,
  691. ruleStack: reviseControlInput.composed.ruleStack,
  692. lengthSpec,
  693. }
  694. : { lengthSpec },
  695. );
  696. if (reviseOutput.revisedContent.length === 0) {
  697. throw new Error("Reviser returned empty content");
  698. }
  699. const normalizedRevision = await this.normalizeDraftLengthIfNeeded({
  700. bookId,
  701. chapterNumber: targetChapter,
  702. chapterContent: reviseOutput.revisedContent,
  703. lengthSpec,
  704. });
  705. const postRevision = await this.evaluateMergedAudit({
  706. auditor,
  707. book,
  708. bookDir,
  709. chapterContent: normalizedRevision.content,
  710. chapterNumber: targetChapter,
  711. language,
  712. auditOptions: reviseControlInput
  713. ? {
  714. temperature: 0,
  715. chapterIntent: reviseControlInput.plan.intentMarkdown,
  716. contextPackage: reviseControlInput.composed.contextPackage,
  717. ruleStack: reviseControlInput.composed.ruleStack,
  718. truthFileOverrides: {
  719. currentState: reviseOutput.updatedState !== "(状态卡未更新)" ? reviseOutput.updatedState : undefined,
  720. ledger: reviseOutput.updatedLedger !== "(账本未更新)" ? reviseOutput.updatedLedger : undefined,
  721. hooks: reviseOutput.updatedHooks !== "(伏笔池未更新)" ? reviseOutput.updatedHooks : undefined,
  722. },
  723. }
  724. : {
  725. temperature: 0,
  726. truthFileOverrides: {
  727. currentState: reviseOutput.updatedState !== "(状态卡未更新)" ? reviseOutput.updatedState : undefined,
  728. ledger: reviseOutput.updatedLedger !== "(账本未更新)" ? reviseOutput.updatedLedger : undefined,
  729. hooks: reviseOutput.updatedHooks !== "(伏笔池未更新)" ? reviseOutput.updatedHooks : undefined,
  730. },
  731. },
  732. });
  733. const effectivePostRevision = this.restoreActionableAuditIfLost(
  734. preRevision,
  735. postRevision,
  736. );
  737. const revisionBaseCount = countChapterLength(content, lengthSpec.countingMode);
  738. const lengthWarnings = this.buildLengthWarnings(
  739. targetChapter,
  740. normalizedRevision.wordCount,
  741. lengthSpec,
  742. );
  743. const lengthTelemetry = this.buildLengthTelemetry({
  744. lengthSpec,
  745. writerCount: revisionBaseCount,
  746. postWriterNormalizeCount: 0,
  747. postReviseCount: normalizedRevision.wordCount,
  748. finalCount: normalizedRevision.wordCount,
  749. normalizeApplied: normalizedRevision.applied,
  750. lengthWarning: lengthWarnings.length > 0,
  751. });
  752. const improvedBlocking = effectivePostRevision.blockingCount < preRevision.blockingCount;
  753. const improvedAITells = effectivePostRevision.aiTellCount < preRevision.aiTellCount;
  754. const blockingDidNotWorsen = effectivePostRevision.blockingCount <= preRevision.blockingCount;
  755. const criticalDidNotWorsen = effectivePostRevision.criticalCount <= preRevision.criticalCount;
  756. const aiDidNotWorsen = effectivePostRevision.aiTellCount <= preRevision.aiTellCount;
  757. const shouldApplyRevision = blockingDidNotWorsen
  758. && criticalDidNotWorsen
  759. && aiDidNotWorsen
  760. && (improvedBlocking || improvedAITells);
  761. if (!shouldApplyRevision) {
  762. return {
  763. chapterNumber: targetChapter,
  764. wordCount: revisionBaseCount,
  765. fixedIssues: [],
  766. applied: false,
  767. status: "unchanged",
  768. skippedReason: "Manual revision did not improve merged audit or AI-tell metrics; kept original chapter.",
  769. };
  770. }
  771. this.logLengthWarnings(lengthWarnings);
  772. // Save revised chapter file
  773. this.logStage(stageLanguage, {
  774. zh: `落盘第${targetChapter}章修订结果`,
  775. en: `persisting revision for chapter ${targetChapter}`,
  776. });
  777. const chaptersDir = join(bookDir, "chapters");
  778. const files = await readdir(chaptersDir);
  779. const paddedNum = String(targetChapter).padStart(4, "0");
  780. const existingFile = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  781. if (!existingFile) {
  782. throw new Error(`Chapter ${targetChapter} file not found in ${chaptersDir} (expected filename starting with ${paddedNum})`);
  783. }
  784. const reviseLang = book.language ?? gp.language;
  785. const reviseHeading = reviseLang === "en"
  786. ? `# Chapter ${targetChapter}: ${chapterMeta.title}`
  787. : `# 第${targetChapter}章 ${chapterMeta.title}`;
  788. await writeFile(
  789. join(chaptersDir, existingFile),
  790. `${reviseHeading}\n\n${normalizedRevision.content}`,
  791. "utf-8",
  792. );
  793. // Update truth files
  794. const storyDir = join(bookDir, "story");
  795. if (reviseOutput.updatedState !== "(状态卡未更新)") {
  796. await writeFile(join(storyDir, "current_state.md"), reviseOutput.updatedState, "utf-8");
  797. }
  798. if (gp.numericalSystem && reviseOutput.updatedLedger && reviseOutput.updatedLedger !== "(账本未更新)") {
  799. await writeFile(join(storyDir, "particle_ledger.md"), reviseOutput.updatedLedger, "utf-8");
  800. }
  801. if (reviseOutput.updatedHooks !== "(伏笔池未更新)") {
  802. await writeFile(join(storyDir, "pending_hooks.md"), reviseOutput.updatedHooks, "utf-8");
  803. }
  804. await this.syncLegacyStructuredStateFromMarkdown(bookDir, targetChapter);
  805. // Update index
  806. const updatedIndex = index.map((ch) =>
  807. ch.number === targetChapter
  808. ? {
  809. ...ch,
  810. status: (effectivePostRevision.auditResult.passed ? "ready-for-review" : "audit-failed") as ChapterMeta["status"],
  811. wordCount: normalizedRevision.wordCount,
  812. updatedAt: new Date().toISOString(),
  813. auditIssues: effectivePostRevision.auditResult.issues.map((i) => `[${i.severity}] ${i.description}`),
  814. lengthWarnings,
  815. lengthTelemetry,
  816. }
  817. : ch,
  818. );
  819. await this.state.saveChapterIndex(bookId, updatedIndex);
  820. const latestChapter = index.length > 0 ? Math.max(...index.map((chapter) => chapter.number)) : targetChapter;
  821. if (targetChapter === latestChapter) {
  822. await this.persistAuditDriftGuidance({
  823. bookDir,
  824. chapterNumber: targetChapter,
  825. issues: effectivePostRevision.auditResult.issues.filter(
  826. (issue) => issue.severity === "critical" || issue.severity === "warning",
  827. ),
  828. language,
  829. }).catch(() => undefined);
  830. }
  831. // Re-snapshot
  832. this.logStage(stageLanguage, {
  833. zh: `更新第${targetChapter}章索引与快照`,
  834. en: `updating chapter index and snapshots for chapter ${targetChapter}`,
  835. });
  836. await this.state.snapshotState(bookId, targetChapter);
  837. await this.syncNarrativeMemoryIndex(bookId);
  838. await this.syncCurrentStateFactHistory(bookId, targetChapter);
  839. await this.emitWebhook("revision-complete", bookId, targetChapter, {
  840. wordCount: normalizedRevision.wordCount,
  841. fixedCount: reviseOutput.fixedIssues.length,
  842. });
  843. return {
  844. chapterNumber: targetChapter,
  845. wordCount: normalizedRevision.wordCount,
  846. fixedIssues: reviseOutput.fixedIssues,
  847. applied: true,
  848. status: effectivePostRevision.auditResult.passed ? "ready-for-review" : "audit-failed",
  849. lengthWarnings,
  850. lengthTelemetry,
  851. };
  852. } finally {
  853. await releaseLock();
  854. }
  855. }
  856. /** Read all truth files for a book. */
  857. async readTruthFiles(bookId: string): Promise<TruthFiles> {
  858. const bookDir = this.state.bookDir(bookId);
  859. const storyDir = join(bookDir, "story");
  860. const readSafe = async (path: string): Promise<string> => {
  861. try {
  862. return await readFile(path, "utf-8");
  863. } catch {
  864. return "(文件不存在)";
  865. }
  866. };
  867. const [currentState, particleLedger, pendingHooks, storyBible, volumeOutline, bookRules] =
  868. await Promise.all([
  869. readSafe(join(storyDir, "current_state.md")),
  870. readSafe(join(storyDir, "particle_ledger.md")),
  871. readSafe(join(storyDir, "pending_hooks.md")),
  872. readSafe(join(storyDir, "story_bible.md")),
  873. readSafe(join(storyDir, "volume_outline.md")),
  874. readSafe(join(storyDir, "book_rules.md")),
  875. ]);
  876. return { currentState, particleLedger, pendingHooks, storyBible, volumeOutline, bookRules };
  877. }
  878. /** Get book status overview. */
  879. async getBookStatus(bookId: string): Promise<BookStatusInfo> {
  880. const book = await this.state.loadBookConfig(bookId);
  881. const chapters = await this.state.loadChapterIndex(bookId);
  882. const nextChapter = await this.state.getNextChapterNumber(bookId);
  883. const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
  884. return {
  885. bookId,
  886. title: book.title,
  887. genre: book.genre,
  888. platform: book.platform,
  889. status: book.status,
  890. chaptersWritten: chapters.length,
  891. totalWords,
  892. nextChapter,
  893. chapters: [...chapters],
  894. };
  895. }
  896. // ---------------------------------------------------------------------------
  897. // Full pipeline (convenience — runs draft + audit + revise in one shot)
  898. // ---------------------------------------------------------------------------
  899. async writeNextChapter(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
  900. const releaseLock = await this.state.acquireBookLock(bookId);
  901. try {
  902. return await this._writeNextChapterLocked(bookId, wordCount, temperatureOverride);
  903. } finally {
  904. await releaseLock();
  905. }
  906. }
  907. async repairChapterState(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
  908. const releaseLock = await this.state.acquireBookLock(bookId);
  909. try {
  910. return await this._repairChapterStateLocked(bookId, chapterNumber);
  911. } finally {
  912. await releaseLock();
  913. }
  914. }
  915. private async _writeNextChapterLocked(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
  916. await this.state.ensureControlDocuments(bookId);
  917. const book = await this.state.loadBookConfig(bookId);
  918. const bookDir = this.state.bookDir(bookId);
  919. await this.assertNoPendingStateRepair(bookId);
  920. const chapterNumber = await this.state.getNextChapterNumber(bookId);
  921. const stageLanguage = await this.resolveBookLanguage(book);
  922. this.logStage(stageLanguage, { zh: "准备章节输入", en: "preparing chapter inputs" });
  923. const writeInput = await this.prepareWriteInput(
  924. book,
  925. bookDir,
  926. chapterNumber,
  927. this.config.externalContext,
  928. );
  929. const reducedControlInput = writeInput.chapterIntent && writeInput.contextPackage && writeInput.ruleStack
  930. ? {
  931. chapterIntent: writeInput.chapterIntent,
  932. contextPackage: writeInput.contextPackage,
  933. ruleStack: writeInput.ruleStack,
  934. }
  935. : undefined;
  936. const { profile: gp } = await this.loadGenreProfile(book.genre);
  937. const pipelineLang = book.language ?? gp.language;
  938. const lengthSpec = buildLengthSpec(
  939. wordCount ?? book.chapterWordCount,
  940. pipelineLang,
  941. );
  942. // 1. Write chapter
  943. const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
  944. this.logStage(stageLanguage, { zh: "撰写章节草稿", en: "writing chapter draft" });
  945. const output = await writer.writeChapter({
  946. book,
  947. bookDir,
  948. chapterNumber,
  949. ...writeInput,
  950. lengthSpec,
  951. ...(wordCount ? { wordCountOverride: wordCount } : {}),
  952. ...(temperatureOverride ? { temperatureOverride } : {}),
  953. });
  954. const writerCount = countChapterLength(output.content, lengthSpec.countingMode);
  955. // Token usage accumulator
  956. let totalUsage: TokenUsageSummary = output.tokenUsage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
  957. let postReviseCount = 0;
  958. let normalizeApplied = false;
  959. // 2a. Post-write error gate: if deterministic rules found errors, auto-fix before LLM audit
  960. let finalContent = output.content;
  961. let finalWordCount = output.wordCount;
  962. let revised = false;
  963. if (output.postWriteErrors.length > 0) {
  964. this.logWarn(pipelineLang, {
  965. zh: `检测到 ${output.postWriteErrors.length} 个后写错误,审计前触发 spot-fix 修补`,
  966. en: `${output.postWriteErrors.length} post-write errors detected, triggering spot-fix before audit`,
  967. });
  968. const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
  969. const spotFixIssues = output.postWriteErrors.map((v) => ({
  970. severity: "critical" as const,
  971. category: v.rule,
  972. description: v.description,
  973. suggestion: v.suggestion,
  974. }));
  975. const fixResult = await reviser.reviseChapter(
  976. bookDir,
  977. finalContent,
  978. chapterNumber,
  979. spotFixIssues,
  980. "spot-fix",
  981. book.genre,
  982. {
  983. ...reducedControlInput,
  984. lengthSpec,
  985. },
  986. );
  987. totalUsage = PipelineRunner.addUsage(totalUsage, fixResult.tokenUsage);
  988. if (fixResult.revisedContent.length > 0) {
  989. finalContent = fixResult.revisedContent;
  990. finalWordCount = fixResult.wordCount;
  991. revised = true;
  992. }
  993. }
  994. const normalizedBeforeAudit = await this.normalizeDraftLengthIfNeeded({
  995. bookId,
  996. chapterNumber,
  997. chapterContent: finalContent,
  998. lengthSpec,
  999. chapterIntent: writeInput.chapterIntent,
  1000. });
  1001. totalUsage = PipelineRunner.addUsage(totalUsage, normalizedBeforeAudit.tokenUsage);
  1002. finalContent = normalizedBeforeAudit.content;
  1003. finalWordCount = normalizedBeforeAudit.wordCount;
  1004. normalizeApplied = normalizeApplied || normalizedBeforeAudit.applied;
  1005. this.assertChapterContentNotEmpty(finalContent, chapterNumber, "draft generation");
  1006. // 2b. LLM audit
  1007. const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
  1008. this.logStage(stageLanguage, { zh: "审计草稿", en: "auditing draft" });
  1009. const llmAudit = await auditor.auditChapter(
  1010. bookDir,
  1011. finalContent,
  1012. chapterNumber,
  1013. book.genre,
  1014. reducedControlInput,
  1015. );
  1016. totalUsage = PipelineRunner.addUsage(totalUsage, llmAudit.tokenUsage);
  1017. const aiTellsResult = analyzeAITells(finalContent);
  1018. const sensitiveWriteResult = analyzeSensitiveWords(finalContent);
  1019. const hasBlockedWriteWords = sensitiveWriteResult.found.some((f) => f.severity === "block");
  1020. let auditResult: AuditResult = {
  1021. passed: hasBlockedWriteWords ? false : llmAudit.passed,
  1022. issues: [...llmAudit.issues, ...aiTellsResult.issues, ...sensitiveWriteResult.issues],
  1023. summary: llmAudit.summary,
  1024. };
  1025. // 3. If audit fails, try auto-revise once
  1026. if (!auditResult.passed) {
  1027. const criticalIssues = auditResult.issues.filter(
  1028. (i) => i.severity === "critical",
  1029. );
  1030. if (criticalIssues.length > 0) {
  1031. const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
  1032. this.logStage(stageLanguage, { zh: "自动修复关键问题", en: "auto-revising critical issues" });
  1033. const reviseOutput = await reviser.reviseChapter(
  1034. bookDir,
  1035. finalContent,
  1036. chapterNumber,
  1037. auditResult.issues,
  1038. "spot-fix",
  1039. book.genre,
  1040. {
  1041. ...reducedControlInput,
  1042. lengthSpec,
  1043. },
  1044. );
  1045. totalUsage = PipelineRunner.addUsage(totalUsage, reviseOutput.tokenUsage);
  1046. if (reviseOutput.revisedContent.length > 0) {
  1047. const normalizedRevision = await this.normalizeDraftLengthIfNeeded({
  1048. bookId,
  1049. chapterNumber,
  1050. chapterContent: reviseOutput.revisedContent,
  1051. lengthSpec,
  1052. chapterIntent: writeInput.chapterIntent,
  1053. });
  1054. totalUsage = PipelineRunner.addUsage(totalUsage, normalizedRevision.tokenUsage);
  1055. postReviseCount = normalizedRevision.wordCount;
  1056. normalizeApplied = normalizeApplied || normalizedRevision.applied;
  1057. // Guard: reject revision if AI markers increased
  1058. const preMarkers = analyzeAITells(finalContent);
  1059. const postMarkers = analyzeAITells(normalizedRevision.content);
  1060. const preCount = preMarkers.issues.length;
  1061. const postCount = postMarkers.issues.length;
  1062. if (postCount > preCount) {
  1063. // Revision made text MORE AI-like — discard it, keep original
  1064. } else {
  1065. finalContent = normalizedRevision.content;
  1066. finalWordCount = normalizedRevision.wordCount;
  1067. revised = true;
  1068. this.assertChapterContentNotEmpty(finalContent, chapterNumber, "revision");
  1069. }
  1070. // Re-audit the (possibly revised) content
  1071. const reAudit = await auditor.auditChapter(
  1072. bookDir,
  1073. finalContent,
  1074. chapterNumber,
  1075. book.genre,
  1076. { ...reducedControlInput, temperature: 0 },
  1077. );
  1078. totalUsage = PipelineRunner.addUsage(totalUsage, reAudit.tokenUsage);
  1079. const reAITells = analyzeAITells(finalContent);
  1080. const reSensitive = analyzeSensitiveWords(finalContent);
  1081. const reHasBlocked = reSensitive.found.some((f) => f.severity === "block");
  1082. auditResult = this.restoreLostAuditIssues(auditResult, {
  1083. passed: reHasBlocked ? false : reAudit.passed,
  1084. issues: [...reAudit.issues, ...reAITells.issues, ...reSensitive.issues],
  1085. summary: reAudit.summary,
  1086. });
  1087. }
  1088. }
  1089. }
  1090. // 4. Save the final chapter and truth files from a single persistence source
  1091. this.logStage(stageLanguage, { zh: "落盘最终章节", en: "persisting final chapter" });
  1092. this.logStage(stageLanguage, { zh: "生成最终真相文件", en: "rebuilding final truth files" });
  1093. const chapterIndexBeforePersist = await this.state.loadChapterIndex(bookId);
  1094. const { resolveDuplicateTitle } = await import("../agents/post-write-validator.js");
  1095. const initialTitleResolution = resolveDuplicateTitle(
  1096. output.title,
  1097. chapterIndexBeforePersist.map((chapter) => chapter.title),
  1098. pipelineLang,
  1099. { content: finalContent },
  1100. );
  1101. let persistenceOutput = await this.buildPersistenceOutput(
  1102. bookId,
  1103. book,
  1104. bookDir,
  1105. chapterNumber,
  1106. initialTitleResolution.title === output.title
  1107. ? output
  1108. : { ...output, title: initialTitleResolution.title },
  1109. finalContent,
  1110. lengthSpec.countingMode,
  1111. reducedControlInput,
  1112. );
  1113. const finalTitleResolution = resolveDuplicateTitle(
  1114. persistenceOutput.title,
  1115. chapterIndexBeforePersist.map((chapter) => chapter.title),
  1116. pipelineLang,
  1117. { content: finalContent },
  1118. );
  1119. if (finalTitleResolution.title !== persistenceOutput.title) {
  1120. persistenceOutput = {
  1121. ...persistenceOutput,
  1122. title: finalTitleResolution.title,
  1123. };
  1124. }
  1125. if (persistenceOutput.title !== output.title) {
  1126. const description = pipelineLang === "en"
  1127. ? `Chapter title "${output.title}" was auto-adjusted to "${persistenceOutput.title}".`
  1128. : `章节标题"${output.title}"已自动调整为"${persistenceOutput.title}"。`;
  1129. this.config.logger?.warn(`[title] ${description}`);
  1130. auditResult = {
  1131. ...auditResult,
  1132. issues: [...auditResult.issues, {
  1133. severity: "warning",
  1134. category: "title-dedup",
  1135. description,
  1136. suggestion: pipelineLang === "en"
  1137. ? "If the auto-renamed title is weak, revise the chapter title manually."
  1138. : "如果自动改名不理想,可以在后续手动修订章节标题。",
  1139. }],
  1140. };
  1141. }
  1142. const longSpanFatigue = await analyzeLongSpanFatigue({
  1143. bookDir,
  1144. chapterNumber,
  1145. chapterContent: finalContent,
  1146. chapterSummary: persistenceOutput.chapterSummary,
  1147. language: pipelineLang,
  1148. });
  1149. auditResult = {
  1150. ...auditResult,
  1151. issues: [
  1152. ...auditResult.issues,
  1153. ...longSpanFatigue.issues,
  1154. ...(persistenceOutput.hookHealthIssues ?? []),
  1155. ],
  1156. };
  1157. finalWordCount = persistenceOutput.wordCount;
  1158. const lengthWarnings = this.buildLengthWarnings(
  1159. chapterNumber,
  1160. finalWordCount,
  1161. lengthSpec,
  1162. );
  1163. const lengthTelemetry = this.buildLengthTelemetry({
  1164. lengthSpec,
  1165. writerCount,
  1166. postWriterNormalizeCount: normalizedBeforeAudit.wordCount,
  1167. postReviseCount,
  1168. finalCount: finalWordCount,
  1169. normalizeApplied,
  1170. lengthWarning: lengthWarnings.length > 0,
  1171. });
  1172. this.logLengthWarnings(lengthWarnings);
  1173. // 4.1 Validate settler output before writing
  1174. this.logStage(stageLanguage, { zh: "校验真相文件变更", en: "validating truth file updates" });
  1175. const storyDir = join(bookDir, "story");
  1176. const [oldState, oldHooks, oldLedger] = await Promise.all([
  1177. readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
  1178. readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
  1179. readFile(join(storyDir, "particle_ledger.md"), "utf-8").catch(() => ""),
  1180. ]);
  1181. const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
  1182. let validation: ValidationResult;
  1183. let chapterStatus: ChapterPipelineResult["status"] | null = null;
  1184. let degradedIssues: ReadonlyArray<AuditIssue> = [];
  1185. try {
  1186. validation = await validator.validate(
  1187. finalContent, chapterNumber,
  1188. oldState, persistenceOutput.updatedState,
  1189. oldHooks, persistenceOutput.updatedHooks,
  1190. pipelineLang,
  1191. );
  1192. } catch (error) {
  1193. throw new Error(`State validation failed for chapter ${chapterNumber}: ${String(error)}`);
  1194. }
  1195. if (validation.warnings.length > 0) {
  1196. this.logWarn(pipelineLang, {
  1197. zh: `状态校验:第${chapterNumber}章发现 ${validation.warnings.length} 条警告`,
  1198. en: `State validation: ${validation.warnings.length} warning(s) for chapter ${chapterNumber}`,
  1199. });
  1200. for (const w of validation.warnings) {
  1201. this.config.logger?.warn(` [${w.category}] ${w.description}`);
  1202. }
  1203. }
  1204. if (!validation.passed) {
  1205. const recovery = await retrySettlementAfterValidationFailure({
  1206. writer,
  1207. validator,
  1208. book,
  1209. bookDir,
  1210. chapterNumber,
  1211. title: persistenceOutput.title,
  1212. content: finalContent,
  1213. reducedControlInput,
  1214. oldState,
  1215. oldHooks,
  1216. originalValidation: validation,
  1217. language: pipelineLang,
  1218. logWarn: (message) => this.logWarn(pipelineLang, message),
  1219. logger: this.config.logger,
  1220. });
  1221. if (recovery.kind === "recovered") {
  1222. persistenceOutput = recovery.output;
  1223. validation = recovery.validation;
  1224. } else {
  1225. chapterStatus = "state-degraded";
  1226. degradedIssues = recovery.issues;
  1227. persistenceOutput = buildStateDegradedPersistenceOutput({
  1228. output: persistenceOutput,
  1229. oldState,
  1230. oldHooks,
  1231. oldLedger,
  1232. });
  1233. auditResult = {
  1234. ...auditResult,
  1235. issues: [...auditResult.issues, ...recovery.issues],
  1236. };
  1237. }
  1238. }
  1239. // 4.2 Final paragraph shape check on persisted content (post-normalize, post-revise)
  1240. {
  1241. const {
  1242. detectParagraphLengthDrift,
  1243. detectParagraphShapeWarnings,
  1244. } = await import("../agents/post-write-validator.js");
  1245. const chapDir = join(bookDir, "chapters");
  1246. const recentFiles = (await readdir(chapDir).catch(() => [] as string[]))
  1247. .filter((f) => f.endsWith(".md") && /^\d{4}/.test(f))
  1248. .sort()
  1249. .slice(-5);
  1250. const recentContent = (await Promise.all(
  1251. recentFiles.map((f) => readFile(join(chapDir, f), "utf-8").catch(() => "")),
  1252. )).join("\n\n");
  1253. const paragraphIssues = [
  1254. ...detectParagraphShapeWarnings(finalContent, pipelineLang),
  1255. ...detectParagraphLengthDrift(finalContent, recentContent, pipelineLang),
  1256. ];
  1257. if (paragraphIssues.length > 0) {
  1258. for (const issue of paragraphIssues) {
  1259. this.config.logger?.warn(`[paragraph] ${issue.description}`);
  1260. }
  1261. auditResult = {
  1262. ...auditResult,
  1263. issues: [...auditResult.issues, ...paragraphIssues.map((v) => ({
  1264. severity: v.severity as "warning",
  1265. category: "paragraph-shape",
  1266. description: v.description,
  1267. suggestion: v.suggestion,
  1268. }))],
  1269. };
  1270. }
  1271. }
  1272. await writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang);
  1273. if (chapterStatus !== "state-degraded") {
  1274. await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang);
  1275. await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput);
  1276. this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" });
  1277. await this.syncNarrativeMemoryIndex(bookId);
  1278. }
  1279. // 5. Update chapter index
  1280. const existingIndex = await this.state.loadChapterIndex(bookId);
  1281. const now = new Date().toISOString();
  1282. const newEntry: ChapterMeta = {
  1283. number: chapterNumber,
  1284. title: persistenceOutput.title,
  1285. status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"),
  1286. wordCount: finalWordCount,
  1287. createdAt: now,
  1288. updatedAt: now,
  1289. auditIssues: auditResult.issues.map(
  1290. (i) => `[${i.severity}] ${i.description}`,
  1291. ),
  1292. lengthWarnings,
  1293. reviewNote: chapterStatus === "state-degraded"
  1294. ? buildStateDegradedReviewNote(
  1295. auditResult.passed ? "ready-for-review" : "audit-failed",
  1296. degradedIssues,
  1297. )
  1298. : undefined,
  1299. lengthTelemetry,
  1300. tokenUsage: totalUsage,
  1301. };
  1302. await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
  1303. await this.markBookActiveIfNeeded(bookId);
  1304. // 5.5 Persist audit drift guidance without polluting current_state.md
  1305. const driftIssues = auditResult.issues.filter(
  1306. (i) => i.severity === "critical" || i.severity === "warning",
  1307. );
  1308. await this.persistAuditDriftGuidance({
  1309. bookDir,
  1310. chapterNumber,
  1311. issues: chapterStatus === "state-degraded" ? [] : driftIssues,
  1312. language: stageLanguage,
  1313. }).catch(() => undefined);
  1314. // 5.6 Snapshot state for rollback support
  1315. if (chapterStatus !== "state-degraded") {
  1316. this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" });
  1317. await this.state.snapshotState(bookId, chapterNumber);
  1318. await this.syncCurrentStateFactHistory(bookId, chapterNumber);
  1319. }
  1320. // 6. Send notification
  1321. if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
  1322. const statusEmoji = chapterStatus === "state-degraded"
  1323. ? "🧯"
  1324. : auditResult.passed ? "✅" : "⚠️";
  1325. const chapterLength = formatLengthCount(finalWordCount, lengthSpec.countingMode);
  1326. await dispatchNotification(this.config.notifyChannels, {
  1327. title: `${statusEmoji} ${book.title} 第${chapterNumber}章`,
  1328. body: [
  1329. `**${persistenceOutput.title}** | ${chapterLength}`,
  1330. revised ? "📝 已自动修正" : "",
  1331. chapterStatus === "state-degraded"
  1332. ? "状态结算: 已降级保存,需先修复 state 再继续"
  1333. : `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`,
  1334. ...auditResult.issues
  1335. .filter((i) => i.severity !== "info")
  1336. .map((i) => `- [${i.severity}] ${i.description}`),
  1337. ]
  1338. .filter(Boolean)
  1339. .join("\n"),
  1340. });
  1341. }
  1342. await this.emitWebhook("pipeline-complete", bookId, chapterNumber, {
  1343. title: persistenceOutput.title,
  1344. wordCount: finalWordCount,
  1345. passed: auditResult.passed,
  1346. revised,
  1347. status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"),
  1348. });
  1349. return {
  1350. chapterNumber,
  1351. title: persistenceOutput.title,
  1352. wordCount: finalWordCount,
  1353. auditResult,
  1354. revised,
  1355. status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"),
  1356. lengthWarnings,
  1357. lengthTelemetry,
  1358. tokenUsage: totalUsage,
  1359. };
  1360. }
  1361. private async _repairChapterStateLocked(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
  1362. const book = await this.state.loadBookConfig(bookId);
  1363. const bookDir = this.state.bookDir(bookId);
  1364. const stageLanguage = await this.resolveBookLanguage(book);
  1365. const index = [...(await this.state.loadChapterIndex(bookId))];
  1366. if (index.length === 0) {
  1367. throw new Error(`Book "${bookId}" has no persisted chapters to repair.`);
  1368. }
  1369. const targetChapter = chapterNumber ?? index[index.length - 1]!.number;
  1370. const targetIndex = index.findIndex((chapter) => chapter.number === targetChapter);
  1371. if (targetIndex < 0) {
  1372. throw new Error(`Chapter ${targetChapter} not found in "${bookId}".`);
  1373. }
  1374. const targetMeta = index[targetIndex]!;
  1375. const latestChapter = Math.max(...index.map((chapter) => chapter.number));
  1376. if (targetMeta.status !== "state-degraded") {
  1377. throw new Error(`Chapter ${targetChapter} is not state-degraded.`);
  1378. }
  1379. if (targetChapter !== latestChapter) {
  1380. throw new Error(`Only the latest state-degraded chapter can be repaired safely (latest is ${latestChapter}).`);
  1381. }
  1382. this.logStage(stageLanguage, { zh: "修复章节状态结算", en: "repairing chapter state settlement" });
  1383. const { profile: gp } = await this.loadGenreProfile(book.genre);
  1384. const pipelineLang = book.language ?? gp.language;
  1385. const content = await this.readChapterContent(bookDir, targetChapter);
  1386. const storyDir = join(bookDir, "story");
  1387. const [oldState, oldHooks] = await Promise.all([
  1388. readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
  1389. readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
  1390. ]);
  1391. const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
  1392. let repairedOutput = await writer.settleChapterState({
  1393. book,
  1394. bookDir,
  1395. chapterNumber: targetChapter,
  1396. title: targetMeta.title,
  1397. content,
  1398. });
  1399. const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
  1400. let validation = await validator.validate(
  1401. content,
  1402. targetChapter,
  1403. oldState,
  1404. repairedOutput.updatedState,
  1405. oldHooks,
  1406. repairedOutput.updatedHooks,
  1407. pipelineLang,
  1408. );
  1409. if (!validation.passed) {
  1410. const recovery = await retrySettlementAfterValidationFailure({
  1411. writer,
  1412. validator,
  1413. book,
  1414. bookDir,
  1415. chapterNumber: targetChapter,
  1416. title: targetMeta.title,
  1417. content,
  1418. oldState,
  1419. oldHooks,
  1420. originalValidation: validation,
  1421. language: pipelineLang,
  1422. logWarn: (message) => this.logWarn(pipelineLang, message),
  1423. logger: this.config.logger,
  1424. });
  1425. if (recovery.kind !== "recovered") {
  1426. throw new Error(
  1427. recovery.issues[0]?.description
  1428. ?? `State repair still failed for chapter ${targetChapter}.`,
  1429. );
  1430. }
  1431. repairedOutput = recovery.output;
  1432. validation = recovery.validation;
  1433. }
  1434. if (!validation.passed) {
  1435. throw new Error(`State repair still failed for chapter ${targetChapter}.`);
  1436. }
  1437. await writer.saveChapter(bookDir, repairedOutput, gp.numericalSystem, pipelineLang);
  1438. await writer.saveNewTruthFiles(bookDir, repairedOutput, pipelineLang);
  1439. await this.syncLegacyStructuredStateFromMarkdown(bookDir, targetChapter, repairedOutput);
  1440. await this.syncNarrativeMemoryIndex(bookId);
  1441. await this.state.snapshotState(bookId, targetChapter);
  1442. await this.syncCurrentStateFactHistory(bookId, targetChapter);
  1443. const baseStatus = resolveStateDegradedBaseStatus(targetMeta);
  1444. const degradedMetadata = parseStateDegradedReviewNote(targetMeta.reviewNote);
  1445. const injectedIssues = new Set(degradedMetadata?.injectedIssues ?? []);
  1446. index[targetIndex] = {
  1447. ...targetMeta,
  1448. status: baseStatus,
  1449. updatedAt: new Date().toISOString(),
  1450. auditIssues: targetMeta.auditIssues.filter((issue) => !injectedIssues.has(issue)),
  1451. reviewNote: undefined,
  1452. };
  1453. await this.state.saveChapterIndex(bookId, index);
  1454. const repairedPassesAudit = baseStatus !== "audit-failed";
  1455. return {
  1456. chapterNumber: targetChapter,
  1457. title: targetMeta.title,
  1458. wordCount: targetMeta.wordCount,
  1459. auditResult: {
  1460. passed: repairedPassesAudit,
  1461. issues: [],
  1462. summary: repairedPassesAudit ? "state repaired" : "state repaired but chapter still needs review",
  1463. },
  1464. revised: false,
  1465. status: baseStatus,
  1466. lengthWarnings: targetMeta.lengthWarnings,
  1467. lengthTelemetry: targetMeta.lengthTelemetry,
  1468. tokenUsage: targetMeta.tokenUsage,
  1469. };
  1470. }
  1471. // ---------------------------------------------------------------------------
  1472. // Import operations (style imitation + canon for spinoff)
  1473. // ---------------------------------------------------------------------------
  1474. /**
  1475. * Generate a qualitative style guide from reference text via LLM.
  1476. * Also saves the statistical style_profile.json.
  1477. */
  1478. async generateStyleGuide(bookId: string, referenceText: string, sourceName?: string): Promise<string> {
  1479. if (referenceText.length < 500) {
  1480. throw new Error(`Reference text too short (${referenceText.length} chars, minimum 500). Provide at least 2000 chars for reliable style extraction.`);
  1481. }
  1482. const { analyzeStyle } = await import("../agents/style-analyzer.js");
  1483. const bookDir = this.state.bookDir(bookId);
  1484. const storyDir = join(bookDir, "story");
  1485. await mkdir(storyDir, { recursive: true });
  1486. // Statistical fingerprint
  1487. const profile = analyzeStyle(referenceText, sourceName);
  1488. await writeFile(join(storyDir, "style_profile.json"), JSON.stringify(profile, null, 2), "utf-8");
  1489. // LLM qualitative extraction
  1490. const response = await chatCompletion(this.config.client, this.config.model, [
  1491. {
  1492. role: "system",
  1493. content: `你是一位文学风格分析专家。分析参考文本的写作风格,提取可供模仿的定性特征。
  1494. 输出格式(Markdown):
  1495. ## 叙事声音与语气
  1496. (冷峻/热烈/讽刺/温情/...,附1-2个原文例句)
  1497. ## 对话风格
  1498. (角色说话的共性特征:句子长短、口头禅倾向、方言痕迹、对话节奏)
  1499. ## 场景描写特征
  1500. (五感偏好、意象选择、描写密度、环境与情绪的关联方式)
  1501. ## 转折与衔接手法
  1502. (场景如何切换、时间跳跃的处理方式、段落间的过渡特征)
  1503. ## 节奏特征
  1504. (长短句分布、段落长度偏好、高潮/舒缓的交替方式)
  1505. ## 词汇偏好
  1506. (高频特色用词、比喻/修辞倾向、口语化程度)
  1507. ## 情绪表达方式
  1508. (直白抒情 vs 动作外化、内心独白的频率和风格)
  1509. ## 独特习惯
  1510. (任何值得模仿的个人写作习惯)
  1511. 分析必须基于原文实际特征,不要泛泛而谈。每个部分用1-2个原文例句佐证。`,
  1512. },
  1513. {
  1514. role: "user",
  1515. content: `分析以下参考文本的写作风格:\n\n${referenceText.slice(0, 20000)}`,
  1516. },
  1517. ], { temperature: 0.3, maxTokens: 4096 });
  1518. await writeFile(join(storyDir, "style_guide.md"), response.content, "utf-8");
  1519. return response.content;
  1520. }
  1521. /**
  1522. * Import canon from parent book for spinoff writing.
  1523. * Reads parent's truth files, uses LLM to generate parent_canon.md in target book.
  1524. */
  1525. async importCanon(targetBookId: string, parentBookId: string): Promise<string> {
  1526. // Validate both books exist
  1527. const bookIds = await this.state.listBooks();
  1528. if (!bookIds.includes(parentBookId)) {
  1529. throw new Error(`Parent book "${parentBookId}" not found. Available: ${bookIds.join(", ") || "(none)"}`);
  1530. }
  1531. if (!bookIds.includes(targetBookId)) {
  1532. throw new Error(`Target book "${targetBookId}" not found. Available: ${bookIds.join(", ") || "(none)"}`);
  1533. }
  1534. const parentDir = this.state.bookDir(parentBookId);
  1535. const targetDir = this.state.bookDir(targetBookId);
  1536. const storyDir = join(targetDir, "story");
  1537. await mkdir(storyDir, { recursive: true });
  1538. const readSafe = async (path: string): Promise<string> => {
  1539. try { return await readFile(path, "utf-8"); } catch { return "(无)"; }
  1540. };
  1541. const parentBook = await this.state.loadBookConfig(parentBookId);
  1542. const [storyBible, currentState, ledger, hooks, summaries, subplots, emotions, matrix] =
  1543. await Promise.all([
  1544. readSafe(join(parentDir, "story/story_bible.md")),
  1545. readSafe(join(parentDir, "story/current_state.md")),
  1546. readSafe(join(parentDir, "story/particle_ledger.md")),
  1547. readSafe(join(parentDir, "story/pending_hooks.md")),
  1548. readSafe(join(parentDir, "story/chapter_summaries.md")),
  1549. readSafe(join(parentDir, "story/subplot_board.md")),
  1550. readSafe(join(parentDir, "story/emotional_arcs.md")),
  1551. readSafe(join(parentDir, "story/character_matrix.md")),
  1552. ]);
  1553. const response = await chatCompletion(this.config.client, this.config.model, [
  1554. {
  1555. role: "system",
  1556. content: `你是一位网络小说架构师。基于正传的全部设定和状态文件,生成一份完整的"正传正典参照"文档,供番外写作和审计使用。
  1557. 输出格式(Markdown):
  1558. # 正传正典(《{正传书名}》)
  1559. ## 世界规则(完整,来自正传设定)
  1560. (力量体系、地理设定、阵营关系、核心规则——完整复制,不压缩)
  1561. ## 正典约束(不可违反的事实)
  1562. | 约束ID | 类型 | 约束内容 | 严重性 |
  1563. |---|---|---|---|
  1564. | C01 | 人物存亡 | ... | critical |
  1565. (列出所有硬性约束:谁活着、谁死了、什么事件已经发生、什么规则不可违反)
  1566. ## 角色快照
  1567. | 角色 | 当前状态 | 性格底色 | 对话特征 | 已知信息 | 未知信息 |
  1568. |---|---|---|---|---|---|
  1569. (从状态卡和角色矩阵中提取每个重要角色的完整快照)
  1570. ## 角色双态处理原则
  1571. - 未来会变强的角色:写潜力暗示
  1572. - 未来会黑化的角色:写微小裂痕
  1573. - 未来会死的角色:写导致死亡的性格底色
  1574. ## 关键事件时间线
  1575. | 章节 | 事件 | 涉及角色 | 对番外的约束 |
  1576. |---|---|---|---|
  1577. (从章节摘要中提取关键事件)
  1578. ## 伏笔状态
  1579. | Hook ID | 类型 | 状态 | 内容 | 预期回收 |
  1580. |---|---|---|---|---|
  1581. ## 资源账本快照
  1582. (当前资源状态)
  1583. ---
  1584. meta:
  1585. parentBookId: "{parentBookId}"
  1586. parentTitle: "{正传书名}"
  1587. generatedAt: "{ISO timestamp}"
  1588. 要求:
  1589. 1. 世界规则完整复制,不压缩——准确性优先
  1590. 2. 正典约束必须穷尽,遗漏会导致番外与正传矛盾
  1591. 3. 角色快照必须包含信息边界(已知/未知),防止番外中角色引用不该知道的信息`,
  1592. },
  1593. {
  1594. role: "user",
  1595. content: `正传书名:${parentBook.title}
  1596. 正传ID:${parentBookId}
  1597. ## 正传世界设定
  1598. ${storyBible}
  1599. ## 正传当前状态卡
  1600. ${currentState}
  1601. ## 正传资源账本
  1602. ${ledger}
  1603. ## 正传伏笔池
  1604. ${hooks}
  1605. ## 正传章节摘要
  1606. ${summaries}
  1607. ## 正传支线进度
  1608. ${subplots}
  1609. ## 正传情感弧线
  1610. ${emotions}
  1611. ## 正传角色矩阵
  1612. ${matrix}`,
  1613. },
  1614. ], { temperature: 0.3, maxTokens: 16384 });
  1615. // Append deterministic meta block (LLM may hallucinate timestamps)
  1616. const metaBlock = [
  1617. "",
  1618. "---",
  1619. "meta:",
  1620. ` parentBookId: "${parentBookId}"`,
  1621. ` parentTitle: "${parentBook.title}"`,
  1622. ` generatedAt: "${new Date().toISOString()}"`,
  1623. ].join("\n");
  1624. const canon = response.content + metaBlock;
  1625. await writeFile(join(storyDir, "parent_canon.md"), canon, "utf-8");
  1626. // Also generate style guide from parent's chapter text if available
  1627. const parentChaptersDir = join(parentDir, "chapters");
  1628. const parentChapterText = await this.readParentChapterSample(parentChaptersDir);
  1629. if (parentChapterText.length >= 500) {
  1630. await this.tryGenerateStyleGuide(targetBookId, parentChapterText, parentBook.title);
  1631. }
  1632. return canon;
  1633. }
  1634. private async readParentChapterSample(chaptersDir: string): Promise<string> {
  1635. try {
  1636. const entries = await readdir(chaptersDir);
  1637. const mdFiles = entries
  1638. .filter((file) => file.endsWith(".md"))
  1639. .sort()
  1640. .slice(0, 5);
  1641. const chunks: string[] = [];
  1642. let totalLength = 0;
  1643. for (const file of mdFiles) {
  1644. if (totalLength >= 20000) break;
  1645. const content = await readFile(join(chaptersDir, file), "utf-8");
  1646. chunks.push(content);
  1647. totalLength += content.length;
  1648. }
  1649. return chunks.join("\n\n---\n\n");
  1650. } catch {
  1651. return "";
  1652. }
  1653. }
  1654. // ---------------------------------------------------------------------------
  1655. // Chapter import (for continuation writing from existing chapters)
  1656. // ---------------------------------------------------------------------------
  1657. /**
  1658. * Import existing chapters into a book. Reverse-engineers all truth files
  1659. * via sequential replay so the Writer and Auditor can continue naturally.
  1660. *
  1661. * Step 1: Generate foundation (story_bible, volume_outline, book_rules) from all chapters.
  1662. * Step 2: Sequentially replay each chapter through ChapterAnalyzer to build truth files.
  1663. */
  1664. async importChapters(input: ImportChaptersInput): Promise<ImportChaptersResult> {
  1665. const releaseLock = await this.state.acquireBookLock(input.bookId);
  1666. try {
  1667. const book = await this.state.loadBookConfig(input.bookId);
  1668. const bookDir = this.state.bookDir(input.bookId);
  1669. const { profile: gp } = await this.loadGenreProfile(book.genre);
  1670. const resolvedLanguage = book.language ?? gp.language;
  1671. const startFrom = input.resumeFrom ?? 1;
  1672. const log = this.config.logger?.child("import");
  1673. // Step 1: Generate foundation on first run (not on resume)
  1674. if (startFrom === 1) {
  1675. log?.info(this.localize(resolvedLanguage, {
  1676. zh: `步骤 1:从 ${input.chapters.length} 章生成基础设定...`,
  1677. en: `Step 1: Generating foundation from ${input.chapters.length} chapters...`,
  1678. }));
  1679. const allText = input.chapters.map((c, i) =>
  1680. resolvedLanguage === "en"
  1681. ? `Chapter ${i + 1}: ${c.title}\n\n${c.content}`
  1682. : `第${i + 1}章 ${c.title}\n\n${c.content}`,
  1683. ).join("\n\n---\n\n");
  1684. const architect = new ArchitectAgent(this.agentCtxFor("architect", input.bookId));
  1685. const foundation = await architect.generateFoundationFromImport(book, allText);
  1686. await architect.writeFoundationFiles(
  1687. bookDir,
  1688. foundation,
  1689. gp.numericalSystem,
  1690. resolvedLanguage,
  1691. );
  1692. await this.resetImportReplayTruthFiles(bookDir, resolvedLanguage);
  1693. await this.state.saveChapterIndex(input.bookId, []);
  1694. await this.state.snapshotState(input.bookId, 0);
  1695. // Generate style guide from imported chapters
  1696. if (allText.length >= 500) {
  1697. log?.info(this.localize(resolvedLanguage, {
  1698. zh: "提取原文风格指纹...",
  1699. en: "Extracting source style fingerprint...",
  1700. }));
  1701. await this.tryGenerateStyleGuide(input.bookId, allText, book.title, resolvedLanguage);
  1702. }
  1703. log?.info(this.localize(resolvedLanguage, {
  1704. zh: "基础设定已生成。",
  1705. en: "Foundation generated.",
  1706. }));
  1707. }
  1708. // Step 2: Sequential replay
  1709. log?.info(this.localize(resolvedLanguage, {
  1710. zh: `步骤 2:从第 ${startFrom} 章开始顺序回放...`,
  1711. en: `Step 2: Sequential replay from chapter ${startFrom}...`,
  1712. }));
  1713. const analyzer = new ChapterAnalyzerAgent(this.agentCtxFor("chapter-analyzer", input.bookId));
  1714. const writer = new WriterAgent(this.agentCtxFor("writer", input.bookId));
  1715. const countingMode = resolveLengthCountingMode(book.language ?? gp.language);
  1716. let totalWords = 0;
  1717. let importedCount = 0;
  1718. for (let i = startFrom - 1; i < input.chapters.length; i++) {
  1719. const ch = input.chapters[i]!;
  1720. const chapterNumber = i + 1;
  1721. const governedInput = await this.prepareWriteInput(book, bookDir, chapterNumber);
  1722. log?.info(this.localize(resolvedLanguage, {
  1723. zh: `分析章节 ${chapterNumber}/${input.chapters.length}:${ch.title}...`,
  1724. en: `Analyzing chapter ${chapterNumber}/${input.chapters.length}: ${ch.title}...`,
  1725. }));
  1726. // Analyze chapter to get truth file updates
  1727. const output = await analyzer.analyzeChapter({
  1728. book,
  1729. bookDir,
  1730. chapterNumber,
  1731. chapterContent: ch.content,
  1732. chapterTitle: ch.title,
  1733. chapterIntent: governedInput.chapterIntent,
  1734. contextPackage: governedInput.contextPackage,
  1735. ruleStack: governedInput.ruleStack,
  1736. });
  1737. // Save chapter file + core truth files (state, ledger, hooks)
  1738. await writer.saveChapter(bookDir, {
  1739. ...output,
  1740. postWriteErrors: [],
  1741. postWriteWarnings: [],
  1742. }, gp.numericalSystem, resolvedLanguage);
  1743. // Save extended truth files (summaries, subplots, emotional arcs, character matrix)
  1744. await writer.saveNewTruthFiles(bookDir, {
  1745. ...output,
  1746. postWriteErrors: [],
  1747. postWriteWarnings: [],
  1748. }, resolvedLanguage);
  1749. await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, output);
  1750. await this.syncNarrativeMemoryIndex(input.bookId);
  1751. // Update chapter index
  1752. const existingIndex = await this.state.loadChapterIndex(input.bookId);
  1753. const now = new Date().toISOString();
  1754. const chapterWordCount = countChapterLength(ch.content, countingMode);
  1755. const newEntry: ChapterMeta = {
  1756. number: chapterNumber,
  1757. title: output.title,
  1758. status: "imported",
  1759. wordCount: chapterWordCount,
  1760. createdAt: now,
  1761. updatedAt: now,
  1762. auditIssues: [],
  1763. lengthWarnings: [],
  1764. };
  1765. // Replace if exists (resume case), otherwise append
  1766. const existingIdx = existingIndex.findIndex((e) => e.number === chapterNumber);
  1767. const updatedIndex = existingIdx >= 0
  1768. ? existingIndex.map((e, idx) => idx === existingIdx ? newEntry : e)
  1769. : [...existingIndex, newEntry];
  1770. await this.state.saveChapterIndex(input.bookId, updatedIndex);
  1771. // Snapshot state after each chapter for rollback + resume support
  1772. await this.state.snapshotState(input.bookId, chapterNumber);
  1773. importedCount++;
  1774. totalWords += chapterWordCount;
  1775. }
  1776. if (input.chapters.length > 0) {
  1777. await this.markBookActiveIfNeeded(input.bookId);
  1778. await this.syncCurrentStateFactHistory(input.bookId, input.chapters.length);
  1779. }
  1780. const nextChapter = input.chapters.length + 1;
  1781. log?.info(this.localize(resolvedLanguage, {
  1782. zh: `完成。已导入 ${importedCount} 章,共 ${formatLengthCount(totalWords, countingMode)}。下一章:${nextChapter}`,
  1783. en: `Done. ${importedCount} chapters imported, ${formatLengthCount(totalWords, countingMode)}. Next chapter: ${nextChapter}`,
  1784. }));
  1785. return {
  1786. bookId: input.bookId,
  1787. importedCount,
  1788. totalWords,
  1789. nextChapter,
  1790. };
  1791. } finally {
  1792. await releaseLock();
  1793. }
  1794. }
  1795. private static addUsage(
  1796. a: TokenUsageSummary,
  1797. b?: { readonly promptTokens: number; readonly completionTokens: number; readonly totalTokens: number },
  1798. ): TokenUsageSummary {
  1799. if (!b) return a;
  1800. return {
  1801. promptTokens: a.promptTokens + b.promptTokens,
  1802. completionTokens: a.completionTokens + b.completionTokens,
  1803. totalTokens: a.totalTokens + b.totalTokens,
  1804. };
  1805. }
  1806. private async buildPersistenceOutput(
  1807. bookId: string,
  1808. book: BookConfig,
  1809. bookDir: string,
  1810. chapterNumber: number,
  1811. output: WriteChapterOutput,
  1812. finalContent: string,
  1813. countingMode: Parameters<typeof countChapterLength>[1],
  1814. reducedControlInput?: {
  1815. chapterIntent: string;
  1816. contextPackage: ContextPackage;
  1817. ruleStack: RuleStack;
  1818. },
  1819. ): Promise<WriteChapterOutput> {
  1820. if (finalContent === output.content) {
  1821. return output;
  1822. }
  1823. const analyzer = new ChapterAnalyzerAgent(this.agentCtxFor("chapter-analyzer", bookId));
  1824. const analyzed = await analyzer.analyzeChapter({
  1825. book,
  1826. bookDir,
  1827. chapterNumber,
  1828. chapterContent: finalContent,
  1829. chapterTitle: output.title,
  1830. chapterIntent: reducedControlInput?.chapterIntent,
  1831. contextPackage: reducedControlInput?.contextPackage,
  1832. ruleStack: reducedControlInput?.ruleStack,
  1833. });
  1834. return {
  1835. ...analyzed,
  1836. content: finalContent,
  1837. wordCount: countChapterLength(finalContent, countingMode),
  1838. postWriteErrors: [],
  1839. postWriteWarnings: [],
  1840. hookHealthIssues: output.hookHealthIssues,
  1841. tokenUsage: output.tokenUsage,
  1842. };
  1843. }
  1844. private async assertNoPendingStateRepair(bookId: string): Promise<void> {
  1845. const existingIndex = await this.state.loadChapterIndex(bookId);
  1846. const latestChapter = [...existingIndex].sort((left, right) => right.number - left.number)[0];
  1847. if (latestChapter?.status !== "state-degraded") {
  1848. return;
  1849. }
  1850. throw new Error(
  1851. `Latest chapter ${latestChapter.number} is state-degraded. Repair state or rewrite that chapter before continuing.`,
  1852. );
  1853. }
  1854. // ---------------------------------------------------------------------------
  1855. // Helpers
  1856. // ---------------------------------------------------------------------------
  1857. private async prepareWriteInput(
  1858. book: BookConfig,
  1859. bookDir: string,
  1860. chapterNumber: number,
  1861. externalContext?: string,
  1862. ): Promise<Pick<WriteChapterInput, "externalContext" | "chapterIntent" | "contextPackage" | "ruleStack" | "trace">> {
  1863. if ((this.config.inputGovernanceMode ?? "v2") === "legacy") {
  1864. return { externalContext };
  1865. }
  1866. const { plan, composed } = await this.createGovernedArtifacts(
  1867. book,
  1868. bookDir,
  1869. chapterNumber,
  1870. externalContext,
  1871. { reuseExistingIntentWhenContextMissing: true },
  1872. );
  1873. return {
  1874. chapterIntent: plan.intentMarkdown,
  1875. contextPackage: composed.contextPackage,
  1876. ruleStack: composed.ruleStack,
  1877. trace: composed.trace,
  1878. };
  1879. }
  1880. private async resetImportReplayTruthFiles(
  1881. bookDir: string,
  1882. language: LengthLanguage,
  1883. ): Promise<void> {
  1884. const storyDir = join(bookDir, "story");
  1885. await Promise.all([
  1886. writeFile(
  1887. join(storyDir, "current_state.md"),
  1888. this.buildImportReplayStateSeed(language),
  1889. "utf-8",
  1890. ),
  1891. writeFile(
  1892. join(storyDir, "pending_hooks.md"),
  1893. this.buildImportReplayHooksSeed(language),
  1894. "utf-8",
  1895. ),
  1896. rm(join(storyDir, "chapter_summaries.md"), { force: true }),
  1897. rm(join(storyDir, "subplot_board.md"), { force: true }),
  1898. rm(join(storyDir, "emotional_arcs.md"), { force: true }),
  1899. rm(join(storyDir, "character_matrix.md"), { force: true }),
  1900. rm(join(storyDir, "volume_summaries.md"), { force: true }),
  1901. rm(join(storyDir, "particle_ledger.md"), { force: true }),
  1902. rm(join(storyDir, "memory.db"), { force: true }),
  1903. rm(join(storyDir, "memory.db-shm"), { force: true }),
  1904. rm(join(storyDir, "memory.db-wal"), { force: true }),
  1905. rm(join(storyDir, "state"), { recursive: true, force: true }),
  1906. rm(join(storyDir, "snapshots"), { recursive: true, force: true }),
  1907. ]);
  1908. }
  1909. private buildImportReplayStateSeed(language: LengthLanguage): string {
  1910. if (language === "en") {
  1911. return [
  1912. "# Current State",
  1913. "",
  1914. "| Field | Value |",
  1915. "| --- | --- |",
  1916. "| Current Chapter | 0 |",
  1917. "| Current Location | (not set) |",
  1918. "| Protagonist State | (not set) |",
  1919. "| Current Goal | (not set) |",
  1920. "| Current Constraint | (not set) |",
  1921. "| Current Alliances | (not set) |",
  1922. "| Current Conflict | (not set) |",
  1923. "",
  1924. ].join("\n");
  1925. }
  1926. return [
  1927. "# 当前状态",
  1928. "",
  1929. "| 字段 | 值 |",
  1930. "| --- | --- |",
  1931. "| 当前章节 | 0 |",
  1932. "| 当前位置 | (未设定) |",
  1933. "| 主角状态 | (未设定) |",
  1934. "| 当前目标 | (未设定) |",
  1935. "| 当前限制 | (未设定) |",
  1936. "| 当前敌我 | (未设定) |",
  1937. "| 当前冲突 | (未设定) |",
  1938. "",
  1939. ].join("\n");
  1940. }
  1941. private buildImportReplayHooksSeed(language: LengthLanguage): string {
  1942. if (language === "en") {
  1943. return [
  1944. "# Pending Hooks",
  1945. "",
  1946. "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
  1947. "| --- | --- | --- | --- | --- | --- | --- |",
  1948. "",
  1949. ].join("\n");
  1950. }
  1951. return [
  1952. "# 伏笔池",
  1953. "",
  1954. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  1955. "| --- | --- | --- | --- | --- | --- | --- |",
  1956. "",
  1957. ].join("\n");
  1958. }
  1959. private async normalizeDraftLengthIfNeeded(params: {
  1960. bookId: string;
  1961. chapterNumber: number;
  1962. chapterContent: string;
  1963. lengthSpec: LengthSpec;
  1964. chapterIntent?: string;
  1965. }): Promise<{
  1966. content: string;
  1967. wordCount: number;
  1968. applied: boolean;
  1969. tokenUsage?: TokenUsageSummary;
  1970. }> {
  1971. const writerCount = countChapterLength(
  1972. params.chapterContent,
  1973. params.lengthSpec.countingMode,
  1974. );
  1975. if (!isOutsideSoftRange(writerCount, params.lengthSpec)) {
  1976. return {
  1977. content: params.chapterContent,
  1978. wordCount: writerCount,
  1979. applied: false,
  1980. };
  1981. }
  1982. const normalizer = new LengthNormalizerAgent(
  1983. this.agentCtxFor("length-normalizer", params.bookId),
  1984. );
  1985. const normalized = await normalizer.normalizeChapter({
  1986. chapterContent: params.chapterContent,
  1987. lengthSpec: params.lengthSpec,
  1988. chapterIntent: params.chapterIntent,
  1989. });
  1990. // Safety net: if normalizer output is less than 25% of original, it was too destructive.
  1991. // Reject and keep original content.
  1992. if (normalized.finalCount < writerCount * 0.25) {
  1993. this.logWarn(this.languageFromLengthSpec(params.lengthSpec), {
  1994. zh: `字数归一化被拒绝:第${params.chapterNumber}章 ${writerCount} -> ${normalized.finalCount}(砍了${Math.round((1 - normalized.finalCount / writerCount) * 100)}%,超过安全阈值)`,
  1995. en: `Length normalization rejected for chapter ${params.chapterNumber}: ${writerCount} -> ${normalized.finalCount} (cut ${Math.round((1 - normalized.finalCount / writerCount) * 100)}%, exceeds safety threshold)`,
  1996. });
  1997. return {
  1998. content: params.chapterContent,
  1999. wordCount: writerCount,
  2000. applied: false,
  2001. };
  2002. }
  2003. this.logInfo(this.languageFromLengthSpec(params.lengthSpec), {
  2004. zh: `审计前字数归一化:第${params.chapterNumber}章 ${writerCount} -> ${normalized.finalCount}`,
  2005. en: `Length normalization before audit for chapter ${params.chapterNumber}: ${writerCount} -> ${normalized.finalCount}`,
  2006. });
  2007. return {
  2008. content: normalized.normalizedContent,
  2009. wordCount: normalized.finalCount,
  2010. applied: normalized.applied,
  2011. tokenUsage: normalized.tokenUsage,
  2012. };
  2013. }
  2014. private assertChapterContentNotEmpty(content: string, chapterNumber: number, stage: string): void {
  2015. if (content.trim().length > 0) return;
  2016. throw new Error(`Chapter ${chapterNumber} has empty chapter content after ${stage}`);
  2017. }
  2018. private async syncCurrentStateFactHistory(bookId: string, uptoChapter: number): Promise<void> {
  2019. const bookDir = this.state.bookDir(bookId);
  2020. try {
  2021. await this.rebuildCurrentStateFactHistory(bookDir, uptoChapter);
  2022. } catch (error) {
  2023. if (this.isMemoryIndexUnavailableError(error)) {
  2024. if (this.canOpenMemoryIndex(bookDir)) {
  2025. try {
  2026. await this.rebuildCurrentStateFactHistory(bookDir, uptoChapter);
  2027. return;
  2028. } catch (retryError) {
  2029. error = retryError;
  2030. }
  2031. } else {
  2032. if (!this.memoryIndexFallbackWarned) {
  2033. this.memoryIndexFallbackWarned = true;
  2034. this.logWarn(await this.resolveBookLanguageById(bookId), {
  2035. zh: "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。",
  2036. en: "SQLite memory index unavailable on this Node runtime; continuing with markdown fallback.",
  2037. });
  2038. await this.logMemoryIndexDebugInfo(bookId, error);
  2039. }
  2040. return;
  2041. }
  2042. }
  2043. this.logWarn(await this.resolveBookLanguageById(bookId), {
  2044. zh: `状态事实同步已跳过:${String(error)}`,
  2045. en: `State fact sync skipped: ${String(error)}`,
  2046. });
  2047. }
  2048. }
  2049. private async syncLegacyStructuredStateFromMarkdown(
  2050. bookDir: string,
  2051. chapterNumber: number,
  2052. output?: {
  2053. readonly runtimeStateDelta?: WriteChapterOutput["runtimeStateDelta"];
  2054. readonly runtimeStateSnapshot?: WriteChapterOutput["runtimeStateSnapshot"];
  2055. },
  2056. ): Promise<void> {
  2057. if (output?.runtimeStateDelta || output?.runtimeStateSnapshot) {
  2058. return;
  2059. }
  2060. await rewriteStructuredStateFromMarkdown({
  2061. bookDir,
  2062. fallbackChapter: chapterNumber,
  2063. });
  2064. }
  2065. private async syncNarrativeMemoryIndex(bookId: string): Promise<void> {
  2066. const bookDir = this.state.bookDir(bookId);
  2067. try {
  2068. await this.rebuildNarrativeMemoryIndex(bookDir);
  2069. } catch (error) {
  2070. if (this.isMemoryIndexUnavailableError(error)) {
  2071. if (this.canOpenMemoryIndex(bookDir)) {
  2072. try {
  2073. await this.rebuildNarrativeMemoryIndex(bookDir);
  2074. return;
  2075. } catch (retryError) {
  2076. error = retryError;
  2077. }
  2078. } else {
  2079. if (!this.memoryIndexFallbackWarned) {
  2080. this.memoryIndexFallbackWarned = true;
  2081. this.logWarn(await this.resolveBookLanguageById(bookId), {
  2082. zh: "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。",
  2083. en: "SQLite memory index unavailable on this Node runtime; continuing with markdown fallback.",
  2084. });
  2085. await this.logMemoryIndexDebugInfo(bookId, error);
  2086. }
  2087. return;
  2088. }
  2089. }
  2090. this.logWarn(await this.resolveBookLanguageById(bookId), {
  2091. zh: `叙事记忆同步已跳过:${String(error)}`,
  2092. en: `Narrative memory sync skipped: ${String(error)}`,
  2093. });
  2094. }
  2095. }
  2096. private async rebuildCurrentStateFactHistory(bookDir: string, uptoChapter: number): Promise<void> {
  2097. const memoryDb = await this.withMemoryIndexRetry(async () => {
  2098. const db = new MemoryDB(bookDir);
  2099. try {
  2100. db.resetFacts();
  2101. const activeFacts = new Map<string, { id: number; object: string }>();
  2102. for (let chapter = 0; chapter <= uptoChapter; chapter++) {
  2103. const snapshotFacts = await loadSnapshotCurrentStateFacts(bookDir, chapter);
  2104. if (snapshotFacts.length === 0) continue;
  2105. const nextFacts = new Map<string, Omit<Fact, "id">>();
  2106. for (const fact of snapshotFacts) {
  2107. nextFacts.set(this.factKey(fact), {
  2108. subject: fact.subject,
  2109. predicate: fact.predicate,
  2110. object: fact.object,
  2111. validFromChapter: chapter,
  2112. validUntilChapter: null,
  2113. sourceChapter: chapter,
  2114. });
  2115. }
  2116. for (const [key, previous] of activeFacts.entries()) {
  2117. const next = nextFacts.get(key);
  2118. if (!next || next.object !== previous.object) {
  2119. db.invalidateFact(previous.id, chapter);
  2120. activeFacts.delete(key);
  2121. }
  2122. }
  2123. for (const [key, fact] of nextFacts.entries()) {
  2124. if (activeFacts.has(key)) continue;
  2125. const id = db.addFact(fact);
  2126. activeFacts.set(key, { id, object: fact.object });
  2127. }
  2128. }
  2129. return db;
  2130. } catch (error) {
  2131. db.close();
  2132. throw error;
  2133. }
  2134. });
  2135. try {
  2136. // No-op: keep the db open only for the duration of the rebuild.
  2137. } finally {
  2138. memoryDb.close();
  2139. }
  2140. }
  2141. private async rebuildNarrativeMemoryIndex(bookDir: string): Promise<void> {
  2142. const memorySeed = await loadNarrativeMemorySeed(bookDir);
  2143. const memoryDb = await this.withMemoryIndexRetry(() => {
  2144. const db = new MemoryDB(bookDir);
  2145. try {
  2146. db.replaceSummaries(memorySeed.summaries);
  2147. db.replaceHooks(memorySeed.hooks);
  2148. return db;
  2149. } catch (error) {
  2150. db.close();
  2151. throw error;
  2152. }
  2153. });
  2154. try {
  2155. // No-op: keep the db open only for the duration of the rebuild.
  2156. } finally {
  2157. memoryDb.close();
  2158. }
  2159. }
  2160. private canOpenMemoryIndex(bookDir: string): boolean {
  2161. let memoryDb: MemoryDB | null = null;
  2162. try {
  2163. memoryDb = new MemoryDB(bookDir);
  2164. return true;
  2165. } catch {
  2166. return false;
  2167. } finally {
  2168. memoryDb?.close();
  2169. }
  2170. }
  2171. private async logMemoryIndexDebugInfo(bookId: string, error: unknown): Promise<void> {
  2172. if (process.env.INKOS_DEBUG_SQLITE_MEMORY !== "1") {
  2173. return;
  2174. }
  2175. const code = typeof error === "object" && error !== null && "code" in error
  2176. ? String((error as { code?: unknown }).code ?? "")
  2177. : "";
  2178. const message = error instanceof Error
  2179. ? error.message
  2180. : String(error);
  2181. this.logWarn(await this.resolveBookLanguageById(bookId), {
  2182. zh: `SQLite 记忆索引调试:node=${process.version}; execArgv=${JSON.stringify(process.execArgv)}; code=${code || "(none)"}; message=${message}`,
  2183. en: `SQLite memory debug: node=${process.version}; execArgv=${JSON.stringify(process.execArgv)}; code=${code || "(none)"}; message=${message}`,
  2184. });
  2185. }
  2186. private async withMemoryIndexRetry<T>(operation: () => Promise<T> | T): Promise<T> {
  2187. const retryDelaysMs = [0, 25, 75];
  2188. let lastError: unknown;
  2189. for (let attempt = 0; attempt < retryDelaysMs.length; attempt += 1) {
  2190. try {
  2191. return await operation();
  2192. } catch (error) {
  2193. lastError = error;
  2194. if (!this.isMemoryIndexBusyError(error) || attempt === retryDelaysMs.length - 1) {
  2195. throw error;
  2196. }
  2197. await new Promise((resolve) => setTimeout(resolve, retryDelaysMs[attempt + 1]!));
  2198. }
  2199. }
  2200. throw lastError;
  2201. }
  2202. private isMemoryIndexUnavailableError(error: unknown): boolean {
  2203. if (!error) return false;
  2204. const code = typeof error === "object" && error !== null && "code" in error
  2205. ? String((error as { code?: unknown }).code ?? "")
  2206. : "";
  2207. const message = error instanceof Error
  2208. ? error.message
  2209. : String(error);
  2210. const normalizedMessage = message.trim();
  2211. return /^No such built-in module:\s*node:sqlite$/i.test(normalizedMessage)
  2212. || /^Cannot find module ['"]node:sqlite['"]$/i.test(normalizedMessage)
  2213. || (code === "ERR_UNKNOWN_BUILTIN_MODULE" && /\bnode:sqlite\b/i.test(normalizedMessage));
  2214. }
  2215. private isMemoryIndexBusyError(error: unknown): boolean {
  2216. if (!error) return false;
  2217. const code = typeof error === "object" && error !== null && "code" in error
  2218. ? String((error as { code?: unknown }).code ?? "")
  2219. : "";
  2220. const message = error instanceof Error
  2221. ? error.message
  2222. : String(error);
  2223. return code === "SQLITE_BUSY"
  2224. || code === "SQLITE_LOCKED"
  2225. || /\bSQLITE_BUSY\b/i.test(message)
  2226. || /\bSQLITE_LOCKED\b/i.test(message)
  2227. || /database is locked/i.test(message)
  2228. || /database is busy/i.test(message);
  2229. }
  2230. private factKey(fact: Pick<Fact, "subject" | "predicate">): string {
  2231. return `${fact.subject}::${fact.predicate}`;
  2232. }
  2233. private buildLengthWarnings(
  2234. chapterNumber: number,
  2235. finalCount: number,
  2236. lengthSpec: LengthSpec,
  2237. ): string[] {
  2238. if (!isOutsideHardRange(finalCount, lengthSpec)) {
  2239. return [];
  2240. }
  2241. return [
  2242. this.localize(this.languageFromLengthSpec(lengthSpec), {
  2243. zh: `第${chapterNumber}章经过一次字数归一化后仍超出硬区间(${lengthSpec.hardMin}-${lengthSpec.hardMax},实际 ${finalCount})。`,
  2244. en: `Chapter ${chapterNumber} remains outside hard range (${lengthSpec.hardMin}-${lengthSpec.hardMax}, actual ${finalCount}) after a single normalization pass.`,
  2245. }),
  2246. ];
  2247. }
  2248. private buildLengthTelemetry(params: {
  2249. lengthSpec: LengthSpec;
  2250. writerCount: number;
  2251. postWriterNormalizeCount: number;
  2252. postReviseCount: number;
  2253. finalCount: number;
  2254. normalizeApplied: boolean;
  2255. lengthWarning: boolean;
  2256. }): LengthTelemetry {
  2257. return {
  2258. target: params.lengthSpec.target,
  2259. softMin: params.lengthSpec.softMin,
  2260. softMax: params.lengthSpec.softMax,
  2261. hardMin: params.lengthSpec.hardMin,
  2262. hardMax: params.lengthSpec.hardMax,
  2263. countingMode: params.lengthSpec.countingMode,
  2264. writerCount: params.writerCount,
  2265. postWriterNormalizeCount: params.postWriterNormalizeCount,
  2266. postReviseCount: params.postReviseCount,
  2267. finalCount: params.finalCount,
  2268. normalizeApplied: params.normalizeApplied,
  2269. lengthWarning: params.lengthWarning,
  2270. };
  2271. }
  2272. private async persistAuditDriftGuidance(params: {
  2273. readonly bookDir: string;
  2274. readonly chapterNumber: number;
  2275. readonly issues: ReadonlyArray<AuditIssue>;
  2276. readonly language: LengthLanguage;
  2277. }): Promise<void> {
  2278. const storyDir = join(params.bookDir, "story");
  2279. const driftPath = join(storyDir, "audit_drift.md");
  2280. const statePath = join(storyDir, "current_state.md");
  2281. const currentState = await readFile(statePath, "utf-8").catch(() => "");
  2282. const sanitizedState = this.stripAuditDriftCorrectionBlock(currentState).trimEnd();
  2283. if (sanitizedState !== currentState) {
  2284. await writeFile(statePath, sanitizedState, "utf-8");
  2285. }
  2286. if (params.issues.length === 0) {
  2287. await rm(driftPath, { force: true }).catch(() => undefined);
  2288. return;
  2289. }
  2290. const block = [
  2291. this.localize(params.language, {
  2292. zh: "# 审计纠偏",
  2293. en: "# Audit Drift",
  2294. }),
  2295. "",
  2296. this.localize(params.language, {
  2297. zh: "## 审计纠偏(自动生成,下一章写作前参照)",
  2298. en: "## Audit Drift Correction",
  2299. }),
  2300. "",
  2301. this.localize(params.language, {
  2302. zh: `> 第${params.chapterNumber}章审计发现以下问题,下一章写作时必须避免:`,
  2303. en: `> Chapter ${params.chapterNumber} audit found the following issues to avoid in the next chapter:`,
  2304. }),
  2305. ...params.issues.map((issue) => `> - [${issue.severity}] ${issue.category}: ${issue.description}`),
  2306. "",
  2307. ].join("\n");
  2308. await writeFile(driftPath, block, "utf-8");
  2309. }
  2310. private stripAuditDriftCorrectionBlock(currentState: string): string {
  2311. const headers = [
  2312. "## 审计纠偏(自动生成,下一章写作前参照)",
  2313. "## Audit Drift Correction",
  2314. "# 审计纠偏",
  2315. "# Audit Drift",
  2316. ];
  2317. let cutIndex = -1;
  2318. for (const header of headers) {
  2319. const index = currentState.indexOf(header);
  2320. if (index >= 0 && (cutIndex < 0 || index < cutIndex)) {
  2321. cutIndex = index;
  2322. }
  2323. }
  2324. if (cutIndex < 0) {
  2325. return currentState;
  2326. }
  2327. return currentState.slice(0, cutIndex).trimEnd();
  2328. }
  2329. private logLengthWarnings(lengthWarnings: ReadonlyArray<string>): void {
  2330. for (const warning of lengthWarnings) {
  2331. this.config.logger?.warn(warning);
  2332. }
  2333. }
  2334. private restoreLostAuditIssues(previous: AuditResult, next: AuditResult): AuditResult {
  2335. if (next.passed || next.issues.length > 0 || previous.issues.length === 0) {
  2336. return next;
  2337. }
  2338. return {
  2339. ...next,
  2340. issues: previous.issues,
  2341. summary: next.summary || previous.summary,
  2342. };
  2343. }
  2344. private restoreActionableAuditIfLost(
  2345. previous: {
  2346. auditResult: AuditResult;
  2347. aiTellCount: number;
  2348. blockingCount: number;
  2349. criticalCount: number;
  2350. },
  2351. next: {
  2352. auditResult: AuditResult;
  2353. aiTellCount: number;
  2354. blockingCount: number;
  2355. criticalCount: number;
  2356. },
  2357. ): {
  2358. auditResult: AuditResult;
  2359. aiTellCount: number;
  2360. blockingCount: number;
  2361. criticalCount: number;
  2362. } {
  2363. const auditResult = this.restoreLostAuditIssues(previous.auditResult, next.auditResult);
  2364. if (auditResult === next.auditResult) {
  2365. return next;
  2366. }
  2367. return {
  2368. ...next,
  2369. auditResult,
  2370. blockingCount: auditResult.issues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length,
  2371. criticalCount: auditResult.issues.filter((issue) => issue.severity === "critical").length,
  2372. };
  2373. }
  2374. private async evaluateMergedAudit(params: {
  2375. auditor: ContinuityAuditor;
  2376. book: BookConfig;
  2377. bookDir: string;
  2378. chapterContent: string;
  2379. chapterNumber: number;
  2380. language: LengthLanguage;
  2381. auditOptions?: {
  2382. temperature?: number;
  2383. chapterIntent?: string;
  2384. contextPackage?: ContextPackage;
  2385. ruleStack?: RuleStack;
  2386. truthFileOverrides?: {
  2387. currentState?: string;
  2388. ledger?: string;
  2389. hooks?: string;
  2390. };
  2391. };
  2392. }): Promise<{
  2393. auditResult: AuditResult;
  2394. aiTellCount: number;
  2395. blockingCount: number;
  2396. criticalCount: number;
  2397. }> {
  2398. const llmAudit = await params.auditor.auditChapter(
  2399. params.bookDir,
  2400. params.chapterContent,
  2401. params.chapterNumber,
  2402. params.book.genre,
  2403. params.auditOptions,
  2404. );
  2405. const aiTells = analyzeAITells(params.chapterContent);
  2406. const sensitiveResult = analyzeSensitiveWords(params.chapterContent);
  2407. const longSpanFatigue = await analyzeLongSpanFatigue({
  2408. bookDir: params.bookDir,
  2409. chapterNumber: params.chapterNumber,
  2410. chapterContent: params.chapterContent,
  2411. language: params.language,
  2412. });
  2413. const hasBlockedWords = sensitiveResult.found.some((f) => f.severity === "block");
  2414. const issues: ReadonlyArray<AuditIssue> = [
  2415. ...llmAudit.issues,
  2416. ...aiTells.issues,
  2417. ...sensitiveResult.issues,
  2418. ...longSpanFatigue.issues,
  2419. ];
  2420. return {
  2421. auditResult: {
  2422. passed: hasBlockedWords ? false : llmAudit.passed,
  2423. issues,
  2424. summary: llmAudit.summary,
  2425. tokenUsage: llmAudit.tokenUsage,
  2426. },
  2427. aiTellCount: aiTells.issues.length,
  2428. blockingCount: issues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length,
  2429. criticalCount: issues.filter((issue) => issue.severity === "critical").length,
  2430. };
  2431. }
  2432. private async markBookActiveIfNeeded(bookId: string): Promise<void> {
  2433. const book = await this.state.loadBookConfig(bookId);
  2434. if (book.status !== "outlining") return;
  2435. await this.state.saveBookConfig(bookId, {
  2436. ...book,
  2437. status: "active",
  2438. updatedAt: new Date().toISOString(),
  2439. });
  2440. }
  2441. private async createGovernedArtifacts(
  2442. book: BookConfig,
  2443. bookDir: string,
  2444. chapterNumber: number,
  2445. externalContext?: string,
  2446. options?: {
  2447. readonly reuseExistingIntentWhenContextMissing?: boolean;
  2448. },
  2449. ): Promise<{
  2450. plan: PlanChapterOutput;
  2451. composed: Awaited<ReturnType<ComposerAgent["composeChapter"]>>;
  2452. }> {
  2453. const plan = await this.resolveGovernedPlan(book, bookDir, chapterNumber, externalContext, options);
  2454. const composer = new ComposerAgent(this.agentCtxFor("composer", book.id));
  2455. const composed = await composer.composeChapter({
  2456. book,
  2457. bookDir,
  2458. chapterNumber,
  2459. plan,
  2460. });
  2461. return { plan, composed };
  2462. }
  2463. private async resolveGovernedPlan(
  2464. book: BookConfig,
  2465. bookDir: string,
  2466. chapterNumber: number,
  2467. externalContext?: string,
  2468. options?: {
  2469. readonly reuseExistingIntentWhenContextMissing?: boolean;
  2470. },
  2471. ): Promise<PlanChapterOutput> {
  2472. if (
  2473. options?.reuseExistingIntentWhenContextMissing &&
  2474. (!externalContext || externalContext.trim().length === 0)
  2475. ) {
  2476. const persisted = await loadPersistedPlan(bookDir, chapterNumber);
  2477. if (persisted) return persisted;
  2478. }
  2479. const planner = new PlannerAgent(this.agentCtxFor("planner", book.id));
  2480. return planner.planChapter({
  2481. book,
  2482. bookDir,
  2483. chapterNumber,
  2484. externalContext,
  2485. });
  2486. }
  2487. private async emitWebhook(
  2488. event: WebhookEvent,
  2489. bookId: string,
  2490. chapterNumber?: number,
  2491. data?: Record<string, unknown>,
  2492. ): Promise<void> {
  2493. if (!this.config.notifyChannels || this.config.notifyChannels.length === 0) return;
  2494. await dispatchWebhookEvent(this.config.notifyChannels, {
  2495. event,
  2496. bookId,
  2497. chapterNumber,
  2498. timestamp: new Date().toISOString(),
  2499. data,
  2500. });
  2501. }
  2502. private async readChapterContent(bookDir: string, chapterNumber: number): Promise<string> {
  2503. const chaptersDir = join(bookDir, "chapters");
  2504. const files = await readdir(chaptersDir);
  2505. const paddedNum = String(chapterNumber).padStart(4, "0");
  2506. const chapterFile = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  2507. if (!chapterFile) {
  2508. throw new Error(`Chapter ${chapterNumber} file not found in ${chaptersDir}`);
  2509. }
  2510. const raw = await readFile(join(chaptersDir, chapterFile), "utf-8");
  2511. // Strip the title line
  2512. const lines = raw.split("\n");
  2513. const contentStart = lines.findIndex((l, i) => i > 0 && l.trim().length > 0);
  2514. return contentStart >= 0 ? lines.slice(contentStart).join("\n") : raw;
  2515. }
  2516. }