|
|
@@ -7,12 +7,13 @@ import { WriterAgent } from "../agents/writer.js";
|
|
|
import { ContinuityAuditor } from "../agents/continuity.js";
|
|
|
import { ReviserAgent } from "../agents/reviser.js";
|
|
|
import { RadarAgent } from "../agents/radar.js";
|
|
|
+import type { RadarSource } from "../agents/radar-source.js";
|
|
|
import { StateManager } from "../state/manager.js";
|
|
|
import { dispatchNotification } from "../notify/dispatcher.js";
|
|
|
import type { AgentContext } from "../agents/base.js";
|
|
|
-import type { AuditResult } from "../agents/continuity.js";
|
|
|
+import type { AuditResult, AuditIssue } from "../agents/continuity.js";
|
|
|
import type { RadarResult } from "../agents/radar.js";
|
|
|
-import { readFile, writeFile } from "node:fs/promises";
|
|
|
+import { readFile, readdir, writeFile } from "node:fs/promises";
|
|
|
import { join } from "node:path";
|
|
|
|
|
|
export interface PipelineConfig {
|
|
|
@@ -20,6 +21,8 @@ export interface PipelineConfig {
|
|
|
readonly model: string;
|
|
|
readonly projectRoot: string;
|
|
|
readonly notifyChannels?: ReadonlyArray<NotifyChannel>;
|
|
|
+ readonly radarSources?: ReadonlyArray<RadarSource>;
|
|
|
+ readonly externalContext?: string;
|
|
|
}
|
|
|
|
|
|
export interface ChapterPipelineResult {
|
|
|
@@ -31,6 +34,41 @@ export interface ChapterPipelineResult {
|
|
|
readonly status: "approved" | "needs-review";
|
|
|
}
|
|
|
|
|
|
+// Atomic operation results
|
|
|
+export interface DraftResult {
|
|
|
+ readonly chapterNumber: number;
|
|
|
+ readonly title: string;
|
|
|
+ readonly wordCount: number;
|
|
|
+ readonly filePath: 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 styleGuide: 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 class PipelineRunner {
|
|
|
private readonly state: StateManager;
|
|
|
private readonly config: PipelineConfig;
|
|
|
@@ -49,9 +87,13 @@ export class PipelineRunner {
|
|
|
};
|
|
|
}
|
|
|
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // Atomic operations (composable by OpenClaw or agent mode)
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+
|
|
|
async runRadar(): Promise<RadarResult> {
|
|
|
- const radar = new RadarAgent(this.agentCtx());
|
|
|
- return radar.scan(["tomato", "feilu"]);
|
|
|
+ const radar = new RadarAgent(this.agentCtx(), this.config.radarSources);
|
|
|
+ return radar.scan();
|
|
|
}
|
|
|
|
|
|
async initBook(book: BookConfig): Promise<void> {
|
|
|
@@ -60,11 +102,224 @@ export class PipelineRunner {
|
|
|
|
|
|
await this.state.saveBookConfig(book.id, book);
|
|
|
|
|
|
- const foundation = await architect.generateFoundation(book);
|
|
|
+ const foundation = await architect.generateFoundation(book, this.config.externalContext);
|
|
|
await architect.writeFoundationFiles(bookDir, foundation);
|
|
|
await this.state.saveChapterIndex(book.id, []);
|
|
|
}
|
|
|
|
|
|
+ /** Write a single draft chapter. Saves chapter file + truth files + index + snapshot. */
|
|
|
+ async writeDraft(bookId: string, context?: string): Promise<DraftResult> {
|
|
|
+ const releaseLock = await this.state.acquireBookLock(bookId);
|
|
|
+ try {
|
|
|
+ const book = await this.state.loadBookConfig(bookId);
|
|
|
+ const bookDir = this.state.bookDir(bookId);
|
|
|
+ const chapterNumber = await this.state.getNextChapterNumber(bookId);
|
|
|
+
|
|
|
+ const writer = new WriterAgent(this.agentCtx(bookId));
|
|
|
+ const output = await writer.writeChapter({
|
|
|
+ book,
|
|
|
+ bookDir,
|
|
|
+ chapterNumber,
|
|
|
+ externalContext: context ?? this.config.externalContext,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 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);
|
|
|
+
|
|
|
+ await writeFile(filePath, `# 第${chapterNumber}章 ${output.title}\n\n${output.content}`, "utf-8");
|
|
|
+
|
|
|
+ // Save truth files
|
|
|
+ await writer.saveChapter(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: [],
|
|
|
+ };
|
|
|
+ await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
|
|
|
+
|
|
|
+ // Snapshot
|
|
|
+ await this.state.snapshotState(bookId, chapterNumber);
|
|
|
+
|
|
|
+ return { chapterNumber, title: output.title, wordCount: output.wordCount, filePath };
|
|
|
+ } finally {
|
|
|
+ await releaseLock();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Audit the latest (or specified) chapter. Read-only, no lock needed. */
|
|
|
+ async auditDraft(bookId: string, chapterNumber?: number): Promise<AuditResult & { readonly chapterNumber: number }> {
|
|
|
+ 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.agentCtx(bookId));
|
|
|
+ const result = await auditor.auditChapter(bookDir, content, targetChapter);
|
|
|
+
|
|
|
+ // 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);
|
|
|
+
|
|
|
+ return { ...result, chapterNumber: targetChapter };
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Revise the latest (or specified) chapter based on audit issues. */
|
|
|
+ async reviseDraft(bookId: string, chapterNumber?: number): Promise<ReviseResult> {
|
|
|
+ const releaseLock = await this.state.acquireBookLock(bookId);
|
|
|
+ try {
|
|
|
+ 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.agentCtx(bookId));
|
|
|
+ const auditResult = await auditor.auditChapter(bookDir, content, targetChapter);
|
|
|
+
|
|
|
+ if (auditResult.passed) {
|
|
|
+ return { chapterNumber: targetChapter, wordCount: content.length, fixedIssues: ["No issues to fix"] };
|
|
|
+ }
|
|
|
+
|
|
|
+ const reviser = new ReviserAgent(this.agentCtx(bookId));
|
|
|
+ const reviseOutput = await reviser.reviseChapter(bookDir, content, targetChapter, auditResult.issues);
|
|
|
+
|
|
|
+ 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) {
|
|
|
+ await writeFile(
|
|
|
+ join(chaptersDir, existingFile),
|
|
|
+ `# 第${targetChapter}章 ${chapterMeta.title}\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 (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);
|
|
|
+
|
|
|
+ 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, styleGuide] =
|
|
|
+ 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, "style_guide.md")),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return { currentState, particleLedger, pendingHooks, storyBible, volumeOutline, styleGuide };
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 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): Promise<ChapterPipelineResult> {
|
|
|
const releaseLock = await this.state.acquireBookLock(bookId);
|
|
|
try {
|
|
|
@@ -85,6 +340,7 @@ export class PipelineRunner {
|
|
|
book,
|
|
|
bookDir,
|
|
|
chapterNumber,
|
|
|
+ externalContext: this.config.externalContext,
|
|
|
});
|
|
|
|
|
|
// 2. Audit chapter
|
|
|
@@ -143,7 +399,7 @@ export class PipelineRunner {
|
|
|
// 4. Save chapter (original or revised)
|
|
|
const chaptersDir = join(bookDir, "chapters");
|
|
|
const paddedNum = String(chapterNumber).padStart(4, "0");
|
|
|
- const title = revised ? output.title : output.title;
|
|
|
+ const title = output.title;
|
|
|
const filename = `${paddedNum}_${title.replace(/[/\\?%*:|"<>]/g, "").replace(/\s+/g, "_").slice(0, 50)}.md`;
|
|
|
|
|
|
await writeFile(
|
|
|
@@ -203,4 +459,23 @@ export class PipelineRunner {
|
|
|
status: auditResult.passed ? "approved" : "needs-review",
|
|
|
};
|
|
|
}
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // Helpers
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+ 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;
|
|
|
+ }
|
|
|
}
|