Explorar o código

feat: expose input governance controls to agent mode

Ma hai 7 meses
pai
achega
b9060dbd00

+ 119 - 0
packages/core/src/__tests__/pipeline-agent.test.ts

@@ -0,0 +1,119 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { AGENT_TOOLS, executeAgentTool } from "../pipeline/agent.js";
+import { PipelineRunner, StateManager, type PipelineConfig } from "../index.js";
+
+describe("agent pipeline tools", () => {
+  let root: string;
+  let state: StateManager;
+  let pipeline: PipelineRunner;
+  let config: PipelineConfig;
+  let bookId: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-agent-tools-"));
+    state = new StateManager(root);
+    bookId = "agent-book";
+
+    config = {
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+      inputGovernanceMode: "v2",
+    };
+
+    pipeline = new PipelineRunner(config);
+
+    await state.saveBookConfig(bookId, {
+      id: bookId,
+      title: "Agent Book",
+      platform: "tomato",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 3000,
+      createdAt: "2026-03-22T00:00:00.000Z",
+      updatedAt: "2026-03-22T00:00:00.000Z",
+    });
+
+    const storyDir = join(state.bookDir(bookId), "story");
+    await mkdir(join(storyDir, "runtime"), { recursive: true });
+    await mkdir(join(state.bookDir(bookId), "chapters"), { recursive: true });
+    await writeFile(join(state.bookDir(bookId), "chapters", "index.json"), "[]", "utf-8");
+
+    await Promise.all([
+      writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nKeep the story centered on the mentor conflict.\n", "utf-8"),
+      writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
+      writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
+      writeFile(join(storyDir, "book_rules.md"), "---\nprohibitions:\n  - Do not reveal the mastermind\n---\n\n# Book Rules\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
+    ]);
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("registers the input governance tools", () => {
+    const toolNames = AGENT_TOOLS.map((tool) => tool.name);
+
+    expect(toolNames).toContain("plan_chapter");
+    expect(toolNames).toContain("compose_chapter");
+    expect(toolNames).toContain("update_author_intent");
+    expect(toolNames).toContain("update_current_focus");
+  });
+
+  it("plans and composes chapters through the agent tool surface", async () => {
+    const planResult = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "plan_chapter",
+      { bookId, guidance: "Ignore the guild chase and focus on the mentor conflict." },
+    ));
+
+    expect(planResult.intentPath).toBe("story/runtime/chapter-0001.intent.md");
+
+    const composeResult = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "compose_chapter",
+      { bookId, guidance: "Ignore the guild chase and focus on the mentor conflict." },
+    ));
+
+    expect(composeResult.contextPath).toBe("story/runtime/chapter-0001.context.json");
+    expect(composeResult.ruleStackPath).toBe("story/runtime/chapter-0001.rule-stack.yaml");
+    expect(composeResult.tracePath).toBe("story/runtime/chapter-0001.trace.json");
+  });
+
+  it("updates author_intent.md and current_focus.md through dedicated tools", async () => {
+    await executeAgentTool(pipeline, state, config, "update_author_intent", {
+      bookId,
+      content: "# Author Intent\n\nMake this a colder revenge story.\n",
+    });
+    await executeAgentTool(pipeline, state, config, "update_current_focus", {
+      bookId,
+      content: "# Current Focus\n\nSpend the next two chapters on mentor fallout.\n",
+    });
+
+    await expect(readFile(join(state.bookDir(bookId), "story", "author_intent.md"), "utf-8"))
+      .resolves.toContain("colder revenge story");
+    await expect(readFile(join(state.bookDir(bookId), "story", "current_focus.md"), "utf-8"))
+      .resolves.toContain("mentor fallout");
+  });
+});

+ 103 - 2
packages/core/src/pipeline/agent.ts

@@ -17,6 +17,30 @@ const TOOLS: ReadonlyArray<ToolDefinition> = [
       required: ["bookId"],
       required: ["bookId"],
     },
     },
   },
   },
