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

feat(core): split backend changes from #182

Ma 6 месяцев назад
Родитель
Сommit
8dd296c24b
58 измененных файлов с 4175 добавлено и 1125 удалено
  1. 1 0
      packages/cli/src/__tests__/tui-chat-draft.test.ts
  2. 1 0
      packages/cli/src/__tests__/tui-dashboard.test.tsx
  3. 3 0
      packages/cli/src/tui/app.ts
  4. 11 6
      packages/cli/src/tui/dashboard.tsx
  5. 7 4
      packages/cli/src/tui/i18n.ts
  6. 1 1
      packages/cli/src/tui/slash-autocomplete.ts
  7. 1 0
      packages/cli/src/tui/tools.ts
  8. 3 2
      packages/core/package.json
  9. 84 0
      packages/core/src/__tests__/agent-system-prompt.test.ts
  10. 109 0
      packages/core/src/__tests__/book-session-store.test.ts
  11. 108 0
      packages/core/src/__tests__/book-session.test.ts
  12. 46 0
      packages/core/src/__tests__/chapter-persistence.test.ts
  13. 48 0
      packages/core/src/__tests__/chapter-truth-validation.test.ts
  14. 102 0
      packages/core/src/__tests__/config-migration.test.ts
  15. 386 0
      packages/core/src/__tests__/draft-directive-parser.test.ts
  16. 69 12
      packages/core/src/__tests__/interaction-chat-tokens.test.ts
  17. 10 5
      packages/core/src/__tests__/pipeline-runner.test.ts
  18. 229 150
      packages/core/src/__tests__/provider.test.ts
  19. 95 0
      packages/core/src/__tests__/secrets.test.ts
  20. 122 0
      packages/core/src/__tests__/service-resolver.test.ts
  21. 16 0
      packages/core/src/__tests__/state-manager.test.ts
  22. 30 0
      packages/core/src/__tests__/state-validator-agent.test.ts
  23. 57 0
      packages/core/src/__tests__/temperature-constraints.test.ts
  24. 304 0
      packages/core/src/agent/agent-session.ts
  25. 98 0
      packages/core/src/agent/agent-system-prompt.ts
  26. 338 0
      packages/core/src/agent/agent-tools.ts
  27. 3 0
      packages/core/src/agent/index.ts
  28. 5 5
      packages/core/src/agents/architect.ts
  29. 32 4
      packages/core/src/agents/chapter-analyzer.ts
  30. 1 1
      packages/core/src/agents/consolidator.ts
  31. 1 1
      packages/core/src/agents/continuity.ts
  32. 1 1
      packages/core/src/agents/fanfic-canon-importer.ts
  33. 1 1
      packages/core/src/agents/foundation-reviewer.ts
  34. 1 1
      packages/core/src/agents/radar.ts
  35. 1 1
      packages/core/src/agents/state-validator.ts
  36. 13 13
      packages/core/src/agents/writer-prompts.ts
  37. 1 1
      packages/core/src/agents/writer.ts
  38. 23 0
      packages/core/src/index.ts
  39. 78 0
      packages/core/src/interaction/book-session-store.ts
  40. 266 0
      packages/core/src/interaction/draft-directive-parser.ts
  41. 23 1
      packages/core/src/interaction/project-session-store.ts
  42. 175 120
      packages/core/src/interaction/project-tools.ts
  43. 15 2
      packages/core/src/interaction/runtime.ts
  44. 87 0
      packages/core/src/interaction/session.ts
  45. 58 0
      packages/core/src/llm/config-migration.ts
  46. 177 660
      packages/core/src/llm/provider.ts
  47. 50 0
      packages/core/src/llm/secrets.ts
  48. 143 0
      packages/core/src/llm/service-presets.ts
  49. 66 0
      packages/core/src/llm/service-resolver.ts
  50. 1 0
      packages/core/src/models/project.ts
  51. 5 1
      packages/core/src/pipeline/chapter-persistence.ts
  52. 27 1
      packages/core/src/pipeline/chapter-truth-validation.ts
  53. 8 3
      packages/core/src/pipeline/runner.ts
  54. 9 1
      packages/core/src/state/manager.ts
  55. 1 1
      packages/studio/src/api/book-create.ts
  56. 1 1
      packages/studio/src/api/index.ts
  57. 77 47
      packages/studio/src/api/server.test.ts
  58. 546 78
      packages/studio/src/api/server.ts

+ 1 - 0
packages/cli/src/__tests__/tui-chat-draft.test.ts

@@ -13,6 +13,7 @@ function createSession(): InteractionSession {
     automationMode: "semi",
     messages: [],
     events: [],
+    draftRounds: [],
   };
 }
 

+ 1 - 0
packages/cli/src/__tests__/tui-dashboard.test.tsx

@@ -36,6 +36,7 @@ function createSession(): InteractionSession {
         detail: "Preparing chapter 12.",
       },
     ],
+    draftRounds: [],
   };
 }
 

+ 3 - 0
packages/cli/src/tui/app.ts

@@ -140,6 +140,9 @@ export async function launchTui(
       onChatTextDelta: (text) => {
         chatStreamBridge.onTextDelta?.(text);
       },
+      onDraftTextDelta: (text) => {
+        chatStreamBridge.onTextDelta?.(text);
+      },
     }));
   } catch (error) {
     const message = error instanceof Error ? error.message : String(error);

+ 11 - 6
packages/cli/src/tui/dashboard.tsx

@@ -105,13 +105,9 @@ export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element
         )}
       </Box>
 
-      {/* Status strip */}
+      {/* Composer area */}
       <Box flexDirection="column" marginTop={1}>
         <Text color={WARM_BORDER}>{thinRule}</Text>
-        <Box marginTop={1}>
-          <ExecutionBadge status={model.executionStatus} color={activeAccent} />
-          <Text color={activeAccent}> {model.statusPrimaryLine}</Text>
-        </Box>
 
         {/* Composer input */}
         <Box
@@ -159,6 +155,10 @@ export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element
             </Box>
           ) : null}
         </Box>
+        <Box marginTop={1}>
+          <ExecutionBadge status={model.executionStatus} color={activeAccent} />
+          <Text color={activeAccent}> {model.statusPrimaryLine}</Text>
+        </Box>
       </Box>
     </Box>
   );
@@ -358,7 +358,8 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
         hasFailed: session.currentExecution?.status === "failed",
       });
       const userTimestamp = Date.now();
-      const assistantDraftTimestamp = routed.intent === "chat" ? userTimestamp + 1 : null;
+      const assistantDraftTimestamp = (routed.intent === "chat" || routed.intent === "develop_book")
+        ? userTimestamp + 1 : null;
       assistantDraftTimestampRef.current = assistantDraftTimestamp;
       setActivityIntent(routed.intent);
       setIsSubmitting(true);
@@ -368,6 +369,10 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
       setHistoryState({ cursor: null, draft: "" });
       setSession((current) => createOptimisticUserMessageSession(current, input, userTimestamp));
 
