| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779 |
- import type { LLMClient, OnStreamProgress } from "../llm/provider.js";
- import { chatCompletion, createLLMClient } from "../llm/provider.js";
- import type { Logger } from "../utils/logger.js";
- import type { BookConfig, FanficMode } from "../models/book.js";
- import type { ChapterMeta } from "../models/chapter.js";
- import type { NotifyChannel, LLMConfig, AgentLLMOverride, InputGovernanceMode } from "../models/project.js";
- import type { GenreProfile } from "../models/genre-profile.js";
- import { ArchitectAgent } from "../agents/architect.js";
- import { PlannerAgent, type PlanChapterOutput } from "../agents/planner.js";
- import { ComposerAgent } from "../agents/composer.js";
- import { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "../agents/writer.js";
- import { LengthNormalizerAgent } from "../agents/length-normalizer.js";
- import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
- import { ContinuityAuditor } from "../agents/continuity.js";
- import { ReviserAgent, DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js";
- import { StateValidatorAgent, type ValidationResult, type ValidationWarning } from "../agents/state-validator.js";
- import { RadarAgent } from "../agents/radar.js";
- import type { RadarSource } from "../agents/radar-source.js";
- import { readGenreProfile } from "../agents/rules-reader.js";
- import { analyzeAITells } from "../agents/ai-tells.js";
- import { analyzeSensitiveWords } from "../agents/sensitive-words.js";
- import { StateManager } from "../state/manager.js";
- import { MemoryDB, type Fact } from "../state/memory-db.js";
- import { dispatchNotification, dispatchWebhookEvent } from "../notify/dispatcher.js";
- import type { WebhookEvent } from "../notify/webhook.js";
- import type { AgentContext } from "../agents/base.js";
- import type { AuditResult, AuditIssue } from "../agents/continuity.js";
- import type { RadarResult } from "../agents/radar.js";
- import type { LengthSpec, LengthTelemetry } from "../models/length-governance.js";
- import type { ContextPackage, RuleStack } from "../models/input-governance.js";
- import { buildLengthSpec, countChapterLength, formatLengthCount, isOutsideHardRange, isOutsideSoftRange, resolveLengthCountingMode, type LengthLanguage } from "../utils/length-metrics.js";
- import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js";
- import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state/runtime-state-store.js";
- import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
- import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/promises";
- import { join } from "node:path";
- import {
- buildStateDegradedPersistenceOutput,
- buildStateDegradedReviewNote,
- parseStateDegradedReviewNote,
- resolveStateDegradedBaseStatus,
- retrySettlementAfterValidationFailure,
- } from "./chapter-state-recovery.js";
- import { loadPersistedPlan, relativeToBookDir } from "./persisted-governed-plan.js";
- export interface PipelineConfig {
- readonly client: LLMClient;
- readonly model: string;
- readonly projectRoot: string;
- readonly defaultLLMConfig?: LLMConfig;
- readonly notifyChannels?: ReadonlyArray<NotifyChannel>;
- readonly radarSources?: ReadonlyArray<RadarSource>;
- readonly externalContext?: string;
- readonly modelOverrides?: Record<string, string | AgentLLMOverride>;
- readonly inputGovernanceMode?: InputGovernanceMode;
- readonly logger?: Logger;
- readonly onStreamProgress?: OnStreamProgress;
- }
- export interface TokenUsageSummary {
- readonly promptTokens: number;
- readonly completionTokens: number;
- readonly totalTokens: number;
- }
- export interface ChapterPipelineResult {
- readonly chapterNumber: number;
- readonly title: string;
- readonly wordCount: number;
- readonly auditResult: AuditResult;
- readonly revised: boolean;
- readonly status: "ready-for-review" | "audit-failed" | "state-degraded";
- readonly lengthWarnings?: ReadonlyArray<string>;
- readonly lengthTelemetry?: LengthTelemetry;
- readonly tokenUsage?: TokenUsageSummary;
- }
- // Atomic operation results
- export interface DraftResult {
- readonly chapterNumber: number;
- readonly title: string;
- readonly wordCount: number;
- readonly filePath: string;
- readonly lengthWarnings?: ReadonlyArray<string>;
- readonly lengthTelemetry?: LengthTelemetry;
- readonly tokenUsage?: TokenUsageSummary;
- }
- export interface PlanChapterResult {
- readonly bookId: string;
- readonly chapterNumber: number;
- readonly intentPath: string;
- readonly goal: string;
- readonly conflicts: ReadonlyArray<string>;
- }
- export interface ComposeChapterResult extends PlanChapterResult {
- readonly contextPath: string;
- readonly ruleStackPath: string;
- readonly tracePath: string;
- }
- export interface ReviseResult {
- readonly chapterNumber: number;
- readonly wordCount: number;
- readonly fixedIssues: ReadonlyArray<string>;
- readonly applied: boolean;
- readonly status: "unchanged" | "ready-for-review" | "audit-failed";
- readonly skippedReason?: string;
- readonly lengthWarnings?: ReadonlyArray<string>;
- readonly lengthTelemetry?: LengthTelemetry;
- }
- export interface TruthFiles {
- readonly currentState: string;
- readonly particleLedger: string;
- readonly pendingHooks: string;
- readonly storyBible: string;
- readonly volumeOutline: string;
- readonly bookRules: string;
- }
- export interface BookStatusInfo {
- readonly bookId: string;
- readonly title: string;
- readonly genre: string;
- readonly platform: string;
- readonly status: string;
- readonly chaptersWritten: number;
- readonly totalWords: number;
- readonly nextChapter: number;
- readonly chapters: ReadonlyArray<ChapterMeta>;
- }
- export interface ImportChaptersInput {
- readonly bookId: string;
- readonly chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>;
- readonly resumeFrom?: number;
- }
- export interface ImportChaptersResult {
- readonly bookId: string;
- readonly importedCount: number;
- readonly totalWords: number;
- readonly nextChapter: number;
- }
- export class PipelineRunner {
- private readonly state: StateManager;
- private readonly config: PipelineConfig;
- private readonly agentClients = new Map<string, LLMClient>();
- private memoryIndexFallbackWarned = false;
- constructor(config: PipelineConfig) {
- this.config = config;
- this.state = new StateManager(config.projectRoot);
- }
- private localize(language: LengthLanguage, messages: { zh: string; en: string }): string {
- return language === "en" ? messages.en : messages.zh;
- }
- private async resolveBookLanguage(
- book: Pick<BookConfig, "genre" | "language">,
- ): Promise<LengthLanguage> {
- if (book.language) {
- return book.language;
- }
- try {
- const { profile } = await this.loadGenreProfile(book.genre);
- return profile.language;
- } catch {
- return "zh";
- }
- }
- private async resolveBookLanguageById(bookId: string): Promise<LengthLanguage> {
- try {
- const book = await this.state.loadBookConfig(bookId);
- return await this.resolveBookLanguage(book);
- } catch {
- return "zh";
- }
- }
- private languageFromLengthSpec(lengthSpec: Pick<LengthSpec, "countingMode">): LengthLanguage {
- return lengthSpec.countingMode === "en_words" ? "en" : "zh";
- }
- private logStage(language: LengthLanguage, message: { zh: string; en: string }): void {
- this.config.logger?.info(
- `${this.localize(language, { zh: "阶段:", en: "Stage: " })}${this.localize(language, message)}`,
- );
- }
- private logInfo(language: LengthLanguage, message: { zh: string; en: string }): void {
- this.config.logger?.info(this.localize(language, message));
- }
- private logWarn(language: LengthLanguage, message: { zh: string; en: string }): void {
- this.config.logger?.warn(this.localize(language, message));
- }
- private async tryGenerateStyleGuide(
- bookId: string,
- referenceText: string,
- sourceName: string | undefined,
- language?: LengthLanguage,
- ): Promise<void> {
- try {
- await this.generateStyleGuide(bookId, referenceText, sourceName);
- } catch (error) {
- const resolvedLanguage = language ?? await this.resolveBookLanguageById(bookId);
- const detail = error instanceof Error ? error.message : String(error);
- this.logWarn(resolvedLanguage, {
- zh: `风格指纹提取失败,已跳过:${detail}`,
- en: `Style fingerprint extraction failed and was skipped: ${detail}`,
- });
- }
- }
- private agentCtx(bookId?: string): AgentContext {
- return {
- client: this.config.client,
- model: this.config.model,
- projectRoot: this.config.projectRoot,
- bookId,
- logger: this.config.logger,
- onStreamProgress: this.config.onStreamProgress,
- };
- }
- private resolveOverride(agentName: string): { model: string; client: LLMClient } {
- const override = this.config.modelOverrides?.[agentName];
- if (!override) {
- return { model: this.config.model, client: this.config.client };
- }
- if (typeof override === "string") {
- return { model: override, client: this.config.client };
- }
- // Full override — needs its own client if baseUrl differs
- if (!override.baseUrl) {
- return { model: override.model, client: this.config.client };
- }
- const base = this.config.defaultLLMConfig;
- const provider = override.provider ?? base?.provider ?? "custom";
- const apiKeySource = override.apiKeyEnv
- ? `env:${override.apiKeyEnv}`
- : `base:${base?.apiKey ?? ""}`;
- const stream = override.stream ?? base?.stream ?? true;
- const apiFormat = base?.apiFormat ?? "chat";
- const cacheKey = [
- provider,
- override.baseUrl,
- apiKeySource,
- `stream:${stream}`,
- `format:${apiFormat}`,
- ].join("|");
- let client = this.agentClients.get(cacheKey);
- if (!client) {
- const apiKey = override.apiKeyEnv
- ? process.env[override.apiKeyEnv] ?? ""
- : base?.apiKey ?? "";
- client = createLLMClient({
- provider,
- baseUrl: override.baseUrl,
- apiKey,
- model: override.model,
- temperature: base?.temperature ?? 0.7,
- maxTokens: base?.maxTokens ?? 8192,
- thinkingBudget: base?.thinkingBudget ?? 0,
- apiFormat,
- stream,
- });
- this.agentClients.set(cacheKey, client);
- }
- return { model: override.model, client };
- }
- private agentCtxFor(agent: string, bookId?: string): AgentContext {
- const { model, client } = this.resolveOverride(agent);
- return {
- client,
- model,
- projectRoot: this.config.projectRoot,
- bookId,
- logger: this.config.logger?.child(agent),
- onStreamProgress: this.config.onStreamProgress,
- };
- }
- private async pathExists(path: string): Promise<boolean> {
- try {
- await stat(path);
- return true;
- } catch {
- return false;
- }
- }
- private async loadGenreProfile(genre: string): Promise<{ profile: GenreProfile }> {
- const parsed = await readGenreProfile(this.config.projectRoot, genre);
- return { profile: parsed.profile };
- }
- // ---------------------------------------------------------------------------
- // Atomic operations (composable by OpenClaw or agent mode)
- // ---------------------------------------------------------------------------
- async runRadar(): Promise<RadarResult> {
- const radar = new RadarAgent(this.agentCtxFor("radar"), this.config.radarSources);
- return radar.scan();
- }
- async initBook(book: BookConfig): Promise<void> {
- const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
- const bookDir = this.state.bookDir(book.id);
- const stagingBookDir = join(
- this.state.booksDir,
- `.tmp-book-create-${book.id}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
- );
- const stageLanguage = await this.resolveBookLanguage(book);
- this.logStage(stageLanguage, { zh: "生成基础设定", en: "generating foundation" });
- const { profile: gp } = await this.loadGenreProfile(book.genre);
- const foundation = await architect.generateFoundation(book, this.config.externalContext);
- try {
- this.logStage(stageLanguage, { zh: "保存书籍配置", en: "saving book config" });
- await this.state.saveBookConfigAt(stagingBookDir, book);
- this.logStage(stageLanguage, { zh: "写入基础设定文件", en: "writing foundation files" });
- await architect.writeFoundationFiles(
- stagingBookDir,
- foundation,
- gp.numericalSystem,
- book.language ?? gp.language,
- );
- this.logStage(stageLanguage, { zh: "初始化控制文档", en: "initializing control documents" });
- await this.state.ensureControlDocumentsAt(
- stagingBookDir,
- book.language ?? gp.language,
- this.config.externalContext,
- );
- await this.state.saveChapterIndexAt(stagingBookDir, []);
- this.logStage(stageLanguage, { zh: "创建初始快照", en: "creating initial snapshot" });
- await this.state.snapshotStateAt(stagingBookDir, 0);
- if (await this.pathExists(bookDir)) {
- if (await this.state.isCompleteBookDirectory(bookDir)) {
- throw new Error(`Book "${book.id}" already exists at books/${book.id}/. Use a different title or delete the existing book first.`);
- }
- await rm(bookDir, { recursive: true, force: true });
- }
- await rename(stagingBookDir, bookDir);
- } catch (error) {
- await rm(stagingBookDir, { recursive: true, force: true }).catch(() => undefined);
- throw error;
- }
- }
- /** Import external source material and generate fanfic_canon.md */
- async importFanficCanon(
- bookId: string,
- sourceText: string,
- sourceName: string,
- fanficMode: FanficMode,
- ): Promise<string> {
- const { FanficCanonImporter } = await import("../agents/fanfic-canon-importer.js");
- const importer = new FanficCanonImporter(this.agentCtxFor("fanfic-canon-importer", bookId));
- const result = await importer.importFromText(sourceText, sourceName, fanficMode);
- const bookDir = this.state.bookDir(bookId);
- const storyDir = join(bookDir, "story");
- await mkdir(storyDir, { recursive: true });
- await writeFile(join(storyDir, "fanfic_canon.md"), result.fullDocument, "utf-8");
- return result.fullDocument;
- }
- /** One-step fanfic book creation: create book + import canon + generate foundation */
- async initFanficBook(
- book: BookConfig,
- sourceText: string,
- sourceName: string,
- fanficMode: FanficMode,
- ): Promise<void> {
- const bookDir = this.state.bookDir(book.id);
- const stageLanguage = await this.resolveBookLanguage(book);
- this.logStage(stageLanguage, { zh: "保存书籍配置", en: "saving book config" });
- await this.state.saveBookConfig(book.id, book);
- // Step 1: Import source material → fanfic_canon.md
- this.logStage(stageLanguage, { zh: "导入同人正典", en: "importing fanfic canon" });
- const fanficCanon = await this.importFanficCanon(book.id, sourceText, sourceName, fanficMode);
- // Step 2: Generate foundation from fanfic canon (not from scratch)
- const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
- this.logStage(stageLanguage, { zh: "生成同人基础设定", en: "generating fanfic foundation" });
- const { profile: gp } = await this.loadGenreProfile(book.genre);
- const foundation = await architect.generateFanficFoundation(book, fanficCanon, fanficMode);
- this.logStage(stageLanguage, { zh: "写入基础设定文件", en: "writing foundation files" });
- await architect.writeFoundationFiles(
- bookDir,
- foundation,
- gp.numericalSystem,
- book.language ?? gp.language,
- );
- this.logStage(stageLanguage, { zh: "初始化控制文档", en: "initializing control documents" });
- await this.state.ensureControlDocuments(book.id, this.config.externalContext);
- // Step 3: Generate style guide from source material
- if (sourceText.length >= 500) {
- this.logStage(stageLanguage, { zh: "提取原作风格指纹", en: "extracting source style fingerprint" });
- await this.tryGenerateStyleGuide(book.id, sourceText, sourceName, stageLanguage);
- }
- // Step 4: Initialize chapters directory + snapshot
- this.logStage(stageLanguage, { zh: "创建初始快照", en: "creating initial snapshot" });
- await mkdir(join(bookDir, "chapters"), { recursive: true });
- await this.state.saveChapterIndex(book.id, []);
- await this.state.snapshotState(book.id, 0);
- }
- /** Write a single draft chapter. Saves chapter file + truth files + index + snapshot. */
- async writeDraft(bookId: string, context?: string, wordCount?: number): Promise<DraftResult> {
- const releaseLock = await this.state.acquireBookLock(bookId);
- try {
- await this.state.ensureControlDocuments(bookId);
- const book = await this.state.loadBookConfig(bookId);
- const bookDir = this.state.bookDir(bookId);
- const chapterNumber = await this.state.getNextChapterNumber(bookId);
- const stageLanguage = await this.resolveBookLanguage(book);
- this.logStage(stageLanguage, { zh: "准备章节输入", en: "preparing chapter inputs" });
- const writeInput = await this.prepareWriteInput(
- book,
- bookDir,
- chapterNumber,
- context ?? this.config.externalContext,
- );
- const { profile: gp } = await this.loadGenreProfile(book.genre);
- const lengthSpec = buildLengthSpec(
- wordCount ?? book.chapterWordCount,
- book.language ?? gp.language,
- );
- const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
- this.logStage(stageLanguage, { zh: "撰写章节草稿", en: "writing chapter draft" });
- const output = await writer.writeChapter({
- book,
- bookDir,
- chapterNumber,
- ...writeInput,
- lengthSpec,
- ...(wordCount ? { wordCountOverride: wordCount } : {}),
- });
- const writerCount = countChapterLength(output.content, lengthSpec.countingMode);
- let totalUsage: TokenUsageSummary = output.tokenUsage ?? {
- promptTokens: 0,
- completionTokens: 0,
- totalTokens: 0,
- };
- const normalizedDraft = await this.normalizeDraftLengthIfNeeded({
- bookId,
- chapterNumber,
- chapterContent: output.content,
- lengthSpec,
- chapterIntent: writeInput.chapterIntent,
- });
- totalUsage = PipelineRunner.addUsage(totalUsage, normalizedDraft.tokenUsage);
- const draftOutput: WriteChapterOutput = {
- ...output,
- content: normalizedDraft.content,
- wordCount: normalizedDraft.wordCount,
- tokenUsage: totalUsage,
- };
- const lengthWarnings = this.buildLengthWarnings(
- chapterNumber,
- draftOutput.wordCount,
- lengthSpec,
- );
- const lengthTelemetry = this.buildLengthTelemetry({
- lengthSpec,
- writerCount,
- postWriterNormalizeCount: normalizedDraft.wordCount,
- postReviseCount: 0,
- finalCount: draftOutput.wordCount,
- normalizeApplied: normalizedDraft.applied,
- lengthWarning: lengthWarnings.length > 0,
- });
- this.logLengthWarnings(lengthWarnings);
- // Save chapter file
- const chaptersDir = join(bookDir, "chapters");
- const paddedNum = String(chapterNumber).padStart(4, "0");
- const sanitized = draftOutput.title.replace(/[/\\?%*:|"<>]/g, "").replace(/\s+/g, "_").slice(0, 50);
- const filename = `${paddedNum}_${sanitized}.md`;
- const filePath = join(chaptersDir, filename);
- const resolvedLang = book.language ?? gp.language;
- const heading = resolvedLang === "en"
- ? `# Chapter ${chapterNumber}: ${draftOutput.title}`
- : `# 第${chapterNumber}章 ${draftOutput.title}`;
- await writeFile(filePath, `${heading}\n\n${draftOutput.content}`, "utf-8");
- // Save truth files
- this.logStage(stageLanguage, { zh: "落盘草稿与真相文件", en: "persisting draft and truth files" });
- await writer.saveChapter(bookDir, draftOutput, gp.numericalSystem, resolvedLang);
- await writer.saveNewTruthFiles(bookDir, draftOutput, resolvedLang);
- await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, draftOutput);
- await this.syncNarrativeMemoryIndex(bookId);
- // Update index
- const existingIndex = await this.state.loadChapterIndex(bookId);
- const now = new Date().toISOString();
- const newEntry: ChapterMeta = {
- number: chapterNumber,
- title: draftOutput.title,
- status: "drafted",
- wordCount: draftOutput.wordCount,
- createdAt: now,
- updatedAt: now,
- auditIssues: [],
- lengthWarnings,
- lengthTelemetry,
- ...(draftOutput.tokenUsage ? { tokenUsage: draftOutput.tokenUsage } : {}),
- };
- await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
- await this.markBookActiveIfNeeded(bookId);
- // Snapshot
- this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" });
- await this.state.snapshotState(bookId, chapterNumber);
- await this.syncCurrentStateFactHistory(bookId, chapterNumber);
- await this.emitWebhook("chapter-complete", bookId, chapterNumber, {
- title: draftOutput.title,
- wordCount: draftOutput.wordCount,
- });
- return {
- chapterNumber,
- title: draftOutput.title,
- wordCount: draftOutput.wordCount,
- filePath,
- lengthWarnings,
- lengthTelemetry,
- tokenUsage: draftOutput.tokenUsage,
- };
- } finally {
- await releaseLock();
- }
- }
- async planChapter(bookId: string, context?: string): Promise<PlanChapterResult> {
- await this.state.ensureControlDocuments(bookId);
- const book = await this.state.loadBookConfig(bookId);
- const bookDir = this.state.bookDir(bookId);
- const chapterNumber = await this.state.getNextChapterNumber(bookId);
- const stageLanguage = await this.resolveBookLanguage(book);
- this.logStage(stageLanguage, { zh: "规划下一章意图", en: "planning next chapter intent" });
- const { plan } = await this.createGovernedArtifacts(
- book,
- bookDir,
- chapterNumber,
- context ?? this.config.externalContext,
- { reuseExistingIntentWhenContextMissing: false },
- );
- return {
- bookId,
- chapterNumber,
- intentPath: relativeToBookDir(bookDir, plan.runtimePath),
- goal: plan.intent.goal,
- conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`),
- };
- }
- async composeChapter(bookId: string, context?: string): Promise<ComposeChapterResult> {
- await this.state.ensureControlDocuments(bookId);
- const book = await this.state.loadBookConfig(bookId);
- const bookDir = this.state.bookDir(bookId);
- const chapterNumber = await this.state.getNextChapterNumber(bookId);
- const stageLanguage = await this.resolveBookLanguage(book);
- this.logStage(stageLanguage, { zh: "组装章节运行时上下文", en: "composing chapter runtime context" });
- const { plan, composed } = await this.createGovernedArtifacts(
- book,
- bookDir,
- chapterNumber,
- context ?? this.config.externalContext,
- { reuseExistingIntentWhenContextMissing: true },
- );
- return {
- bookId,
- chapterNumber,
- intentPath: relativeToBookDir(bookDir, plan.runtimePath),
- goal: plan.intent.goal,
- conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`),
- contextPath: relativeToBookDir(bookDir, composed.contextPath),
- ruleStackPath: relativeToBookDir(bookDir, composed.ruleStackPath),
- tracePath: relativeToBookDir(bookDir, composed.tracePath),
- };
- }
- /** Audit the latest (or specified) chapter. Read-only, no lock needed. */
- async auditDraft(bookId: string, chapterNumber?: number): Promise<AuditResult & { readonly chapterNumber: number }> {
- const book = await this.state.loadBookConfig(bookId);
- const bookDir = this.state.bookDir(bookId);
- const targetChapter = chapterNumber ?? (await this.state.getNextChapterNumber(bookId)) - 1;
- if (targetChapter < 1) {
- throw new Error(`No chapters to audit for "${bookId}"`);
- }
- const content = await this.readChapterContent(bookDir, targetChapter);
- const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
- const { profile: gp } = await this.loadGenreProfile(book.genre);
- const language = book.language ?? gp.language;
- this.logStage(language, {
- zh: `审计第${targetChapter}章`,
- en: `auditing chapter ${targetChapter}`,
- });
- const evaluation = await this.evaluateMergedAudit({
- auditor,
- book,
- bookDir,
- chapterContent: content,
- chapterNumber: targetChapter,
- language,
- });
- const result = evaluation.auditResult;
- // Update index with audit result
- const index = await this.state.loadChapterIndex(bookId);
- const updated = index.map((ch) =>
- ch.number === targetChapter
- ? {
- ...ch,
- status: (result.passed ? "ready-for-review" : "audit-failed") as ChapterMeta["status"],
- updatedAt: new Date().toISOString(),
- auditIssues: result.issues.map((i) => `[${i.severity}] ${i.description}`),
- }
- : ch,
- );
- await this.state.saveChapterIndex(bookId, updated);
- const latestChapter = index.length > 0 ? Math.max(...index.map((chapter) => chapter.number)) : targetChapter;
- if (targetChapter === latestChapter) {
- await this.persistAuditDriftGuidance({
- bookDir,
- chapterNumber: targetChapter,
- issues: result.issues.filter((issue) => issue.severity === "critical" || issue.severity === "warning"),
- language,
- }).catch(() => undefined);
- }
- await this.emitWebhook(
- result.passed ? "audit-passed" : "audit-failed",
- bookId,
- targetChapter,
- { summary: result.summary, issueCount: result.issues.length },
- );
- return { ...result, chapterNumber: targetChapter };
- }
- /** Revise the latest (or specified) chapter based on audit issues. */
- async reviseDraft(bookId: string, chapterNumber?: number, mode: ReviseMode = DEFAULT_REVISE_MODE): Promise<ReviseResult> {
- const releaseLock = await this.state.acquireBookLock(bookId);
- try {
- const book = await this.state.loadBookConfig(bookId);
- const bookDir = this.state.bookDir(bookId);
- const targetChapter = chapterNumber ?? (await this.state.getNextChapterNumber(bookId)) - 1;
- if (targetChapter < 1) {
- throw new Error(`No chapters to revise for "${bookId}"`);
- }
- const stageLanguage = await this.resolveBookLanguage(book);
- // Read the current audit issues from index
- this.logStage(stageLanguage, {
- zh: `加载第${targetChapter}章修订上下文`,
- en: `loading revision context for chapter ${targetChapter}`,
- });
- const index = await this.state.loadChapterIndex(bookId);
- const chapterMeta = index.find((ch) => ch.number === targetChapter);
- if (!chapterMeta) {
- throw new Error(`Chapter ${targetChapter} not found in index`);
- }
- // Re-audit to get structured issues (index only stores strings)
- const content = await this.readChapterContent(bookDir, targetChapter);
- const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
- const { profile: gp } = await this.loadGenreProfile(book.genre);
- const language = book.language ?? gp.language;
- const countingMode = resolveLengthCountingMode(language);
- const reviseControlInput = (this.config.inputGovernanceMode ?? "v2") === "legacy"
- ? undefined
- : await this.createGovernedArtifacts(
- book,
- bookDir,
- targetChapter,
- undefined,
- { reuseExistingIntentWhenContextMissing: true },
- );
- const preRevision = await this.evaluateMergedAudit({
- auditor,
- book,
- bookDir,
- chapterContent: content,
- chapterNumber: targetChapter,
- language,
- auditOptions: reviseControlInput
- ? {
- chapterIntent: reviseControlInput.plan.intentMarkdown,
- contextPackage: reviseControlInput.composed.contextPackage,
- ruleStack: reviseControlInput.composed.ruleStack,
- }
- : undefined,
- });
- if (preRevision.blockingCount === 0 && preRevision.aiTellCount === 0) {
- return {
- chapterNumber: targetChapter,
- wordCount: countChapterLength(content, countingMode),
- fixedIssues: [],
- applied: false,
- status: "unchanged",
- skippedReason: "No warning, critical, or AI-tell issues to fix.",
- };
- }
- const chapterLengthTarget = chapterMeta.lengthTelemetry?.target ?? book.chapterWordCount;
- const lengthLanguage = chapterMeta.lengthTelemetry?.countingMode === "en_words"
- ? "en"
- : language;
- const lengthSpec = buildLengthSpec(
- chapterLengthTarget,
- lengthLanguage,
- );
- const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
- this.logStage(stageLanguage, {
- zh: `修订第${targetChapter}章`,
- en: `revising chapter ${targetChapter}`,
- });
- const reviseOutput = await reviser.reviseChapter(
- bookDir,
- content,
- targetChapter,
- preRevision.auditResult.issues,
- mode,
- book.genre,
- reviseControlInput
- ? {
- chapterIntent: reviseControlInput.plan.intentMarkdown,
- contextPackage: reviseControlInput.composed.contextPackage,
- ruleStack: reviseControlInput.composed.ruleStack,
- lengthSpec,
- }
- : { lengthSpec },
- );
- if (reviseOutput.revisedContent.length === 0) {
- throw new Error("Reviser returned empty content");
- }
- const normalizedRevision = await this.normalizeDraftLengthIfNeeded({
- bookId,
- chapterNumber: targetChapter,
- chapterContent: reviseOutput.revisedContent,
- lengthSpec,
- });
- const postRevision = await this.evaluateMergedAudit({
- auditor,
- book,
- bookDir,
- chapterContent: normalizedRevision.content,
- chapterNumber: targetChapter,
- language,
- auditOptions: reviseControlInput
- ? {
- temperature: 0,
- chapterIntent: reviseControlInput.plan.intentMarkdown,
- contextPackage: reviseControlInput.composed.contextPackage,
- ruleStack: reviseControlInput.composed.ruleStack,
- truthFileOverrides: {
- currentState: reviseOutput.updatedState !== "(状态卡未更新)" ? reviseOutput.updatedState : undefined,
- ledger: reviseOutput.updatedLedger !== "(账本未更新)" ? reviseOutput.updatedLedger : undefined,
- hooks: reviseOutput.updatedHooks !== "(伏笔池未更新)" ? reviseOutput.updatedHooks : undefined,
- },
- }
- : {
- temperature: 0,
- truthFileOverrides: {
- currentState: reviseOutput.updatedState !== "(状态卡未更新)" ? reviseOutput.updatedState : undefined,
- ledger: reviseOutput.updatedLedger !== "(账本未更新)" ? reviseOutput.updatedLedger : undefined,
- hooks: reviseOutput.updatedHooks !== "(伏笔池未更新)" ? reviseOutput.updatedHooks : undefined,
- },
- },
- });
- const effectivePostRevision = this.restoreActionableAuditIfLost(
- preRevision,
- postRevision,
- );
- const revisionBaseCount = countChapterLength(content, lengthSpec.countingMode);
- const lengthWarnings = this.buildLengthWarnings(
- targetChapter,
- normalizedRevision.wordCount,
- lengthSpec,
- );
- const lengthTelemetry = this.buildLengthTelemetry({
- lengthSpec,
- writerCount: revisionBaseCount,
- postWriterNormalizeCount: 0,
- postReviseCount: normalizedRevision.wordCount,
- finalCount: normalizedRevision.wordCount,
- normalizeApplied: normalizedRevision.applied,
- lengthWarning: lengthWarnings.length > 0,
- });
- const improvedBlocking = effectivePostRevision.blockingCount < preRevision.blockingCount;
- const improvedAITells = effectivePostRevision.aiTellCount < preRevision.aiTellCount;
- const blockingDidNotWorsen = effectivePostRevision.blockingCount <= preRevision.blockingCount;
- const criticalDidNotWorsen = effectivePostRevision.criticalCount <= preRevision.criticalCount;
- const aiDidNotWorsen = effectivePostRevision.aiTellCount <= preRevision.aiTellCount;
- const shouldApplyRevision = blockingDidNotWorsen
- && criticalDidNotWorsen
- && aiDidNotWorsen
- && (improvedBlocking || improvedAITells);
- if (!shouldApplyRevision) {
- return {
- chapterNumber: targetChapter,
- wordCount: revisionBaseCount,
- fixedIssues: [],
- applied: false,
- status: "unchanged",
- skippedReason: "Manual revision did not improve merged audit or AI-tell metrics; kept original chapter.",
- };
- }
- this.logLengthWarnings(lengthWarnings);
- // Save revised chapter file
- this.logStage(stageLanguage, {
- zh: `落盘第${targetChapter}章修订结果`,
- en: `persisting revision for chapter ${targetChapter}`,
- });
- const chaptersDir = join(bookDir, "chapters");
- const files = await readdir(chaptersDir);
- const paddedNum = String(targetChapter).padStart(4, "0");
- const existingFile = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
- if (!existingFile) {
- throw new Error(`Chapter ${targetChapter} file not found in ${chaptersDir} (expected filename starting with ${paddedNum})`);
- }
- const reviseLang = book.language ?? gp.language;
- const reviseHeading = reviseLang === "en"
- ? `# Chapter ${targetChapter}: ${chapterMeta.title}`
- : `# 第${targetChapter}章 ${chapterMeta.title}`;
- await writeFile(
- join(chaptersDir, existingFile),
- `${reviseHeading}\n\n${normalizedRevision.content}`,
- "utf-8",
- );
- // Update truth files
- const storyDir = join(bookDir, "story");
- if (reviseOutput.updatedState !== "(状态卡未更新)") {
- await writeFile(join(storyDir, "current_state.md"), reviseOutput.updatedState, "utf-8");
- }
- if (gp.numericalSystem && reviseOutput.updatedLedger && reviseOutput.updatedLedger !== "(账本未更新)") {
- await writeFile(join(storyDir, "particle_ledger.md"), reviseOutput.updatedLedger, "utf-8");
- }
- if (reviseOutput.updatedHooks !== "(伏笔池未更新)") {
- await writeFile(join(storyDir, "pending_hooks.md"), reviseOutput.updatedHooks, "utf-8");
- }
- await this.syncLegacyStructuredStateFromMarkdown(bookDir, targetChapter);
- // Update index
- const updatedIndex = index.map((ch) =>
- ch.number === targetChapter
- ? {
- ...ch,
- status: (effectivePostRevision.auditResult.passed ? "ready-for-review" : "audit-failed") as ChapterMeta["status"],
- wordCount: normalizedRevision.wordCount,
- updatedAt: new Date().toISOString(),
- auditIssues: effectivePostRevision.auditResult.issues.map((i) => `[${i.severity}] ${i.description}`),
- lengthWarnings,
- lengthTelemetry,
- }
- : ch,
- );
- await this.state.saveChapterIndex(bookId, updatedIndex);
- const latestChapter = index.length > 0 ? Math.max(...index.map((chapter) => chapter.number)) : targetChapter;
- if (targetChapter === latestChapter) {
- await this.persistAuditDriftGuidance({
- bookDir,
- chapterNumber: targetChapter,
- issues: effectivePostRevision.auditResult.issues.filter(
- (issue) => issue.severity === "critical" || issue.severity === "warning",
- ),
- language,
- }).catch(() => undefined);
- }
- // Re-snapshot
- this.logStage(stageLanguage, {
- zh: `更新第${targetChapter}章索引与快照`,
- en: `updating chapter index and snapshots for chapter ${targetChapter}`,
- });
- await this.state.snapshotState(bookId, targetChapter);
- await this.syncNarrativeMemoryIndex(bookId);
- await this.syncCurrentStateFactHistory(bookId, targetChapter);
- await this.emitWebhook("revision-complete", bookId, targetChapter, {
- wordCount: normalizedRevision.wordCount,
- fixedCount: reviseOutput.fixedIssues.length,
- });
- return {
- chapterNumber: targetChapter,
- wordCount: normalizedRevision.wordCount,
- fixedIssues: reviseOutput.fixedIssues,
- applied: true,
- status: effectivePostRevision.auditResult.passed ? "ready-for-review" : "audit-failed",
- lengthWarnings,
- lengthTelemetry,
- };
- } finally {
- await releaseLock();
- }
- }
- /** Read all truth files for a book. */
- async readTruthFiles(bookId: string): Promise<TruthFiles> {
- const bookDir = this.state.bookDir(bookId);
- const storyDir = join(bookDir, "story");
- const readSafe = async (path: string): Promise<string> => {
- try {
- return await readFile(path, "utf-8");
- } catch {
- return "(文件不存在)";
- }
- };
- const [currentState, particleLedger, pendingHooks, storyBible, volumeOutline, bookRules] =
- await Promise.all([
- readSafe(join(storyDir, "current_state.md")),
- readSafe(join(storyDir, "particle_ledger.md")),
- readSafe(join(storyDir, "pending_hooks.md")),
- readSafe(join(storyDir, "story_bible.md")),
- readSafe(join(storyDir, "volume_outline.md")),
- readSafe(join(storyDir, "book_rules.md")),
- ]);
- return { currentState, particleLedger, pendingHooks, storyBible, volumeOutline, bookRules };
- }
- /** Get book status overview. */
- async getBookStatus(bookId: string): Promise<BookStatusInfo> {
- const book = await this.state.loadBookConfig(bookId);
- const chapters = await this.state.loadChapterIndex(bookId);
- const nextChapter = await this.state.getNextChapterNumber(bookId);
- const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
- return {
- bookId,
- title: book.title,
- genre: book.genre,
- platform: book.platform,
- status: book.status,
- chaptersWritten: chapters.length,
- totalWords,
- nextChapter,
- chapters: [...chapters],
- };
- }
- // ---------------------------------------------------------------------------
- // Full pipeline (convenience — runs draft + audit + revise in one shot)
- // ---------------------------------------------------------------------------
- async writeNextChapter(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
- const releaseLock = await this.state.acquireBookLock(bookId);
- try {
- return await this._writeNextChapterLocked(bookId, wordCount, temperatureOverride);
- } finally {
- await releaseLock();
- }
- }
- async repairChapterState(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
- const releaseLock = await this.state.acquireBookLock(bookId);
- try {
- return await this._repairChapterStateLocked(bookId, chapterNumber);
- } finally {
- await releaseLock();
- }
- }
- private async _writeNextChapterLocked(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
- await this.state.ensureControlDocuments(bookId);
- const book = await this.state.loadBookConfig(bookId);
- const bookDir = this.state.bookDir(bookId);
- await this.assertNoPendingStateRepair(bookId);
- const chapterNumber = await this.state.getNextChapterNumber(bookId);
- const stageLanguage = await this.resolveBookLanguage(book);
- this.logStage(stageLanguage, { zh: "准备章节输入", en: "preparing chapter inputs" });
- const writeInput = await this.prepareWriteInput(
- book,
- bookDir,
- chapterNumber,
- this.config.externalContext,
- );
- const reducedControlInput = writeInput.chapterIntent && writeInput.contextPackage && writeInput.ruleStack
- ? {
- chapterIntent: writeInput.chapterIntent,
- contextPackage: writeInput.contextPackage,
- ruleStack: writeInput.ruleStack,
- }
- : undefined;
- const { profile: gp } = await this.loadGenreProfile(book.genre);
- const pipelineLang = book.language ?? gp.language;
- const lengthSpec = buildLengthSpec(
- wordCount ?? book.chapterWordCount,
- pipelineLang,
- );
- // 1. Write chapter
- const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
- this.logStage(stageLanguage, { zh: "撰写章节草稿", en: "writing chapter draft" });
- const output = await writer.writeChapter({
- book,
- bookDir,
- chapterNumber,
- ...writeInput,
- lengthSpec,
- ...(wordCount ? { wordCountOverride: wordCount } : {}),
- ...(temperatureOverride ? { temperatureOverride } : {}),
- });
- const writerCount = countChapterLength(output.content, lengthSpec.countingMode);
- // Token usage accumulator
- let totalUsage: TokenUsageSummary = output.tokenUsage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
- let postReviseCount = 0;
- let normalizeApplied = false;
- // 2a. Post-write error gate: if deterministic rules found errors, auto-fix before LLM audit
- let finalContent = output.content;
- let finalWordCount = output.wordCount;
- let revised = false;
- if (output.postWriteErrors.length > 0) {
- this.logWarn(pipelineLang, {
- zh: `检测到 ${output.postWriteErrors.length} 个后写错误,审计前触发 spot-fix 修补`,
- en: `${output.postWriteErrors.length} post-write errors detected, triggering spot-fix before audit`,
- });
- const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
- const spotFixIssues = output.postWriteErrors.map((v) => ({
- severity: "critical" as const,
- category: v.rule,
- description: v.description,
- suggestion: v.suggestion,
- }));
- const fixResult = await reviser.reviseChapter(
- bookDir,
- finalContent,
- chapterNumber,
- spotFixIssues,
- "spot-fix",
- book.genre,
- {
- ...reducedControlInput,
- lengthSpec,
- },
- );
- totalUsage = PipelineRunner.addUsage(totalUsage, fixResult.tokenUsage);
- if (fixResult.revisedContent.length > 0) {
- finalContent = fixResult.revisedContent;
- finalWordCount = fixResult.wordCount;
- revised = true;
- }
- }
- const normalizedBeforeAudit = await this.normalizeDraftLengthIfNeeded({
- bookId,
- chapterNumber,
- chapterContent: finalContent,
- lengthSpec,
- chapterIntent: writeInput.chapterIntent,
- });
- totalUsage = PipelineRunner.addUsage(totalUsage, normalizedBeforeAudit.tokenUsage);
- finalContent = normalizedBeforeAudit.content;
- finalWordCount = normalizedBeforeAudit.wordCount;
- normalizeApplied = normalizeApplied || normalizedBeforeAudit.applied;
- this.assertChapterContentNotEmpty(finalContent, chapterNumber, "draft generation");
- // 2b. LLM audit
- const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
- this.logStage(stageLanguage, { zh: "审计草稿", en: "auditing draft" });
- const llmAudit = await auditor.auditChapter(
- bookDir,
- finalContent,
- chapterNumber,
- book.genre,
- reducedControlInput,
- );
- totalUsage = PipelineRunner.addUsage(totalUsage, llmAudit.tokenUsage);
- const aiTellsResult = analyzeAITells(finalContent);
- const sensitiveWriteResult = analyzeSensitiveWords(finalContent);
- const hasBlockedWriteWords = sensitiveWriteResult.found.some((f) => f.severity === "block");
- let auditResult: AuditResult = {
- passed: hasBlockedWriteWords ? false : llmAudit.passed,
- issues: [...llmAudit.issues, ...aiTellsResult.issues, ...sensitiveWriteResult.issues],
- summary: llmAudit.summary,
- };
- // 3. If audit fails, try auto-revise once
- if (!auditResult.passed) {
- const criticalIssues = auditResult.issues.filter(
- (i) => i.severity === "critical",
- );
- if (criticalIssues.length > 0) {
- const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
- this.logStage(stageLanguage, { zh: "自动修复关键问题", en: "auto-revising critical issues" });
- const reviseOutput = await reviser.reviseChapter(
- bookDir,
- finalContent,
- chapterNumber,
- auditResult.issues,
- "spot-fix",
- book.genre,
- {
- ...reducedControlInput,
- lengthSpec,
- },
- );
- totalUsage = PipelineRunner.addUsage(totalUsage, reviseOutput.tokenUsage);
- if (reviseOutput.revisedContent.length > 0) {
- const normalizedRevision = await this.normalizeDraftLengthIfNeeded({
- bookId,
- chapterNumber,
- chapterContent: reviseOutput.revisedContent,
- lengthSpec,
- chapterIntent: writeInput.chapterIntent,
- });
- totalUsage = PipelineRunner.addUsage(totalUsage, normalizedRevision.tokenUsage);
- postReviseCount = normalizedRevision.wordCount;
- normalizeApplied = normalizeApplied || normalizedRevision.applied;
- // Guard: reject revision if AI markers increased
- const preMarkers = analyzeAITells(finalContent);
- const postMarkers = analyzeAITells(normalizedRevision.content);
- const preCount = preMarkers.issues.length;
- const postCount = postMarkers.issues.length;
- if (postCount > preCount) {
- // Revision made text MORE AI-like — discard it, keep original
- } else {
- finalContent = normalizedRevision.content;
- finalWordCount = normalizedRevision.wordCount;
- revised = true;
- this.assertChapterContentNotEmpty(finalContent, chapterNumber, "revision");
- }
- // Re-audit the (possibly revised) content
- const reAudit = await auditor.auditChapter(
- bookDir,
- finalContent,
- chapterNumber,
- book.genre,
- { ...reducedControlInput, temperature: 0 },
- );
- totalUsage = PipelineRunner.addUsage(totalUsage, reAudit.tokenUsage);
- const reAITells = analyzeAITells(finalContent);
- const reSensitive = analyzeSensitiveWords(finalContent);
- const reHasBlocked = reSensitive.found.some((f) => f.severity === "block");
- auditResult = this.restoreLostAuditIssues(auditResult, {
- passed: reHasBlocked ? false : reAudit.passed,
- issues: [...reAudit.issues, ...reAITells.issues, ...reSensitive.issues],
- summary: reAudit.summary,
- });
- }
- }
- }
- // 4. Save the final chapter and truth files from a single persistence source
- this.logStage(stageLanguage, { zh: "落盘最终章节", en: "persisting final chapter" });
- this.logStage(stageLanguage, { zh: "生成最终真相文件", en: "rebuilding final truth files" });
- const chapterIndexBeforePersist = await this.state.loadChapterIndex(bookId);
- const { resolveDuplicateTitle } = await import("../agents/post-write-validator.js");
- const initialTitleResolution = resolveDuplicateTitle(
- output.title,
- chapterIndexBeforePersist.map((chapter) => chapter.title),
- pipelineLang,
- { content: finalContent },
- );
- let persistenceOutput = await this.buildPersistenceOutput(
- bookId,
- book,
- bookDir,
- chapterNumber,
- initialTitleResolution.title === output.title
- ? output
- : { ...output, title: initialTitleResolution.title },
- finalContent,
- lengthSpec.countingMode,
- reducedControlInput,
- );
- const finalTitleResolution = resolveDuplicateTitle(
- persistenceOutput.title,
- chapterIndexBeforePersist.map((chapter) => chapter.title),
- pipelineLang,
- { content: finalContent },
- );
- if (finalTitleResolution.title !== persistenceOutput.title) {
- persistenceOutput = {
- ...persistenceOutput,
- title: finalTitleResolution.title,
- };
- }
- if (persistenceOutput.title !== output.title) {
- const description = pipelineLang === "en"
- ? `Chapter title "${output.title}" was auto-adjusted to "${persistenceOutput.title}".`
- : `章节标题"${output.title}"已自动调整为"${persistenceOutput.title}"。`;
- this.config.logger?.warn(`[title] ${description}`);
- auditResult = {
- ...auditResult,
- issues: [...auditResult.issues, {
- severity: "warning",
- category: "title-dedup",
- description,
- suggestion: pipelineLang === "en"
- ? "If the auto-renamed title is weak, revise the chapter title manually."
- : "如果自动改名不理想,可以在后续手动修订章节标题。",
- }],
- };
- }
- const longSpanFatigue = await analyzeLongSpanFatigue({
- bookDir,
- chapterNumber,
- chapterContent: finalContent,
- chapterSummary: persistenceOutput.chapterSummary,
- language: pipelineLang,
- });
- auditResult = {
- ...auditResult,
- issues: [
- ...auditResult.issues,
- ...longSpanFatigue.issues,
- ...(persistenceOutput.hookHealthIssues ?? []),
- ],
- };
- finalWordCount = persistenceOutput.wordCount;
- const lengthWarnings = this.buildLengthWarnings(
- chapterNumber,
- finalWordCount,
- lengthSpec,
- );
- const lengthTelemetry = this.buildLengthTelemetry({
- lengthSpec,
- writerCount,
- postWriterNormalizeCount: normalizedBeforeAudit.wordCount,
- postReviseCount,
- finalCount: finalWordCount,
- normalizeApplied,
- lengthWarning: lengthWarnings.length > 0,
- });
- this.logLengthWarnings(lengthWarnings);
- // 4.1 Validate settler output before writing
- this.logStage(stageLanguage, { zh: "校验真相文件变更", en: "validating truth file updates" });
- const storyDir = join(bookDir, "story");
- const [oldState, oldHooks, oldLedger] = await Promise.all([
- readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
- readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
- readFile(join(storyDir, "particle_ledger.md"), "utf-8").catch(() => ""),
- ]);
- const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
- let validation: ValidationResult;
- let chapterStatus: ChapterPipelineResult["status"] | null = null;
- let degradedIssues: ReadonlyArray<AuditIssue> = [];
- try {
- validation = await validator.validate(
- finalContent, chapterNumber,
- oldState, persistenceOutput.updatedState,
- oldHooks, persistenceOutput.updatedHooks,
- pipelineLang,
- );
- } catch (error) {
- throw new Error(`State validation failed for chapter ${chapterNumber}: ${String(error)}`);
- }
- if (validation.warnings.length > 0) {
- this.logWarn(pipelineLang, {
- zh: `状态校验:第${chapterNumber}章发现 ${validation.warnings.length} 条警告`,
- en: `State validation: ${validation.warnings.length} warning(s) for chapter ${chapterNumber}`,
- });
- for (const w of validation.warnings) {
- this.config.logger?.warn(` [${w.category}] ${w.description}`);
- }
- }
- if (!validation.passed) {
- const recovery = await retrySettlementAfterValidationFailure({
- writer,
- validator,
- book,
- bookDir,
- chapterNumber,
- title: persistenceOutput.title,
- content: finalContent,
- reducedControlInput,
- oldState,
- oldHooks,
- originalValidation: validation,
- language: pipelineLang,
- logWarn: (message) => this.logWarn(pipelineLang, message),
- logger: this.config.logger,
- });
- if (recovery.kind === "recovered") {
- persistenceOutput = recovery.output;
- validation = recovery.validation;
- } else {
- chapterStatus = "state-degraded";
- degradedIssues = recovery.issues;
- persistenceOutput = buildStateDegradedPersistenceOutput({
- output: persistenceOutput,
- oldState,
- oldHooks,
- oldLedger,
- });
- auditResult = {
- ...auditResult,
- issues: [...auditResult.issues, ...recovery.issues],
- };
- }
- }
- // 4.2 Final paragraph shape check on persisted content (post-normalize, post-revise)
- {
- const {
- detectParagraphLengthDrift,
- detectParagraphShapeWarnings,
- } = await import("../agents/post-write-validator.js");
- const chapDir = join(bookDir, "chapters");
- const recentFiles = (await readdir(chapDir).catch(() => [] as string[]))
- .filter((f) => f.endsWith(".md") && /^\d{4}/.test(f))
- .sort()
- .slice(-5);
- const recentContent = (await Promise.all(
- recentFiles.map((f) => readFile(join(chapDir, f), "utf-8").catch(() => "")),
- )).join("\n\n");
- const paragraphIssues = [
- ...detectParagraphShapeWarnings(finalContent, pipelineLang),
- ...detectParagraphLengthDrift(finalContent, recentContent, pipelineLang),
- ];
- if (paragraphIssues.length > 0) {
- for (const issue of paragraphIssues) {
- this.config.logger?.warn(`[paragraph] ${issue.description}`);
- }
- auditResult = {
- ...auditResult,
- issues: [...auditResult.issues, ...paragraphIssues.map((v) => ({
- severity: v.severity as "warning",
- category: "paragraph-shape",
- description: v.description,
- suggestion: v.suggestion,
- }))],
- };
- }
- }
- await writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang);
- if (chapterStatus !== "state-degraded") {
- await writer.saveNewTruthFiles(bookDir, persistenceOutput, pipelineLang);
- await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, persistenceOutput);
- this.logStage(stageLanguage, { zh: "同步记忆索引", en: "syncing memory indexes" });
- await this.syncNarrativeMemoryIndex(bookId);
- }
- // 5. Update chapter index
- const existingIndex = await this.state.loadChapterIndex(bookId);
- const now = new Date().toISOString();
- const newEntry: ChapterMeta = {
- number: chapterNumber,
- title: persistenceOutput.title,
- status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"),
- wordCount: finalWordCount,
- createdAt: now,
- updatedAt: now,
- auditIssues: auditResult.issues.map(
- (i) => `[${i.severity}] ${i.description}`,
- ),
- lengthWarnings,
- reviewNote: chapterStatus === "state-degraded"
- ? buildStateDegradedReviewNote(
- auditResult.passed ? "ready-for-review" : "audit-failed",
- degradedIssues,
- )
- : undefined,
- lengthTelemetry,
- tokenUsage: totalUsage,
- };
- await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
- await this.markBookActiveIfNeeded(bookId);
- // 5.5 Persist audit drift guidance without polluting current_state.md
- const driftIssues = auditResult.issues.filter(
- (i) => i.severity === "critical" || i.severity === "warning",
- );
- await this.persistAuditDriftGuidance({
- bookDir,
- chapterNumber,
- issues: chapterStatus === "state-degraded" ? [] : driftIssues,
- language: stageLanguage,
- }).catch(() => undefined);
- // 5.6 Snapshot state for rollback support
- if (chapterStatus !== "state-degraded") {
- this.logStage(stageLanguage, { zh: "更新章节索引与快照", en: "updating chapter index and snapshots" });
- await this.state.snapshotState(bookId, chapterNumber);
- await this.syncCurrentStateFactHistory(bookId, chapterNumber);
- }
- // 6. Send notification
- if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
- const statusEmoji = chapterStatus === "state-degraded"
- ? "🧯"
- : auditResult.passed ? "✅" : "⚠️";
- const chapterLength = formatLengthCount(finalWordCount, lengthSpec.countingMode);
- await dispatchNotification(this.config.notifyChannels, {
- title: `${statusEmoji} ${book.title} 第${chapterNumber}章`,
- body: [
- `**${persistenceOutput.title}** | ${chapterLength}`,
- revised ? "📝 已自动修正" : "",
- chapterStatus === "state-degraded"
- ? "状态结算: 已降级保存,需先修复 state 再继续"
- : `审稿: ${auditResult.passed ? "通过" : "需人工审核"}`,
- ...auditResult.issues
- .filter((i) => i.severity !== "info")
- .map((i) => `- [${i.severity}] ${i.description}`),
- ]
- .filter(Boolean)
- .join("\n"),
- });
- }
- await this.emitWebhook("pipeline-complete", bookId, chapterNumber, {
- title: persistenceOutput.title,
- wordCount: finalWordCount,
- passed: auditResult.passed,
- revised,
- status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"),
- });
- return {
- chapterNumber,
- title: persistenceOutput.title,
- wordCount: finalWordCount,
- auditResult,
- revised,
- status: chapterStatus ?? (auditResult.passed ? "ready-for-review" : "audit-failed"),
- lengthWarnings,
- lengthTelemetry,
- tokenUsage: totalUsage,
- };
- }
- private async _repairChapterStateLocked(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
- const book = await this.state.loadBookConfig(bookId);
- const bookDir = this.state.bookDir(bookId);
- const stageLanguage = await this.resolveBookLanguage(book);
- const index = [...(await this.state.loadChapterIndex(bookId))];
- if (index.length === 0) {
- throw new Error(`Book "${bookId}" has no persisted chapters to repair.`);
- }
- const targetChapter = chapterNumber ?? index[index.length - 1]!.number;
- const targetIndex = index.findIndex((chapter) => chapter.number === targetChapter);
- if (targetIndex < 0) {
- throw new Error(`Chapter ${targetChapter} not found in "${bookId}".`);
- }
- const targetMeta = index[targetIndex]!;
- const latestChapter = Math.max(...index.map((chapter) => chapter.number));
- if (targetMeta.status !== "state-degraded") {
- throw new Error(`Chapter ${targetChapter} is not state-degraded.`);
- }
- if (targetChapter !== latestChapter) {
- throw new Error(`Only the latest state-degraded chapter can be repaired safely (latest is ${latestChapter}).`);
- }
- this.logStage(stageLanguage, { zh: "修复章节状态结算", en: "repairing chapter state settlement" });
- const { profile: gp } = await this.loadGenreProfile(book.genre);
- const pipelineLang = book.language ?? gp.language;
- const content = await this.readChapterContent(bookDir, targetChapter);
- const storyDir = join(bookDir, "story");
- const [oldState, oldHooks] = await Promise.all([
- readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
- readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
- ]);
- const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
- let repairedOutput = await writer.settleChapterState({
- book,
- bookDir,
- chapterNumber: targetChapter,
- title: targetMeta.title,
- content,
- });
- const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
- let validation = await validator.validate(
- content,
- targetChapter,
- oldState,
- repairedOutput.updatedState,
- oldHooks,
- repairedOutput.updatedHooks,
- pipelineLang,
- );
- if (!validation.passed) {
- const recovery = await retrySettlementAfterValidationFailure({
- writer,
- validator,
- book,
- bookDir,
- chapterNumber: targetChapter,
- title: targetMeta.title,
- content,
- oldState,
- oldHooks,
- originalValidation: validation,
- language: pipelineLang,
- logWarn: (message) => this.logWarn(pipelineLang, message),
- logger: this.config.logger,
- });
- if (recovery.kind !== "recovered") {
- throw new Error(
- recovery.issues[0]?.description
- ?? `State repair still failed for chapter ${targetChapter}.`,
- );
- }
- repairedOutput = recovery.output;
- validation = recovery.validation;
- }
- if (!validation.passed) {
- throw new Error(`State repair still failed for chapter ${targetChapter}.`);
- }
- await writer.saveChapter(bookDir, repairedOutput, gp.numericalSystem, pipelineLang);
- await writer.saveNewTruthFiles(bookDir, repairedOutput, pipelineLang);
- await this.syncLegacyStructuredStateFromMarkdown(bookDir, targetChapter, repairedOutput);
- await this.syncNarrativeMemoryIndex(bookId);
- await this.state.snapshotState(bookId, targetChapter);
- await this.syncCurrentStateFactHistory(bookId, targetChapter);
- const baseStatus = resolveStateDegradedBaseStatus(targetMeta);
- const degradedMetadata = parseStateDegradedReviewNote(targetMeta.reviewNote);
- const injectedIssues = new Set(degradedMetadata?.injectedIssues ?? []);
- index[targetIndex] = {
- ...targetMeta,
- status: baseStatus,
- updatedAt: new Date().toISOString(),
- auditIssues: targetMeta.auditIssues.filter((issue) => !injectedIssues.has(issue)),
- reviewNote: undefined,
- };
- await this.state.saveChapterIndex(bookId, index);
- const repairedPassesAudit = baseStatus !== "audit-failed";
- return {
- chapterNumber: targetChapter,
- title: targetMeta.title,
- wordCount: targetMeta.wordCount,
- auditResult: {
- passed: repairedPassesAudit,
- issues: [],
- summary: repairedPassesAudit ? "state repaired" : "state repaired but chapter still needs review",
- },
- revised: false,
- status: baseStatus,
- lengthWarnings: targetMeta.lengthWarnings,
- lengthTelemetry: targetMeta.lengthTelemetry,
- tokenUsage: targetMeta.tokenUsage,
- };
- }
- // ---------------------------------------------------------------------------
- // Import operations (style imitation + canon for spinoff)
- // ---------------------------------------------------------------------------
- /**
- * Generate a qualitative style guide from reference text via LLM.
- * Also saves the statistical style_profile.json.
- */
- async generateStyleGuide(bookId: string, referenceText: string, sourceName?: string): Promise<string> {
- if (referenceText.length < 500) {
- throw new Error(`Reference text too short (${referenceText.length} chars, minimum 500). Provide at least 2000 chars for reliable style extraction.`);
- }
- const { analyzeStyle } = await import("../agents/style-analyzer.js");
- const bookDir = this.state.bookDir(bookId);
- const storyDir = join(bookDir, "story");
- await mkdir(storyDir, { recursive: true });
- // Statistical fingerprint
- const profile = analyzeStyle(referenceText, sourceName);
- await writeFile(join(storyDir, "style_profile.json"), JSON.stringify(profile, null, 2), "utf-8");
- // LLM qualitative extraction
- const response = await chatCompletion(this.config.client, this.config.model, [
- {
- role: "system",
- content: `你是一位文学风格分析专家。分析参考文本的写作风格,提取可供模仿的定性特征。
- 输出格式(Markdown):
- ## 叙事声音与语气
- (冷峻/热烈/讽刺/温情/...,附1-2个原文例句)
- ## 对话风格
- (角色说话的共性特征:句子长短、口头禅倾向、方言痕迹、对话节奏)
- ## 场景描写特征
- (五感偏好、意象选择、描写密度、环境与情绪的关联方式)
- ## 转折与衔接手法
- (场景如何切换、时间跳跃的处理方式、段落间的过渡特征)
- ## 节奏特征
- (长短句分布、段落长度偏好、高潮/舒缓的交替方式)
- ## 词汇偏好
- (高频特色用词、比喻/修辞倾向、口语化程度)
- ## 情绪表达方式
- (直白抒情 vs 动作外化、内心独白的频率和风格)
- ## 独特习惯
- (任何值得模仿的个人写作习惯)
- 分析必须基于原文实际特征,不要泛泛而谈。每个部分用1-2个原文例句佐证。`,
- },
- {
- role: "user",
- content: `分析以下参考文本的写作风格:\n\n${referenceText.slice(0, 20000)}`,
- },
- ], { temperature: 0.3, maxTokens: 4096 });
- await writeFile(join(storyDir, "style_guide.md"), response.content, "utf-8");
- return response.content;
- }
- /**
- * Import canon from parent book for spinoff writing.
- * Reads parent's truth files, uses LLM to generate parent_canon.md in target book.
- */
- async importCanon(targetBookId: string, parentBookId: string): Promise<string> {
- // Validate both books exist
- const bookIds = await this.state.listBooks();
- if (!bookIds.includes(parentBookId)) {
- throw new Error(`Parent book "${parentBookId}" not found. Available: ${bookIds.join(", ") || "(none)"}`);
- }
- if (!bookIds.includes(targetBookId)) {
- throw new Error(`Target book "${targetBookId}" not found. Available: ${bookIds.join(", ") || "(none)"}`);
- }
- const parentDir = this.state.bookDir(parentBookId);
- const targetDir = this.state.bookDir(targetBookId);
- const storyDir = join(targetDir, "story");
- await mkdir(storyDir, { recursive: true });
- const readSafe = async (path: string): Promise<string> => {
- try { return await readFile(path, "utf-8"); } catch { return "(无)"; }
- };
- const parentBook = await this.state.loadBookConfig(parentBookId);
- const [storyBible, currentState, ledger, hooks, summaries, subplots, emotions, matrix] =
- await Promise.all([
- readSafe(join(parentDir, "story/story_bible.md")),
- readSafe(join(parentDir, "story/current_state.md")),
- readSafe(join(parentDir, "story/particle_ledger.md")),
- readSafe(join(parentDir, "story/pending_hooks.md")),
- readSafe(join(parentDir, "story/chapter_summaries.md")),
- readSafe(join(parentDir, "story/subplot_board.md")),
- readSafe(join(parentDir, "story/emotional_arcs.md")),
- readSafe(join(parentDir, "story/character_matrix.md")),
- ]);
- const response = await chatCompletion(this.config.client, this.config.model, [
- {
- role: "system",
- content: `你是一位网络小说架构师。基于正传的全部设定和状态文件,生成一份完整的"正传正典参照"文档,供番外写作和审计使用。
- 输出格式(Markdown):
- # 正传正典(《{正传书名}》)
- ## 世界规则(完整,来自正传设定)
- (力量体系、地理设定、阵营关系、核心规则——完整复制,不压缩)
- ## 正典约束(不可违反的事实)
- | 约束ID | 类型 | 约束内容 | 严重性 |
- |---|---|---|---|
- | C01 | 人物存亡 | ... | critical |
- (列出所有硬性约束:谁活着、谁死了、什么事件已经发生、什么规则不可违反)
- ## 角色快照
- | 角色 | 当前状态 | 性格底色 | 对话特征 | 已知信息 | 未知信息 |
- |---|---|---|---|---|---|
- (从状态卡和角色矩阵中提取每个重要角色的完整快照)
- ## 角色双态处理原则
- - 未来会变强的角色:写潜力暗示
- - 未来会黑化的角色:写微小裂痕
- - 未来会死的角色:写导致死亡的性格底色
- ## 关键事件时间线
- | 章节 | 事件 | 涉及角色 | 对番外的约束 |
- |---|---|---|---|
- (从章节摘要中提取关键事件)
- ## 伏笔状态
- | Hook ID | 类型 | 状态 | 内容 | 预期回收 |
- |---|---|---|---|---|
- ## 资源账本快照
- (当前资源状态)
- ---
- meta:
- parentBookId: "{parentBookId}"
- parentTitle: "{正传书名}"
- generatedAt: "{ISO timestamp}"
- 要求:
- 1. 世界规则完整复制,不压缩——准确性优先
- 2. 正典约束必须穷尽,遗漏会导致番外与正传矛盾
- 3. 角色快照必须包含信息边界(已知/未知),防止番外中角色引用不该知道的信息`,
- },
- {
- role: "user",
- content: `正传书名:${parentBook.title}
- 正传ID:${parentBookId}
- ## 正传世界设定
- ${storyBible}
- ## 正传当前状态卡
- ${currentState}
- ## 正传资源账本
- ${ledger}
- ## 正传伏笔池
- ${hooks}
- ## 正传章节摘要
- ${summaries}
- ## 正传支线进度
- ${subplots}
- ## 正传情感弧线
- ${emotions}
- ## 正传角色矩阵
- ${matrix}`,
- },
- ], { temperature: 0.3, maxTokens: 16384 });
- // Append deterministic meta block (LLM may hallucinate timestamps)
- const metaBlock = [
- "",
- "---",
- "meta:",
- ` parentBookId: "${parentBookId}"`,
- ` parentTitle: "${parentBook.title}"`,
- ` generatedAt: "${new Date().toISOString()}"`,
- ].join("\n");
- const canon = response.content + metaBlock;
- await writeFile(join(storyDir, "parent_canon.md"), canon, "utf-8");
- // Also generate style guide from parent's chapter text if available
- const parentChaptersDir = join(parentDir, "chapters");
- const parentChapterText = await this.readParentChapterSample(parentChaptersDir);
- if (parentChapterText.length >= 500) {
- await this.tryGenerateStyleGuide(targetBookId, parentChapterText, parentBook.title);
- }
- return canon;
- }
- private async readParentChapterSample(chaptersDir: string): Promise<string> {
- try {
- const entries = await readdir(chaptersDir);
- const mdFiles = entries
- .filter((file) => file.endsWith(".md"))
- .sort()
- .slice(0, 5);
- const chunks: string[] = [];
- let totalLength = 0;
- for (const file of mdFiles) {
- if (totalLength >= 20000) break;
- const content = await readFile(join(chaptersDir, file), "utf-8");
- chunks.push(content);
- totalLength += content.length;
- }
- return chunks.join("\n\n---\n\n");
- } catch {
- return "";
- }
- }
- // ---------------------------------------------------------------------------
- // Chapter import (for continuation writing from existing chapters)
- // ---------------------------------------------------------------------------
- /**
- * Import existing chapters into a book. Reverse-engineers all truth files
- * via sequential replay so the Writer and Auditor can continue naturally.
- *
- * Step 1: Generate foundation (story_bible, volume_outline, book_rules) from all chapters.
- * Step 2: Sequentially replay each chapter through ChapterAnalyzer to build truth files.
- */
- async importChapters(input: ImportChaptersInput): Promise<ImportChaptersResult> {
- const releaseLock = await this.state.acquireBookLock(input.bookId);
- try {
- const book = await this.state.loadBookConfig(input.bookId);
- const bookDir = this.state.bookDir(input.bookId);
- const { profile: gp } = await this.loadGenreProfile(book.genre);
- const resolvedLanguage = book.language ?? gp.language;
- const startFrom = input.resumeFrom ?? 1;
- const log = this.config.logger?.child("import");
- // Step 1: Generate foundation on first run (not on resume)
- if (startFrom === 1) {
- log?.info(this.localize(resolvedLanguage, {
- zh: `步骤 1:从 ${input.chapters.length} 章生成基础设定...`,
- en: `Step 1: Generating foundation from ${input.chapters.length} chapters...`,
- }));
- const allText = input.chapters.map((c, i) =>
- resolvedLanguage === "en"
- ? `Chapter ${i + 1}: ${c.title}\n\n${c.content}`
- : `第${i + 1}章 ${c.title}\n\n${c.content}`,
- ).join("\n\n---\n\n");
- const architect = new ArchitectAgent(this.agentCtxFor("architect", input.bookId));
- const foundation = await architect.generateFoundationFromImport(book, allText);
- await architect.writeFoundationFiles(
- bookDir,
- foundation,
- gp.numericalSystem,
- resolvedLanguage,
- );
- await this.resetImportReplayTruthFiles(bookDir, resolvedLanguage);
- await this.state.saveChapterIndex(input.bookId, []);
- await this.state.snapshotState(input.bookId, 0);
- // Generate style guide from imported chapters
- if (allText.length >= 500) {
- log?.info(this.localize(resolvedLanguage, {
- zh: "提取原文风格指纹...",
- en: "Extracting source style fingerprint...",
- }));
- await this.tryGenerateStyleGuide(input.bookId, allText, book.title, resolvedLanguage);
- }
- log?.info(this.localize(resolvedLanguage, {
- zh: "基础设定已生成。",
- en: "Foundation generated.",
- }));
- }
- // Step 2: Sequential replay
- log?.info(this.localize(resolvedLanguage, {
- zh: `步骤 2:从第 ${startFrom} 章开始顺序回放...`,
- en: `Step 2: Sequential replay from chapter ${startFrom}...`,
- }));
- const analyzer = new ChapterAnalyzerAgent(this.agentCtxFor("chapter-analyzer", input.bookId));
- const writer = new WriterAgent(this.agentCtxFor("writer", input.bookId));
- const countingMode = resolveLengthCountingMode(book.language ?? gp.language);
- let totalWords = 0;
- let importedCount = 0;
- for (let i = startFrom - 1; i < input.chapters.length; i++) {
- const ch = input.chapters[i]!;
- const chapterNumber = i + 1;
- const governedInput = await this.prepareWriteInput(book, bookDir, chapterNumber);
- log?.info(this.localize(resolvedLanguage, {
- zh: `分析章节 ${chapterNumber}/${input.chapters.length}:${ch.title}...`,
- en: `Analyzing chapter ${chapterNumber}/${input.chapters.length}: ${ch.title}...`,
- }));
- // Analyze chapter to get truth file updates
- const output = await analyzer.analyzeChapter({
- book,
- bookDir,
- chapterNumber,
- chapterContent: ch.content,
- chapterTitle: ch.title,
- chapterIntent: governedInput.chapterIntent,
- contextPackage: governedInput.contextPackage,
- ruleStack: governedInput.ruleStack,
- });
- // Save chapter file + core truth files (state, ledger, hooks)
- await writer.saveChapter(bookDir, {
- ...output,
- postWriteErrors: [],
- postWriteWarnings: [],
- }, gp.numericalSystem, resolvedLanguage);
- // Save extended truth files (summaries, subplots, emotional arcs, character matrix)
- await writer.saveNewTruthFiles(bookDir, {
- ...output,
- postWriteErrors: [],
- postWriteWarnings: [],
- }, resolvedLanguage);
- await this.syncLegacyStructuredStateFromMarkdown(bookDir, chapterNumber, output);
- await this.syncNarrativeMemoryIndex(input.bookId);
- // Update chapter index
- const existingIndex = await this.state.loadChapterIndex(input.bookId);
- const now = new Date().toISOString();
- const chapterWordCount = countChapterLength(ch.content, countingMode);
- const newEntry: ChapterMeta = {
- number: chapterNumber,
- title: output.title,
- status: "imported",
- wordCount: chapterWordCount,
- createdAt: now,
- updatedAt: now,
- auditIssues: [],
- lengthWarnings: [],
- };
- // Replace if exists (resume case), otherwise append
- const existingIdx = existingIndex.findIndex((e) => e.number === chapterNumber);
- const updatedIndex = existingIdx >= 0
- ? existingIndex.map((e, idx) => idx === existingIdx ? newEntry : e)
- : [...existingIndex, newEntry];
- await this.state.saveChapterIndex(input.bookId, updatedIndex);
- // Snapshot state after each chapter for rollback + resume support
- await this.state.snapshotState(input.bookId, chapterNumber);
- importedCount++;
- totalWords += chapterWordCount;
- }
- if (input.chapters.length > 0) {
- await this.markBookActiveIfNeeded(input.bookId);
- await this.syncCurrentStateFactHistory(input.bookId, input.chapters.length);
- }
- const nextChapter = input.chapters.length + 1;
- log?.info(this.localize(resolvedLanguage, {
- zh: `完成。已导入 ${importedCount} 章,共 ${formatLengthCount(totalWords, countingMode)}。下一章:${nextChapter}`,
- en: `Done. ${importedCount} chapters imported, ${formatLengthCount(totalWords, countingMode)}. Next chapter: ${nextChapter}`,
- }));
- return {
- bookId: input.bookId,
- importedCount,
- totalWords,
- nextChapter,
- };
- } finally {
- await releaseLock();
- }
- }
- private static addUsage(
- a: TokenUsageSummary,
- b?: { readonly promptTokens: number; readonly completionTokens: number; readonly totalTokens: number },
- ): TokenUsageSummary {
- if (!b) return a;
- return {
- promptTokens: a.promptTokens + b.promptTokens,
- completionTokens: a.completionTokens + b.completionTokens,
- totalTokens: a.totalTokens + b.totalTokens,
- };
- }
- private async buildPersistenceOutput(
- bookId: string,
- book: BookConfig,
- bookDir: string,
- chapterNumber: number,
- output: WriteChapterOutput,
- finalContent: string,
- countingMode: Parameters<typeof countChapterLength>[1],
- reducedControlInput?: {
- chapterIntent: string;
- contextPackage: ContextPackage;
- ruleStack: RuleStack;
- },
- ): Promise<WriteChapterOutput> {
- if (finalContent === output.content) {
- return output;
- }
- const analyzer = new ChapterAnalyzerAgent(this.agentCtxFor("chapter-analyzer", bookId));
- const analyzed = await analyzer.analyzeChapter({
- book,
- bookDir,
- chapterNumber,
- chapterContent: finalContent,
- chapterTitle: output.title,
- chapterIntent: reducedControlInput?.chapterIntent,
- contextPackage: reducedControlInput?.contextPackage,
- ruleStack: reducedControlInput?.ruleStack,
- });
- return {
- ...analyzed,
- content: finalContent,
- wordCount: countChapterLength(finalContent, countingMode),
- postWriteErrors: [],
- postWriteWarnings: [],
- hookHealthIssues: output.hookHealthIssues,
- tokenUsage: output.tokenUsage,
- };
- }
- private async assertNoPendingStateRepair(bookId: string): Promise<void> {
- const existingIndex = await this.state.loadChapterIndex(bookId);
- const latestChapter = [...existingIndex].sort((left, right) => right.number - left.number)[0];
- if (latestChapter?.status !== "state-degraded") {
- return;
- }
- throw new Error(
- `Latest chapter ${latestChapter.number} is state-degraded. Repair state or rewrite that chapter before continuing.`,
- );
- }
- // ---------------------------------------------------------------------------
- // Helpers
- // ---------------------------------------------------------------------------
- private async prepareWriteInput(
- book: BookConfig,
- bookDir: string,
- chapterNumber: number,
- externalContext?: string,
- ): Promise<Pick<WriteChapterInput, "externalContext" | "chapterIntent" | "contextPackage" | "ruleStack" | "trace">> {
- if ((this.config.inputGovernanceMode ?? "v2") === "legacy") {
- return { externalContext };
- }
- const { plan, composed } = await this.createGovernedArtifacts(
- book,
- bookDir,
- chapterNumber,
- externalContext,
- { reuseExistingIntentWhenContextMissing: true },
- );
- return {
- chapterIntent: plan.intentMarkdown,
- contextPackage: composed.contextPackage,
- ruleStack: composed.ruleStack,
- trace: composed.trace,
- };
- }
- private async resetImportReplayTruthFiles(
- bookDir: string,
- language: LengthLanguage,
- ): Promise<void> {
- const storyDir = join(bookDir, "story");
- await Promise.all([
- writeFile(
- join(storyDir, "current_state.md"),
- this.buildImportReplayStateSeed(language),
- "utf-8",
- ),
- writeFile(
- join(storyDir, "pending_hooks.md"),
- this.buildImportReplayHooksSeed(language),
- "utf-8",
- ),
- rm(join(storyDir, "chapter_summaries.md"), { force: true }),
- rm(join(storyDir, "subplot_board.md"), { force: true }),
- rm(join(storyDir, "emotional_arcs.md"), { force: true }),
- rm(join(storyDir, "character_matrix.md"), { force: true }),
- rm(join(storyDir, "volume_summaries.md"), { force: true }),
- rm(join(storyDir, "particle_ledger.md"), { force: true }),
- rm(join(storyDir, "memory.db"), { force: true }),
- rm(join(storyDir, "memory.db-shm"), { force: true }),
- rm(join(storyDir, "memory.db-wal"), { force: true }),
- rm(join(storyDir, "state"), { recursive: true, force: true }),
- rm(join(storyDir, "snapshots"), { recursive: true, force: true }),
- ]);
- }
- private buildImportReplayStateSeed(language: LengthLanguage): string {
- if (language === "en") {
- return [
- "# Current State",
- "",
- "| Field | Value |",
- "| --- | --- |",
- "| Current Chapter | 0 |",
- "| Current Location | (not set) |",
- "| Protagonist State | (not set) |",
- "| Current Goal | (not set) |",
- "| Current Constraint | (not set) |",
- "| Current Alliances | (not set) |",
- "| Current Conflict | (not set) |",
- "",
- ].join("\n");
- }
- return [
- "# 当前状态",
- "",
- "| 字段 | 值 |",
- "| --- | --- |",
- "| 当前章节 | 0 |",
- "| 当前位置 | (未设定) |",
- "| 主角状态 | (未设定) |",
- "| 当前目标 | (未设定) |",
- "| 当前限制 | (未设定) |",
- "| 当前敌我 | (未设定) |",
- "| 当前冲突 | (未设定) |",
- "",
- ].join("\n");
- }
- private buildImportReplayHooksSeed(language: LengthLanguage): string {
- if (language === "en") {
- return [
- "# Pending Hooks",
- "",
- "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "",
- ].join("\n");
- }
- return [
- "# 伏笔池",
- "",
- "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "",
- ].join("\n");
- }
- private async normalizeDraftLengthIfNeeded(params: {
- bookId: string;
- chapterNumber: number;
- chapterContent: string;
- lengthSpec: LengthSpec;
- chapterIntent?: string;
- }): Promise<{
- content: string;
- wordCount: number;
- applied: boolean;
- tokenUsage?: TokenUsageSummary;
- }> {
- const writerCount = countChapterLength(
- params.chapterContent,
- params.lengthSpec.countingMode,
- );
- if (!isOutsideSoftRange(writerCount, params.lengthSpec)) {
- return {
- content: params.chapterContent,
- wordCount: writerCount,
- applied: false,
- };
- }
- const normalizer = new LengthNormalizerAgent(
- this.agentCtxFor("length-normalizer", params.bookId),
- );
- const normalized = await normalizer.normalizeChapter({
- chapterContent: params.chapterContent,
- lengthSpec: params.lengthSpec,
- chapterIntent: params.chapterIntent,
- });
- // Safety net: if normalizer output is less than 25% of original, it was too destructive.
- // Reject and keep original content.
- if (normalized.finalCount < writerCount * 0.25) {
- this.logWarn(this.languageFromLengthSpec(params.lengthSpec), {
- zh: `字数归一化被拒绝:第${params.chapterNumber}章 ${writerCount} -> ${normalized.finalCount}(砍了${Math.round((1 - normalized.finalCount / writerCount) * 100)}%,超过安全阈值)`,
- en: `Length normalization rejected for chapter ${params.chapterNumber}: ${writerCount} -> ${normalized.finalCount} (cut ${Math.round((1 - normalized.finalCount / writerCount) * 100)}%, exceeds safety threshold)`,
- });
- return {
- content: params.chapterContent,
- wordCount: writerCount,
- applied: false,
- };
- }
- this.logInfo(this.languageFromLengthSpec(params.lengthSpec), {
- zh: `审计前字数归一化:第${params.chapterNumber}章 ${writerCount} -> ${normalized.finalCount}`,
- en: `Length normalization before audit for chapter ${params.chapterNumber}: ${writerCount} -> ${normalized.finalCount}`,
- });
- return {
- content: normalized.normalizedContent,
- wordCount: normalized.finalCount,
- applied: normalized.applied,
- tokenUsage: normalized.tokenUsage,
- };
- }
- private assertChapterContentNotEmpty(content: string, chapterNumber: number, stage: string): void {
- if (content.trim().length > 0) return;
- throw new Error(`Chapter ${chapterNumber} has empty chapter content after ${stage}`);
- }
- private async syncCurrentStateFactHistory(bookId: string, uptoChapter: number): Promise<void> {
- const bookDir = this.state.bookDir(bookId);
- try {
- await this.rebuildCurrentStateFactHistory(bookDir, uptoChapter);
- } catch (error) {
- if (this.isMemoryIndexUnavailableError(error)) {
- if (this.canOpenMemoryIndex(bookDir)) {
- try {
- await this.rebuildCurrentStateFactHistory(bookDir, uptoChapter);
- return;
- } catch (retryError) {
- error = retryError;
- }
- } else {
- if (!this.memoryIndexFallbackWarned) {
- this.memoryIndexFallbackWarned = true;
- this.logWarn(await this.resolveBookLanguageById(bookId), {
- zh: "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。",
- en: "SQLite memory index unavailable on this Node runtime; continuing with markdown fallback.",
- });
- await this.logMemoryIndexDebugInfo(bookId, error);
- }
- return;
- }
- }
- this.logWarn(await this.resolveBookLanguageById(bookId), {
- zh: `状态事实同步已跳过:${String(error)}`,
- en: `State fact sync skipped: ${String(error)}`,
- });
- }
- }
- private async syncLegacyStructuredStateFromMarkdown(
- bookDir: string,
- chapterNumber: number,
- output?: {
- readonly runtimeStateDelta?: WriteChapterOutput["runtimeStateDelta"];
- readonly runtimeStateSnapshot?: WriteChapterOutput["runtimeStateSnapshot"];
- },
- ): Promise<void> {
- if (output?.runtimeStateDelta || output?.runtimeStateSnapshot) {
- return;
- }
- await rewriteStructuredStateFromMarkdown({
- bookDir,
- fallbackChapter: chapterNumber,
- });
- }
- private async syncNarrativeMemoryIndex(bookId: string): Promise<void> {
- const bookDir = this.state.bookDir(bookId);
- try {
- await this.rebuildNarrativeMemoryIndex(bookDir);
- } catch (error) {
- if (this.isMemoryIndexUnavailableError(error)) {
- if (this.canOpenMemoryIndex(bookDir)) {
- try {
- await this.rebuildNarrativeMemoryIndex(bookDir);
- return;
- } catch (retryError) {
- error = retryError;
- }
- } else {
- if (!this.memoryIndexFallbackWarned) {
- this.memoryIndexFallbackWarned = true;
- this.logWarn(await this.resolveBookLanguageById(bookId), {
- zh: "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。",
- en: "SQLite memory index unavailable on this Node runtime; continuing with markdown fallback.",
- });
- await this.logMemoryIndexDebugInfo(bookId, error);
- }
- return;
- }
- }
- this.logWarn(await this.resolveBookLanguageById(bookId), {
- zh: `叙事记忆同步已跳过:${String(error)}`,
- en: `Narrative memory sync skipped: ${String(error)}`,
- });
- }
- }
- private async rebuildCurrentStateFactHistory(bookDir: string, uptoChapter: number): Promise<void> {
- const memoryDb = await this.withMemoryIndexRetry(async () => {
- const db = new MemoryDB(bookDir);
- try {
- db.resetFacts();
- const activeFacts = new Map<string, { id: number; object: string }>();
- for (let chapter = 0; chapter <= uptoChapter; chapter++) {
- const snapshotFacts = await loadSnapshotCurrentStateFacts(bookDir, chapter);
- if (snapshotFacts.length === 0) continue;
- const nextFacts = new Map<string, Omit<Fact, "id">>();
- for (const fact of snapshotFacts) {
- nextFacts.set(this.factKey(fact), {
- subject: fact.subject,
- predicate: fact.predicate,
- object: fact.object,
- validFromChapter: chapter,
- validUntilChapter: null,
- sourceChapter: chapter,
- });
- }
- for (const [key, previous] of activeFacts.entries()) {
- const next = nextFacts.get(key);
- if (!next || next.object !== previous.object) {
- db.invalidateFact(previous.id, chapter);
- activeFacts.delete(key);
- }
- }
- for (const [key, fact] of nextFacts.entries()) {
- if (activeFacts.has(key)) continue;
- const id = db.addFact(fact);
- activeFacts.set(key, { id, object: fact.object });
- }
- }
- return db;
- } catch (error) {
- db.close();
- throw error;
- }
- });
- try {
- // No-op: keep the db open only for the duration of the rebuild.
- } finally {
- memoryDb.close();
- }
- }
- private async rebuildNarrativeMemoryIndex(bookDir: string): Promise<void> {
- const memorySeed = await loadNarrativeMemorySeed(bookDir);
- const memoryDb = await this.withMemoryIndexRetry(() => {
- const db = new MemoryDB(bookDir);
- try {
- db.replaceSummaries(memorySeed.summaries);
- db.replaceHooks(memorySeed.hooks);
- return db;
- } catch (error) {
- db.close();
- throw error;
- }
- });
- try {
- // No-op: keep the db open only for the duration of the rebuild.
- } finally {
- memoryDb.close();
- }
- }
- private canOpenMemoryIndex(bookDir: string): boolean {
- let memoryDb: MemoryDB | null = null;
- try {
- memoryDb = new MemoryDB(bookDir);
- return true;
- } catch {
- return false;
- } finally {
- memoryDb?.close();
- }
- }
- private async logMemoryIndexDebugInfo(bookId: string, error: unknown): Promise<void> {
- if (process.env.INKOS_DEBUG_SQLITE_MEMORY !== "1") {
- return;
- }
- const code = typeof error === "object" && error !== null && "code" in error
- ? String((error as { code?: unknown }).code ?? "")
- : "";
- const message = error instanceof Error
- ? error.message
- : String(error);
- this.logWarn(await this.resolveBookLanguageById(bookId), {
- zh: `SQLite 记忆索引调试:node=${process.version}; execArgv=${JSON.stringify(process.execArgv)}; code=${code || "(none)"}; message=${message}`,
- en: `SQLite memory debug: node=${process.version}; execArgv=${JSON.stringify(process.execArgv)}; code=${code || "(none)"}; message=${message}`,
- });
- }
- private async withMemoryIndexRetry<T>(operation: () => Promise<T> | T): Promise<T> {
- const retryDelaysMs = [0, 25, 75];
- let lastError: unknown;
- for (let attempt = 0; attempt < retryDelaysMs.length; attempt += 1) {
- try {
- return await operation();
- } catch (error) {
- lastError = error;
- if (!this.isMemoryIndexBusyError(error) || attempt === retryDelaysMs.length - 1) {
- throw error;
- }
- await new Promise((resolve) => setTimeout(resolve, retryDelaysMs[attempt + 1]!));
- }
- }
- throw lastError;
- }
- private isMemoryIndexUnavailableError(error: unknown): boolean {
- if (!error) return false;
- const code = typeof error === "object" && error !== null && "code" in error
- ? String((error as { code?: unknown }).code ?? "")
- : "";
- const message = error instanceof Error
- ? error.message
- : String(error);
- const normalizedMessage = message.trim();
- return /^No such built-in module:\s*node:sqlite$/i.test(normalizedMessage)
- || /^Cannot find module ['"]node:sqlite['"]$/i.test(normalizedMessage)
- || (code === "ERR_UNKNOWN_BUILTIN_MODULE" && /\bnode:sqlite\b/i.test(normalizedMessage));
- }
- private isMemoryIndexBusyError(error: unknown): boolean {
- if (!error) return false;
- const code = typeof error === "object" && error !== null && "code" in error
- ? String((error as { code?: unknown }).code ?? "")
- : "";
- const message = error instanceof Error
- ? error.message
- : String(error);
- return code === "SQLITE_BUSY"
- || code === "SQLITE_LOCKED"
- || /\bSQLITE_BUSY\b/i.test(message)
- || /\bSQLITE_LOCKED\b/i.test(message)
- || /database is locked/i.test(message)
- || /database is busy/i.test(message);
- }
- private factKey(fact: Pick<Fact, "subject" | "predicate">): string {
- return `${fact.subject}::${fact.predicate}`;
- }
- private buildLengthWarnings(
- chapterNumber: number,
- finalCount: number,
- lengthSpec: LengthSpec,
- ): string[] {
- if (!isOutsideHardRange(finalCount, lengthSpec)) {
- return [];
- }
- return [
- this.localize(this.languageFromLengthSpec(lengthSpec), {
- zh: `第${chapterNumber}章经过一次字数归一化后仍超出硬区间(${lengthSpec.hardMin}-${lengthSpec.hardMax},实际 ${finalCount})。`,
- en: `Chapter ${chapterNumber} remains outside hard range (${lengthSpec.hardMin}-${lengthSpec.hardMax}, actual ${finalCount}) after a single normalization pass.`,
- }),
- ];
- }
- private buildLengthTelemetry(params: {
- lengthSpec: LengthSpec;
- writerCount: number;
- postWriterNormalizeCount: number;
- postReviseCount: number;
- finalCount: number;
- normalizeApplied: boolean;
- lengthWarning: boolean;
- }): LengthTelemetry {
- return {
- target: params.lengthSpec.target,
- softMin: params.lengthSpec.softMin,
- softMax: params.lengthSpec.softMax,
- hardMin: params.lengthSpec.hardMin,
- hardMax: params.lengthSpec.hardMax,
- countingMode: params.lengthSpec.countingMode,
- writerCount: params.writerCount,
- postWriterNormalizeCount: params.postWriterNormalizeCount,
- postReviseCount: params.postReviseCount,
- finalCount: params.finalCount,
- normalizeApplied: params.normalizeApplied,
- lengthWarning: params.lengthWarning,
- };
- }
- private async persistAuditDriftGuidance(params: {
- readonly bookDir: string;
- readonly chapterNumber: number;
- readonly issues: ReadonlyArray<AuditIssue>;
- readonly language: LengthLanguage;
- }): Promise<void> {
- const storyDir = join(params.bookDir, "story");
- const driftPath = join(storyDir, "audit_drift.md");
- const statePath = join(storyDir, "current_state.md");
- const currentState = await readFile(statePath, "utf-8").catch(() => "");
- const sanitizedState = this.stripAuditDriftCorrectionBlock(currentState).trimEnd();
- if (sanitizedState !== currentState) {
- await writeFile(statePath, sanitizedState, "utf-8");
- }
- if (params.issues.length === 0) {
- await rm(driftPath, { force: true }).catch(() => undefined);
- return;
- }
- const block = [
- this.localize(params.language, {
- zh: "# 审计纠偏",
- en: "# Audit Drift",
- }),
- "",
- this.localize(params.language, {
- zh: "## 审计纠偏(自动生成,下一章写作前参照)",
- en: "## Audit Drift Correction",
- }),
- "",
- this.localize(params.language, {
- zh: `> 第${params.chapterNumber}章审计发现以下问题,下一章写作时必须避免:`,
- en: `> Chapter ${params.chapterNumber} audit found the following issues to avoid in the next chapter:`,
- }),
- ...params.issues.map((issue) => `> - [${issue.severity}] ${issue.category}: ${issue.description}`),
- "",
- ].join("\n");
- await writeFile(driftPath, block, "utf-8");
- }
- private stripAuditDriftCorrectionBlock(currentState: string): string {
- const headers = [
- "## 审计纠偏(自动生成,下一章写作前参照)",
- "## Audit Drift Correction",
- "# 审计纠偏",
- "# Audit Drift",
- ];
- let cutIndex = -1;
- for (const header of headers) {
- const index = currentState.indexOf(header);
- if (index >= 0 && (cutIndex < 0 || index < cutIndex)) {
- cutIndex = index;
- }
- }
- if (cutIndex < 0) {
- return currentState;
- }
- return currentState.slice(0, cutIndex).trimEnd();
- }
- private logLengthWarnings(lengthWarnings: ReadonlyArray<string>): void {
- for (const warning of lengthWarnings) {
- this.config.logger?.warn(warning);
- }
- }
- private restoreLostAuditIssues(previous: AuditResult, next: AuditResult): AuditResult {
- if (next.passed || next.issues.length > 0 || previous.issues.length === 0) {
- return next;
- }
- return {
- ...next,
- issues: previous.issues,
- summary: next.summary || previous.summary,
- };
- }
- private restoreActionableAuditIfLost(
- previous: {
- auditResult: AuditResult;
- aiTellCount: number;
- blockingCount: number;
- criticalCount: number;
- },
- next: {
- auditResult: AuditResult;
- aiTellCount: number;
- blockingCount: number;
- criticalCount: number;
- },
- ): {
- auditResult: AuditResult;
- aiTellCount: number;
- blockingCount: number;
- criticalCount: number;
- } {
- const auditResult = this.restoreLostAuditIssues(previous.auditResult, next.auditResult);
- if (auditResult === next.auditResult) {
- return next;
- }
- return {
- ...next,
- auditResult,
- blockingCount: auditResult.issues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length,
- criticalCount: auditResult.issues.filter((issue) => issue.severity === "critical").length,
- };
- }
- private async evaluateMergedAudit(params: {
- auditor: ContinuityAuditor;
- book: BookConfig;
- bookDir: string;
- chapterContent: string;
- chapterNumber: number;
- language: LengthLanguage;
- auditOptions?: {
- temperature?: number;
- chapterIntent?: string;
- contextPackage?: ContextPackage;
- ruleStack?: RuleStack;
- truthFileOverrides?: {
- currentState?: string;
- ledger?: string;
- hooks?: string;
- };
- };
- }): Promise<{
- auditResult: AuditResult;
- aiTellCount: number;
- blockingCount: number;
- criticalCount: number;
- }> {
- const llmAudit = await params.auditor.auditChapter(
- params.bookDir,
- params.chapterContent,
- params.chapterNumber,
- params.book.genre,
- params.auditOptions,
- );
- const aiTells = analyzeAITells(params.chapterContent);
- const sensitiveResult = analyzeSensitiveWords(params.chapterContent);
- const longSpanFatigue = await analyzeLongSpanFatigue({
- bookDir: params.bookDir,
- chapterNumber: params.chapterNumber,
- chapterContent: params.chapterContent,
- language: params.language,
- });
- const hasBlockedWords = sensitiveResult.found.some((f) => f.severity === "block");
- const issues: ReadonlyArray<AuditIssue> = [
- ...llmAudit.issues,
- ...aiTells.issues,
- ...sensitiveResult.issues,
- ...longSpanFatigue.issues,
- ];
- return {
- auditResult: {
- passed: hasBlockedWords ? false : llmAudit.passed,
- issues,
- summary: llmAudit.summary,
- tokenUsage: llmAudit.tokenUsage,
- },
- aiTellCount: aiTells.issues.length,
- blockingCount: issues.filter((issue) => issue.severity === "warning" || issue.severity === "critical").length,
- criticalCount: issues.filter((issue) => issue.severity === "critical").length,
- };
- }
- private async markBookActiveIfNeeded(bookId: string): Promise<void> {
- const book = await this.state.loadBookConfig(bookId);
- if (book.status !== "outlining") return;
- await this.state.saveBookConfig(bookId, {
- ...book,
- status: "active",
- updatedAt: new Date().toISOString(),
- });
- }
- private async createGovernedArtifacts(
- book: BookConfig,
- bookDir: string,
- chapterNumber: number,
- externalContext?: string,
- options?: {
- readonly reuseExistingIntentWhenContextMissing?: boolean;
- },
- ): Promise<{
- plan: PlanChapterOutput;
- composed: Awaited<ReturnType<ComposerAgent["composeChapter"]>>;
- }> {
- const plan = await this.resolveGovernedPlan(book, bookDir, chapterNumber, externalContext, options);
- const composer = new ComposerAgent(this.agentCtxFor("composer", book.id));
- const composed = await composer.composeChapter({
- book,
- bookDir,
- chapterNumber,
- plan,
- });
- return { plan, composed };
- }
- private async resolveGovernedPlan(
- book: BookConfig,
- bookDir: string,
- chapterNumber: number,
- externalContext?: string,
- options?: {
- readonly reuseExistingIntentWhenContextMissing?: boolean;
- },
- ): Promise<PlanChapterOutput> {
- if (
- options?.reuseExistingIntentWhenContextMissing &&
- (!externalContext || externalContext.trim().length === 0)
- ) {
- const persisted = await loadPersistedPlan(bookDir, chapterNumber);
- if (persisted) return persisted;
- }
- const planner = new PlannerAgent(this.agentCtxFor("planner", book.id));
- return planner.planChapter({
- book,
- bookDir,
- chapterNumber,
- externalContext,
- });
- }
- private async emitWebhook(
- event: WebhookEvent,
- bookId: string,
- chapterNumber?: number,
- data?: Record<string, unknown>,
- ): Promise<void> {
- if (!this.config.notifyChannels || this.config.notifyChannels.length === 0) return;
- await dispatchWebhookEvent(this.config.notifyChannels, {
- event,
- bookId,
- chapterNumber,
- timestamp: new Date().toISOString(),
- data,
- });
- }
- private async readChapterContent(bookDir: string, chapterNumber: number): Promise<string> {
- const chaptersDir = join(bookDir, "chapters");
- const files = await readdir(chaptersDir);
- const paddedNum = String(chapterNumber).padStart(4, "0");
- const chapterFile = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
- if (!chapterFile) {
- throw new Error(`Chapter ${chapterNumber} file not found in ${chaptersDir}`);
- }
- const raw = await readFile(join(chaptersDir, chapterFile), "utf-8");
- // Strip the title line
- const lines = raw.split("\n");
- const contentStart = lines.findIndex((l, i) => i > 0 && l.trim().length > 0);
- return contentStart >= 0 ? lines.slice(contentStart).join("\n") : raw;
- }
- }
|