Просмотр исходного кода

feat(interaction): carry foundation drafts into book creation

Ma 7 месяцев назад
Родитель
Сommit
378acafb7a

+ 4 - 1
packages/cli/src/__tests__/tui-slash-autocomplete.test.ts

@@ -11,7 +11,8 @@ describe("tui slash autocomplete", () => {
     expect(getSlashSuggestions("/st", SLASH_COMMANDS)).toEqual(["/status"]);
     expect(getSlashSuggestions("/w", SLASH_COMMANDS)).toEqual(["/write"]);
     expect(getSlashSuggestions("/o", SLASH_COMMANDS)).toEqual(["/open <book>"]);
-    expect(getSlashSuggestions("/d", SLASH_COMMANDS)).toEqual(["/depth <light|normal|deep>"]);
+    expect(getSlashSuggestions("/d", SLASH_COMMANDS)).toEqual(["/draft", "/discard", "/depth <light|normal|deep>"]);
+    expect(getSlashSuggestions("/cr", SLASH_COMMANDS)).toEqual(["/create"]);
   });
 
   it("does not suggest anything for non-slash input", () => {
@@ -29,5 +30,7 @@ describe("tui slash autocomplete", () => {
     expect(applySlashSuggestion("/st", ["/status"], 0)).toBe("/status");
     expect(applySlashSuggestion("/o", ["/open <book>"], 0)).toBe("/open ");
     expect(applySlashSuggestion("/d", ["/depth <light|normal|deep>"], 0)).toBe("/depth ");
+    expect(applySlashSuggestion("/dr", ["/draft"], 0)).toBe("/draft");
+    expect(applySlashSuggestion("/cr", ["/create"], 0)).toBe("/create");
   });
 });

+ 4 - 4
packages/cli/src/tui/i18n.ts