+  {
+    name: "plan_chapter",
+    description: "为下一章生成 chapter intent(章节目标、必须保留、冲突说明)。适合在正式写作前检查当前控制输入是否正确。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
+        guidance: { type: "string", description: "本章额外指导(可选,自然语言)" },
+      },
+      required: ["bookId"],
+    },
+  },
+  {
+    name: "compose_chapter",
+    description: "为下一章生成 context/rule-stack/trace 运行时产物。适合在写作前确认系统实际会带哪些上下文和优先级。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
+        guidance: { type: "string", description: "本章额外指导(可选,自然语言)" },
+      },
+      required: ["bookId"],
+    },
+  },
   {
   {
     name: "audit_chapter",
     name: "audit_chapter",
     description: "审计指定章节。检查连续性、OOC、数值、伏笔等问题。",
     description: "审计指定章节。检查连续性、OOC、数值、伏笔等问题。",
@@ -64,6 +88,30 @@ const TOOLS: ReadonlyArray<ToolDefinition> = [
       required: ["title", "genre", "platform"],
       required: ["title", "genre", "platform"],
     },
     },
   },
   },
+  {
+    name: "update_author_intent",
+    description: "更新书级长期意图文档 author_intent.md。用于修改这本书长期想成为什么。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
+        content: { type: "string", description: "author_intent.md 的完整新内容" },
+      },
+      required: ["bookId", "content"],
+    },
+  },
+  {
+    name: "update_current_focus",
+    description: "更新当前关注点文档 current_focus.md。用于把最近几章的注意力拉回某条主线或冲突。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
+        content: { type: "string", description: "current_focus.md 的完整新内容" },
+      },
+      required: ["bookId", "content"],
+    },
+  },
   {
   {
     name: "get_book_status",
     name: "get_book_status",
     description: "获取书籍状态概览:章数、字数、最近章节审计情况。",
     description: "获取书籍状态概览:章数、字数、最近章节审计情况。",
@@ -199,9 +247,13 @@ export async function runAgentLoop(
 | get_book_status | 查看书的章数、字数、审计状态 |
 | get_book_status | 查看书的章数、字数、审计状态 |
 | read_truth_files | 读取长期记忆(状态卡、资源账本、伏笔池)和设定(世界观、卷纲、本书规则) |
 | read_truth_files | 读取长期记忆(状态卡、资源账本、伏笔池)和设定(世界观、卷纲、本书规则) |
 | create_book | 建书,生成世界观、卷纲、本书规则(自动加载题材 genre profile) |
 | create_book | 建书,生成世界观、卷纲、本书规则(自动加载题材 genre profile) |
+| plan_chapter | 先生成 chapter intent,确认本章目标/冲突/优先级 |
+| compose_chapter | 再生成 runtime context/rule stack,确认实际输入 |
 | write_draft | 写一章草稿(自动加载 genre profile + book_rules) |
 | write_draft | 写一章草稿(自动加载 genre profile + book_rules) |
 | audit_chapter | 审计章节(32维度,按题材条件启用,含AI痕迹+敏感词检测) |
 | audit_chapter | 审计章节(32维度,按题材条件启用,含AI痕迹+敏感词检测) |
 | revise_chapter | 修订章节(支持 polish/rewrite/rework/spot-fix/anti-detect 五种模式) |
 | revise_chapter | 修订章节(支持 polish/rewrite/rework/spot-fix/anti-detect 五种模式) |
+| update_author_intent | 更新书级长期意图 author_intent.md |
+| update_current_focus | 更新当前关注点 current_focus.md |
 | write_full_pipeline | 完整管线:写 → 审 → 改(如需要) |
 | write_full_pipeline | 完整管线:写 → 审 → 改(如需要) |
 | scan_market | 扫描平台排行榜,分析市场趋势 |
 | scan_market | 扫描平台排行榜,分析市场趋势 |
 | web_fetch | 抓取指定URL的文本内容 |
 | web_fetch | 抓取指定URL的文本内容 |
@@ -212,7 +264,11 @@ export async function runAgentLoop(
 
 
 ## 长期记忆
 ## 长期记忆
 
 
-每本书有七个长期记忆文件,是 Agent 写作和审计的事实依据:
+每本书有两层控制面:
+- **author_intent.md** — 这本书长期想成为什么
+- **current_focus.md** — 最近 1-3 章要把注意力拉回哪里
+
+以及七个长期记忆文件,是 Agent 写作和审计的事实依据:
 - **current_state.md** — 角色位置、关系、已知信息、当前冲突
 - **current_state.md** — 角色位置、关系、已知信息、当前冲突
 - **particle_ledger.md** — 物品/资源账本,每笔增减有据可查
 - **particle_ledger.md** — 物品/资源账本,每笔增减有据可查
 - **pending_hooks.md** — 已埋伏笔、推进状态、预期回收时机
 - **pending_hooks.md** — 已埋伏笔、推进状态、预期回收时机
@@ -232,6 +288,7 @@ export async function runAgentLoop(
 - 用户提供了题材/创意但没说要扫描市场 → 跳过 scan_market,直接 create_book
 - 用户提供了题材/创意但没说要扫描市场 → 跳过 scan_market,直接 create_book
 - 用户说了书名/bookId → 直接操作,不需要先 list_books
 - 用户说了书名/bookId → 直接操作,不需要先 list_books
 - 每完成一步,简要汇报进展
 - 每完成一步,简要汇报进展
+- 当用户要求“先把注意力拉回某条线”时,优先 update_current_focus,然后 plan_chapter / compose_chapter,再决定是否 write_draft 或 write_full_pipeline
 - 仿写流程:用户提供参考文本 → import_style → 生成 style_guide.md,后续写作自动参照
 - 仿写流程:用户提供参考文本 → import_style → 生成 style_guide.md,后续写作自动参照
 - 番外流程:先 create_book 建番外书 → import_canon 导入正传正典 → 然后正常 write_draft
 - 番外流程:先 create_book 建番外书 → import_canon 导入正传正典 → 然后正常 write_draft
 - 续写流程:用户提供已有章节 → import_chapters → 然后 write_draft 续写`,
 - 续写流程:用户提供已有章节 → import_chapters → 然后 write_draft 续写`,
@@ -279,7 +336,7 @@ export async function runAgentLoop(
   return lastAssistantMessage;
   return lastAssistantMessage;
 }
 }
 
 
-async function executeTool(
+export async function executeAgentTool(
   pipeline: PipelineRunner,
   pipeline: PipelineRunner,
   state: import("../state/manager.js").StateManager,
   state: import("../state/manager.js").StateManager,
   config: PipelineConfig,
   config: PipelineConfig,
@@ -287,6 +344,22 @@ async function executeTool(
   args: Record<string, unknown>,
   args: Record<string, unknown>,
 ): Promise<string> {
 ): Promise<string> {
   switch (name) {
   switch (name) {
+    case "plan_chapter": {
+      const result = await pipeline.planChapter(
+        args.bookId as string,
+        args.guidance as string | undefined,
+      );
+      return JSON.stringify(result);
+    }
+
+    case "compose_chapter": {
+      const result = await pipeline.composeChapter(
+        args.bookId as string,
+        args.guidance as string | undefined,
+      );
+      return JSON.stringify(result);
+    }
+
     case "write_draft": {
     case "write_draft": {
       const result = await pipeline.writeDraft(
       const result = await pipeline.writeDraft(
         args.bookId as string,
         args.bookId as string,
@@ -354,6 +427,24 @@ async function executeTool(
       return JSON.stringify(result);
       return JSON.stringify(result);
     }
     }
 
 
+    case "update_author_intent": {
+      await state.ensureControlDocuments(args.bookId as string);
+      const { writeFile } = await import("node:fs/promises");
+      const { join } = await import("node:path");
+      const storyDir = join(state.bookDir(args.bookId as string), "story");
+      await writeFile(join(storyDir, "author_intent.md"), args.content as string, "utf-8");
+      return JSON.stringify({ bookId: args.bookId, file: "story/author_intent.md", written: true });
+    }
+
+    case "update_current_focus": {
+      await state.ensureControlDocuments(args.bookId as string);
+      const { writeFile } = await import("node:fs/promises");
+      const { join } = await import("node:path");
+      const storyDir = join(state.bookDir(args.bookId as string), "story");
+      await writeFile(join(storyDir, "current_focus.md"), args.content as string, "utf-8");
+      return JSON.stringify({ bookId: args.bookId, file: "story/current_focus.md", written: true });
+    }
+
     case "read_truth_files": {
     case "read_truth_files": {
       const result = await pipeline.readTruthFiles(args.bookId as string);
       const result = await pipeline.readTruthFiles(args.bookId as string);
       return JSON.stringify(result);
       return JSON.stringify(result);
@@ -468,5 +559,15 @@ async function executeTool(
   }
   }
 }
 }
 
 
+async function executeTool(
+  pipeline: PipelineRunner,
+  state: import("../state/manager.js").StateManager,
+  config: PipelineConfig,
+  name: string,
+  args: Record<string, unknown>,
+): Promise<string> {
+  return executeAgentTool(pipeline, state, config, name, args);
+}
+
 /** Export tool definitions so external systems can reference them. */
 /** Export tool definitions so external systems can reference them. */
 export { TOOLS as AGENT_TOOLS };
 export { TOOLS as AGENT_TOOLS };

+ 57 - 0
skills/SKILL.md

@@ -107,6 +107,41 @@ inkos status
    inkos review approve-all
    inkos review approve-all
    ```
    ```
 
 
+### Workflow 2.5: Steering Chapter Focus Before Writing
+
+Use this when the user says things like "pull focus back to the mentor conflict", "pause the merchant guild subplot", or "change what the next chapter should prioritize".
+
+1. **Update the book-level control docs when needed**:
+   - Use `update_author_intent` to change the long-horizon identity of the book
+   - Use `update_current_focus` to change the next 1-3 chapters' focus
+
+2. **Compile the next chapter intent**:
+   ```text
+   plan_chapter(bookId, guidance?)
+   ```
+   - Generates `story/runtime/chapter-XXXX.intent.md`
+   - Use this to verify what the system thinks the next chapter should do
+
+3. **Compose the actual runtime input package**:
+   ```text
+   compose_chapter(bookId, guidance?)
+   ```
+   - Generates `story/runtime/chapter-XXXX.context.json`
+   - Generates `story/runtime/chapter-XXXX.rule-stack.yaml`
+   - Generates `story/runtime/chapter-XXXX.trace.json`
+
+4. **Only then write**:
+   - `write_draft` if the user wants intermediate review
+   - `write_full_pipeline` if they want the usual write → audit → revise flow
+
+Recommended orchestration:
+- user asks to redirect focus
+- `update_current_focus`
+- `plan_chapter`
+- `compose_chapter`
+- inspect the resulting intent/paths
+- `write_draft` or `write_full_pipeline`
+
 ### Workflow 3: Import Existing Chapters & Continue
 ### Workflow 3: Import Existing Chapters & Continue
 
 
 Use this when you have an existing novel (or partial novel) and want InkOS to pick up where it left off.
 Use this when you have an existing novel (or partial novel) and want InkOS to pick up where it left off.
@@ -258,6 +293,28 @@ inkos agent "写一部都市题材的小说,主角是一个年轻律师,第
 - Agent interprets natural language and invokes appropriate commands
 - Agent interprets natural language and invokes appropriate commands
 - Useful for complex multi-step requests
 - Useful for complex multi-step requests
 
 
+## Input Governance Tools
+
+These tools are the preferred control surface for chapter steering:
+
+- `plan_chapter(bookId, guidance?)`
+  - Generates chapter intent for the next chapter
+  - Use before writing when the user wants to change focus
+
+- `compose_chapter(bookId, guidance?)`
+  - Generates runtime context/rule-stack/trace artifacts
+  - Use after planning and before writing
+
+- `update_author_intent(bookId, content)`
+  - Rewrites `story/author_intent.md`
+  - Use for long-horizon changes to the book's identity
+
+- `update_current_focus(bookId, content)`
+  - Rewrites `story/current_focus.md`
+  - Use for local steering over the next 1-3 chapters
+
+`write_truth_file` remains available for broad file edits, but prefer the dedicated control tools above for input-governance changes.
+
 ## Key Concepts
 ## Key Concepts
 
 
 ### Book ID Auto-Detection
 ### Book ID Auto-Detection