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

feat(tui): add project book selection commands

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

+ 2 - 0
packages/cli/src/__tests__/interaction-tools.test.ts

@@ -47,6 +47,7 @@ describe("interaction tools adapter", () => {
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
       loadChapterIndex: vi.fn(async () => []),
       loadChapterIndex: vi.fn(async () => []),
       saveChapterIndex: vi.fn(async () => undefined),
       saveChapterIndex: vi.fn(async () => undefined),
+      listBooks: vi.fn(async () => ["harbor"]),
     };
     };
 
 
     const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);
     const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);
@@ -68,6 +69,7 @@ describe("interaction tools adapter", () => {
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
       loadChapterIndex: vi.fn(async () => []),
       loadChapterIndex: vi.fn(async () => []),
       saveChapterIndex: vi.fn(async () => undefined),
       saveChapterIndex: vi.fn(async () => undefined),
+      listBooks: vi.fn(async () => ["harbor"]),
     };
     };
 
 
     const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);
     const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);

+ 26 - 0
packages/cli/src/__tests__/tui-control-flow.test.ts

@@ -25,6 +25,7 @@ describe("tui control flow", () => {
     });
     });
 
 
     const tools = {
     const tools = {
+      listBooks: vi.fn(async () => ["harbor"]),
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
       patchChapterText: vi.fn(async () => ({ ok: true })),
       patchChapterText: vi.fn(async () => ({ ok: true })),
@@ -51,6 +52,7 @@ describe("tui control flow", () => {
     });
     });
 
 
     const tools = {
     const tools = {
+      listBooks: vi.fn(async () => ["harbor"]),
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
       patchChapterText: vi.fn(async () => ({ ok: true })),
       patchChapterText: vi.fn(async () => ({ ok: true })),
@@ -72,6 +74,7 @@ describe("tui control flow", () => {
     });
     });
 
 
     const tools = {
     const tools = {
+      listBooks: vi.fn(async () => ["harbor"]),
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
       patchChapterText: vi.fn(async () => ({ ok: true })),
       patchChapterText: vi.fn(async () => ({ ok: true })),
@@ -93,6 +96,7 @@ describe("tui control flow", () => {
     });
     });
 
 
     const tools = {
     const tools = {
+      listBooks: vi.fn(async () => ["harbor"]),
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
       patchChapterText: vi.fn(async () => ({ ok: true })),
       patchChapterText: vi.fn(async () => ({ ok: true })),
@@ -110,4 +114,26 @@ describe("tui control flow", () => {
       "Bring it back",
       "Bring it back",
     );
     );
   });
   });
+
+  it("binds the active book through /open in multi-book projects", async () => {
+    await mkdir(join(projectRoot, "books", "beta"), { recursive: true });
+    await writeFile(join(projectRoot, "books", "beta", "book.json"), "{}", "utf-8");
+    await persistProjectSession(projectRoot, createProjectSession(projectRoot));
+
+    const tools = {
+      listBooks: vi.fn(async () => ["harbor", "beta"]),
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+      writeTruthFile: vi.fn(async () => ({ ok: true })),
+    };
+
+    const result = await processTuiInput(projectRoot, "/open beta", tools);
+
+    expect(result.session.activeBookId).toBe("beta");
+    expect(result.session.messages.at(-1)?.content).toContain("beta");
+  });
 });
 });

+ 1 - 1
packages/cli/src/tui/tools.ts

