فهرست منبع

feat(agent): 写作前找纲协议与 ContextPack 预算收敛

写章节前注入大纲定位协议,增强 list_outlines(含子目录与 type 标注);
统一 resolveContextPackTokenBudget / 写作输出预留,避免未绑定预算与挤占生成窗口。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 ماه پیش
والد
کامیت
d68b255cba
46فایلهای تغییر یافته به همراه1043 افزوده شده و 126 حذف شده
  1. 35 14
      src/components/chat/chat-panel.tsx
  2. 1 1
      src/components/chat/tool-call-timeline.tsx
  3. 5 2
      src/components/novel/character-aura-view.tsx
  4. 6 9
      src/components/sources/outline-chat-panel.tsx
  5. 1 0
      src/hooks/use-agent-config.ts
  6. 2 2
      src/i18n/en.json
  7. 2 2
      src/i18n/zh.json
  8. 1 1
      src/lib/agent/plan-execute-policy.ts
  9. 74 1
      src/lib/agent/plugins/build-system-prompt-plugin.spec.ts
  10. 22 6
      src/lib/agent/plugins/build-system-prompt-plugin.ts
  11. 3 2
      src/lib/agent/plugins/post-write-check-ai.ts
  12. 3 3
      src/lib/agent/plugins/trim-context-plugin.spec.ts
  13. 11 12
      src/lib/agent/plugins/trim-context-plugin.ts
  14. 22 3
      src/lib/agent/tools/index.ts
  15. 27 5
      src/lib/agent/tools/list-chapters.ts
  16. 44 10
      src/lib/agent/tools/list-outlines.ts
  17. 58 4
      src/lib/agent/tools/list-tools.spec.ts
  18. 74 0
      src/lib/agent/tools/outline-list-helpers.spec.ts
  19. 144 0
      src/lib/agent/tools/outline-list-helpers.ts
  20. 8 5
      src/lib/agent/tools/trim-context.ts
  21. 83 0
      src/lib/context-budget.contract.spec.ts
  22. 91 0
      src/lib/context-budget.test.ts
  23. 76 0
      src/lib/context-budget.ts
  24. 3 5
      src/lib/context-hub/ai-chat-integration.spec.ts
  25. 6 0
      src/lib/context-hub/ai-outline-integration.spec.ts
  26. 21 0
      src/lib/context-hub/composer.spec.ts
  27. 8 1
      src/lib/context-hub/composer.ts
  28. 2 0
      src/lib/context-hub/context-hub.ts
  29. 3 0
      src/lib/context-hub/types.ts
  30. 5 3
      src/lib/lint.ts
  31. 2 1
      src/lib/novel/book-analysis/character-extraction-engine.ts
  32. 3 2
      src/lib/novel/book-analysis/story-framework-extraction.ts
  33. 2 1
      src/lib/novel/book-analysis/style-analysis-adapter.ts
  34. 2 0
      src/lib/novel/chapter-excerpts.ts
  35. 2 1
      src/lib/novel/chapter-ingest.ts
  36. 2 1
      src/lib/novel/chapter-plan-compliance.ts
  37. 11 3
      src/lib/novel/context-engine.ts
  38. 7 10
      src/lib/novel/deep-chapter-generation.ts
  39. 10 3
      src/lib/novel/dimension-review-adapter.ts
  40. 9 4
      src/lib/novel/lint.ts
  41. 55 0
      src/lib/novel/outline-find-protocol.spec.ts
  42. 52 0
      src/lib/novel/outline-find-protocol.ts
  43. 3 1
      src/lib/novel/prompt-templates.ts
  44. 1 1
      src/lib/novel/review-adapter.spec.ts
  45. 14 6
      src/lib/novel/review-adapter.ts
  46. 27 1
      src/lib/novel/task-router.ts

+ 35 - 14
src/components/chat/chat-panel.tsx

@@ -55,6 +55,7 @@ import type { PrePluginChainResult } from "@/lib/agent/pipeline"
 import { applyAgentToolActivityEvent, applyAgentToolEvent } from "@/lib/agent/tool-events"
 import { applyAgentToolActivityEvent, applyAgentToolEvent } from "@/lib/agent/tool-events"
 import { applyAgentActivityEvent, createAgentActivityEvent, settleRunningAgentStages } from "@/lib/agent/activity-trace"
 import { applyAgentActivityEvent, createAgentActivityEvent, settleRunningAgentStages } from "@/lib/agent/activity-trace"
 import { useAgentConfig } from "@/hooks/use-agent-config"
 import { useAgentConfig } from "@/hooks/use-agent-config"
+import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 import { resolveChapterLengthSpec } from "@/lib/novel/deep-chapter-prompts"
 import { resolveChapterLengthSpec } from "@/lib/novel/deep-chapter-prompts"
 import { executeIngestWrites } from "@/lib/ingest"
 import { executeIngestWrites } from "@/lib/ingest"
 import { routeTask, buildTaskDirective, type TaskRouteResult } from "@/lib/novel/task-router"
 import { routeTask, buildTaskDirective, type TaskRouteResult } from "@/lib/novel/task-router"
@@ -104,6 +105,10 @@ import type { FrameworkBinding, StoryFramework } from "@/lib/novel/story-simulat
 
 
 import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
 import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
 import { buildPlanExecutePolicyPrompt, WRITING_INTENTS } from "@/lib/agent/plan-execute-policy"
 import { buildPlanExecutePolicyPrompt, WRITING_INTENTS } from "@/lib/agent/plan-execute-policy"
+import {
+  buildOutlineFindProtocol,
+  shouldIncludeOutlineFindProtocol,
+} from "@/lib/novel/outline-find-protocol"
 import { createContextTrace, finishTrace, setContextInfo, type ContextTrace } from "@/lib/agent/context-trace"
 import { createContextTrace, finishTrace, setContextInfo, type ContextTrace } from "@/lib/agent/context-trace"
 import { settleRunningAgentToolCalls } from "@/lib/agent/tool-events"
 import { settleRunningAgentToolCalls } from "@/lib/agent/tool-events"
 import { appendMcpCallTrace } from "@/lib/agent/mcp-trace"
 import { appendMcpCallTrace } from "@/lib/agent/mcp-trace"
@@ -285,6 +290,9 @@ function buildChatAgentSystemPrompt(options: {
   agentWritingSkills?: UserSkill[]
   agentWritingSkills?: UserSkill[]
   projectName?: string
   projectName?: string
   bindingTitle?: string
   bindingTitle?: string
+  targetChapterNumber?: number
+  /** 仅章节写作且不会走 pre-plugin 时注入,避免与 build_system_prompt plugin 重复 */
+  includeOutlineFindProtocol?: boolean
 }): string {
 }): string {
   const lines = [
   const lines = [
     options.novelMode
     options.novelMode
@@ -305,6 +313,9 @@ function buildChatAgentSystemPrompt(options: {
     lines.push("小说模式下,如果用户要求生成、续写或改写章节,只输出可直接放入章节库的正文。")
     lines.push("小说模式下,如果用户要求生成、续写或改写章节,只输出可直接放入章节库的正文。")
     lines.push("章节生成、续写或改写任务的最终回复必须只包含章节正文,不要把工具读取过程、写作计划或执行过程展示给用户。")
     lines.push("章节生成、续写或改写任务的最终回复必须只包含章节正文,不要把工具读取过程、写作计划或执行过程展示给用户。")
     lines.push("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议、引用来源或 Markdown 表格;章节标题和正文以外的内容都不要输出。")
     lines.push("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议、引用来源或 Markdown 表格;章节标题和正文以外的内容都不要输出。")
+    if (options.includeOutlineFindProtocol) {
+      lines.push(buildOutlineFindProtocol(options.targetChapterNumber))
+    }
     if (options.aiWorkflowMode === "fast") {
     if (options.aiWorkflowMode === "fast") {
       lines.push("快速模式下可以读取必要上下文;除非用户明确要求使用工作流或 Skill,否则不要主动调用 run_chapter_workflow。")
       lines.push("快速模式下可以读取必要上下文;除非用户明确要求使用工作流或 Skill,否则不要主动调用 run_chapter_workflow。")
     } else {
     } else {
@@ -1350,16 +1361,6 @@ export function ChatPanel() {
         return
         return
       }
       }
 
 
-      const sessionAgentSystemPrompt = buildChatAgentSystemPrompt({
-        novelMode,
-        mode,
-        deepChapterEnabled,
-        chatEditModeEnabled,
-        aiWorkflowMode,
-        planExecuteEnabled: planExecuteActive,
-        projectName: project?.name,
-        bindingTitle: activeBinding?.framework.title,
-      })
       const lastGeneratedChapterNumber = novelMode
       const lastGeneratedChapterNumber = novelMode
         ? detectLastGeneratedChapterNumber(
         ? detectLastGeneratedChapterNumber(
             activeConvMessages
             activeConvMessages
@@ -1468,6 +1469,22 @@ export function ChatPanel() {
           }
           }
         : taskRoute
         : taskRoute
 
 
+      const sessionAgentSystemPrompt = buildChatAgentSystemPrompt({
+        novelMode,
+        mode,
+        deepChapterEnabled,
+        chatEditModeEnabled,
+        aiWorkflowMode,
+        planExecuteEnabled: planExecuteActive,
+        projectName: project?.name,
+        bindingTitle: activeBinding?.framework.title,
+        targetChapterNumber,
+        // pre-plugin 会注入找纲协议;仅在不会跑 pre-plugin 的章节写作路径由这里注入一次
+        includeOutlineFindProtocol:
+          shouldIncludeOutlineFindProtocol(effectiveTaskRoute?.intent) &&
+          !(novelMode && (aiWorkflowMode !== "fast" || planExecuteActive)),
+      })
+
       if (novelMode && effectiveTaskRoute) {
       if (novelMode && effectiveTaskRoute) {
         const contextHub = getContextHub(pp)
         const contextHub = getContextHub(pp)
         const novelConfig = useWikiStore.getState().novelConfig
         const novelConfig = useWikiStore.getState().novelConfig
@@ -1488,9 +1505,8 @@ export function ChatPanel() {
               content: message.content,
               content: message.content,
             })),
             })),
             existingSummary: activeConv?.contextSummary,
             existingSummary: activeConv?.contextSummary,
-            tokenBudget: novelConfig.contextTokenBudget > 0
-              ? novelConfig.contextTokenBudget
-              : undefined,
+            tokenBudget: novelConfig.contextTokenBudget,
+            maxContextSize: agentConfig.llmConfig.maxContextSize,
           })
           })
           if (contextHubResult) {
           if (contextHubResult) {
             try {
             try {
@@ -1611,7 +1627,10 @@ export function ChatPanel() {
             revisionDirectives: "",
             revisionDirectives: "",
             }))
             }))
             const novelConfig = useWikiStore.getState().novelConfig
             const novelConfig = useWikiStore.getState().novelConfig