+      if (routed.intent === "develop_book" && !session.creationDraft) {
+        appendSystemNote(copy.notes.newBookGuide);
+      }
+
       const result = await processProjectInteractionInput({
         projectRoot: props.projectRoot,
         input,

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

@@ -36,6 +36,7 @@ export interface TuiCopy {
     readonly status: (stage: string, mode: string) => string;
     readonly config: string;
     readonly depthSet: (depthLabel: string) => string;
+    readonly newBookGuide: string;
     readonly noLlmConfig: string;
     readonly setupProvider: string;
     readonly toolInitFailed: (message: string) => string;
@@ -93,16 +94,17 @@ const ZH_CN: TuiCopy = {
   composer: {
     placeholder: "告诉 InkOS 要写什么、修改什么,或解释什么…",
     emptyConversation: "先告诉 InkOS 你要做什么。",
-    helper: "回车发送 • /new • /draft • /create • /write • /books • /open • /mode • /depth • /help",
+    helper: "回车发送 • /new 输入你的想法,自动构建新书 • /draft • /create • /write • /books • /open • /mode • /depth • /help",
     submitting: "处理中…",
     failed: "上次请求失败",
     ready: "就绪",
   },
   notes: {
-    help: "可用命令:/new、/draft、/create、/discard、/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}。`,
+    newBookGuide: "开始构思新书。直接描述你的想法——题材、世界观、主角、核心冲突都可以。AI 会逐步引导你完善草案,随时用 /draft 查看进度,/create 建书。",
     noLlmConfig: "未发现 LLM 配置。",
     setupProvider: "先配置 API 提供方。",
     toolInitFailed: (message) => `初始化 TUI 工具失败:${message}`,
@@ -181,16 +183,17 @@ const EN: TuiCopy = {
   composer: {
     placeholder: "Ask InkOS to write, revise, or explain…",
     emptyConversation: "Start by asking InkOS what to do.",
-    helper: "Enter to send • /new • /draft • /create • /write • /books • /open • /mode • /depth • /help",
+    helper: "Enter to send • /new describe your idea to start a book • /draft • /create • /write • /books • /open • /mode • /depth • /help",
     submitting: "Submitting…",
     failed: "Last request failed",
     ready: "Ready",
   },
   notes: {
-    help: "Commands: /new, /draft, /create, /discard, /write, /books, /open, /mode, /rewrite, /focus, /truth, /rename, /replace, /export, /status, /clear, /depth, /quit. Natural language still works.",
+    help: "Commands: /new (describe your idea to start a book), /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}.`,
+    newBookGuide: "Starting a new book. Describe your idea — genre, world, protagonist, core conflict, anything. The AI will guide you step by step. Use /draft to check progress, /create to finalize.",
     noLlmConfig: "No LLM configuration found.",
     setupProvider: "Let's set up your API provider first.",
     toolInitFailed: (message) => `Failed to initialize TUI tools: ${message}`,

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

@@ -1,5 +1,5 @@
 export const SLASH_COMMANDS = [
-  "/new <idea>",
+  "/new 输入你的想法",
   "/draft",
   "/create",
   "/discard",

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

@@ -10,6 +10,7 @@ type CliPipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
 type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadBookConfig" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
 type CliInteractionToolHooks = {
   readonly onChatTextDelta?: (text: string) => void;
+  readonly onDraftTextDelta?: (text: string) => void;
   readonly getChatRequestOptions?: () => {
     readonly temperature?: number;
     readonly maxTokens?: number;

+ 3 - 2
packages/core/package.json

@@ -44,10 +44,11 @@
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {
-    "@anthropic-ai/sdk": "^0.78.0",
+    "@mariozechner/pi-agent-core": "^0.67.1",
+    "@mariozechner/pi-ai": "^0.67.1",
+    "@sinclair/typebox": "^0.34.49",
     "dotenv": "^16.4.0",
     "js-yaml": "^4.1.1",
-    "openai": "^4.80.0",
     "zod": "^3.24.0"
   },
   "devDependencies": {

+ 84 - 0
packages/core/src/__tests__/agent-system-prompt.test.ts

@@ -0,0 +1,84 @@
+import { describe, expect, it } from "vitest";
+import { buildAgentSystemPrompt } from "../agent/agent-system-prompt.js";
+
+describe("buildAgentSystemPrompt", () => {
+  describe("no book (creation flow)", () => {
+    it("Chinese prompt includes info collection workflow", () => {
+      const prompt = buildAgentSystemPrompt(null, "zh");
+      expect(prompt).toContain("建书助手");
+      expect(prompt).toContain("收集信息");
+      expect(prompt).toContain("题材");
+      expect(prompt).toContain("世界观");
+      expect(prompt).toContain("主角");
+      expect(prompt).toContain("核心冲突");
+      expect(prompt).toContain("architect");
+      expect(prompt).toContain("sub_agent");
+    });
+
+    it("English prompt includes info collection workflow", () => {
+      const prompt = buildAgentSystemPrompt(null, "en");
+      expect(prompt).toContain("book creation");
+      expect(prompt).toContain("architect");
+      expect(prompt).toContain("Genre");
+      expect(prompt).toContain("Protagonist");
+      expect(prompt).toContain("Core conflict");
+    });
+
+    it("Chinese prompt forbids emoji", () => {
+      const prompt = buildAgentSystemPrompt(null, "zh");
+      expect(prompt).toContain("不要在回复中添加表情符号");
+    });
+
+    it("English prompt forbids emoji", () => {
+      const prompt = buildAgentSystemPrompt(null, "en");
+      expect(prompt).toContain("Do NOT use emoji");
+    });
+
+    it("no-book prompt does NOT mention read/edit/grep/ls", () => {
+      const prompt = buildAgentSystemPrompt(null, "zh");
+      expect(prompt).not.toMatch(/\bread\b.*读取/);
+      expect(prompt).not.toContain("edit");
+    });
+  });
+
+  describe("with book (writing flow)", () => {
+    it("Chinese prompt includes all tools except architect", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      expect(prompt).toContain("my-book");
+      expect(prompt).toContain("sub_agent");
+      expect(prompt).toContain("writer");
+      expect(prompt).toContain("auditor");
+      expect(prompt).toContain("reviser");
+      expect(prompt).toContain("read");
+      expect(prompt).toContain("edit");
+      expect(prompt).toContain("grep");
+      expect(prompt).toContain("ls");
+    });
+
+    it("Chinese prompt warns NOT to call architect", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      expect(prompt).toContain("不要调用 architect");
+    });
+
+    it("English prompt warns NOT to call architect", () => {
+      const prompt = buildAgentSystemPrompt("novel", "en");
+      expect(prompt).toContain("Do NOT call architect");
+    });
+
+    it("Chinese with-book prompt forbids emoji", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      expect(prompt).toContain("不要在回复中添加表情符号");
+    });
+
+    it("English with-book prompt forbids emoji", () => {
+      const prompt = buildAgentSystemPrompt("novel", "en");
+      expect(prompt).toContain("Do NOT use emoji");
+    });
+
+    it("with-book prompt does NOT list architect as available", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      // architect 不在可用工具列表里
+      expect(prompt).not.toMatch(/agent="architect"/);
+    });
+  });
+});

+ 109 - 0
packages/core/src/__tests__/book-session-store.test.ts

@@ -0,0 +1,109 @@
+import { describe, expect, it, beforeEach, afterEach } from "vitest";
+import { mkdtemp, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import {
+  loadBookSession,
+  persistBookSession,
+  listBookSessions,
+  findOrCreateBookSession,
+} from "../interaction/book-session-store.js";
+import { createBookSession, appendBookSessionMessage } from "../interaction/session.js";
+
+describe("book-session-store", () => {
+  let tempDir: string;
+
+  beforeEach(async () => {
+    tempDir = await mkdtemp(join(tmpdir(), "inkos-test-"));
+  });
+
+  afterEach(async () => {
+    await rm(tempDir, { recursive: true, force: true });
+  });
+
+  describe("persistBookSession + loadBookSession", () => {
+    it("round-trips a session", async () => {
+      const session = createBookSession("my-book");
+      await persistBookSession(tempDir, session);
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded).not.toBeNull();
+      expect(loaded!.sessionId).toBe(session.sessionId);
+      expect(loaded!.bookId).toBe("my-book");
+    });
+
+    it("returns null for non-existent session", async () => {
+      const loaded = await loadBookSession(tempDir, "nonexistent");
+      expect(loaded).toBeNull();
+    });
+
+    it("persists messages", async () => {
+      let session = createBookSession("book");
+      session = appendBookSessionMessage(session, { role: "user" as const, content: "test", timestamp: 100 });
+      await persistBookSession(tempDir, session);
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded!.messages).toHaveLength(1);
+      expect(loaded!.messages[0].content).toBe("test");
+    });
+  });
+
+  describe("listBookSessions", () => {
+    it("returns empty for no sessions", async () => {
+      const list = await listBookSessions(tempDir, "no-book");
+      expect(list).toEqual([]);
+    });
+
+    it("filters by bookId", async () => {
+      const s1 = createBookSession("book-a");
+      const s2 = createBookSession("book-b");
+      const s3 = createBookSession("book-a");
+      await persistBookSession(tempDir, s1);
+      await persistBookSession(tempDir, s2);
+      await persistBookSession(tempDir, s3);
+
+      const listA = await listBookSessions(tempDir, "book-a");
+      expect(listA).toHaveLength(2);
+      expect(listA.every((s) => s.bookId === "book-a")).toBe(true);
+
+      const listB = await listBookSessions(tempDir, "book-b");
+      expect(listB).toHaveLength(1);
+    });
+
+    it("sorts by updatedAt descending", async () => {
+      const s1 = { ...createBookSession("book"), updatedAt: 100 };
+      const s2 = { ...createBookSession("book"), updatedAt: 300 };
+      const s3 = { ...createBookSession("book"), updatedAt: 200 };
+      await persistBookSession(tempDir, s1);
+      await persistBookSession(tempDir, s2);
+      await persistBookSession(tempDir, s3);
+
+      const list = await listBookSessions(tempDir, "book");
+      expect(list[0].updatedAt).toBe(300);
+      expect(list[1].updatedAt).toBe(200);
+      expect(list[2].updatedAt).toBe(100);
+    });
+
+    it("lists null bookId sessions", async () => {
+      const s = createBookSession(null);
+      await persistBookSession(tempDir, s);
+      const list = await listBookSessions(tempDir, null);
+      expect(list).toHaveLength(1);
+    });
+  });
+
+  describe("findOrCreateBookSession", () => {
+    it("creates new if none exist", async () => {
+      const session = await findOrCreateBookSession(tempDir, "new-book");
+      expect(session.bookId).toBe("new-book");
+      // Verify it was persisted
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded).not.toBeNull();
+    });
+
+    it("returns existing if found", async () => {
+      const existing = createBookSession("book");
+      await persistBookSession(tempDir, existing);
+      const found = await findOrCreateBookSession(tempDir, "book");
+      expect(found.sessionId).toBe(existing.sessionId);
+    });
+  });
+});

+ 108 - 0
packages/core/src/__tests__/book-session.test.ts

@@ -0,0 +1,108 @@
+import { describe, expect, it } from "vitest";
+import {
+  BookSessionSchema,
+  GlobalSessionSchema,
+  createBookSession,
+  appendBookSessionMessage,
+} from "../interaction/session.js";
+
+describe("BookSession", () => {
+  describe("BookSessionSchema", () => {
+    it("parses a valid session", () => {
+      const raw = {
+        sessionId: "123-abc",
+        bookId: "my-book",
+        messages: [],
+        draftRounds: [],
+        events: [],
+        createdAt: 1000,
+        updatedAt: 1000,
+      };
+      const result = BookSessionSchema.parse(raw);
+      expect(result.sessionId).toBe("123-abc");
+      expect(result.bookId).toBe("my-book");
+    });
+
+    it("accepts null bookId for draft sessions", () => {
+      const raw = {
+        sessionId: "123-abc",
+        bookId: null,
+        messages: [],
+        draftRounds: [],
+        events: [],
+        createdAt: 1000,
+        updatedAt: 1000,
+      };
+      const result = BookSessionSchema.parse(raw);
+      expect(result.bookId).toBeNull();
+    });
+
+    it("defaults empty arrays", () => {
+      const raw = {
+        sessionId: "123-abc",
+        bookId: null,
+        createdAt: 1000,
+        updatedAt: 1000,
+      };
+      const result = BookSessionSchema.parse(raw);
+      expect(result.messages).toEqual([]);
+      expect(result.draftRounds).toEqual([]);
+      expect(result.events).toEqual([]);
+    });
+  });
+
+  describe("GlobalSessionSchema", () => {
+    it("parses with defaults", () => {
+      const result = GlobalSessionSchema.parse({});
+      expect(result.automationMode).toBe("semi");
+      expect(result.activeBookId).toBeUndefined();
+    });
+
+    it("parses with values", () => {
+      const result = GlobalSessionSchema.parse({ activeBookId: "book-1", automationMode: "auto" });
+      expect(result.activeBookId).toBe("book-1");
+      expect(result.automationMode).toBe("auto");
+    });
+  });
+
+  describe("createBookSession", () => {
+    it("creates session with bookId", () => {
+      const session = createBookSession("my-book");
+      expect(session.bookId).toBe("my-book");
+      expect(session.sessionId).toBeTruthy();
+      expect(session.messages).toEqual([]);
+      expect(session.createdAt).toBeGreaterThan(0);
+      expect(session.updatedAt).toBe(session.createdAt);
+    });
+
+    it("creates session with null bookId", () => {
+      const session = createBookSession(null);
+      expect(session.bookId).toBeNull();
+    });
+
+    it("generates unique sessionIds", () => {
+      const a = createBookSession("book");
+      const b = createBookSession("book");
+      expect(a.sessionId).not.toBe(b.sessionId);
+    });
+  });
+
+  describe("appendBookSessionMessage", () => {
+    it("appends message and updates timestamp", () => {
+      const session = createBookSession("book");
+      const msg = { role: "user" as const, content: "hello", timestamp: Date.now() };
+      const updated = appendBookSessionMessage(session, msg);
+      expect(updated.messages).toHaveLength(1);
+      expect(updated.messages[0].content).toBe("hello");
+      expect(updated.updatedAt).toBeGreaterThanOrEqual(session.updatedAt);
+    });
+
+    it("sorts messages by timestamp", () => {
+      let session = createBookSession("book");
+      session = appendBookSessionMessage(session, { role: "user" as const, content: "second", timestamp: 200 });
+      session = appendBookSessionMessage(session, { role: "assistant" as const, content: "first", timestamp: 100 });
+      expect(session.messages[0].content).toBe("first");
+      expect(session.messages[1].content).toBe("second");
+    });
+  });
+});

+ 46 - 0
packages/core/src/__tests__/chapter-persistence.test.ts

@@ -149,4 +149,50 @@ describe("persistChapterArtifacts", () => {
     expect(snapshotState).not.toHaveBeenCalled();
     expect(syncCurrentStateFactHistory).not.toHaveBeenCalled();
   });
+
+  it("replaces existing entry for the same chapter number instead of appending", async () => {
+    const saveChapterIndex = vi.fn().mockResolvedValue(undefined);
+    const existingEntry: ChapterMeta = {
+      number: 1,
+      title: "Old Title",
+      status: "drafted",
+      wordCount: 500,
+      createdAt: "2026-01-01T00:00:00.000Z",
+      updatedAt: "2026-01-01T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    };
+
+    await persistChapterArtifacts({
+      chapterNumber: 1,
+      chapterTitle: "New Title",
+      status: "ready-for-review",
+      auditResult: createAuditResult(),
+      finalWordCount: 2000,
+      lengthWarnings: [],
+      degradedIssues: [],
+      tokenUsage: ZERO_USAGE,
+      loadChapterIndex: async () => [existingEntry],
+      saveChapter: vi.fn().mockResolvedValue(undefined),
+      saveTruthFiles: vi.fn().mockResolvedValue(undefined),
+      saveChapterIndex,
+      markBookActiveIfNeeded: vi.fn().mockResolvedValue(undefined),
+      persistAuditDriftGuidance: vi.fn().mockResolvedValue(undefined),
+      snapshotState: vi.fn().mockResolvedValue(undefined),
+      syncCurrentStateFactHistory: vi.fn().mockResolvedValue(undefined),
+      logSnapshotStage: vi.fn(),
+      now: () => "2026-04-01T00:00:00.000Z",
+    });
+
+    const savedIndex = saveChapterIndex.mock.calls[0][0] as ChapterMeta[];
+    // Must have exactly 1 entry, not 2
+    expect(savedIndex).toHaveLength(1);
+    expect(savedIndex[0].number).toBe(1);
+    expect(savedIndex[0].title).toBe("New Title");
+    expect(savedIndex[0].wordCount).toBe(2000);
+    expect(savedIndex[0].status).toBe("ready-for-review");
+    // Must preserve original createdAt
+    expect(savedIndex[0].createdAt).toBe("2026-01-01T00:00:00.000Z");
+    expect(savedIndex[0].updatedAt).toBe("2026-04-01T00:00:00.000Z");
+  });
 });

+ 48 - 0
packages/core/src/__tests__/chapter-truth-validation.test.ts

@@ -125,6 +125,54 @@ describe("validateChapterTruthPersistence", () => {
     expect(logger.warn).toHaveBeenCalledWith("  [unsupported_change] 正文写铜牌在怀里,但 state 说未携带。");
   });
 
+  it("degrades gracefully when validator throws (e.g. LLM returned empty response)", async () => {
+    const validator = {
+      validate: vi.fn().mockRejectedValue(new Error("LLM returned empty response")),
+    };
+    const writer = {
+      settleChapterState: vi.fn(),
+    };
+    const logWarn = vi.fn();
+    const logger = { warn: vi.fn() };
+
+    const result = await validateChapterTruthPersistence({
+      writer,
+      validator,
+      book: BOOK,
+      bookDir: "/tmp/book",
+      chapterNumber: 1,
+      title: "Test Chapter",
+      content: "Chapter content.",
+      persistenceOutput: createWriterOutput({
+        updatedState: "new state",
+        updatedHooks: "new hooks",
+        updatedLedger: "new ledger",
+      }),
+      auditResult: createAuditResult(),
+      previousTruth: {
+        oldState: "old state",
+        oldHooks: "old hooks",
+        oldLedger: "old ledger",
+      },
+      language: "zh",
+      logWarn,
+      logger,
+    });
+
+    expect(result.chapterStatus).toBe("state-degraded");
+    expect(result.persistenceOutput.updatedState).toBe("old state");
+    expect(result.persistenceOutput.updatedHooks).toBe("old hooks");
+    expect(result.persistenceOutput.updatedLedger).toBe("old ledger");
+    expect(result.degradedIssues).toEqual([
+      expect.objectContaining({
+        severity: "warning",
+        category: "state-validation",
+      }),
+    ]);
+    // Should NOT have attempted settlement retry
+    expect(writer.settleChapterState).not.toHaveBeenCalled();
+  });
+
   it("degrades persistence output and appends audit issues when retry still fails", async () => {
     const validator = {
       validate: vi.fn()

+ 102 - 0
packages/core/src/__tests__/config-migration.test.ts

@@ -0,0 +1,102 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { migrateConfig } from "../llm/config-migration.js";
+import { loadSecrets } from "../llm/secrets.js";
+import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+describe("config migration", () => {
+  let root: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-migrate-"));
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("migrates old llm.provider+model+apiKey to services[] + secrets", async () => {
+    const oldConfig = {
+      name: "mybook",
+      llm: {
+        provider: "openai",
+        model: "kimi-k2.5",
+        baseUrl: "https://api.moonshot.cn/v1",
+        apiKey: "sk-old-key",
+      },
+      language: "zh",
+    };
+    await writeFile(join(root, "inkos.json"), JSON.stringify(oldConfig));
+
+    const result = await migrateConfig(root);
+
+    expect(result.migrated).toBe(true);
+
+    const raw = await readFile(join(root, "inkos.json"), "utf-8");
+    const config = JSON.parse(raw);
+    expect(config.llm.services).toHaveLength(1);
+    expect(config.llm.services[0].service).toBe("moonshot");
+    expect(config.llm.services[0].apiKey).toBeUndefined();
+    expect(config.llm.defaultModel).toBe("kimi-k2.5");
+    expect(config.llm.provider).toBeUndefined();
+    expect(config.llm.model).toBeUndefined();
+    expect(config.llm.apiKey).toBeUndefined();
+
+    const secrets = await loadSecrets(root);
+    expect(secrets.services.moonshot.apiKey).toBe("sk-old-key");
+  });
+
+  it("does nothing if already in new format", async () => {
+    const newConfig = {
+      name: "mybook",
+      llm: {
+        services: [{ service: "moonshot" }],
+        defaultModel: "kimi-k2.5",
+      },
+      language: "zh",
+    };
+    await writeFile(join(root, "inkos.json"), JSON.stringify(newConfig));
+
+    const result = await migrateConfig(root);
+    expect(result.migrated).toBe(false);
+  });
+
+  it("guesses service from baseUrl", async () => {
+    const oldConfig = {
+      llm: {
+        provider: "openai",
+        model: "deepseek-chat",
+        baseUrl: "https://api.deepseek.com/v1",
+        apiKey: "sk-deep",
+      },
+    };
+    await writeFile(join(root, "inkos.json"), JSON.stringify(oldConfig));
+
+    await migrateConfig(root);
+
+    const raw = await readFile(join(root, "inkos.json"), "utf-8");
+    const config = JSON.parse(raw);
+    expect(config.llm.services[0].service).toBe("deepseek");
+  });
+
+  it("creates custom service when baseUrl is unrecognized", async () => {
+    const oldConfig = {
+      llm: {
+        provider: "openai",
+        model: "my-model",
+        baseUrl: "https://llm.internal.corp/v1",
+        apiKey: "sk-corp",
+      },
+    };
+    await writeFile(join(root, "inkos.json"), JSON.stringify(oldConfig));
+
+    await migrateConfig(root);
+
+    const raw = await readFile(join(root, "inkos.json"), "utf-8");
+    const config = JSON.parse(raw);
+    expect(config.llm.services[0].service).toBe("custom");
+    expect(config.llm.services[0].baseUrl).toBe("https://llm.internal.corp/v1");
+    expect(config.llm.services[0].name).toBe("Custom");
+  });
+});

+ 386 - 0
packages/core/src/__tests__/draft-directive-parser.test.ts

@@ -0,0 +1,386 @@
+import { describe, it, expect } from "vitest";
+import {
+  parseDraftDirectives,
+  createDirectiveStreamFilter,
+} from "../interaction/draft-directive-parser.js";
+
+// ---------------------------------------------------------------------------
+// 1. Pure markdown — no directives
+// ---------------------------------------------------------------------------
+
+describe("parseDraftDirectives", () => {
+  it("returns empty fields and full text when input has no directives", () => {
+    const raw = "# 欢迎\n\n这是一段普通的 markdown,没有任何表单标记。";
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields).toEqual({});
+    expect(result.textContent).toBe(raw);
+    expect(result.summary).toBe("");
+    expect(result.raw).toBe(raw);
+  });
+
+  // ---------------------------------------------------------------------------
+  // 2. Single :::field extraction
+  // ---------------------------------------------------------------------------
+
+  it("extracts a single :::field block", () => {
+    const raw = [
+      "请为你的小说起一个名字:",
+      "",
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      "",
+      "很好的名字!",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields["title"]).toBe("星河彼岸");
+    expect(result.textContent).toBe(
+      ["请为你的小说起一个名字:", "", "", "很好的名字!"].join("\n"),
+    );
+    expect(result.raw).toBe(raw);
+  });
+
+  // ---------------------------------------------------------------------------
+  // 3. Multiple fields of different types
+  // ---------------------------------------------------------------------------
+
+  it("extracts multiple fields of different types", () => {
+    const raw = [
+      "以下是你的创作信息:",
+      "",
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      "",
+      ':::field{key="worldPremise" label="世界观" type="textarea"}',
+      "一个被星际战争撕裂的宇宙",
+      ":::",
+      "",
+      ':::pick{key="platform" label="目标平台"}',
+      "- 起点中文网",
+      "- 番茄小说",
+      "- 七猫",
+      ":::",
+      "",
+      ':::number{key="targetChapters" label="目标章数"}',
+      "300",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields["title"]).toBe("星河彼岸");
+    expect(result.fields["worldPremise"]).toBe("一个被星际战争撕裂的宇宙");
+    expect(result.fields["platform"]).toBe("起点中文网");
+    expect(result.fields["targetChapters"]).toBe("300");
+  });
+
+  // ---------------------------------------------------------------------------
+  // 4. Nested :::group containing multiple fields
+  // ---------------------------------------------------------------------------
+
+  it("extracts fields nested inside a :::group", () => {
+    const raw = [
+      "请确认篇幅设置:",
+      "",
+      ':::group{label="篇幅"}',
+      ':::number{key="targetChapters" label="目标章数"}',
+      "300",
+      ":::",
+      ':::number{key="chapterLength" label="每章字数"}',
+      "3000",
+      ":::",
+      ":::",
+      "",
+      "确认无误!",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields["targetChapters"]).toBe("300");
+    expect(result.fields["chapterLength"]).toBe("3000");
+    // group itself should not appear in textContent
+    expect(result.textContent).toBe(
+      ["请确认篇幅设置:", "", "", "确认无误!"].join("\n"),
+    );
+  });
+
+  // ---------------------------------------------------------------------------
+  // 5. Mixed content: markdown paragraphs interspersed with directives
+  // ---------------------------------------------------------------------------
+
+  it("handles mixed markdown and directives", () => {
+    const raw = [
+      "# 创建新书",
+      "",
+      "让我们开始吧。首先需要一个书名:",
+      "",
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      "",
+      "好的!接下来设定你的世界观:",
+      "",
+      ':::field{key="worldPremise" label="世界观" type="textarea"}',
+      "宇宙分裂为光暗两域",
+      ":::",
+      "",
+      "让我们继续完善细节。",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields["title"]).toBe("星河彼岸");
+    expect(result.fields["worldPremise"]).toBe("宇宙分裂为光暗两域");
+    expect(result.textContent).toContain("# 创建新书");
+    expect(result.textContent).toContain("让我们开始吧。首先需要一个书名:");
+    expect(result.textContent).toContain("好的!接下来设定你的世界观:");
+    expect(result.textContent).toContain("让我们继续完善细节。");
+    expect(result.textContent).not.toContain(":::field");
+    expect(result.textContent).not.toContain("星河彼岸");
+  });
+
+  // ---------------------------------------------------------------------------
+  // 6. :::pick extracts first option as default value
+  // ---------------------------------------------------------------------------
+
+  it("extracts first option from :::pick as default value", () => {
+    const raw = [
+      ':::pick{key="genre" label="题材"}',
+      "- 玄幻",
+      "- 仙侠",
+      "- 都市",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["genre"]).toBe("玄幻");
+  });
+
+  it("handles :::pick with no options gracefully", () => {
+    const raw = [
+      ':::pick{key="genre" label="题材"}',
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["genre"]).toBe("");
+  });
+
+  // ---------------------------------------------------------------------------
+  // 7. Summary generation from field labels
+  // ---------------------------------------------------------------------------
+
+  it("generates summary from field labels", () => {
+    const raw = [
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      ':::field{key="worldPremise" label="世界观"}',
+      "一个宇宙",
+      ":::",
+      ':::field{key="protagonist" label="主角"}',
+      "陈风",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.summary).toBe("确立了书名、世界观和主角");
+  });
+
+  it("generates summary with single field", () => {
+    const raw = [
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.summary).toBe("确立了书名");
+  });
+
+  it("generates summary with two fields", () => {
+    const raw = [
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      ':::field{key="worldPremise" label="世界观"}',
+      "一个宇宙",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.summary).toBe("确立了书名和世界观");
+  });
+
+  // ---------------------------------------------------------------------------
+  // 8. Edge case: ::: in code blocks should NOT be parsed as directives
+  // ---------------------------------------------------------------------------
+
+  it("does not parse ::: inside fenced code blocks", () => {
+    const raw = [
+      "下面是一个示例:",
+      "",
+      "```markdown",
+      ':::field{key="demo" label="示例"}',
+      "这不是真正的字段",
+      ":::",
+      "```",
+      "",
+      "以上只是演示。",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields).toEqual({});
+    expect(result.textContent).toBe(raw);
+  });
+
+  it("does not parse ::: inside indented code blocks with backtick fences", () => {
+    const raw = [
+      "示例代码:",
+      "",
+      "````",
+      ':::field{key="demo" label="示例"}',
+      "不是字段",
+      ":::",
+      "````",
+      "",
+      "结束。",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields).toEqual({});
+    expect(result.textContent).toBe(raw);
+  });
+
+  // ---------------------------------------------------------------------------
+  // Multi-line field value
+  // ---------------------------------------------------------------------------
+
+  it("extracts multi-line field value from textarea type", () => {
+    const raw = [
+      ':::field{key="outline" label="大纲" type="textarea"}',
+      "第一卷:起源",
+      "第二卷:征途",
+      "第三卷:终局",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["outline"]).toBe(
+      "第一卷:起源\n第二卷:征途\n第三卷:终局",
+    );
+  });
+
+  // ---------------------------------------------------------------------------
+  // group label appears in summary
+  // ---------------------------------------------------------------------------
+
+  it("does not include group labels in summary (only leaf fields)", () => {
+    const raw = [
+      ':::group{label="篇幅设置"}',
+      ':::number{key="chapterCount" label="总章数"}',
+      "200",
+      ":::",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    // summary should mention "总章数", not "篇幅设置"
+    expect(result.summary).toBe("确立了总章数");
+  });
+
+  // ---------------------------------------------------------------------------
+  // Attribute parsing edge cases
+  // ---------------------------------------------------------------------------
+
+  it("handles single-quoted attributes", () => {
+    const raw = [
+      ":::field{key='title' label='书名'}",
+      "星河彼岸",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["title"]).toBe("星河彼岸");
+  });
+
+  it("handles attributes with extra spaces", () => {
+    const raw = [
+      ':::field{ key="title"  label="书名" }',
+      "星河彼岸",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["title"]).toBe("星河彼岸");
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Streaming filter
+// ---------------------------------------------------------------------------
+
+describe("createDirectiveStreamFilter", () => {
+  it("passes through pure text unchanged", () => {
+    const filter = createDirectiveStreamFilter();
+    expect(filter("你好世界")).toBe("你好世界");
+    expect(filter("第二段文字")).toBe("第二段文字");
+  });
+
+  it("filters out a complete directive block arriving in one chunk", () => {
+    const filter = createDirectiveStreamFilter();
+    const chunk = ':::field{key="title" label="书名"}\n星河彼岸\n:::\n';
+    expect(filter(chunk)).toBe("");
+  });
+
+  it("filters directive blocks arriving across multiple chunks", () => {
+    const filter = createDirectiveStreamFilter();
+
+    const out1 = filter("欢迎!\n");
+    expect(out1).toBe("欢迎!\n");
+
+    // directive opening arrives
+    const out2 = filter(':::field{key="title" label="书名"}\n');
+    expect(out2).toBe("");
+
+    // content inside directive
+    const out3 = filter("星河彼岸\n");
+    expect(out3).toBe("");
+
+    // directive close
+    const out4 = filter(":::\n");
+    expect(out4).toBe("");
+
+    // back to normal text
+    const out5 = filter("继续对话。\n");
+    expect(out5).toBe("继续对话。\n");
+  });
+
+  it("handles nested group directives in stream", () => {
+    const filter = createDirectiveStreamFilter();
+
+    expect(filter("开始\n")).toBe("开始\n");
+    expect(filter(':::group{label="篇幅"}\n')).toBe("");
+    expect(filter(':::number{key="ch" label="章数"}\n')).toBe("");
+    expect(filter("300\n")).toBe("");
+    expect(filter(":::\n")).toBe(""); // closes number
+    expect(filter(":::\n")).toBe(""); // closes group
+    expect(filter("结束\n")).toBe("结束\n");
+  });
+
+  it("does not filter ::: inside code blocks during streaming", () => {
+    const filter = createDirectiveStreamFilter();
+
+    expect(filter("```\n")).toBe("```\n");
+    expect(filter(':::field{key="x" label="y"}\n')).toBe(
+      ':::field{key="x" label="y"}\n',
+    );
+    expect(filter(":::\n")).toBe(":::\n");
+    expect(filter("```\n")).toBe("```\n");
+  });
+});

+ 69 - 12
packages/core/src/__tests__/interaction-chat-tokens.test.ts

@@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
 import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
 
 const mockChatCompletion = vi.hoisted(() => vi.fn());
+const mockChatWithTools = vi.hoisted(() => vi.fn());
 
 vi.mock("../index.js", async (importOriginal) => {
   const actual = await importOriginal<Record<string, unknown>>();
-  return { ...actual, chatCompletion: mockChatCompletion };
+  return { ...actual, chatCompletion: mockChatCompletion, chatWithTools: mockChatWithTools };
 });
 
 const fakePipeline = {
@@ -26,14 +27,28 @@ const fakeState = {
   listBooks: vi.fn(async () => []),
 };
 
-const MOCK_RESPONSE = {
-  content: JSON.stringify({
-    assistantReply: "好的,你想写都市异能,请问主角是什么类型的能力?",
-    draft: { concept: "都市异能", missingFields: ["title", "genre"], readyToCreate: false },
-  }),
+const MOCK_CHAT_RESPONSE = {
+  content: [
+    "好的,你想写都市异能,请问主角是什么类型的能力?",
+    "",
+    ':::field{key="title" label="书名"}',
+    "都市异能",
+    ":::",
+  ].join("\n"),
   tokensUsed: { prompt: 5, completion: 80, total: 85 },
 };
 
+const MOCK_TOOL_RESPONSE = {
+  content: "好的,已根据你的描述生成建书参数。",
+  toolCalls: [
+    {
+      id: "call_1",
+      name: "create_book",
+      arguments: JSON.stringify({ title: "都市异能", genre: "urban", platform: "tomato", brief: "都市异能题材" }),
+    },
+  ],
+};
+
 describe("chat tool – maxTokens forwarding", () => {
   beforeEach(() => {
     mockChatCompletion.mockResolvedValue({
@@ -92,13 +107,13 @@ describe("chat tool – maxTokens forwarding", () => {
   });
 });
 
-describe("developBookDraft – maxTokens not capped", () => {
+describe("developBookDraft – uses chatWithTools", () => {
   beforeEach(() => {
-    mockChatCompletion.mockResolvedValue(MOCK_RESPONSE);
-    mockChatCompletion.mockClear();
+    mockChatWithTools.mockResolvedValue(MOCK_TOOL_RESPONSE);
+    mockChatWithTools.mockClear();
   });
 
-  it("does not pass maxTokens to chatCompletion so thinking models are not truncated", async () => {
+  it("calls chatWithTools with create_book tool and does not pass maxTokens", async () => {
     const tools = createInteractionToolsFromDeps(
       fakePipeline as never,
       fakeState as never,
@@ -106,8 +121,50 @@ describe("developBookDraft – maxTokens not capped", () => {
 
     await tools.developBookDraft?.("我想写都市异能", undefined);
 
-    expect(mockChatCompletion).toHaveBeenCalledOnce();
-    const options = mockChatCompletion.mock.calls[0]?.[3] as Record<string, unknown> | undefined;
+    expect(mockChatWithTools).toHaveBeenCalledOnce();
+    const options = mockChatWithTools.mock.calls[0]?.[4] as Record<string, unknown> | undefined;
     expect(options).not.toHaveProperty("maxTokens");
   });
+
+  it("extracts tool call arguments into the creation draft", async () => {
+    const tools = createInteractionToolsFromDeps(
+      fakePipeline as never,
+      fakeState as never,
+    );
+
+    const result = await tools.developBookDraft?.("我想写都市异能", undefined) as Record<string, unknown>;
+    const interaction = (result as { __interaction: Record<string, unknown> }).__interaction;
+    const details = interaction.details as Record<string, unknown>;
+
+    expect(details.creationDraft).toEqual(expect.objectContaining({
+      title: "都市异能",
+      genre: "urban",
+      platform: "tomato",
+      blurb: "都市异能题材",
+      readyToCreate: true,
+    }));
+    expect(details.toolCall).toEqual({
+      name: "create_book",
+      arguments: { title: "都市异能", genre: "urban", platform: "tomato", brief: "都市异能题材" },
+    });
+  });
+
+  it("returns fallback when no LLM is configured", async () => {
+    const noLlmPipeline = {
+      config: {},
+      writeNextChapter: vi.fn(),
+      reviseDraft: vi.fn(),
+    };
+
+    const tools = createInteractionToolsFromDeps(
+      noLlmPipeline as never,
+      fakeState as never,
+    );
+
+    const result = await tools.developBookDraft?.("我想写都市异能", undefined) as Record<string, unknown>;
+    const interaction = (result as { __interaction: Record<string, unknown> }).__interaction;
+
+    expect(mockChatWithTools).not.toHaveBeenCalled();
+    expect(interaction.responseText).toContain("请先配置 LLM 模型");
+  });
 });

+ 10 - 5
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -247,6 +247,7 @@ describe("PipelineRunner", () => {
         projectRoot: process.cwd(),
         defaultLLMConfig: {
           provider: "custom",
+          service: "custom",
           baseUrl: "https://base.example/v1",
           apiKey: "base-key",
           model: "base-model",
@@ -895,6 +896,7 @@ describe("PipelineRunner", () => {
       const result = await runner.writeDraft(bookId);
 
       expect(result.chapterNumber).toBe(1);
+      console.log("DEBUG warnings:", JSON.stringify(warnings, null, 2));
       expect(warnings).toContain(
         "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。",
       );
@@ -2279,11 +2281,10 @@ describe("PipelineRunner", () => {
     await rm(root, { recursive: true, force: true });
   });
 
-  it("does not persist chapter files or index entries when state validation errors before save", async () => {
+  it("degrades to state-degraded when state validation errors instead of aborting", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture({
       inputGovernanceMode: "legacy",
     });
-    const chaptersDir = join(state.bookDir(bookId), "chapters");
 
     vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
       createWriterOutput({
@@ -2302,9 +2303,13 @@ describe("PipelineRunner", () => {
       new Error("LLM returned empty response"),
     );
 
-    await expect(runner.writeNextChapter(bookId)).rejects.toThrow("LLM returned empty response");
-    await expect(readdir(chaptersDir)).resolves.toEqual([]);
-    await expect(state.loadChapterIndex(bookId)).resolves.toEqual([]);
+    const result = await runner.writeNextChapter(bookId);
+    expect(result.status).toBe("state-degraded");
+
+    // Chapter should be saved (content is fine, only truth files are degraded)
+    const index = await state.loadChapterIndex(bookId);
+    expect(index).toHaveLength(1);
+    expect(index[0]!.status).toBe("state-degraded");
 
     await rm(root, { recursive: true, force: true });
   });

+ 229 - 150
packages/core/src/__tests__/provider.test.ts

@@ -1,16 +1,125 @@
 import { beforeEach, describe, expect, it, vi } from "vitest";
-import type OpenAI from "openai";
+import type { AssistantMessage, Model, Api } from "@mariozechner/pi-ai";
 import {
   __resetFixedTemperatureWarnings,
   chatCompletion,
   type LLMClient,
 } from "../llm/provider.js";
 
-const ZERO_USAGE = {
-  prompt_tokens: 11,
-  completion_tokens: 7,
-  total_tokens: 18,
-} as const;
+// ── Mock @mariozechner/pi-ai ──────────────────────────────────────────────────
+// We intercept streamSimple so tests don't hit the network.
+
+const mockStreamSimple = vi.fn();
+
+vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
+  const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
+  return {
+    ...original,
+    streamSimple: (...args: unknown[]) => mockStreamSimple(...args),
+  };
+});
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+const MOCK_USAGE = {
+  input: 11,
+  output: 7,
+  cacheRead: 0,
+  cacheWrite: 0,
+  totalTokens: 18,
+  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+};
+
+function makeAssistantMessage(text: string): AssistantMessage {
+  return {
+    role: "assistant",
+    content: [{ type: "text", text }],
+    api: "openai-completions" as Api,
+    provider: "openai",
+    model: "test-model",
+    usage: MOCK_USAGE,
+    stopReason: "stop",
+    timestamp: Date.now(),
+  };
+}
+
+/** Builds an async iterable that emits the given events. */
+function makeEventStream(
+  events: Array<Record<string, unknown>>,
+): AsyncIterable<Record<string, unknown>> {
+  return {
+    [Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
+      let i = 0;
+      return {
+        async next() {
+          if (i < events.length) return { value: events[i++]!, done: false };
+          return { value: undefined as unknown as Record<string, unknown>, done: true };
+        },
+      };
+    },
+  };
+}
+
+/** Stream that emits one text_delta and then done. */
+function makeTextStream(text: string): AsyncIterable<Record<string, unknown>> {
+  const msg = makeAssistantMessage(text);
+  return makeEventStream([
+    { type: "text_delta", contentIndex: 0, delta: text, partial: msg },
+    { type: "done", reason: "stop", message: msg },
+  ]);
+}
+
+/** Stream that emits only done with empty content. */
+function makeEmptyStream(): AsyncIterable<Record<string, unknown>> {
+  const msg = makeAssistantMessage("");
+  return makeEventStream([
+    { type: "done", reason: "stop", message: msg },
+  ]);
+}
+
+/** Stream that throws immediately. */
+function makeErrorStream(message: string): AsyncIterable<Record<string, unknown>> {
+  return {
+    [Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
+      return {
+        async next() {
+          throw new Error(message);
+        },
+      };
+    },
+  };
+}
+
+const MOCK_PI_MODEL: Model<Api> = {
+  id: "test-model",
+  name: "test-model",
+  api: "openai-completions",
+  provider: "openai",
+  baseUrl: "https://api.openai.com/v1",
+  reasoning: false,
+  input: ["text"],
+  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+  contextWindow: 128000,
+  maxTokens: 8192,
+};
+
+function makeClient(temperature = 0.7, extra: Partial<LLMClient> = {}): LLMClient {
+  return {
+    provider: "openai",
+    apiFormat: "chat",
+    stream: true,
+    _piModel: MOCK_PI_MODEL,
+    _apiKey: "test-key",
+    defaults: {
+      temperature,
+      maxTokens: 512,
+      thinkingBudget: 0,
+      maxTokensCap: null,
+      extra: {},
+    },
+    ...extra,
+  };
+}
 
 async function captureError(task: Promise<unknown>): Promise<Error> {
   try {
@@ -21,169 +130,139 @@ async function captureError(task: Promise<unknown>): Promise<Error> {
   throw new Error("Expected promise to reject");
 }
 
-describe("chatCompletion stream fallback", () => {
-  it("falls back to sync chat completion when streamed chat returns no chunks", async () => {
-    const create = vi.fn()
-      .mockResolvedValueOnce({
-        async *[Symbol.asyncIterator](): AsyncIterableIterator<unknown> {
-          return;
-        },
-      })
-      .mockResolvedValueOnce({
-        choices: [{ message: { content: "fallback content" } }],
-        usage: ZERO_USAGE,
-      });
-
-    const client: LLMClient = {
-      provider: "openai",
-      apiFormat: "chat",
-      stream: true,
-      _openai: {
-        chat: {
-          completions: {
-            create,
-          },
-        },
-      } as unknown as OpenAI,
-      defaults: {
-        temperature: 0.7,
-        maxTokens: 512,
-        thinkingBudget: 0, maxTokensCap: null,
-        extra: {},
-      },
-    };
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("chatCompletion via pi-ai", () => {
+  beforeEach(() => {
+    mockStreamSimple.mockReset();
+  });
 
+  it("returns text content from a successful stream", async () => {
+    mockStreamSimple.mockReturnValue(makeTextStream("hello world"));
+
+    const client = makeClient();
     const result = await chatCompletion(client, "test-model", [
       { role: "user", content: "ping" },
     ]);
 
-    expect(result.content).toBe("fallback content");
-    expect(result.usage).toEqual({
-      promptTokens: 11,
-      completionTokens: 7,
-      totalTokens: 18,
-    });
-    expect(create).toHaveBeenCalledTimes(2);
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ stream: true });
-    expect(create.mock.calls[1]?.[0]).toMatchObject({ stream: false });
-  });
-
-  it("does not blindly suggest stream false for generic 400 errors", async () => {
-    const create = vi.fn().mockRejectedValue(new Error("400 Bad Request"));
-
-    const client: LLMClient = {
-      provider: "openai",
-      apiFormat: "chat",
-      stream: false,
-      _openai: {
-        chat: {
-          completions: {
-            create,
-          },
-        },
-      } as unknown as OpenAI,
-      defaults: {
-        temperature: 0.7,
-        maxTokens: 512,
-        thinkingBudget: 0, maxTokensCap: null,
-        extra: {},
-      },
-    };
-
-    const error = await captureError(chatCompletion(client, "test-model", [
-      { role: "user", content: "ping" },
-    ]));
+    expect(result.content).toBe("hello world");
+    expect(result.usage.promptTokens).toBe(11);
+    expect(result.usage.completionTokens).toBe(7);
+    expect(result.usage.totalTokens).toBe(18);
+    expect(mockStreamSimple).toHaveBeenCalledOnce();
+  });
+
+  it("throws when stream produces no text content", async () => {
+    mockStreamSimple.mockReturnValue(makeEmptyStream());
+
+    const client = makeClient();
+    const error = await captureError(
+      chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
+    );
+
+    expect(error.message).toContain("empty response");
+  });
+
+  it("wraps 400 API errors with a user-friendly message", async () => {
+    mockStreamSimple.mockReturnValue(makeErrorStream("400 Bad Request"));
+
+    const client = makeClient();
+    const error = await captureError(
+      chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
+    );
 
     expect(error.message).toContain("API 返回 400");
-    expect(error.message).not.toContain("\"stream\": false");
     expect(error.message).toContain("检查提供方文档");
   });
 
-  it("reports when sync fallback is rejected because provider requires streaming", async () => {
-    const create = vi.fn()
-      .mockResolvedValueOnce({
-        async *[Symbol.asyncIterator](): AsyncIterableIterator<unknown> {
-          return;
-        },
-      })
-      .mockRejectedValueOnce(new Error("400 {\"detail\":\"Stream must be set to true\"}"));
-
-    const client: LLMClient = {
-      provider: "openai",
-      apiFormat: "chat",
-      stream: true,
-      _openai: {
-        chat: {
-          completions: {
-            create,
-          },
-        },
-      } as unknown as OpenAI,
-      defaults: {
-        temperature: 0.7,
-        maxTokens: 512,
-        thinkingBudget: 0, maxTokensCap: null,
-        extra: {},
-      },
-    };
-
-    const error = await captureError(chatCompletion(client, "test-model", [
-      { role: "user", content: "ping" },
+  it("wraps 401 errors with an unauthorized message", async () => {
+    mockStreamSimple.mockReturnValue(makeErrorStream("401 Unauthorized"));
+
+    const client = makeClient();
+    const error = await captureError(
+      chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
+    );
+
+    expect(error.message).toContain("API 返回 401");
+  });
+
+  it("wraps connection errors with a friendly message", async () => {
+    mockStreamSimple.mockReturnValue(makeErrorStream("fetch failed: ECONNREFUSED"));
+
+    const client = makeClient();
+    const error = await captureError(
+      chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
+    );
+
+    expect(error.message).toContain("无法连接到 API 服务");
+  });
+
+  it("passes temperature and maxTokens to streamSimple", async () => {
+    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
+
+    const client = makeClient(0.5);
+    await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }], {
+      temperature: 0.3,
+      maxTokens: 256,
+    });
+
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(0.3);
+    expect(opts.maxTokens).toBe(256);
+  });
+
+  it("uses client defaults when no per-call overrides are provided", async () => {
+    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
+
+    const client = makeClient(0.8);
+    await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }]);
+
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(0.8);
+    expect(opts.maxTokens).toBe(512);
+  });
+
+  it("calls onTextDelta for each text chunk", async () => {
+    const msg = makeAssistantMessage("abc");
+    mockStreamSimple.mockReturnValue(makeEventStream([
+      { type: "text_delta", contentIndex: 0, delta: "a", partial: msg },
+      { type: "text_delta", contentIndex: 0, delta: "b", partial: msg },
+      { type: "text_delta", contentIndex: 0, delta: "c", partial: msg },
+      { type: "done", reason: "stop", message: msg },
     ]));
 
-    expect(create).toHaveBeenCalledTimes(2);
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ stream: true });
-    expect(create.mock.calls[1]?.[0]).toMatchObject({ stream: false });
-    expect(error.message).toContain("stream:true");
-    expect(error.message).not.toContain("\"stream\": false");
+    const deltas: string[] = [];
+    const client = makeClient();
+    await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }], {
+      onTextDelta: (d) => deltas.push(d),
+    });
+
+    expect(deltas).toEqual(["a", "b", "c"]);
   });
 });
 
 describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
   beforeEach(() => {
     __resetFixedTemperatureWarnings();
+    mockStreamSimple.mockReset();
+    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
   });
 
-  function makeSyncClient(create: ReturnType<typeof vi.fn>, temperature: number): LLMClient {
-    return {
-      provider: "openai",
-      apiFormat: "chat",
-      stream: false,
-      _openai: {
-        chat: { completions: { create } },
-      } as unknown as OpenAI,
-      defaults: {
-        temperature,
-        maxTokens: 512,
-        thinkingBudget: 0,
-        maxTokensCap: null,
-        extra: {},
-      },
-    };
-  }
-
-  const OK_RESPONSE = {
-    choices: [{ message: { content: "ok" } }],
-    usage: ZERO_USAGE,
-  };
-
   it("forces temperature=1 for kimi-k2.5 even when client default is 0.7", async () => {
-    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
-    const client = makeSyncClient(create, 0.7);
+    const client = makeClient(0.7);
     const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
 
     await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
 
-    expect(create).toHaveBeenCalledTimes(1);
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(1);
     expect(warn).toHaveBeenCalledOnce();
     expect(warn.mock.calls[0]?.[0]).toContain("kimi-k2.5");
     warn.mockRestore();
   });
 
   it("clamps per-call temperature override (0.3) to 1 for kimi-k2.5", async () => {
-    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
-    const client = makeSyncClient(create, 0.7);
+    const client = makeClient(0.7);
     vi.spyOn(console, "warn").mockImplementation(() => {});
 
     await chatCompletion(
@@ -193,12 +272,12 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
       { temperature: 0.3 },
     );
 
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(1);
   });
 
   it("only warns once per model name across multiple calls", async () => {
-    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
-    const client = makeSyncClient(create, 0.7);
+    const client = makeClient(0.7);
     const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
 
     await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "a" }]);
@@ -210,20 +289,19 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
   });
 
   it("also clamps any model name containing 'thinking'", async () => {
-    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
-    const client = makeSyncClient(create, 0.5);
+    const client = makeClient(0.5);
     vi.spyOn(console, "warn").mockImplementation(() => {});
 
     await chatCompletion(client, "kimi-thinking-preview", [
       { role: "user", content: "hi" },
     ]);
 
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(1);
   });
 
   it("leaves regular models untouched (no clamp, no warning)", async () => {
-    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
-    const client = makeSyncClient(create, 0.7);
+    const client = makeClient(0.7);
     const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
 
     await chatCompletion(
@@ -233,19 +311,20 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
       { temperature: 0.3 },
     );
 
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 0.3 });
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(0.3);
     expect(warn).not.toHaveBeenCalled();
     warn.mockRestore();
   });
 
   it("does not warn when requested temperature is already 1", async () => {
-    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
-    const client = makeSyncClient(create, 1);
+    const client = makeClient(1);
     const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
 
     await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
 
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(1);
     expect(warn).not.toHaveBeenCalled();
     warn.mockRestore();
   });

+ 95 - 0
packages/core/src/__tests__/secrets.test.ts

@@ -0,0 +1,95 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { loadSecrets, saveSecrets, getServiceApiKey } from "../llm/secrets.js";
+import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+describe("secrets", () => {
+  let root: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-secrets-"));
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  describe("loadSecrets", () => {
+    it("returns empty when .inkos/secrets.json does not exist", async () => {
+      const secrets = await loadSecrets(root);
+      expect(secrets).toEqual({ services: {} });
+    });
+
+    it("reads existing secrets file", async () => {
+      await mkdir(join(root, ".inkos"), { recursive: true });
+      await writeFile(
+        join(root, ".inkos", "secrets.json"),
+        JSON.stringify({ services: { moonshot: { apiKey: "sk-test" } } }),
+      );
+      const secrets = await loadSecrets(root);
+      expect(secrets.services.moonshot.apiKey).toBe("sk-test");
+    });
+  });
+
+  describe("saveSecrets", () => {
+    it("creates .inkos dir and writes secrets file", async () => {
+      await saveSecrets(root, {
+        services: { deepseek: { apiKey: "sk-deep" } },
+      });
+      const raw = await readFile(join(root, ".inkos", "secrets.json"), "utf-8");
+      const parsed = JSON.parse(raw);
+      expect(parsed.services.deepseek.apiKey).toBe("sk-deep");
+    });
+
+    it("overwrites existing secrets file", async () => {
+      await mkdir(join(root, ".inkos"), { recursive: true });
+      await writeFile(
+        join(root, ".inkos", "secrets.json"),
+        JSON.stringify({ services: { old: { apiKey: "old-key" } } }),
+      );
+      await saveSecrets(root, {
+        services: { new: { apiKey: "new-key" } },
+      });
+      const secrets = await loadSecrets(root);
+      expect(secrets.services.new.apiKey).toBe("new-key");
+      expect(secrets.services.old).toBeUndefined();
+    });
+  });
+
+  describe("getServiceApiKey", () => {
+    it("returns key from secrets.json first", async () => {
+      await mkdir(join(root, ".inkos"), { recursive: true });
+      await writeFile(
+        join(root, ".inkos", "secrets.json"),
+        JSON.stringify({ services: { moonshot: { apiKey: "sk-from-file" } } }),
+      );
+      const key = await getServiceApiKey(root, "moonshot");
+      expect(key).toBe("sk-from-file");
+    });
+
+    it("falls back to environment variable", async () => {
+      vi.stubEnv("MOONSHOT_API_KEY", "sk-from-env");
+      const key = await getServiceApiKey(root, "moonshot");
+      expect(key).toBe("sk-from-env");
+      vi.unstubAllEnvs();
+    });
+
+    it("returns null when neither secrets nor env exists", async () => {
+      const key = await getServiceApiKey(root, "moonshot");
+      expect(key).toBeNull();
+    });
+
+    it("handles custom service with colon key format", async () => {
+      await mkdir(join(root, ".inkos"), { recursive: true });
+      await writeFile(
+        join(root, ".inkos", "secrets.json"),
+        JSON.stringify({
+          services: { "custom:内网GPT": { apiKey: "sk-custom" } },
+        }),
+      );
+      const key = await getServiceApiKey(root, "custom:内网GPT");
+      expect(key).toBe("sk-custom");
+    });
+  });
+});

+ 122 - 0
packages/core/src/__tests__/service-resolver.test.ts

@@ -0,0 +1,122 @@
+// packages/core/src/__tests__/service-resolver.test.ts
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+// Models that exist in pi-ai's built-in registry (simulated)
+const KNOWN_MODELS = new Set(["gpt-4o", "kimi-k2.5"]);
+
+// Mock pi-ai's getModel — returns undefined for models not in registry (like the real implementation)
+vi.mock("@mariozechner/pi-ai", () => ({
+  getModel: vi.fn((provider: string, modelId: string) => {
+    if (!KNOWN_MODELS.has(modelId)) return undefined;
+    return {
+      id: modelId,
+      name: modelId,
+      api: "openai-completions",
+      provider,
+      baseUrl: "https://api.openai.com/v1",
+      reasoning: false,
+      input: ["text"],
+      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+      contextWindow: 128000,
+      maxTokens: 16384,
+    };
+  }),
+  getEnvApiKey: vi.fn(() => undefined),
+}));
+
+import { resolveServiceModel } from "../llm/service-resolver.js";
+
+describe("resolveServiceModel", () => {
+  let root: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-resolver-"));
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+    vi.unstubAllEnvs();
+  });
+
+  it("resolves built-in service with key from secrets", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { moonshot: { apiKey: "sk-moon" } } }),
+    );
+
+    const result = await resolveServiceModel("moonshot", "kimi-k2.5", root);
+
+    expect(result.model.id).toBe("kimi-k2.5");
+    expect(result.apiKey).toBe("sk-moon");
+    expect(result.writingTemperature).toBe(1.0);
+    expect(result.temperatureRange).toEqual([0, 1]);
+  });
+
+  it("resolves deepseek with correct temperature", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { deepseek: { apiKey: "sk-deep" } } }),
+    );
+
+    const result = await resolveServiceModel("deepseek", "deepseek-chat", root);
+
+    expect(result.apiKey).toBe("sk-deep");
+    expect(result.writingTemperature).toBe(1.5);
+    expect(result.temperatureRange).toEqual([0, 2]);
+  });
+
+  it("constructs model from preset when getModel returns undefined", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { deepseek: { apiKey: "sk-deep" } } }),
+    );
+
+    // "deepseek-chat" is NOT in KNOWN_MODELS, so getModel returns undefined
+    const result = await resolveServiceModel("deepseek", "deepseek-chat", root);
+
+    expect(result.model).toBeDefined();
+    expect(result.model.id).toBe("deepseek-chat");
+    expect(result.model.api).toBe("openai-completions");
+    expect(result.model.baseUrl).toBe("https://api.deepseek.com");
+    expect(result.model.provider).toBe("openai");
+    expect(result.apiKey).toBe("sk-deep");
+  });
+
+  it("falls back to env var when no secrets file", async () => {
+    vi.stubEnv("DEEPSEEK_API_KEY", "sk-env");
+
+    const result = await resolveServiceModel("deepseek", "deepseek-chat", root);
+
+    expect(result.apiKey).toBe("sk-env");
+  });
+
+  it("throws when no key found", async () => {
+    await expect(
+      resolveServiceModel("moonshot", "kimi-k2.5", root),
+    ).rejects.toThrow(/API key/i);
+  });
+
+  it("resolves custom service with baseUrl", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { "custom:内网GPT": { apiKey: "sk-corp" } } }),
+    );
+
+    const result = await resolveServiceModel(
+      "custom:内网GPT",
+      "gpt-4o",
+      root,
+      "https://llm.internal.corp/v1",
+    );
+
+    expect(result.apiKey).toBe("sk-corp");
+    expect(result.model.id).toBe("gpt-4o");
+  });
+});

+ 16 - 0
packages/core/src/__tests__/state-manager.test.ts

@@ -678,6 +678,22 @@ describe("StateManager", () => {
       expect(String(rejected[0]?.reason)).toMatch(/is locked/);
     });
 
+    it("reclaims same-process stale lock when no active write is in progress", async () => {
+      await mkdir(manager.bookDir("lock-book-self"), { recursive: true });
+      const lockPath = join(manager.bookDir("lock-book-self"), ".write.lock");
+      // Simulate a stale lock left by our own process (e.g. after a failed pipeline)
+      await writeFile(lockPath, `pid:${process.pid} ts:${Date.now() - 60000}`, "utf-8");
+
+      // Should auto-reclaim since our process knows it's not actively writing this book
+      const release = await manager.acquireBookLock("lock-book-self");
+      expect(typeof release).toBe("function");
+
+      const lockData = await readFile(lockPath, "utf-8");
+      expect(lockData).toContain(`pid:${process.pid}`);
+
+      await release();
+    });
+
     it("reclaims a stale lock when the recorded pid is no longer alive", async () => {
       await mkdir(manager.bookDir("lock-book-5"), { recursive: true });
       const lockPath = join(manager.bookDir("lock-book-5"), ".write.lock");

+ 30 - 0
packages/core/src/__tests__/state-validator-agent.test.ts

@@ -55,6 +55,36 @@ describe("StateValidatorAgent", () => {
     });
   });
 
+  it("passes maxTokens large enough for thinking models to chat()", async () => {
+    const agent = new StateValidatorAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 8192,
+          thinkingBudget: 0,
+          maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const chatSpy = vi.spyOn(
+      agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> },
+      "chat",
+    ).mockResolvedValue({ content: "PASS", usage: ZERO_USAGE });
+
+    await agent.validate("Body.", 1, "old", "new state", "old hooks", "new hooks", "zh");
+
+    const options = chatSpy.mock.calls[0]?.[1] as { maxTokens?: number } | undefined;
+    // Must not hardcode a small value like 2048 that starves thinking models
+    expect(options?.maxTokens).toBeUndefined();
+  });
+
   it("throws when the validator model returns an empty response", async () => {
     const agent = new StateValidatorAgent({
       client: {

+ 57 - 0
packages/core/src/__tests__/temperature-constraints.test.ts

@@ -0,0 +1,57 @@
+import { describe, it, expect } from "vitest";
+import { resolveServicePreset, clampTemperature, getWritingTemperature } from "../llm/service-presets.js";
+
+describe("temperature constraints per service", () => {
+  it("moonshot has range [0, 1] and writingTemperature 1.0", () => {
+    const preset = resolveServicePreset("moonshot");
+    expect(preset?.temperatureRange).toEqual([0, 1]);
+    expect(preset?.writingTemperature).toBe(1.0);
+  });
+
+  it("deepseek has range [0, 2] and writingTemperature 1.5", () => {
+    const preset = resolveServicePreset("deepseek");
+    expect(preset?.temperatureRange).toEqual([0, 2]);
+    expect(preset?.writingTemperature).toBe(1.5);
+  });
+
+  it("anthropic has range [0, 1] and writingTemperature 1.0", () => {
+    const preset = resolveServicePreset("anthropic");
+    expect(preset?.temperatureRange).toEqual([0, 1]);
+    expect(preset?.writingTemperature).toBe(1.0);
+  });
+
+  it("openai has range [0, 2] and writingTemperature 1.0", () => {
+    const preset = resolveServicePreset("openai");
+    expect(preset?.temperatureRange).toEqual([0, 2]);
+    expect(preset?.writingTemperature).toBe(1.0);
+  });
+
+  it("zhipu has range [0, 1]", () => {
+    const preset = resolveServicePreset("zhipu");
+    expect(preset?.temperatureRange).toEqual([0, 1]);
+  });
+
+  it("bailian has range [0, 2]", () => {
+    const preset = resolveServicePreset("bailian");
+    expect(preset?.temperatureRange).toEqual([0, 2]);
+  });
+
+  it("minimax has range [0, 2]", () => {
+    const preset = resolveServicePreset("minimax");
+    expect(preset?.temperatureRange).toEqual([0, 2]);
+  });
+
+  it("clampTemperature respects service range", () => {
+    expect(clampTemperature("moonshot", 1.5)).toBe(1.0);
+    expect(clampTemperature("moonshot", 0.7)).toBe(0.7);
+    expect(clampTemperature("deepseek", 1.5)).toBe(1.5);
+    expect(clampTemperature("deepseek", 2.5)).toBe(2.0);
+    expect(clampTemperature("unknown-service", 1.5)).toBe(1.5);
+  });
+
+  it("getWritingTemperature returns service-specific value", () => {
+    expect(getWritingTemperature("moonshot")).toBe(1.0);
+    expect(getWritingTemperature("deepseek")).toBe(1.5);
+    expect(getWritingTemperature("anthropic")).toBe(1.0);
+  });
+});

+ 304 - 0
packages/core/src/agent/agent-session.ts

@@ -0,0 +1,304 @@
+import { Agent } from "@mariozechner/pi-agent-core";
+import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
+import { streamSimple, getModel, getEnvApiKey } from "@mariozechner/pi-ai";
+import type { Model, Api, AssistantMessage, UserMessage } from "@mariozechner/pi-ai";
+import type { PipelineRunner } from "../pipeline/runner.js";
+import { buildAgentSystemPrompt } from "./agent-system-prompt.js";
+import {
+  createSubAgentTool,
+  createReadTool,
+  createEditTool,
+  createGrepTool,
+  createLsTool,
+} from "./agent-tools.js";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface AgentSessionConfig {
+  /** Unique session identifier (typically the BookSession id). */
+  sessionId: string;
+  /** Book ID, or null if in "new book" mode. */
+  bookId: string | null;
+  /** Language for the system prompt. */
+  language: string;
+  /** PipelineRunner for sub-agent tool delegation. */
+  pipeline: PipelineRunner;
+  /** Project root directory (books/ lives under this). */
+  projectRoot: string;
+  /** pi-ai Model to use, or provider+modelId to resolve via getModel. */
+  model: Model<Api> | { provider: string; modelId: string };
+  /** Optional API key. When omitted, falls back to env-based key lookup. */
+  apiKey?: string;
+  /** Optional listener for streaming events (for SSE forwarding). */
+  onEvent?: (event: AgentEvent) => void;
+}
+
+export interface AgentSessionResult {
+  /** Extracted text from the final assistant message. */
+  responseText: string;
+  /** Full conversation history for persistence. */
+  messages: Array<{ role: string; content: string; thinking?: string }>;
+}
+
+// ---------------------------------------------------------------------------
+// Cache
+// ---------------------------------------------------------------------------
+
+interface CachedAgent {
+  agent: Agent;
+  lastActive: number;
+}
+
+const agentCache = new Map<string, CachedAgent>();
+
+/** TTL for cached agents: 5 minutes. */
+const CACHE_TTL_MS = 5 * 60 * 1000;
+
+/** Cleanup interval handle (lazy-started). */
+let cleanupTimer: ReturnType<typeof setInterval> | null = null;
+
+function ensureCleanupTimer(): void {
+  if (cleanupTimer) return;
+  cleanupTimer = setInterval(() => {
+    const now = Date.now();
+    for (const [id, entry] of agentCache) {
+      if (now - entry.lastActive > CACHE_TTL_MS) {
+        agentCache.delete(id);
+      }
+    }
+    // Stop the timer when nothing left to watch.
+    if (agentCache.size === 0 && cleanupTimer) {
+      clearInterval(cleanupTimer);
+      cleanupTimer = null;
+    }
+  }, 60_000); // run every 60 s
+  // Allow the process to exit even if this timer is alive.
+  if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
+    cleanupTimer.unref();
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function resolveModel(spec: AgentSessionConfig["model"]): Model<Api> {
+  if (!spec) {
+    throw new Error("Model is required but was undefined. Check LLM configuration.");
+  }
+  if (typeof spec === "object" && "id" in spec && "api" in spec) {
+    // Already a Model object.
+    return spec as Model<Api>;
+  }
+  const { provider, modelId } = spec as { provider: string; modelId: string };
+  if (!provider || !modelId) {
+    throw new Error(`Invalid model spec: provider=${provider}, modelId=${modelId}`);
+  }
+  return getModel(provider as any, modelId as any);
+}
+
+/**
+ * Extract readable text from an AssistantMessage's content array.
+ * Filters out tool-call blocks; concatenates text blocks.
+ */
+function extractTextFromAssistant(msg: AssistantMessage): string {
+  return msg.content
+    .filter((c): c is { type: "text"; text: string } => c.type === "text")
+    .map((c) => c.text)
+    .join("");
+}
+
+/**
+ * Extract thinking/reasoning text from an AssistantMessage's content array.
+ */
+function extractThinkingFromAssistant(msg: AssistantMessage): string {
+  return msg.content
+    .filter((c: any) => c.type === "thinking")
+    .map((c: any) => c.thinking ?? "")
+    .join("");
+}
+
+/**
+ * Convert plain `{ role, content }` messages (from BookSession disk storage)
+ * back into pi-agent AgentMessage format so they can be loaded into an Agent.
+ */
+function plainToAgentMessages(
+  plain: Array<{ role: string; content: string }>,
+): AgentMessage[] {
+  return plain.map((m) => {
+    const ts = Date.now();
+    if (m.role === "user") {
+      return { role: "user", content: m.content, timestamp: ts } satisfies UserMessage;
+    }
+    // For stored assistant messages we only have the text.
+    // Re-wrap as a minimal AssistantMessage with a single TextContent.
+    return {
+      role: "assistant",
+      content: [{ type: "text", text: m.content }],
+      api: "anthropic-messages",
+      provider: "anthropic",
+      model: "unknown",
+      usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
+      stopReason: "stop",
+      timestamp: ts,
+    } satisfies AssistantMessage;
+  });
+}
+
+/**
+ * Flatten the Agent's in-memory messages to plain `{ role, content }` pairs
+ * suitable for BookSession persistence.
+ */
+function agentMessagesToPlain(
+  messages: AgentMessage[],
+): Array<{ role: string; content: string; thinking?: string }> {
+  const out: Array<{ role: string; content: string; thinking?: string }> = [];
+  for (const msg of messages) {
+    if (!msg || typeof msg !== "object" || !("role" in msg)) continue;
+
+    const m = msg as { role: string; [k: string]: any };
+
+    if (m.role === "user") {
+      const content = typeof m.content === "string"
+        ? m.content
+        : Array.isArray(m.content)
+          ? m.content
+              .filter((c: any) => c.type === "text")
+              .map((c: any) => c.text)
+              .join("")
+          : "";
+      if (content) out.push({ role: "user", content });
+    } else if (m.role === "assistant") {
+      const text = extractTextFromAssistant(m as AssistantMessage);
+      const thinking = extractThinkingFromAssistant(m as AssistantMessage);
+      if (text || thinking) {
+        const entry: { role: string; content: string; thinking?: string } = { role: "assistant", content: text };
+        if (thinking) entry.thinking = thinking;
+        out.push(entry);
+      }
+    }
+    // ToolResult messages are internal; skip them for persistence.
+  }
+  return out;
+}
+
+// ---------------------------------------------------------------------------
+// Main entry point
+// ---------------------------------------------------------------------------
+
+/**
+ * Run a single conversation turn within a cached Agent session.
+ *
+ * If the session already exists in the cache, reuses the Agent (with its full
+ * in-memory message history including tool calls). Otherwise creates a new
+ * Agent, optionally restoring messages from `initialMessages`.
+ */
+export async function runAgentSession(
+  config: AgentSessionConfig,
+  userMessage: string,
+  initialMessages?: Array<{ role: string; content: string }>,
+): Promise<AgentSessionResult> {
+  const { sessionId, bookId, language, pipeline, projectRoot, onEvent } = config;
+
+  // ----- Resolve or create Agent -----
+  let cached = agentCache.get(sessionId);
+
+  if (cached) {
+    // Check if model changed — evict and rebuild if so
+    const currentModelId = (cached.agent.state.model as any)?.id;
+    const newModelId = typeof config.model === 'object' && 'id' in config.model
+      ? (config.model as any).id
+      : undefined;
+    if (currentModelId && newModelId && currentModelId !== newModelId) {
+      // Preserve conversation messages for re-injection
+      const preservedMessages = agentMessagesToPlain(cached.agent.state.messages);
+      agentCache.delete(sessionId);
+      cached = undefined;
+      // Pass preserved messages as initialMessages if none were provided
+      if (!initialMessages || initialMessages.length === 0) {
+        initialMessages = preservedMessages;
+      }
+    }
+  }
+
+  if (!cached) {
+    const model = resolveModel(config.model);
+    const agent = new Agent({
+      initialState: {
+        model,
+        systemPrompt: buildAgentSystemPrompt(bookId, language),
+        tools: [
+          createSubAgentTool(pipeline, bookId),
+          createReadTool(projectRoot),
+          createEditTool(projectRoot),
+          createGrepTool(projectRoot),
+          createLsTool(projectRoot),
+        ],
+      },
+      streamFn: streamSimple,
+      getApiKey: (provider: string) => {
+        if (config.apiKey) return config.apiKey;
+        return getEnvApiKey(provider);
+      },
+    });
+
+    // Restore prior conversation if provided.
+    if (initialMessages && initialMessages.length > 0) {
+      agent.state.messages = plainToAgentMessages(initialMessages);
+    }
+
+    cached = { agent, lastActive: Date.now() };
+    agentCache.set(sessionId, cached);
+    ensureCleanupTimer();
+  }
+
+  cached.lastActive = Date.now();
+  const { agent } = cached;
+
+  // ----- Subscribe to events (for SSE streaming to frontend) -----
+  let unsubscribe: (() => void) | undefined;
+  if (onEvent) {
+    unsubscribe = agent.subscribe((event: AgentEvent) => {
+      onEvent(event);
+    });
+  }
+
+  // ----- Execute the turn -----
+  try {
+    await agent.prompt(userMessage);
+  } finally {
+    unsubscribe?.();
+  }
+
+  // ----- Extract result -----
+  const allMessages = agent.state.messages;
+  const responseText = extractResponseText(allMessages);
+  const plainMessages = agentMessagesToPlain(allMessages);
+
+  return { responseText, messages: plainMessages };
+}
+
+/**
+ * Walk backward through messages to find the last assistant message and
+ * extract its text content.
+ */
+function extractResponseText(messages: AgentMessage[]): string {
+  for (let i = messages.length - 1; i >= 0; i--) {
+    const msg = messages[i];
+    if (msg && typeof msg === "object" && "role" in msg && (msg as any).role === "assistant") {
+      return extractTextFromAssistant(msg as AssistantMessage);
+    }
+  }
+  return "";
+}
+
+// ---------------------------------------------------------------------------
+// Cache management
+// ---------------------------------------------------------------------------
+
+/** Manually evict a cached Agent session. */
+export function evictAgentCache(sessionId: string): boolean {
+  return agentCache.delete(sessionId);
+}

+ 98 - 0
packages/core/src/agent/agent-system-prompt.ts

@@ -0,0 +1,98 @@
+export function buildAgentSystemPrompt(bookId: string | null, language: string): string {
+  const isZh = language === "zh";
+
+  if (!bookId) {
+    return isZh
+      ? `你是 InkOS 建书助手。你的任务是帮用户从零开始创建一本新书。
+
+## 工作流程
+
+1. **收集信息**(对话阶段)— 通过自然对话逐步了解:
+   - 题材/类型(如玄幻、都市、悬疑、言情等)
+   - 目标平台(番茄小说、起点中文网、飞卢等)
+   - 世界观设定(什么样的世界?有什么特殊规则?)
+   - 主角设定(谁?什么背景?什么性格?)
+   - 核心冲突(主线矛盾是什么?)
+   - 写作语言(中文/English)
+
+2. **确认建书**(调用阶段)— 当信息足够时,调用 sub_agent 工具委托 architect 子智能体建书:
+   - instruction 中包含收集到的所有信息(题材、世界观、主角、冲突等)
+   - architect 会生成完整的 foundation(世界观设定、卷纲规划、叙事规则等)
+
+## 对话风格
+
+- 每次只问一个问题,不要一次问太多
+- 用户回答模糊时,给出 2-3 个具体选项引导
+- 当信息基本齐了,主动提议建书,不要无限追问
+- 保持简短、自然
+- **不要在回复中添加表情符号**`
+      : `You are the InkOS book creation assistant. Help the user create a new book from scratch.
+
+## Workflow
+
+1. **Collect information** — Through conversation, gradually learn:
+   - Genre (fantasy, urban, mystery, romance, etc.)
+   - Target platform
+   - World setting
+   - Protagonist
+   - Core conflict
+   - Writing language
+
+2. **Create book** — When you have enough info, call the sub_agent tool with agent="architect":
+   - Include all collected info in the instruction
+   - The architect will generate the complete foundation
+
+## Style
+
+- Ask one question at a time
+- Offer 2-3 concrete options when the user is vague
+- Proactively suggest creating the book when enough info is collected
+- Keep responses brief and natural
+- **Do NOT use emoji in your responses**`;
+  }
+
+  return isZh
+    ? `你是 InkOS 写作助手,当前正在处理书籍「${bookId}」。
+
+## 可用工具
+
+- **sub_agent** — 委托子智能体执行重操作:
+  - agent="writer" 写下一章
+  - agent="auditor" 审计章节质量
+  - agent="reviser" 修订章节
+  - agent="exporter" 导出书籍
+- **read** — 读取书籍的设定文件或章节内容
+- **edit** — 编辑设定文件(如修改角色名、调整世界观)
+- **grep** — 搜索内容(如"哪一章提到了某个角色")
+- **ls** — 列出文件或章节
+
+## 使用原则
+
+- 写章节、修订、审计等重操作 → 使用 sub_agent 委托对应子智能体
+- 用户问设定相关问题 → 先用 read 读取对应文件再回答
+- 用户想做小修改(改名字、调设定)→ 用 edit 直接修改
+- 其他情况 → 直接对话回答
+- **注意:不要调用 architect,当前已有书籍,不需要建书**
+- **不要在回复中添加表情符号**`
+    : `You are the InkOS writing assistant, working on book "${bookId}".
+
+## Available Tools
+
+- **sub_agent** — Delegate to sub-agents:
+  - agent="writer" for writing next chapter
+  - agent="auditor" for chapter quality audit
+  - agent="reviser" for chapter revision
+  - agent="exporter" for book export
+- **read** — Read truth files or chapter content
+- **edit** — Edit truth files (rename characters, adjust world settings)
+- **grep** — Search content across chapters
+- **ls** — List files or chapters
+
+## Guidelines
+
+- Use sub_agent for heavy operations (writing, revision, auditing)
+- Use read/edit for settings inquiries and small changes
+- Chat directly for other questions
+- **Do NOT call architect — a book already exists**
+- **Do NOT use emoji in your responses**`;
+}

+ 338 - 0
packages/core/src/agent/agent-tools.ts

@@ -0,0 +1,338 @@
+import { Type, type Static } from "@mariozechner/pi-ai";
+import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
+import type { PipelineRunner } from "../pipeline/runner.js";
+import type { ReviseMode } from "../agents/reviser.js";
+import { readFile, writeFile, readdir, stat } from "node:fs/promises";
+import { join, normalize, resolve } from "node:path";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function textResult(text: string): AgentToolResult<undefined> {
+  return { content: [{ type: "text", text }], details: undefined };
+}
+
+/**
+ * Resolve a user-supplied relative path against the books root and guard
+ * against path-traversal (../ etc.).
+ */
+function safeBooksPath(booksRoot: string, relativePath: string): string {
+  const resolved = resolve(booksRoot, normalize(relativePath));
+  if (!resolved.startsWith(booksRoot)) {
+    throw new Error(`Path traversal blocked: ${relativePath}`);
+  }
+  return resolved;
+}
+
+// ---------------------------------------------------------------------------
+// 1. SubAgentTool (sub_agent)
+// ---------------------------------------------------------------------------
+
+const SubAgentParams = Type.Object({
+  agent: Type.Union([
+    Type.Literal("architect"),
+    Type.Literal("writer"),
+    Type.Literal("auditor"),
+    Type.Literal("reviser"),
+    Type.Literal("exporter"),
+  ]),
+  instruction: Type.String({ description: "Natural language instruction from the main Agent" }),
+  bookId: Type.Optional(Type.String({ description: "Book ID — required for all agents except architect" })),
+});
+
+export function createSubAgentTool(pipeline: PipelineRunner, activeBookId: string | null): AgentTool<typeof SubAgentParams> {
+  return {
+    name: "sub_agent",
+    description:
+      "Delegate a heavy operation to a specialised sub-agent. " +
+      "Use agent='architect' to initialise a new book, 'writer' to write the next chapter, " +
+      "'auditor' to audit quality, 'reviser' to revise a chapter, 'exporter' to export.",
+    label: "Sub-Agent",
+    parameters: SubAgentParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof SubAgentParams>,
+      _signal?: AbortSignal,
+      onUpdate?: AgentToolUpdateCallback,
+    ): Promise<AgentToolResult<undefined>> {
+      const { agent, instruction, bookId } = params;
+
+      const progress = (msg: string) => {
+        onUpdate?.(textResult(msg));
+      };
+
+      try {
+        switch (agent) {
+          case "architect": {
+            // architect 只在没有书的时候可用(建书流程)
+            if (activeBookId) {
+              return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
+            }
+            const id = bookId || `book-${Date.now().toString(36)}`;
+            progress(`Starting architect for book "${id}"...`);
+            await pipeline.initBook(
+              { id, genre: "general", title: "", language: "zh" } as any,
+              { externalContext: instruction },
+            );
+            progress(`Architect finished — book "${id}" foundation created.`);
+            return textResult(`Book "${id}" initialised successfully. Foundation files are ready.`);
+          }
+
+          case "writer": {
+            if (!bookId) return textResult("Error: bookId is required for the writer agent.");
+            progress(`Writing next chapter for "${bookId}"...`);
+            const result = await pipeline.writeNextChapter(bookId);
+            progress(`Writer finished chapter for "${bookId}".`);
+            return textResult(
+              `Chapter written for "${bookId}". ` +
+              `Word count: ${(result as any).wordCount ?? "unknown"}.`,
+            );
+          }
+
+          case "auditor": {
+            if (!bookId) return textResult("Error: bookId is required for the auditor agent.");
+            progress(`Auditing draft for "${bookId}"...`);
+            const audit = await pipeline.auditDraft(bookId);
+            progress(`Audit complete for "${bookId}".`);
+            const issueCount = audit.issues?.length ?? 0;
+            return textResult(
+              `Audit complete for "${bookId}": ${issueCount} issue(s) found. ` +
+              `Chapter ${audit.chapterNumber}.`,
+            );
+          }
+
+          case "reviser": {
+            if (!bookId) return textResult("Error: bookId is required for the reviser agent.");
+            // Detect revision mode from instruction keywords
+            const mode: ReviseMode = /rewrite|改写|重写/.test(instruction)
+              ? "rewrite"
+              : /polish|润色/.test(instruction)
+                ? "polish"
+                : /rework|返工/.test(instruction)
+                  ? "rework"
+                  : "spot-fix";
+            progress(`Revising "${bookId}" in ${mode} mode...`);
+            await pipeline.reviseDraft(bookId, undefined, mode);
+            progress(`Revision complete for "${bookId}".`);
+            return textResult(`Revision (${mode}) complete for "${bookId}".`);
+          }
+
+          case "exporter": {
+            return textResult("Export is not yet implemented. Coming soon.");
+          }
+
+          default:
+            return textResult(`Unknown agent: ${agent}`);
+        }
+      } catch (err: any) {
+        console.error(`[sub_agent] "${agent}" failed:`, err);
+        return textResult(`Sub-agent "${agent}" failed: ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 2. Read Tool
+// ---------------------------------------------------------------------------
+
+const ReadParams = Type.Object({
+  path: Type.String({ description: "File path relative to books/, e.g. {bookId}/story/story_bible.md" }),
+});
+
+export function createReadTool(projectRoot: string): AgentTool<typeof ReadParams> {
+  const booksRoot = join(projectRoot, "books");
+
+  return {
+    name: "read",
+    description: "Read a file from the book directory. Path is relative to books/.",
+    label: "Read File",
+    parameters: ReadParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof ReadParams>,
+    ): Promise<AgentToolResult<undefined>> {
+      try {
+        const filePath = safeBooksPath(booksRoot, params.path);
+        let content = await readFile(filePath, "utf-8");
+        if (content.length > 10_000) {
+          content = content.slice(0, 10_000) + "\n\n... [truncated at 10 000 chars]";
+        }
+        return textResult(content);
+      } catch (err: any) {
+        return textResult(`Failed to read "${params.path}": ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 3. Edit Tool
+// ---------------------------------------------------------------------------
+
+const EditParams = Type.Object({
+  path: Type.String({ description: "File path relative to books/" }),
+  old_string: Type.String({ description: "Exact string to find in the file" }),
+  new_string: Type.String({ description: "Replacement string" }),
+});
+
+export function createEditTool(projectRoot: string): AgentTool<typeof EditParams> {
+  const booksRoot = join(projectRoot, "books");
+
+  return {
+    name: "edit",
+    description:
+      "Edit a file using exact string replacement. " +
+      "old_string must appear exactly once in the file. Path is relative to books/.",
+    label: "Edit File",
+    parameters: EditParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof EditParams>,
+    ): Promise<AgentToolResult<undefined>> {
+      try {
+        const filePath = safeBooksPath(booksRoot, params.path);
+        const content = await readFile(filePath, "utf-8");
+        const idx = content.indexOf(params.old_string);
+        if (idx === -1) {
+          return textResult(`old_string not found in "${params.path}".`);
+        }
+        if (content.indexOf(params.old_string, idx + 1) !== -1) {
+          return textResult(`old_string appears more than once in "${params.path}". Provide a more specific match.`);
+        }
+        const updated = content.slice(0, idx) + params.new_string + content.slice(idx + params.old_string.length);
+        await writeFile(filePath, updated, "utf-8");
+        return textResult(`File "${params.path}" updated successfully.`);
+      } catch (err: any) {
+        return textResult(`Failed to edit "${params.path}": ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 4. Grep Tool
+// ---------------------------------------------------------------------------
+
+const GrepParams = Type.Object({
+  bookId: Type.String({ description: "Book ID to search within" }),
+  pattern: Type.String({ description: "Search pattern (plain text or regex)" }),
+});
+
+export function createGrepTool(projectRoot: string): AgentTool<typeof GrepParams> {
+  const booksRoot = join(projectRoot, "books");
+
+  return {
+    name: "grep",
+    description:
+      "Search for a text pattern across a book's story/ and chapters/ directories. Returns matching lines.",
+    label: "Search",
+    parameters: GrepParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof GrepParams>,
+    ): Promise<AgentToolResult<undefined>> {
+      try {
+        const bookDir = safeBooksPath(booksRoot, params.bookId);
+        const regex = new RegExp(params.pattern, "gi");
+        const results: string[] = [];
+
+        async function searchDir(dir: string, prefix: string) {
+          let entries: string[];
+          try {
+            entries = await readdir(dir);
+          } catch {
+            return; // directory doesn't exist
+          }
+          for (const entry of entries) {
+            const fullPath = join(dir, entry);
+            const entryStat = await stat(fullPath);
+            if (entryStat.isDirectory()) {
+              await searchDir(fullPath, `${prefix}${entry}/`);
+            } else if (entry.endsWith(".md") || entry.endsWith(".txt") || entry.endsWith(".json")) {
+              const content = await readFile(fullPath, "utf-8");
+              const lines = content.split("\n");
+              for (let i = 0; i < lines.length; i++) {
+                if (regex.test(lines[i])) {
+                  results.push(`${prefix}${entry}:${i + 1}: ${lines[i]}`);
+                  regex.lastIndex = 0; // reset for next test
+                }
+              }
+            }
+          }
+        }
+
+        await Promise.all([
+          searchDir(join(bookDir, "story"), "story/"),
+          searchDir(join(bookDir, "chapters"), "chapters/"),
+        ]);
+
+        if (results.length === 0) {
+          return textResult(`No matches for "${params.pattern}" in book "${params.bookId}".`);
+        }
+
+        const truncated = results.length > 100
+          ? results.slice(0, 100).join("\n") + `\n\n... [${results.length - 100} more matches]`
+          : results.join("\n");
+
+        return textResult(truncated);
+      } catch (err: any) {
+        return textResult(`Grep failed: ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 5. Ls Tool
+// ---------------------------------------------------------------------------
+
+const LsParams = Type.Object({
+  bookId: Type.String({ description: "Book ID" }),
+  subdir: Type.Optional(
+    Type.String({ description: "Subdirectory within the book, e.g. 'story', 'chapters', 'story/runtime'" }),
+  ),
+});
+
+export function createLsTool(projectRoot: string): AgentTool<typeof LsParams> {
+  const booksRoot = join(projectRoot, "books");
+
+  return {
+    name: "ls",
+    description: "List files in a book directory. Optionally specify a subdirectory like 'story' or 'chapters'.",
+    label: "List Files",
+    parameters: LsParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof LsParams>,
+    ): Promise<AgentToolResult<undefined>> {
+      try {
+        const base = safeBooksPath(booksRoot, params.bookId);
+        const target = params.subdir ? safeBooksPath(base, params.subdir) : base;
+
+        const entries = await readdir(target);
+        const details: string[] = [];
+
+        for (const entry of entries) {
+          const fullPath = join(target, entry);
+          try {
+            const entryStat = await stat(fullPath);
+            const suffix = entryStat.isDirectory() ? "/" : ` (${entryStat.size} bytes)`;
+            details.push(`${entry}${suffix}`);
+          } catch {
+            details.push(entry);
+          }
+        }
+
+        if (details.length === 0) {
+          return textResult(`Directory is empty: ${params.bookId}/${params.subdir ?? ""}`);
+        }
+
+        return textResult(details.join("\n"));
+      } catch (err: any) {
+        return textResult(`Failed to list "${params.bookId}/${params.subdir ?? ""}": ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}

+ 3 - 0
packages/core/src/agent/index.ts

@@ -0,0 +1,3 @@
+export { buildAgentSystemPrompt } from "./agent-system-prompt.js";
+export { createSubAgentTool, createReadTool, createEditTool, createGrepTool, createLsTool } from "./agent-tools.js";
+export { runAgentSession, evictAgentCache, type AgentSessionConfig, type AgentSessionResult } from "./agent-session.js";

+ 5 - 5
packages/core/src/agents/architect.ts

@@ -279,7 +279,7 @@ ${finalRequirementsPrompt}`;
     const response = await this.chat([
       { role: "system", content: langPrefix + systemPrompt },
       { role: "user", content: userMessage },
-    ], { maxTokens: 16384, temperature: 0.8 });
+    ], { temperature: 0.8 });
 
     return this.parseSections(response.content);
   }
@@ -332,8 +332,8 @@ ${finalRequirementsPrompt}`;
       writeFile(
         join(storyDir, "character_matrix.md"),
         language === "en"
-          ? "# Character Matrix\n\n### Character Profiles\n| Character | Core Tags | Contrast Detail | Speech Style | Personality Core | Relationship to Protagonist | Core Motivation | Current Goal |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n\n### Encounter Log\n| Character A | Character B | First Meeting Chapter | Latest Interaction Chapter | Relationship Type | Relationship Change |\n| --- | --- | --- | --- | --- | --- |\n\n### Information Boundaries\n| Character | Known Information | Unknown Information | Source Chapter |\n| --- | --- | --- | --- |\n"
-          : "# 角色交互矩阵\n\n### 角色档案\n| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |\n|------|----------|----------|----------|----------|------------|----------|----------|\n\n### 相遇记录\n| 角色A | 角色B | 首次相遇章 | 最近交互章 | 关系性质 | 关系变化 |\n|-------|-------|------------|------------|----------|----------|\n\n### 信息边界\n| 角色 | 已知信息 | 未知信息 | 信息来源章 |\n|------|----------|----------|------------|\n",
+          ? "# Character Matrix\n\n<!-- One ## section per character. Add new characters as new ## blocks. -->\n"
+          : "# 角色矩阵\n\n<!-- 每个角色一个 ## 块,新角色追加新 ## 即可。 -->\n",
         "utf-8",
       ),
     );
@@ -676,7 +676,7 @@ ${keyPrinciplesPrompt}`;
         role: "user",
         content: userMessage,
       },
-    ], { maxTokens: 16384, temperature: 0.5 });
+    ], { temperature: 0.5 });
 
     return this.parseSections(response.content);
   }
@@ -765,7 +765,7 @@ prohibitions:
         role: "user",
         content: `请为标题为"${book.title}"的${fanficMode}模式同人小说生成基础设定。目标${book.targetChapters}章,每章${book.chapterWordCount}字。`,
       },
-    ], { maxTokens: 16384, temperature: 0.7 });
+    ], { temperature: 0.7 });
 
     return this.parseSections(response.content);
   }

+ 32 - 4
packages/core/src/agents/chapter-analyzer.ts

@@ -169,7 +169,7 @@ export class ChapterAnalyzerAgent extends BaseAgent {
         { role: "system", content: systemPrompt },
         { role: "user", content: userPrompt },
       ],
-      { maxTokens: 16384, temperature: 0.3 },
+      { temperature: 0.3 },
     );
 
     const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
@@ -292,7 +292,21 @@ Updated subplot board (Markdown table)
 Updated emotional arcs (Markdown table)
 
 === UPDATED_CHARACTER_MATRIX ===
-Updated character interaction matrix (Markdown table)
+Updated character matrix (one ## section per character, bullet-list fields):
+
+## Character Name
+- **Role**: protagonist / antagonist / ally / minor / mentioned
+- **Tags**: core identity tags
+- **Contrast**: distinctive details that defy expectations
+- **Speech**: speaking style summary
+- **Personality**: core personality traits
+- **Motivation**: fundamental driving force
+- **Current**: immediate goal this chapter
+- **Relationships**: OtherChar(type/Ch#) | ...
+- **Known**: what this character knows (only witnessed or told)
+- **Unknown**: what this character does not know
+
+(Repeat for each character. Add new characters; keep existing ones updated.)
 
 ## Rules
 
@@ -385,14 +399,28 @@ ${bookRulesBody ? `## 本书规则\n\n${bookRulesBody}` : ""}
 更新后的情感弧线(Markdown表格)
 
 === UPDATED_CHARACTER_MATRIX ===
-更新后的角色交互矩阵(Markdown表格)
+更新后的角色矩阵(每个角色一个 ## 块,字段用 bullet list):
+
+## 角色名
+- **定位**: 主角 / 反派 / 盟友 / 配角 / 提及
+- **标签**: 核心身份标签
+- **反差**: 打破刻板印象的独特细节
+- **说话**: 说话风格概述
+- **性格**: 性格底色
+- **动机**: 根本驱动力
+- **当前**: 本章即时目标
+- **关系**: 某角色(关系性质/Ch#) | ...
+- **已知**: 该角色已知的信息(仅限亲历或被告知)
+- **未知**: 该角色不知道的信息
+
+(每个角色重复以上格式。新角色追加新 ## 块,已有角色做增量更新。)
 
 ## 关键规则
 
 1. 状态卡和伏笔池必须基于"当前追踪文件"做增量更新,不是从零开始
 2. 正文中的每一个事实性变化都必须反映在对应的追踪文件中
 3. 不要遗漏细节:数值变化、位置变化、关系变化、信息变化都要记录
-4. 角色交互矩阵中的"信息边界"要准确——角色只知道他在场时发生的事`;
+4. 角色矩阵中的"已知/未知"要准确——角色只知道他在场时发生的事`;
   }
 
   private buildUserPrompt(params: {

+ 1 - 1
packages/core/src/agents/consolidator.ts

@@ -96,7 +96,7 @@ export class ConsolidatorAgent extends BaseAgent {
           role: "user",
           content: `Volume: ${vol.name} (Chapters ${vol.startCh}-${vol.endCh})\n\nChapter summaries:\n${header}\n${volSummaryRows}`,
         },
-      ], { temperature: 0.3, maxTokens: 1024 });
+      ], { temperature: 0.3 });
 
       newSummaries.push(`\n## ${vol.name} (Ch.${vol.startCh}-${vol.endCh})\n\n${response.content.trim()}`);
     }

+ 1 - 1
packages/core/src/agents/continuity.ts

@@ -536,7 +536,7 @@ ${chapterContent}`;
       { role: "system" as const, content: systemPrompt },
       { role: "user" as const, content: userPrompt },
     ];
-    const chatOptions = { temperature: options?.temperature ?? 0.3, maxTokens: 8192 };
+    const chatOptions = { temperature: options?.temperature ?? 0.3 };
 
     // Use web search for fact verification when eraResearch is enabled
     const response = gp.eraResearch

+ 1 - 1
packages/core/src/agents/fanfic-canon-importer.ts

@@ -94,7 +94,7 @@ ${truncated ? "\n注意:原作素材过长,已截断。请基于已有部分
         { role: "system", content: systemPrompt },
         { role: "user", content: `以下是原作《${sourceName}》的素材:\n\n${text}` },
       ],
-      { maxTokens: 8192, temperature: 0.3 },
+      { temperature: 0.3 },
     );
 
     const content = response.content;

+ 1 - 1
packages/core/src/agents/foundation-reviewer.ts

@@ -47,7 +47,7 @@ export class FoundationReviewerAgent extends BaseAgent {
     const response = await this.chat([
       { role: "system", content: systemPrompt },
       { role: "user", content: userPrompt },
-    ], { maxTokens: 4096, temperature: 0.3 });
+    ], { temperature: 0.3 });
 
     return this.parseReviewResult(response.content, dimensions);
   }

+ 1 - 1
packages/core/src/agents/radar.ts

@@ -94,7 +94,7 @@ ${rankingsText}
           content: `请基于上面的实时排行榜数据,分析当前网文市场热度,给出开书建议。`,
         },
       ],
-      { temperature: 0.6, maxTokens: 4096 },
+      { temperature: 0.6 },
     );
 
     return this.parseResult(response.content);

+ 1 - 1
packages/core/src/agents/state-validator.ts

@@ -93,7 +93,7 @@ ${chapterContent.slice(0, 6000)}`;
           { role: "system", content: systemPrompt },
           { role: "user", content: userPrompt },
         ],
-        { temperature: 0.1, maxTokens: 2048 },
+        { temperature: 0.1 },
       );
 
       return this.parseResult(response.content);

+ 13 - 13
packages/core/src/agents/writer-prompts.ts

@@ -624,17 +624,17 @@ ${updatedLedger}
 |------|------|----------|----------|------------|----------|
 
 === UPDATED_CHARACTER_MATRIX ===
-(更新后的角色交互矩阵,分三个子表)
-
-### 角色档案
-| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |
-|------|----------|----------|----------|----------|------------|----------|----------|
-
-### 相遇记录
-| 角色A | 角色B | 首次相遇章 | 最近交互章 | 关系性质 | 关系变化 |
-|-------|-------|------------|------------|----------|----------|
-
-### 信息边界
-| 角色 | 已知信息 | 未知信息 | 信息来源章 |
-|------|----------|----------|------------|`;
+(更新后的角色矩阵,每个角色一个 ## 块)
+
+## 角色名
+- **定位**: 主角 / 反派 / 盟友 / 配角 / 提及
+- **标签**: 核心身份标签
+- **反差**: 打破刻板印象的独特细节
+- **说话**: 说话风格概述
+- **性格**: 性格底色
+- **动机**: 根本驱动力
+- **当前**: 本章即时目标
+- **关系**: 某角色(关系性质/Ch#) | ...
+- **已知**: 该角色已知的信息(仅限亲历或被告知)
+- **未知**: 该角色不知道的信息`;
 }

+ 1 - 1
packages/core/src/agents/writer.ts

@@ -546,7 +546,7 @@ export class WriterAgent extends BaseAgent {
         { role: "system", content: observerSystem },
         { role: "user", content: observerUser },
       ],
-      { maxTokens: 4096, temperature: 0.5 },
+      { temperature: 0.5 },
     );
     const observations = observerResponse.content;
 

+ 23 - 0
packages/core/src/index.ts

@@ -90,10 +90,12 @@ export {
 } from "./interaction/events.js";
 export {
   BookCreationDraftSchema,
+  DraftRoundSchema,
   PendingDecisionSchema,
   InteractionMessageSchema,
   InteractionSessionSchema,
   type BookCreationDraft,
+  type DraftRound,
   type PendingDecision,
   type InteractionMessage,
   type InteractionSession,
@@ -104,6 +106,12 @@ export {
   updateCreationDraft,
   appendInteractionMessage,
   appendInteractionEvent,
+  BookSessionSchema,
+  GlobalSessionSchema,
+  type BookSession,
+  type GlobalSession,
+  createBookSession,
+  appendBookSessionMessage,
 } from "./interaction/session.js";
 export {
   resolveProjectSessionPath,
@@ -111,7 +119,10 @@ export {
   loadProjectSession,
   persistProjectSession,
   resolveSessionActiveBook,
+  loadGlobalSession,
+  persistGlobalSession,
 } from "./interaction/project-session-store.js";
+export { loadBookSession, persistBookSession, listBookSessions, findOrCreateBookSession } from "./interaction/book-session-store.js";
 export { routeInteractionRequest } from "./interaction/request-router.js";
 export {
   routeNaturalLanguageIntent,
@@ -140,9 +151,21 @@ export {
   type InteractionRuntimeTools,
   type InteractionRuntimeResult,
 } from "./interaction/runtime.js";
+export {
+  parseDraftDirectives,
+  createDirectiveStreamFilter,
+  type ParsedDraftResponse,
+} from "./interaction/draft-directive-parser.js";
+
+// Agent (pi-agent integration)
+export * from "./agent/index.js";
 
 // LLM
 export { createLLMClient, chatCompletion, chatWithTools, createStreamMonitor, PartialResponseError, type LLMClient, type LLMResponse, type LLMMessage, type ToolDefinition, type ToolCall, type AgentMessage, type ChatWithToolsResult, type StreamProgress, type OnStreamProgress } from "./llm/provider.js";
+export { SERVICE_PRESETS, SERVICE_TO_PI_PROVIDER, resolveServicePreset, guessServiceFromBaseUrl, listModelsForService, listServicesWithModelCount, type ServicePreset, type ModelInfo } from "./llm/service-presets.js";
+export { resolveServiceModel, type ResolvedModel } from "./llm/service-resolver.js";
+export { loadSecrets, saveSecrets, getServiceApiKey, type SecretsFile } from "./llm/secrets.js";
+export { migrateConfig, type MigrationResult } from "./llm/config-migration.js";
 
 // Agents
 export { BaseAgent, type AgentContext } from "./agents/base.js";

+ 78 - 0
packages/core/src/interaction/book-session-store.ts

@@ -0,0 +1,78 @@
+import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
+import { join } from "node:path";
+import { BookSessionSchema, createBookSession } from "./session.js";
+import type { BookSession } from "./session.js";
+
+const SESSIONS_DIR = ".inkos/sessions";
+
+function sessionsDir(projectRoot: string): string {
+  return join(projectRoot, SESSIONS_DIR);
+}
+
+function sessionPath(projectRoot: string, sessionId: string): string {
+  return join(sessionsDir(projectRoot), `${sessionId}.json`);
+}
+
+export async function loadBookSession(
+  projectRoot: string,
+  sessionId: string,
+): Promise<BookSession | null> {
+  try {
+    const raw = await readFile(sessionPath(projectRoot, sessionId), "utf-8");
+    return BookSessionSchema.parse(JSON.parse(raw));
+  } catch {
+    return null;
+  }
+}
+
+export async function persistBookSession(
+  projectRoot: string,
+  session: BookSession,
+): Promise<void> {
+  const dir = sessionsDir(projectRoot);
+  await mkdir(dir, { recursive: true });
+  await writeFile(
+    sessionPath(projectRoot, session.sessionId),
+    JSON.stringify(session, null, 2),
+  );
+}
+
+export async function listBookSessions(
+  projectRoot: string,
+  bookId: string | null,
+): Promise<ReadonlyArray<BookSession>> {
+  const dir = sessionsDir(projectRoot);
+  let files: string[];
+  try {
+    files = await readdir(dir);
+  } catch {
+    return [];
+  }
+
+  const sessions: BookSession[] = [];
+  for (const file of files) {
+    if (!file.endsWith(".json")) continue;
+    try {
+      const raw = await readFile(join(dir, file), "utf-8");
+      const session = BookSessionSchema.parse(JSON.parse(raw));
+      if (session.bookId === bookId) {
+        sessions.push(session);
+      }
+    } catch {
+      // skip corrupt files
+    }
+  }
+
+  return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
+}
+
+export async function findOrCreateBookSession(
+  projectRoot: string,
+  bookId: string | null,
+): Promise<BookSession> {
+  const existing = await listBookSessions(projectRoot, bookId);
+  if (existing.length > 0) return existing[0];
+  const session = createBookSession(bookId);
+  await persistBookSession(projectRoot, session);
+  return session;
+}

+ 266 - 0
packages/core/src/interaction/draft-directive-parser.ts

@@ -0,0 +1,266 @@
+/**
+ * Draft directive parser — extracts structured form data from LLM output
+ * that uses markdown directive syntax (:::type{attrs}...:::).
+ *
+ * Used by both TUI (textContent) and Studio (raw + fields).
+ */
+
+// ---------------------------------------------------------------------------
+// Public types
+// ---------------------------------------------------------------------------
+
+export interface ParsedDraftResponse {
+  /** key → value extracted from directive blocks */
+  fields: Record<string, string>;
+  /** Raw text with all ::: directive blocks stripped (for TUI display) */
+  textContent: string;
+  /** Auto-generated turn summary, e.g. "确立了书名、世界观和主角" */
+  summary: string;
+  /** Original LLM output, untouched */
+  raw: string;
+}
+
+// ---------------------------------------------------------------------------
+// Attribute parsing
+// ---------------------------------------------------------------------------
+
+interface DirectiveAttrs {
+  type: string; // "field" | "pick" | "number" | "group"
+  key?: string;
+  label?: string;
+  fieldType?: string; // the `type` attribute on field directives
+}
+
+const DIRECTIVE_OPEN_RE = /^:::(field|pick|number|group)\{(.+)\}\s*$/;
+const DIRECTIVE_CLOSE_RE = /^:::\s*$/;
+const CODE_FENCE_RE = /^(`{3,}|~{3,})/;
+const LIST_ITEM_RE = /^-\s+(.+)$/;
+
+function parseAttrs(attrStr: string): Record<string, string> {
+  const attrs: Record<string, string> = {};
+  // Match key="value" or key='value'
+  const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(attrStr)) !== null) {
+    attrs[m[1]!] = m[2] ?? m[3] ?? "";
+  }
+  return attrs;
+}
+
+function parseDirectiveOpen(line: string): DirectiveAttrs | null {
+  const m = DIRECTIVE_OPEN_RE.exec(line);
+  if (!m) return null;
+  const type = m[1]!;
+  const rawAttrs = parseAttrs(m[2]!);
+  return {
+    type,
+    key: rawAttrs["key"],
+    label: rawAttrs["label"],
+    fieldType: rawAttrs["type"],
+  };
+}
+
+// ---------------------------------------------------------------------------
+// State machine for full-text parsing
+// ---------------------------------------------------------------------------
+
+type ParserMode = "text" | "directive" | "codeblock";
+
+interface DirectiveFrame {
+  attrs: DirectiveAttrs;
+  contentLines: string[];
+}
+
+/**
+ * Parse raw LLM output containing markdown directive blocks.
+ *
+ * State machine:
+ *   text → directive (on :::type{...})
+ *   text → codeblock (on ``` or ~~~)
+ *   directive → text (on standalone :::)
+ *   directive → directive (on nested :::type{...} inside group)
+ *   codeblock → text (on matching fence close)
+ */
+export function parseDraftDirectives(raw: string): ParsedDraftResponse {
+  const lines = raw.split("\n");
+  const fields: Record<string, string> = {};
+  const labels: string[] = [];
+  const textLines: string[] = [];
+
+  let mode: ParserMode = "text";
+  let codeFenceMarker = "";
+  // Stack of open directives — supports nesting (group > field/number).
+  const stack: DirectiveFrame[] = [];
+
+  for (const line of lines) {
+    // --- Code-block handling (highest priority) ---
+    if (mode === "codeblock") {
+      textLines.push(line);
+      if (CODE_FENCE_RE.test(line) && line.trimStart().startsWith(codeFenceMarker)) {
+        mode = "text";
+        codeFenceMarker = "";
+      }
+      continue;
+    }
+
+    if (mode === "text") {
+      const fenceMatch = CODE_FENCE_RE.exec(line);
+      if (fenceMatch) {
+        codeFenceMarker = fenceMatch[1]!;
+        mode = "codeblock";
+        textLines.push(line);
+        continue;
+      }
+    }
+
+    // --- Directive close (standalone :::) ---
+    if (DIRECTIVE_CLOSE_RE.test(line) && stack.length > 0) {
+      const frame = stack.pop()!;
+      const { attrs, contentLines } = frame;
+
+      if (attrs.type !== "group" && attrs.key) {
+        const value = extractValue(attrs.type, contentLines);
+        fields[attrs.key] = value;
+        if (attrs.label) {
+          labels.push(attrs.label);
+        }
+      }
+
+      // If we just closed the last frame, we're back in text mode
+      if (stack.length === 0) {
+        mode = "text";
+      }
+      continue;
+    }
+
+    // --- Directive open ---
+    const directiveOpen = parseDirectiveOpen(line);
+    if (directiveOpen) {
+      stack.push({ attrs: directiveOpen, contentLines: [] });
+      mode = "directive";
+      continue;
+    }
+
+    // --- Inside a directive: collect content ---
+    if (mode === "directive" && stack.length > 0) {
+      stack[stack.length - 1]!.contentLines.push(line);
+      continue;
+    }
+
+    // --- Normal text ---
+    textLines.push(line);
+  }
+
+  return {
+    fields,
+    textContent: textLines.join("\n"),
+    summary: buildSummary(labels),
+    raw,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// Value extraction per directive type
+// ---------------------------------------------------------------------------
+
+function extractValue(type: string, contentLines: string[]): string {
+  if (type === "pick") {
+    // Extract first list item value
+    for (const line of contentLines) {
+      const m = LIST_ITEM_RE.exec(line.trim());
+      if (m) return m[1]!.trim();
+    }
+    return "";
+  }
+
+  // field, number — join all content lines, trim surrounding whitespace
+  return contentLines.join("\n").trim();
+}
+
+// ---------------------------------------------------------------------------
+// Summary builder
+// ---------------------------------------------------------------------------
+
+function buildSummary(labels: string[]): string {
+  if (labels.length === 0) return "";
+  if (labels.length === 1) return `确立了${labels[0]}`;
+  if (labels.length === 2) return `确立了${labels[0]}和${labels[1]}`;
+  // 3+: 确立了A、B和C
+  const allButLast = labels.slice(0, -1).join("、");
+  return `确立了${allButLast}和${labels[labels.length - 1]}`;
+}
+
+// ---------------------------------------------------------------------------
+// Streaming filter
+// ---------------------------------------------------------------------------
+
+/**
+ * Creates a stateful filter function for streaming LLM output.
+ * Text portions pass through immediately; directive blocks (:::...:::)
+ * are buffered and suppressed.
+ *
+ * Usage:
+ *   const filter = createDirectiveStreamFilter();
+ *   onChunk(chunk => { const visible = filter(chunk); display(visible); });
+ */
+export function createDirectiveStreamFilter(): (chunk: string) => string {
+  let depth = 0; // nesting depth of open directives
+  let inCodeBlock = false;
+  let codeFenceMarker = "";
+
+  return (chunk: string): string => {
+    const lines = chunk.split("\n");
+    const outputParts: string[] = [];
+
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i]!;
+      const isLastLine = i === lines.length - 1;
+
+      // --- Code-block toggle ---
+      if (inCodeBlock) {
+        if (CODE_FENCE_RE.test(line) && line.trimStart().startsWith(codeFenceMarker)) {
+          inCodeBlock = false;
+          codeFenceMarker = "";
+        }
+        outputParts.push(line);
+        if (!isLastLine) outputParts.push("\n");
+        continue;
+      }
+
+      // Detect code-fence opening (only outside directives)
+      if (depth === 0) {
+        const fenceMatch = CODE_FENCE_RE.exec(line);
+        if (fenceMatch) {
+          inCodeBlock = true;
+          codeFenceMarker = fenceMatch[1]!;
+          outputParts.push(line);
+          if (!isLastLine) outputParts.push("\n");
+          continue;
+        }
+      }
+
+      // --- Directive open ---
+      if (parseDirectiveOpen(line)) {
+        depth++;
+        continue;
+      }
+
+      // --- Directive close ---
+      if (DIRECTIVE_CLOSE_RE.test(line) && depth > 0) {
+        depth--;
+        continue;
+      }
+
+      // --- Inside directive: suppress ---
+      if (depth > 0) {
+        continue;
+      }
+
+      // --- Normal text: pass through ---
+      outputParts.push(line);
+      if (!isLastLine) outputParts.push("\n");
+    }
+
+    return outputParts.join("");
+  };
+}

+ 23 - 1
packages/core/src/interaction/project-session-store.ts

@@ -1,6 +1,6 @@
 import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
 import { join } from "node:path";
-import { InteractionSessionSchema, type InteractionSession } from "./session.js";
+import { InteractionSessionSchema, type InteractionSession, GlobalSessionSchema, type GlobalSession } from "./session.js";
 
 const SESSION_DIR = ".inkos";
 const SESSION_FILE = "session.json";
@@ -36,6 +36,28 @@ export async function persistProjectSession(
   await writeFile(resolveProjectSessionPath(projectRoot), JSON.stringify(session, null, 2), "utf-8");
 }
 
+export async function loadGlobalSession(projectRoot: string): Promise<GlobalSession> {
+  try {
+    const raw = await readFile(join(projectRoot, SESSION_DIR, SESSION_FILE), "utf-8");
+    const data = JSON.parse(raw);
+    return GlobalSessionSchema.parse({
+      activeBookId: data.activeBookId,
+      automationMode: data.automationMode ?? "semi",
+    });
+  } catch {
+    return { automationMode: "semi" };
+  }
+}
+
+export async function persistGlobalSession(
+  projectRoot: string,
+  global: GlobalSession,
+): Promise<void> {
+  const dir = join(projectRoot, SESSION_DIR);
+  await mkdir(dir, { recursive: true });
+  await writeFile(join(dir, SESSION_FILE), JSON.stringify(global, null, 2));
+}
+
 export async function resolveSessionActiveBook(
   projectRoot: string,
   session: InteractionSession,

+ 175 - 120
packages/core/src/interaction/project-tools.ts

@@ -9,8 +9,9 @@ import type {
   LLMClient,
   BookConfig,
   Platform,
+  ToolDefinition,
 } from "../index.js";
-import { chatCompletion } from "../index.js";
+import { chatCompletion, chatWithTools } from "../index.js";
 import { executeEditTransaction } from "./edit-controller.js";
 import type { InteractionRuntimeTools } from "./runtime.js";
 import type { BookCreationDraft } from "./session.js";
@@ -45,84 +46,6 @@ function normalizePlatform(platform?: string): Platform {
   }
 }
 
-function extractBalancedJsonObject(text: string): string | null {
-  const start = text.indexOf("{");
-  if (start < 0) {
-    return null;
-  }
-
-  let depth = 0;
-  let inString = false;
-  let escaped = false;
-
-  for (let index = start; index < text.length; index += 1) {
-    const char = text[index]!;
-
-    if (inString) {
-      if (escaped) {
-        escaped = false;
-        continue;
-      }
-      if (char === "\\") {
-        escaped = true;
-        continue;
-      }
-      if (char === "\"") {
-        inString = false;
-      }
-      continue;
-    }
-
-    if (char === "\"") {
-      inString = true;
-      continue;
-    }
-
-    if (char === "{") {
-      depth += 1;
-      continue;
-    }
-
-    if (char === "}") {
-      depth -= 1;
-      if (depth === 0) {
-        return text.slice(start, index + 1);
-      }
-      if (depth < 0) {
-        return null;
-      }
-    }
-  }
-
-  return null;
-}
-
-function parseCreationDraftResult(text: string): {
-  readonly assistantReply: string;
-  readonly draft: BookCreationDraft;
-} | null {
-  const candidate = extractBalancedJsonObject(text);
-  if (!candidate) {
-    return null;
-  }
-
-  try {
-    const parsed = JSON.parse(candidate) as {
-      assistantReply?: string;
-      draft?: BookCreationDraft;
-    };
-    if (!parsed.assistantReply || !parsed.draft) {
-      return null;
-    }
-    return {
-      assistantReply: parsed.assistantReply,
-      draft: parsed.draft,
-    };
-  } catch {
-    return null;
-  }
-}
-
 function deriveBookId(title: string): string {
   return title
     .toLowerCase()
@@ -437,11 +360,142 @@ async function withPipelineInteractionTelemetry<T extends { chapterNumber?: numb
   }
 }
 
+const CREATE_BOOK_TOOL: ToolDefinition = {
+  name: "create_book",
+  description: "根据用户描述生成建书参数。系统会将参数渲染为可编辑表单,用户确认后建书。",
+  parameters: {
+    type: "object",
+    properties: {
+      title: { type: "string", description: "书名" },
+      genre: { type: "string", description: "题材标识,如 xuanhuan, urban, romance, scifi, mystery" },
+      platform: { type: "string", enum: ["tomato", "qidian", "feilu", "other"], description: "发布平台" },
+      targetChapters: { type: "number", description: "目标章数,默认 200" },
+      chapterWordCount: { type: "number", description: "每章字数,默认 3000" },
+      language: { type: "string", enum: ["zh", "en"], description: "写作语言,默认 zh" },
+      brief: { type: "string", description: "创意简述,会传给 Architect 智能体生成完整的世界观、主角、冲突等 foundation 文件。把用户提到的所有创意要素都写进这里。" },
+    },
+    required: ["title", "genre", "platform", "brief"],
+  },
+};
+
+const BOOK_DRAFT_SYSTEM_PROMPT = [
+  "你是 InkOS 的建书助手。用户会描述想写的书,你需要调用 create_book 工具来生成建书参数。",
+  "",
+  "规则:",
+  "1. 从用户描述中推断所有字段,大胆预填合理默认值。",
+  "2. brief 字段要详细——它会传给 Architect 智能体生成完整的世界观、主角、冲突等 foundation 文件。把用户提到的所有创意要素都写进 brief。",
+  "3. 如果用户后续要求修改某些字段,重新调用 create_book 工具,只更新被提到的字段,其余保持不变。",
+  "4. 不要只回复文字讨论——必须调用 create_book 工具输出结构化参数。",
+].join("\n");
+
+/** Map directive field keys to BookCreationDraft property names. */
+function applyFieldsToDraft(
+  existing: BookCreationDraft | undefined,
+  fields: Readonly<Record<string, string>>,
+  concept: string,
+): BookCreationDraft {
+  const draft: BookCreationDraft = {
+    concept,
+    missingFields: [],
+    readyToCreate: false,
+    ...(existing ?? {}),
+  };
+
+  for (const [key, value] of Object.entries(fields)) {
+    if (!value) continue;
+
+    switch (key) {
+      case "title":
+        draft.title = value;
+        break;
+      case "genre":
+        draft.genre = value;
+        break;
+      case "platform":
+        draft.platform = value;
+        break;
+      case "language":
+        if (value === "zh" || value === "en") draft.language = value;
+        break;
+      case "targetChapters": {
+        const n = parseInt(value, 10);
+        if (!Number.isNaN(n) && n > 0) draft.targetChapters = n;
+        break;
+      }
+      case "chapterWordCount":
+      case "chapterLength": {
+        const n = parseInt(value, 10);
+        if (!Number.isNaN(n) && n > 0) draft.chapterWordCount = n;
+        break;
+      }
+      case "blurb":
+        draft.blurb = value;
+        break;
+      case "worldPremise":
+        draft.worldPremise = value;
+        break;
+      case "settingNotes":
+        draft.settingNotes = value;
+        break;
+      case "protagonist":
+        draft.protagonist = value;
+        break;
+      case "supportingCast":
+        draft.supportingCast = value;
+        break;
+      case "conflictCore":
+        draft.conflictCore = value;
+        break;
+      case "volumeOutline":
+        draft.volumeOutline = value;
+        break;
+      case "constraints":
+        draft.constraints = value;
+        break;
+      case "authorIntent":
+        draft.authorIntent = value;
+        break;
+      case "currentFocus":
+        draft.currentFocus = value;
+        break;
+      // Unknown keys are silently ignored — the LLM may emit
+      // application-level keys we don't map to the draft struct.
+    }
+  }
+
+  return draft;
+}
+
+function formatDraftForUserMessage(
+  existingDraft: BookCreationDraft | undefined,
+  userMessage: string,
+): string {
+  const parts: string[] = [];
+
+  if (existingDraft) {
+    parts.push("## 当前草案状态");
+    const entries = Object.entries(existingDraft).filter(
+      ([, v]) => v !== undefined && v !== "" && !(Array.isArray(v) && v.length === 0),
+    );
+    for (const [key, value] of entries) {
+      parts.push(`- **${key}**: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`);
+    }
+    parts.push("");
+  }
+
+  parts.push("## 用户输入");
+  parts.push(userMessage);
+
+  return parts.join("\n");
+}
+
 export function createInteractionToolsFromDeps(
   pipeline: PipelineLike,
   state: StateLike,
   hooks?: {
     readonly onChatTextDelta?: (text: string) => void;
+    readonly onDraftTextDelta?: (text: string) => void;
+    readonly onDraftRawDelta?: (text: string) => void;
     readonly getChatRequestOptions?: () => {
       readonly temperature?: number;
       readonly maxTokens?: number;
@@ -453,70 +507,71 @@ export function createInteractionToolsFromDeps(
   return {
     listBooks: () => state.listBooks(),
     developBookDraft: async (input, existingDraft) => {
+      const concept = existingDraft?.concept ?? input;
+
       if (!instrumentedPipeline.config?.client || !instrumentedPipeline.config?.model) {
-        const concept = existingDraft?.concept ?? input;
+        // Fallback: no LLM configured
         return {
           __interaction: {
-            responseText: "先把这本书的大概方向收住。你更想写长篇连载,还是十来章能收住的版本?",
+            responseText: "请先配置 LLM 模型,然后再创建书籍。",
             details: {
               creationDraft: {
                 concept,
-                title: existingDraft?.title,
-                genre: existingDraft?.genre,
-                platform: existingDraft?.platform,
-                language: existingDraft?.language,
-                targetChapters: existingDraft?.targetChapters,
-                chapterWordCount: existingDraft?.chapterWordCount,
-                blurb: existingDraft?.blurb,
-                authorIntent: existingDraft?.authorIntent,
-                currentFocus: existingDraft?.currentFocus,
-                nextQuestion: "你更想写长篇连载,还是十来章能收住的版本?",
-                missingFields: existingDraft?.missingFields ?? ["title", "genre", "targetChapters"],
-                readyToCreate: existingDraft?.readyToCreate ?? false,
-              } satisfies BookCreationDraft,
+                missingFields: ["title", "genre", "targetChapters"],
+                readyToCreate: false,
+              },
             },
           },
         };
       }
 
-      const response = await chatCompletion(
+      // Build messages - include existing draft context if present
+      const userContent = existingDraft
+        ? `当前草案参数:${JSON.stringify(existingDraft, null, 2)}\n\n用户输入:${input}`
+        : input;
+
+      const result = await chatWithTools(
         instrumentedPipeline.config.client,
         instrumentedPipeline.config.model,
         [
-          {
-            role: "system",
-            content: [
-              "You are InkOS book ideation assistant.",
-              "Turn the user's latest message and the current draft into a tighter book creation draft.",
-              "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, 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(" "),
-          },
-          {
-            role: "user",
-            content: JSON.stringify({
-              currentDraft: existingDraft ?? null,
-              latestMessage: input,
-            }, null, 2),
-          },
+          { role: "system", content: BOOK_DRAFT_SYSTEM_PROMPT },
+          { role: "user", content: userContent },
         ],
+        [CREATE_BOOK_TOOL],
         { temperature: 0.4 },
       );
 
-      const parsed = parseCreationDraftResult(response.content);
-      if (!parsed) {
-        throw new Error("Book draft assistant returned invalid JSON.");
+      // Extract tool call if present
+      const toolCall = result.toolCalls[0];
+      let parsedArgs: Record<string, unknown> = {};
+      if (toolCall) {
+        try {
+          parsedArgs = JSON.parse(toolCall.arguments);
+        } catch {
+          // If parsing fails, use empty args
+        }
       }
 
+      // Build a draft from tool call arguments
+      const draft: BookCreationDraft = {
+        concept,
+        title: (parsedArgs.title as string) ?? existingDraft?.title,
+        genre: (parsedArgs.genre as string) ?? existingDraft?.genre,
+        platform: (parsedArgs.platform as string) ?? existingDraft?.platform,
+        language: (parsedArgs.language as "zh" | "en") ?? existingDraft?.language,
+        targetChapters: (parsedArgs.targetChapters as number) ?? existingDraft?.targetChapters,
+        chapterWordCount: (parsedArgs.chapterWordCount as number) ?? existingDraft?.chapterWordCount,
+        blurb: (parsedArgs.brief as string) ?? existingDraft?.blurb,
+        missingFields: [],
+        readyToCreate: Boolean(parsedArgs.title && parsedArgs.genre && parsedArgs.platform),
+      };
+
       return {
         __interaction: {
-          responseText: parsed.assistantReply,
+          responseText: result.content || "已生成建书参数,请确认或修改。",
           details: {
-            creationDraft: parsed.draft,
+            creationDraft: draft,
+            toolCall: toolCall ? { name: toolCall.name, arguments: parsedArgs } : undefined,
           },
         },
       };

+ 15 - 2
packages/core/src/interaction/runtime.ts

@@ -2,7 +2,7 @@ import type { AutomationMode } from "./modes.js";
 import { routeInteractionRequest } from "./request-router.js";
 import type { InteractionRequest } from "./intents.js";
 import type { ExecutionState, InteractionEvent } from "./events.js";
-import type { PendingDecision, InteractionSession } from "./session.js";
+import type { PendingDecision, InteractionSession, DraftRound } from "./session.js";
 import {
   appendInteractionEvent,
   bindActiveBook,
@@ -380,7 +380,20 @@ async function handleDraftLifecycleRequest(params: {
           en: "Book-draft tool did not return draft data.",
         }));
       }
-      const nextSession = appendToolEvents(updateCreationDraft(session, draft), metadata.events);
+      const newRound: DraftRound = {
+        roundId: (session.draftRounds?.length ?? 0) + 1,
+        userMessage: request.instruction ?? "",
+        assistantRaw: metadata.details?.draftRaw as string ?? "",
+        fieldsUpdated: (metadata.details?.fieldsUpdated as string[]) ?? [],
+        summary: metadata.details?.draftSummary as string ?? "",
+        timestamp: Date.now(),
+      };
+      const withDraft = updateCreationDraft(session, draft);
+      const withRounds = {
+        ...withDraft,
+        draftRounds: [...(withDraft.draftRounds ?? []), newRound],
+      };
+      const nextSession = appendToolEvents(withRounds, metadata.events);
       const completed = {
         ...markCompleted(nextSession),
         currentExecution: metadata.currentExecution ?? markCompleted(nextSession).currentExecution,

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

@@ -11,9 +11,34 @@ export const PendingDecisionSchema = z.object({
 
 export type PendingDecision = z.infer<typeof PendingDecisionSchema>;
 
+export const PipelineStageSchema = z.object({
+  label: z.string(),
+  status: z.enum(["pending", "active", "completed"]),
+});
+
+export type PipelineStage = z.infer<typeof PipelineStageSchema>;
+
+export const ToolExecutionSchema = z.object({
+  id: z.string(),
+  tool: z.string(),
+  agent: z.string().optional(),
+  label: z.string(),
+  status: z.enum(["running", "processing", "completed", "error"]),
+  args: z.record(z.unknown()).optional(),
+  result: z.string().optional(),
+  error: z.string().optional(),
+  stages: z.array(PipelineStageSchema).optional(),
+  startedAt: z.number(),
+  completedAt: z.number().optional(),
+});
+
+export type ToolExecution = z.infer<typeof ToolExecutionSchema>;
+
 export const InteractionMessageSchema = z.object({
   role: z.enum(["user", "assistant", "system"]),
   content: z.string().min(1),
+  thinking: z.string().optional(),
+  toolExecutions: z.array(ToolExecutionSchema).optional(),
   timestamp: z.number().int().nonnegative(),
 });
 
@@ -44,12 +69,24 @@ export const BookCreationDraftSchema = z.object({
 
 export type BookCreationDraft = z.infer<typeof BookCreationDraftSchema>;
 
+export const DraftRoundSchema = z.object({
+  roundId: z.number().int().min(1),
+  userMessage: z.string(),
+  assistantRaw: z.string(),
+  fieldsUpdated: z.array(z.string()).default([]),
+  summary: z.string().default(""),
+  timestamp: z.number().int().nonnegative(),
+});
+
+export type DraftRound = z.infer<typeof DraftRoundSchema>;
+
 export const InteractionSessionSchema = z.object({
   sessionId: z.string().min(1),
   projectRoot: z.string().min(1),
   activeBookId: z.string().min(1).optional(),
   activeChapterNumber: z.number().int().min(1).optional(),
   creationDraft: BookCreationDraftSchema.optional(),
+  draftRounds: z.array(DraftRoundSchema).default([]),
   automationMode: AutomationModeSchema.default("semi"),
   messages: z.array(InteractionMessageSchema).default([]),
   events: z.array(InteractionEventSchema).default([]),
@@ -59,6 +96,55 @@ export const InteractionSessionSchema = z.object({
 
 export type InteractionSession = z.infer<typeof InteractionSessionSchema>;
 
+// -- Per-book session --
+
+export const BookSessionSchema = z.object({
+  sessionId: z.string().min(1),
+  bookId: z.string().nullable(),
+  messages: z.array(InteractionMessageSchema).default([]),
+  creationDraft: BookCreationDraftSchema.optional(),
+  draftRounds: z.array(DraftRoundSchema).default([]),
+  events: z.array(InteractionEventSchema).default([]),
+  currentExecution: ExecutionStateSchema.optional(),
+  createdAt: z.number().int().nonnegative(),
+  updatedAt: z.number().int().nonnegative(),
+});
+
+export type BookSession = z.infer<typeof BookSessionSchema>;
+
+// -- Global session (simplified) --
+
+export const GlobalSessionSchema = z.object({
+  activeBookId: z.string().min(1).optional(),
+  automationMode: AutomationModeSchema.default("semi"),
+});
+
+export type GlobalSession = z.infer<typeof GlobalSessionSchema>;
+
+export function createBookSession(bookId: string | null): BookSession {
+  const now = Date.now();
+  return {
+    sessionId: `${now}-${Math.random().toString(36).slice(2, 8)}`,
+    bookId,
+    messages: [],
+    draftRounds: [],
+    events: [],
+    createdAt: now,
+    updatedAt: now,
+  };
+}
+
+export function appendBookSessionMessage(
+  session: BookSession,
+  message: InteractionMessage,
+): BookSession {
+  return {
+    ...session,
+    messages: [...session.messages, message].sort((a, b) => a.timestamp - b.timestamp),
+    updatedAt: Date.now(),
+  };
+}
+
 export function bindActiveBook(
   session: InteractionSession,
   bookId: string,
@@ -100,6 +186,7 @@ export function clearCreationDraft(session: InteractionSession): InteractionSess
   return {
     ...session,
     creationDraft: undefined,
+    draftRounds: [],
   };
 }
 

+ 58 - 0
packages/core/src/llm/config-migration.ts

@@ -0,0 +1,58 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { saveSecrets, loadSecrets } from "./secrets.js";
+import { guessServiceFromBaseUrl } from "./service-presets.js";
+
+export interface MigrationResult {
+  migrated: boolean;
+}
+
+export async function migrateConfig(projectRoot: string): Promise<MigrationResult> {
+  const configPath = join(projectRoot, "inkos.json");
+  let raw: string;
+  try {
+    raw = await readFile(configPath, "utf-8");
+  } catch {
+    return { migrated: false };
+  }
+
+  const config = JSON.parse(raw);
+  const llm = config.llm;
+  if (!llm) return { migrated: false };
+
+  // Already new format
+  if (Array.isArray(llm.services)) return { migrated: false };
+
+  // Old format: llm.provider, llm.model, llm.baseUrl, llm.apiKey
+  const { provider, model, baseUrl, apiKey, ...restLlm } = llm;
+  if (!model && !provider) return { migrated: false };
+
+  // Determine service from baseUrl
+  const guessedService = baseUrl ? guessServiceFromBaseUrl(baseUrl) : null;
+  const service = guessedService ?? "custom";
+
+  // Build new service entry
+  const serviceEntry: Record<string, string> = { service };
+  if (service === "custom") {
+    serviceEntry.name = "Custom";
+    if (baseUrl) serviceEntry.baseUrl = baseUrl;
+  }
+
+  // Write new config (no apiKey)
+  config.llm = {
+    ...restLlm,
+    services: [serviceEntry],
+    defaultModel: model,
+  };
+  await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
+
+  // Move apiKey to secrets
+  if (apiKey) {
+    const secrets = await loadSecrets(projectRoot);
+    const secretKey = service === "custom" ? `custom:${serviceEntry.name}` : service;
+    secrets.services[secretKey] = { apiKey };
+    await saveSecrets(projectRoot, secrets);
+  }
+
+  return { migrated: true };
+}

+ 177 - 660
packages/core/src/llm/provider.ts

@@ -1,6 +1,18 @@
-import OpenAI from "openai";
-import Anthropic from "@anthropic-ai/sdk";
 import type { LLMConfig } from "../models/project.js";
+import {
+  streamSimple as piStreamSimple,
+  stream as piStream,
+} from "@mariozechner/pi-ai";
+import type {
+  Api as PiApi,
+  Model as PiModel,
+  Context as PiContext,
+  AssistantMessageEvent,
+  Tool as PiTool,
+  TextContent as PiTextContent,
+  ToolCall as PiToolCall,
+} from "@mariozechner/pi-ai";
+import { resolveServicePreset } from "./service-presets.js";
 
 // === Streaming Monitor Types ===
 
@@ -73,8 +85,8 @@ export interface LLMClient {
   readonly provider: "openai" | "anthropic";
   readonly apiFormat: "chat" | "responses";
   readonly stream: boolean;
-  readonly _openai?: OpenAI;
-  readonly _anthropic?: Anthropic;
+  readonly _piModel?: PiModel<PiApi>;
+  readonly _apiKey?: string;
   readonly defaults: {
     readonly temperature: number;
     readonly maxTokens: number;
@@ -123,28 +135,34 @@ export function createLLMClient(config: LLMConfig): LLMClient {
   const apiFormat = config.apiFormat ?? "chat";
   const stream = config.stream ?? true;
 
-  if (config.provider === "anthropic") {
-    // Anthropic SDK appends /v1/ internally — strip if user included it
-    const baseURL = config.baseUrl.replace(/\/v1\/?$/, "");
-    return {
-      provider: "anthropic",
-      apiFormat,
-      stream,
-      _anthropic: new Anthropic({ apiKey: config.apiKey, baseURL }),
-      defaults,
-    };
-  }
-  // openai or custom — both use OpenAI SDK
+  // --- Build pi-ai Model object ---
+  const serviceName = config.service ?? "custom";
+  const preset = resolveServicePreset(serviceName);
+  const piApi = (preset?.api ?? "openai-completions") as PiApi;
+  const baseUrl = config.baseUrl || preset?.baseUrl || "";
   const extraHeaders = config.headers ?? parseEnvHeaders();
+
+  const piModel: PiModel<PiApi> = {
+    id: config.model,
+    name: config.model,
+    api: piApi,
+    provider: serviceName,
+    baseUrl,
+    reasoning: (config.thinkingBudget ?? 0) > 0,
+    input: ["text"] as ("text" | "image")[],
+    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+    contextWindow: 128_000,
+    maxTokens: config.maxTokens ?? 8192,
+    ...(extraHeaders ? { headers: extraHeaders } : {}),
+  };
+
+  const provider = config.provider === "anthropic" ? "anthropic" : "openai";
   return {
-    provider: "openai",
+    provider,
     apiFormat,
     stream,
-    _openai: new OpenAI({
-      apiKey: config.apiKey,
-      baseURL: config.baseUrl,
-      ...(extraHeaders ? { defaultHeaders: extraHeaders } : {}),
-    }),
+    _piModel: piModel,
+    _apiKey: config.apiKey,
     defaults,
   };
 }
@@ -273,23 +291,6 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
   return error instanceof Error ? error : new Error(msg);
 }
 
-function wrapStreamRequiredError(
-  streamError: unknown,
-  syncError: unknown,
-  context?: { readonly baseUrl?: string; readonly model?: string },
-): Error {
-  const ctxLine = context
-    ? `\n  (baseUrl: ${context.baseUrl}, model: ${context.model})`
-    : "";
-  return new Error(
-    `API 提供方要求使用流式请求(stream:true),不能回退到同步模式。` +
-    `\n  这次失败不是模型名错误,而是前一次流式请求先失败了,随后同步回退又被提供方拒绝。` +
-    `\n  建议:保持 stream:true,并检查该提供方/代理的 SSE 流是否稳定。` +
-    `\n  原始流式错误:${String(streamError)}` +
-    `\n  同步回退错误:${String(syncError)}${ctxLine}`,
-  );
-}
-
 // === Simple Chat (used by all agents via BaseAgent.chat()) ===
 
 export async function chatCompletion(
@@ -316,22 +317,10 @@ export async function chatCompletion(
   };
   const onStreamProgress = options?.onStreamProgress;
   const onTextDelta = options?.onTextDelta;
-  const errorCtx = { baseUrl: client._openai?.baseURL ?? "(anthropic)", model };
+  const errorCtx = { baseUrl: client._piModel?.baseUrl ?? "(unknown)", model };
 
   try {
-    if (client.provider === "anthropic") {
-      return client.stream
-        ? await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onStreamProgress, onTextDelta)
-        : await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onTextDelta);
-    }
-    if (client.apiFormat === "responses") {
-      return client.stream
-        ? await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
-        : await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
-    }
-    return client.stream
-      ? await chatCompletionOpenAIChat(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
-      : await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
+    return await chatCompletionViaPiAi(client, model, messages, resolved, onStreamProgress, onTextDelta);
   } catch (error) {
     // Stream interrupted but partial content is usable — return truncated response
     if (error instanceof PartialResponseError) {
@@ -340,59 +329,10 @@ export async function chatCompletion(
         usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
       };
     }
-
-    // Auto-fallback: if streaming failed, retry with sync (many proxies don't support SSE)
-    if (client.stream) {
-      const isStreamRelated = isLikelyStreamError(error);
-      if (isStreamRelated) {
-        try {
-          if (client.provider === "anthropic") {
-            return await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget);
-          }
-          if (client.apiFormat === "responses") {
-            return await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch);
-          }
-          return await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch);
-        } catch (syncError) {
-          if (isStreamRequiredError(syncError)) {
-            throw wrapStreamRequiredError(error, syncError, errorCtx);
-          }
-          throw wrapLLMError(syncError, errorCtx);
-        }
-      }
-    }
-
     throw wrapLLMError(error, errorCtx);
   }
 }
 
-function isLikelyStreamError(error: unknown): boolean {
-  const msg = String(error).toLowerCase();
-  // Common indicators that streaming specifically is the problem:
-  // - SSE parse errors, chunked transfer issues, content-type mismatches
-  // - Some proxies return 400/415 when stream=true
-  // - "stream" mentioned in error, or generic network errors during streaming
-  return (
-    msg.includes("stream") ||
-    msg.includes("text/event-stream") ||
-    msg.includes("chunked") ||
-    msg.includes("unexpected end") ||
-    msg.includes("premature close") ||
-    msg.includes("terminated") ||
-    msg.includes("econnreset") ||
-    (msg.includes("400") && !msg.includes("content"))
-  );
-}
-
-function isStreamRequiredError(error: unknown): boolean {
-  const msg = String(error).toLowerCase();
-  return (
-    msg.includes("stream must be set to true") ||
-    (msg.includes("stream") && msg.includes("must be set to true")) ||
-    (msg.includes("stream") && msg.includes("required"))
-  );
-}
-
 // === Tool-calling Chat (used by agent loop) ===
 
 export async function chatWithTools(
@@ -413,460 +353,158 @@ export async function chatWithTools(
       ),
       maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
     };
-    // Tool-calling always uses streaming (only used by agent loop, not by writer/auditor)
-    if (client.provider === "anthropic") {
-      return await chatWithToolsAnthropic(client._anthropic!, model, messages, tools, resolved, client.defaults.thinkingBudget);
-    }
-    if (client.apiFormat === "responses") {
-      return await chatWithToolsOpenAIResponses(client._openai!, model, messages, tools, resolved);
-    }
-    return await chatWithToolsOpenAIChat(client._openai!, model, messages, tools, resolved);
+    return await chatWithToolsViaPiAi(client, model, messages, tools, resolved);
   } catch (error) {
     throw wrapLLMError(error);
   }
 }
 
-// === OpenAI Chat Completions API Implementation (default) ===
-
-async function chatCompletionOpenAIChat(
-  client: OpenAI,
-  model: string,
-  messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
-  webSearch?: boolean,
-  onStreamProgress?: OnStreamProgress,
-  onTextDelta?: (text: string) => void,
-): Promise<LLMResponse> {
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const createParams: any = {
-    model,
-    messages: messages.map((m) => ({ role: m.role, content: m.content })),
-    temperature: options.temperature,
-    max_tokens: options.maxTokens,
-    stream: true,
-    ...(webSearch ? { web_search_options: { search_context_size: "medium" as const } } : {}),
-    ...stripReservedKeys(options.extra),
-  };
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const stream = await client.chat.completions.create(createParams) as any;
-
-  const chunks: string[] = [];
-  let inputTokens = 0;
-  let outputTokens = 0;
-  const monitor = createStreamMonitor(onStreamProgress);
-
-  try {
-    for await (const chunk of stream) {
-      const delta = chunk.choices[0]?.delta?.content;
-      if (delta) {
-        chunks.push(delta);
-        monitor.onChunk(delta);
-        onTextDelta?.(delta);
-      }
-      if (chunk.usage) {
-        inputTokens = chunk.usage.prompt_tokens ?? 0;
-        outputTokens = chunk.usage.completion_tokens ?? 0;
-      }
-    }
-  } catch (streamError) {
-    monitor.stop();
-    const partial = chunks.join("");
-    if (partial.length >= MIN_SALVAGEABLE_CHARS) {
-      throw new PartialResponseError(partial, streamError);
-    }
-    throw streamError;
-  } finally {
-    monitor.stop();
-  }
-
-  const content = chunks.join("");
-  if (!content) throw new Error("LLM returned empty response from stream");
-
-  return {
-    content,
-    usage: {
-      promptTokens: inputTokens,
-      completionTokens: outputTokens,
-      totalTokens: inputTokens + outputTokens,
-    },
-  };
-}
-
-async function chatCompletionOpenAIChatSync(
-  client: OpenAI,
-  model: string,
-  messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
-  _webSearch?: boolean,
-  onTextDelta?: (text: string) => void,
-): Promise<LLMResponse> {
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const syncParams: any = {
-    model,
-    messages: messages.map((m) => ({ role: m.role, content: m.content })),
-    temperature: options.temperature,
-    max_tokens: options.maxTokens,
-    stream: false,
-    ...stripReservedKeys(options.extra),
-  };
-  const response = await client.chat.completions.create(syncParams);
-
-  const content = response.choices[0]?.message?.content ?? "";
-  if (!content) throw new Error("LLM returned empty response");
-  onTextDelta?.(content);
-
-  return {
-    content,
-    usage: {
-      promptTokens: response.usage?.prompt_tokens ?? 0,
-      completionTokens: response.usage?.completion_tokens ?? 0,
-      totalTokens: response.usage?.total_tokens ?? 0,
-    },
-  };
-}
-
-async function chatWithToolsOpenAIChat(
-  client: OpenAI,
-  model: string,
-  messages: ReadonlyArray<AgentMessage>,
-  tools: ReadonlyArray<ToolDefinition>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-): Promise<ChatWithToolsResult> {
-  const openaiMessages = agentMessagesToOpenAIChat(messages);
-  const openaiTools: OpenAI.Chat.Completions.ChatCompletionTool[] = tools.map((t) => ({
-    type: "function" as const,
-    function: {
-      name: t.name,
-      description: t.description,
-      parameters: t.parameters,
-    },
-  }));
-
-  const stream = await client.chat.completions.create({
-    model,
-    messages: openaiMessages,
-    tools: openaiTools,
-    temperature: options.temperature,
-    max_tokens: options.maxTokens,
-    stream: true,
-  });
-
-  let content = "";
-  const toolCallMap = new Map<number, { id: string; name: string; arguments: string }>();
-
-  for await (const chunk of stream) {
-    const delta = chunk.choices[0]?.delta;
-    if (delta?.content) content += delta.content;
-    if (delta?.tool_calls) {
-      for (const tc of delta.tool_calls) {
-        const existing = toolCallMap.get(tc.index);
-        if (existing) {
-          existing.arguments += tc.function?.arguments ?? "";
-        } else {
-          toolCallMap.set(tc.index, {
-            id: tc.id ?? "",
-            name: tc.function?.name ?? "",
-            arguments: tc.function?.arguments ?? "",
-          });
-        }
+// === pi-ai Unified Implementation ===
+
+/**
+ * Build a pi-ai Model<Api> for a specific per-call model name.
+ * The base template comes from client._piModel (created in createLLMClient);
+ * we override .id / .name when the caller passes a different model string
+ * (e.g. agent overrides).
+ */
+function resolvePiModel(client: LLMClient, model: string): PiModel<PiApi> {
+  const base = client._piModel!;
+  if (base.id === model) return base;
+  return { ...base, id: model, name: model };
+}
+
+/** Convert inkos LLMMessage[] to pi-ai Context. */
+function toPiContext(messages: ReadonlyArray<LLMMessage>): PiContext {
+  const systemParts = messages.filter((m) => m.role === "system").map((m) => m.content);
+  const systemPrompt = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
+  const piMessages = messages
+    .filter((m) => m.role !== "system")
+    .map((m) => {
+      if (m.role === "user") {
+        return { role: "user" as const, content: m.content, timestamp: Date.now() };
       }
-    }
-  }
-
-  const toolCalls: ToolCall[] = [...toolCallMap.values()];
-  return { content, toolCalls };
-}
-
-function agentMessagesToOpenAIChat(
-  messages: ReadonlyArray<AgentMessage>,
-): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
-  const result: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
-
-  for (const msg of messages) {
-    if (msg.role === "system") {
-      result.push({ role: "system", content: msg.content });
-      continue;
-    }
-    if (msg.role === "user") {
-      result.push({ role: "user", content: msg.content });
-      continue;
-    }
-    if (msg.role === "assistant") {
-      const assistantMsg: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam = {
-        role: "assistant",
-        content: msg.content ?? null,
+      // assistant
+      return {
+        role: "assistant" as const,
+        content: [{ type: "text" as const, text: m.content }],
+        api: "openai-completions" as PiApi,
+        provider: "openai",
+        model: "",
+        usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
+        stopReason: "stop" as const,
+        timestamp: Date.now(),
       };
-      if (msg.toolCalls && msg.toolCalls.length > 0) {
-        assistantMsg.tool_calls = msg.toolCalls.map((tc) => ({
-          id: tc.id,
-          type: "function" as const,
-          function: { name: tc.name, arguments: tc.arguments },
-        }));
-      }
-      result.push(assistantMsg);
-      continue;
-    }
-    if (msg.role === "tool") {
-      result.push({
-        role: "tool",
-        tool_call_id: msg.toolCallId,
-        content: msg.content,
-      });
-    }
-  }
-
-  return result;
-}
-
-// === OpenAI Responses API Implementation (optional) ===
-
-async function chatCompletionOpenAIResponses(
-  client: OpenAI,
-  model: string,
-  messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-  webSearch?: boolean,
-  onStreamProgress?: OnStreamProgress,
-  onTextDelta?: (text: string) => void,
-): Promise<LLMResponse> {
-  const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
-    role: m.role as "system" | "user" | "assistant",
-    content: m.content,
-  }));
-
-  const tools: OpenAI.Responses.Tool[] | undefined = webSearch
-    ? [{ type: "web_search_preview" as const }]
-    : undefined;
-
-  const stream = await client.responses.create({
-    model,
-    input,
-    temperature: options.temperature,
-    max_output_tokens: options.maxTokens,
-    stream: true,
-    ...(tools ? { tools } : {}),
-  });
-
-  const chunks: string[] = [];
-  let inputTokens = 0;
-  let outputTokens = 0;
-  const monitor = createStreamMonitor(onStreamProgress);
-
-  try {
-    for await (const event of stream) {
-      if (event.type === "response.output_text.delta") {
-        chunks.push(event.delta);
-        monitor.onChunk(event.delta);
-        onTextDelta?.(event.delta);
-      }
-      if (event.type === "response.completed") {
-        inputTokens = event.response.usage?.input_tokens ?? 0;
-        outputTokens = event.response.usage?.output_tokens ?? 0;
-      }
-    }
-  } catch (streamError) {
-    monitor.stop();
-    const partial = chunks.join("");
-    if (partial.length >= MIN_SALVAGEABLE_CHARS) {
-      throw new PartialResponseError(partial, streamError);
-    }
-    throw streamError;
-  } finally {
-    monitor.stop();
-  }
-
-  const content = chunks.join("");
-  if (!content) throw new Error("LLM returned empty response from stream");
-
-  return {
-    content,
-    usage: {
-      promptTokens: inputTokens,
-      completionTokens: outputTokens,
-      totalTokens: inputTokens + outputTokens,
-    },
-  };
-}
-
-async function chatCompletionOpenAIResponsesSync(
-  client: OpenAI,
-  model: string,
-  messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-  _webSearch?: boolean,
-  onTextDelta?: (text: string) => void,
-): Promise<LLMResponse> {
-  const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
-    role: m.role as "system" | "user" | "assistant",
-    content: m.content,
-  }));
-
-  const response = await client.responses.create({
-    model,
-    input,
-    temperature: options.temperature,
-    max_output_tokens: options.maxTokens,
-    stream: false,
-  });
-
-  const content = response.output
-    .filter((item): item is OpenAI.Responses.ResponseOutputMessage => item.type === "message")
-    .flatMap((item) => item.content)
-    .filter((block): block is OpenAI.Responses.ResponseOutputText => block.type === "output_text")
-    .map((block) => block.text)
-    .join("");
-
-  if (!content) throw new Error("LLM returned empty response");
-  onTextDelta?.(content);
-
-  return {
-    content,
-    usage: {
-      promptTokens: response.usage?.input_tokens ?? 0,
-      completionTokens: response.usage?.output_tokens ?? 0,
-      totalTokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0),
-    },
-  };
+    });
+  return { systemPrompt, messages: piMessages };
 }
 
-async function chatWithToolsOpenAIResponses(
-  client: OpenAI,
-  model: string,
-  messages: ReadonlyArray<AgentMessage>,
-  tools: ReadonlyArray<ToolDefinition>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-): Promise<ChatWithToolsResult> {
-  const input = agentMessagesToResponsesInput(messages);
-  const responsesTools: OpenAI.Responses.Tool[] = tools.map((t) => ({
-    type: "function" as const,
-    name: t.name,
-    description: t.description,
-    parameters: t.parameters as OpenAI.Responses.FunctionTool["parameters"],
-    strict: false,
-  }));
-
-  const stream = await client.responses.create({
-    model,
-    input,
-    tools: responsesTools,
-    temperature: options.temperature,
-    max_output_tokens: options.maxTokens,
-    stream: true,
-  });
-
-  let content = "";
-  const toolCalls: ToolCall[] = [];
-
-  for await (const event of stream) {
-    if (event.type === "response.output_text.delta") {
-      content += event.delta;
-    }
-    if (event.type === "response.output_item.done" && event.item.type === "function_call") {
-      toolCalls.push({
-        id: event.item.call_id,
-        name: event.item.name,
-        arguments: event.item.arguments,
-      });
-    }
-  }
-
-  return { content, toolCalls };
-}
-
-function agentMessagesToResponsesInput(
-  messages: ReadonlyArray<AgentMessage>,
-): OpenAI.Responses.ResponseInputItem[] {
-  const result: OpenAI.Responses.ResponseInputItem[] = [];
-
+/** Convert inkos AgentMessage[] to pi-ai Context (with tool calls/results). */
+function agentMessagesToPiContext(messages: ReadonlyArray<AgentMessage>): PiContext {
+  const systemParts = messages.filter((m) => m.role === "system").map((m) => (m as { content: string }).content);
+  const systemPrompt = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
+  const piMessages: PiContext["messages"] = [];
   for (const msg of messages) {
-    if (msg.role === "system") {
-      result.push({ role: "system", content: msg.content });
-      continue;
-    }
+    if (msg.role === "system") continue;
     if (msg.role === "user") {
-      result.push({ role: "user", content: msg.content });
+      piMessages.push({ role: "user", content: msg.content, timestamp: Date.now() });
       continue;
     }
     if (msg.role === "assistant") {
-      if (msg.content) {
-        result.push({ role: "assistant", content: msg.content });
-      }
+      const content: (PiTextContent | PiToolCall)[] = [];
+      if (msg.content) content.push({ type: "text", text: msg.content });
       if (msg.toolCalls) {
         for (const tc of msg.toolCalls) {
-          result.push({
-            type: "function_call" as const,
-            call_id: tc.id,
+          content.push({
+            type: "toolCall",
+            id: tc.id,
             name: tc.name,
-            arguments: tc.arguments,
+            arguments: JSON.parse(tc.arguments),
           });
         }
       }
+      if (content.length === 0) content.push({ type: "text", text: "" });
+      piMessages.push({
+        role: "assistant",
+        content,
+        api: "openai-completions" as PiApi,
+        provider: "openai",
+        model: "",
+        usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
+        stopReason: "stop",
+        timestamp: Date.now(),
+      });
       continue;
     }
     if (msg.role === "tool") {
-      result.push({
-        type: "function_call_output" as const,
-        call_id: msg.toolCallId,
-        output: msg.content,
+      piMessages.push({
+        role: "toolResult",
+        toolCallId: msg.toolCallId,
+        toolName: "",
+        content: [{ type: "text", text: msg.content }],
+        isError: false,
+        timestamp: Date.now(),
       });
     }
   }
-
-  return result;
+  return { systemPrompt, messages: piMessages };
 }
 
-// === Anthropic Implementation ===
+/** Convert inkos ToolDefinition[] to pi-ai Tool[]. */
+function toPiTools(tools: ReadonlyArray<ToolDefinition>): PiTool[] {
+  return tools.map((t) => ({
+    name: t.name,
+    description: t.description,
+    parameters: t.parameters as PiTool["parameters"],
+  }));
+}
 
-async function chatCompletionAnthropic(
-  client: Anthropic,
+async function chatCompletionViaPiAi(
+  client: LLMClient,
   model: string,
   messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-  thinkingBudget: number = 0,
+  resolved: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
   onStreamProgress?: OnStreamProgress,
   onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
-  const systemText = messages
-    .filter((m) => m.role === "system")
-    .map((m) => m.content)
-    .join("\n\n");
-  const nonSystem = messages.filter((m) => m.role !== "system");
-
-  const stream = await client.messages.create({
-    model,
-    ...(systemText ? { system: systemText } : {}),
-    messages: nonSystem.map((m) => ({
-      role: m.role as "user" | "assistant",
-      content: m.content,
-    })),
-    ...(thinkingBudget > 0
-      ? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
-      : { temperature: options.temperature }),
-    max_tokens: options.maxTokens,
-    stream: true,
-  });
+  const piModel = resolvePiModel(client, model);
+  const context = toPiContext(messages);
+  const streamOpts = {
+    temperature: resolved.temperature,
+    maxTokens: resolved.maxTokens,
+    apiKey: client._apiKey,
+    headers: piModel.headers,
+  };
 
+  const eventStream = piStreamSimple(piModel, context, streamOpts);
   const chunks: string[] = [];
+  const monitor = createStreamMonitor(onStreamProgress);
   let inputTokens = 0;
   let outputTokens = 0;
-  const monitor = createStreamMonitor(onStreamProgress);
 
   try {
-    for await (const event of stream) {
-      if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
-        chunks.push(event.delta.text);
-        monitor.onChunk(event.delta.text);
-        onTextDelta?.(event.delta.text);
-      }
-      if (event.type === "message_start") {
-        inputTokens = event.message.usage?.input_tokens ?? 0;
+    for await (const event of eventStream) {
+      if (event.type === "text_delta") {
+        chunks.push(event.delta);
+        monitor.onChunk(event.delta);
+        onTextDelta?.(event.delta);
       }
-      if (event.type === "message_delta") {
-        outputTokens = ((event as unknown as { usage?: { output_tokens?: number } }).usage?.output_tokens) ?? 0;
+      if (event.type === "done" || event.type === "error") {
+        const msg = event.type === "done" ? event.message : event.error;
+        inputTokens = msg.usage.input;
+        outputTokens = msg.usage.output;
+        if (event.type === "error" && msg.errorMessage) {
+          // Check if we have partial content worth salvaging
+          const partial = chunks.join("");
+          if (partial.length >= MIN_SALVAGEABLE_CHARS) {
+            throw new PartialResponseError(partial, new Error(msg.errorMessage));
+          }
+          throw new Error(msg.errorMessage);
+        }
       }
     }
   } catch (streamError) {
     monitor.stop();
+    if (streamError instanceof PartialResponseError) throw streamError;
     const partial = chunks.join("");
     if (partial.length >= MIN_SALVAGEABLE_CHARS) {
       throw new PartialResponseError(partial, streamError);
@@ -877,7 +515,11 @@ async function chatCompletionAnthropic(
   }
 
   const content = chunks.join("");
-  if (!content) throw new Error("LLM returned empty response from stream");
+  if (!content) {
+    const diag = `usage=${inputTokens}+${outputTokens}`;
+    console.warn(`[inkos] LLM 流式响应无文本内容 (${diag})`);
+    throw new Error(`LLM returned empty response from stream (${diag})`);
+  }
 
   return {
     content,
@@ -889,167 +531,42 @@ async function chatCompletionAnthropic(
   };
 }
 
-async function chatCompletionAnthropicSync(
-  client: Anthropic,
-  model: string,
-  messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-  thinkingBudget: number = 0,
-  onTextDelta?: (text: string) => void,
-): Promise<LLMResponse> {
-  const systemText = messages
-    .filter((m) => m.role === "system")
-    .map((m) => m.content)
-    .join("\n\n");
-  const nonSystem = messages.filter((m) => m.role !== "system");
-
-  const response = await client.messages.create({
-    model,
-    ...(systemText ? { system: systemText } : {}),
-    messages: nonSystem.map((m) => ({
-      role: m.role as "user" | "assistant",
-      content: m.content,
-    })),
-    ...(thinkingBudget > 0
-      ? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
-      : { temperature: options.temperature }),
-    max_tokens: options.maxTokens,
-  });
-
-  const content = response.content
-    .filter((block): block is Anthropic.Messages.TextBlock => block.type === "text")
-    .map((block) => block.text)
-    .join("");
-
-  if (!content) throw new Error("LLM returned empty response");
-  onTextDelta?.(content);
-
-  return {
-    content,
-    usage: {
-      promptTokens: response.usage?.input_tokens ?? 0,
-      completionTokens: response.usage?.output_tokens ?? 0,
-      totalTokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0),
-    },
-  };
-}
-
-async function chatWithToolsAnthropic(
-  client: Anthropic,
+async function chatWithToolsViaPiAi(
+  client: LLMClient,
   model: string,
   messages: ReadonlyArray<AgentMessage>,
   tools: ReadonlyArray<ToolDefinition>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-  thinkingBudget: number = 0,
+  resolved: { readonly temperature: number; readonly maxTokens: number },
 ): Promise<ChatWithToolsResult> {
-  const systemText = messages
-    .filter((m) => m.role === "system")
-    .map((m) => (m as { content: string }).content)
-    .join("\n\n");
-  const nonSystem = messages.filter((m) => m.role !== "system");
-
-  const anthropicMessages = agentMessagesToAnthropic(nonSystem);
-  const anthropicTools = tools.map((t) => ({
-    name: t.name,
-    description: t.description,
-    input_schema: t.parameters as Anthropic.Messages.Tool.InputSchema,
-  }));
-
-  const stream = await client.messages.create({
-    model,
-    ...(systemText ? { system: systemText } : {}),
-    messages: anthropicMessages,
-    tools: anthropicTools,
-    ...(thinkingBudget > 0
-      ? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
-      : { temperature: options.temperature }),
-    max_tokens: options.maxTokens,
-    stream: true,
-  });
+  const piModel = resolvePiModel(client, model);
+  const context = agentMessagesToPiContext(messages);
+  context.tools = toPiTools(tools);
+  const streamOpts = {
+    temperature: resolved.temperature,
+    maxTokens: resolved.maxTokens,
+    apiKey: client._apiKey,
+    headers: piModel.headers,
+  };
 
+  const eventStream = piStream(piModel, context, streamOpts);
   let content = "";
   const toolCalls: ToolCall[] = [];
-  let currentBlock: { id: string; name: string; input: string } | null = null;
 
-  for await (const event of stream) {
-    if (event.type === "content_block_start" && event.content_block.type === "tool_use") {
-      currentBlock = {
-        id: event.content_block.id,
-        name: event.content_block.name,
-        input: "",
-      };
-    }
-    if (event.type === "content_block_delta") {
-      if (event.delta.type === "text_delta") {
-        content += event.delta.text;
-      }
-      if (event.delta.type === "input_json_delta" && currentBlock) {
-        currentBlock.input += event.delta.partial_json;
-      }
+  for await (const event of eventStream) {
+    if (event.type === "text_delta") {
+      content += event.delta;
     }
-    if (event.type === "content_block_stop" && currentBlock) {
+    if (event.type === "toolcall_end") {
       toolCalls.push({
-        id: currentBlock.id,
-        name: currentBlock.name,
-        arguments: currentBlock.input,
+        id: event.toolCall.id,
+        name: event.toolCall.name,
+        arguments: JSON.stringify(event.toolCall.arguments),
       });
-      currentBlock = null;
-    }
-  }
-
-  return { content, toolCalls };
-}
-
-function agentMessagesToAnthropic(
-  messages: ReadonlyArray<AgentMessage>,
-): Anthropic.Messages.MessageParam[] {
-  const result: Anthropic.Messages.MessageParam[] = [];
-
-  for (const msg of messages) {
-    if (msg.role === "system") continue;
-
-    if (msg.role === "user") {
-      result.push({ role: "user", content: msg.content });
-      continue;
-    }
-
-    if (msg.role === "assistant") {
-      const blocks: Anthropic.Messages.ContentBlockParam[] = [];
-      if (msg.content) {
-        blocks.push({ type: "text", text: msg.content });
-      }
-      if (msg.toolCalls) {
-        for (const tc of msg.toolCalls) {
-          blocks.push({
-            type: "tool_use",
-            id: tc.id,
-            name: tc.name,
-            input: JSON.parse(tc.arguments),
-          });
-        }
-      }
-      if (blocks.length === 0) {
-        blocks.push({ type: "text", text: "" });
-      }
-      result.push({ role: "assistant", content: blocks });
-      continue;
     }
-
-    if (msg.role === "tool") {
-      const toolResult: Anthropic.Messages.ToolResultBlockParam = {
-        type: "tool_result",
-        tool_use_id: msg.toolCallId,
-        content: msg.content,
-      };
-      // Merge consecutive tool results into one user message (Anthropic requires alternating roles)
-      const prev = result[result.length - 1];
-      if (prev && prev.role === "user" && Array.isArray(prev.content)) {
-        (prev.content as Anthropic.Messages.ToolResultBlockParam[]).push(toolResult);
-      } else {
-        result.push({ role: "user", content: [toolResult] });
-      }
+    if (event.type === "error" && event.error.errorMessage) {
+      throw new Error(event.error.errorMessage);
     }
   }
 
-  return result;
+  return { content, toolCalls };
 }

+ 50 - 0
packages/core/src/llm/secrets.ts

@@ -0,0 +1,50 @@
+import { readFile, writeFile, mkdir } from "node:fs/promises";
+import { join } from "node:path";
+
+export interface SecretsFile {
+  services: Record<string, { apiKey: string }>;
+}
+
+const SECRETS_DIR = ".inkos";
+const SECRETS_FILE = "secrets.json";
+
+export async function loadSecrets(projectRoot: string): Promise<SecretsFile> {
+  try {
+    const raw = await readFile(
+      join(projectRoot, SECRETS_DIR, SECRETS_FILE),
+      "utf-8",
+    );
+    return JSON.parse(raw) as SecretsFile;
+  } catch {
+    return { services: {} };
+  }
+}
+
+export async function saveSecrets(
+  projectRoot: string,
+  secrets: SecretsFile,
+): Promise<void> {
+  const dir = join(projectRoot, SECRETS_DIR);
+  await mkdir(dir, { recursive: true });
+  await writeFile(
+    join(dir, SECRETS_FILE),
+    JSON.stringify(secrets, null, 2),
+    "utf-8",
+  );
+}
+
+export async function getServiceApiKey(
+  projectRoot: string,
+  service: string,
+): Promise<string | null> {
+  // 1. secrets.json
+  const secrets = await loadSecrets(projectRoot);
+  const entry = secrets.services[service];
+  if (entry?.apiKey) return entry.apiKey;
+
+  // 2. Environment variable: MOONSHOT_API_KEY, DEEPSEEK_API_KEY, etc.
+  const envKey = `${service.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}_API_KEY`;
+  if (process.env[envKey]) return process.env[envKey]!;
+
+  return null;
+}

+ 143 - 0
packages/core/src/llm/service-presets.ts

@@ -0,0 +1,143 @@
+export interface ServicePreset {
+  readonly api: string;
+  readonly baseUrl: string;
+  readonly label: string;
+  readonly temperatureRange?: [number, number];
+  readonly defaultTemperature?: number;
+  readonly writingTemperature?: number;
+  readonly temperatureHint?: string;
+}
+
+export const SERVICE_PRESETS: Record<string, ServicePreset> = {
+  openai:      { api: "openai-responses",   baseUrl: "https://api.openai.com/v1",                         label: "OpenAI",          temperatureRange: [0, 2], defaultTemperature: 1.0, writingTemperature: 1.0 },
+  anthropic:   { api: "anthropic-messages",  baseUrl: "https://api.anthropic.com",                         label: "Anthropic",       temperatureRange: [0, 1], defaultTemperature: 1.0, writingTemperature: 1.0, temperatureHint: "不要同时改 temperature 和 top_p" },
+  deepseek:    { api: "openai-completions",  baseUrl: "https://api.deepseek.com",                          label: "DeepSeek",        temperatureRange: [0, 2], defaultTemperature: 1.0, writingTemperature: 1.5, temperatureHint: "创意写作推荐 1.5" },
+  moonshot:    { api: "openai-completions",  baseUrl: "https://api.moonshot.cn/v1",                        label: "Moonshot (Kimi)", temperatureRange: [0, 1], defaultTemperature: 0.3, writingTemperature: 1.0, temperatureHint: "kimi-k2.5 推荐 temperature=1.0" },
+  minimax:     { api: "openai-completions",  baseUrl: "https://api.minimax.chat/v1",                       label: "MiniMax",         temperatureRange: [0, 2], defaultTemperature: 0.9, writingTemperature: 0.9 },
+  bailian:     { api: "openai-completions",  baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", label: "百炼 (通义千问)", temperatureRange: [0, 2], defaultTemperature: 0.7, writingTemperature: 1.0 },
+  zhipu:       { api: "openai-completions",  baseUrl: "https://open.bigmodel.cn/api/paas/v4",              label: "智谱 GLM",        temperatureRange: [0, 1], defaultTemperature: 0.95, writingTemperature: 0.95 },
+  siliconflow: { api: "openai-completions",  baseUrl: "https://api.siliconflow.cn/v1",                     label: "硅基流动" },
+  ppio:        { api: "openai-completions",  baseUrl: "https://api.ppinfra.com/v3/openai",                 label: "PPIO" },
+  openrouter:  { api: "openai-responses",    baseUrl: "https://openrouter.ai/api/v1",                      label: "OpenRouter" },
+  ollama:      { api: "openai-completions",  baseUrl: "http://localhost:11434/v1",                         label: "Ollama (本地)" },
+  custom:      { api: "openai-completions",  baseUrl: "",                                                   label: "自定义端点" },
+};
+
+export function resolveServicePreset(service: string): ServicePreset | undefined {
+  return SERVICE_PRESETS[service];
+}
+
+const DEFAULT_TEMPERATURE_RANGE: [number, number] = [0, 2];
+
+export function clampTemperature(service: string, temperature: number): number {
+  const preset = resolveServicePreset(service);
+  const [min, max] = preset?.temperatureRange ?? DEFAULT_TEMPERATURE_RANGE;
+  return Math.max(min, Math.min(max, temperature));
+}
+
+export function getWritingTemperature(service: string): number {
+  const preset = resolveServicePreset(service);
+  return preset?.writingTemperature ?? preset?.defaultTemperature ?? 1.0;
+}
+
+export function guessServiceFromBaseUrl(baseUrl: string): string {
+  for (const [key, preset] of Object.entries(SERVICE_PRESETS)) {
+    if (key === "custom" || !preset.baseUrl) continue;
+    try {
+      if (baseUrl.includes(new URL(preset.baseUrl).hostname)) return key;
+    } catch {
+      continue;
+    }
+  }
+  return "custom";
+}
+
+// pi-ai service → pi-ai provider 映射
+export const SERVICE_TO_PI_PROVIDER: Record<string, string> = {
+  openai: "openai",
+  anthropic: "anthropic",
+  deepseek: "openai",         // OpenAI 兼容,pi-ai 无独立 provider
+  moonshot: "openai",         // Moonshot API (api.moonshot.cn) 是 OpenAI 兼容,不是 kimi-coding (api.kimi.com)
+  minimax: "minimax",
+  bailian: "openai",          // 百炼走 OpenAI 兼容
+  zhipu: "zai",               // pi-ai 有 zai provider
+  siliconflow: "openai",      // OpenAI 兼容
+  ppio: "openai",             // OpenAI 兼容
+  openrouter: "openrouter",
+  ollama: "openai",           // OpenAI 兼容
+};
+
+export interface ModelInfo {
+  readonly id: string;
+  readonly name: string;
+  readonly reasoning: boolean;
+  readonly contextWindow: number;
+}
+
+/**
+ * 动态获取某个 service 下可用的模型列表。
+ * 优先调用服务商的 GET /models API(OpenAI 兼容),回退到 pi-ai 内置模型列表。
+ *
+ * @param apiKey 用户配置的 API key,用于认证 /models 请求
+ */
+export async function listModelsForService(service: string, apiKey?: string): Promise<ReadonlyArray<ModelInfo>> {
+  const preset = SERVICE_PRESETS[service];
+  if (!preset || service === "custom") return [];
+
+  // 1) 尝试动态获取:调用 GET {baseUrl}/models
+  if (apiKey && preset.baseUrl) {
+    try {
+      const modelsUrl = preset.baseUrl.replace(/\/$/, "") + "/models";
+      const res = await fetch(modelsUrl, {
+        headers: { Authorization: `Bearer ${apiKey}` },
+        signal: AbortSignal.timeout(10_000),
+      });
+      if (res.ok) {
+        const json = await res.json() as { data?: Array<{ id: string; owned_by?: string }> };
+        if (json.data && json.data.length > 0) {
+          return json.data.map((m) => ({
+            id: m.id,
+            name: m.id,
+            reasoning: false,
+            contextWindow: 0,
+          }));
+        }
+      }
+    } catch {
+      // /models 不可用,回退
+    }
+  }
+
+  // 2) 回退到 pi-ai 内置模型列表
+  const piProvider = SERVICE_TO_PI_PROVIDER[service];
+  if (!piProvider) return [];
+
+  try {
+    const { getModels } = await import("@mariozechner/pi-ai");
+    const models = getModels(piProvider as any);
+    return models.map((m: any) => ({
+      id: m.id,
+      name: m.name,
+      reasoning: m.reasoning ?? false,
+      contextWindow: m.contextWindow ?? 0,
+    }));
+  } catch {
+    return [];
+  }
+}
+
+/**
+ * 获取所有 service 及其可用模型数。
+ */
+export async function listServicesWithModelCount(): Promise<ReadonlyArray<{ service: string; label: string; modelCount: number }>> {
+  const result: { service: string; label: string; modelCount: number }[] = [];
+  for (const [key, preset] of Object.entries(SERVICE_PRESETS)) {
+    if (key === "custom") {
+      result.push({ service: key, label: preset.label, modelCount: 0 });
+      continue;
+    }
+    const models = await listModelsForService(key);
+    result.push({ service: key, label: preset.label, modelCount: models.length });
+  }
+  return result;
+}

+ 66 - 0
packages/core/src/llm/service-resolver.ts

@@ -0,0 +1,66 @@
+import { getModel } from "@mariozechner/pi-ai";
+import type { Model, Api } from "@mariozechner/pi-ai";
+import { resolveServicePreset, SERVICE_TO_PI_PROVIDER } from "./service-presets.js";
+import { getServiceApiKey } from "./secrets.js";
+
+export interface ResolvedModel {
+  model: Model<Api>;
+  apiKey: string;
+  writingTemperature?: number;
+  temperatureRange?: [number, number];
+  temperatureHint?: string;
+}
+
+export async function resolveServiceModel(
+  service: string,
+  modelId: string,
+  projectRoot: string,
+  customBaseUrl?: string,
+): Promise<ResolvedModel> {
+  // Resolve API key
+  const apiKey = await getServiceApiKey(projectRoot, service);
+  if (!apiKey) {
+    throw new Error(
+      `API key not found for service "${service}". Add it in .inkos/secrets.json or set the environment variable.`,
+    );
+  }
+
+  // Determine pi-ai provider
+  const baseService = service.startsWith("custom:") ? "custom" : service;
+  const preset = resolveServicePreset(baseService);
+  const piProvider = SERVICE_TO_PI_PROVIDER[baseService] ?? "openai";
+
+  // Get pi-ai Model — may return undefined for model IDs not in the built-in registry
+  let model = getModel(piProvider as any, modelId as any) as Model<Api> | undefined;
+
+  if (!model) {
+    // Construct a Model object from service preset for models not in pi-ai's registry
+    const apiType = preset?.api ?? "openai-completions";
+    const baseUrl = customBaseUrl ?? preset?.baseUrl ?? "";
+    if (!baseUrl) {
+      throw new Error(
+        `Cannot resolve model "${modelId}" for service "${service}": no baseUrl available.`,
+      );
+    }
+    model = {
+      id: modelId,
+      name: modelId,
+      api: apiType as Api,
+      provider: piProvider,
+      baseUrl,
+      reasoning: false,
+      input: ["text"] as ("text" | "image")[],
+      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+      contextWindow: 0,
+      maxTokens: 16384,
+    };
+  }
+
+  return {
+    model,
+    apiKey,
+    writingTemperature: preset?.writingTemperature,
+    temperatureRange: preset?.temperatureRange,
+    temperatureHint: preset?.temperatureHint,
+  };
+}

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

@@ -2,6 +2,7 @@ import { z } from "zod";
 
 export const LLMConfigSchema = z.object({
   provider: z.enum(["anthropic", "openai", "custom"]),
+  service: z.string().default("custom"),
   baseUrl: z.string().url(),
   apiKey: z.string().default(""),
   model: z.string().min(1),

+ 5 - 1
packages/core/src/pipeline/chapter-persistence.ts

@@ -57,7 +57,11 @@ export async function persistChapterArtifacts(params: {
     lengthTelemetry: params.lengthTelemetry,
     tokenUsage: params.tokenUsage,
   };
-  await params.saveChapterIndex([...existingIndex, entry]);
+  const existingIdx = existingIndex.findIndex((e) => e.number === params.chapterNumber);
+  const updatedIndex = existingIdx >= 0
+    ? existingIndex.map((e, i) => i === existingIdx ? { ...entry, createdAt: e.createdAt } : e)
+    : [...existingIndex, entry];
+  await params.saveChapterIndex(updatedIndex);
   await params.markBookActiveIfNeeded();
 
   const driftIssues = params.auditResult.issues.filter(

+ 27 - 1
packages/core/src/pipeline/chapter-truth-validation.ts

@@ -57,7 +57,33 @@ export async function validateChapterTruthPersistence(params: {
       params.language,
     );
   } catch (error) {
-    throw new Error(`State validation failed for chapter ${params.chapterNumber}: ${String(error)}`);
+    params.logger?.warn(`State validation error for chapter ${params.chapterNumber}: ${String(error)}`);
+    const errorDescription = params.language === "en"
+      ? `State validation unavailable: ${String(error)}`
+      : `状态校验不可用:${String(error)}`;
+    const errorIssue: AuditIssue = {
+      severity: "warning",
+      category: "state-validation",
+      description: errorDescription,
+      suggestion: params.language === "en"
+        ? "Repair chapter state from the persisted body before continuing."
+        : "请先基于已保存正文修复本章 state,再继续后续章节。",
+    };
+    return {
+      validation: { passed: true, warnings: [] },
+      chapterStatus: "state-degraded",
+      degradedIssues: [errorIssue],
+      persistenceOutput: buildStateDegradedPersistenceOutput({
+        output: persistenceOutput,
+        oldState: params.previousTruth.oldState,
+        oldHooks: params.previousTruth.oldHooks,
+        oldLedger: params.previousTruth.oldLedger,
+      }),
+      auditResult: {
+        ...params.auditResult,
+        issues: [...params.auditResult.issues, errorIssue],
+      },
+    };
   }
 
   if (validation.warnings.length > 0) {

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

@@ -393,6 +393,7 @@ export class PipelineRunner {
         : base?.apiKey ?? "";
       client = createLLMClient({
         provider,
+        service: base?.service ?? "custom",
         baseUrl: override.baseUrl,
         apiKey,
         model: override.model,
@@ -693,7 +694,11 @@ export class PipelineRunner {
         lengthTelemetry,
         ...(draftOutput.tokenUsage ? { tokenUsage: draftOutput.tokenUsage } : {}),
       };
-      await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
+      const existingIdx = existingIndex.findIndex((e) => e.number === chapterNumber);
+      const updatedIndex = existingIdx >= 0
+        ? existingIndex.map((e, i) => i === existingIdx ? newEntry : e)
+        : [...existingIndex, newEntry];
+      await this.state.saveChapterIndex(bookId, updatedIndex);
       await this.markBookActiveIfNeeded(bookId);
 
       // Snapshot
@@ -1808,7 +1813,7 @@ export class PipelineRunner {
         role: "user",
         content: `分析以下参考文本的写作风格:\n\n${referenceText.slice(0, 20000)}`,
       },
-    ], { temperature: 0.3, maxTokens: 4096 });
+    ], { temperature: 0.3 });
 
     await writeFile(join(storyDir, "style_guide.md"), response.content, "utf-8");
     return response.content;
@@ -1930,7 +1935,7 @@ ${emotions}
 ## 正传角色矩阵
 ${matrix}`,
       },
-    ], { temperature: 0.3, maxTokens: 16384 });
+    ], { temperature: 0.3 });
 
     // Append deterministic meta block (LLM may hallucinate timestamps)
     const metaBlock = [

+ 9 - 1
packages/core/src/state/manager.ts

@@ -5,6 +5,9 @@ import type { ChapterMeta } from "../models/chapter.js";
 import { bootstrapStructuredStateFromMarkdown, resolveDurableStoryProgress } from "./state-bootstrap.js";
 
 export class StateManager {
+  /** Books actively being written by this process — used for same-process stale lock detection. */
+  private readonly activeWrites = new Set<string>();
+
   constructor(private readonly projectRoot: string) {}
 
   private static defaultAuthorIntent(language: "zh" | "en"): string {
@@ -93,7 +96,10 @@ export class StateManager {
       if (code === "EEXIST") {
         const lockData = await readFile(lockPath, "utf-8").catch(() => "pid:unknown ts:unknown");
         const lockPid = this.extractLockPid(lockData);
-        if (lockPid !== undefined && !this.isProcessAlive(lockPid)) {
+        const isStale =
+          (lockPid !== undefined && !this.isProcessAlive(lockPid)) ||
+          (lockPid === process.pid && !this.activeWrites.has(bookId));
+        if (isStale) {
           await unlink(lockPath).catch(() => undefined);
           return this.acquireBookLock(bookId);
         }
@@ -104,7 +110,9 @@ export class StateManager {
       }
       throw e;
     }
+    this.activeWrites.add(bookId);
     return async () => {
+      this.activeWrites.delete(bookId);
       try {
         await unlink(lockPath);
       } catch {

+ 1 - 1
packages/studio/src/api/book-create.ts

@@ -83,7 +83,7 @@ export async function waitForStudioBookReady(
   const retryDelayMs = options.retryDelayMs ?? 150;
 
   for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
-    const response = await fetchImpl(`/api/books/${encodeURIComponent(bookId)}`);
+    const response = await fetchImpl(`/api/v1/books/${encodeURIComponent(bookId)}`);
     if (response.ok) {
       return await response.json() as StudioBookDetail;
     }

+ 1 - 1
packages/studio/src/api/index.ts

@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
 
 const __dirname = dirname(fileURLToPath(import.meta.url));
 
-const root = process.argv[2] ?? process.env.INKOS_PROJECT_ROOT ?? process.cwd();
+const root = resolve(process.argv[2] ?? process.env.INKOS_PROJECT_ROOT ?? process.cwd());
 const port = parseInt(process.env.INKOS_STUDIO_PORT ?? "4567", 10);
 
 // Find studio package root (2 levels up from src/api/)

+ 77 - 47
packages/studio/src/api/server.test.ts

@@ -22,6 +22,12 @@ const processProjectInteractionRequestMock = vi.fn();
 const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
 const loadProjectSessionMock = vi.fn();
 const resolveSessionActiveBookMock = vi.fn();
+const runAgentSessionMock = vi.fn();
+const findOrCreateBookSessionMock = vi.fn();
+const loadBookSessionMock = vi.fn();
+const persistBookSessionMock = vi.fn();
+const appendBookSessionMessageMock = vi.fn();
+const resolveServiceModelMock = vi.fn();
 
 const logger = {
   child: () => logger,
@@ -112,6 +118,12 @@ vi.mock("@actalk/inkos-core", () => {
     createInteractionToolsFromDeps: createInteractionToolsFromDepsMock,
     loadProjectSession: loadProjectSessionMock,
     resolveSessionActiveBook: resolveSessionActiveBookMock,
+    runAgentSession: runAgentSessionMock,
+    findOrCreateBookSession: findOrCreateBookSessionMock,
+    loadBookSession: loadBookSessionMock,
+    persistBookSession: persistBookSessionMock,
+    appendBookSessionMessage: appendBookSessionMessageMock,
+    resolveServiceModel: resolveServiceModelMock,
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
   };
 });
@@ -262,6 +274,30 @@ describe("createStudioServer daemon lifecycle", () => {
     saveChapterIndexMock.mockResolvedValue(undefined);
     rollbackToChapterMock.mockResolvedValue([]);
     pipelineConfigs.length = 0;
+    runAgentSessionMock.mockReset();
+    findOrCreateBookSessionMock.mockReset();
+    loadBookSessionMock.mockReset();
+    persistBookSessionMock.mockReset();
+    appendBookSessionMessageMock.mockReset();
+    resolveServiceModelMock.mockReset();
+    // Default BookSession for agent tests
+    const defaultBookSession = {
+      sessionId: "agent-session-1",
+      projectRoot: root,
+      activeBookId: "demo-book",
+      messages: [],
+      events: [],
+    };
+    findOrCreateBookSessionMock.mockResolvedValue(defaultBookSession);
+    loadBookSessionMock.mockResolvedValue(null);
+    persistBookSessionMock.mockResolvedValue(undefined);
+    appendBookSessionMessageMock.mockImplementation(
+      (session: unknown, _msg: unknown) => session,
+    );
+    runAgentSessionMock.mockResolvedValue({
+      responseText: "Agent response.",
+      messages: [],
+    });
   });
 
   afterEach(async () => {
@@ -281,7 +317,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
     const responseOrTimeout = await Promise.race([
-      app.request("http://localhost/api/daemon/start", { method: "POST" }),
+      app.request("http://localhost/api/v1/daemon/start", { method: "POST" }),
       new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 30)),
     ]);
 
@@ -291,7 +327,7 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(response.status).toBe(200);
     await expect(response.json()).resolves.toMatchObject({ ok: true, running: true });
 
-    const status = await app.request("http://localhost/api/daemon");
+    const status = await app.request("http://localhost/api/v1/daemon");
     await expect(status.json()).resolves.toEqual({ running: true });
 
     resolveStart?.();
@@ -301,7 +337,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/..%2Fetc%2Fpasswd", {
+    const response = await app.request("http://localhost/api/v1/books/..%2Fetc%2Fpasswd", {
       method: "GET",
     });
 
@@ -326,14 +362,14 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const readAuthorIntent = await app.request("http://localhost/api/books/demo-book/truth/author_intent.md");
+    const readAuthorIntent = await app.request("http://localhost/api/v1/books/demo-book/truth/author_intent.md");
     expect(readAuthorIntent.status).toBe(200);
     await expect(readAuthorIntent.json()).resolves.toMatchObject({
       file: "author_intent.md",
       content: "# Author Intent\n\nStay cold.\n",
     });
 
-    const updateCurrentFocus = await app.request("http://localhost/api/books/demo-book/truth/current_focus.md", {
+    const updateCurrentFocus = await app.request("http://localhost/api/v1/books/demo-book/truth/current_focus.md", {
       method: "PUT",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ content: "# Current Focus\n\nPull focus back to the harbor trail.\n" }),
@@ -349,7 +385,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const save = await app.request("http://localhost/api/project", {
+    const save = await app.request("http://localhost/api/v1/project", {
       method: "PUT",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({
@@ -362,7 +398,7 @@ describe("createStudioServer daemon lifecycle", () => {
 
     expect(save.status).toBe(200);
 
-    const project = await app.request("http://localhost/api/project");
+    const project = await app.request("http://localhost/api/v1/project");
     await expect(project.json()).resolves.toMatchObject({
       language: "en",
       temperature: 0.2,
@@ -394,7 +430,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(startupConfig as never, root);
 
-    const response = await app.request("http://localhost/api/doctor");
+    const response = await app.request("http://localhost/api/v1/doctor");
 
     expect(response.status).toBe(200);
     expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
@@ -432,7 +468,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(startupConfig as never, root);
 
-    const response = await app.request("http://localhost/api/radar/scan", {
+    const response = await app.request("http://localhost/api/v1/radar/scan", {
       method: "POST",
     });
 
@@ -451,7 +487,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const save = await app.request("http://localhost/api/project/language", {
+    const save = await app.request("http://localhost/api/v1/project/language", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ language: "en" }),
@@ -459,7 +495,7 @@ describe("createStudioServer daemon lifecycle", () => {
 
     expect(save.status).toBe(200);
 
-    const project = await app.request("http://localhost/api/project");
+    const project = await app.request("http://localhost/api/v1/project");
     await expect(project.json()).resolves.toMatchObject({
       language: "en",
       languageExplicit: true,
@@ -474,7 +510,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/create", {
+    const response = await app.request("http://localhost/api/v1/books/create", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({
@@ -499,7 +535,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/create", {
+    const response = await app.request("http://localhost/api/v1/books/create", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({
@@ -513,7 +549,7 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(response.status).toBe(200);
     await Promise.resolve();
 
-    const status = await app.request("http://localhost/api/books/broken-book/create-status");
+    const status = await app.request("http://localhost/api/v1/books/broken-book/create-status");
     expect(status.status).toBe(200);
     await expect(status.json()).resolves.toMatchObject({
       status: "error",
@@ -549,7 +585,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/demo-book/chapters/3/reject", {
+    const response = await app.request("http://localhost/api/v1/books/demo-book/chapters/3/reject", {
       method: "POST",
     });
 
@@ -569,7 +605,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/create", {
+    const response = await app.request("http://localhost/api/v1/books/create", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({
@@ -602,7 +638,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/demo-book/revise/3", {
+    const response = await app.request("http://localhost/api/v1/books/demo-book/revise/3", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ mode: "rewrite", brief: "把注意力拉回师债主线。" }),
@@ -617,7 +653,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/demo-book/resync/3", {
+    const response = await app.request("http://localhost/api/v1/books/demo-book/resync/3", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ brief: "以师债线为准同步状态。" }),
@@ -632,7 +668,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/demo-book/export-save", {
+    const response = await app.request("http://localhost/api/v1/books/demo-book/export-save", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ format: "md", approvedOnly: true }),
@@ -655,26 +691,19 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
-  it("routes /api/agent through the shared interaction control layer", async () => {
-    processProjectInteractionInputMock.mockResolvedValue({
-      request: { intent: "write_next", bookId: "demo-book" },
+  it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
+    runAgentSessionMock.mockResolvedValueOnce({
       responseText: "Completed write_next for demo-book.",
-      session: {
-        sessionId: "session-1",
-        projectRoot: root,
-        activeBookId: "demo-book",
-        automationMode: "semi",
-        messages: [
-          { role: "user", content: "continue", timestamp: 1 },
-          { role: "assistant", content: "Completed write_next for demo-book.", timestamp: 2 },
-        ],
-      },
+      messages: [
+        { role: "user", content: "continue" },
+        { role: "assistant", content: "Completed write_next for demo-book." },
+      ],
     });
 
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/agent", {
+    const response = await app.request("http://localhost/api/v1/agent", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
@@ -683,26 +712,27 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(response.status).toBe(200);
     await expect(response.json()).resolves.toMatchObject({
       response: "Completed write_next for demo-book.",
-      request: { intent: "write_next", bookId: "demo-book" },
       session: expect.objectContaining({
-        activeBookId: "demo-book",
+        sessionId: "agent-session-1",
       }),
     });
-    expect(createInteractionToolsFromDepsMock).toHaveBeenCalledTimes(1);
-    expect(processProjectInteractionInputMock).toHaveBeenCalledWith(expect.objectContaining({
-      projectRoot: root,
-      input: "continue",
-      activeBookId: "demo-book",
-    }));
+    expect(runAgentSessionMock).toHaveBeenCalledWith(
+      expect.objectContaining({
+        bookId: "demo-book",
+        projectRoot: root,
+      }),
+      "continue",
+      expect.any(Array),
+    );
   });
 
-  it("returns 500 with an error payload when the shared agent execution fails", async () => {
-    processProjectInteractionInputMock.mockRejectedValueOnce(new Error("boom"));
+  it("returns 500 with an error payload when the agent session fails", async () => {
+    runAgentSessionMock.mockRejectedValueOnce(new Error("boom"));
 
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/agent", {
+    const response = await app.request("http://localhost/api/v1/agent", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
@@ -711,7 +741,7 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(response.status).toBe(500);
     await expect(response.json()).resolves.toEqual({
       error: {
-        code: "INTERACTION_ERROR",
+        code: "AGENT_ERROR",
         message: "boom",
       },
     });
@@ -732,7 +762,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/interaction/session");
+    const response = await app.request("http://localhost/api/v1/interaction/session");
 
     expect(response.status).toBe(200);
     await expect(response.json()).resolves.toMatchObject({
@@ -763,7 +793,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/interaction/session");
+    const response = await app.request("http://localhost/api/v1/interaction/session");
 
     expect(response.status).toBe(200);
     await expect(response.json()).resolves.toMatchObject({

Разница между файлами не показана из-за своего большого размера
+ 546 - 78
packages/studio/src/api/server.ts


Некоторые файлы не были показаны из-за большого количества измененных файлов