Bläddra i källkod

feat: add chapter import for continuation writing

Extract parseWriterOutput to shared writer-parser.ts. Add ChapterAnalyzerAgent
for reverse-engineering truth files from existing chapters. Add
architect.generateFoundationFromImport for reverse-engineering foundation.
Add chapter-splitter utility, pipeline.importChapters orchestrator,
CLI 'inkos import chapters' command, and agent loop import_chapters tool.
Add "imported" chapter status.
Ma 7 månader sedan
förälder
incheckning
860d390ac6

+ 92 - 1
packages/cli/src/commands/import.ts

@@ -1,5 +1,7 @@
 import { Command } from "commander";
-import { PipelineRunner } from "@actalk/inkos-core";
+import { PipelineRunner, splitChapters } from "@actalk/inkos-core";
+import { readFile, readdir, stat } from "node:fs/promises";
+import { join, resolve } from "node:path";
 import { loadConfig, createClient, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const importCommand = new Command("import")
@@ -47,3 +49,92 @@ importCommand
       process.exit(1);
     }
   });
+
+importCommand
+  .command("chapters")
+  .description("Import existing chapters for continuation writing. Reverse-engineers all truth files.")
+  .argument("[book-id]", "Target book ID (auto-detected if only one book)")
+  .requiredOption("--from <path>", "Path to a text file (auto-split) or directory of .md/.txt files")
+  .option("--split <regex>", "Custom regex for chapter splitting (single-file mode)")
+  .option("--resume-from <n>", "Resume from chapter N (for interrupted imports)", parseInt)
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string | undefined, opts) => {
+    try {
+      const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
+      const config = await loadConfig();
+      const client = createClient(config);
+
+      const fromPath = resolve(opts.from);
+      const fromStat = await stat(fromPath);
+
+      let chapters: Array<{ title: string; content: string }>;
+
+      if (fromStat.isDirectory()) {
+        // Directory mode: read each .md/.txt file in sorted order
+        const entries = await readdir(fromPath);
+        const textFiles = entries
+          .filter((f) => f.endsWith(".md") || f.endsWith(".txt"))
+          .sort();
+
+        if (textFiles.length === 0) {
+          throw new Error(`No .md or .txt files found in ${fromPath}`);
+        }
+
+        chapters = await Promise.all(
+          textFiles.map(async (f) => {
+            const content = await readFile(join(fromPath, f), "utf-8");
+            const title = f.replace(/\.(md|txt)$/, "").replace(/^\d+[_\-\s]*/, "");
+            return { title, content };
+          }),
+        );
+      } else {
+        // Single file mode: split by chapter pattern
+        const text = await readFile(fromPath, "utf-8");
+        chapters = [...splitChapters(text, opts.split)];
+
+        if (chapters.length === 0) {
+          throw new Error(
+            `No chapters found in ${fromPath}. ` +
+            `Default pattern matches "第X章". Use --split to provide a custom regex.`,
+          );
+        }
+      }
+
+      if (!opts.json) {
+        log(`Found ${chapters.length} chapters to import into "${bookId}".`);
+        if (opts.resumeFrom) {
+          log(`Resuming from chapter ${opts.resumeFrom}.`);
+        }
+      }
+
+      const pipeline = new PipelineRunner({
+        client,
+        model: config.llm.model,
+        projectRoot: root,
+      });
+
+      const result = await pipeline.importChapters({
+        bookId,
+        chapters,
+        resumeFrom: opts.resumeFrom,
+      });
+
+      if (opts.json) {
+        log(JSON.stringify(result, null, 2));
+      } else {
+        log(`Import complete:`);
+        log(`  Chapters imported: ${result.importedCount}`);
+        log(`  Total characters: ${result.totalWords}`);
+        log(`  Next chapter number: ${result.nextChapter}`);
+        log(`\nRun "inkos write next ${bookId}" to continue writing.`);
+      }
+    } catch (e) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Chapter import failed: ${e}`);
+      }
+      process.exit(1);
+    }
+  });

+ 2 - 2
packages/core/src/__tests__/models.test.ts

@@ -257,8 +257,8 @@ describe("ChapterStatusSchema", () => {
     expect(ChapterStatusSchema.parse(value)).toBe(value);
   });
 
-  it("has exactly 11 valid statuses", () => {
-    expect(ChapterStatusSchema.options).toHaveLength(11);
+  it("has exactly 12 valid statuses", () => {
+    expect(ChapterStatusSchema.options).toHaveLength(12);
   });
 
   it("rejects unknown status", () => {

+ 147 - 0
packages/core/src/agents/architect.ts

@@ -210,6 +210,153 @@ ${eraBlock}
     await Promise.all(writes);
   }
 
+  /**
+   * Reverse-engineer foundation from existing chapters.
+   * Reads all chapters as a single text block and asks LLM to extract story_bible,
+   * volume_outline, book_rules, current_state, and pending_hooks.
+   */
+  async generateFoundationFromImport(
+    book: BookConfig,
+    chaptersText: string,
+    externalContext?: string,
+  ): Promise<ArchitectOutput> {
+    const { profile: gp, body: genreBody } =
+      await readGenreProfile(this.ctx.projectRoot, book.genre);
+
+    const contextBlock = externalContext
+      ? `\n\n## 外部指令\n${externalContext}\n`
+      : "";
+
+    const numericalBlock = gp.numericalSystem
+      ? `- 有明确的数值/资源体系可追踪
+- 在 book_rules 中定义 numericalSystemOverrides(hardCap、resourceTypes)`
+      : "- 本题材无数值系统,不需要资源账本";
+
+    const powerBlock = gp.powerScaling
+      ? "- 有明确的战力等级体系"
+      : "";
+
+    const eraBlock = gp.eraResearch
+      ? "- 需要年代考据支撑(在 book_rules 中设置 eraConstraints)"
+      : "";
+
+    const systemPrompt = `你是一个专业的网络小说架构师。你的任务是从已有的小说正文中反向推导完整的基础设定。${contextBlock}
+
+## 工作模式
+
+这不是从零创建,而是从已有正文中提取和推导。你需要:
+1. 从正文中提取世界观、势力、角色、力量体系 → 生成 story_bible
+2. 从叙事结构推断卷纲 → 生成 volume_outline(已有章节的回顾 + 预测后续方向)
+3. 从角色行为推断主角锁定和禁忌 → 生成 book_rules
+4. 从最新章节状态推断 current_state(反映最后一章结束时的状态)
+5. 从正文中识别已埋伏笔 → 生成 pending_hooks
+
+## 书籍信息
+
+- 标题:${book.title}
+- 平台:${book.platform}
+- 题材:${gp.name}(${book.genre})
+- 目标章数:${book.targetChapters}章
+- 每章字数:${book.chapterWordCount}字
+
+## 题材特征
+
+${genreBody}
+
+## 生成要求
+
+你需要生成以下内容,每个部分用 === SECTION: <name> === 分隔:
+
+=== SECTION: story_bible ===
+从正文中提取,用结构化二级标题组织:
+## 01_世界观
+从正文中提取的世界观设定、核心规则体系
+
+## 02_主角
+从正文中推断的主角设定(身份/金手指/性格底色/行为边界)
+
+## 03_势力与人物
+从正文中出现的势力分布、重要配角(每人:名字、身份、动机、与主角关系、独立目标)
+
+## 04_地理与环境
+从正文中出现的地图/场景设定、环境特色
+
+## 05_书名与简介
+保留原书名"${book.title}",根据正文内容生成简介
+
+=== SECTION: volume_outline ===
+基于已有正文反推卷纲:
+- 已有章节部分:根据实际内容回顾每卷的结构
+- 后续预测部分:基于已有伏笔和剧情走向预测未来方向
+每卷包含:卷名、章节范围、核心冲突、关键转折
+
+=== SECTION: book_rules ===
+从正文中角色行为反推 book_rules.md 格式的 YAML frontmatter + 叙事指导:
+\`\`\`
+---
+version: "1.0"
+protagonist:
+  name: (从正文提取主角名)
+  personalityLock: [(从行为推断3-5个性格关键词)]
+  behavioralConstraints: [(从行为推断3-5条行为约束)]
+genreLock:
+  primary: ${book.genre}
+  forbidden: [(2-3种禁止混入的文风)]
+${gp.numericalSystem ? `numericalSystemOverrides:
+  hardCap: (从正文推断)
+  resourceTypes: [(从正文提取核心资源类型)]` : ""}
+prohibitions:
+  - (从正文推断3-5条本书禁忌)
+chapterTypesOverride: []
+fatigueWordsOverride: []
+additionalAuditDimensions: []
+enableFullCastTracking: false
+---
+
+## 叙事视角
+(从正文推断本书叙事视角和风格)
+
+## 核心冲突驱动
+(从正文推断本书的核心矛盾和驱动力)
+\`\`\`
+
+=== SECTION: current_state ===
+反映最后一章结束时的状态卡:
+| 字段 | 值 |
+|------|-----|
+| 当前章节 | (最后一章章节号) |
+| 当前位置 | (最后一章结束时的位置) |
+| 主角状态 | (最后一章结束时的状态) |
+| 当前目标 | (当前目标) |
+| 当前限制 | (当前限制) |
+| 当前敌我 | (当前敌我关系) |
+| 当前冲突 | (当前冲突) |
+
+=== SECTION: pending_hooks ===
+从正文中识别的所有伏笔(Markdown表格):
+| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |
+
+## 关键原则
+
+1. 一切从正文出发,不要臆造正文中没有的设定
+2. 伏笔识别要完整:悬而未决的线索、暗示、预告都算
+3. 角色推断要准确:从对话和行为推断性格,不要想当然
+4. 准确性优先,宁可详细也不要遗漏
+${numericalBlock}
+${powerBlock}
+${eraBlock}`;
+
+    const response = await this.chat([
+      { role: "system", content: systemPrompt },
+      {
+        role: "user",
+        content: `以下是《${book.title}》的全部已有正文,请从中反向推导完整基础设定:\n\n${chaptersText}`,
+      },
+    ], { maxTokens: 16384, temperature: 0.5 });
+
+    return this.parseSections(response.content);
+  }
+
   private parseSections(content: string): ArchitectOutput {
     const extract = (name: string): string => {
       const regex = new RegExp(

+ 249 - 0
packages/core/src/agents/chapter-analyzer.ts

@@ -0,0 +1,249 @@
+import { BaseAgent } from "./base.js";
+import type { BookConfig } from "../models/book.js";
+import type { GenreProfile } from "../models/genre-profile.js";
+import { readGenreProfile, readBookRules } from "./rules-reader.js";
+import { parseWriterOutput, type ParsedWriterOutput } from "./writer-parser.js";
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+
+export interface AnalyzeChapterInput {
+  readonly book: BookConfig;
+  readonly bookDir: string;
+  readonly chapterNumber: number;
+  readonly chapterContent: string;
+  readonly chapterTitle?: string;
+}
+
+export type AnalyzeChapterOutput = ParsedWriterOutput;
+
+export class ChapterAnalyzerAgent extends BaseAgent {
+  get name(): string {
+    return "chapter-analyzer";
+  }
+
+  async analyzeChapter(input: AnalyzeChapterInput): Promise<AnalyzeChapterOutput> {
+    const { book, bookDir, chapterNumber, chapterContent, chapterTitle } = input;
+
+    // Read current truth files (same set as writer.ts)
+    const [
+      currentState, ledger, hooks,
+      chapterSummaries, subplotBoard, emotionalArcs, characterMatrix,
+      storyBible, volumeOutline,
+    ] = await Promise.all([
+      this.readFileOrDefault(join(bookDir, "story/current_state.md")),
+      this.readFileOrDefault(join(bookDir, "story/particle_ledger.md")),
+      this.readFileOrDefault(join(bookDir, "story/pending_hooks.md")),
+      this.readFileOrDefault(join(bookDir, "story/chapter_summaries.md")),
+      this.readFileOrDefault(join(bookDir, "story/subplot_board.md")),
+      this.readFileOrDefault(join(bookDir, "story/emotional_arcs.md")),
+      this.readFileOrDefault(join(bookDir, "story/character_matrix.md")),
+      this.readFileOrDefault(join(bookDir, "story/story_bible.md")),
+      this.readFileOrDefault(join(bookDir, "story/volume_outline.md")),
+    ]);
+
+    const { profile: genreProfile, body: genreBody } =
+      await readGenreProfile(this.ctx.projectRoot, book.genre);
+    const parsedBookRules = await readBookRules(bookDir);
+    const bookRulesBody = parsedBookRules?.body ?? "";
+
+    const systemPrompt = this.buildSystemPrompt(book, genreProfile, genreBody, bookRulesBody);
+
+    const userPrompt = this.buildUserPrompt({
+      chapterNumber,
+      chapterContent,
+      chapterTitle,
+      storyBible,
+      volumeOutline,
+      currentState,
+      ledger: genreProfile.numericalSystem ? ledger : "",
+      hooks,
+      chapterSummaries,
+      subplotBoard,
+      emotionalArcs,
+      characterMatrix,
+    });
+
+    const response = await this.chat(
+      [
+        { role: "system", content: systemPrompt },
+        { role: "user", content: userPrompt },
+      ],
+      { maxTokens: 16384, temperature: 0.3 },
+    );
+
+    const output = parseWriterOutput(chapterNumber, response.content, genreProfile);
+
+    // If LLM didn't return a title, use the one from input or derive from chapter number
+    if (output.title === `第${chapterNumber}章` && chapterTitle) {
+      return { ...output, title: chapterTitle };
+    }
+
+    return output;
+  }
+
+  private buildSystemPrompt(
+    book: BookConfig,
+    genreProfile: GenreProfile,
+    genreBody: string,
+    bookRulesBody: string,
+  ): string {
+    const numericalBlock = genreProfile.numericalSystem
+      ? `\n- 本题材有数值/资源体系,你必须在 UPDATED_LEDGER 中追踪正文中出现的所有资源变动`
+      : `\n- 本题材无数值系统,UPDATED_LEDGER 留空`;
+
+    return `你是小说连续性分析师。你的任务是分析一章已完成的小说正文,从中提取所有状态变化并更新追踪文件。
+
+## 工作模式
+
+你不是在写作,而是在分析已有正文。你需要:
+1. 仔细阅读正文,提取所有关键信息
+2. 基于"当前追踪文件"做增量更新
+3. 输出格式与写作模块完全一致
+
+## 分析维度
+
+从正文中提取以下信息:
+- 角色出场、退场、状态变化(受伤/突破/死亡等)
+- 位置移动、场景转换
+- 物品/资源的获得与消耗
+- 伏笔的埋设、推进、回收
+- 情感弧线变化
+- 支线进展
+- 角色间关系变化、新的信息边界
+
+## 书籍信息
+
+- 标题:${book.title}
+- 题材:${genreProfile.name}(${book.genre})
+- 平台:${book.platform}
+${numericalBlock}
+
+## 题材特征
+
+${genreBody}
+
+${bookRulesBody ? `## 本书规则\n\n${bookRulesBody}` : ""}
+
+## 输出格式(必须严格遵循)
+
+使用 === TAG === 分隔各部分,与写作模块完全一致:
+
+=== CHAPTER_TITLE ===
+(从正文标题行提取或推断章节标题,只输出标题文字)
+
+=== CHAPTER_CONTENT ===
+(原样输出正文内容,不做任何修改)
+
+=== PRE_WRITE_CHECK ===
+(留空,分析模式不需要写作自检)
+
+=== POST_SETTLEMENT ===
+(留空,分析模式不需要写后结算)
+
+=== UPDATED_STATE ===
+更新后的状态卡(Markdown表格),反映本章结束时的最新状态:
+| 字段 | 值 |
+|------|-----|
+| 当前章节 | {章节号} |
+| 当前位置 | ... |
+| 主角状态 | ... |
+| 当前目标 | ... |
+| 当前限制 | ... |
+| 当前敌我 | ... |
+| 当前冲突 | ... |
+
+=== UPDATED_LEDGER ===
+(如有数值系统:更新后的完整资源账本表格;无则留空)
+
+=== UPDATED_HOOKS ===
+更新后的伏笔池(Markdown表格),包含所有已知伏笔的最新状态:
+| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |
+
+=== CHAPTER_SUMMARY ===
+本章摘要(Markdown表格行):
+| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |
+
+=== UPDATED_SUBPLOTS ===
+更新后的支线进度板(Markdown表格)
+
+=== UPDATED_EMOTIONAL_ARCS ===
+更新后的情感弧线(Markdown表格)
+
+=== UPDATED_CHARACTER_MATRIX ===
+更新后的角色交互矩阵(Markdown表格)
+
+## 关键规则
+
+1. 状态卡和伏笔池必须基于"当前追踪文件"做增量更新,不是从零开始
+2. 正文中的每一个事实性变化都必须反映在对应的追踪文件中
+3. 不要遗漏细节:数值变化、位置变化、关系变化、信息变化都要记录
+4. 角色交互矩阵中的"信息边界"要准确——角色只知道他在场时发生的事`;
+  }
+
+  private buildUserPrompt(params: {
+    readonly chapterNumber: number;
+    readonly chapterContent: string;
+    readonly chapterTitle?: string;
+    readonly storyBible: string;
+    readonly volumeOutline: string;
+    readonly currentState: string;
+    readonly ledger: string;
+    readonly hooks: string;
+    readonly chapterSummaries: string;
+    readonly subplotBoard: string;
+    readonly emotionalArcs: string;
+    readonly characterMatrix: string;
+  }): string {
+    const titleLine = params.chapterTitle
+      ? `章节标题:${params.chapterTitle}\n`
+      : "";
+
+    const ledgerBlock = params.ledger
+      ? `\n## 当前资源账本\n${params.ledger}\n`
+      : "";
+
+    const summariesBlock = params.chapterSummaries !== "(文件尚未创建)"
+      ? `\n## 已有章节摘要\n${params.chapterSummaries}\n`
+      : "";
+
+    const subplotBlock = params.subplotBoard !== "(文件尚未创建)"
+      ? `\n## 当前支线进度板\n${params.subplotBoard}\n`
+      : "";
+
+    const emotionalBlock = params.emotionalArcs !== "(文件尚未创建)"
+      ? `\n## 当前情感弧线\n${params.emotionalArcs}\n`
+      : "";
+
+    const matrixBlock = params.characterMatrix !== "(文件尚未创建)"
+      ? `\n## 当前角色交互矩阵\n${params.characterMatrix}\n`
+      : "";
+
+    return `请分析第${params.chapterNumber}章正文,更新所有追踪文件。
+${titleLine}
+## 正文内容
+
+${params.chapterContent}
+
+## 当前状态卡
+${params.currentState}
+${ledgerBlock}
+## 当前伏笔池
+${params.hooks}
+${summariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}
+## 世界观设定
+${params.storyBible}
+
+## 卷纲
+${params.volumeOutline}
+
+请严格按照 === TAG === 格式输出分析结果。`;
+  }
+
+  private async readFileOrDefault(path: string): Promise<string> {
+    try {
+      return await readFile(path, "utf-8");
+    } catch {
+      return "(文件尚未创建)";
+    }
+  }
+}

+ 42 - 0
packages/core/src/agents/writer-parser.ts

@@ -0,0 +1,42 @@
+import type { GenreProfile } from "../models/genre-profile.js";
+import type { WriteChapterOutput } from "./writer.js";
+
+export type ParsedWriterOutput = Omit<WriteChapterOutput, "postWriteErrors" | "postWriteWarnings">;
+
+/**
+ * Parse LLM output that uses === TAG === delimiters into structured chapter data.
+ * Shared by WriterAgent (writing new chapters) and ChapterAnalyzerAgent (analyzing existing chapters).
+ */
+export function parseWriterOutput(
+  chapterNumber: number,
+  content: string,
+  genreProfile: GenreProfile,
+): ParsedWriterOutput {
+  const extract = (tag: string): string => {
+    const regex = new RegExp(
+      `=== ${tag} ===\\s*([\\s\\S]*?)(?==== [A-Z_]+ ===|$)`,
+    );
+    const match = content.match(regex);
+    return match?.[1]?.trim() ?? "";
+  };
+
+  const chapterContent = extract("CHAPTER_CONTENT");
+
+  return {
+    chapterNumber,
+    title: extract("CHAPTER_TITLE") || `第${chapterNumber}章`,
+    content: chapterContent,
+    wordCount: chapterContent.length,
+    preWriteCheck: extract("PRE_WRITE_CHECK"),
+    postSettlement: extract("POST_SETTLEMENT"),
+    updatedState: extract("UPDATED_STATE") || "(状态卡未更新)",
+    updatedLedger: genreProfile.numericalSystem
+      ? (extract("UPDATED_LEDGER") || "(账本未更新)")
+      : "",
+    updatedHooks: extract("UPDATED_HOOKS") || "(伏笔池未更新)",
+    chapterSummary: extract("CHAPTER_SUMMARY"),
+    updatedSubplots: extract("UPDATED_SUBPLOTS"),
+    updatedEmotionalArcs: extract("UPDATED_EMOTIONAL_ARCS"),
+    updatedCharacterMatrix: extract("UPDATED_CHARACTER_MATRIX"),
+  };
+}

+ 2 - 27
packages/core/src/agents/writer.ts

@@ -6,6 +6,7 @@ import { buildWriterSystemPrompt } from "./writer-prompts.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { validatePostWrite, type PostWriteViolation } from "./post-write-validator.js";
 import { analyzeAITells } from "./ai-tells.js";
+import { parseWriterOutput } from "./writer-parser.js";
 import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -300,33 +301,7 @@ ${params.volumeOutline}
     content: string,
     genreProfile: GenreProfile,
   ): Omit<WriteChapterOutput, "postWriteErrors" | "postWriteWarnings"> {
-    const extract = (tag: string): string => {
-      const regex = new RegExp(
-        `=== ${tag} ===\\s*([\\s\\S]*?)(?==== [A-Z_]+ ===|$)`,
-      );
-      const match = content.match(regex);
-      return match?.[1]?.trim() ?? "";
-    };
-
-    const chapterContent = extract("CHAPTER_CONTENT");
-
-    return {
-      chapterNumber,
-      title: extract("CHAPTER_TITLE") || `第${chapterNumber}章`,
-      content: chapterContent,
-      wordCount: chapterContent.length,
-      preWriteCheck: extract("PRE_WRITE_CHECK"),
-      postSettlement: extract("POST_SETTLEMENT"),
-      updatedState: extract("UPDATED_STATE") || "(状态卡未更新)",
-      updatedLedger: genreProfile.numericalSystem
-        ? (extract("UPDATED_LEDGER") || "(账本未更新)")
-        : "",
-      updatedHooks: extract("UPDATED_HOOKS") || "(伏笔池未更新)",
-      chapterSummary: extract("CHAPTER_SUMMARY"),
-      updatedSubplots: extract("UPDATED_SUBPLOTS"),
-      updatedEmotionalArcs: extract("UPDATED_EMOTIONAL_ARCS"),
-      updatedCharacterMatrix: extract("UPDATED_CHARACTER_MATRIX"),
-    };
+    return parseWriterOutput(chapterNumber, content, genreProfile);
   }
 
   /** Save new truth files (summaries, subplots, emotional arcs, character matrix). */

+ 4 - 1
packages/core/src/index.ts

@@ -27,12 +27,15 @@ export { detectAIContent, type DetectionResult } from "./agents/detector.js";
 export { analyzeStyle } from "./agents/style-analyzer.js";
 export { analyzeDetectionInsights } from "./agents/detection-insights.js";
 export { validatePostWrite, type PostWriteViolation } from "./agents/post-write-validator.js";
+export { ChapterAnalyzerAgent, type AnalyzeChapterInput, type AnalyzeChapterOutput } from "./agents/chapter-analyzer.js";
+export { parseWriterOutput, type ParsedWriterOutput } from "./agents/writer-parser.js";
 
 // Utils
 export { fetchUrl } from "./utils/web-search.js";
+export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js";
 
 // Pipeline
-export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo } from "./pipeline/runner.js";
+export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo, type ImportChaptersInput, type ImportChaptersResult } from "./pipeline/runner.js";
 export { Scheduler, type SchedulerConfig } from "./pipeline/scheduler.js";
 export { runAgentLoop, AGENT_TOOLS as AGENT_TOOLS, type AgentLoopOptions } from "./pipeline/agent.js";
 export { detectChapter, detectAndRewrite, loadDetectionHistory, type DetectChapterResult, type DetectAndRewriteResult } from "./pipeline/detection-runner.js";