@@ -7,7 +7,7 @@ import {
 import { buildPipelineConfig, loadConfig } from "../utils.js";
 import { buildPipelineConfig, loadConfig } from "../utils.js";
 
 
 type CliPipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
 type CliPipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
-type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadChapterIndex" | "saveChapterIndex">;
+type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
 
 
 export function createCliInteractionToolsFromDeps(
 export function createCliInteractionToolsFromDeps(
   pipeline: CliPipelineLike,
   pipeline: CliPipelineLike,

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

@@ -74,6 +74,13 @@ describe("interaction natural-language router", () => {
   });
   });
 
 
   it("maps slash commands for direct control", () => {
   it("maps slash commands for direct control", () => {
+    expect(routeNaturalLanguageIntent("/books", { activeBookId: "harbor" })).toEqual({
+      intent: "list_books",
+    });
+    expect(routeNaturalLanguageIntent("/open beta", { activeBookId: "harbor" })).toEqual({
+      intent: "select_book",
+      bookId: "beta",
+    });
     expect(routeNaturalLanguageIntent("/write", { activeBookId: "harbor" })).toEqual({
     expect(routeNaturalLanguageIntent("/write", { activeBookId: "harbor" })).toEqual({
       intent: "write_next",
       intent: "write_next",
       bookId: "harbor",
       bookId: "harbor",
@@ -114,6 +121,10 @@ describe("interaction natural-language router", () => {
   });
   });
 
 
   it("maps rename and chapter patch requests from natural language", () => {
   it("maps rename and chapter patch requests from natural language", () => {
+    expect(routeNaturalLanguageIntent("open beta", { activeBookId: "harbor" })).toEqual({
+      intent: "select_book",
+      bookId: "beta",
+    });
     expect(routeNaturalLanguageIntent("把陆尘改成林砚", { activeBookId: "harbor" })).toEqual({
     expect(routeNaturalLanguageIntent("把陆尘改成林砚", { activeBookId: "harbor" })).toEqual({
       intent: "rename_entity",
       intent: "rename_entity",
       bookId: "harbor",
       bookId: "harbor",

+ 75 - 81
packages/core/src/__tests__/interaction-runtime.test.ts

@@ -2,6 +2,20 @@ import { describe, expect, it, vi } from "vitest";
 import { InteractionSessionSchema } from "../interaction/session.js";
 import { InteractionSessionSchema } from "../interaction/session.js";
 import { runInteractionRequest } from "../interaction/runtime.js";
 import { runInteractionRequest } from "../interaction/runtime.js";
 
 
+function makeTools(overrides: Partial<Parameters<typeof runInteractionRequest>[0]["tools"]> = {}) {
+  return {
+    listBooks: vi.fn(async () => ["harbor"]),
+    writeNextChapter: vi.fn(),
+    reviseDraft: vi.fn(),
+    patchChapterText: vi.fn(),
+    renameEntity: vi.fn(),
+    updateCurrentFocus: vi.fn(),
+    updateAuthorIntent: vi.fn(),
+    writeTruthFile: vi.fn(),
+    ...overrides,
+  };
+}
+
 describe("interaction runtime", () => {
 describe("interaction runtime", () => {
   it("keeps write_next completed in auto mode", async () => {
   it("keeps write_next completed in auto mode", async () => {
     const writeNextChapter = vi.fn(async () => ({
     const writeNextChapter = vi.fn(async () => ({
@@ -28,15 +42,13 @@ describe("interaction runtime", () => {
     const result = await runInteractionRequest({
     const result = await runInteractionRequest({
       session,
       session,
       request: { intent: "write_next", bookId: "harbor" },
       request: { intent: "write_next", bookId: "harbor" },
-      tools: {
+      tools: makeTools({
         writeNextChapter,
         writeNextChapter,
         reviseDraft,
         reviseDraft,
-        patchChapterText: vi.fn(),
-        renameEntity: vi.fn(),
         updateCurrentFocus,
         updateCurrentFocus,
         updateAuthorIntent,
         updateAuthorIntent,
         writeTruthFile,
         writeTruthFile,
-      },
+      }),
     });
     });
 
 
     expect(writeNextChapter).toHaveBeenCalledWith("harbor");
     expect(writeNextChapter).toHaveBeenCalledWith("harbor");
@@ -81,15 +93,9 @@ describe("interaction runtime", () => {
         events: [],
         events: [],
       }),
       }),
       request: { intent: "write_next", bookId: "harbor" },
       request: { intent: "write_next", bookId: "harbor" },
-      tools: {
+      tools: makeTools({
         writeNextChapter,
         writeNextChapter,
-        reviseDraft: vi.fn(),
-        patchChapterText: vi.fn(),
-        renameEntity: vi.fn(),
-        updateCurrentFocus: vi.fn(),
-        updateAuthorIntent: vi.fn(),
-        writeTruthFile: vi.fn(),
-      },
+      }),
     });
     });
 
 
     expect(result.session.currentExecution?.status).toBe("waiting_human");
     expect(result.session.currentExecution?.status).toBe("waiting_human");
@@ -124,15 +130,10 @@ describe("interaction runtime", () => {
     await runInteractionRequest({
     await runInteractionRequest({
       session,
       session,
       request: { intent: "revise_chapter", bookId: "harbor", chapterNumber: 3 },
       request: { intent: "revise_chapter", bookId: "harbor", chapterNumber: 3 },
-      tools: {
+      tools: makeTools({
         writeNextChapter,
         writeNextChapter,
         reviseDraft,
         reviseDraft,
-        patchChapterText: vi.fn(),
-        renameEntity: vi.fn(),
-        updateCurrentFocus: vi.fn(),
-        updateAuthorIntent: vi.fn(),
-        writeTruthFile: vi.fn(),
-      },
+      }),
     });
     });
 
 
     expect(reviseDraft).toHaveBeenCalledWith("harbor", 3, "local-fix");
     expect(reviseDraft).toHaveBeenCalledWith("harbor", 3, "local-fix");
@@ -156,15 +157,9 @@ describe("interaction runtime", () => {
         messages: [],
         messages: [],
       }),
       }),
       request: { intent: "rewrite_chapter", bookId: "harbor", chapterNumber: 5 },
       request: { intent: "rewrite_chapter", bookId: "harbor", chapterNumber: 5 },
-      tools: {
-        writeNextChapter: vi.fn(),
+      tools: makeTools({
         reviseDraft,
         reviseDraft,
-        patchChapterText: vi.fn(),
-        renameEntity: vi.fn(),
-        updateCurrentFocus: vi.fn(),
-        updateAuthorIntent: vi.fn(),
-        writeTruthFile: vi.fn(),
-      },
+      }),
     });
     });
 
 
     expect(reviseDraft).toHaveBeenCalledWith("harbor", 5, "rewrite");
     expect(reviseDraft).toHaveBeenCalledWith("harbor", 5, "rewrite");
@@ -186,15 +181,9 @@ describe("interaction runtime", () => {
         bookId: "harbor",
         bookId: "harbor",
         instruction: "Bring the story back to the old harbor debt line.",
         instruction: "Bring the story back to the old harbor debt line.",
       },
       },
-      tools: {
-        writeNextChapter: vi.fn(),
-        reviseDraft: vi.fn(),
-        patchChapterText: vi.fn(),
-        renameEntity: vi.fn(),
+      tools: makeTools({
         updateCurrentFocus,
         updateCurrentFocus,
-        updateAuthorIntent: vi.fn(),
-        writeTruthFile: vi.fn(),
-      },
+      }),
     });
     });
 
 
     expect(updateCurrentFocus).toHaveBeenCalledWith(
     expect(updateCurrentFocus).toHaveBeenCalledWith(
@@ -221,15 +210,9 @@ describe("interaction runtime", () => {
         fileName: "current_focus.md",
         fileName: "current_focus.md",
         instruction: "# Current Focus\n\nBring the story back to the old harbor debt line.\n",
         instruction: "# Current Focus\n\nBring the story back to the old harbor debt line.\n",
       },
       },
-      tools: {
-        writeNextChapter: vi.fn(),
-        reviseDraft: vi.fn(),
-        patchChapterText: vi.fn(),
-        renameEntity: vi.fn(),
-        updateCurrentFocus: vi.fn(),
-        updateAuthorIntent: vi.fn(),
+      tools: makeTools({
         writeTruthFile,
         writeTruthFile,
-      },
+      }),
     });
     });
 
 
     expect(writeTruthFile).toHaveBeenCalledWith(
     expect(writeTruthFile).toHaveBeenCalledWith(
@@ -257,15 +240,9 @@ describe("interaction runtime", () => {
         oldValue: "陆尘",
         oldValue: "陆尘",
         newValue: "林砚",
         newValue: "林砚",
       },
       },
-      tools: {
-        writeNextChapter: vi.fn(),
-        reviseDraft: vi.fn(),
-        updateCurrentFocus: vi.fn(),
-        updateAuthorIntent: vi.fn(),
-        writeTruthFile: vi.fn(),
+      tools: makeTools({
         renameEntity,
         renameEntity,
-        patchChapterText: vi.fn(),
-      },
+      }),
     });
     });
 
 
     expect(renameEntity).toHaveBeenCalledWith("harbor", "陆尘", "林砚");
     expect(renameEntity).toHaveBeenCalledWith("harbor", "陆尘", "林砚");
@@ -295,15 +272,9 @@ describe("interaction runtime", () => {
         targetText: "旧名字",
         targetText: "旧名字",
         replacementText: "新名字",
         replacementText: "新名字",
       },
       },
-      tools: {
-        writeNextChapter: vi.fn(),
-        reviseDraft: vi.fn(),
-        updateCurrentFocus: vi.fn(),
-        updateAuthorIntent: vi.fn(),
-        writeTruthFile: vi.fn(),
-        renameEntity: vi.fn(),
+      tools: makeTools({
         patchChapterText,
         patchChapterText,
-      },
+      }),
     });
     });
 
 
     expect(patchChapterText).toHaveBeenCalledWith("harbor", 3, "旧名字", "新名字");
     expect(patchChapterText).toHaveBeenCalledWith("harbor", 3, "旧名字", "新名字");
@@ -329,15 +300,13 @@ describe("interaction runtime", () => {
         events: [],
         events: [],
       }),
       }),
       request: { intent: "switch_mode", mode: "auto" },
       request: { intent: "switch_mode", mode: "auto" },
-      tools: {
+      tools: makeTools({
         writeNextChapter,
         writeNextChapter,
         reviseDraft,
         reviseDraft,
-        patchChapterText: vi.fn(),
-        renameEntity: vi.fn(),
         updateCurrentFocus,
         updateCurrentFocus,
         updateAuthorIntent,
         updateAuthorIntent,
         writeTruthFile,
         writeTruthFile,
-      },
+      }),
     });
     });
 
 
     expect(result.session.automationMode).toBe("auto");
     expect(result.session.automationMode).toBe("auto");
@@ -351,6 +320,47 @@ describe("interaction runtime", () => {
     ]);
     ]);
   });
   });
 
 