@@ -85,13 +85,13 @@ const ZH_CN: TuiCopy = {
   composer: {
     placeholder: "告诉 InkOS 要写什么、修改什么,或解释什么…",
     emptyConversation: "先告诉 InkOS 你要做什么。",
-    helper: "回车发送 • /write • /books • /open • /mode • /depth • /help",
+    helper: "回车发送 • /new • /draft • /create • /write • /books • /open • /mode • /depth • /help",
     submitting: "处理中…",
     failed: "上次请求失败",
     ready: "就绪",
   },
   notes: {
-    help: "可用命令:/write、/books、/open、/mode、/rewrite、/focus、/truth、/rename、/replace、/export、/status、/clear、/depth、/quit。也支持直接输入自然语言。",
+    help: "可用命令:/new、/draft、/create、/discard、/write、/books、/open、/mode、/rewrite、/focus、/truth、/rename、/replace、/export、/status、/clear、/depth、/quit。也支持直接输入自然语言。",
     status: (stage, mode) => `当前状态:${stage}(${mode})。`,
     config: "当前 Ink 仪表盘里还不支持交互式 /config。请使用 inkos config set-global。",
     depthSet: (depthLabel) => `思考深度已切换为 ${depthLabel}。`,
@@ -165,13 +165,13 @@ const EN: TuiCopy = {
   composer: {
     placeholder: "Ask InkOS to write, revise, or explain…",
     emptyConversation: "Start by asking InkOS what to do.",
-    helper: "Enter to send • /write • /books • /open • /mode • /depth • /help",
+    helper: "Enter to send • /new • /draft • /create • /write • /books • /open • /mode • /depth • /help",
     submitting: "Submitting…",
     failed: "Last request failed",
     ready: "Ready",
   },
   notes: {
-    help: "Commands: /write, /books, /open, /mode, /rewrite, /focus, /truth, /rename, /replace, /export, /status, /clear, /depth, /quit. Natural language still works.",
+    help: "Commands: /new, /draft, /create, /discard, /write, /books, /open, /mode, /rewrite, /focus, /truth, /rename, /replace, /export, /status, /clear, /depth, /quit. Natural language still works.",
     status: (stage, mode) => `Status: ${stage} (${mode}).`,
     config: "Interactive /config is not available inside the Ink dashboard yet. Use inkos config set-global.",
     depthSet: (depthLabel) => `Thinking depth set to ${depthLabel}.`,

+ 4 - 0
packages/cli/src/tui/slash-autocomplete.ts

@@ -1,4 +1,8 @@
 export const SLASH_COMMANDS = [
+  "/new <idea>",
+  "/draft",
+  "/create",
+  "/discard",
   "/write",
   "/books",
   "/open <book>",

+ 3 - 0
packages/core/src/__tests__/interaction-nl-router.test.ts

@@ -135,6 +135,9 @@ describe("interaction natural-language router", () => {
     expect(routeNaturalLanguageIntent("/create", { hasCreationDraft: true })).toEqual({
       intent: "create_book",
     });
+    expect(routeNaturalLanguageIntent("/draft", { hasCreationDraft: true })).toEqual({
+      intent: "show_book_draft",
+    });
     expect(routeNaturalLanguageIntent("/discard", { hasCreationDraft: true })).toEqual({
       intent: "discard_book_draft",
     });

+ 31 - 0
packages/core/src/__tests__/interaction-runtime.test.ts

@@ -134,6 +134,37 @@ describe("interaction runtime", () => {
     expect(result.responseText).toContain("已丢弃");
   });
 
+  it("renders the current creation draft when show_book_draft is requested", async () => {
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-show-draft",
+        projectRoot: "/tmp/project",
+        automationMode: "semi",
+        creationDraft: {
+          concept: "港风商战悬疑,主角从灰产洗白。",
+          title: "夜港账本",
+          genre: "urban",
+          worldPremise: "近未来港口城,账本牵出多方势力。",
+          protagonist: "林砚,水货账房出身,擅长记账和看人。",
+          conflictCore: "洗白与旧债回潮的对撞。",
+          nextQuestion: "卷一先查账还是先砸场?",
+          missingFields: ["targetChapters"],
+          readyToCreate: false,
+        },
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "show_book_draft",
+      },
+      tools: makeTools(),
+    });
+
+    expect(result.responseText).toContain("夜港账本");
+    expect(result.responseText).toContain("近未来港口城");
+    expect(result.responseText).toContain("卷一先查账还是先砸场");
+  });
+
   it("routes export_book through the shared export tool", async () => {
     const exportBook = vi.fn(async () => ({
       outputPath: "/tmp/project/exports/harbor.md",

+ 70 - 0
packages/core/src/__tests__/interaction-tools.test.ts

@@ -180,4 +180,74 @@ describe("interaction tools", () => {
     await expect(readFile(join(projectRoot, "books", "harbor", "story", "author_intent.md"), "utf-8"))
       .resolves.toContain("harbor mystery");
   });
+
+  it("forwards foundation draft fields into shared book creation", async () => {
+    const pipeline = {
+      initBook: vi.fn(async () => undefined),
+      writeNextChapter: vi.fn(async () => ({
+        chapterNumber: 1,
+        title: "Draft",
+        wordCount: 1000,
+        revised: false,
+        status: "ready-for-review" as const,
+        auditResult: { passed: true, issues: [], summary: "ok" },
+      })),
+      reviseDraft: vi.fn(async () => ({
+        chapterNumber: 3,
+        wordCount: 1200,
+        fixedIssues: [],
+        applied: true,
+        status: "ready-for-review" as const,
+      })),
+    };
+    const state = {
+      ensureControlDocuments: vi.fn(async () => {}),
+      bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadBookConfig: vi.fn(async () => ({
+        id: "night-harbor",
+        title: "Night Harbor",
+        platform: "other" as const,
+        genre: "urban",
+        status: "outlining" as const,
+        targetChapters: 120,
+        chapterWordCount: 2800,
+        createdAt: "2026-04-10T00:00:00.000Z",
+        updatedAt: "2026-04-10T00:00:00.000Z",
+      })),
+      loadChapterIndex: vi.fn(async () => []),
+      saveChapterIndex: vi.fn(async () => undefined),
+      listBooks: vi.fn(async () => []),
+    };
+
+    const tools = createInteractionToolsFromDeps(pipeline, state);
+    await tools.createBook?.({
+      title: "Night Harbor",
+      genre: "urban",
+      platform: "tomato",
+      chapterWordCount: 2800,
+      targetChapters: 120,
+      blurb: "一个做灰产生意的人,准备在夜港洗白,却先被旧账拖回去。",
+      worldPremise: "近未来架空香港,港口账本牵出多方势力。",
+      protagonist: "林砚,水货账房出身,聪明克制,不轻易信人。",
+      conflictCore: "洗白与旧债回潮的对撞。",
+      volumeOutline: "卷一先查账,再暴露港口旧案。",
+      authorIntent: "# 作者意图\n\n写成冷硬、克制、利益驱动的商战悬疑。\n",
+      currentFocus: "# 当前聚焦\n\n先把旧账线和港口势力网立住。\n",
+    });
+
+    expect(pipeline.initBook).toHaveBeenCalledWith(
+      expect.objectContaining({
+        title: "Night Harbor",
+        genre: "urban",
+        platform: "tomato",
+        targetChapters: 120,
+        chapterWordCount: 2800,
+      }),
+      expect.objectContaining({
+        externalContext: expect.stringContaining("近未来架空香港"),
+        authorIntent: expect.stringContaining("冷硬、克制"),
+        currentFocus: expect.stringContaining("旧账线"),
+      }),
+    );
+  });
 });

+ 61 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -348,6 +348,67 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("applies creation-draft overrides while initializing a book", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-init-book-overrides-"));
+    const bookId = "override-book";
+    const book: BookConfig = {
+      id: bookId,
+      title: "Override Book",
+      platform: "tomato",
+      genre: "xuanhuan",
+      status: "outlining",
+      targetChapters: 20,
+      chapterWordCount: 2800,
+      createdAt: "2026-04-13T00:00:00.000Z",
+      updatedAt: "2026-04-13T00:00:00.000Z",
+    };
+
+    const runner = new PipelineRunner({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+        },
+      } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const generateFoundationSpy = vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
+      storyBible: "# Story Bible\n",
+      volumeOutline: "# Volume Outline\n",
+      bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
+      currentState: "# Current State\n",
+      pendingHooks: "# Pending Hooks\n",
+    });
+
+    try {
+      await runner.initBook(book, {
+        externalContext: "世界观重点:近未来港口城,账本与旧案牵出多方势力。",
+        authorIntent: "# 作者意图\n\n写成冷硬、克制、利益驱动的商战悬疑。\n",
+        currentFocus: "# 当前聚焦\n\n先把旧账线和港口势力网立住。\n",
+      });
+
+      expect(generateFoundationSpy).toHaveBeenCalledWith(
+        book,
+        expect.stringContaining("近未来港口城"),
+        undefined,
+      );
+
+      const storyDir = join(root, "books", bookId, "story");
+      await expect(readFile(join(storyDir, "author_intent.md"), "utf-8"))
+        .resolves.toContain("冷硬、克制、利益驱动");
+      await expect(readFile(join(storyDir, "current_focus.md"), "utf-8"))
+        .resolves.toContain("旧账线和港口势力网");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("feeds foundation review feedback into the regeneration call after a rejection", async () => {
     const { root, runner, bookId } = await createRunnerFixture();
     const reviewer = new FoundationReviewerAgent({

+ 11 - 0
packages/core/src/interaction/intents.ts

@@ -3,6 +3,7 @@ import { AutomationModeSchema } from "./modes.js";
 
 export const InteractionIntentTypeSchema = z.enum([
   "develop_book",
+  "show_book_draft",
   "create_book",
   "discard_book_draft",
   "list_books",
@@ -37,6 +38,16 @@ export const InteractionRequestSchema = z.object({
   language: z.enum(["zh", "en"]).optional(),
   chapterWordCount: z.number().int().min(1).optional(),
   targetChapters: z.number().int().min(1).optional(),
+  blurb: z.string().min(1).optional(),
+  worldPremise: z.string().min(1).optional(),
+  settingNotes: z.string().min(1).optional(),
+  protagonist: z.string().min(1).optional(),
+  supportingCast: z.string().min(1).optional(),
+  conflictCore: z.string().min(1).optional(),
+  volumeOutline: z.string().min(1).optional(),
+  constraints: z.string().min(1).optional(),
+  authorIntent: z.string().min(1).optional(),
+  currentFocus: z.string().min(1).optional(),
   fileName: z.string().min(1).optional(),
   format: z.enum(["txt", "md", "epub"]).optional(),
   approvedOnly: z.boolean().optional(),

+ 6 - 0
packages/core/src/interaction/nl-router.ts

@@ -55,6 +55,12 @@ export function routeNaturalLanguageIntent(
     };
   }
 
+  if (/^\/draft$/i.test(trimmed)) {
+    return {
+      intent: "show_book_draft",
+    };
+  }
+
   if (/^\/discard$/i.test(trimmed)) {
     return {
       intent: "discard_book_draft",

+ 44 - 4
packages/core/src/interaction/project-tools.ts

@@ -16,7 +16,14 @@ import type { InteractionRuntimeTools } from "./runtime.js";
 import type { BookCreationDraft } from "./session.js";
 
 type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft"> & {
-  readonly initBook?: PipelineRunner["initBook"];
+  readonly initBook?: (
+    book: BookConfig,
+    options?: {
+      readonly externalContext?: string;
+      readonly authorIntent?: string;
+      readonly currentFocus?: string;
+    },
+  ) => Promise<void>;
 };
 type StateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadBookConfig" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
 type InstrumentablePipelineLike = PipelineLike & {
@@ -147,6 +154,34 @@ function buildBookConfig(input: {
   };
 }
 
+function buildCreationExternalContext(input: {
+  readonly blurb?: string;
+  readonly worldPremise?: string;
+  readonly settingNotes?: string;
+  readonly protagonist?: string;
+  readonly supportingCast?: string;
+  readonly conflictCore?: string;
+  readonly volumeOutline?: string;
+  readonly constraints?: string;
+}): string | undefined {
+  const sections = [
+    input.worldPremise ? `## 世界观与核心设定\n${input.worldPremise}` : undefined,
+    input.settingNotes ? `## 补充设定\n${input.settingNotes}` : undefined,
+    input.protagonist ? `## 主角设定\n${input.protagonist}` : undefined,
+    input.supportingCast ? `## 关键角色与势力\n${input.supportingCast}` : undefined,
+    input.conflictCore ? `## 核心冲突\n${input.conflictCore}` : undefined,
+    input.volumeOutline ? `## 卷纲方向\n${input.volumeOutline}` : undefined,
+    input.blurb ? `## 简介卖点\n${input.blurb}` : undefined,
+    input.constraints ? `## 创作约束\n${input.constraints}` : undefined,
+  ].filter((section): section is string => Boolean(section?.trim()));
+
+  if (sections.length === 0) {
+    return undefined;
+  }
+
+  return sections.join("\n\n");
+}
+
 async function exportBookToPath(state: StateLike, bookId: string, options: {
   readonly format?: "txt" | "md" | "epub";
   readonly approvedOnly?: boolean;
@@ -445,8 +480,9 @@ export function createInteractionToolsFromDeps(
               "Ask at most one sharp next question.",
               "Default to concise Chinese unless the draft language is clearly English.",
               "Return JSON only with keys assistantReply and draft.",
-              "draft must include concept and may include title, genre, platform, language, targetChapters, chapterWordCount, blurb, authorIntent, currentFocus, nextQuestion, missingFields, readyToCreate.",
-              "Be conservative: only mark readyToCreate=true when the draft already has a workable title, genre, targetChapters, and chapterWordCount.",
+              "draft must include concept and may include title, genre, platform, language, targetChapters, chapterWordCount, blurb, worldPremise, settingNotes, protagonist, supportingCast, conflictCore, volumeOutline, constraints, authorIntent, currentFocus, nextQuestion, missingFields, readyToCreate.",
+              "Help the user decide and revise worldview, setting, protagonist, supporting cast, core conflict, blurb, and volume direction.",
+              "Be conservative: only mark readyToCreate=true when the draft already has a workable title, genre, targetChapters, chapterWordCount, and enough setting/conflict detail to generate a foundation.",
             ].join(" "),
           },
           {
@@ -479,7 +515,11 @@ export function createInteractionToolsFromDeps(
       if (!pipeline.initBook) {
         throw new Error("Pipeline does not support shared book creation.");
       }
-      await pipeline.initBook(book);
+      await pipeline.initBook(book, {
+        externalContext: buildCreationExternalContext(input),
+        authorIntent: input.authorIntent,
+        currentFocus: input.currentFocus,
+      });
       return {
         bookId: book.id,
         title: book.title,

+ 67 - 0
packages/core/src/interaction/runtime.ts

@@ -28,6 +28,16 @@ export interface InteractionRuntimeTools {
     readonly language?: "zh" | "en";
     readonly chapterWordCount?: number;
     readonly targetChapters?: number;
+    readonly blurb?: string;
+    readonly worldPremise?: string;
+    readonly settingNotes?: string;
+    readonly protagonist?: string;
+    readonly supportingCast?: string;
+    readonly conflictCore?: string;
+    readonly volumeOutline?: string;
+    readonly constraints?: string;
+    readonly authorIntent?: string;
+    readonly currentFocus?: string;
   }) => Promise<unknown>;
   readonly exportBook?: (bookId: string, options: {
     readonly format?: "txt" | "md" | "epub";
@@ -118,6 +128,38 @@ function localizeMode(mode: AutomationMode, language: RuntimeLanguage): string {
   }[mode] ?? mode;
 }
 
+function renderCreationDraft(
+  draft: NonNullable<InteractionSession["creationDraft"]>,
+  language: RuntimeLanguage,
+): string {
+  const lines = language === "en"
+    ? [
+        "# Current Book Draft",
+        draft.title ? `- Title: ${draft.title}` : undefined,
+        draft.genre ? `- Genre: ${draft.genre}` : undefined,
+        draft.platform ? `- Platform: ${draft.platform}` : undefined,
+        draft.worldPremise ? `- World: ${draft.worldPremise}` : undefined,
+        draft.protagonist ? `- Protagonist: ${draft.protagonist}` : undefined,
+        draft.conflictCore ? `- Core Conflict: ${draft.conflictCore}` : undefined,
+        draft.volumeOutline ? `- Volume Direction: ${draft.volumeOutline}` : undefined,
+        draft.blurb ? `- Blurb: ${draft.blurb}` : undefined,
+        draft.nextQuestion ? `- Next: ${draft.nextQuestion}` : undefined,
+      ]
+    : [
+        "# 当前创作草案",
+        draft.title ? `- 书名:${draft.title}` : undefined,
+        draft.genre ? `- 题材:${draft.genre}` : undefined,
+        draft.platform ? `- 平台:${draft.platform}` : undefined,
+        draft.worldPremise ? `- 世界观:${draft.worldPremise}` : undefined,
+        draft.protagonist ? `- 主角:${draft.protagonist}` : undefined,
+        draft.conflictCore ? `- 核心冲突:${draft.conflictCore}` : undefined,
+        draft.volumeOutline ? `- 卷纲方向:${draft.volumeOutline}` : undefined,
+        draft.blurb ? `- 简介:${draft.blurb}` : undefined,
+        draft.nextQuestion ? `- 下一步:${draft.nextQuestion}` : undefined,
+      ];
+  return lines.filter(Boolean).join("\n");
+}
+
 function buildTaskStartedState(
   session: InteractionSession,
   request: InteractionRequest,
@@ -384,6 +426,21 @@ export async function runInteractionRequest(params: {
         details: metadata.details,
       };
     }
+    case "show_book_draft": {
+      if (!session.creationDraft) {
+        return {
+          session: markCompleted(session),
+          responseText: localize(language, {
+            zh: "当前还没有创作草案。先告诉我你想写什么,再逐步把书收出来。",
+            en: "There is no active book draft yet. Start by telling me what you want to write.",
+          }),
+        };
+      }
+      return {
+        session: markCompleted(session),
+        responseText: renderCreationDraft(session.creationDraft, language),
+      };
+    }
     case "create_book": {
       if (!params.tools.createBook) {
         throw new Error(localize(language, {
@@ -406,6 +463,16 @@ export async function runInteractionRequest(params: {
         language: request.language ?? effectiveDraft?.language,
         chapterWordCount: request.chapterWordCount ?? effectiveDraft?.chapterWordCount,
         targetChapters: request.targetChapters ?? effectiveDraft?.targetChapters,
+        blurb: request.blurb ?? effectiveDraft?.blurb,
+        worldPremise: request.worldPremise ?? effectiveDraft?.worldPremise,
+        settingNotes: request.settingNotes ?? effectiveDraft?.settingNotes,
+        protagonist: request.protagonist ?? effectiveDraft?.protagonist,
+        supportingCast: request.supportingCast ?? effectiveDraft?.supportingCast,
+        conflictCore: request.conflictCore ?? effectiveDraft?.conflictCore,
+        volumeOutline: request.volumeOutline ?? effectiveDraft?.volumeOutline,
+        constraints: request.constraints ?? effectiveDraft?.constraints,
+        authorIntent: request.authorIntent ?? effectiveDraft?.authorIntent,
+        currentFocus: request.currentFocus ?? effectiveDraft?.currentFocus,
       });
       const metadata = extractToolMetadata(toolResult);
       const createdBookId = typeof toolResult === "object" && toolResult !== null && "bookId" in toolResult

+ 7 - 0
packages/core/src/interaction/session.ts

@@ -28,6 +28,13 @@ export const BookCreationDraftSchema = z.object({
   targetChapters: z.number().int().min(1).optional(),
   chapterWordCount: z.number().int().min(1).optional(),
   blurb: z.string().min(1).optional(),
+  worldPremise: z.string().min(1).optional(),
+  settingNotes: z.string().min(1).optional(),
+  protagonist: z.string().min(1).optional(),
+  supportingCast: z.string().min(1).optional(),
+  conflictCore: z.string().min(1).optional(),
+  volumeOutline: z.string().min(1).optional(),
+  constraints: z.string().min(1).optional(),
   authorIntent: z.string().min(1).optional(),
   currentFocus: z.string().min(1).optional(),
   nextQuestion: z.string().min(1).optional(),

+ 16 - 3
packages/core/src/pipeline/runner.ts

@@ -171,6 +171,12 @@ export interface ImportChaptersResult {
   readonly nextChapter: number;
 }
 
+export interface InitBookOptions {
+  readonly externalContext?: string;
+  readonly authorIntent?: string;
+  readonly currentFocus?: string;
+}
+
 export class PipelineRunner {
   private readonly state: StateManager;
   private readonly config: PipelineConfig;
@@ -436,7 +442,7 @@ export class PipelineRunner {
     return radar.scan();
   }
 
-  async initBook(book: BookConfig): Promise<void> {
+  async initBook(book: BookConfig, options: InitBookOptions = {}): Promise<void> {
     const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
     const bookDir = this.state.bookDir(book.id);
     const stagingBookDir = join(
@@ -452,7 +458,7 @@ export class PipelineRunner {
     const foundation = await this.generateAndReviewFoundation({
       generate: (reviewFeedback) => architect.generateFoundation(
         book,
-        this.config.externalContext,
+        options.externalContext ?? this.config.externalContext,
         reviewFeedback,
       ),
       reviewer,
@@ -476,8 +482,15 @@ export class PipelineRunner {
       await this.state.ensureControlDocumentsAt(
         stagingBookDir,
         book.language ?? gp.language,
-        this.config.externalContext,
+        options.authorIntent ?? this.config.externalContext,
       );
+      if (options.currentFocus?.trim()) {
+        await writeFile(
+          join(stagingBookDir, "story", "current_focus.md"),
+          options.currentFocus.trimEnd() + "\n",
+          "utf-8",
+        );
+      }
 
       await this.state.saveChapterIndexAt(stagingBookDir, []);