| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319 |
- 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 } from "../agents/planner.js";
- import { ComposerAgent } from "../agents/composer.js";
- import { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "../agents/writer.js";
- import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
- import { ContinuityAuditor } from "../agents/continuity.js";
- import { ReviserAgent, type ReviseMode } from "../agents/reviser.js";
- import { StateValidatorAgent } 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 { 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 { readFile, readdir, writeFile, mkdir } from "node:fs/promises";
- import { join } from "node:path";
- 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";
- readonly tokenUsage?: TokenUsageSummary;
- }
- // Atomic operation results
- export interface DraftResult {
- readonly chapterNumber: number;
- readonly title: string;
- readonly wordCount: number;
- readonly filePath: string;
- 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>;
- }
- 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>();
- constructor(config: PipelineConfig) {
- this.config = config;
- this.state = new StateManager(config.projectRoot);
- }
- 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 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);
- await this.state.saveBookConfig(book.id, book);
- const { profile: gp } = await this.loadGenreProfile(book.genre);
- const foundation = await architect.generateFoundation(book, this.config.externalContext);
- await architect.writeFoundationFiles(bookDir, foundation, gp.numericalSystem);
- await this.state.ensureControlDocuments(book.id, this.config.externalContext);
- // Ensure chapters directory exists (prevents ENOENT if init was previously interrupted)
- await mkdir(join(bookDir, "chapters"), { recursive: true });
- await this.state.saveChapterIndex(book.id, []);
- // Snapshot initial state so rewrite of chapter 1 can restore to pre-chapter state
- await this.state.snapshotState(book.id, 0);
- }
- /** 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);
- await this.state.saveBookConfig(book.id, book);
- // Step 1: Import source material → fanfic_canon.md
- 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));
- const { profile: gp } = await this.loadGenreProfile(book.genre);
- const foundation = await architect.generateFanficFoundation(book, fanficCanon, fanficMode);
- await architect.writeFoundationFiles(bookDir, foundation, gp.numericalSystem);
- await this.state.ensureControlDocuments(book.id, this.config.externalContext);
- // Step 3: Initialize chapters directory + 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 writeInput = await this.prepareWriteInput(
- book,
- bookDir,
- chapterNumber,
- context ?? this.config.externalContext,
- );
- const { profile: gp } = await this.loadGenreProfile(book.genre);
- const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
- const output = await writer.writeChapter({
- book,
- bookDir,
- chapterNumber,
- ...writeInput,
- ...(wordCount ? { wordCountOverride: wordCount } : {}),
- });
- // Save chapter file
- const chaptersDir = join(bookDir, "chapters");
- const paddedNum = String(chapterNumber).padStart(4, "0");
- const sanitized = output.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}: ${output.title}`
- : `# 第${chapterNumber}章 ${output.title}`;
- await writeFile(filePath, `${heading}\n\n${output.content}`, "utf-8");
- // Save truth files
- await writer.saveChapter(bookDir, output, gp.numericalSystem, resolvedLang);
- await writer.saveNewTruthFiles(bookDir, output);
- // Update index
- const existingIndex = await this.state.loadChapterIndex(bookId);
- const now = new Date().toISOString();
- const newEntry: ChapterMeta = {
- number: chapterNumber,
- title: output.title,
- status: "drafted",
- wordCount: output.wordCount,
- createdAt: now,
- updatedAt: now,
- auditIssues: [],
- ...(output.tokenUsage ? { tokenUsage: output.tokenUsage } : {}),
- };
- await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
- // Snapshot
- await this.state.snapshotState(bookId, chapterNumber);
- await this.emitWebhook("chapter-complete", bookId, chapterNumber, {
- title: output.title,
- wordCount: output.wordCount,
- });
- return { chapterNumber, title: output.title, wordCount: output.wordCount, filePath, tokenUsage: output.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 { plan } = await this.createGovernedArtifacts(
- book,
- bookDir,
- chapterNumber,
- context ?? this.config.externalContext,
- );
- return {
- bookId,
- chapterNumber,
- intentPath: this.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 { plan, composed } = await this.createGovernedArtifacts(
- book,
- bookDir,
- chapterNumber,
- context ?? this.config.externalContext,
- );
- return {
- bookId,
- chapterNumber,
- intentPath: this.relativeToBookDir(bookDir, plan.runtimePath),
- goal: plan.intent.goal,
- conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`),
- contextPath: this.relativeToBookDir(bookDir, composed.contextPath),
- ruleStackPath: this.relativeToBookDir(bookDir, composed.ruleStackPath),
- tracePath: this.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 llmResult = await auditor.auditChapter(bookDir, content, targetChapter, book.genre);
- // Merge rule-based AI-tell detection
- const aiTells = analyzeAITells(content);
- // Merge sensitive word detection
- const sensitiveResult = analyzeSensitiveWords(content);
- const hasBlockedWords = sensitiveResult.found.some((f) => f.severity === "block");
- const mergedIssues: ReadonlyArray<AuditIssue> = [
- ...llmResult.issues,
- ...aiTells.issues,
- ...sensitiveResult.issues,
- ];
- const result: AuditResult = {
- passed: hasBlockedWords ? false : llmResult.passed,
- issues: mergedIssues,
- summary: llmResult.summary,
- };
- // 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);
- 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 = "rewrite"): 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}"`);
- }
- // Read the current audit issues from index
- 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 auditResult = await auditor.auditChapter(bookDir, content, targetChapter, book.genre);
- if (auditResult.passed && auditResult.issues.filter(i => i.severity === "warning" || i.severity === "critical").length === 0) {
- return { chapterNumber: targetChapter, wordCount: content.length, fixedIssues: [] };
- }
- const { profile: gp } = await this.loadGenreProfile(book.genre);
- const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
- const reviseOutput = await reviser.reviseChapter(
- bookDir, content, targetChapter, auditResult.issues, mode, book.genre,
- );
- if (reviseOutput.revisedContent.length === 0) {
- throw new Error("Reviser returned empty content");
- }
- // Save revised chapter file
- 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${reviseOutput.revisedContent}`,
- "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");
- }
- // Update index
- const updatedIndex = index.map((ch) =>
- ch.number === targetChapter
- ? {
- ...ch,
- status: "ready-for-review" as ChapterMeta["status"],
- wordCount: reviseOutput.wordCount,
- updatedAt: new Date().toISOString(),
- }
- : ch,
- );
- await this.state.saveChapterIndex(bookId, updatedIndex);
- // Re-snapshot
- await this.state.snapshotState(bookId, targetChapter);
- await this.emitWebhook("revision-complete", bookId, targetChapter, {
- wordCount: reviseOutput.wordCount,
- fixedCount: reviseOutput.fixedIssues.length,
- });
- return {
- chapterNumber: targetChapter,
- wordCount: reviseOutput.wordCount,
- fixedIssues: reviseOutput.fixedIssues,
- };
- } 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();
- }
- }
- 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);
- const chapterNumber = await this.state.getNextChapterNumber(bookId);
- 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);
- // 1. Write chapter
- const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
- const output = await writer.writeChapter({
- book,
- bookDir,
- chapterNumber,
- ...writeInput,
- ...(wordCount ? { wordCountOverride: wordCount } : {}),
- ...(temperatureOverride ? { temperatureOverride } : {}),
- });
- // Token usage accumulator
- let totalUsage: TokenUsageSummary = output.tokenUsage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
- // 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.config.logger?.warn(
- `${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,
- );
- totalUsage = PipelineRunner.addUsage(totalUsage, fixResult.tokenUsage);
- if (fixResult.revisedContent.length > 0) {
- finalContent = fixResult.revisedContent;
- finalWordCount = fixResult.wordCount;
- revised = true;
- }
- }
- // 2b. LLM audit
- const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
- 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));
- const reviseOutput = await reviser.reviseChapter(
- bookDir,
- finalContent,
- chapterNumber,
- auditResult.issues,
- "spot-fix",
- book.genre,
- reducedControlInput,
- );
- totalUsage = PipelineRunner.addUsage(totalUsage, reviseOutput.tokenUsage);
- if (reviseOutput.revisedContent.length > 0) {
- // Guard: reject revision if AI markers increased
- const preMarkers = analyzeAITells(finalContent);
- const postMarkers = analyzeAITells(reviseOutput.revisedContent);
- 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 = reviseOutput.revisedContent;
- finalWordCount = reviseOutput.wordCount;
- revised = true;
- }
- // 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 = {
- 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
- const persistenceOutput = await this.buildPersistenceOutput(
- bookId,
- book,
- bookDir,
- chapterNumber,
- output,
- finalContent,
- );
- finalWordCount = persistenceOutput.wordCount;
- const pipelineLang = book.language ?? gp.language;
- // 4.1 Validate settler output before writing (non-blocking)
- try {
- 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 validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
- const validation = await validator.validate(
- finalContent, chapterNumber,
- oldState, persistenceOutput.updatedState,
- oldHooks, persistenceOutput.updatedHooks,
- pipelineLang,
- );
- if (validation.warnings.length > 0) {
- this.config.logger?.warn(`State validation: ${validation.warnings.length} warning(s) for chapter ${chapterNumber}`);
- for (const w of validation.warnings) {
- this.config.logger?.warn(` [${w.category}] ${w.description}`);
- }
- }
- } catch (e) {
- this.config.logger?.warn(`State validation skipped: ${e}`);
- }
- await writer.saveChapter(bookDir, persistenceOutput, gp.numericalSystem, pipelineLang);
- await writer.saveNewTruthFiles(bookDir, persistenceOutput);
- // 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: auditResult.passed ? "ready-for-review" : "audit-failed",
- wordCount: finalWordCount,
- createdAt: now,
- updatedAt: now,
- auditIssues: auditResult.issues.map(
- (i) => `[${i.severity}] ${i.description}`,
- ),
- tokenUsage: totalUsage,
- };
- await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
- // 5.5 Audit drift correction — feed audit findings back into state
- // This prevents the writer from repeating mistakes in the next chapter
- const driftIssues = auditResult.issues.filter(
- (i) => i.severity === "critical" || i.severity === "warning",
- );
- if (driftIssues.length > 0) {
- const storyDir = join(bookDir, "story");
- try {
- const statePath = join(storyDir, "current_state.md");
- const currentState = await readFile(statePath, "utf-8").catch(() => "");
- // Append drift correction section (or replace existing one)
- const correctionHeader = "## 审计纠偏(自动生成,下一章写作前参照)";
- const correctionBlock = [
- correctionHeader,
- `> 第${chapterNumber}章审计发现以下问题,下一章写作时必须避免:`,
- ...driftIssues.map((i) => `> - [${i.severity}] ${i.category}: ${i.description}`),
- "",
- ].join("\n");
- // Replace existing correction block or append
- const existingCorrectionIdx = currentState.indexOf(correctionHeader);
- const updatedState = existingCorrectionIdx >= 0
- ? currentState.slice(0, existingCorrectionIdx) + correctionBlock
- : currentState + "\n\n" + correctionBlock;
- await writeFile(statePath, updatedState, "utf-8");
- } catch {
- // Non-critical — don't block pipeline if drift correction fails
- }
- }
- // 5.6 Snapshot state for rollback support
- await this.state.snapshotState(bookId, chapterNumber);
- // 6. Send notification
- if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
- const statusEmoji = auditResult.passed ? "✅" : "⚠️";
- await dispatchNotification(this.config.notifyChannels, {
- title: `${statusEmoji} ${book.title} 第${chapterNumber}章`,
- body: [
- `**${persistenceOutput.title}** | ${finalWordCount}字`,
- revised ? "📝 已自动修正" : "",
- `审稿: ${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,
- });
- return {
- chapterNumber,
- title: persistenceOutput.title,
- wordCount: finalWordCount,
- auditResult,
- revised,
- status: auditResult.passed ? "ready-for-review" : "audit-failed",
- tokenUsage: totalUsage,
- };
- }
- // ---------------------------------------------------------------------------
- // 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");
- return canon;
- }
- // ---------------------------------------------------------------------------
- // 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 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(`Step 1: Generating foundation from ${input.chapters.length} chapters...`);
- const allText = input.chapters.map((c, i) =>
- `第${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);
- await this.state.saveChapterIndex(input.bookId, []);
- log?.info("Foundation generated.");
- }
- // Step 2: Sequential replay
- log?.info(`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));
- 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;
- log?.info(`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,
- });
- // Save chapter file + core truth files (state, ledger, hooks)
- await writer.saveChapter(bookDir, {
- ...output,
- postWriteErrors: [],
- postWriteWarnings: [],
- }, gp.numericalSystem);
- // Save extended truth files (summaries, subplots, emotional arcs, character matrix)
- await writer.saveNewTruthFiles(bookDir, {
- ...output,
- postWriteErrors: [],
- postWriteWarnings: [],
- });
- // Update chapter index
- const existingIndex = await this.state.loadChapterIndex(input.bookId);
- const now = new Date().toISOString();
- const newEntry: ChapterMeta = {
- number: chapterNumber,
- title: output.title,
- status: "imported",
- wordCount: ch.content.length,
- createdAt: now,
- updatedAt: now,
- auditIssues: [],
- };
- // 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 += ch.content.length;
- }
- const nextChapter = input.chapters.length + 1;
- log?.info(`Done. ${importedCount} chapters imported, ${totalWords} chars. 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,
- ): 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,
- });
- return {
- ...analyzed,
- postWriteErrors: [],
- postWriteWarnings: [],
- tokenUsage: output.tokenUsage,
- };
- }
- // ---------------------------------------------------------------------------
- // Helpers
- // ---------------------------------------------------------------------------
- private async prepareWriteInput(
- book: BookConfig,
- bookDir: string,
- chapterNumber: number,
- externalContext?: string,
- ): Promise<Pick<WriteChapterInput, "externalContext" | "chapterIntent" | "contextPackage" | "ruleStack" | "trace">> {
- if ((this.config.inputGovernanceMode ?? "legacy") === "legacy") {
- return { externalContext };
- }
- const { plan, composed } = await this.createGovernedArtifacts(
- book,
- bookDir,
- chapterNumber,
- externalContext,
- );
- return {
- chapterIntent: plan.intentMarkdown,
- contextPackage: composed.contextPackage,
- ruleStack: composed.ruleStack,
- trace: composed.trace,
- };
- }
- private async createGovernedArtifacts(
- book: BookConfig,
- bookDir: string,
- chapterNumber: number,
- externalContext?: string,
- ): Promise<{
- plan: Awaited<ReturnType<PlannerAgent["planChapter"]>>;
- composed: Awaited<ReturnType<ComposerAgent["composeChapter"]>>;
- }> {
- const planner = new PlannerAgent(this.agentCtxFor("planner", book.id));
- const plan = await planner.planChapter({
- book,
- bookDir,
- chapterNumber,
- externalContext,
- });
- const composer = new ComposerAgent(this.agentCtxFor("composer", book.id));
- const composed = await composer.composeChapter({
- book,
- bookDir,
- chapterNumber,
- plan,
- });
- return { plan, composed };
- }
- private relativeToBookDir(bookDir: string, absolutePath: string): string {
- const prefix = `${bookDir}/`;
- return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : absolutePath;
- }
- 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;
- }
- }
|