+  it("binds the selected book without invoking pipeline tools", async () => {
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-5b",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: { intent: "select_book", bookId: "beta" },
+      tools: makeTools({
+        listBooks: vi.fn(async () => ["harbor", "beta"]),
+      }),
+    });
+
+    expect(result.session.activeBookId).toBe("beta");
+    expect(result.responseText).toContain("beta");
+  });
+
+  it("lists project books through the shared runtime", async () => {
+    const listBooks = vi.fn(async () => ["harbor", "beta"]);
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-5c",
+        projectRoot: "/tmp/project",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: { intent: "list_books" },
+      tools: makeTools({
+        listBooks,
+      }),
+    });
+
+    expect(listBooks).toHaveBeenCalledTimes(1);
+    expect(result.responseText).toContain("harbor");
+    expect(result.responseText).toContain("beta");
+  });
+
   it("pauses the active book without invoking pipeline tools", async () => {
   it("pauses the active book without invoking pipeline tools", async () => {
     const result = await runInteractionRequest({
     const result = await runInteractionRequest({
       session: InteractionSessionSchema.parse({
       session: InteractionSessionSchema.parse({
@@ -362,15 +372,7 @@ describe("interaction runtime", () => {
         events: [],
         events: [],
       }),
       }),
       request: { intent: "pause_book", bookId: "harbor" },
       request: { intent: "pause_book", bookId: "harbor" },
-      tools: {
-        writeNextChapter: vi.fn(),
-        reviseDraft: vi.fn(),
-        patchChapterText: vi.fn(),
-        renameEntity: vi.fn(),
-        updateCurrentFocus: vi.fn(),
-        updateAuthorIntent: vi.fn(),
-        writeTruthFile: vi.fn(),
-      },
+      tools: makeTools(),
     });
     });
 
 
     expect(result.session.currentExecution?.status).toBe("blocked");
     expect(result.session.currentExecution?.status).toBe("blocked");
@@ -398,15 +400,7 @@ describe("interaction runtime", () => {
         },
         },
       }),
       }),
       request: { intent: "explain_status", bookId: "harbor", instruction: "what are you doing?" },
       request: { intent: "explain_status", bookId: "harbor", instruction: "what are you doing?" },
-      tools: {
-        writeNextChapter: vi.fn(),
-        reviseDraft: vi.fn(),
-        patchChapterText: vi.fn(),
-        renameEntity: vi.fn(),
-        updateCurrentFocus: vi.fn(),
-        updateAuthorIntent: vi.fn(),
-        writeTruthFile: vi.fn(),
-      },
+      tools: makeTools(),
     });
     });
 
 
     expect(result.responseText).toContain("repairing");
     expect(result.responseText).toContain("repairing");

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