+ 1 - 0
packages/core/src/models/chapter.ts

@@ -12,6 +12,7 @@ export const ChapterStatusSchema = z.enum([
   "approved",
   "rejected",
   "published",
+  "imported",
 ]);
 export type ChapterStatus = z.infer<typeof ChapterStatusSchema>;
 

+ 32 - 1
packages/core/src/pipeline/agent.ts

@@ -142,6 +142,19 @@ const TOOLS: ReadonlyArray<ToolDefinition> = [
       required: ["targetBookId", "parentBookId"],
     },
   },
+  {
+    name: "import_chapters",
+    description: "导入已有章节。从文本中分割章节并逐章分析,反推所有真相文件。导入后可用 write_draft 续写。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "目标书籍ID" },
+        text: { type: "string", description: "包含多章的完整文本" },
+        splitPattern: { type: "string", description: "章节分割正则(可选,默认匹配'第X章')" },
+      },
+      required: ["bookId", "text"],
+    },
+  },
 ];
 
 export interface AgentLoopOptions {
@@ -181,6 +194,7 @@ export async function runAgentLoop(
 | web_fetch | 抓取指定URL的文本内容 |
 | import_style | 从参考文本生成文风指南(统计+LLM分析) |
 | import_canon | 从正传导入正典参照,启用番外模式 |
+| import_chapters | 导入已有章节,反推所有真相文件,支持续写 |
 
 ## 长期记忆
 
@@ -205,7 +219,8 @@ export async function runAgentLoop(
 - 用户说了书名/bookId → 直接操作,不需要先 list_books
 - 每完成一步,简要汇报进展
 - 仿写流程:用户提供参考文本 → import_style → 生成 style_guide.md,后续写作自动参照
-- 番外流程:先 create_book 建番外书 → import_canon 导入正传正典 → 然后正常 write_draft`,
+- 番外流程:先 create_book 建番外书 → import_canon 导入正传正典 → 然后正常 write_draft
+- 续写流程:用户提供已有章节 → import_chapters → 然后 write_draft 续写`,
     },
     { role: "user", content: instruction },
   ];
@@ -386,6 +401,22 @@ async function executeTool(
       });
     }
 