-            const budget = novelConfig.contextTokenBudget > 0 ? novelConfig.contextTokenBudget : undefined
+            const budget = resolveContextPackTokenBudget({
+              maxContextSize: agentConfig.llmConfig.maxContextSize,
+              contextTokenBudget: novelConfig.contextTokenBudget,
+            })
             novelContextPrompt = [
             novelContextPrompt = [
               taskDirective,
               taskDirective,
               goldenDirective,
               goldenDirective,
@@ -1696,6 +1715,8 @@ export function ChatPanel() {
           getChatConversations: () => [],
           getChatConversations: () => [],
           getOutlineConversations: () => [],
           getOutlineConversations: () => [],
           readTextFile: contextHubResult.readFile,
           readTextFile: contextHubResult.readFile,
+          llmConfig: agentConfig.llmConfig,
+          maxContextSize: agentConfig.llmConfig.maxContextSize,
           enabledToolNames: [
           enabledToolNames: [
             "read_chapter",
             "read_chapter",
             "read_outline",
             "read_outline",

+ 1 - 1
src/components/chat/tool-call-timeline.tsx

@@ -101,7 +101,7 @@ export function getToolCallDescription(name: string, params: Record<string, unkn
     case "load_context":
     case "load_context":
       return `加载小说上下文${params.chapterNumber ? `(第${params.chapterNumber}章)` : ""}`
       return `加载小说上下文${params.chapterNumber ? `(第${params.chapterNumber}章)` : ""}`
     case "trim_context":
     case "trim_context":
-      return `裁剪上下文至 ${params.targetChars || "默认"} 字符`
+      return `裁剪上下文至 ${params.tokenBudget || params.targetChars || "默认"} Token`
     default:
     default:
       return name
       return name
   }
   }

+ 5 - 2
src/components/novel/character-aura-view.tsx

@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input"
 import { Label } from "@/components/ui/label"
 import { Label } from "@/components/ui/label"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
 import { buildContextPack, contextPackToPrompt } from "@/lib/novel/context-engine"
 import { buildContextPack, contextPackToPrompt } from "@/lib/novel/context-engine"
-import { computeNovelContextTokenBudget } from "@/lib/context-budget"
+import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 import { resolveNovelModel } from "@/lib/novel/model-resolver"
 import { resolveNovelModel } from "@/lib/novel/model-resolver"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import {
 import {
@@ -357,7 +357,10 @@ export function CharacterAuraView({ hideSidebar = false }: { hideSidebar?: boole
       }
       }
       const contextPack = await buildContextPack(project.path, auraPreviewTask)
       const contextPack = await buildContextPack(project.path, auraPreviewTask)
       const previewPack = { ...contextPack, characterAuras: characterAuraPreview }
       const previewPack = { ...contextPack, characterAuras: characterAuraPreview }
-      const contextPrompt = contextPackToPrompt(previewPack, computeNovelContextTokenBudget(llmConfig.maxContextSize, novelConfig.contextTokenBudget))
+      const contextPrompt = contextPackToPrompt(previewPack, resolveContextPackTokenBudget({
+        maxContextSize: llmConfig.maxContextSize,
+        contextTokenBudget: novelConfig.contextTokenBudget,
+      }))
       const effectiveConfig = resolveNovelModel(llmConfig, novelConfig, "writing")
       const effectiveConfig = resolveNovelModel(llmConfig, novelConfig, "writing")
       const messages: ChatMessage[] = [
       const messages: ChatMessage[] = [
         {
         {

+ 6 - 9
src/components/sources/outline-chat-panel.tsx

@@ -1886,9 +1886,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           references: tokens.map(describeReferenceForOutlineAgent),
           references: tokens.map(describeReferenceForOutlineAgent),
           messages: historyBeforeSend,
           messages: historyBeforeSend,
           existingSummary: forceRefresh ? undefined : targetConversation?.contextSummary,
           existingSummary: forceRefresh ? undefined : targetConversation?.contextSummary,
-          tokenBudget: novelConfig.contextTokenBudget > 0
-            ? novelConfig.contextTokenBudget
-            : undefined,
+          tokenBudget: novelConfig.contextTokenBudget,
+          maxContextSize: effectiveLlmConfig.maxContextSize,
           forceRefresh,
           forceRefresh,
         });
         });
         if (contextHubResult) {
         if (contextHubResult) {
@@ -2753,9 +2752,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               content: message.content,
               content: message.content,
             })),
             })),
             existingSummary: conv.contextSummary,
             existingSummary: conv.contextSummary,
-            tokenBudget: novelConfig.contextTokenBudget > 0
-              ? novelConfig.contextTokenBudget
-              : undefined,
+            tokenBudget: novelConfig.contextTokenBudget,
+            maxContextSize: effectiveLlmConfig.maxContextSize,
           });
           });
           if (contextHubResult) {
           if (contextHubResult) {
             try {
             try {
@@ -3140,9 +3138,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             intent: "generate",
             intent: "generate",
             messages: historyMessages,
             messages: historyMessages,
             existingSummary: undefined,
             existingSummary: undefined,
-            tokenBudget: novelConfig.contextTokenBudget > 0
-              ? novelConfig.contextTokenBudget
-              : undefined,
+            tokenBudget: novelConfig.contextTokenBudget,
+            maxContextSize: effectiveLlmConfig.maxContextSize,
           });
           });
           if (contextHubResult && isCurrentRun()) {
           if (contextHubResult && isCurrentRun()) {
             try {
             try {

+ 1 - 0
src/hooks/use-agent-config.ts

@@ -143,6 +143,7 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
       getOutlineConversations,
       getOutlineConversations,
       mcpTools: mcpRuntime.mcpTools,
       mcpTools: mcpRuntime.mcpTools,
       llmConfig: agentLlmConfig,
       llmConfig: agentLlmConfig,
+      maxContextSize: agentLlmConfig.maxContextSize,
       chapterWritingLlmConfig,
       chapterWritingLlmConfig,
       aiWorkflowMode,
       aiWorkflowMode,
       runDeepChapterGeneration,
       runDeepChapterGeneration,

+ 2 - 2
src/i18n/en.json

@@ -1264,8 +1264,8 @@
       "searchTopK": "Search Result Count (Retrieval TOPK)",
       "searchTopK": "Search Result Count (Retrieval TOPK)",
       "searchTopKHint": "Controls how many relevant memory records are retrieved and injected into the writing context. Higher values add more references, but can add noise and consume context.",
       "searchTopKHint": "Controls how many relevant memory records are retrieved and injected into the writing context. Higher values add more references, but can add noise and consume context.",
       "contextTokenBudget": "Context Token Budget",
       "contextTokenBudget": "Context Token Budget",
-      "contextTokenBudgetHint": "0 means unlimited",
-      "contextTokenBudgetHelp": "Limits the number of context tokens injected into one novel writing or extraction call. Use 0 for no extra limit.",
+      "contextTokenBudgetHint": "0 = auto from model window",
+      "contextTokenBudgetHelp": "Limits context tokens injected into one novel writing or extraction call. 0 means no extra limit: the budget is derived from a safe fraction of the model context window. Deep chapter writing first reserves output headroom as max(2× chapter target at CJK density, chapter maxOutputTokens), then allocates the context pack.",
       "chatHistoryLength": "Chat History Length",
       "chatHistoryLength": "Chat History Length",
       "chatHistoryLengthHint": "Number of previous messages sent to the AI with each request. More history gives fuller context but uses more tokens.",
       "chatHistoryLengthHint": "Number of previous messages sent to the AI with each request. More history gives fuller context but uses more tokens.",
       "chatHistoryLengthHelp": "Controls how many AI chat messages are included in each request. Higher values preserve more conversation context and consume more tokens.",
       "chatHistoryLengthHelp": "Controls how many AI chat messages are included in each request. Higher values preserve more conversation context and consume more tokens.",

+ 2 - 2
src/i18n/zh.json

@@ -1133,8 +1133,8 @@
       "searchTopK": "检索结果数量(检索 TOPK)",
       "searchTopK": "检索结果数量(检索 TOPK)",
       "searchTopKHint": "控制从小说记忆库中检索并注入上下文的相关资料条数。数值越大,参考资料越多,但噪音和上下文占用也会增加。",
       "searchTopKHint": "控制从小说记忆库中检索并注入上下文的相关资料条数。数值越大,参考资料越多,但噪音和上下文占用也会增加。",
       "contextTokenBudget": "上下文 Token 预算",
       "contextTokenBudget": "上下文 Token 预算",
-      "contextTokenBudgetHint": "0 表示无限制",
-      "contextTokenBudgetHelp": "限制一次小说写作或资料提取时可注入的上下文 Token 数量,避免提示词过长。设置为 0 时不额外限制。",
+      "contextTokenBudgetHint": "0 表示按模型窗口自动计算",
+      "contextTokenBudgetHelp": "限制一次小说写作或资料提取时可注入的上下文 Token 数量,避免提示词过长。设置为 0 时不额外限制,按模型上下文窗口的安全比例自动计算。深度写作会先按「单章目标字数×2(按中文密度折算)与章节 maxOutputTokens 取较大值」预留正文输出,再分配资料包。",
       "chatHistoryLength": "对话历史长度",
       "chatHistoryLength": "对话历史长度",
       "chatHistoryLengthHint": "每次请求发给 AI 的历史消息条数。多 = 上下文更完整但更费 token。",
       "chatHistoryLengthHint": "每次请求发给 AI 的历史消息条数。多 = 上下文更完整但更费 token。",
       "chatHistoryLengthHelp": "控制 AI 会话中每次请求携带多少条历史消息。数量越多,AI 记得的上下文越完整,但消耗的 token 也越多。",
       "chatHistoryLengthHelp": "控制 AI 会话中每次请求携带多少条历史消息。数量越多,AI 记得的上下文越完整,但消耗的 token 也越多。",

+ 1 - 1
src/lib/agent/plan-execute-policy.ts

@@ -27,7 +27,7 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
     "计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
     "计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
     "输出计划后必须暂停,等待用户确认后再进入正文或执行阶段。",
     "输出计划后必须暂停,等待用户确认后再进入正文或执行阶段。",
     "计划必须包含:任务目标、已读取依据、缺失资料、执行步骤、确认后动作。",
     "计划必须包含:任务目标、已读取依据、缺失资料、执行步骤、确认后动作。",
-    "读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件;不要凭空编造章节、大纲或记忆条目名称。",
+    "读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件;不要凭空编造章节、大纲或记忆条目名称。list_outlines 后按 type 分流:优先关注 overview(索引)与 concept(硬约束),对 outline 类必须读正文确认对应该章后再写。",
     "如果资料缺失,必须在“缺失资料”里说明,并基于已读取内容继续制定可执行方案。",
     "如果资料缺失,必须在“缺失资料”里说明,并基于已读取内容继续制定可执行方案。",
   ].join("\n")
   ].join("\n")
 
 

+ 74 - 1
src/lib/agent/plugins/build-system-prompt-plugin.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest"
 import { describe, expect, it } from "vitest"
 import { createBuildSystemPromptPlugin } from "./build-system-prompt-plugin"
 import { createBuildSystemPromptPlugin } from "./build-system-prompt-plugin"
 import { normalizeUserSkill } from "@/lib/novel/skill-library"
 import { normalizeUserSkill } from "@/lib/novel/skill-library"
+import { buildOutlineFindProtocol } from "@/lib/novel/outline-find-protocol"
 
 
 describe("BuildSystemPromptPlugin selected skills", () => {
 describe("BuildSystemPromptPlugin selected skills", () => {
   it("injects selected skill prompt before final model execution", async () => {
   it("injects selected skill prompt before final model execution", async () => {
@@ -103,10 +104,82 @@ describe("BuildSystemPromptPlugin selected skills", () => {
     expect(result.finalSystemPrompt).toContain("禁止违背")
     expect(result.finalSystemPrompt).toContain("禁止违背")
     expect(result.finalSystemPrompt).toContain("可自由发挥")
     expect(result.finalSystemPrompt).toContain("可自由发挥")
     expect(result.finalSystemPrompt).toContain("planBlueprint")
     expect(result.finalSystemPrompt).toContain("planBlueprint")
+    expect(result.finalSystemPrompt).toContain("大纲定位协议")
+    expect(result.finalSystemPrompt).toContain("list_outlines")
+    expect(result.finalSystemPrompt).toContain("按 type 分流")
     expect(result.finalSystemRulesPrompt).toContain("章节主编策划协议")
     expect(result.finalSystemRulesPrompt).toContain("章节主编策划协议")
     expect(result.finalSystemRulesPrompt).not.toContain("context prompt")
     expect(result.finalSystemRulesPrompt).not.toContain("context prompt")
     const finalPrompt = result.finalSystemPrompt ?? ""
     const finalPrompt = result.finalSystemPrompt ?? ""
-    expect(finalPrompt.length).toBeLessThan(3000)
+    expect(finalPrompt.length).toBeLessThan(4500)
+  })
+
+  it("injects outline find protocol for chapter writing even without Plan Execute", async () => {
+    const plugin = createBuildSystemPromptPlugin({
+      baseSystemPrompt: "base prompt",
+      buildTaskDirectiveFn: () => "task directive",
+    })
+
+    const result = await plugin.run({
+      userMessage: "写第167章",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "strict",
+      planExecuteEnabled: false,
+      taskRoute: {
+        intent: "write_chapter",
+        confidence: 0.95,
+        chapterNumber: 167,
+        extractedParams: { chapterNumber: "167" },
+      },
+    })
+
+    expect(result.finalSystemPrompt).toContain("大纲定位协议")
+    expect(result.finalSystemPrompt).toContain("本次写作目标:第 167 章")
+    expect(result.finalSystemPrompt).toContain("overview")
+    expect(result.finalSystemPrompt).not.toContain("章节主编策划协议")
+    const occurrences = (result.finalSystemPrompt?.match(/大纲定位协议/g) ?? []).length
+    expect(occurrences).toBe(1)
+  })
+
+  it("does not inject outline find protocol for non-writing intents", async () => {
+    const plugin = createBuildSystemPromptPlugin({
+      baseSystemPrompt: "base prompt",
+    })
+
+    const result = await plugin.run({
+      userMessage: "陈远现在知道什么",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "strict",
+      taskRoute: { intent: "character_query", confidence: 0.9, extractedParams: {} },
+    })
+
+    expect(result.finalSystemPrompt).not.toContain("大纲定位协议")
+  })
+
+  it("dedupes outline find protocol already present in base system prompt", async () => {
+    const plugin = createBuildSystemPromptPlugin({
+      baseSystemPrompt: ["base", buildOutlineFindProtocol(1), "tail"].join("\n\n"),
+    })
+
+    const result = await plugin.run({
+      userMessage: "写下一章",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: {
+        intent: "write_chapter",
+        confidence: 0.9,
+        chapterNumber: 167,
+        extractedParams: {},
+      },
+    })
+
+    const occurrences = (result.finalSystemPrompt?.match(/大纲定位协议/g) ?? []).length
+    expect(occurrences).toBe(1)
+    expect(result.finalSystemPrompt).toContain("本次写作目标:第 167 章")
   })
   })
 
 
   it.each(["fast", "standard", "strict"] as const)(
   it.each(["fast", "standard", "strict"] as const)(

+ 22 - 6
src/lib/agent/plugins/build-system-prompt-plugin.ts

@@ -1,10 +1,15 @@
-  import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
+import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 import { buildTaskDirective } from "@/lib/novel/task-router"
 import { buildTaskDirective } from "@/lib/novel/task-router"
+import {
+  buildOutlineFindProtocol,
+  shouldIncludeOutlineFindProtocol,
+  stripOutlineFindProtocol,
+} from "@/lib/novel/outline-find-protocol"
 import { buildSelectedSkillsPrompt } from "./select-skills-plugin"
 import { buildSelectedSkillsPrompt } from "./select-skills-plugin"
 import { getWorkflowModeLabel, resolveAiWorkflowMode, type LegacyAiWorkflowMode } from "../workflow-mode"
 import { getWorkflowModeLabel, resolveAiWorkflowMode, type LegacyAiWorkflowMode } from "../workflow-mode"
 import { WRITING_INTENTS } from "../plan-execute-policy"
 import { WRITING_INTENTS } from "../plan-execute-policy"
 
 
-  export interface BuildSystemPromptPluginDeps {
+export interface BuildSystemPromptPluginDeps {
   baseSystemPrompt?: string
   baseSystemPrompt?: string
   buildTaskDirectiveFn?: typeof buildTaskDirective
   buildTaskDirectiveFn?: typeof buildTaskDirective
   onError?: (error: Error) => void
   onError?: (error: Error) => void
@@ -26,7 +31,9 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
         const parts: string[] = []
         const parts: string[] = []
         const rulesParts: string[] = []
         const rulesParts: string[] = []
 
 
-        const base = baseSystemPrompt || (input.agentConfig as any)?.systemPrompt || ""
+        // 去掉 base 里可能已有的找纲协议,统一由本 plugin 注入一次,避免重复。
+        const rawBase = baseSystemPrompt || (input.agentConfig as any)?.systemPrompt || ""
+        const base = rawBase ? stripOutlineFindProtocol(rawBase) : ""
         if (base) {
         if (base) {
           parts.push(base)
           parts.push(base)
           rulesParts.push(base)
           rulesParts.push(base)
@@ -42,9 +49,18 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
           rulesParts.push(selectedSkillsPrompt)
           rulesParts.push(selectedSkillsPrompt)
         }
         }
 
 
+        const routeForWriting = input.effectiveTaskRoute || input.taskRoute
+        const isWritingTask = Boolean(
+          routeForWriting?.intent && WRITING_INTENTS.has(routeForWriting.intent),
+        )
+
+        if (shouldIncludeOutlineFindProtocol(routeForWriting?.intent)) {
+          const outlineProtocol = buildOutlineFindProtocol(routeForWriting?.chapterNumber)
+          parts.push(outlineProtocol)
+          rulesParts.push(outlineProtocol)
+        }
+
         if (input.planExecuteEnabled && input.aiWorkflowMode) {
         if (input.planExecuteEnabled && input.aiWorkflowMode) {
-          const routeForPlan = input.effectiveTaskRoute || input.taskRoute
-          const isWritingTask = routeForPlan?.intent && WRITING_INTENTS.has(routeForPlan.intent)
           if (isWritingTask) {
           if (isWritingTask) {
             const planProtocol = buildChapterPlanProtocol(input.aiWorkflowMode)
             const planProtocol = buildChapterPlanProtocol(input.aiWorkflowMode)
             parts.push(planProtocol)
             parts.push(planProtocol)
@@ -83,7 +99,7 @@ function buildChapterPlanProtocol(mode: LegacyAiWorkflowMode): string {
     "输出规范:",
     "输出规范:",
     "1. 计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
     "1. 计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
     "2. 计划只供用户确认,正文生成必须等待用户确认后再开始。当前阶段禁用正文生成类工具,只能使用读取类工具收集资料。",
     "2. 计划只供用户确认,正文生成必须等待用户确认后再开始。当前阶段禁用正文生成类工具,只能使用读取类工具收集资料。",
-    "3. 计划必须基于会话上下文包;读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件名,绝不编造资料名称。",
+    "3. 计划必须基于会话上下文包;读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件名,绝不编造资料名称。list_outlines 后按 type 分流:优先关注 overview(索引)与 concept(硬约束),对 outline 类必须 read_outline 读正文确认对应该章后再写。",
     "4. 计划总长控制在 1200-1800字,避免堆砌分析维度;用结论和执行项表达。",
     "4. 计划总长控制在 1200-1800字,避免堆砌分析维度;用结论和执行项表达。",
     "5. 场景必须用 S1/S2/S3 编号,后续正文会按编号执行。",
     "5. 场景必须用 S1/S2/S3 编号,后续正文会按编号执行。",
     "6. 不要使用旧版分析报告式编号标题。",
     "6. 不要使用旧版分析报告式编号标题。",

+ 3 - 2
src/lib/agent/plugins/post-write-check-ai.ts

@@ -1,5 +1,6 @@
 import type { PostWriteCheck, PostWriteCheckItem } from "../context-trace"
 import type { PostWriteCheck, PostWriteCheckItem } from "../context-trace"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import type { ContextPack } from "@/lib/novel/context-engine"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { LlmConfig } from "@/stores/wiki-store"
 import { streamChat } from "@/lib/llm-client"
 import { streamChat } from "@/lib/llm-client"
 import { resolveNovelModel, type NovelTaskType } from "@/lib/novel/model-resolver"
 import { resolveNovelModel, type NovelTaskType } from "@/lib/novel/model-resolver"
@@ -28,8 +29,8 @@ const AI_TIMEOUT_MS = 30_000
 const VALID_SEVERITIES = ["info", "warning", "error"] as const
 const VALID_SEVERITIES = ["info", "warning", "error"] as const
 
 
 function buildPostWriteCheckPrompt(chapterContent: string, contextPack?: ContextPack): string {
 function buildPostWriteCheckPrompt(chapterContent: string, contextPack?: ContextPack): string {
-  const truncated = chapterContent.length > 8000
-    ? chapterContent.slice(0, 8000) + "\n\n[正文已截断]"
+  const truncated = chapterContent.length > CHAPTER_BODY_EXCERPT_MAX_CHARS
+    ? chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS) + "\n\n[正文已截断]"
     : chapterContent
     : chapterContent
   const chapterGoal = contextPack?.chapterGoal || "未提供"
   const chapterGoal = contextPack?.chapterGoal || "未提供"
   const previousEnding = contextPack?.previousChapterEnding || "未提供"
   const previousEnding = contextPack?.previousChapterEnding || "未提供"

+ 3 - 3
src/lib/agent/plugins/trim-context-plugin.spec.ts

@@ -52,7 +52,7 @@ describe("TrimContextPlugin", () => {
       userMessage: "写第5章",
       userMessage: "写第5章",
       projectPath: "/test-project",
       projectPath: "/test-project",
       agentConfig: {
       agentConfig: {
-        llmConfig: { maxContextSize: 4000 },
+        llmConfig: { maxContextSize: 204_800 },
       } as any,
       } as any,
       novelMode: true,
       novelMode: true,
       contextPack: mockContextPack,
       contextPack: mockContextPack,
@@ -62,8 +62,8 @@ describe("TrimContextPlugin", () => {
     expect(mockToPrompt).toHaveBeenCalled()
     expect(mockToPrompt).toHaveBeenCalled()
     const budgetArg = mockToPrompt.mock.calls[0][1]
     const budgetArg = mockToPrompt.mock.calls[0][1]
     expect(typeof budgetArg).toBe("number")
     expect(typeof budgetArg).toBe("number")
-    expect(budgetArg).toBeGreaterThan(500)
-    expect(budgetArg).toBeLessThan(4000)
+    expect(budgetArg).toBeGreaterThan(0)
+    expect(Number.isFinite(budgetArg)).toBe(true)
   })
   })
 
 
   it("supports custom token budget", async () => {
   it("supports custom token budget", async () => {

+ 11 - 12
src/lib/agent/plugins/trim-context-plugin.ts

@@ -1,5 +1,7 @@
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 import type { ContextPack, TrimResult } from "@/lib/novel/context-engine"
 import type { ContextPack, TrimResult } from "@/lib/novel/context-engine"
+import { resolveContextPackTokenBudget } from "@/lib/context-budget"
+import { useWikiStore } from "@/stores/wiki-store"
 
 
 export interface TrimContextPluginDeps {
 export interface TrimContextPluginDeps {
   contextPackToPromptFn?: (pack: ContextPack, tokenBudget?: number, options?: { excludeOutline?: boolean }) => string
   contextPackToPromptFn?: (pack: ContextPack, tokenBudget?: number, options?: { excludeOutline?: boolean }) => string
@@ -26,7 +28,7 @@ export function createTrimContextPlugin(deps: TrimContextPluginDeps = {}): PrePl
       let callId: string | undefined
       let callId: string | undefined
       if (onVirtualTool) {
       if (onVirtualTool) {
         callId = `trim_context_${Date.now()}`
         callId = `trim_context_${Date.now()}`
-        const budget = tokenBudget ?? computeTokenBudget(input)
+        const budget = tokenBudget ?? resolveTokenBudget(input)
         onVirtualTool("start", "trim_context", {
         onVirtualTool("start", "trim_context", {
           callId,
           callId,
           params: {
           params: {
@@ -37,7 +39,7 @@ export function createTrimContextPlugin(deps: TrimContextPluginDeps = {}): PrePl
       }
       }
 
 
       try {
       try {
-        const budget = tokenBudget ?? computeTokenBudget(input)
+        const budget = tokenBudget ?? resolveTokenBudget(input)
         let trimmedPrompt: string
         let trimmedPrompt: string
         let trimResult: TrimResult | null = null
         let trimResult: TrimResult | null = null
 
 
@@ -89,14 +91,11 @@ export function createTrimContextPlugin(deps: TrimContextPluginDeps = {}): PrePl
   }
   }
 }
 }
 
 
-function computeTokenBudget(input: PrePluginInput): number | undefined {
-  const maxContextSize = (input.agentConfig as any)?.llmConfig?.maxContextSize
-  if (!maxContextSize) return undefined
-  const estimatedUserTokens = Math.ceil(input.userMessage.length / 4)
-  const estimatedHistoryTokens = input.historyMessages
-    ? input.historyMessages.reduce((acc, msg) => acc + Math.ceil(msg.content.length / 4), 0)
-    : 0
-  const reservedTokens = 2000
-  const budget = maxContextSize - estimatedUserTokens - estimatedHistoryTokens - reservedTokens
-  return budget > 500 ? budget : undefined
+function resolveTokenBudget(input: PrePluginInput): number {
+  const maxContextSize = input.agentConfig?.llmConfig?.maxContextSize
+  const contextTokenBudget = useWikiStore.getState().novelConfig?.contextTokenBudget
+  return resolveContextPackTokenBudget({
+    maxContextSize,
+    contextTokenBudget,
+  })
 }
 }

+ 22 - 3
src/lib/agent/tools/index.ts

@@ -28,13 +28,17 @@ import type { UserSkill } from "@/lib/novel/skill-library"
 import type { LlmConfig, SearchApiConfig } from "@/stores/wiki-store"
 import type { LlmConfig, SearchApiConfig } from "@/stores/wiki-store"
 import type { TaskRouteResult } from "@/lib/novel/task-router"
 import type { TaskRouteResult } from "@/lib/novel/task-router"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import type { ContextPack } from "@/lib/novel/context-engine"
+import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 
 
 export interface VirtualToolContext {
 export interface VirtualToolContext {
   userMessage: string
   userMessage: string
   projectPath: string
   projectPath: string
   taskRoute?: TaskRouteResult
   taskRoute?: TaskRouteResult
   contextPack?: ContextPack
   contextPack?: ContextPack
-  targetChars?: number
+  /** ContextPack token budget; resolved from model window when omitted. */
+  tokenBudget?: number
+  /** Session model context window in characters. */
+  maxContextSize?: number
 }
 }
 
 
 export interface ToolFactoryOptions {
 export interface ToolFactoryOptions {
@@ -48,6 +52,8 @@ export interface ToolFactoryOptions {
   mcpTools?: Tool[]
   mcpTools?: Tool[]
   draftMode?: boolean
   draftMode?: boolean
   projectPath?: string
   projectPath?: string
+  /** Session model context window in characters (for trim_context defaults). */
+  maxContextSize?: number
   sourceConversationId?: string
   sourceConversationId?: string
   sourceMessageId?: string
   sourceMessageId?: string
   enabledToolNames?: string[]
   enabledToolNames?: string[]
@@ -80,7 +86,14 @@ export function registerAllBuiltInTools(registry: ToolRegistry, options: ToolFac
   if (shouldRegister("read_outline_history")) registry.register(createReadOutlineHistoryTool(options.getOutlineConversations()))
   if (shouldRegister("read_outline_history")) registry.register(createReadOutlineHistoryTool(options.getOutlineConversations()))
   if (shouldRegister("search_chapters")) registry.register(createSearchChaptersTool(chaptersDir, options.readTextFile))
   if (shouldRegister("search_chapters")) registry.register(createSearchChaptersTool(chaptersDir, options.readTextFile))
   if (shouldRegister("list_chapters")) registry.register(createListChaptersTool(chaptersDir))
   if (shouldRegister("list_chapters")) registry.register(createListChaptersTool(chaptersDir))
-  if (shouldRegister("list_outlines")) registry.register(createListOutlinesTool(outlinesDir))
+  if (shouldRegister("list_outlines")) {
+    registry.register(
+      createListOutlinesTool(outlinesDir, {
+        readTextFile: options.readTextFile,
+        getDefaultChapterNumber: () => options.virtualToolContext?.taskRoute?.chapterNumber,
+      }),
+    )
+  }
   if (shouldRegister("list_memories")) registry.register(createListMemoriesTool(memoryDir))
   if (shouldRegister("list_memories")) registry.register(createListMemoriesTool(memoryDir))
   if (shouldRegister("list_deductions")) registry.register(createListDeductionsTool(simDir))
   if (shouldRegister("list_deductions")) registry.register(createListDeductionsTool(simDir))
   if (shouldRegister("write_chapter")) {
   if (shouldRegister("write_chapter")) {
@@ -124,7 +137,13 @@ export function registerAllBuiltInTools(registry: ToolRegistry, options: ToolFac
       registry.register(createLoadContextTool(vtc.projectPath, vtc.userMessage, vtc.taskRoute))
       registry.register(createLoadContextTool(vtc.projectPath, vtc.userMessage, vtc.taskRoute))
     }
     }
     if (vtc.contextPack && shouldRegister("trim_context")) {
     if (vtc.contextPack && shouldRegister("trim_context")) {
-      registry.register(createTrimContextTool(vtc.contextPack, vtc.targetChars ?? 8000))
+      const tokenBudget = vtc.tokenBudget
+        ?? resolveContextPackTokenBudget({
+          maxContextSize: vtc.maxContextSize
+            ?? options.maxContextSize
+            ?? options.llmConfig?.maxContextSize,
+        })
+      registry.register(createTrimContextTool(vtc.contextPack, tokenBudget))
     }
     }
   }
   }
 }
 }

+ 27 - 5
src/lib/agent/tools/list-chapters.ts

@@ -1,19 +1,41 @@
 import type { Tool } from "../types"
 import type { Tool } from "../types"
 import { listDirectory } from "@/commands/fs"
 import { listDirectory } from "@/commands/fs"
+import { flattenMdFiles } from "@/lib/novel/chapter-utils"
+
+/** 仅匹配「第N章/节/回」,避免 backup-2024 等文件名污染最新章号 */
+export function extractStrictChapterNumber(text: string): number | null {
+  const m = text.match(/第\s*(\d+)\s*[章节回]/)
+  if (!m?.[1]) return null
+  const n = Number.parseInt(m[1], 10)
+  return Number.isFinite(n) && n > 0 ? n : null
+}
 
 
 export function createListChaptersTool(chaptersDir: string): Tool {
 export function createListChaptersTool(chaptersDir: string): Tool {
   return {
   return {
     name: "list_chapters",
     name: "list_chapters",
-    description: "列出所有章节文件的名称列表。无需参数。",
+    description:
+      "列出所有章节文件的名称列表,并标注最新已写章节号。续写「下一章」时可用最新章号+1 作为目标章号。无需参数。",
     category: "read",
     category: "read",
     parameters: {},
     parameters: {},
     execute: async () => {
     execute: async () => {
       try {
       try {
         const files = await listDirectory(chaptersDir)
         const files = await listDirectory(chaptersDir)
-        const chapters = files
-          .filter((f) => !f.is_dir && f.name.endsWith(".md"))
-          .map((f) => f.name.replace(/\.md$/, ""))
-        return `可用章节列表:\n${chapters.map((c, i) => `${i + 1}. ${c}`).join("\n")}`
+        const chapters = flattenMdFiles(files).map((f) => f.name.replace(/\.md$/i, ""))
+        const numbers = chapters
+          .map((name) => extractStrictChapterNumber(name))
+          .filter((n): n is number => n !== null)
+        const latest = numbers.length > 0 ? Math.max(...numbers) : null
+
+        const lines = [
+          "可用章节列表:",
+          ...chapters.map((c, i) => `${i + 1}. ${c}`),
+        ]
+        if (latest !== null) {
+          lines.push("")
+          lines.push(`最新已写章节:第 ${latest} 章。若任务是续写下一章,可推断目标为第 ${latest + 1} 章。`)
+          lines.push("找大纲时以「本次要写的目标章号」为准,不要用最新已写章号直接当卷纲匹配锚点。")
+        }
+        return lines.join("\n")
       } catch {
       } catch {
         return "错误:无法列出章节目录"
         return "错误:无法列出章节目录"
       }
       }

+ 44 - 10
src/lib/agent/tools/list-outlines.ts

@@ -1,19 +1,53 @@
 import type { Tool } from "../types"
 import type { Tool } from "../types"
-import { listDirectory } from "@/commands/fs"
+import { readFile } from "@/commands/fs"
+import {
+  buildOutlineListToolResult,
+  listOutlineEntries,
+} from "./outline-list-helpers"
+
+export interface ListOutlinesToolOptions {
+  readTextFile?: (path: string) => Promise<string>
+  /** 模型未传 chapterNumber 时的默认目标章号(通常来自当前任务路由) */
+  getDefaultChapterNumber?: () => number | undefined
+}
+
+function parseChapterNumberParam(raw: unknown): number | undefined {
+  if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return Math.floor(raw)
+  if (typeof raw === "string" && /^\d+$/.test(raw.trim())) {
+    const n = Number.parseInt(raw.trim(), 10)
+    return n > 0 ? n : undefined
+  }
+  return undefined
+}
+
+export function createListOutlinesTool(
+  outlinesDir: string,
+  readTextFileOrOptions: ((path: string) => Promise<string>) | ListOutlinesToolOptions = readFile,
+): Tool {
+  const options: ListOutlinesToolOptions =
+    typeof readTextFileOrOptions === "function"
+      ? { readTextFile: readTextFileOrOptions }
+      : readTextFileOrOptions
+  const readTextFile = options.readTextFile ?? readFile
 
 
-export function createListOutlinesTool(outlinesDir: string): Tool {
   return {
   return {
     name: "list_outlines",
     name: "list_outlines",
-    description: "列出所有大纲文件的名称列表。无需参数。",
+    description:
+      "列出大纲目录下全部 Markdown 文件(含子目录),并标注 frontmatter 的 type / outline_type。可选参数 chapterNumber 用于在结果中标注本次目标章号。写章节前应先调用本工具,再按 type 分流并用 read_outline 读正文判断哪份对应该章。",
     category: "read",
     category: "read",
-    parameters: {},
-    execute: async () => {
+    parameters: {
+      chapterNumber: {
+        type: "number",
+        description: "本次要写的目标章号(可选)。传入后会在列表末尾标注,便于对照正文找纲。",
+      },
+    },
+    execute: async (params) => {
       try {
       try {
-        const files = await listDirectory(outlinesDir)
-        const outlines = files
-          .filter((f) => !f.is_dir && f.name.endsWith(".md"))
-          .map((f) => f.name.replace(/\.md$/, ""))
-        return `可用大纲列表:\n${outlines.map((o, i) => `${i + 1}. ${o}`).join("\n")}`
+        const chapterNumber =
+          parseChapterNumberParam(params.chapterNumber) ?? options.getDefaultChapterNumber?.()
+
+        const entries = await listOutlineEntries(outlinesDir, readTextFile)
+        return buildOutlineListToolResult(entries, chapterNumber)
       } catch {
       } catch {
         return "错误:无法列出大纲目录"
         return "错误:无法列出大纲目录"
       }
       }

+ 58 - 4
src/lib/agent/tools/list-tools.spec.ts

@@ -1,24 +1,78 @@
 import { describe, expect, it, vi, beforeEach } from "vitest"
 import { describe, expect, it, vi, beforeEach } from "vitest"
 import { createListChaptersTool } from "./list-chapters"
 import { createListChaptersTool } from "./list-chapters"
+import { createListOutlinesTool } from "./list-outlines"
 import { createListMemoriesTool } from "./list-memories"
 import { createListMemoriesTool } from "./list-memories"
 
 
-vi.mock("@/commands/fs", () => ({ listDirectory: vi.fn() }))
-import { listDirectory } from "@/commands/fs"
+vi.mock("@/commands/fs", () => ({
+  listDirectory: vi.fn(),
+  readFile: vi.fn(),
+}))
+import { listDirectory, readFile } from "@/commands/fs"
 
 
 describe("list tools", () => {
 describe("list tools", () => {
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks()
     vi.clearAllMocks()
   })
   })
 
 
-  it("list_chapters returns file list from chapters dir", async () => {
+  it("list_chapters returns file list and latest chapter number", async () => {
     vi.mocked(listDirectory).mockResolvedValue([
     vi.mocked(listDirectory).mockResolvedValue([
       { name: "第1章-无我绝响.md", path: "/p/第1章-无我绝响.md", is_dir: false },
       { name: "第1章-无我绝响.md", path: "/p/第1章-无我绝响.md", is_dir: false },
+      { name: "第166章-账本.md", path: "/p/第166章-账本.md", is_dir: false },
       { name: "第2章.md", path: "/p/第2章.md", is_dir: false },
       { name: "第2章.md", path: "/p/第2章.md", is_dir: false },
+      { name: "backup-2024.md", path: "/p/backup-2024.md", is_dir: false },
     ])
     ])
     const tool = createListChaptersTool("/project/wiki/chapters")
     const tool = createListChaptersTool("/project/wiki/chapters")
     const result = await tool.execute({})
     const result = await tool.execute({})
     expect(result).toContain("第1章-无我绝响")
     expect(result).toContain("第1章-无我绝响")
-    expect(result).toContain("第2章")
+    expect(result).toContain("第166章-账本")
+    expect(result).toContain("最新已写章节:第 166 章")
+    expect(result).toContain("目标为第 167 章")
+    expect(result).not.toContain("最新已写章节:第 2024 章")
+  })
+
+  it("list_outlines uses default chapterNumber when param omitted", async () => {
+    vi.mocked(listDirectory).mockResolvedValue([
+      { name: "总纲.md", path: "/project/wiki/outlines/总纲.md", is_dir: false },
+    ])
+    vi.mocked(readFile).mockResolvedValue(`---\ntype: outline\n---\n`)
+    const tool = createListOutlinesTool("/project/wiki/outlines", {
+      readTextFile: readFile,
+      getDefaultChapterNumber: () => 167,
+    })
+    const result = await tool.execute({})
+    expect(result).toContain("本次目标章号:第 167 章")
+  })
+
+  it("list_outlines recursively lists files with type annotations", async () => {
+    vi.mocked(listDirectory).mockImplementation(async (path: string) => {
+      if (path.endsWith("/outlines")) {
+        return [
+          { name: "全局设定.md", path: `${path}/全局设定.md`, is_dir: false },
+          { name: "卷纲", path: `${path}/卷纲`, is_dir: true },
+        ]
+      }
+      if (path.endsWith("/卷纲")) {
+        return [
+          { name: "第三卷大纲.md", path: `${path}/第三卷大纲.md`, is_dir: false },
+        ]
+      }
+      return []
+    })
+    vi.mocked(readFile).mockImplementation(async (path: string) => {
+      if (path.includes("全局设定")) {
+        return `---\ntype: overview\ntitle: "全局设定"\n---\n`
+      }
+      if (path.includes("第三卷大纲")) {
+        return `---\ntype: outline\ntitle: "第三卷"\n---\n`
+      }
+      return ""
+    })
+
+    const tool = createListOutlinesTool("/project/wiki/outlines")
+    const result = await tool.execute({ chapterNumber: 167 })
+    expect(result).toContain("全局设定.md  type=overview")
+    expect(result).toContain("卷纲/第三卷大纲.md  type=outline")
+    expect(result).toContain("本次目标章号:第 167 章")
   })
   })
 
 
   it("list_memories returns file list from memory dir", async () => {
   it("list_memories returns file list from memory dir", async () => {

+ 74 - 0
src/lib/agent/tools/outline-list-helpers.spec.ts

@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest"
+import {
+  buildOutlineListToolResult,
+  extractOutlineTypeFields,
+  formatOutlineListLine,
+  type OutlineListEntry,
+} from "./outline-list-helpers"
+
+describe("outline-list-helpers", () => {
+  it("extracts type and outline_type from frontmatter", () => {
+    expect(
+      extractOutlineTypeFields(`---
+type: outline
+title: "第三卷大纲"
+---
+
+正文`),
+    ).toEqual({ type: "outline", outlineType: undefined })
+
+    expect(
+      extractOutlineTypeFields(`---
+type: concept
+outline_type: volume-outline
+title: "规则"
+---
+`),
+    ).toEqual({ type: "concept", outlineType: "volume-outline" })
+
+    expect(extractOutlineTypeFields("# 无 frontmatter")).toEqual({})
+  })
+
+  it("formats list lines with type fields", () => {
+    const entry: OutlineListEntry = {
+      relativePath: "卷纲/第三卷.md",
+      absolutePath: "/p/wiki/outlines/卷纲/第三卷.md",
+      type: "outline",
+      outlineType: "volume-outline",
+    }
+    expect(formatOutlineListLine(entry, 0)).toBe(
+      "1. 卷纲/第三卷.md  type=outline  outline_type=volume-outline",
+    )
+  })
+
+  it("builds tool result with mixed types and optional target chapter", () => {
+    const result = buildOutlineListToolResult(
+      [
+        {
+          relativePath: "第三卷大纲.md",
+          absolutePath: "/o/第三卷大纲.md",
+          type: "outline",
+        },
+        {
+          relativePath: "全局设定.md",
+          absolutePath: "/o/全局设定.md",
+          type: "overview",
+        },
+        {
+          relativePath: "钢铁洪流系统规则.md",
+          absolutePath: "/o/钢铁洪流系统规则.md",
+          type: "concept",
+        },
+      ],
+      167,
+    )
+
+    expect(result).toContain("1. 第三卷大纲.md  type=outline")
+    expect(result).toContain("2. 全局设定.md  type=overview")
+    expect(result).toContain("3. 钢铁洪流系统规则.md  type=concept")
+    expect(result).toContain("本次目标章号:第 167 章")
+    expect(result).toContain("overview:优先当索引读")
+    expect(result).toContain("concept:全书硬约束")
+    expect(result).toContain("不要跳过 overview/concept")
+  })
+})

+ 144 - 0
src/lib/agent/tools/outline-list-helpers.ts

@@ -0,0 +1,144 @@
+import { listDirectory, readFile } from "@/commands/fs"
+import { parseFrontmatter } from "@/lib/frontmatter"
+import { normalizePath } from "@/lib/path-utils"
+import type { FileNode } from "@/types/wiki"
+
+export interface OutlineListEntry {
+  /** 相对 outlines 根目录的路径,含 .md */
+  relativePath: string
+  absolutePath: string
+  type?: string
+  outlineType?: string
+}
+
+function stripMarkdownExt(name: string): string {
+  return name.replace(/\.md$/i, "")
+}
+
+function scalarFrontmatterString(value: unknown): string | undefined {
+  if (typeof value === "string" && value.trim()) return value.trim()
+  if (typeof value === "number" && Number.isFinite(value)) return String(value)
+  return undefined
+}
+
+export function extractOutlineTypeFields(content: string): {
+  type?: string
+  outlineType?: string
+} {
+  const { frontmatter } = parseFrontmatter(content)
+  if (!frontmatter) return {}
+  return {
+    type: scalarFrontmatterString(frontmatter.type),
+    outlineType: scalarFrontmatterString(frontmatter.outline_type),
+  }
+}
+
+async function collectOutlineMarkdownFiles(
+  rootDir: string,
+  depth = 0,
+  maxDepth = 4,
+): Promise<Array<{ name: string; path: string }>> {
+  let entries: FileNode[] = []
+  try {
+    entries = await listDirectory(rootDir)
+  } catch {
+    return []
+  }
+
+  const files: Array<{ name: string; path: string }> = []
+  for (const entry of entries) {
+    if (!entry.is_dir) {
+      if (entry.name.toLowerCase().endsWith(".md")) {
+        files.push({ name: entry.name, path: entry.path })
+      }
+      continue
+    }
+    if (depth >= maxDepth) continue
+    files.push(...(await collectOutlineMarkdownFiles(entry.path, depth + 1, maxDepth)))
+  }
+  return files
+}
+
+function toRelativePath(outlinesDir: string, absolutePath: string): string {
+  const root = normalizePath(outlinesDir).replace(/\/$/, "")
+  const full = normalizePath(absolutePath)
+  if (full === root) return ""
+  if (full.startsWith(`${root}/`)) return full.slice(root.length + 1)
+  return full.split("/").pop() ?? full
+}
+
+export function formatOutlineListLine(entry: OutlineListEntry, index: number): string {
+  const parts = [`${index + 1}. ${entry.relativePath}`]
+  if (entry.type) parts.push(`type=${entry.type}`)
+  if (entry.outlineType) parts.push(`outline_type=${entry.outlineType}`)
+  return parts.join("  ")
+}
+
+const FRONTMATTER_READ_CHARS = 8192
+
+export async function listOutlineEntries(
+  outlinesDir: string,
+  readTextFile: (path: string) => Promise<string> = readFile,
+): Promise<OutlineListEntry[]> {
+  const files = await collectOutlineMarkdownFiles(outlinesDir)
+  const entries: OutlineListEntry[] = []
+
+  for (const file of files) {
+    const relativePath = toRelativePath(outlinesDir, file.path) || file.name
+    let type: string | undefined
+    let outlineType: string | undefined
+    try {
+      const content = await readTextFile(file.path)
+      // 只需 frontmatter;大卷纲全文可达数百 KB,避免 list 时整文件读入。
+      const fields = extractOutlineTypeFields(content.slice(0, FRONTMATTER_READ_CHARS))
+      type = fields.type
+      outlineType = fields.outlineType
+    } catch {
+      // 单个文件读失败仍列入清单
+    }
+    entries.push({
+      relativePath,
+      absolutePath: file.path,
+      type,
+      outlineType,
+    })
+  }
+
+  entries.sort((a, b) =>
+    a.relativePath.localeCompare(b.relativePath, "zh-Hans-CN", { numeric: true }),
+  )
+  return entries
+}
+
+export function buildOutlineListToolResult(
+  entries: OutlineListEntry[],
+  targetChapterNumber?: number,
+): string {
+  if (entries.length === 0) {
+    return "可用大纲列表:\n(空)"
+  }
+
+  const lines = [
+    "可用大纲列表:",
+    ...entries.map((entry, index) => formatOutlineListLine(entry, index)),
+    "",
+    "说明:请根据 frontmatter 的 type / outline_type 分流。",
+    "- overview:优先当索引读,用来发现规则与卷纲入口。",
+    "- concept:全书硬约束/机制,写正文前关注并读取相关项;不要用章号匹配。",
+    "- outline:读正文判断是否对目标章;同为 outline 也可能不是卷纲。",
+    "不要只凭文件名选择;不要跳过 overview/concept 只读一份卷纲。",
+  ]
+
+  if (typeof targetChapterNumber === "number" && targetChapterNumber > 0) {
+    lines.push(
+      `本次目标章号:第 ${targetChapterNumber} 章。请结合 overview/concept 约束,为该章找到正文内容对应的大纲。`,
+    )
+  }
+
+  return lines.join("\n")
+}
+
+/** 供测试与调用方:从文件名推导展示名(无扩展名) */
+export function outlineDisplayName(relativePath: string): string {
+  return stripMarkdownExt(relativePath.split("/").pop() ?? relativePath)
+}

+ 8 - 5
src/lib/agent/tools/trim-context.ts

@@ -2,19 +2,22 @@ import type { Tool } from "../types"
 import { contextPackToPrompt } from "@/lib/novel/context-engine"
 import { contextPackToPrompt } from "@/lib/novel/context-engine"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import type { ContextPack } from "@/lib/novel/context-engine"
 
 
-export function createTrimContextTool(contextPack: ContextPack, targetChars: number): Tool {
+/**
+ * @param tokenBudget ContextPack token budget (not characters).
+ */
+export function createTrimContextTool(contextPack: ContextPack, tokenBudget: number): Tool {
   return {
   return {
     name: "trim_context",
     name: "trim_context",
     description:
     description:
-      "虚拟工具:将 ContextPack 按 targetChars 预算裁剪为最终提示字符串。M1 阶段包装现有头尾切,M4 升级为两级裁剪。由管道前置链自动执行,LLM 不直接调用。",
+      "虚拟工具:将 ContextPack 按 tokenBudget 预算裁剪为最终提示字符串。由管道前置链自动执行,LLM 不直接调用。",
     category: "virtual",
     category: "virtual",
     parameters: {},
     parameters: {},
     execute: async () => {
     execute: async () => {
-      if (targetChars <= 0) {
-        return "【上下文为空】targetChars 为 0,已跳过上下文加载"
+      if (tokenBudget <= 0) {
+        return "【上下文为空】tokenBudget 为 0,已跳过上下文加载"
       }
       }
       try {
       try {
-        return contextPackToPrompt(contextPack, targetChars)
+        return contextPackToPrompt(contextPack, tokenBudget)
       } catch (e) {
       } catch (e) {
         return `错误:裁剪上下文失败 - ${e instanceof Error ? e.message : String(e)}`
         return `错误:裁剪上下文失败 - ${e instanceof Error ? e.message : String(e)}`
       }
       }

+ 83 - 0
src/lib/context-budget.contract.spec.ts

@@ -0,0 +1,83 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+import {
+  computeContextBudget,
+  computeNovelContextTokenBudget,
+  computeWritingContextPackTokenBudget,
+  resolveContextPackTokenBudget,
+  WRITING_OUTPUT_RESERVE_MULTIPLIER,
+} from "./context-budget"
+
+describe("context pack budget contracts", () => {
+  it("auto mode is always finite and bounded by the window-derived cap", () => {
+    for (const maxContextSize of [64_000, 204_800, 1_000_000]) {
+      const general = resolveContextPackTokenBudget({
+        maxContextSize,
+        contextTokenBudget: 0,
+        langScale: 1,
+      })
+      const writing = computeWritingContextPackTokenBudget({
+        maxContextSize,
+        contextTokenBudget: 0,
+        chapterTargetChars: 3_000,
+        langScale: 1,
+      })
+      expect(Number.isFinite(general)).toBe(true)
+      expect(Number.isFinite(writing)).toBe(true)
+      expect(general).toBeGreaterThan(0)
+      expect(writing).toBeGreaterThan(0)
+      expect(general).toBeLessThanOrEqual(computeNovelContextTokenBudget(maxContextSize, 0, 1))
+      expect(writing).toBeLessThanOrEqual(general)
+    }
+  })
+
+  it("writing budget collapses to zero when the window cannot fit output reserve", () => {
+    const writing = computeWritingContextPackTokenBudget({
+      maxContextSize: 32_000,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })
+    expect(writing).toBe(0)
+  })
+
+  it("writing pack leaves room for output-token reserve plus scaffold", () => {
+    for (const chapterTargetChars of [2_000, 3_000, 6_000]) {
+      for (const maxContextSize of [64_000, 204_800]) {
+        const { maxCtx } = computeContextBudget(maxContextSize, 1)
+        const packTokens = computeWritingContextPackTokenBudget({
+          maxContextSize,
+          chapterTargetChars,
+          langScale: 1,
+        })
+        const maxOutputTokens = chapterTargetChars === 3_000
+          ? 8_000
+          : Math.max(8_000, Math.ceil((chapterTargetChars + 500) * 2))
+        const targetReserveTokens = Math.ceil(
+          (chapterTargetChars * WRITING_OUTPUT_RESERVE_MULTIPLIER) / 1.7,
+        )
+        const outputReserveChars = Math.max(targetReserveTokens, maxOutputTokens) * 4
+        const scaffold = Math.max(8_000, Math.floor(maxCtx * 0.08))
+        expect(packTokens * 4 + outputReserveChars + scaffold).toBeLessThanOrEqual(maxCtx)
+      }
+    }
+  })
+
+  it("chat-panel fallback resolves budget instead of passing undefined", () => {
+    const source = readFileSync(resolve(__dirname, "../components/chat/chat-panel.tsx"), "utf8")
+    expect(source).toContain("resolveContextPackTokenBudget({")
+    expect(source).not.toContain("contextTokenBudget > 0 ? novelConfig.contextTokenBudget : undefined")
+  })
+
+  it("context-engine never uses Infinity for pack trimming", () => {
+    const source = readFileSync(resolve(__dirname, "./novel/context-engine.ts"), "utf8")
+    expect(source).not.toMatch(/tokenBudget \? tokenBudget \* 4 : Infinity/)
+    expect(source).toContain("resolveContextPackTokenBudget({ maxContextSize: options?.maxContextSize })")
+  })
+
+  it("deep chapter no longer hard-codes a 32000 context budget", () => {
+    const source = readFileSync(resolve(__dirname, "./novel/deep-chapter-generation.ts"), "utf8")
+    expect(source).toContain("computeWritingContextPackTokenBudget({")
+    expect(source).not.toContain("DEEP_CHAPTER_CONTEXT_TOKEN_BUDGET")
+  })
+})

+ 91 - 0
src/lib/context-budget.test.ts

@@ -2,7 +2,10 @@ import { describe, it, expect } from "vitest"
 import {
 import {
   computeContextBudget,
   computeContextBudget,
   computeNovelContextTokenBudget,
   computeNovelContextTokenBudget,
+  computeWritingContextPackTokenBudget,
   contextScaleForLanguage,
   contextScaleForLanguage,
+  resolveContextPackTokenBudget,
+  WRITING_OUTPUT_RESERVE_MULTIPLIER,
 } from "./context-budget"
 } from "./context-budget"
 
 
 // The base-math tests pin langScale=1 so they stay deterministic
 // The base-math tests pin langScale=1 so they stay deterministic
@@ -86,3 +89,91 @@ describe("computeNovelContextTokenBudget", () => {
     expect(computeNovelContextTokenBudget(204_800, 0, zh)).toBe(14_144)
     expect(computeNovelContextTokenBudget(204_800, 0, zh)).toBe(14_144)
   })
   })
 })
 })
+
+describe("resolveContextPackTokenBudget", () => {
+  it("always returns a positive finite budget for auto mode", () => {
+    const budget = resolveContextPackTokenBudget({ maxContextSize: 204_800, contextTokenBudget: 0, langScale: 1 })
+    expect(budget).toBe(33_280)
+    expect(Number.isFinite(budget)).toBe(true)
+  })
+
+  it("honors an explicit user budget within the window cap", () => {
+    expect(resolveContextPackTokenBudget({
+      maxContextSize: 204_800,
+      contextTokenBudget: 10_000,
+      langScale: 1,
+    })).toBe(10_000)
+  })
+})
+
+describe("computeWritingContextPackTokenBudget", () => {
+  it("reserves at least maxOutputTokens (as chars) before allocating the pack", () => {
+    const maxContextSize = 204_800
+    const chapterTargetChars = 3_000
+    const langScale = 1
+    const { maxCtx } = computeContextBudget(maxContextSize, langScale)
+    const budget = computeWritingContextPackTokenBudget({
+      maxContextSize,
+      contextTokenBudget: 0,
+      chapterTargetChars,
+      langScale,
+    })
+    const maxOutputTokens = 8_000
+    const targetReserveTokens = Math.ceil((chapterTargetChars * WRITING_OUTPUT_RESERVE_MULTIPLIER) / 1.7)
+    const outputReserveChars = Math.max(targetReserveTokens, maxOutputTokens) * 4
+    const scaffold = Math.max(8_000, Math.floor(maxCtx * 0.08))
+    expect(budget * 4 + outputReserveChars + scaffold).toBeLessThanOrEqual(maxCtx)
+    expect(outputReserveChars).toBeGreaterThanOrEqual(maxOutputTokens * 4)
+  })
+
+  it("shrinks when chapter target grows", () => {
+    const smallTarget = computeWritingContextPackTokenBudget({
+      maxContextSize: 204_800,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })
+    const largeTarget = computeWritingContextPackTokenBudget({
+      maxContextSize: 204_800,
+      chapterTargetChars: 6_000,
+      langScale: 1,
+    })
+    expect(largeTarget).toBeLessThanOrEqual(smallTarget)
+  })
+
+  it("grows with a larger window within the general cap", () => {
+    const smallWindow = computeWritingContextPackTokenBudget({
+      maxContextSize: 64_000,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })
+    const largeWindow = computeWritingContextPackTokenBudget({
+      maxContextSize: 204_800,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })
+    expect(largeWindow).toBeGreaterThanOrEqual(smallWindow)
+  })
+
+  it("never exceeds the general window cap", () => {
+    const budget = computeWritingContextPackTokenBudget({
+      maxContextSize: 204_800,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })
+    expect(budget).toBeLessThanOrEqual(computeNovelContextTokenBudget(204_800, 0, 1))
+  })
+
+  it("clamps an explicit user budget to the writing-derived auto budget", () => {
+    const auto = computeWritingContextPackTokenBudget({
+      maxContextSize: 204_800,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })
+    expect(computeWritingContextPackTokenBudget({
+      maxContextSize: 204_800,
+      contextTokenBudget: 100_000,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })).toBe(auto)
+  })
+})

+ 76 - 0
src/lib/context-budget.ts

@@ -175,6 +175,82 @@ export function computeNovelContextTokenBudget(
   return cap
   return cap
 }
 }
 
 
+export interface ResolveContextPackTokenBudgetInput {
+  maxContextSize?: number
+  /** User setting; 0 / undefined = auto from window. */
+  contextTokenBudget?: number
+  langScale?: number
+}
+
+/**
+ * Canonical resolver for chat / context-hub / trim-plugin ContextPack budgets.
+ * Always returns a positive finite token budget (never "unbounded").
+ */
+export function resolveContextPackTokenBudget(
+  input: ResolveContextPackTokenBudgetInput = {},
+): number {
+  return computeNovelContextTokenBudget(
+    input.maxContextSize,
+    input.contextTokenBudget,
+    input.langScale,
+  )
+}
+
+/** Output reserve multiplier: chapter target chars × this factor. */
+export const WRITING_OUTPUT_RESERVE_MULTIPLIER = 2
+/** Minimum scaffold reserve for writing prompts (instructions / outline shell). */
+const WRITING_SCAFFOLD_RESERVE_FLOOR = 8_000
+const WRITING_SCAFFOLD_RESERVE_FRAC = 0.08
+
+export interface ComputeWritingContextPackTokenBudgetInput {
+  maxContextSize?: number
+  contextTokenBudget?: number
+  chapterTargetChars?: number
+  langScale?: number
+}
+
+/**
+ * Deep-chapter ContextPack budget: window minus output reserve (target×2)
+ * and scaffold, then clamped by the general window cap / user budget.
+ */
+export function computeWritingContextPackTokenBudget(
+  input: ComputeWritingContextPackTokenBudgetInput,
+): number {
+  const langScale = input.langScale
+  const { maxCtx } = computeContextBudget(input.maxContextSize, langScale)
+  // Inline clamp mirrors resolveChapterLengthSpec without importing deep-chapter-prompts
+  // (avoids circular deps). Keep in sync with DEEP_CHAPTER 2000–6000 bounds.
+  const rawTarget = input.chapterTargetChars
+  const target = Number.isFinite(rawTarget) && (rawTarget as number) > 0
+    ? Math.max(2_000, Math.min(6_000, Math.round(rawTarget as number)))
+    : 3_000
+  // Same formula as resolveChapterLengthSpec.maxOutputTokens.
+  const maxOutputTokens = target === 3_000
+    ? 8_000
+    : Math.max(8_000, Math.ceil((target + 500) * 2))
+  // User redundancy: 2× target chars at CJK density (~1.7 chars/token), then take the
+  // larger of that vs the chapter maxOutputTokens so generation headroom is real.
+  const targetReserveTokens = Math.ceil(
+    (target * WRITING_OUTPUT_RESERVE_MULTIPLIER) / CHARS_PER_TOKEN_CJK,
+  )
+  const outputReserveTokens = Math.max(targetReserveTokens, maxOutputTokens)
+  const outputReserveChars = outputReserveTokens * CHARS_PER_TOKEN
+  const scaffoldReserveChars = Math.max(
+    WRITING_SCAFFOLD_RESERVE_FLOOR,
+    Math.floor(maxCtx * WRITING_SCAFFOLD_RESERVE_FRAC),
+  )
+  const availableChars = Math.max(0, maxCtx - outputReserveChars - scaffoldReserveChars)
+  // Do not inflate with NOVEL_CONTEXT_TOKEN_FLOOR: that would steal the output reserve
+  // on small windows. Prefer leaving room for chapter generation.
+  const derivedTokens = Math.max(0, Math.floor(availableChars / CHARS_PER_TOKEN))
+  const windowCap = computeNovelContextTokenBudget(input.maxContextSize, 0, langScale)
+  const autoTokens = Math.min(derivedTokens, windowCap)
+  if (input.contextTokenBudget && input.contextTokenBudget > 0) {
+    return Math.min(input.contextTokenBudget, autoTokens)
+  }
+  return autoTokens
+}
+
 /** Legacy single-pass outline ingest floor; kept so small windows still behave predictably. */
 /** Legacy single-pass outline ingest floor; kept so small windows still behave predictably. */
 export const OUTLINE_INGEST_MIN_BODY_BUDGET = 8_000
 export const OUTLINE_INGEST_MIN_BODY_BUDGET = 8_000
 /** Upper cap aligned with wiki long-source ingest. */
 /** Upper cap aligned with wiki long-source ingest. */

+ 3 - 5
src/lib/context-hub/ai-chat-integration.spec.ts

@@ -38,10 +38,8 @@ describe("AI chat context hub integration", () => {
     )
     )
   })
   })
 
 
-  it("passes project and conversation scope into global user memory", () => {
-    expect(source).toContain("userMemoryProjectKey: projectPath")
-    expect(source).toContain("userMemorySessionKey: capturedConvId")
-    expect(source).toContain("projectKey: projectPath")
-    expect(source).toContain("sessionKey: capturedConvId")
+  it("passes resolved context budget from the model window when novel budget is unlimited", () => {
+    expect(source).toContain("tokenBudget: novelConfig.contextTokenBudget,")
+    expect(source).toContain("maxContextSize: agentConfig.llmConfig.maxContextSize,")
   })
   })
 })
 })

+ 6 - 0
src/lib/context-hub/ai-outline-integration.spec.ts

@@ -33,4 +33,10 @@ describe("AI outline context hub integration", () => {
     expect(source).not.toContain("formatContextHubStatsForDetails")
     expect(source).not.toContain("formatContextHubStatsForDetails")
     expect(source).toContain("contextHub.saveSnapshot(`${messageId}:${runId}`, contextHubResult)")
     expect(source).toContain("contextHub.saveSnapshot(`${messageId}:${runId}`, contextHubResult)")
   })
   })
+
+  it("passes model window size so unlimited token budget scales safely", () => {
+    expect(source).toContain("tokenBudget: novelConfig.contextTokenBudget,")
+    expect(source).toContain("maxContextSize: effectiveLlmConfig.maxContextSize,")
+    expect(source).not.toContain("contextTokenBudget > 0")
+  })
 })
 })

+ 21 - 0
src/lib/context-hub/composer.spec.ts

@@ -1,4 +1,5 @@
 import { describe, expect, it } from "vitest"
 import { describe, expect, it } from "vitest"
+import { computeNovelContextTokenBudget } from "@/lib/context-budget"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import { composeContext } from "./composer"
 import { composeContext } from "./composer"
 import { estimateContextTokens } from "./token-estimator"
 import { estimateContextTokens } from "./token-estimator"
@@ -141,4 +142,24 @@ describe("composeContext", () => {
     expect(result.stats.composedTokens).toBeLessThanOrEqual(800)
     expect(result.stats.composedTokens).toBeLessThanOrEqual(800)
     expect(result.stats.utilizationPercent).toBeLessThanOrEqual(100)
     expect(result.stats.utilizationPercent).toBeLessThanOrEqual(100)
   })
   })
+
+  it("无显式预算时按模型上下文窗口安全比例计算,而不是写死上限", () => {
+    const large = composeContext({
+      contextPack: pack(),
+      dependencies: {},
+      maxContextSize: 204_800,
+      tokenBudget: 0,
+    })
+    const small = composeContext({
+      contextPack: pack(),
+      dependencies: {},
+      maxContextSize: 32_000,
+      tokenBudget: 0,
+    })
+
+    expect(large.stats.budgetTokens).toBe(computeNovelContextTokenBudget(204_800, 0))
+    expect(small.stats.budgetTokens).toBe(computeNovelContextTokenBudget(32_000, 0))
+    expect(large.stats.budgetTokens).toBeGreaterThan(small.stats.budgetTokens)
+    expect(large.stats.budgetTokens).not.toBe(16_000)
+  })
 })
 })

+ 8 - 1
src/lib/context-hub/composer.ts

@@ -1,3 +1,4 @@
+import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 import { contextPackToPrompt, type ContextPack } from "@/lib/novel/context-engine"
 import { contextPackToPrompt, type ContextPack } from "@/lib/novel/context-engine"
 import { estimateContextTokens } from "./token-estimator"
 import { estimateContextTokens } from "./token-estimator"
 import type { ContextHubStats } from "./types"
 import type { ContextHubStats } from "./types"
@@ -8,7 +9,10 @@ export interface ComposeContextInput {
   dependencies: Record<string, number>
   dependencies: Record<string, number>
   referenceContext?: string[]
   referenceContext?: string[]
   confidence?: number
   confidence?: number
+  /** Explicit token budget; 0 / undefined = window-derived safe cap. */
   tokenBudget?: number
   tokenBudget?: number
+  /** Model context window in characters (wiki-store `maxContextSize`). */
+  maxContextSize?: number
 }
 }
 export interface ComposedContext {
 export interface ComposedContext {
   stableCore: string
   stableCore: string
@@ -158,7 +162,10 @@ function fitFragmentsProportionally(fragments: ContextFragment[], tokenBudget: n
 
 
 export function composeContext(input: ComposeContextInput): ComposedContext {
 export function composeContext(input: ComposeContextInput): ComposedContext {
   const expanded = (input.confidence ?? 0.8) < 0.6
   const expanded = (input.confidence ?? 0.8) < 0.6
-  const tokenBudget = Math.max(0, input.tokenBudget ?? 16_000)
+  const tokenBudget = resolveContextPackTokenBudget({
+    maxContextSize: input.maxContextSize,
+    contextTokenBudget: input.tokenBudget,
+  })
   const stableBudget = Math.floor(tokenBudget * 0.4)
   const stableBudget = Math.floor(tokenBudget * 0.4)
   const summaryBudget = Math.floor(tokenBudget * 0.15)
   const summaryBudget = Math.floor(tokenBudget * 0.15)
   const stableCore = joinSections(fitFragmentsProportionally(stableFragments(input.contextPack), stableBudget))
   const stableCore = joinSections(fitFragmentsProportionally(stableFragments(input.contextPack), stableBudget))

+ 2 - 0
src/lib/context-hub/context-hub.ts

@@ -78,6 +78,7 @@ function prepareKey(request: ContextHubRequest): string {
     references: request.references ?? [],
     references: request.references ?? [],
     summary: request.existingSummary ?? null,
     summary: request.existingSummary ?? null,
     tokenBudget: request.tokenBudget ?? null,
     tokenBudget: request.tokenBudget ?? null,
+    maxContextSize: request.maxContextSize ?? null,
     forceRefresh: request.forceRefresh ?? false,
     forceRefresh: request.forceRefresh ?? false,
   })
   })
 }
 }
@@ -236,6 +237,7 @@ export class ContextHubController implements ContextHub {
       referenceContext: request.references,
       referenceContext: request.references,
       confidence: confidenceFor(request, contextPack),
       confidence: confidenceFor(request, contextPack),
       tokenBudget: request.tokenBudget,
       tokenBudget: request.tokenBudget,
+      maxContextSize: request.maxContextSize,
     })
     })
     const cacheStats = cacheAdapter.getStats()
     const cacheStats = cacheAdapter.getStats()
     const cacheItems = cacheAdapter.getTraceItems()
     const cacheItems = cacheAdapter.getTraceItems()

+ 3 - 0
src/lib/context-hub/types.ts

@@ -117,7 +117,10 @@ export interface ContextHubRequest {
   references?: string[]
   references?: string[]
   messages?: AgentMessage[]
   messages?: AgentMessage[]
   existingSummary?: SessionContextSummary
   existingSummary?: SessionContextSummary
+  /** Explicit token budget; 0 / undefined = window-derived safe cap. */
   tokenBudget?: number
   tokenBudget?: number
+  /** Model context window in characters (wiki-store `maxContextSize`). */
+  maxContextSize?: number
   forceRefresh?: boolean
   forceRefresh?: boolean
 }
 }
 
 

+ 5 - 3
src/lib/lint.ts

@@ -7,6 +7,7 @@ import { getFileName, getRelativePath, normalizePath } from "@/lib/path-utils"
 import { buildLanguageDirective } from "@/lib/output-language"
 import { buildLanguageDirective } from "@/lib/output-language"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { buildContextPack, contextPackToPrompt } from "@/lib/novel/context-engine"
 import { buildContextPack, contextPackToPrompt } from "@/lib/novel/context-engine"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 import i18n from "@/i18n"
 import i18n from "@/i18n"
 
 
 export interface LintResult {
 export interface LintResult {
@@ -182,6 +183,7 @@ async function buildSemanticNovelPrompt(
   projectPath: string,
   projectPath: string,
   chapterContent: string,
   chapterContent: string,
   chapterNumber?: number,
   chapterNumber?: number,
+  maxContextSize?: number,
 ): Promise<string> {
 ): Promise<string> {
   const contextPack = await buildContextPack(
   const contextPack = await buildContextPack(
     projectPath,
     projectPath,
@@ -192,7 +194,7 @@ async function buildSemanticNovelPrompt(
   return [
   return [
     "你是一个小说连贯性检查编辑。请根据小说上下文包检查本章是否存在连贯性和执行偏差问题。",
     "你是一个小说连贯性检查编辑。请根据小说上下文包检查本章是否存在连贯性和执行偏差问题。",
     "",
     "",
-    contextPackToPrompt(contextPack),
+    contextPackToPrompt(contextPack, undefined, { maxContextSize }),
     "",
     "",
     "请重点检查:",
     "请重点检查:",
     "1. 本章必须完成:是否已完成,若未完成请指出缺失推进。",
     "1. 本章必须完成:是否已完成,若未完成请指出缺失推进。",
@@ -218,7 +220,7 @@ async function buildSemanticNovelPrompt(
     "- info: 建议优化",
     "- info: 建议优化",
     "",
     "",
     "章节正文:",
     "章节正文:",
-    chapterContent.slice(0, 8000),
+    chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS),
   ].join("\n")
   ].join("\n")
 }
 }
 
 
@@ -270,7 +272,7 @@ export async function runSemanticLint(
 
 
   const novelMode = useWikiStore.getState().novelMode
   const novelMode = useWikiStore.getState().novelMode
   const prompt = novelMode && options.chapterContent?.trim()
   const prompt = novelMode && options.chapterContent?.trim()
-    ? await buildSemanticNovelPrompt(pp, options.chapterContent, options.chapterNumber)
+    ? await buildSemanticNovelPrompt(pp, options.chapterContent, options.chapterNumber, llmConfig.maxContextSize)
     : buildSemanticWikiPrompt(summaries)
     : buildSemanticWikiPrompt(summaries)
 
 
   let raw = ""
   let raw = ""

+ 2 - 1
src/lib/novel/book-analysis/character-extraction-engine.ts

@@ -20,6 +20,7 @@ import { joinPath } from "@/lib/path-utils"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
 import { analyzeSixDimensions, DEPTH_DESCRIPTIONS } from "./six-dimension-engine"
 import { analyzeSixDimensions, DEPTH_DESCRIPTIONS } from "./six-dimension-engine"
 import { stableCharacterId } from "./character-recognition-engine"
 import { stableCharacterId } from "./character-recognition-engine"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 
 
 export interface CharacterExtractionInput {
 export interface CharacterExtractionInput {
   bookPath: string
   bookPath: string
@@ -72,7 +73,7 @@ async function identifyCharactersInChapter(
 章节:${chapterTitle}
 章节:${chapterTitle}
 
 
 内容:
 内容:
-${chapterContent.substring(0, 8000)} ${chapterContent.length > 8000 ? "...(内容过长已截断)" : ""}
+${chapterContent.substring(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)} ${chapterContent.length > CHAPTER_BODY_EXCERPT_MAX_CHARS ? "...(内容过长已截断)" : ""}
 
 
 请以JSON格式返回角色列表,格式如下:
 请以JSON格式返回角色列表,格式如下:
 {
 {

+ 3 - 2
src/lib/novel/book-analysis/story-framework-extraction.ts

@@ -5,6 +5,7 @@ import {
   extractPlotFrameworkLineageFromAnalysis,
   extractPlotFrameworkLineageFromAnalysis,
 } from "@/lib/novel/dismantling"
 } from "@/lib/novel/dismantling"
 import type { PlotFramework } from "@/lib/novel/plot-framework"
 import type { PlotFramework } from "@/lib/novel/plot-framework"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 
 
 export interface BookStoryFrameworkChapter {
 export interface BookStoryFrameworkChapter {
   id: string
   id: string
@@ -148,6 +149,6 @@ function readFrontmatterValue(frontmatter: string, key: string): string {
 
 
 function trimChapterContentForPrompt(content: string): string {
 function trimChapterContentForPrompt(content: string): string {
   const trimmed = content.trim()
   const trimmed = content.trim()
-  if (trimmed.length <= 8000) return trimmed
-  return `${trimmed.slice(0, 8000)}\n\n[本章内容过长,已截断用于故事框架提取]`
+  if (trimmed.length <= CHAPTER_BODY_EXCERPT_MAX_CHARS) return trimmed
+  return `${trimmed.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}\n\n[本章内容过长,已截断用于故事框架提取]`
 }
 }

+ 2 - 1
src/lib/novel/book-analysis/style-analysis-adapter.ts

@@ -15,6 +15,7 @@ import {
 } from "./style-prompts"
 } from "./style-prompts"
 import { styleProfileToMarkdown } from "./style-extraction-engine"
 import { styleProfileToMarkdown } from "./style-extraction-engine"
 import type { BookStyleProfile } from "./types"
 import type { BookStyleProfile } from "./types"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 
 
 export interface StyleAnalysisChunkResult {
 export interface StyleAnalysisChunkResult {
   raw: string
   raw: string
@@ -128,7 +129,7 @@ export function createStyleAnalysisAdapter(
       const blocks: string[] = []
       const blocks: string[] = []
       for (const chapterId of chunk.chapterIds) {
       for (const chapterId of chunk.chapterIds) {
         const raw = await dependencies.readFile(joinPath(bookPath, "chapters", `${chapterId}.md`))
         const raw = await dependencies.readFile(joinPath(bookPath, "chapters", `${chapterId}.md`))
-        const body = stripFrontmatter(raw).slice(0, 8000)
+        const body = stripFrontmatter(raw).slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)
         if (body) blocks.push(`【章节ID:${chapterId}】\n${body}`)
         if (body) blocks.push(`【章节ID:${chapterId}】\n${body}`)
       }
       }
       if (blocks.length !== chunk.chapterIds.length) throw new Error("所选文风章节正文为空,请检查后重试")
       if (blocks.length !== chunk.chapterIds.length) throw new Error("所选文风章节正文为空,请检查后重试")

+ 2 - 0
src/lib/novel/chapter-excerpts.ts

@@ -0,0 +1,2 @@
+/** Max characters of chapter body injected into a single review / lint / check prompt. */
+export const CHAPTER_BODY_EXCERPT_MAX_CHARS = 12_000

+ 2 - 1
src/lib/novel/chapter-ingest.ts

@@ -22,6 +22,7 @@ import { buildStructuredMemoryDocuments, isValidMemorySnapshot } from "./memory-
 import { clearGraphCache } from "@/lib/graph-relevance"
 import { clearGraphCache } from "@/lib/graph-relevance"
 import { RetrievalStore } from "./retrieval"
 import { RetrievalStore } from "./retrieval"
 import { computeOutlineIngestBodyBudget } from "@/lib/context-budget"
 import { computeOutlineIngestBodyBudget } from "@/lib/context-budget"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
 
 
 export interface ValidationWarning {
 export interface ValidationWarning {
   type: "entity_new" | "canon_conflict"
   type: "entity_new" | "canon_conflict"
@@ -737,7 +738,7 @@ ${langReminder}`
 章节编号:第${chapterNumber}章
 章节编号:第${chapterNumber}章
 
 
 章节正文:
 章节正文:
-${chapterBody.slice(0, 8000)}
+${chapterBody.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}
 
 
 请输出以下格式的 JSON:
 请输出以下格式的 JSON:
 {
 {

+ 2 - 1
src/lib/novel/chapter-plan-compliance.ts

@@ -1,7 +1,8 @@
 import { streamChat } from "@/lib/llm-client"
 import { streamChat } from "@/lib/llm-client"
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { LlmConfig } from "@/stores/wiki-store"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
 
 
-const FINAL_CONTENT_EXCERPT_MAX_CHARS = 12000
+const FINAL_CONTENT_EXCERPT_MAX_CHARS = CHAPTER_BODY_EXCERPT_MAX_CHARS
 const FINAL_CONTENT_EXCERPT_MARKER = "(正文中段已截断,保留开头与结尾用于检查承接和章末钩子。)"
 const FINAL_CONTENT_EXCERPT_MARKER = "(正文中段已截断,保留开头与结尾用于检查承接和章末钩子。)"
 
 
 export type ChapterPlanComplianceStatus =
 export type ChapterPlanComplianceStatus =

+ 11 - 3
src/lib/novel/context-engine.ts

@@ -1,3 +1,4 @@
+import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 import { listDirectory, readFile } from "@/commands/fs"
 import { listDirectory, readFile } from "@/commands/fs"
 import i18n from "@/i18n"
 import i18n from "@/i18n"
 import { searchWiki, tokenizeQuery } from "@/lib/search"
 import { searchWiki, tokenizeQuery } from "@/lib/search"
@@ -1086,7 +1087,11 @@ const FIELD_CONFIGS: FieldConfig[] = [
   { titleKey: "novel.contextPack.graphSearchResults", fieldKey: "graphSearchResults" },
   { titleKey: "novel.contextPack.graphSearchResults", fieldKey: "graphSearchResults" },
 ]
 ]
 
 
-export function contextPackToPrompt(pack: ContextPack, tokenBudget?: number, options?: { excludeOutline?: boolean }): string {
+export function contextPackToPrompt(
+  pack: ContextPack,
+  tokenBudget?: number,
+  options?: { excludeOutline?: boolean; maxContextSize?: number },
+): string {
   const result = trimContextPack(pack, tokenBudget, options)
   const result = trimContextPack(pack, tokenBudget, options)
   return result.prompt
   return result.prompt
 }
 }
@@ -1122,7 +1127,7 @@ function trimFieldContent(content: string | string[], maxChars: number): string
 export function trimContextPack(
 export function trimContextPack(
   pack: ContextPack,
   pack: ContextPack,
   tokenBudget?: number,
   tokenBudget?: number,
-  options?: { excludeOutline?: boolean }
+  options?: { excludeOutline?: boolean; maxContextSize?: number }
 ): TrimResult {
 ): TrimResult {
   const sections: string[] = []
   const sections: string[] = []
 
 
@@ -1163,7 +1168,10 @@ export function trimContextPack(
   const originalChars = totalChars
   const originalChars = totalChars
   const trimmedFields: string[] = []
   const trimmedFields: string[] = []
 
 
-  const targetChars = tokenBudget ? tokenBudget * 4 : Infinity
+  const resolvedTokenBudget = tokenBudget && tokenBudget > 0
+    ? tokenBudget
+    : resolveContextPackTokenBudget({ maxContextSize: options?.maxContextSize })
+  const targetChars = resolvedTokenBudget * 4
 
 
   if (totalChars <= targetChars) {
   if (totalChars <= targetChars) {
     for (const { title, content } of fieldData) {
     for (const { title, content } of fieldData) {

+ 7 - 10
src/lib/novel/deep-chapter-generation.ts

@@ -13,7 +13,7 @@ import {
   isReasoningOnlyResponseError,
   isReasoningOnlyResponseError,
   withReasoningDisabled,
   withReasoningDisabled,
 } from "@/lib/reasoning-retry";
 } from "@/lib/reasoning-retry";
-import { computeNovelContextTokenBudget } from "@/lib/context-budget";
+import { computeWritingContextPackTokenBudget } from "@/lib/context-budget";
 import { USER_ABORT_MESSAGE, rethrowIfUserAbort, throwIfAborted } from "@/lib/user-abort";
 import { USER_ABORT_MESSAGE, rethrowIfUserAbort, throwIfAborted } from "@/lib/user-abort";
 import {
 import {
   buildContextPack,
   buildContextPack,
@@ -146,9 +146,6 @@ const defaultDeps: DeepChapterGenerationDeps = {
 const REPEAT_CHECK_MIN_CHARS = 600;
 const REPEAT_CHECK_MIN_CHARS = 600;
 const REPEAT_WINDOW_CHARS = 120;
 const REPEAT_WINDOW_CHARS = 120;
 const REPEAT_HIT_LIMIT = 3;
 const REPEAT_HIT_LIMIT = 3;
-/** Legacy deep-chapter context budget (tokens). Kept as the upper bound;
- *  computeNovelContextTokenBudget clamps it down for small context windows. */
-const DEEP_CHAPTER_CONTEXT_TOKEN_BUDGET = 32000;
 /** chars/token approximation used to convert the token budget to characters
 /** chars/token approximation used to convert the token budget to characters
  *  for the outline cap (mirrors context-budget.ts / contextPackToPrompt). */
  *  for the outline cap (mirrors context-budget.ts / contextPackToPrompt). */
 const DEEP_CHAPTER_CHARS_PER_TOKEN = 4;
 const DEEP_CHAPTER_CHARS_PER_TOKEN = 4;
@@ -656,12 +653,12 @@ export async function runDeepChapterGeneration(
   );
   );
   throwIfAborted(signal);
   throwIfAborted(signal);
 
 
-  // 大纲与其余上下文共用同一窗口预算(派生自 maxContextSize)。大纲优先,
-  // 但设有上限占比,避免其独占整个窗口;剩余额度再分给记忆/设定/检索上下文。
-  const totalContextTokenBudget = computeNovelContextTokenBudget(
-    input.llmConfig.maxContextSize,
-    DEEP_CHAPTER_CONTEXT_TOKEN_BUDGET,
-  );
+  // 大纲与其余上下文共用同一窗口预算:按单章目标字数×2预留输出,再分配资料包。
+  const totalContextTokenBudget = computeWritingContextPackTokenBudget({
+    maxContextSize: input.llmConfig.maxContextSize,
+    contextTokenBudget: novelConfig.contextTokenBudget,
+    chapterTargetChars: novelConfig.chapterTargetChars,
+  });
   const totalContextCharBudget =
   const totalContextCharBudget =
     totalContextTokenBudget * DEEP_CHAPTER_CHARS_PER_TOKEN;
     totalContextTokenBudget * DEEP_CHAPTER_CHARS_PER_TOKEN;
   const outlineCharCap = Math.floor(
   const outlineCharCap = Math.floor(

+ 10 - 3
src/lib/novel/dimension-review-adapter.ts

@@ -3,6 +3,7 @@ import type { ChatMessage } from "@/lib/llm-providers"
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { LlmConfig } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { buildContextPack, contextPackToPrompt, type ContextPack } from "./context-engine"
 import { buildContextPack, contextPackToPrompt, type ContextPack } from "./context-engine"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
 import { resolveNovelModel } from "./model-resolver"
 import { resolveNovelModel } from "./model-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import type { NovelReviewResult } from "./review-adapter"
 import type { NovelReviewResult } from "./review-adapter"
@@ -101,8 +102,9 @@ export function buildDimensionReviewPrompt(
   pack: ContextPack,
   pack: ContextPack,
   chapterContent: string,
   chapterContent: string,
   dimension: SixReviewDimensionDefinition,
   dimension: SixReviewDimensionDefinition,
+  maxContextSize?: number,
 ): string {
 ): string {
-  return `${contextPackToPrompt(pack)}
+  return `${contextPackToPrompt(pack, undefined, { maxContextSize })}
 
 
 六维独立审查维度:${dimension.label}
 六维独立审查维度:${dimension.label}
 审查目标:${dimension.objective}
 审查目标:${dimension.objective}
@@ -136,7 +138,7 @@ ${dimension.checks.map((check) => `- ${check}`).join("\n")}
 }
 }
 
 
 章节正文:
 章节正文:
-${chapterContent.slice(0, 8000)}`
+${chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}`
 }
 }
 
 
 export async function reviewChapterDimension({
 export async function reviewChapterDimension({
@@ -153,7 +155,12 @@ export async function reviewChapterDimension({
   callbacks?: DimensionReviewCallbacks
   callbacks?: DimensionReviewCallbacks
 }): Promise<DimensionReviewResult> {
 }): Promise<DimensionReviewResult> {
   callbacks.onThinking?.(dimension.key, formatDimensionThinking(dimension, "正在读取上下文..."))
   callbacks.onThinking?.(dimension.key, formatDimensionThinking(dimension, "正在读取上下文..."))
-  const analysisPrompt = buildDimensionReviewPrompt(contextPack, chapterContent, dimension)
+  const analysisPrompt = buildDimensionReviewPrompt(
+    contextPack,
+    chapterContent,
+    dimension,
+    llmConfig.maxContextSize,
+  )
   const analysis = await runDimensionStage(
   const analysis = await runDimensionStage(
     llmConfig,
     llmConfig,
     dimension,
     dimension,

+ 9 - 4
src/lib/novel/lint.ts

@@ -4,6 +4,7 @@ import type { ChatMessage } from "@/lib/llm-providers"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { getOutputLanguage, buildLanguageReminder } from "@/lib/output-language"
 import { getOutputLanguage, buildLanguageReminder } from "@/lib/output-language"
 import { contextPackToPrompt, buildContextPack, type ContextPack } from "./context-engine"
 import { contextPackToPrompt, buildContextPack, type ContextPack } from "./context-engine"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
 import { resolveNovelModel } from "./model-resolver"
 import { resolveNovelModel } from "./model-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 
 
@@ -36,8 +37,12 @@ const NOVEL_LINT_DIMENSIONS = [
   "是否缺少章节钩子",
   "是否缺少章节钩子",
 ]
 ]
 
 
-export function buildNovelLintPrompt(pack: ContextPack, chapterContent: string): string {
-  return `${contextPackToPrompt(pack)}
+export function buildNovelLintPrompt(
+  pack: ContextPack,
+  chapterContent: string,
+  maxContextSize?: number,
+): string {
+  return `${contextPackToPrompt(pack, undefined, { maxContextSize })}
 
 
 ${i18n.t("novel.lint.lintInstruction", { defaultValue: "请对以下章节进行连贯性检查,逐一核对以下维度:" })}
 ${i18n.t("novel.lint.lintInstruction", { defaultValue: "请对以下章节进行连贯性检查,逐一核对以下维度:" })}
 ${NOVEL_LINT_DIMENSIONS.map((key, i) => `${i + 1}. ${key}`).join("\n")}
 ${NOVEL_LINT_DIMENSIONS.map((key, i) => `${i + 1}. ${key}`).join("\n")}
@@ -45,7 +50,7 @@ ${NOVEL_LINT_DIMENSIONS.map((key, i) => `${i + 1}. ${key}`).join("\n")}
 ${i18n.t("novel.lint.lintOutputFormat", { defaultValue: "请严格按照 JSON 数组格式输出检查结果。每个问题包含以下字段:severity(error/warning/info)、type(问题类型)、message(问题描述)、evidence(正文证据)、relatedMemory(相关记忆引用)、suggestion(修改建议)。如果没有发现问题,输出空数组 []。不要输出任何其他内容。" })}
 ${i18n.t("novel.lint.lintOutputFormat", { defaultValue: "请严格按照 JSON 数组格式输出检查结果。每个问题包含以下字段:severity(error/warning/info)、type(问题类型)、message(问题描述)、evidence(正文证据)、relatedMemory(相关记忆引用)、suggestion(修改建议)。如果没有发现问题,输出空数组 []。不要输出任何其他内容。" })}
 
 
 ${i18n.t("novel.lint.chapterContent", { defaultValue: "章节正文:" })}
 ${i18n.t("novel.lint.chapterContent", { defaultValue: "章节正文:" })}
-${chapterContent.slice(0, 8000)}`
+${chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}`
 }
 }
 
 
 export async function runNovelLint(
 export async function runNovelLint(
@@ -77,7 +82,7 @@ export async function runNovelLint(
 请严格按照 JSON 数组格式输出检查结果,不要输出任何其他内容。
 请严格按照 JSON 数组格式输出检查结果,不要输出任何其他内容。
 ${langReminder}`
 ${langReminder}`
 
 
-  const userPrompt = buildNovelLintPrompt(contextPack, chapterContent)
+  const userPrompt = buildNovelLintPrompt(contextPack, chapterContent, llmConfig.maxContextSize)
 
 
   try {
   try {
     const messages: ChatMessage[] = [
     const messages: ChatMessage[] = [

+ 55 - 0
src/lib/novel/outline-find-protocol.spec.ts

@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest"
+import {
+  buildOutlineFindProtocol,
+  formatTargetChapterLine,
+  shouldIncludeOutlineFindProtocol,
+  stripOutlineFindProtocol,
+} from "./outline-find-protocol"
+
+describe("outline-find-protocol", () => {
+  it("includes explicit target chapter when provided", () => {
+    const text = buildOutlineFindProtocol(167)
+    expect(text).toContain("本次写作目标:第 167 章")
+    expect(text).toContain("list_outlines")
+    expect(text).toContain("overview(高优先级入口)")
+    expect(text).toContain("concept / 设定类(写作硬约束)")
+    expect(text).toContain("禁止在未查看 overview/concept")
+    expect(text).toContain("read_outline")
+    expect(text).toContain("禁止只凭文件名")
+  })
+
+  it("asks model to resolve chapter number when missing", () => {
+    const text = buildOutlineFindProtocol()
+    expect(text).toContain("必须先明确本次要写的目标章号")
+    expect(text).not.toContain("本次写作目标:第")
+  })
+
+  it("formats target chapter line", () => {
+    expect(formatTargetChapterLine(104)).toBe("本次写作目标:第 104 章。")
+  })
+
+  it("only enables protocol for chapter writing intents", () => {
+    expect(shouldIncludeOutlineFindProtocol("write_chapter")).toBe(true)
+    expect(shouldIncludeOutlineFindProtocol("polish_chapter")).toBe(true)
+    expect(shouldIncludeOutlineFindProtocol("generate_outline")).toBe(false)
+    expect(shouldIncludeOutlineFindProtocol("character_query")).toBe(false)
+    expect(shouldIncludeOutlineFindProtocol(undefined)).toBe(false)
+  })
+
+  it("strips outline find protocol block without removing following sections", () => {
+    const prompt = [
+      "base rules",
+      "",
+      buildOutlineFindProtocol(10),
+      "",
+      "## 其它规则",
+      "keep me",
+    ].join("\n")
+    const stripped = stripOutlineFindProtocol(prompt)
+    expect(stripped).toContain("base rules")
+    expect(stripped).toContain("## 其它规则")
+    expect(stripped).toContain("keep me")
+    expect(stripped).not.toContain("大纲定位协议")
+    expect(stripped).not.toContain("list_outlines")
+  })
+})

+ 52 - 0
src/lib/novel/outline-find-protocol.ts

@@ -0,0 +1,52 @@
+/**
+ * 写作前找大纲协议:先钉目标章号,再按 type 分流,最后读正文判断归属。
+ * 不依赖文件名规范,也不要求大纲正文写法统一。
+ */
+
+import type { NovelTaskIntent } from "./task-router"
+
+/** 需要「按目标章找纲」协议的章节写作意图(不含大纲生成) */
+export const OUTLINE_FIND_CHAPTER_INTENTS = new Set<NovelTaskIntent>([
+  "write_chapter",
+  "continue_chapter",
+  "rewrite_chapter",
+  "polish_chapter",
+])
+
+export function shouldIncludeOutlineFindProtocol(intent?: string | null): boolean {
+  return Boolean(intent && OUTLINE_FIND_CHAPTER_INTENTS.has(intent as NovelTaskIntent))
+}
+
+export function buildOutlineFindProtocol(targetChapterNumber?: number): string {
+  const targetLine =
+    typeof targetChapterNumber === "number" && targetChapterNumber > 0
+      ? `本次写作目标:第 ${targetChapterNumber} 章。后续找大纲必须以该章号为准。`
+      : "写作前必须先明确本次要写的目标章号 N(可从任务路由、list_chapters 的最新章+1,或用户明示获得)。"
+
+  return [
+    "## 大纲定位协议(写章节前必须遵守)",
+    "",
+    targetLine,
+    "1. 调用 list_outlines 查看全部大纲候选及其 type / outline_type;先扫一遍有哪些 overview / concept / outline,不要只盯卷纲文件名。",
+    "2. 按 type 分流处理(有 type 用 type;无 type 则读正文判断用途):",
+    "   - overview(高优先级入口):应优先 read_outline 读索引,按 related / 文档表发现必须遵守的规则文档与卷纲入口;不要跳过 overview 直接瞎点卷纲。overview 本身不是本章剧情大纲。",
+    "   - concept / 设定类(写作硬约束):列表中出现的 concept 默认视为全书机制/叙述禁则;写正文前应至少读与本次任务相关的 concept(有 overview 指引时按指引读;无指引时对列出的 concept 做必要性判断并读取关键项)。不要用章号去「匹配」concept,也不要把它们当成卷纲。",
+    "   - outline(及 outline_type 为 story/volume/chapter-outline 等):主候选;必须 read_outline 读正文,判断是否对应该章 / 当前阶段。",
+    "   - 未知 type:必须读正文判断是卷纲、章纲、设定还是清单。",
+    "3. 同为 outline 也可能不是卷纲(如资产明细、人物表);禁止只看 type 或文件名就选定。",
+    "4. 确认对应该章的大纲,并已知相关 overview/concept 约束后,再写作或调用 run_chapter_workflow。",
+    "禁止只凭文件名猜测分卷;禁止把 concept/overview 当成章节剧情大纲;禁止在未查看 overview/concept 的情况下只读一份卷纲就开写。",
+  ].join("\n")
+}
+
+export function formatTargetChapterLine(chapterNumber: number): string {
+  return `本次写作目标:第 ${chapterNumber} 章。`
+}
+
+/** 从已拼接的系统提示中移除找纲协议块,避免与 plugin 重复注入。 */
+export function stripOutlineFindProtocol(prompt: string): string {
+  return prompt
+    .replace(/\n*## 大纲定位协议(写章节前必须遵守)\n[\s\S]*?(?=\n## |\n*$)/, "")
+    .replace(/\n{3,}/g, "\n\n")
+    .trim()
+}

+ 3 - 1
src/lib/novel/prompt-templates.ts

@@ -1,3 +1,5 @@
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
+
 export const PROMPTS = {
 export const PROMPTS = {
   chapterGeneration: (contextPack: string, chapterGoal: string) =>
   chapterGeneration: (contextPack: string, chapterGoal: string) =>
     [
     [
@@ -43,7 +45,7 @@ export const PROMPTS = {
       contextPack,
       contextPack,
       "",
       "",
       "请检查以下章节的连贯性:",
       "请检查以下章节的连贯性:",
-      chapterContent.slice(0, 8000),
+      chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS),
       "",
       "",
       "检查维度:",
       "检查维度:",
       "1. 人设一致性",
       "1. 人设一致性",

+ 1 - 1
src/lib/novel/review-adapter.spec.ts

@@ -344,7 +344,7 @@ describe("review-adapter staged review", () => {
       callbacks.onDone()
       callbacks.onDone()
     })
     })
 
 
-    await expect(reviewChapter("E:/Novel", "正".repeat(9000), 8, {
+    await expect(reviewChapter("E:/Novel", "正".repeat(13000), 8, {
       contextPack,
       contextPack,
       throwOnFailure: true,
       throwOnFailure: true,
     })).rejects.toThrow()
     })).rejects.toThrow()

+ 14 - 6
src/lib/novel/review-adapter.ts

@@ -4,6 +4,7 @@ import type { ChatMessage } from "@/lib/llm-providers"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { getOutputLanguage, buildLanguageReminder } from "@/lib/output-language"
 import { getOutputLanguage, buildLanguageReminder } from "@/lib/output-language"
 import { contextPackToPrompt, buildContextPack, type ContextPack } from "./context-engine"
 import { contextPackToPrompt, buildContextPack, type ContextPack } from "./context-engine"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
 import { resolveNovelModel } from "./model-resolver"
 import { resolveNovelModel } from "./model-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { rethrowIfUserAbort } from "@/lib/user-abort"
 import { rethrowIfUserAbort } from "@/lib/user-abort"
@@ -88,12 +89,12 @@ const REVIEW_STAGES = [
   "阶段7:二次复核",
   "阶段7:二次复核",
 ]
 ]
 
 
-const REVIEW_CHUNK_SIZE = 8000
+const REVIEW_CHUNK_SIZE = CHAPTER_BODY_EXCERPT_MAX_CHARS
 const REVIEW_MAX_CHUNKS = 3
 const REVIEW_MAX_CHUNKS = 3
 
 
 /**
 /**
- * 把超长章节分段用于审查。章节 ≤ 8000 字时返回单段;
- * 超过时按 8000 字一段切分,最多 3 段(覆盖 24000 字),超出部分追加到最后一段。
+ * 把超长章节分段用于审查。章节 ≤ 12000 字时返回单段;
+ * 超过时按 12000 字一段切分,最多 3 段(覆盖 36000 字),超出部分追加到最后一段。
  */
  */
 function splitChapterForReview(content: string): string[] {
 function splitChapterForReview(content: string): string[] {
   if (content.length <= REVIEW_CHUNK_SIZE) return [content]
   if (content.length <= REVIEW_CHUNK_SIZE) return [content]
@@ -113,6 +114,7 @@ export function buildReviewPrompt(
   chapterContent: string,
   chapterContent: string,
   characterOnly = false,
   characterOnly = false,
   planBlueprint?: string,
   planBlueprint?: string,
+  maxContextSize?: number,
 ): string {
 ): string {
   const baseDimensions = characterOnly ? CHARACTER_REVIEW_DIMENSIONS : REVIEW_DIMENSIONS
   const baseDimensions = characterOnly ? CHARACTER_REVIEW_DIMENSIONS : REVIEW_DIMENSIONS
   // 当传入用户确认的计划时,追加计划偏离维度(characterOnly 模式下不追加,保持轻量)
   // 当传入用户确认的计划时,追加计划偏离维度(characterOnly 模式下不追加,保持轻量)
@@ -135,7 +137,7 @@ export function buildReviewPrompt(
         planBlueprint.trim(),
         planBlueprint.trim(),
       ].join("\n")
       ].join("\n")
     : ""
     : ""
-  return `${contextPackToPrompt(pack)}
+  return `${contextPackToPrompt(pack, undefined, { maxContextSize })}
 
 
 ${modeTitle}:
 ${modeTitle}:
 ${modeStages.map((stage) => `- ${stage}:必须使用高级 thinking,先分析证据,再给结论。`).join("\n")}
 ${modeStages.map((stage) => `- ${stage}:必须使用高级 thinking,先分析证据,再给结论。`).join("\n")}
@@ -180,7 +182,7 @@ ${blueprintSection}
 4. 输出要求:在审查 JSON 中,角色相关问题 type 使用 "character_consistency",relatedMemory 必须引用对应的光环/状态/认知/大纲原文。
 4. 输出要求:在审查 JSON 中,角色相关问题 type 使用 "character_consistency",relatedMemory 必须引用对应的光环/状态/认知/大纲原文。
 
 
 ${i18n.t("novel.reviewPrompt.chapterContent")}
 ${i18n.t("novel.reviewPrompt.chapterContent")}
-${chapterContent.slice(0, 8000)}
+${chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}
 
 
 ${i18n.t("novel.reviewPrompt.outputFormat")}
 ${i18n.t("novel.reviewPrompt.outputFormat")}
 [
 [
@@ -277,7 +279,13 @@ ${langReminder}`
       const chunkContent = chunks.length > 1
       const chunkContent = chunks.length > 1
         ? `【第${i + 1}段/共${chunks.length}段】\n${chunk}`
         ? `【第${i + 1}段/共${chunks.length}段】\n${chunk}`
         : chunk
         : chunk
-      const userPrompt = buildReviewPrompt(contextPack, chunkContent, options.characterOnly, options.planBlueprint)
+      const userPrompt = buildReviewPrompt(
+        contextPack,
+        chunkContent,
+        options.characterOnly,
+        options.planBlueprint,
+        llmConfig.maxContextSize,
+      )
       const stageTitle = chunks.length > 1
       const stageTitle = chunks.length > 1
         ? (options.characterOnly ? `角色一致性审查(第${i + 1}/${chunks.length}段)` : `深度审查(第${i + 1}/${chunks.length}段)`)
         ? (options.characterOnly ? `角色一致性审查(第${i + 1}/${chunks.length}段)` : `深度审查(第${i + 1}/${chunks.length}段)`)
         : (options.characterOnly ? "角色一致性审查" : "深度审查")
         : (options.characterOnly ? "角色一致性审查" : "深度审查")

+ 27 - 1
src/lib/novel/task-router.ts

@@ -413,6 +413,13 @@ function parseChineseChapterNumber(text: string): number {
 /**
 /**
  * 根据任务路由结果生成对 AI 的系统提示增强
  * 根据任务路由结果生成对 AI 的系统提示增强
  */
  */
+const CHAPTER_WRITING_INTENTS = new Set<NovelTaskIntent>([
+  "write_chapter",
+  "continue_chapter",
+  "rewrite_chapter",
+  "polish_chapter",
+])
+
 export function buildTaskDirective(route: TaskRouteResult): string {
 export function buildTaskDirective(route: TaskRouteResult): string {
   const directives: Record<NovelTaskIntent, string> = {
   const directives: Record<NovelTaskIntent, string> = {
     write_chapter: "用户要求生成新章节。请根据上下文包中的大纲、人物状态和伏笔状态,生成完整的章节正文。注意保持人设一致,结尾留有钩子。",
     write_chapter: "用户要求生成新章节。请根据上下文包中的大纲、人物状态和伏笔状态,生成完整的章节正文。注意保持人设一致,结尾留有钩子。",
@@ -437,7 +444,26 @@ export function buildTaskDirective(route: TaskRouteResult): string {
   const directive = directives[route.intent]
   const directive = directives[route.intent]
   if (!directive) return ""
   if (!directive) return ""
 
 
-  return `\n## 任务类型识别\n意图:${intentToLabel(route.intent)}(置信度 ${Math.round(route.confidence * 100)}%)\n指令:${directive}\n`
+  const lines = [
+    "",
+    "## 任务类型识别",
+    `意图:${intentToLabel(route.intent)}(置信度 ${Math.round(route.confidence * 100)}%)`,
+    `指令:${directive}`,
+  ]
+
+  if (
+    CHAPTER_WRITING_INTENTS.has(route.intent) &&
+    typeof route.chapterNumber === "number" &&
+    route.chapterNumber > 0
+  ) {
+    lines.push(`本次写作目标:第 ${route.chapterNumber} 章。`)
+    lines.push(
+      "写正文前先 list_outlines(可传 chapterNumber),再按 type 分流并用 read_outline 读正文,确认对应该章的大纲后再写。",
+    )
+  }
+
+  lines.push("")
+  return lines.join("\n")
 }
 }
 
 
 function intentToLabel(intent: NovelTaskIntent): string {
 function intentToLabel(intent: NovelTaskIntent): string {