@@ -46,6 +46,7 @@ describe("interaction tools", () => {
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
       loadChapterIndex: vi.fn(async () => []),
       loadChapterIndex: vi.fn(async () => []),
       saveChapterIndex: vi.fn(async () => undefined),
       saveChapterIndex: vi.fn(async () => undefined),
+      listBooks: vi.fn(async () => ["harbor"]),
     };
     };
 
 
     const tools = createInteractionToolsFromDeps(pipeline, state);
     const tools = createInteractionToolsFromDeps(pipeline, state);
@@ -94,6 +95,7 @@ describe("interaction tools", () => {
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
       loadChapterIndex: vi.fn(async () => []),
       loadChapterIndex: vi.fn(async () => []),
       saveChapterIndex: vi.fn(async () => undefined),
       saveChapterIndex: vi.fn(async () => undefined),
+      listBooks: vi.fn(async () => ["harbor"]),
     };
     };
 
 
     const tools = createInteractionToolsFromDeps(pipeline, state);
     const tools = createInteractionToolsFromDeps(pipeline, state);
@@ -133,6 +135,7 @@ describe("interaction tools", () => {
         bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
         bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
         loadChapterIndex: vi.fn(async () => []),
         loadChapterIndex: vi.fn(async () => []),
         saveChapterIndex: vi.fn(async () => undefined),
         saveChapterIndex: vi.fn(async () => undefined),
+        listBooks: vi.fn(async () => ["harbor"]),
       },
       },
     );
     );
 
 