+    case "import_chapters": {
+      const { splitChapters } = await import("../utils/chapter-splitter.js");
+      const chapters = splitChapters(
+        args.text as string,
+        args.splitPattern as string | undefined,
+      );
+      if (chapters.length === 0) {
+        return JSON.stringify({ error: "No chapters found. Check text format or provide a splitPattern." });
+      }
+      const result = await pipeline.importChapters({
+        bookId: args.bookId as string,
+        chapters: [...chapters],
+      });
+      return JSON.stringify(result);
+    }
+
     default:
       return JSON.stringify({ error: `Unknown tool: ${name}` });
   }

+ 122 - 0
packages/core/src/pipeline/runner.ts

@@ -6,6 +6,7 @@ import type { NotifyChannel } from "../models/project.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import { ArchitectAgent } from "../agents/architect.js";
 import { WriterAgent } 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 { RadarAgent } from "../agents/radar.js";
@@ -76,6 +77,19 @@ export interface BookStatusInfo {
   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;
@@ -798,6 +812,114 @@ ${matrix}`,
     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;
+
+      // Step 1: Generate foundation on first run (not on resume)
+      if (startFrom === 1) {
+        process.stderr.write(`[import] Step 1: Generating foundation from ${input.chapters.length} chapters...\n`);
+        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, []);
+        process.stderr.write(`[import] Foundation generated.\n`);
+      }
+
+      // Step 2: Sequential replay
+      process.stderr.write(`[import] Step 2: Sequential replay from chapter ${startFrom}...\n`);
+      const analyzer = new ChapterAnalyzerAgent(this.agentCtxFor("chapter-analyzer", input.bookId));
+      const writer = new WriterAgent(this.agentCtxFor("writer", input.bookId));
+      let totalWords = 0;
+
+      for (let i = startFrom - 1; i < input.chapters.length; i++) {
+        const ch = input.chapters[i]!;
+        const chapterNumber = i + 1;
+
+        process.stderr.write(`[import] Analyzing chapter ${chapterNumber}/${input.chapters.length}: ${ch.title}...\n`);
+
+        // 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);
+
+        totalWords += ch.content.length;
+      }
+
+      const nextChapter = input.chapters.length + 1;
+      process.stderr.write(`[import] Done. ${input.chapters.length} chapters imported, ${totalWords} chars. Next chapter: ${nextChapter}\n`);
+
+      return {
+        bookId: input.bookId,
+        importedCount: input.chapters.length,
+        totalWords,
+        nextChapter,
+      };
+    } finally {
+      await releaseLock();
+    }
+  }
+
   // ---------------------------------------------------------------------------
   // Helpers
   // ---------------------------------------------------------------------------

+ 57 - 0
packages/core/src/utils/chapter-splitter.ts

@@ -0,0 +1,57 @@
+export interface SplitChapter {
+  readonly title: string;
+  readonly content: string;
+}
+
+/**
+ * Split a single text file into chapters by matching title lines.
+ *
+ * Default pattern matches:
+ * - "第一章 xxxx" / "第1章 xxxx"
+ * - "# 第1章 xxxx" / "## 第23章 xxxx"
+ *
+ * Each match marks the start of a new chapter. Content between matches
+ * belongs to the preceding chapter.
+ */
+export function splitChapters(
+  text: string,
+  pattern?: string,
+): ReadonlyArray<SplitChapter> {
+  const defaultPattern = /^#{0,2}\s*第[零一二三四五六七八九十百千万\d]+章\s*(.*)/;
+  const regex = pattern ? new RegExp(pattern, "m") : defaultPattern;
+
+  const lines = text.split("\n");
+  const chapters: Array<{ title: string; startLine: number }> = [];
+
+  for (let i = 0; i < lines.length; i++) {
+    const match = lines[i]!.match(regex);
+    if (match) {
+      chapters.push({
+        title: (match[1] ?? "").trim(),
+        startLine: i,
+      });
+    }
+  }
+
+  if (chapters.length === 0) {
+    return [];
+  }
+
+  const result: SplitChapter[] = [];
+
+  for (let i = 0; i < chapters.length; i++) {
+    const chapter = chapters[i]!;
+    const nextStart = i + 1 < chapters.length ? chapters[i + 1]!.startLine : lines.length;
+
+    // Content starts after the title line
+    const contentLines = lines.slice(chapter.startLine + 1, nextStart);
+    const content = contentLines.join("\n").trim();
+
+    result.push({
+      title: chapter.title || `第${i + 1}章`,
+      content,
+    });
+  }
+
+  return result;
+}