+ 25 - 0
packages/core/src/__tests__/project-interaction.test.ts

@@ -29,6 +29,7 @@ describe("project interaction control", () => {
     });
     });
 
 
     const tools = {
     const tools = {
+      listBooks: vi.fn(async () => ["harbor"]),
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
       patchChapterText: vi.fn(async () => ({ ok: true })),
       patchChapterText: vi.fn(async () => ({ ok: true })),
@@ -63,6 +64,7 @@ describe("project interaction control", () => {
       projectRoot,
       projectRoot,
       input: "切换到全自动",
       input: "切换到全自动",
       tools: {
       tools: {
+        listBooks: vi.fn(async () => ["harbor"]),
         writeNextChapter: vi.fn(async () => ({ ok: true })),
         writeNextChapter: vi.fn(async () => ({ ok: true })),
         reviseDraft: vi.fn(async () => ({ ok: true })),
         reviseDraft: vi.fn(async () => ({ ok: true })),
         patchChapterText: vi.fn(async () => ({ ok: true })),
         patchChapterText: vi.fn(async () => ({ ok: true })),
@@ -90,6 +92,7 @@ describe("project interaction control", () => {
       projectRoot,
       projectRoot,
       input: "continue",
       input: "continue",
       tools: {
       tools: {
+        listBooks: vi.fn(async () => ["harbor"]),
         writeNextChapter: vi.fn(async () => {
         writeNextChapter: vi.fn(async () => {
           throw new Error("boom");
           throw new Error("boom");
         }),
         }),
@@ -107,4 +110,26 @@ describe("project interaction control", () => {
     expect(failedSession.events.at(-1)?.kind).toBe("task.failed");
     expect(failedSession.events.at(-1)?.kind).toBe("task.failed");
     expect(failedSession.events.at(-1)?.detail).toContain("boom");
     expect(failedSession.events.at(-1)?.detail).toContain("boom");
   });
   });
+
+  it("persists book selection into the shared project session", async () => {
+    await persistProjectSession(projectRoot, createProjectSession(projectRoot));
+
+    const result = await processProjectInteractionInput({
+      projectRoot,
+      input: "/open harbor",
+      tools: {
+        writeNextChapter: vi.fn(async () => ({ ok: true })),
+        reviseDraft: vi.fn(async () => ({ ok: true })),
+        patchChapterText: vi.fn(async () => ({ ok: true })),
+        renameEntity: vi.fn(async () => ({ ok: true })),
+        updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+        updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+        writeTruthFile: vi.fn(async () => ({ ok: true })),
+        listBooks: vi.fn(async () => ["harbor"]),
+      },
+    });
+
+    expect(result.session.activeBookId).toBe("harbor");
+    expect(result.request.intent).toBe("select_book");
+  });
 });
 });

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

@@ -3,6 +3,7 @@ import { AutomationModeSchema } from "./modes.js";
 
 
 export const InteractionIntentTypeSchema = z.enum([
 export const InteractionIntentTypeSchema = z.enum([
   "create_book",
   "create_book",
+  "list_books",
   "select_book",
   "select_book",
   "continue_book",
   "continue_book",
   "write_next",
   "write_next",

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

@@ -26,6 +26,20 @@ export function routeNaturalLanguageIntent(
     };
     };
   }
   }
 
 
+  if (/^\/books$/i.test(trimmed)) {
+    return {
+      intent: "list_books",
+    };
+  }
+
+  const openCommand = trimmed.match(/^\/open\s+(.+)$/i);
+  if (openCommand) {
+    return {
+      intent: "select_book",
+      bookId: openCommand[1]!.trim(),
+    };
+  }
+
   if (/^(pause|pause this book|暂停|暂停这本书)$/i.test(trimmed)) {
   if (/^(pause|pause this book|暂停|暂停这本书)$/i.test(trimmed)) {
     return {
     return {
       intent: "pause_book",
       intent: "pause_book",
@@ -151,6 +165,14 @@ export function routeNaturalLanguageIntent(
     };
     };
   }
   }
 
 
+  const openMatch = trimmed.match(/^open\s+(.+)$/i);
+  if (openMatch) {
+    return {
+      intent: "select_book",
+      bookId: openMatch[1]!.trim(),
+    };
+  }
+
   if (/(focus|聚焦|主线|旧案线)/i.test(trimmed)) {
   if (/(focus|聚焦|主线|旧案线)/i.test(trimmed)) {
     return {
     return {
       intent: "update_focus",
       intent: "update_focus",

+ 2 - 1
packages/core/src/interaction/project-tools.ts

@@ -11,7 +11,7 @@ import { executeEditTransaction } from "./edit-controller.js";
 import type { InteractionRuntimeTools } from "./runtime.js";
 import type { InteractionRuntimeTools } from "./runtime.js";
 
 
 type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
 type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
-type StateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadChapterIndex" | "saveChapterIndex">;
+type StateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
 type InstrumentablePipelineLike = PipelineLike & {
 type InstrumentablePipelineLike = PipelineLike & {
   readonly config?: {
   readonly config?: {
     logger?: Logger;
     logger?: Logger;
@@ -196,6 +196,7 @@ export function createInteractionToolsFromDeps(
   const instrumentedPipeline = pipeline as InstrumentablePipelineLike;
   const instrumentedPipeline = pipeline as InstrumentablePipelineLike;
 
 
   return {
   return {
+    listBooks: () => state.listBooks(),
     writeNextChapter: (bookId) => withPipelineInteractionTelemetry(
     writeNextChapter: (bookId) => withPipelineInteractionTelemetry(
       instrumentedPipeline,
       instrumentedPipeline,
       bookId,
       bookId,

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

@@ -13,6 +13,7 @@ import {
 type ReviseMode = "local-fix" | "rewrite";
 type ReviseMode = "local-fix" | "rewrite";
 
 
 export interface InteractionRuntimeTools {
 export interface InteractionRuntimeTools {
+  readonly listBooks: () => Promise<ReadonlyArray<string>>;
   readonly writeNextChapter: (bookId: string) => Promise<unknown>;
   readonly writeNextChapter: (bookId: string) => Promise<unknown>;
   readonly reviseDraft: (bookId: string, chapterNumber: number, mode: ReviseMode) => Promise<unknown>;
   readonly reviseDraft: (bookId: string, chapterNumber: number, mode: ReviseMode) => Promise<unknown>;
   readonly patchChapterText: (
   readonly patchChapterText: (
@@ -266,6 +267,30 @@ export async function runInteractionRequest(params: {
         ),
         ),
       };
       };
     }
     }
+    case "list_books": {
+      const books = await params.tools.listBooks();
+      const completed = markCompleted(session);
+      return {
+        session: addEvent(completed, "task.completed", "completed", `Listed ${books.length} book(s).`),
+        responseText: books.length > 0
+          ? `Books: ${books.join(", ")}`
+          : "No books found in this project.",
+      };
+    }
+    case "select_book": {
+      if (!request.bookId) {
+        throw new Error("Book selection requires a book id.");
+      }
+      const books = await params.tools.listBooks();
+      if (!books.includes(request.bookId)) {
+        throw new Error(`Book "${request.bookId}" not found in this project.`);
+      }
+      const completed = markCompleted(bindActiveBook(session, request.bookId));
+      return {
+        session: addEvent(completed, "task.completed", "completed", `Bound active book to ${request.bookId}.`),
+        responseText: `Opened ${request.bookId}.`,
+      };
+    }
     case "revise_chapter":
     case "revise_chapter":
     case "rewrite_chapter": {
     case "rewrite_chapter": {
       const bookId = request.bookId ?? session.activeBookId;
       const bookId = request.bookId ?? session.activeBookId;