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

feat: 增加上下文中控与缓存详情

Mochocyang 2 месяцев назад
Родитель
Сommit
cfb54e9ff3
72 измененных файлов с 4389 добавлено и 196 удалено
  1. 30 0
      src-tauri/src/commands/fs.rs
  2. 4 0
      src-tauri/src/types/wiki.rs
  3. 45 0
      src/commands/fs.spec.ts
  4. 30 3
      src/commands/fs.ts
  5. 3 2
      src/components/chat/agent-tool-call-message.spec.tsx
  6. 29 1
      src/components/chat/chat-message.spec.tsx
  7. 22 12
      src/components/chat/chat-message.tsx
  8. 151 18
      src/components/chat/chat-panel.tsx
  9. 126 0
      src/components/chat/context-trace-panel.spec.tsx
  10. 58 19
      src/components/chat/context-trace-panel.tsx
  11. 0 14
      src/components/chat/tool-call-timeline.tsx
  12. 167 0
      src/components/common/context-hub-details.spec.tsx
  13. 205 0
      src/components/common/context-hub-details.tsx
  14. 1 15
      src/components/common/event-stream.tsx
  15. 63 0
      src/components/common/timeline-duration-visibility.spec.tsx
  16. 0 13
      src/components/common/timeline-tool-event.tsx
  17. 0 8
      src/components/common/timeline-tool-group.tsx
  18. 59 5
      src/components/sources/outline-chat-panel.spec.tsx
  19. 212 28
      src/components/sources/outline-chat-panel.tsx
  20. 28 0
      src/lib/agent/context-trace-builders.spec.ts
  21. 3 1
      src/lib/agent/context-trace-builders.ts
  22. 2 0
      src/lib/agent/context-trace.ts
  23. 2 0
      src/lib/agent/pipeline.ts
  24. 6 0
      src/lib/agent/plugins/build-system-prompt-plugin.spec.ts
  25. 12 3
      src/lib/agent/plugins/build-system-prompt-plugin.ts
  26. 23 0
      src/lib/agent/runner.spec.ts
  27. 8 1
      src/lib/agent/runner.ts
  28. 6 5
      src/lib/agent/tools/index.ts
  29. 3 3
      src/lib/agent/tools/read-chapter.ts
  30. 6 2
      src/lib/agent/tools/read-deduction.ts
  31. 6 3
      src/lib/agent/tools/read-markdown-resource.ts
  32. 13 6
      src/lib/agent/tools/read-memory.ts
  33. 12 6
      src/lib/agent/tools/read-outline.ts
  34. 6 2
      src/lib/agent/tools/search-chapters.ts
  35. 2 2
      src/lib/agent/types.ts
  36. 65 0
      src/lib/context-hub/agent-tools-cache.spec.ts
  37. 38 0
      src/lib/context-hub/ai-chat-integration.spec.ts
  38. 34 0
      src/lib/context-hub/ai-outline-integration.spec.ts
  39. 93 0
      src/lib/context-hub/composer.spec.ts
  40. 141 0
      src/lib/context-hub/composer.ts
  41. 234 0
      src/lib/context-hub/context-hub.spec.ts
  42. 318 0
      src/lib/context-hub/context-hub.ts
  43. 119 0
      src/lib/context-hub/data-source-cache.spec.ts
  44. 180 0
      src/lib/context-hub/data-source-cache.ts
  45. 19 0
      src/lib/context-hub/index.ts
  46. 28 0
      src/lib/context-hub/prompt-content.spec.ts
  47. 24 0
      src/lib/context-hub/prompt-content.ts
  48. 40 0
      src/lib/context-hub/session-store-integration.spec.ts
  49. 60 0
      src/lib/context-hub/session-summary.spec.ts
  50. 95 0
      src/lib/context-hub/session-summary.ts
  51. 45 0
      src/lib/context-hub/source-paths.spec.ts
  52. 63 0
      src/lib/context-hub/source-paths.ts
  53. 139 0
      src/lib/context-hub/source-registry.spec.ts
  54. 182 0
      src/lib/context-hub/source-registry.ts
  55. 245 0
      src/lib/context-hub/storage.spec.ts
  56. 314 0
      src/lib/context-hub/storage.ts
  57. 18 0
      src/lib/context-hub/token-estimator.spec.ts
  58. 9 0
      src/lib/context-hub/token-estimator.ts
  59. 133 0
      src/lib/context-hub/types.ts
  60. 40 0
      src/lib/llm-providers.spec.ts
  61. 28 10
      src/lib/llm-providers.ts
  62. 14 1
      src/lib/novel/context-data-source.spec.ts
  63. 18 1
      src/lib/novel/context-data-source.ts
  64. 12 5
      src/lib/novel/context-engine.ts
  65. 22 0
      src/lib/novel/outline-context-reuse.spec.ts
  66. 9 5
      src/lib/novel/outline-context-reuse.ts
  67. 145 0
      src/lib/persist.spec.ts
  68. 15 0
      src/lib/persist.ts
  69. 13 0
      src/stores/chat-store.ts
  70. 71 0
      src/stores/outline-chat-store.spec.ts
  71. 21 2
      src/stores/outline-chat-store.ts
  72. 2 0
      src/types/wiki.ts

+ 30 - 0
src-tauri/src/commands/fs.rs

@@ -1395,6 +1395,13 @@ fn build_tree(
         // fail to match Rust-returned `\` paths.
         let path_str = virtualize_project_storage_path(&entry_path);
         let is_dir = entry_path.is_dir();
+        let metadata = if is_dir { None } else { entry.metadata().ok() };
+        let mtime_ms = metadata
+            .as_ref()
+            .and_then(|value| value.modified().ok())
+            .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok())
+            .and_then(|value| u64::try_from(value.as_millis()).ok());
+        let size = metadata.as_ref().map(std::fs::Metadata::len);
 
         let children = if is_dir {
             let kids = build_tree(&entry_path, depth + 1, max_depth, include_hidden)?;
@@ -1411,6 +1418,8 @@ fn build_tree(
             name,
             path: path_str,
             is_dir,
+            mtime_ms,
+            size,
             children,
         });
     }
@@ -1889,6 +1898,27 @@ mod tests {
     use super::*;
     use std::io::Write;
 
+    #[test]
+    fn directory_tree_includes_file_version_metadata() {
+        let root = std::env::temp_dir().join(format!(
+            "qmai-tree-metadata-{}",
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .unwrap()
+                .as_nanos()
+        ));
+        fs::create_dir_all(&root).unwrap();
+        fs::write(root.join("chapter.md"), b"chapter body").unwrap();
+
+        let nodes = build_tree(&root, 0, 2, false).unwrap();
+        let chapter = nodes.iter().find(|node| node.name == "chapter.md").unwrap();
+
+        assert_eq!(chapter.size, Some(12));
+        assert!(chapter.mtime_ms.is_some());
+
+        let _ = fs::remove_dir_all(root);
+    }
+
     /// Write `bytes` to a fresh tmp path with `.pdf` suffix and return
     /// the path (the OS tmpdir is NOT cleaned up — acceptable for tests).
     fn tmp_pdf_with_bytes(bytes: &[u8]) -> String {

+ 4 - 0
src-tauri/src/types/wiki.rs

@@ -11,6 +11,10 @@ pub struct FileNode {
     pub name: String,
     pub path: String,
     pub is_dir: bool,
+    #[serde(rename = "mtimeMs", skip_serializing_if = "Option::is_none")]
+    pub mtime_ms: Option<u64>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub size: Option<u64>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub children: Option<Vec<FileNode>>,
 }

+ 45 - 0
src/commands/fs.spec.ts

@@ -0,0 +1,45 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const invokeMock = vi.hoisted(() => vi.fn())
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }))
+
+import {
+  deleteFile,
+  subscribeProjectFileMutations,
+  writeFile,
+  writeFileAtomic,
+} from "./fs"
+
+describe("project file mutation notifications", () => {
+  beforeEach(() => {
+    invokeMock.mockReset()
+    invokeMock.mockResolvedValue(undefined)
+  })
+
+  it("notifies after successful writes and deletes", async () => {
+    const listener = vi.fn()
+    const unsubscribe = subscribeProjectFileMutations(listener)
+
+    await writeFile("E:/Novel/wiki/chapters/1.md", "一")
+    await writeFileAtomic("E:/Novel/wiki/outlines/main.md", "二")
+    await deleteFile("E:/Novel/wiki/memory/old.md")
+
+    expect(listener.mock.calls.map(([event]) => event)).toEqual([
+      { type: "write", path: "E:/Novel/wiki/chapters/1.md" },
+      { type: "write", path: "E:/Novel/wiki/outlines/main.md" },
+      { type: "delete", path: "E:/Novel/wiki/memory/old.md" },
+    ])
+    unsubscribe()
+  })
+
+  it("does not notify when the underlying operation fails", async () => {
+    const listener = vi.fn()
+    const unsubscribe = subscribeProjectFileMutations(listener)
+    invokeMock.mockRejectedValueOnce(new Error("磁盘错误"))
+
+    await expect(writeFile("E:/Novel/wiki/chapters/1.md", "一")).rejects.toThrow("磁盘错误")
+    expect(listener).not.toHaveBeenCalled()
+    unsubscribe()
+  })
+})

+ 30 - 3
src/commands/fs.ts

@@ -7,16 +7,42 @@ interface RawProject {
   path: string
 }
 
+export type ProjectFileMutation = {
+  type: "write" | "delete"
+  path: string
+}
+
+const projectFileMutationListeners = new Set<(event: ProjectFileMutation) => void>()
+
+export function subscribeProjectFileMutations(
+  listener: (event: ProjectFileMutation) => void,
+): () => void {
+  projectFileMutationListeners.add(listener)
+  return () => projectFileMutationListeners.delete(listener)
+}
+
+function notifyProjectFileMutation(event: ProjectFileMutation): void {
+  for (const listener of projectFileMutationListeners) {
+    try {
+      listener(event)
+    } catch (error) {
+      console.warn("文件变更订阅处理失败:", error)
+    }
+  }
+}
+
 export async function readFile(path: string): Promise<string> {
   return invoke<string>("read_file", { path })
 }
 
 export async function writeFile(path: string, contents: string): Promise<void> {
-  return invoke<void>("write_file", { path, contents })
+  await invoke<void>("write_file", { path, contents })
+  notifyProjectFileMutation({ type: "write", path })
 }
 
 export async function writeFileAtomic(path: string, contents: string): Promise<void> {
-  return invoke<void>("write_file_atomic", { path, contents })
+  await invoke<void>("write_file_atomic", { path, contents })
+  notifyProjectFileMutation({ type: "write", path })
 }
 
 /**
@@ -95,7 +121,8 @@ export async function preprocessFile(path: string): Promise<string> {
 }
 
 export async function deleteFile(path: string): Promise<void> {
-  return invoke("delete_file", { path })
+  await invoke("delete_file", { path })
+  notifyProjectFileMutation({ type: "delete", path })
 }
 
 export async function findRelatedWikiPages(

+ 3 - 2
src/components/chat/agent-tool-call-message.spec.tsx

@@ -88,7 +88,8 @@ describe("AgentToolCallMessage", () => {
     expect(host.textContent).toContain("生成记忆写入草稿「写入资料」")
     expect(host.textContent).toContain("应用技能「去AI味」")
     expect(host.textContent).toContain("搜索章节关键词「李明」")
-    expect(host.textContent).toContain("耗时")
+    expect(host.textContent).not.toContain("耗时")
+    expect(host.textContent).not.toMatch(/\d+(?:\.\d+)?(?:ms|s)/)
     expect(host.textContent).toContain("工具 5 次")
     expect(host.textContent).not.toMatch(/[⏱🔢💡]/u)
 
@@ -318,7 +319,7 @@ describe("AgentToolCallMessage", () => {
 
     expect(host.textContent).toContain("已读取章节")
     expect(host.textContent).toContain("2项")
-    expect(host.textContent).toContain("50ms")
+    expect(host.textContent).not.toContain("50ms")
     expect(host.textContent).toContain("工具 2 次")
     expect(host.textContent).not.toContain("读取章节《第1章》")
     expect(host.textContent).not.toContain("读取章节《第2章》")

+ 29 - 1
src/components/chat/chat-message.spec.tsx

@@ -195,7 +195,35 @@ describe("agent stage stream integration", () => {
 
     expect(html).toContain("读取章节")
     expect(html).toContain("完成。")
-    expect(html).toContain("耗时")
+    expect(html).not.toContain("耗时")
+    expect(html).not.toContain("1ms")
+  })
+})
+
+describe("AI 对话上下文中控入口", () => {
+  it("只有中控快照时仍显示生成详情入口", () => {
+    const message: DisplayMessage = {
+      id: "assistant-context-hub",
+      role: "assistant",
+      content: "回答正文",
+      timestamp: 10,
+      conversationId: "chat-1",
+      contextHubSnapshot: {
+        id: "assistant-context-hub",
+        surface: "ai-chat",
+        createdAt: 10,
+        stats: {
+          hits: 2, refreshed: 1, failures: 0,
+          stableTokens: 100, summaryTokens: 20, dynamicTokens: 30,
+          candidateTokens: 300, estimatedSavedTokens: 150, estimatedSavedPercent: 50,
+          expanded: false, providerCacheEnabled: true,
+        },
+      },
+    }
+
+    const html = renderToStaticMarkup(<ChatMessage message={message} />)
+
+    expect(html).toContain("查看生成详情")
   })
 })
 

+ 22 - 12
src/components/chat/chat-message.tsx

@@ -37,6 +37,7 @@ import { AgentStageStream } from "@/components/chat/agent-stage-stream";
 import { ReferenceChip } from "@/components/reference/ReferenceChip";
 import type { DisplayMessage } from "@/stores/chat-store";
 import { ContextTracePanel } from "@/components/chat/context-trace-panel";
+import { ContextHubDetails } from "@/components/common/context-hub-details";
 
 import { convertLatexToUnicode } from "@/lib/latex-to-unicode";
 import { resolveMarkdownImageSrc } from "@/lib/markdown-image-resolver";
@@ -113,9 +114,10 @@ export function ChatMessage({
     canContinueUnfinishedDeepChapter(message.content),
   );
   const hasContextTrace = Boolean(
-    message.contextTrace &&
-    (message.contextTrace.toolCalls.length > 0 ||
-      message.contextTrace.contextInfo),
+    message.contextHubSnapshot ||
+    (message.contextTrace &&
+      (message.contextTrace.toolCalls.length > 0 ||
+        message.contextTrace.contextInfo)),
   );
 
   // 仅对最后一条流式助手消息提取 thinking,避免历史消息重复提取
@@ -276,16 +278,24 @@ export function ChatMessage({
         {isAssistant &&
           !message.discarded &&
           contextTraceExpanded &&
-          message.contextTrace && (
+          (message.contextTrace || message.contextHubSnapshot) && (
             <div className="mt-1">
-              <ContextTracePanel
-                trace={message.contextTrace}
-                projectPath={projectPath}
-                onRebuildRetrievalIndex={onRebuildRetrievalIndex}
-                retrievalIndexHasIndex={retrievalIndexHasIndex}
-                isRebuildingRetrievalIndex={isRebuildingRetrievalIndex}
-                lastRebuildResult={lastRebuildRetrievalResult}
-              />
+              {message.contextTrace ? (
+                <ContextTracePanel
+                  trace={message.contextTrace}
+                  contextHubSnapshot={message.contextHubSnapshot}
+                  projectPath={projectPath}
+                  onRebuildRetrievalIndex={onRebuildRetrievalIndex}
+                  retrievalIndexHasIndex={retrievalIndexHasIndex}
+                  isRebuildingRetrievalIndex={isRebuildingRetrievalIndex}
+                  lastRebuildResult={lastRebuildRetrievalResult}
+                />
+              ) : message.contextHubSnapshot ? (
+                <ContextHubDetails
+                  reference={message.contextHubSnapshot}
+                  projectPath={projectPath}
+                />
+              ) : null}
             </div>
           )}
         {isLastAssistant && saveStatus && (

+ 151 - 18
src/components/chat/chat-panel.tsx

@@ -39,6 +39,7 @@ import {
 import type { ReferenceToken } from "@/lib/reference/types"
 import { runAiChatSession } from "@/lib/agent/ai-chat-session"
 import { ToolRegistry } from "@/lib/agent/registry"
+import { registerAllBuiltInTools } from "@/lib/agent/tools"
 import {
   runChapterPlanRevision as runChapterPlanRevisionModel,
   runChapterPlanSelfCheck as runChapterPlanSelfCheckModel,
@@ -116,6 +117,15 @@ import { buildResultProtocolTrace } from "@/lib/novel/result-parser"
 // import { joinPath } from "@/lib/path-utils"
 // import type { AiCapability } from "@/lib/agent/capabilities/types"
 import { deAiSkillToUserSkill } from "@/lib/novel/de-ai-skill-library"
+import {
+  buildContextHubSystemContent,
+  buildSessionContextSummary,
+  flattenContextHubSystemContent,
+  getContextHub,
+  selectContextHistoryMessages,
+  type ContextHubResult,
+  type ContextIntent,
+} from "@/lib/context-hub"
 
 
 /* spec-test patterns */
@@ -344,6 +354,18 @@ function buildAgentUserContent(text: string, tokens: ReferenceToken[]): string {
   ].join("\n")
 }
 
+function resolveChatContextIntent(
+  route: TaskRouteResult | null,
+  deAiEnabled: boolean,
+): ContextIntent {
+  if (deAiEnabled || route?.intent === "review_chapter") return "review"
+  if (route?.intent === "lint_chapter") return "lint"
+  if (!route || route.intent === "general_chat" || route.intent.endsWith("_query") || route.intent === "search_plot") {
+    return "question"
+  }
+  return "generate"
+}
+
 const SIMULATION_INTENTS = new Set([
   "story_framework_generate",
   "multi_agent_simulate",
@@ -1327,7 +1349,10 @@ export function ChatPanel() {
       let effectiveTaskRoute = taskRoute
       let contextPack: ContextPack | null = null
       void contextPack
+      let contextHubResult: ContextHubResult | null = null
       let novelContextPrompt: string = ""
+      let taskDirective = ""
+      let goldenDirective = ""
       let prePluginResult: PrePluginChainResult | null = null
       const shouldRunNovelPrePluginChain = novelMode && (aiWorkflowMode !== "fast" || planExecuteActive)
       void shouldRunNovelPrePluginChain
@@ -1403,6 +1428,46 @@ export function ChatPanel() {
           }
         : taskRoute
 
+      if (novelMode && effectiveTaskRoute) {
+        const contextHub = getContextHub(pp)
+        const novelConfig = useWikiStore.getState().novelConfig
+        try {
+          contextHubResult = await contextHub.prepare({
+            projectPath: pp,
+            surface: "ai-chat",
+            sessionId: capturedConvId,
+            task: plainText,
+            intent: resolveChatContextIntent(
+              effectiveTaskRoute,
+              Boolean(activeConv?.deAiMode || activeConv?.selectedDeAiSkillId),
+            ),
+            chapterNumber: effectiveTaskRoute.chapterNumber,
+            references: tokens.map(describeReferenceForAgent),
+            messages: activeConvMessages.map((message) => ({
+              role: message.role,
+              content: message.content,
+            })),
+            existingSummary: activeConv?.contextSummary,
+            tokenBudget: novelConfig.contextTokenBudget > 0
+              ? novelConfig.contextTokenBudget
+              : undefined,
+          })
+          if (contextHubResult) {
+            try {
+              const contextHubSnapshot = await contextHub.saveSnapshot(assistantMessage.id, contextHubResult)
+              updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+                ...message,
+                contextHubSnapshot,
+              }))
+            } catch (error) {
+              console.warn("上下文快照保存失败,继续生成:", error)
+            }
+          }
+        } catch (error) {
+          console.warn("上下文中控准备失败,继续使用原有流程:", error)
+        }
+      }
+
       if (shouldRunNovelPrePluginChain && effectiveTaskRoute) {
         try {
           prePluginResult = await runNovelPrePluginChain({
@@ -1422,6 +1487,9 @@ export function ChatPanel() {
               mcpCapabilities: agentMcpCapabilities,
               selectedFile,
             },
+            deps: contextHubResult
+              ? { buildContextPack: async () => contextHubResult.contextPack }
+              : undefined,
           })
         } catch (e) {
           console.warn("Pre-plugin chain failed:", e)
@@ -1472,11 +1540,14 @@ export function ChatPanel() {
 
       if (novelMode && effectiveTaskRoute) {
         try {
-          const taskDirective = buildTaskDirective(effectiveTaskRoute)
+          taskDirective = buildTaskDirective(effectiveTaskRoute)
           const goldenThreeChapter = detectGoldenThreeChapterRequest(plainText, effectiveTaskRoute.chapterNumber)
-          const goldenDirective = buildGoldenThreeChapterDirective(goldenThreeChapter)
-          const { buildContextPack, contextPackToPrompt } = await import("@/lib/novel/context-engine")
-          contextPack = await buildContextPack(pp, plainText, effectiveTaskRoute.chapterNumber).catch(() => ({
+          goldenDirective = buildGoldenThreeChapterDirective(goldenThreeChapter)
+          if (contextHubResult) {
+            contextPack = contextHubResult.contextPack
+          } else {
+            const { buildContextPack, contextPackToPrompt } = await import("@/lib/novel/context-engine")
+            contextPack = await buildContextPack(pp, plainText, effectiveTaskRoute.chapterNumber).catch(() => ({
             task: plainText,
             chapterGoal: "",
             outline: "",
@@ -1498,15 +1569,16 @@ export function ChatPanel() {
             mustAvoid: "",
             nextChapterAdvice: "",
             revisionDirectives: "",
-          }))
-          const novelConfig = useWikiStore.getState().novelConfig
-          const budget = novelConfig.contextTokenBudget > 0 ? novelConfig.contextTokenBudget : undefined
-          novelContextPrompt = [
-            taskDirective,
-            goldenDirective,
-            "## 小说上下文包",
-            contextPackToPrompt(contextPack, budget),
-          ].filter(Boolean).join("\n\n")
+            }))
+            const novelConfig = useWikiStore.getState().novelConfig
+            const budget = novelConfig.contextTokenBudget > 0 ? novelConfig.contextTokenBudget : undefined
+            novelContextPrompt = [
+              taskDirective,
+              goldenDirective,
+              "## 小说上下文包",
+              contextPackToPrompt(contextPack, budget),
+            ].filter(Boolean).join("\n\n")
+          }
         } catch (error) {
           console.warn("构建Agent小说上下文失败:", error)
         }
@@ -1521,6 +1593,7 @@ export function ChatPanel() {
       }
 
       const prePluginSystemPrompt = prePluginResult?.finalSystemPrompt?.trim()
+      const prePluginSystemRulesPrompt = prePluginResult?.finalSystemRulesPrompt?.trim()
       const baseSystemPrompt = [
         prePluginSystemPrompt || sessionAgentSystemPrompt,
         qmQuaiSystemPrompt ? `## QM-QUAI 技能\n${qmQuaiSystemPrompt}` : "",
@@ -1540,6 +1613,21 @@ export function ChatPanel() {
         : [
             baseSystemPrompt,
           ].filter(Boolean).join("\n")
+      const contextHubSoftwareRules = prePluginSystemRulesPrompt || sessionAgentSystemPrompt
+      const contextHubSystemContent = contextHubResult
+        ? buildContextHubSystemContent(contextHubSoftwareRules, contextHubResult, [
+            qmQuaiSystemPrompt ? `## QM-QUAI 技能\n${qmQuaiSystemPrompt}` : "",
+            prePluginSystemRulesPrompt ? "" : taskDirective,
+            goldenDirective,
+            prePluginSystemRulesPrompt ? "" : selectedSkillsPrompt,
+            !prePluginSystemRulesPrompt && prePluginResult?.selectedSkills?.length
+              ? `## 当前会话写作技能\n${buildSelectedSkillsPrompt(prePluginResult.selectedSkills)}`
+              : "",
+          ])
+        : null
+      const systemPromptForConfig = contextHubSystemContent
+        ? flattenContextHubSystemContent(contextHubSystemContent)
+        : effectiveSystemPrompt
 
       const deAiMode = activeConv?.deAiMode ?? false
       const rawUserContent = buildAgentUserContent(plainText, tokens)
@@ -1547,8 +1635,11 @@ export function ChatPanel() {
         ? injectDeAiDirective(rawUserContent, deAiMode)
         : rawUserContent
       const agentMessages: AgentMessage[] = [
-        { role: "system", content: effectiveSystemPrompt },
-        ...activeConvMessages.map((message) => ({
+        { role: "system", content: contextHubSystemContent ?? effectiveSystemPrompt },
+        ...selectContextHistoryMessages(
+          activeConvMessages,
+          contextHubResult?.sessionSummary,
+        ).map((message) => ({
           role: message.role,
           content: message.content,
         } satisfies AgentMessage)),
@@ -1556,6 +1647,24 @@ export function ChatPanel() {
       ]
       const sessionRegistry = new ToolRegistry()
       agentRegistry.list().forEach((tool) => sessionRegistry.register(tool))
+      if (contextHubResult) {
+        registerAllBuiltInTools(sessionRegistry, {
+          wikiPath: `${pp}/wiki`,
+          getSkillConfig: () => agentSkillConfig,
+          getUserSkills: () => agentUserWritingSkills,
+          getSearchApiConfig: () => useWikiStore.getState().searchApiConfig,
+          getChatConversations: () => [],
+          getOutlineConversations: () => [],
+          readTextFile: contextHubResult.readFile,
+          enabledToolNames: [
+            "read_chapter",
+            "read_outline",
+            "read_memory",
+            "read_deduction",
+            "search_chapters",
+          ],
+        })
+      }
       if (planBlueprint) {
         const workflowTool = agentRegistry.get("run_chapter_workflow")
         if (workflowTool) {
@@ -1577,7 +1686,7 @@ export function ChatPanel() {
           projectPath,
           agentConfig: {
             ...agentConfig,
-            systemPrompt: effectiveSystemPrompt,
+            systemPrompt: systemPromptForConfig,
             projectPath,
             taskGoal: plainText,
             requestOverrides: agentConfig.requestOverrides,
@@ -1633,7 +1742,10 @@ export function ChatPanel() {
         finishAgentSession(() => {
           if (!hasAgentError) {
             if (contextTrace && effectiveTaskRoute) {
-              const traceInfo = buildInitialContextTraceInfo(effectiveTaskRoute, prePluginResult, { workflowMode: aiWorkflowMode })
+              const traceInfo = buildInitialContextTraceInfo(effectiveTaskRoute, prePluginResult, {
+                workflowMode: aiWorkflowMode,
+                contextHub: contextHubResult?.stats,
+              })
               contextTrace = setContextInfo(contextTrace, traceInfo)
               const storeStateForValidation = useChatStore.getState()
               const lastAssistantForValidation = storeStateForValidation.messages.find(
@@ -1680,6 +1792,22 @@ export function ChatPanel() {
             markDone(record)
           }
         })
+        if (!hasAgentError && contextHubResult) {
+          const completedMessages = useChatStore.getState().messages
+            .filter((message) => (
+              message.conversationId === capturedConvId
+              && (message.role === "user" || message.role === "assistant")
+              && !message.discarded
+              && !message.isAgentRunning
+            ))
+          useChatStore.getState().setConversationContextSummary(
+            capturedConvId,
+            buildSessionContextSummary({
+              messages: completedMessages,
+              dependencies: contextHubResult.dependencies,
+            }),
+          )
+        }
         if (hasAgentError) {
           useChatStore.getState().failConversationRun(capturedConvId, lastAgentError, runId)
           toast.error(lastAgentError, {
@@ -1747,9 +1875,11 @@ export function ChatPanel() {
       agentConfig,
       agentMcpCapabilities,
       agentRegistry,
+      agentSkillConfig,
       agentSkillConfigLoaded,
       agentSupportsTools,
       agentSystemPrompt,
+      agentUserWritingSkills,
       aiWorkflowMode,
       availableAgentSkills,
       chatEditModeEnabled,
@@ -1823,7 +1953,9 @@ export function ChatPanel() {
   const handleRegenerate = useCallback(async () => {
     // 直接从 store 获取最新状态,避免闭包旧值
     const storeState = useChatStore.getState()
-    if (storeState.streamingContents[storeState.activeConversationId ?? ""] !== undefined) return
+    const capturedConversationId = storeState.activeConversationId
+    if (!capturedConversationId) return
+    if (storeState.streamingContents[capturedConversationId] !== undefined) return
     // Find the last user message in active conversation
     const active = storeState.getActiveMessages()
     const lastUserMsg = [...active].reverse().find((m) => m.role === "user")
@@ -1839,6 +1971,7 @@ export function ChatPanel() {
         messages: s.messages.filter((m) => m.id !== lastUser.id),
       }))
     }
+    store.setConversationContextSummary(capturedConversationId, undefined)
     handleSend(lastUserMsg.content, lastUserMsg.attachedReferences ?? [])
   }, [removeLastAssistantMessage, handleSend])
 

+ 126 - 0
src/components/chat/context-trace-panel.spec.tsx

@@ -2,8 +2,134 @@ import { renderToStaticMarkup } from "react-dom/server"
 import { describe, expect, it } from "vitest"
 import { ContextTracePanel } from "./context-trace-panel"
 import type { ContextTrace } from "@/lib/agent/context-trace"
+import type { ContextHubSnapshotRef } from "@/lib/context-hub/types"
 
 describe("ContextTracePanel selected skills", () => {
+  it("renders local cache and token composition without claiming a provider hit", () => {
+    const trace: ContextTrace = {
+      id: "trace-context-hub",
+      startedAt: 1,
+      finishedAt: 5,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "write_chapter",
+        confidence: 0.9,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        contextHub: {
+          hits: 4,
+          refreshed: 1,
+          failures: 0,
+          stableTokens: 1200,
+          summaryTokens: 180,
+          dynamicTokens: 420,
+          candidateTokens: 3200,
+          estimatedSavedTokens: 1400,
+          estimatedSavedPercent: 44,
+          expanded: false,
+          providerCacheEnabled: true,
+        },
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("上下文中控")
+    expect(html).not.toContain("4ms")
+    expect(html).toContain("本地缓存:命中 4,刷新 1,失败 0")
+    expect(html).toContain("稳定核心 1,200 Token")
+    expect(html).toContain("会话摘要 180 Token")
+    expect(html).toContain("动态片段 420 Token")
+    expect(html).toContain("项目资料预计节省 1,400 Token(44%)")
+    expect(html).toContain("已启用稳定前缀缓存")
+    expect(html).not.toContain("供应商已确认命中")
+  })
+
+  it("only reports a confirmed provider hit when cached token usage exists", () => {
+    const trace: ContextTrace = {
+      id: "trace-provider-cache-hit",
+      startedAt: 1,
+      finishedAt: 5,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "generate_outline",
+        confidence: 0.9,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+        contextHub: {
+          hits: 0,
+          refreshed: 2,
+          failures: 0,
+          stableTokens: 900,
+          summaryTokens: 0,
+          dynamicTokens: 300,
+          candidateTokens: 1800,
+          estimatedSavedTokens: 600,
+          estimatedSavedPercent: 33,
+          expanded: true,
+          providerCacheEnabled: true,
+          providerCachedTokens: 768,
+        },
+      },
+    }
+
+    const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
+
+    expect(html).toContain("低置信度扩展:已启用")
+    expect(html).toContain("供应商已确认命中 768 Token")
+  })
+
+  it("uses the shared cache viewer when a persisted snapshot reference exists", () => {
+    const trace: ContextTrace = {
+      id: "trace-snapshot",
+      startedAt: 1,
+      status: "done",
+      toolCalls: [],
+      contextInfo: {
+        intent: "generate_outline",
+        confidence: 0.9,
+        routeSource: "default",
+        loadedSources: [],
+        blockedSources: [],
+        retrievalHits: [],
+        trimmedSections: [],
+      },
+    }
+    const contextHubSnapshot: ContextHubSnapshotRef = {
+      id: "assistant:1",
+      surface: "ai-chat",
+      createdAt: 10,
+      stats: {
+        hits: 2,
+        refreshed: 1,
+        failures: 0,
+        stableTokens: 100,
+        summaryTokens: 20,
+        dynamicTokens: 30,
+        candidateTokens: 300,
+        estimatedSavedTokens: 150,
+        estimatedSavedPercent: 50,
+        expanded: false,
+        providerCacheEnabled: true,
+      },
+    }
+
+    const html = renderToStaticMarkup(
+      <ContextTracePanel trace={trace} contextHubSnapshot={contextHubSnapshot} />,
+    )
+
+    expect(html).toContain("展开上下文中控")
+    expect(html).toContain("本地缓存:命中 2,刷新 1,失败 0")
+  })
+
   it("renders web search trace entries in the overview", () => {
     const trace: ContextTrace = {
       id: "trace-web",

+ 58 - 19
src/components/chat/context-trace-panel.tsx

@@ -1,7 +1,6 @@
 import { useState, useRef, useEffect } from "react"
 import {
   X,
-  Clock,
   Zap,
   Database,
   ShieldAlert,
@@ -41,6 +40,8 @@ import {
 } from "@/lib/novel/classification"
 import { cn } from "@/lib/utils"
 import { ToolCallTimeline } from "./tool-call-timeline"
+import { ContextHubDetails } from "@/components/common/context-hub-details"
+import type { ContextHubSnapshotRef } from "@/lib/context-hub/types"
 
 interface RebuildRetrievalResult {
   success: boolean
@@ -50,6 +51,7 @@ interface RebuildRetrievalResult {
 
 interface ContextTracePanelProps {
   trace: ContextTrace | null
+  contextHubSnapshot?: ContextHubSnapshotRef
   projectPath?: string | null
   onClose?: () => void
   className?: string
@@ -87,18 +89,6 @@ const ROUTE_SOURCE_LABELS: Record<RouteSource, string> = {
 
 type TabType = "overview" | "timeline"
 
-function formatDuration(startedAt: number, finishedAt?: number): string {
-  const end = finishedAt || Date.now()
-  const ms = end - startedAt
-  if (ms <= 0) return "0ms"
-  if (ms < 1000) return `${ms}ms`
-  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
-  const seconds = Math.floor(ms / 1000)
-  const minutes = Math.floor(seconds / 60)
-  const secs = seconds % 60
-  return `${minutes}m${secs}s`
-}
-
 function StatusBadge({ status }: { status: ContextTrace["status"] }) {
   if (status === "running") {
     return (
@@ -356,6 +346,7 @@ function RetrievalIndexSection({
 
 function OverviewTab({
   contextInfo,
+  contextHubSnapshot,
   projectPath,
   onUpgraded,
   onRebuildRetrievalIndex,
@@ -364,6 +355,7 @@ function OverviewTab({
   lastRebuildResult,
 }: {
   contextInfo: TraceContextInfo | undefined
+  contextHubSnapshot?: ContextHubSnapshotRef
   projectPath?: string | null
   onUpgraded?: () => void
   onRebuildRetrievalIndex?: () => Promise<RebuildRetrievalResult>
@@ -393,6 +385,9 @@ function OverviewTab({
     }
   }
   if (!contextInfo) {
+    if (contextHubSnapshot) {
+      return <ContextHubDetails reference={contextHubSnapshot} projectPath={projectPath} className="mt-0 border-t-0 pt-0" />
+    }
     return (
       <div className="flex flex-col items-center justify-center py-12 text-center">
         <Layers className="mb-3 h-10 w-10 text-muted-foreground/40" />
@@ -422,6 +417,54 @@ function OverviewTab({
         value={ROUTE_SOURCE_LABELS[contextInfo.routeSource] || contextInfo.routeSource}
       />
 
+      {contextHubSnapshot && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <ContextHubDetails
+            reference={contextHubSnapshot}
+            projectPath={projectPath}
+            className="mt-0 border-t-0 pt-2"
+          />
+        </>
+      )}
+
+      {contextInfo.contextHub && !contextHubSnapshot && (
+        <>
+          <div className="my-1 h-px bg-border/60" />
+          <div className="py-2">
+            <div className="mb-2 flex items-center gap-2">
+              <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-teal-100 text-teal-600 dark:bg-teal-950/40 dark:text-teal-400">
+                <Database className="h-3.5 w-3.5" />
+              </div>
+              <div className="text-[11px] font-medium text-foreground">上下文中控</div>
+            </div>
+            <div className="ml-9 space-y-1 text-[11px] text-muted-foreground">
+              <div>
+                本地缓存:命中 {contextInfo.contextHub.hits.toLocaleString()},刷新 {contextInfo.contextHub.refreshed.toLocaleString()},失败 {contextInfo.contextHub.failures.toLocaleString()}
+              </div>
+              <div className="flex flex-wrap gap-x-3 gap-y-1">
+                <span>稳定核心 {contextInfo.contextHub.stableTokens.toLocaleString()} Token</span>
+                <span>会话摘要 {contextInfo.contextHub.summaryTokens.toLocaleString()} Token</span>
+                <span>动态片段 {contextInfo.contextHub.dynamicTokens.toLocaleString()} Token</span>
+              </div>
+              <div>
+                项目资料预计节省 {contextInfo.contextHub.estimatedSavedTokens.toLocaleString()} Token({contextInfo.contextHub.estimatedSavedPercent}%)
+              </div>
+              <div>
+                低置信度扩展:{contextInfo.contextHub.expanded ? "已启用" : "未启用"}
+              </div>
+              {contextInfo.contextHub.providerCachedTokens != null ? (
+                <div className="font-medium text-green-600 dark:text-green-400">
+                  供应商已确认命中 {contextInfo.contextHub.providerCachedTokens.toLocaleString()} Token
+                </div>
+              ) : contextInfo.contextHub.providerCacheEnabled ? (
+                <div>已启用稳定前缀缓存</div>
+              ) : null}
+            </div>
+          </div>
+        </>
+      )}
+
       {contextInfo.selectedCapabilities && contextInfo.selectedCapabilities.length > 0 && (
         <>
           <div className="my-1 h-px bg-border/60" />
@@ -1034,6 +1077,7 @@ function CopyTraceButton({ trace }: { trace: ContextTrace }) {
 
 export function ContextTracePanel({
   trace,
+  contextHubSnapshot,
   projectPath,
   onClose,
   className,
@@ -1046,8 +1090,6 @@ export function ContextTracePanel({
 
   if (!trace) return null
 
-  const duration = formatDuration(trace.startedAt, trace.finishedAt)
-
   return (
     <div className={cn("", className)}>
       <CollapsiblePanel
@@ -1057,10 +1099,6 @@ export function ContextTracePanel({
         rightContent={
           <div className="flex items-center gap-2">
             <StatusBadge status={trace.status} />
-            <div className="flex items-center gap-1 text-[11px] text-muted-foreground">
-              <Clock className="h-3 w-3" />
-              <span className="tabular-nums">{duration}</span>
-            </div>
             <CopyTraceButton trace={trace} />
             {onClose && (
               <button
@@ -1111,6 +1149,7 @@ export function ContextTracePanel({
           {activeTab === "overview" ? (
             <OverviewTab
               contextInfo={trace.contextInfo}
+              contextHubSnapshot={contextHubSnapshot}
               projectPath={projectPath}
               onRebuildRetrievalIndex={onRebuildRetrievalIndex}
               retrievalIndexHasIndex={retrievalIndexHasIndex}

+ 0 - 14
src/components/chat/tool-call-timeline.tsx

@@ -56,17 +56,6 @@ function formatWordCount(content?: unknown): string {
   return count > 0 ? `(${count}字)` : ""
 }
 
-function formatDuration(startedAt: number, finishedAt: number): string {
-  const ms = finishedAt - startedAt
-  if (ms <= 0) return ""
-  if (ms < 1000) return `(${(ms / 1000).toFixed(1)}s)`
-  if (ms < 60000) return `(${(ms / 1000).toFixed(1)}s)`
-  const seconds = Math.floor(ms / 1000)
-  const minutes = Math.floor(seconds / 60)
-  const secs = seconds % 60
-  return `(${minutes}m${secs}s)`
-}
-
 export function getToolCallDescription(name: string, params: Record<string, unknown>): string {
   switch (name) {
     case "read_chapter": {
@@ -256,8 +245,6 @@ function TimelineItem({
     () => getToolCallDescription(call.name, call.params),
     [call.name, call.params],
   )
-  const duration = formatDuration(call.startedAt, call.finishedAt)
-
   const cardClass = hasError
     ? "border-red-200 bg-red-50 text-red-800 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300 border-solid"
     : isCancelled
@@ -301,7 +288,6 @@ function TimelineItem({
             <span className="col-start-2 col-span-3 min-w-0 break-words text-muted-foreground">
               <span className="line-clamp-2">
                 {description}
-                {duration && <span className="ml-1 text-[10px] opacity-70">{duration}</span>}
               </span>
             </span>
           </button>

+ 167 - 0
src/components/common/context-hub-details.spec.tsx

@@ -0,0 +1,167 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { ContextHubDetails } from "./context-hub-details"
+import { CONTEXT_CACHE_SCHEMA_VERSION, type ContextHubSnapshot } from "@/lib/context-hub/types"
+
+const snapshot: ContextHubSnapshot = {
+  schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+  id: "assistant:1",
+  surface: "ai-chat",
+  createdAt: 10,
+  stats: {
+    hits: 3,
+    refreshed: 2,
+    failures: 0,
+    stableTokens: 1200,
+    summaryTokens: 60,
+    dynamicTokens: 420,
+    candidateTokens: 3000,
+    estimatedSavedTokens: 1320,
+    estimatedSavedPercent: 44,
+    expanded: false,
+    providerCacheEnabled: true,
+  },
+  items: [
+    {
+      key: "data-source:outline",
+      sourceName: "outline",
+      status: "hit",
+      dependencyPaths: ["wiki/outlines/main.md"],
+    },
+    {
+      key: "stable-core:ai-chat",
+      sourceName: "stableCore",
+      status: "refreshed",
+      dependencyPaths: ["wiki/settings/world.md"],
+    },
+  ],
+  stableCore: "稳定核心正文",
+  sessionSummary: "会话摘要正文",
+  dynamicContext: "动态片段正文",
+}
+
+const reference = {
+  id: snapshot.id,
+  surface: snapshot.surface,
+  createdAt: snapshot.createdAt,
+  stats: snapshot.stats,
+}
+
+describe("ContextHubDetails", () => {
+  let host: HTMLDivElement
+  let root: Root
+
+  beforeEach(() => {
+    ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean })
+      .IS_REACT_ACT_ENVIRONMENT = true
+    host = document.createElement("div")
+    document.body.appendChild(host)
+    root = createRoot(host)
+  })
+
+  afterEach(() => {
+    act(() => root.unmount())
+    host.remove()
+  })
+
+  it("loads the persisted snapshot and shows cache items plus all composed sections", async () => {
+    const loadSnapshot = vi.fn(async () => snapshot)
+    await act(async () => {
+      root.render(
+        <ContextHubDetails
+          reference={reference}
+          loadSnapshot={loadSnapshot}
+        />,
+      )
+    })
+
+    expect(host.textContent).toContain("上下文中控")
+    expect(host.textContent).toContain("命中 3")
+    expect(host.textContent).toContain("刷新 2")
+    expect(host.textContent).not.toContain("稳定核心正文")
+
+    const expandButton = host.querySelector<HTMLButtonElement>('button[aria-label="展开上下文中控"]')
+    await act(async () => {
+      expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+    })
+
+    expect(loadSnapshot).toHaveBeenCalledWith(reference)
+    expect(host.textContent).toContain("大纲资料")
+    expect(host.textContent).toContain("稳定核心缓存")
+    expect(host.textContent).toContain("wiki/outlines/main.md")
+    expect(host.textContent).toContain("稳定核心正文")
+    expect(host.innerHTML).toContain("max-h-96")
+    expect(host.innerHTML).toContain("overflow-y-auto")
+
+    const summaryTab = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent === "会话摘要")
+    await act(async () => {
+      summaryTab?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+    })
+    expect(host.textContent).toContain("会话摘要正文")
+
+    const dynamicTab = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent === "动态片段")
+    await act(async () => {
+      dynamicTab?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+    })
+    expect(host.textContent).toContain("动态片段正文")
+  })
+
+  it("keeps summary statistics visible when the snapshot cannot be read", async () => {
+    await act(async () => {
+      root.render(
+        <ContextHubDetails
+          reference={reference}
+          loadSnapshot={async () => null}
+        />,
+      )
+    })
+
+    const expandButton = host.querySelector<HTMLButtonElement>('button[aria-label="展开上下文中控"]')
+    await act(async () => {
+      expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+    })
+
+    expect(host.textContent).toContain("命中 3")
+    expect(host.textContent).toContain("上下文快照不可用")
+  })
+
+  it("reloads an expanded snapshot when the same message receives a newer snapshot", async () => {
+    const newerSnapshot: ContextHubSnapshot = {
+      ...snapshot,
+      createdAt: 20,
+      stableCore: "续传后的稳定核心",
+    }
+    let currentSnapshot = snapshot
+    const loadSnapshot = vi.fn(async () => currentSnapshot)
+
+    await act(async () => {
+      root.render(<ContextHubDetails reference={reference} loadSnapshot={loadSnapshot} />)
+    })
+    await act(async () => {
+      host.querySelector<HTMLButtonElement>('button[aria-label="展开上下文中控"]')
+        ?.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+    })
+    expect(host.textContent).toContain("稳定核心正文")
+
+    currentSnapshot = newerSnapshot
+    const newerReference = {
+      id: "assistant:1:resume-2",
+      surface: "ai-chat" as const,
+      createdAt: newerSnapshot.createdAt,
+      stats: newerSnapshot.stats,
+    }
+    currentSnapshot = { ...newerSnapshot, id: newerReference.id }
+    await act(async () => {
+      root.render(<ContextHubDetails reference={newerReference} loadSnapshot={loadSnapshot} />)
+    })
+
+    expect(loadSnapshot).toHaveBeenLastCalledWith(newerReference)
+    expect(host.textContent).toContain("续传后的稳定核心")
+    expect(host.textContent).not.toContain("稳定核心正文")
+  })
+})

+ 205 - 0
src/components/common/context-hub-details.tsx

@@ -0,0 +1,205 @@
+import { useEffect, useState } from "react"
+import { ChevronDown, ChevronUp, Database, FileText } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { getContextHub } from "@/lib/context-hub/context-hub"
+import type {
+  ContextCacheItemStatus,
+  ContextCacheItemTrace,
+  ContextHubSnapshot,
+  ContextHubSnapshotRef,
+} from "@/lib/context-hub/types"
+
+type ContextSection = "stableCore" | "sessionSummary" | "dynamicContext"
+
+interface ContextHubDetailsProps {
+  reference: ContextHubSnapshotRef
+  projectPath?: string | null
+  loadSnapshot?: (reference: ContextHubSnapshotRef) => Promise<ContextHubSnapshot | null>
+  className?: string
+}
+
+const SOURCE_LABELS: Record<string, string> = {
+  outline: "大纲资料",
+  chapterOutline: "章节大纲",
+  volumeContext: "分卷上下文",
+  snapshots: "章节快照",
+  recentChapterContents: "最近章节正文",
+  fallbackRecentSummaries: "最近章节摘要",
+  fallbackPreviousEnding: "上一章结尾",
+  fallbackCharacterStates: "人物当前状态",
+  fallbackForeshadowingStates: "伏笔状态",
+  fallbackTimeline: "故事时间线",
+  relatedSettings: "相关设定",
+  canonRules: "硬性世界规则",
+  writingStyle: "写作风格",
+  searchResults: "任务检索结果",
+  graphSearchResults: "关系图检索结果",
+  revisionFeedback: "修订反馈",
+  cognitionText: "人物认知",
+  soulDoc: "作品灵魂",
+  characterAuras: "人物气质",
+  sectionBriefing: "小节简报",
+  stableCore: "稳定核心缓存",
+}
+
+const STATUS_LABELS: Record<ContextCacheItemStatus, string> = {
+  hit: "命中",
+  refreshed: "已刷新",
+  failed: "失败",
+}
+
+const STATUS_ORDER: ContextCacheItemStatus[] = ["hit", "refreshed", "failed"]
+
+const SECTION_LABELS: Record<ContextSection, string> = {
+  stableCore: "稳定核心",
+  sessionSummary: "会话摘要",
+  dynamicContext: "动态片段",
+}
+
+function getSourceLabel(sourceName: string): string {
+  return SOURCE_LABELS[sourceName] ?? "其他上下文"
+}
+
+function CacheItemGroup({ status, items }: { status: ContextCacheItemStatus; items: ContextCacheItemTrace[] }) {
+  if (items.length === 0) return null
+  return (
+    <section className="border-t border-border/60 py-2 first:border-t-0">
+      <div className="mb-1 text-[11px] font-medium text-foreground">
+        {STATUS_LABELS[status]}({items.length})
+      </div>
+      <div className="space-y-2">
+        {items.map((item, index) => (
+          <div key={`${item.key}:${item.status}:${index}`} className="min-w-0 text-[11px] text-muted-foreground">
+            <div className="flex min-w-0 items-center gap-1.5">
+              <FileText aria-hidden="true" className="h-3 w-3 shrink-0" />
+              <span className="min-w-0 break-words text-foreground/80">{getSourceLabel(item.sourceName)}</span>
+            </div>
+            {item.dependencyPaths.length > 0 && (
+              <ul className="ml-4 mt-1 space-y-0.5 border-l border-border/70 pl-2">
+                {item.dependencyPaths.map((path) => (
+                  <li key={path} className="break-all">{path}</li>
+                ))}
+              </ul>
+            )}
+          </div>
+        ))}
+      </div>
+    </section>
+  )
+}
+
+export function ContextHubDetails({
+  reference,
+  projectPath,
+  loadSnapshot,
+  className,
+}: ContextHubDetailsProps) {
+  const [expanded, setExpanded] = useState(false)
+  const [snapshot, setSnapshot] = useState<ContextHubSnapshot | null | undefined>(undefined)
+  const [loading, setLoading] = useState(false)
+  const [activeSection, setActiveSection] = useState<ContextSection>("stableCore")
+  const stats = reference.stats
+
+  useEffect(() => {
+    if (!expanded) return
+    let cancelled = false
+    setSnapshot(undefined)
+    setLoading(true)
+    const read = async () => {
+      const loader = loadSnapshot
+        ?? (projectPath ? (value: ContextHubSnapshotRef) => getContextHub(projectPath).readSnapshot(value) : undefined)
+      try {
+        const value = loader ? await loader(reference) : null
+        if (!cancelled) setSnapshot(value)
+      } catch {
+        if (!cancelled) setSnapshot(null)
+      } finally {
+        if (!cancelled) setLoading(false)
+      }
+    }
+    void read()
+    return () => { cancelled = true }
+  }, [expanded, loadSnapshot, projectPath, reference.createdAt, reference.id])
+
+  return (
+    <div className={cn("mt-2 min-w-0 border-t border-border/60 pt-2", className)}>
+      <button
+        type="button"
+        aria-label={expanded ? "收起上下文中控" : "展开上下文中控"}
+        aria-expanded={expanded}
+        onClick={() => setExpanded((value) => !value)}
+        className="flex w-full min-w-0 items-start gap-2 text-left"
+      >
+        <span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-teal-100 text-teal-600 dark:bg-teal-950/40 dark:text-teal-400">
+          <Database aria-hidden="true" className="h-3.5 w-3.5" />
+        </span>
+        <span className="min-w-0 flex-1">
+          <span className="block text-xs font-medium text-foreground">上下文中控</span>
+          <span className="mt-0.5 block text-[11px] text-muted-foreground">
+            本地缓存:命中 {stats.hits.toLocaleString()},刷新 {stats.refreshed.toLocaleString()},失败 {stats.failures.toLocaleString()}
+          </span>
+          <span className="mt-0.5 block text-[11px] text-muted-foreground">
+            稳定核心 {stats.stableTokens.toLocaleString()} Token 会话摘要 {stats.summaryTokens.toLocaleString()} Token 动态片段 {stats.dynamicTokens.toLocaleString()} Token
+          </span>
+        </span>
+        {expanded
+          ? <ChevronUp aria-hidden="true" className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
+          : <ChevronDown aria-hidden="true" className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />}
+      </button>
+
+      {expanded && (
+        <div className="ml-8 mt-2 min-w-0">
+          <div className="mb-2 space-y-0.5 text-[11px] text-muted-foreground">
+            <div>项目资料预计节省 {stats.estimatedSavedTokens.toLocaleString()} Token({stats.estimatedSavedPercent}%)</div>
+            <div>低置信度扩展:{stats.expanded ? "已启用" : "未启用"}</div>
+            {stats.providerCachedTokens != null
+              ? <div className="font-medium text-green-600 dark:text-green-400">供应商已确认命中 {stats.providerCachedTokens.toLocaleString()} Token</div>
+              : stats.providerCacheEnabled
+                ? <div>已启用稳定前缀缓存</div>
+                : null}
+          </div>
+
+          {loading ? (
+            <div className="py-3 text-[11px] text-muted-foreground">正在读取上下文快照...</div>
+          ) : snapshot === null ? (
+            <div className="py-3 text-[11px] text-amber-700 dark:text-amber-300">上下文快照不可用</div>
+          ) : snapshot ? (
+            <div className="mt-2 min-w-0">
+              <div className="max-h-48 overflow-y-auto border-y border-border/60 pr-1">
+                {STATUS_ORDER.map((status) => (
+                  <CacheItemGroup
+                    key={status}
+                    status={status}
+                    items={snapshot.items.filter((item) => item.status === status)}
+                  />
+                ))}
+              </div>
+              <div className="flex border-b border-border/60" role="tablist" aria-label="上下文内容">
+                {(Object.keys(SECTION_LABELS) as ContextSection[]).map((section) => (
+                  <button
+                    key={section}
+                    type="button"
+                    role="tab"
+                    aria-selected={activeSection === section}
+                    onClick={() => setActiveSection(section)}
+                    className={cn(
+                      "border-b-2 px-2 py-1.5 text-[11px] font-medium",
+                      activeSection === section
+                        ? "border-primary text-foreground"
+                        : "border-transparent text-muted-foreground hover:text-foreground",
+                    )}
+                  >
+                    {SECTION_LABELS[section]}
+                  </button>
+                ))}
+              </div>
+              <pre className="max-h-96 min-w-0 overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words bg-muted/30 p-2 text-[11px] leading-relaxed text-foreground/80">
+                {snapshot[activeSection] || `本轮无${SECTION_LABELS[activeSection]}内容`}
+              </pre>
+            </div>
+          ) : null}
+        </div>
+      )}
+    </div>
+  )
+}

+ 1 - 15
src/components/common/event-stream.tsx

@@ -4,7 +4,7 @@ import { groupTimelineEvents } from "./timeline-grouping"
 import { ThinkingEvent } from "./timeline-thinking-event"
 import { ToolCallEvent } from "./timeline-tool-event"
 import { getToolCallGroupRenderKey, ToolCallGroup } from "./timeline-tool-group"
-import { Brain, Clock3, Hash } from "lucide-react"
+import { Brain, Hash } from "lucide-react"
 
 interface EventStreamProps {
   events: TimelineEvent[]
@@ -25,14 +25,6 @@ function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }:
     return count
   }, 0)
 
-  const formatDuration = (ms: number): string => {
-    if (ms < 1000) return `${ms}ms`
-    if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
-    const minutes = Math.floor(ms / 60000)
-    const seconds = ((ms % 60000) / 1000).toFixed(0)
-    return `${minutes}分${seconds}秒`
-  }
-
   useEffect(() => {
     if (!isStreaming) return
     const container = containerRef.current
@@ -122,12 +114,6 @@ function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }:
               animation: "slideInUp 300ms ease-out",
             }}
           >
-            {totalDurationMs !== undefined && (
-              <span className="flex items-center gap-1">
-                <Clock3 aria-hidden="true" className="h-3 w-3" />
-                <span>耗时 {formatDuration(totalDurationMs)}</span>
-              </span>
-            )}
             {totalTokens !== undefined && (
               <span className="flex items-center gap-1">
                 <Hash aria-hidden="true" className="h-3 w-3" />

+ 63 - 0
src/components/common/timeline-duration-visibility.spec.tsx

@@ -0,0 +1,63 @@
+import { renderToStaticMarkup } from "react-dom/server"
+import { describe, expect, it } from "vitest"
+import { ToolCallTimeline } from "@/components/chat/tool-call-timeline"
+import { EventStream } from "./event-stream"
+import type { TimelineEvent, ToolCallEventItem } from "./timeline-types"
+
+function readEvent(id: string, startedAt: number, finishedAt: number): ToolCallEventItem {
+  return {
+    id,
+    name: "read_chapter",
+    description: `读取章节《${id}》`,
+    category: "read",
+    status: "done",
+    startedAt,
+    finishedAt,
+  }
+}
+
+describe("AI 对话与 AI 大纲耗时展示", () => {
+  it("共享事件流不显示单项、分组或总耗时", () => {
+    const singleEvents: TimelineEvent[] = [
+      { kind: "tool_call", data: readEvent("第一章", 100, 105) },
+    ]
+    const groupedEvents: TimelineEvent[] = [
+      { kind: "tool_call", data: readEvent("第一章", 100, 105) },
+      { kind: "tool_call", data: readEvent("第二章", 106, 119) },
+    ]
+
+    const singleHtml = renderToStaticMarkup(
+      <EventStream events={singleEvents} isStreaming={false} totalDurationMs={19} />,
+    )
+    const groupedHtml = renderToStaticMarkup(
+      <EventStream events={groupedEvents} isStreaming={false} totalDurationMs={19} />,
+    )
+
+    expect(singleHtml).toContain("读取章节《第一章》")
+    expect(singleHtml).not.toContain("5ms")
+    expect(singleHtml).not.toContain("耗时")
+    expect(groupedHtml).toContain("2项")
+    expect(groupedHtml).not.toContain("18ms")
+    expect(groupedHtml).not.toContain("19ms")
+    expect(groupedHtml).not.toContain("耗时")
+  })
+
+  it("AI 对话旧工具时间线不显示耗时", () => {
+    const html = renderToStaticMarkup(
+      <ToolCallTimeline
+        toolCalls={[{
+          id: "tool-1",
+          name: "read_chapter",
+          params: { chapter: "第一章" },
+          result: "章节内容",
+          status: "done",
+          startedAt: 100,
+          finishedAt: 1100,
+        }]}
+      />,
+    )
+
+    expect(html).toContain("读取章节")
+    expect(html).not.toContain("1.0s")
+  })
+})

+ 0 - 13
src/components/common/timeline-tool-event.tsx

@@ -57,13 +57,6 @@ const STATUS_COLOR: Record<ToolCallEventItem["status"], string> = {
   cancelled: "text-muted-foreground",
 }
 
-function formatDuration(startedAt?: number, finishedAt?: number): string {
-  if (startedAt === undefined || finishedAt === undefined || finishedAt <= startedAt) return ""
-  const ms = finishedAt - startedAt
-  if (ms < 1000) return `${ms}ms`
-  return `${(ms / 1000).toFixed(1)}s`
-}
-
 function formatParamValue(value: unknown): string {
   if (typeof value === "string") return value
   if (value === undefined) return "undefined"
@@ -74,7 +67,6 @@ function ToolCallEventImpl({ event, compact = false }: ToolCallEventProps) {
   const [expanded, setExpanded] = useState(false)
   const isRunning = event.status === "running"
   const isError = event.status === "error"
-  const duration = formatDuration(event.startedAt, event.finishedAt)
   const CategoryIcon = CATEGORY_ICON[event.category]
   const StatusIcon = STATUS_ICON[event.status]
   const statusLabel = STATUS_LABEL[event.status]
@@ -113,11 +105,6 @@ function ToolCallEventImpl({ event, compact = false }: ToolCallEventProps) {
         )}>
           {event.description}
         </span>
-        {duration && (
-          <span className="mt-0.5 shrink-0 text-[10px] text-muted-foreground/50">
-            {duration}
-          </span>
-        )}
         <span className={cn(
           "ml-1 mt-0.5 flex shrink-0 items-center gap-1 text-[10px]",
           STATUS_COLOR[event.status],

+ 0 - 8
src/components/common/timeline-tool-group.tsx

@@ -12,15 +12,8 @@ export function getToolCallGroupRenderKey(group: ToolCallGroupItem): string {
   return `tool-group:${JSON.stringify([group.kind, group.items[0]?.id ?? group.id])}`
 }
 
-function formatDuration(durationMs?: number): string {
-  if (!durationMs || durationMs <= 0) return ""
-  if (durationMs < 1000) return `${durationMs}ms`
-  return `${(durationMs / 1000).toFixed(1)}s`
-}
-
 function ToolCallGroupImpl({ group, style }: ToolCallGroupProps) {
   const [expanded, setExpanded] = useState(false)
-  const duration = formatDuration(group.durationMs)
   const Chevron = expanded ? ChevronDown : ChevronRight
 
   return (
@@ -36,7 +29,6 @@ function ToolCallGroupImpl({ group, style }: ToolCallGroupProps) {
         <Check aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-emerald-500/45" />
         <span className="min-w-0 flex-1 break-words text-foreground/75">{group.label}</span>
         <span className="shrink-0 text-[10px]">{group.items.length}项</span>
-        {duration && <span className="shrink-0 text-[10px] text-muted-foreground/50">{duration}</span>}
       </button>
 
       {expanded && (

+ 59 - 5
src/components/sources/outline-chat-panel.spec.tsx

@@ -24,12 +24,19 @@ import {
   type OutlineChatConversation,
   type OutlineChatMessage,
 } from "../../stores/outline-chat-store"
+import type { AgentMessage } from "@/lib/agent/types"
+import type { ContextHubSnapshotRef } from "@/lib/context-hub/types"
 
 const source = readFileSync(resolve(__dirname, "outline-chat-panel.tsx"), "utf8")
 const outlineSectionConfigsSource = readFileSync(resolve(__dirname, "../../lib/novel/outline-section-configs.ts"), "utf8")
 
 const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []
 
+function agentMessageContentText(content: AgentMessage["content"]): string {
+  if (typeof content === "string") return content
+  return content.map((block) => block.type === "text" ? block.text : "").join("")
+}
+
 function conversation(messages: OutlineChatMessage[] = []): OutlineChatConversation {
   return {
     id: "outline-active",
@@ -112,6 +119,38 @@ afterEach(async () => {
 
 describe("OutlineChatPanel controls", () => {
 
+  it("在 AI 大纲回复下方独立显示上下文中控摘要", async () => {
+    const contextHubSnapshot: ContextHubSnapshotRef = {
+      id: "outline-assistant-1",
+      surface: "ai-outline",
+      createdAt: 10,
+      stats: {
+        hits: 3,
+        refreshed: 2,
+        failures: 0,
+        stableTokens: 1200,
+        summaryTokens: 60,
+        dynamicTokens: 420,
+        candidateTokens: 3000,
+        estimatedSavedTokens: 1320,
+        estimatedSavedPercent: 44,
+        expanded: false,
+        providerCacheEnabled: true,
+      },
+    }
+    setOutlineConversations([conversation([{
+      id: "outline-assistant-1",
+      role: "assistant",
+      content: "大纲正文",
+      contextHubSnapshot,
+    }])], "outline-active")
+
+    const container = await renderOutlineChatPanel()
+
+    expect(container.textContent).toContain("上下文中控")
+    expect(container.textContent).toContain("本地缓存:命中 3,刷新 2,失败 0")
+  })
+
   it.each([
     ["继续完善人物弧光", "A"],
     ["检查伏笔闭环", "B"],
@@ -461,7 +500,7 @@ describe("OutlineChatPanel controls", () => {
   it("后续普通追问复用 AI 大纲上下文并节流资料读取工具", () => {
     expect(source).toContain("planOutlineContextReuse")
     expect(source).toContain("planOutlineAgentHistory")
-    expect(source).toContain("buildOutlineContextSummary")
+    expect(source).toContain("buildSessionContextSummary")
     expect(source).toContain("contextDecision")
     expect(source).toContain("historyPlan")
     expect(source).toContain("contextDecision.instruction")
@@ -483,13 +522,23 @@ describe("OutlineChatPanel controls", () => {
 
   it("将 AI 大纲上下文摘要持久化到会话字段而不是组件内存缓存", () => {
     expect(source).toContain("contextSummary:")
-    expect(source).toContain("buildOutlineContextSummary")
+    expect(source).toContain("buildSessionContextSummary")
+    expect(source).toContain("dependencies: contextHubResult?.dependencies")
     // 上下文摘要已通过 setConversationContextSummary 持久化到会话字段
     expect(source).toContain("setConversationContextSummary")
     expect(source).not.toContain("contextSummaryByConversation")
     expect(source).not.toContain("setContextSummaryByConversation")
   })
 
+  it("主发送、续传多 Agent 和重新生成统一接入上下文中控快照", () => {
+    expect(source.match(/contextHub\.prepare\(/g)).toHaveLength(3)
+    expect(source.match(/readTextFile: contextHubResult\.readFile/g)).toHaveLength(3)
+    expect(source.match(/\.saveSnapshot\(/g)).toHaveLength(3)
+    expect(source).toContain("<ContextHubDetails")
+    expect(source).not.toContain("formatContextHubStatsForDetails")
+    expect(source.match(/buildContextHubSystemContent\(/g)?.length ?? 0).toBeGreaterThanOrEqual(3)
+  })
+
   it("keeps outline reference chips as tool-readable hints instead of preloading file contents", () => {
     expect(source).toContain("buildOutlineAgentUserContent")
     expect(source).toContain("请优先使用工具读取引用内容")
@@ -579,7 +628,8 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain('import { OutlineMultiAgentPanel } from "@/components/sources/outline-multi-agent-panel"')
     expect(source).toContain("multiAgentRun")
     expect(source).toContain("updateOutlineMultiAgentRun")
-    expect(source).toContain("<OutlineMultiAgentPanel run={msg.multiAgentRun} />")
+    expect(source).toContain("<OutlineMultiAgentPanel")
+    expect(source).toContain("run={msg.multiAgentRun}")
     expect(source).toContain("status: \"pending\"")
     expect(source).toContain("status: \"running\"")
     expect(source).toContain("status: \"merging\"")
@@ -887,7 +937,9 @@ describe("OutlineChatPanel controls", () => {
     ].join("\n")
     const fallbackCalls: Array<{ modelId: string; messages: Array<{ role: string; content: string }> }> = []
     vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (config, _registry, messages, callbacks) => {
-      const system = messages.find((message) => message.role === "system")?.content ?? ""
+      const system = agentMessageContentText(
+        messages.find((message) => message.role === "system")?.content ?? "",
+      )
       if (system.includes("\u53ea\u8d1f\u8d23\u89c4\u5212\u5927\u7eb2\u5b50 Agent \u4efb\u52a1\u56fe")) {
         return { toolCalls: [], roundsUsed: 1, finalText: "{}" }
       }
@@ -956,7 +1008,9 @@ describe("OutlineChatPanel controls", () => {
     let subAgentCallCount = 0
     let fallbackCallCount = 0
     vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, messages, callbacks) => {
-      const system = messages.find((message) => message.role === "system")?.content ?? ""
+      const system = agentMessageContentText(
+        messages.find((message) => message.role === "system")?.content ?? "",
+      )
       if (system.includes("\u53ea\u8d1f\u8d23\u89c4\u5212\u5927\u7eb2\u5b50 Agent \u4efb\u52a1\u56fe")) {
         return { toolCalls: [], roundsUsed: 1, finalText: "{}" }
       }

+ 212 - 28
src/components/sources/outline-chat-panel.tsx

@@ -122,6 +122,7 @@ import { useStreamingText } from "@/hooks/use-streaming-text";
 import { highlightCode } from "@/lib/streaming-code-highlight";
 import { separateThinking } from "@/lib/separate-thinking";
 import { StreamingMarkdown } from "@/components/common/streaming-markdown";
+import { ContextHubDetails } from "@/components/common/context-hub-details";
 import {
   ReferenceInput,
   type InsertReferenceTokens,
@@ -165,10 +166,17 @@ import {
   shouldUseWebResearch,
 } from "@/lib/web-research";
 import {
-  buildOutlineContextSummary,
   planOutlineAgentHistory,
   planOutlineContextReuse,
 } from "@/lib/novel/outline-context-reuse";
+import {
+  buildContextHubSystemContent,
+  buildSessionContextSummary,
+  flattenContextHubSystemContent,
+  getContextHub,
+  type ContextHubResult,
+  type ContextHubSnapshotRef,
+} from "@/lib/context-hub";
 import {
   getConversationTabTitle,
   splitConversationToolbarItems,
@@ -940,6 +948,12 @@ function OutlineAssistantMessage({
           <OutlineMarkdownContent content={text} projectPath={projectPath} />
         )}
       />
+      {msg.contextHubSnapshot ? (
+        <ContextHubDetails
+          reference={msg.contextHubSnapshot}
+          projectPath={projectPath}
+        />
+      ) : null}
       {/* File edit preview */}
       {parsed.hasEdits && !editDismissed && projectPath && !isStreaming ? (
         <FileEditPreview
@@ -1808,9 +1822,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           ? useOutlineChatStore
               .getState()
               .conversations.find((conversation) => conversation.id === convId)
-              ?.contextSummary
+              ?.contextSummary?.text
           : undefined;
-      const historyPlan = planOutlineAgentHistory({
+      let historyPlan = planOutlineAgentHistory({
         history: historyBeforeSend,
         contextDecision,
         cachedSummary,
@@ -1850,8 +1864,46 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       userScrolledUpRef.current = false;
       let hiddenToolCalls: AgentRunRecord["toolCalls"] = [];
       let followUpGenerationPrompt: string | null = null;
+      let contextHubResult: ContextHubResult | null = null;
 
       try {
+        const contextHub = getContextHub(normalizePath(project.path));
+        contextHubResult = await contextHub.prepare({
+          projectPath: normalizePath(project.path),
+          surface: "ai-outline",
+          sessionId: capturedConvId,
+          task: prompt,
+          intent: options.intentPhase === "generation" ? "generate" : "question",
+          references: tokens.map(describeReferenceForOutlineAgent),
+          messages: historyBeforeSend,
+          existingSummary: forceRefresh ? undefined : targetConversation?.contextSummary,
+          tokenBudget: novelConfig.contextTokenBudget > 0
+            ? novelConfig.contextTokenBudget
+            : undefined,
+          forceRefresh,
+        });
+        if (contextHubResult) {
+          try {
+            const contextHubSnapshot = await contextHub.saveSnapshot(assistantId, contextHubResult);
+            if (isCurrentRun()) {
+              updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+                ...message,
+                contextHubSnapshot,
+              }));
+            }
+          } catch (error) {
+            console.warn("AI 大纲上下文快照保存失败,继续生成:", error);
+          }
+        }
+        if (contextHubResult && contextDecision.mode === "reuse") {
+          historyPlan = planOutlineAgentHistory({
+            history: historyBeforeSend,
+            contextDecision,
+            cachedSummary: contextHubResult.sessionSummary || undefined,
+            summaryInSystem: true,
+          });
+        }
+
         let webResearchMarkdown = "";
         let outlineSources = [...initialSources];
         if (shouldUseWebResearch(prompt)) {
@@ -1873,13 +1925,32 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           (): DeAiSkillConfig | null => null,
         );
         const soulDoc = await readSoulDoc(project.path).catch(() => "");
-        const systemPrompt = buildOutlineAgentSystemPrompt({
+        const baseSystemPrompt = buildOutlineAgentSystemPrompt({
+          projectName: project.name,
+        });
+        const legacySystemPrompt = buildOutlineAgentSystemPrompt({
           projectName: project.name,
           webResearchContext: webResearchMarkdown,
           soulDoc,
         }) + `\n\n## 本轮上下文策略\n${contextDecision.instruction}\n\n${historyPlan.instruction}`;
+        const commonDynamicParts = [
+          webResearchMarkdown ? `## 本轮联网资料\n${webResearchMarkdown}` : "",
+          `## 本轮上下文策略\n${contextDecision.instruction}\n\n${historyPlan.instruction}`,
+        ];
+        const buildOutlineRunSystemContent = (extraRules = ""): AgentMessage["content"] => (
+          contextHubResult
+            ? buildContextHubSystemContent(baseSystemPrompt, contextHubResult, [
+                ...commonDynamicParts,
+                extraRules,
+              ])
+            : [legacySystemPrompt, extraRules].filter(Boolean).join("\n\n")
+        );
+        const primarySystemContent = buildOutlineRunSystemContent();
+        const systemPrompt = typeof primarySystemContent === "string"
+          ? primarySystemContent
+          : flattenContextHubSystemContent(primarySystemContent);
         const agentMessages: AgentMessage[] = [
-          { role: "system", content: systemPrompt },
+          { role: "system", content: primarySystemContent },
           ...historyPlan.messages,
           {
             role: "user",
@@ -1930,6 +2001,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   : OUTLINE_CHAT_DISABLED_TOOLS,
                 contextDecision.disabledTools,
               ),
+              ...(contextHubResult
+                ? { readTextFile: contextHubResult.readFile }
+                : {}),
             },
           );
           return { agentConfig, registry };
@@ -2016,7 +2090,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           const plannerPrompt = buildDynamicOutlinePlannerPrompt({
             userTask: prompt,
             projectSummary: [
-              targetConversation?.contextSummary,
+              targetConversation?.contextSummary?.text,
               contextDecision.instruction,
               historyPlan.instruction,
             ].filter(Boolean).join("\n"),
@@ -2034,7 +2108,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             const plannerRun = await runOutlineAgentOnce([
               {
                 role: "system",
-                content: "你只负责规划大纲子 Agent 任务图,不执行大纲生成,不调用工具,只输出 JSON。",
+                content: buildOutlineRunSystemContent(
+                  "你只负责规划大纲子 Agent 任务图,不执行大纲生成,不调用工具,只输出 JSON。",
+                ),
               },
               { role: "user", content: plannerPrompt },
             ], {
@@ -2088,14 +2164,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               const subAgentMessages: AgentMessage[] = [
                 {
                   role: "system",
-                  content: [
-                    systemPrompt,
-                    "",
+                  content: buildOutlineRunSystemContent([
                     "## 子 Agent 运行规则",
                     `当前身份:${subAgentPlan.name}`,
                     "你只能处理本 Agent 负责的维度,禁止写入文件。",
                     "必须输出符合 AI 大纲子 Agent JSON 协议的 JSON,不要输出额外说明。",
-                  ].join("\n"),
+                  ].join("\n")),
                 },
                 ...historyPlan.messages,
                 {
@@ -2164,13 +2238,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               const mergeMessages: AgentMessage[] = [
                 {
                   role: "system",
-                  content: [
-                    systemPrompt,
-                    "",
+                  content: buildOutlineRunSystemContent([
                     "## 合并 Agent 运行规则",
                     "你负责合并多个子 Agent 的结构化结果,形成最终可预览的大纲草稿。",
                     "输出必须是用户可直接阅读和保存的大纲正文,不要输出内部调度报告。",
-                  ].join("\n"),
+                  ].join("\n")),
                 },
                 ...historyPlan.messages,
                 {
@@ -2355,11 +2427,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         }
 
         const nextContextSummaryPayload = {
-          contextSummary: buildOutlineContextSummary([
-            ...historyBeforeSend,
-            { role: "user", content: prompt },
-            { role: "assistant", content: finalContent },
-          ]),
+          contextSummary: buildSessionContextSummary({
+            messages: [
+              ...historyBeforeSend,
+              { role: "user", content: prompt },
+              { role: "assistant", content: finalContent },
+            ],
+            dependencies: contextHubResult?.dependencies ?? {},
+          }),
         };
         if (!isCurrentRun()) return { started: true, sent: false };
         setConversationContextSummary(convId, nextContextSummaryPayload.contextSummary);
@@ -2598,9 +2673,53 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       );
 
       try {
+        let contextHubResult: ContextHubResult | null = null;
+        try {
+          const contextHub = getContextHub(normalizePath(project.path));
+          contextHubResult = await contextHub.prepare({
+            projectPath: normalizePath(project.path),
+            surface: "ai-outline",
+            sessionId: capturedConvId,
+            task: `继续未完成的 AI 大纲多 Agent 任务:${failedAgentIds.join("、")}`,
+            intent: "generate",
+            messages: conv.messages.map((message) => ({
+              role: message.role,
+              content: message.content,
+            })),
+            existingSummary: conv.contextSummary,
+            tokenBudget: novelConfig.contextTokenBudget > 0
+              ? novelConfig.contextTokenBudget
+              : undefined,
+          });
+          if (contextHubResult) {
+            try {
+              const contextHubSnapshot = await contextHub.saveSnapshot(`${messageId}:${runId}`, contextHubResult);
+              if (isCurrentRun()) {
+                updateOutlineAssistantMessage(capturedConvId, messageId, (message) => ({
+                  ...message,
+                  contextHubSnapshot,
+                }));
+              }
+            } catch (error) {
+              console.warn("AI 大纲续传上下文快照保存失败,继续生成:", error);
+            }
+          }
+        } catch (error) {
+          console.warn("AI 大纲续传上下文中控准备失败,继续使用原有流程:", error);
+        }
         const skillConfig = await loadDeAiSkillConfig(project.path).catch((): DeAiSkillConfig | null => null);
         const soulDoc = await readSoulDoc(project.path).catch(() => "");
-        const systemPrompt = buildOutlineAgentSystemPrompt({ projectName: project.name, soulDoc });
+        const baseSystemPrompt = buildOutlineAgentSystemPrompt({ projectName: project.name });
+        const legacySystemPrompt = buildOutlineAgentSystemPrompt({ projectName: project.name, soulDoc });
+        const buildResumeSystemContent = (extraRules: string): AgentMessage["content"] => (
+          contextHubResult
+            ? buildContextHubSystemContent(baseSystemPrompt, contextHubResult, [extraRules])
+            : [legacySystemPrompt, extraRules].filter(Boolean).join("\n\n")
+        );
+        const primarySystemContent = buildResumeSystemContent("");
+        const systemPrompt = typeof primarySystemContent === "string"
+          ? primarySystemContent
+          : flattenContextHubSystemContent(primarySystemContent);
         const buildConfig = (_skillNames: string[], disableWriteTools: boolean) => {
           const r = new ToolRegistry();
           const c = buildAgentConfig(effectiveModelId, systemPrompt, r, {
@@ -2620,6 +2739,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             getOutlineConversations: () => mapOutlineConversationsForModel(useOutlineChatStore.getState().conversations),
             llmConfig: effectiveLlmConfig,
             disabledTools: disableWriteTools ? OUTLINE_CHAT_DISABLED_TOOLS : [],
+            ...(contextHubResult
+              ? { readTextFile: contextHubResult.readFile }
+              : {}),
           });
           return { agentConfig: c, registry: r };
         };
@@ -2652,7 +2774,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             let runText = "";
             let agentError: Error | null = null;
             await new AgentRunner().run(agentConfig, reg, [
-              { role: "system", content: [systemPrompt, "", "## 子 Agent 运行规则", `当前身份:${subAgentPlan.name}`, "你只能处理本 Agent 负责的维度,禁止写入文件。", "必须输出符合 AI 大纲子 Agent JSON 协议的 JSON,不要输出额外说明。"].join("\n") },
+              { role: "system", content: buildResumeSystemContent(["## 子 Agent 运行规则", `当前身份:${subAgentPlan.name}`, "你只能处理本 Agent 负责的维度,禁止写入文件。", "必须输出符合 AI 大纲子 Agent JSON 协议的 JSON,不要输出额外说明。"].join("\n")) },
               { role: "user", content: subAgentPlan.taskPrompt },
             ], {
               onText: (chunk) => { runText += chunk; },
@@ -2677,7 +2799,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             let mergeText = "";
             let mergeError: Error | null = null;
             await new AgentRunner().run(agentConfig, reg, [
-              { role: "system", content: [systemPrompt, "", "## 合并 Agent 运行规则", "你负责合并多个子 Agent 的结构化结果,形成最终可预览的大纲草稿。", "输出必须是用户可直接阅读和保存的大纲正文,不要输出内部调度报告。"].join("\n") },
+              { role: "system", content: buildResumeSystemContent(["## 合并 Agent 运行规则", "你负责合并多个子 Agent 的结构化结果,形成最终可预览的大纲草稿。", "输出必须是用户可直接阅读和保存的大纲正文,不要输出内部调度报告。"].join("\n")) },
               { role: "user", content: ["请合并以下 AI 大纲子 Agent 结果,解决冲突并输出最终大纲草稿。", "", "## 子 Agent 结构化结果", JSON.stringify(subAgentResults, null, 2)].join("\n") },
             ], {
               onText: (chunk) => {
@@ -2745,7 +2867,19 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           updateOutlineAssistantMessage(capturedConvId, messageId, (message) => ({
             ...message,
             content: resumeResult.finalText,
+            sources: Array.from(new Set([
+              ...(message.sources ?? []),
+            ])),
+          }));
+        }
+        const completedConversation = useOutlineChatStore.getState().conversations
+          .find((conversation) => conversation.id === capturedConvId);
+        if (completedConversation) {
+          setConversationContextSummary(capturedConvId, buildSessionContextSummary({
+            messages: completedConversation.messages,
+            dependencies: contextHubResult?.dependencies ?? {},
           }));
+          void useOutlineChatStore.getState().saveToDisk();
         }
       } catch (err) {
         const aborted = controller.signal.aborted || (err instanceof Error ? err.message : "").toLowerCase().includes("aborted");
@@ -2757,7 +2891,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         clearStreamingContent(capturedConvId);
       }
     },
-    [project, activeConversationId, llmConfig, novelConfig, effectiveOutlineModelId, providerConfigs, outlineWritingSkills, startConversationRun, stopConversationRun, clearStreamingContent],
+    [project, activeConversationId, llmConfig, novelConfig, effectiveOutlineModelId, providerConfigs, outlineWritingSkills, startConversationRun, stopConversationRun, clearStreamingContent, setConversationContextSummary],
   );
 
   const handleFocusInput = useCallback(() => {
@@ -2882,8 +3016,34 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         const regenerationInput = buildOutlineRegenerationInput(targetMessages);
         const lastUserRequest = regenerationInput.request;
         const historyMessages = regenerationInput.history satisfies AgentMessage[];
-        let result = "";
         const assistantId = crypto.randomUUID();
+        let contextHubSnapshot: ContextHubSnapshotRef | undefined;
+        let contextHubResult: ContextHubResult | null = null;
+        try {
+          const contextHub = getContextHub(normalizePath(project.path));
+          contextHubResult = await contextHub.prepare({
+            projectPath: normalizePath(project.path),
+            surface: "ai-outline",
+            sessionId: capturedConvId,
+            task: lastUserRequest,
+            intent: "generate",
+            messages: historyMessages,
+            existingSummary: undefined,
+            tokenBudget: novelConfig.contextTokenBudget > 0
+              ? novelConfig.contextTokenBudget
+              : undefined,
+          });
+          if (contextHubResult && isCurrentRun()) {
+            try {
+              contextHubSnapshot = await contextHub.saveSnapshot(assistantId, contextHubResult);
+            } catch (error) {
+              console.warn("AI 大纲重新生成上下文快照保存失败,继续生成:", error);
+            }
+          }
+        } catch (error) {
+          console.warn("AI 大纲重新生成上下文中控准备失败,继续使用原有流程:", error);
+        }
+        let result = "";
 
         addMessage(capturedConvId, {
           id: assistantId,
@@ -2892,6 +3052,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           sources: [],
           agentToolCalls: [],
           isAgentRunning: true,
+          contextHubSnapshot,
         });
 
         const skillConfig = await loadDeAiSkillConfig(project.path).catch(
@@ -2899,10 +3060,19 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         );
         const soulDoc = await readSoulDoc(project.path).catch(() => "");
         const registry = new ToolRegistry();
-        const systemPrompt = buildOutlineAgentSystemPrompt({
+        const baseSystemPrompt = buildOutlineAgentSystemPrompt({
+          projectName: project.name,
+        });
+        const legacySystemPrompt = buildOutlineAgentSystemPrompt({
           projectName: project.name,
           soulDoc,
         });
+        const systemContent: AgentMessage["content"] = contextHubResult
+          ? buildContextHubSystemContent(baseSystemPrompt, contextHubResult)
+          : legacySystemPrompt;
+        const systemPrompt = typeof systemContent === "string"
+          ? systemContent
+          : flattenContextHubSystemContent(systemContent);
         const agentConfig = buildAgentConfig(
           effectiveModelId,
           systemPrompt,
@@ -2932,6 +3102,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               ),
             llmConfig: effectiveLlmConfig,
             disabledTools: OUTLINE_CHAT_DISABLED_TOOLS,
+            ...(contextHubResult
+              ? { readTextFile: contextHubResult.readFile }
+              : {}),
           },
         );
         let agentError: Error | null = null;
@@ -2939,7 +3112,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           agentConfig,
           registry,
           [
-            { role: "system", content: systemPrompt },
+            { role: "system", content: systemContent },
             ...historyMessages,
             { role: "user", content: lastUserRequest },
           ],
@@ -2988,7 +3161,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         if (agentError) throw agentError;
         if (!isCurrentRun()) return;
 
-        const sources = outlineToolCallsToSources(record.toolCalls);
+        const sources = [
+          ...outlineToolCallsToSources(record.toolCalls),
+        ];
         const nextStepExtraction = extractNextStep(
           result || record.finalText || "AI大纲未返回内容。",
           { allowFallback: true, completedModule: "当前模块" },
@@ -3019,6 +3194,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             nextStepRecommendation: nextStepExtraction.recommendation,
           }),
         );
+        setConversationContextSummary(capturedConvId, buildSessionContextSummary({
+          messages: [
+            ...historyMessages,
+            { role: "user", content: lastUserRequest },
+            { role: "assistant", content: finalContent },
+          ],
+          dependencies: contextHubResult?.dependencies ?? {},
+        }));
         if (!isCurrentRun()) return;
         await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);
         if (!isCurrentRun()) return;
@@ -3072,6 +3255,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       startConversationRun,
       finishConversationRun,
       failConversationRun,
+      setConversationContextSummary,
     ],
   );
 

+ 28 - 0
src/lib/agent/context-trace-builders.spec.ts

@@ -59,6 +59,34 @@ describe("context trace builders", () => {
     expect(info.workflowMode).toBe("strict")
   })
 
+  it("carries context hub statistics into initial trace context", () => {
+    const contextHub = {
+      hits: 4,
+      refreshed: 1,
+      failures: 0,
+      stableTokens: 1200,
+      summaryTokens: 180,
+      dynamicTokens: 420,
+      candidateTokens: 3200,
+      estimatedSavedTokens: 1400,
+      estimatedSavedPercent: 44,
+      expanded: false,
+      providerCacheEnabled: true,
+    }
+
+    const info = buildInitialContextTraceInfo(
+      {
+        intent: "write_chapter",
+        confidence: 0.91,
+        extractedParams: {},
+      } as any,
+      null,
+      { contextHub },
+    )
+
+    expect(info.contextHub).toEqual(contextHub)
+  })
+
   it("carries selected skill metadata into initial trace context", () => {
     const info = buildInitialContextTraceInfo(
       {

+ 3 - 1
src/lib/agent/context-trace-builders.ts

@@ -3,16 +3,18 @@ import type { TraceContextInfo } from "./context-trace"
 import type { DataSourceCategory, RouteSource } from "@/lib/novel/classification"
 import type { TaskRouteResult } from "@/lib/novel/task-router"
 import type { AiWorkflowMode } from "./workflow-mode"
+import type { ContextHubStats } from "@/lib/context-hub/types"
 
 export function buildInitialContextTraceInfo(
   route: TaskRouteResult,
   prePluginResult?: Partial<PrePluginChainResult> | null,
-  options?: { workflowMode?: AiWorkflowMode },
+  options?: { workflowMode?: AiWorkflowMode; contextHub?: ContextHubStats },
 ): TraceContextInfo {
   return {
     intent: route.intent as any,
     confidence: route.confidence,
     workflowMode: options?.workflowMode,
+    contextHub: options?.contextHub,
     routeSource: (prePluginResult?.routeSource as RouteSource | undefined) ?? "default",
     loadedSources: [],
     blockedSources: (prePluginResult?.blockedSources as DataSourceCategory[] | undefined) ?? [],

+ 2 - 0
src/lib/agent/context-trace.ts

@@ -4,6 +4,7 @@ import type { ToolCallStatus } from "./types"
 import type { AiWorkflowMode } from "./workflow-mode"
 import type { SkillKind, SkillMode, SkillStage } from "@/lib/novel/skill-library"
 import type { CapabilityKind, CapabilityPermission } from "./capabilities/types"
+import type { ContextHubStats } from "@/lib/context-hub/types"
 
 export type TraceToolCategory = "read" | "write" | "action" | "virtual"
 
@@ -120,6 +121,7 @@ export interface ClassificationVersionInfo {
   retrievalHits: TraceRetrievalHit[]
   trimmedSections: string[]
   contextBudget?: TraceContextBudget
+  contextHub?: ContextHubStats
     resultProtocol?: TraceResultProtocol
     postWriteCheck?: PostWriteCheck
     fallbackReason?: string

+ 2 - 0
src/lib/agent/pipeline.ts

@@ -25,6 +25,7 @@ export interface PrePluginInput {
   selectedCapabilities?: SelectedCapabilityTrace[]
   novelSystemPrompt?: string
   finalSystemPrompt?: string
+  finalSystemRulesPrompt?: string
   shouldStop?: boolean
   stopReason?: string
   [key: string]: unknown
@@ -39,6 +40,7 @@ export interface PrePluginOutput {
   enabledToolNames?: string[]
   novelSystemPrompt?: string
   finalSystemPrompt?: string
+  finalSystemRulesPrompt?: string
   shouldStop?: boolean
   stopReason?: string
   [key: string]: unknown

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

@@ -35,6 +35,10 @@ describe("BuildSystemPromptPlugin selected skills", () => {
     expect(result.finalSystemPrompt).toContain("三翻四抖")
     expect(result.finalSystemPrompt).toContain("三次转折,四次震惊。")
     expect(result.finalSystemPrompt).toContain("task directive")
+    expect(result.finalSystemRulesPrompt).toContain("base prompt")
+    expect(result.finalSystemRulesPrompt).toContain("本次启用 Skill")
+    expect(result.finalSystemRulesPrompt).toContain("task directive")
+    expect(result.finalSystemRulesPrompt).not.toContain("context prompt")
   })
 
   it("does not inject chapter plan protocol from standard mode unless Plan Execute is enabled", async () => {
@@ -99,6 +103,8 @@ describe("BuildSystemPromptPlugin selected skills", () => {
     expect(result.finalSystemPrompt).toContain("禁止违背")
     expect(result.finalSystemPrompt).toContain("可自由发挥")
     expect(result.finalSystemPrompt).toContain("planBlueprint")
+    expect(result.finalSystemRulesPrompt).toContain("章节主编策划协议")
+    expect(result.finalSystemRulesPrompt).not.toContain("context prompt")
     const finalPrompt = result.finalSystemPrompt ?? ""
     expect(finalPrompt.length).toBeLessThan(3000)
   })

+ 12 - 3
src/lib/agent/plugins/build-system-prompt-plugin.ts

@@ -24,9 +24,13 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
         const route = input.effectiveTaskRoute || input.taskRoute
 
         const parts: string[] = []
+        const rulesParts: string[] = []
 
         const base = baseSystemPrompt || (input.agentConfig as any)?.systemPrompt || ""
-        if (base) parts.push(base)
+        if (base) {
+          parts.push(base)
+          rulesParts.push(base)
+        }
 
         if (input.novelSystemPrompt) {
           parts.push(input.novelSystemPrompt)
@@ -35,13 +39,16 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
         const selectedSkillsPrompt = buildSelectedSkillsPrompt(input.selectedSkills)
         if (selectedSkillsPrompt) {
           parts.push(selectedSkillsPrompt)
+          rulesParts.push(selectedSkillsPrompt)
         }
 
         if (input.planExecuteEnabled && input.aiWorkflowMode) {
           const routeForPlan = input.effectiveTaskRoute || input.taskRoute
           const isWritingTask = routeForPlan?.intent && WRITING_INTENTS.has(routeForPlan.intent)
           if (isWritingTask) {
-            parts.push(buildChapterPlanProtocol(input.aiWorkflowMode))
+            const planProtocol = buildChapterPlanProtocol(input.aiWorkflowMode)
+            parts.push(planProtocol)
+            rulesParts.push(planProtocol)
           }
         }
 
@@ -49,11 +56,13 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
           const taskDirective = buildDirective(route)
           if (taskDirective) {
             parts.push(taskDirective)
+            rulesParts.push(taskDirective)
           }
         }
 
         const finalSystemPrompt = parts.join("\n\n")
-        return { finalSystemPrompt }
+        const finalSystemRulesPrompt = rulesParts.join("\n\n")
+        return { finalSystemPrompt, finalSystemRulesPrompt }
       } catch (error) {
         onError?.(error instanceof Error ? error : new Error(String(error)))
         return {}

+ 23 - 0
src/lib/agent/runner.spec.ts

@@ -88,6 +88,29 @@ describe("AgentRunner", () => {
     expect(callbacks.onError).not.toHaveBeenCalled()
   })
 
+  it("passes cacheable system content blocks through to the provider layer", async () => {
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onToken("完成")
+      cb.onDone()
+    })
+    const cacheableSystem: AgentMessage = {
+      role: "system",
+      content: [
+        { type: "text", text: "稳定核心", cacheControl: true },
+        { type: "text", text: "动态上下文" },
+      ],
+    }
+
+    await runner.run(
+      { maxRounds: 1, tools: [], systemPrompt: "", llmConfig: mockLlmConfig },
+      registry,
+      [cacheableSystem, userMsg],
+      { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
+    )
+
+    expect(mockStreamChat.mock.calls[0][1][0]).toEqual(cacheableSystem)
+  })
+
   it("executes tool calls and continues the loop", async () => {
     const tool: Tool = {
       name: "read_chapter",

+ 8 - 1
src/lib/agent/runner.ts

@@ -23,6 +23,13 @@ export class ModelDoesNotSupportToolsError extends Error {
   }
 }
 
+function messageContentText(content: AgentMessage["content"]): string {
+  if (typeof content === "string") return content
+  return content
+    .map((block) => (block.type === "text" ? block.text : ""))
+    .join("")
+}
+
 function withToolTimeout<T>(operation: Promise<T>, timeoutMs: number | undefined): Promise<T> {
   const resolvedTimeoutMs = timeoutMs ?? TOOL_EXECUTE_TIMEOUT_MS
   if (resolvedTimeoutMs <= 0) return operation
@@ -49,7 +56,7 @@ export class AgentRunner {
     const projectPath = config.projectPath
     const taskGoal =
       config.taskGoal ||
-      [...messages].reverse().find((m) => m.role === "user")?.content ||
+      messageContentText([...messages].reverse().find((m) => m.role === "user")?.content ?? "") ||
       "未命名任务"
     let taskBreakpoint: TaskBreakpoint | null = projectPath
       ? createTaskBreakpoint({

+ 6 - 5
src/lib/agent/tools/index.ts

@@ -57,6 +57,7 @@ export interface ToolFactoryOptions {
   runDeepChapterGeneration?: RunDeepChapterGeneration
   onToolEvent?: (event: AgentToolEvent) => void
   getPlanBlueprint?: () => string | undefined
+  readTextFile?: (path: string) => Promise<string>
 }
 
 export function registerAllBuiltInTools(registry: ToolRegistry, options: ToolFactoryOptions): void {
@@ -69,13 +70,13 @@ export function registerAllBuiltInTools(registry: ToolRegistry, options: ToolFac
   const shouldRegister = (name: string) =>
     !disabledTools.has(name) && (!enabledToolNames || enabledToolNames.has(name))
 
-  if (shouldRegister("read_chapter")) registry.register(createReadChapterTool(chaptersDir))
-  if (shouldRegister("read_outline")) registry.register(createReadOutlineTool(outlinesDir))
-  if (shouldRegister("read_memory")) registry.register(createReadMemoryTool(memoryDir))
-  if (shouldRegister("read_deduction")) registry.register(createReadDeductionTool(simDir))
+  if (shouldRegister("read_chapter")) registry.register(createReadChapterTool(chaptersDir, options.readTextFile))
+  if (shouldRegister("read_outline")) registry.register(createReadOutlineTool(outlinesDir, options.readTextFile))
+  if (shouldRegister("read_memory")) registry.register(createReadMemoryTool(memoryDir, options.readTextFile))
+  if (shouldRegister("read_deduction")) registry.register(createReadDeductionTool(simDir, options.readTextFile))
   if (shouldRegister("read_chat_history")) registry.register(createReadChatHistoryTool(options.getChatConversations()))
   if (shouldRegister("read_outline_history")) registry.register(createReadOutlineHistoryTool(options.getOutlineConversations()))
-  if (shouldRegister("search_chapters")) registry.register(createSearchChaptersTool(chaptersDir))
+  if (shouldRegister("search_chapters")) registry.register(createSearchChaptersTool(chaptersDir, options.readTextFile))
   if (shouldRegister("list_chapters")) registry.register(createListChaptersTool(chaptersDir))
   if (shouldRegister("list_outlines")) registry.register(createListOutlinesTool(outlinesDir))
   if (shouldRegister("list_memories")) registry.register(createListMemoriesTool(memoryDir))

+ 3 - 3
src/lib/agent/tools/read-chapter.ts

@@ -1,7 +1,7 @@
 import type { Tool } from "../types"
-import { readMarkdownResource } from "./read-markdown-resource"
+import { readMarkdownResource, type ReadTextFile } from "./read-markdown-resource"
 
-export function createReadChapterTool(chaptersDir: string): Tool {
+export function createReadChapterTool(chaptersDir: string, readTextFile?: ReadTextFile): Tool {
   return {
     name: "read_chapter",
     description: "读取指定章节的完整内容。参数 name 为章节名称(如「第1章-无我绝响」),或 path 为完整文件路径。",
@@ -10,6 +10,6 @@ export function createReadChapterTool(chaptersDir: string): Tool {
       name: { type: "string", description: "章节名称,系统会自动查找对应 .md 文件" },
       path: { type: "string", description: "章节文件的完整路径(可选,与 name 二选一)" },
     },
-    execute: async (params) => readMarkdownResource(chaptersDir, params, "章节"),
+    execute: async (params) => readMarkdownResource(chaptersDir, params, "章节", readTextFile),
   }
 }

+ 6 - 2
src/lib/agent/tools/read-deduction.ts

@@ -1,7 +1,11 @@
 import type { Tool } from "../types"
 import { readFile } from "@/commands/fs"
+import type { ReadTextFile } from "./read-markdown-resource"
 
-export function createReadDeductionTool(simDir: string): Tool {
+export function createReadDeductionTool(
+  simDir: string,
+  readTextFile: ReadTextFile = readFile,
+): Tool {
   return {
     name: "read_deduction",
     description: "读取推演室的推演结果或故事框架内容。参数 name 为推演结果名称,或 path 为完整文件路径。",
@@ -15,7 +19,7 @@ export function createReadDeductionTool(simDir: string): Tool {
       const path = params.path as string | undefined
       const filePath = path || `${simDir}/${name}.json`
       try {
-        return await readFile(filePath)
+        return await readTextFile(filePath)
       } catch {
         return `错误:无法读取推演室内容「${name || path}」`
       }

+ 6 - 3
src/lib/agent/tools/read-markdown-resource.ts

@@ -12,6 +12,8 @@ interface DirectoryCandidate {
   children: MarkdownCandidate[]
 }
 
+export type ReadTextFile = (path: string) => Promise<string>
+
 function ensureMarkdownName(name: string): string {
   return name.toLowerCase().endsWith(".md") ? name : `${name}.md`
 }
@@ -131,6 +133,7 @@ export async function readMarkdownResource(
   baseDir: string,
   params: Record<string, unknown>,
   label: string,
+  readTextFile: ReadTextFile = readFile,
 ): Promise<string> {
   const name = typeof params.name === "string" ? params.name.trim() : ""
   const explicitPath = typeof params.path === "string" ? params.path.trim() : ""
@@ -138,7 +141,7 @@ export async function readMarkdownResource(
 
   if (explicitPath) {
     try {
-      return await readFile(explicitPath)
+      return await readTextFile(explicitPath)
     } catch {
       return `错误:无法读取${label}「${displayName}」,请确认文件存在`
     }
@@ -150,7 +153,7 @@ export async function readMarkdownResource(
 
   const directPath = `${baseDir}/${ensureMarkdownName(name)}`
   try {
-    return await readFile(directPath)
+    return await readTextFile(directPath)
   } catch {
     // 继续用目录候选纠错。
   }
@@ -167,7 +170,7 @@ export async function readMarkdownResource(
   const singleMatch = pickSingleMatch(name, files)
   if (singleMatch) {
     try {
-      return await readFile(singleMatch.path)
+      return await readTextFile(singleMatch.path)
     } catch {
       return `错误:已匹配到${label}「${singleMatch.name}」,但无法读取文件,请确认文件存在`
     }

+ 13 - 6
src/lib/agent/tools/read-memory.ts

@@ -1,5 +1,5 @@
 import type { Tool } from "../types"
-import { readMarkdownResource } from "./read-markdown-resource"
+import { readMarkdownResource, type ReadTextFile } from "./read-markdown-resource"
 import { readFile } from "@/commands/fs"
 
 const MEMORY_ALIAS_FILES: Array<{ fileName: string; label: string; patterns: RegExp[] }> = [
@@ -52,14 +52,18 @@ function resolveMemoryAliasFiles(name: string): Array<{ fileName: string; label:
   })
 }
 
-async function readMemoryAliases(memoryDir: string, name: string): Promise<string | null> {
+async function readMemoryAliases(
+  memoryDir: string,
+  name: string,
+  readTextFile: ReadTextFile,
+): Promise<string | null> {
   const aliasFiles = resolveMemoryAliasFiles(name)
   if (aliasFiles.length === 0) return null
 
   const sections: string[] = []
   for (const alias of aliasFiles) {
     try {
-      const content = await readFile(`${memoryDir}/${alias.fileName}`)
+      const content = await readTextFile(`${memoryDir}/${alias.fileName}`)
       if (content.trim()) {
         sections.push(`## ${alias.label}\n\n${content}`)
       }
@@ -72,7 +76,10 @@ async function readMemoryAliases(memoryDir: string, name: string): Promise<strin
   return `已读取记忆条目「${name}」对应的结构化记忆:\n\n${sections.join("\n\n---\n\n")}`
 }
 
-export function createReadMemoryTool(memoryDir: string): Tool {
+export function createReadMemoryTool(
+  memoryDir: string,
+  readTextFile: ReadTextFile = readFile,
+): Tool {
   return {
     name: "read_memory",
     description: "读取记忆库中的指定条目内容。参数 name 为记忆条目名称,或 path 为完整文件路径。",
@@ -85,10 +92,10 @@ export function createReadMemoryTool(memoryDir: string): Tool {
       const hasExplicitPath = typeof params.path === "string" && params.path.trim()
       const name = typeof params.name === "string" ? params.name.trim() : ""
       if (!hasExplicitPath && name) {
-        const aliasResult = await readMemoryAliases(memoryDir, name)
+        const aliasResult = await readMemoryAliases(memoryDir, name, readTextFile)
         if (aliasResult) return aliasResult
       }
-      return readMarkdownResource(memoryDir, params, "记忆条目")
+      return readMarkdownResource(memoryDir, params, "记忆条目", readTextFile)
     },
   }
 }

+ 12 - 6
src/lib/agent/tools/read-outline.ts

@@ -1,5 +1,5 @@
 import type { Tool } from "../types"
-import { readMarkdownResource } from "./read-markdown-resource"
+import { readMarkdownResource, type ReadTextFile } from "./read-markdown-resource"
 import { listDirectory, readFile } from "@/commands/fs"
 
 function deriveProjectPathFromOutlinesDir(outlinesDir: string): string | null {
@@ -13,7 +13,10 @@ function outlineSnapshotOrder(name: string): number {
   return match?.[1] ? Number.parseInt(match[1], 10) : Number.MAX_SAFE_INTEGER
 }
 
-async function readOutlineSnapshots(outlinesDir: string): Promise<string | null> {
+async function readOutlineSnapshots(
+  outlinesDir: string,
+  readTextFile: ReadTextFile,
+): Promise<string | null> {
   const projectPath = deriveProjectPathFromOutlinesDir(outlinesDir)
   if (!projectPath) return null
 
@@ -35,7 +38,7 @@ async function readOutlineSnapshots(outlinesDir: string): Promise<string | null>
   const sections: string[] = []
   for (const file of files) {
     try {
-      const content = await readFile(file.path)
+      const content = await readTextFile(file.path)
       if (content.trim()) {
         sections.push(`## ${file.name}\n\n${content}`)
       }
@@ -52,7 +55,10 @@ async function readOutlineSnapshots(outlinesDir: string): Promise<string | null>
   ].join("\n")
 }
 
-export function createReadOutlineTool(outlinesDir: string): Tool {
+export function createReadOutlineTool(
+  outlinesDir: string,
+  readTextFile: ReadTextFile = readFile,
+): Tool {
   return {
     name: "read_outline",
     description: "读取指定大纲文件的完整内容。参数 path 为大纲文件的完整路径,或 name 为大纲名称。",
@@ -62,7 +68,7 @@ export function createReadOutlineTool(outlinesDir: string): Tool {
       path: { type: "string", description: "大纲文件完整路径(可选,与 name 二选一)" },
     },
     execute: async (params) => {
-      const result = await readMarkdownResource(outlinesDir, params, "大纲")
+      const result = await readMarkdownResource(outlinesDir, params, "大纲", readTextFile)
       if (!result.startsWith("错误:无法读取大纲")) return result
 
       const name = typeof params.name === "string" ? params.name : ""
@@ -70,7 +76,7 @@ export function createReadOutlineTool(outlinesDir: string): Tool {
       const broadOutlineRequest = /大纲|outline/i.test(`${name} ${path}`)
       if (!broadOutlineRequest) return result
 
-      return (await readOutlineSnapshots(outlinesDir)) ?? result
+      return (await readOutlineSnapshots(outlinesDir, readTextFile)) ?? result
     },
   }
 }

+ 6 - 2
src/lib/agent/tools/search-chapters.ts

@@ -1,5 +1,6 @@
 import type { Tool } from "../types"
 import { listDirectory, readFile } from "@/commands/fs"
+import type { ReadTextFile } from "./read-markdown-resource"
 
 interface ChapterSearchMatch {
   chapterName: string
@@ -29,7 +30,10 @@ function createLeadingSnippet(content: string): string {
   return normalized.length > 160 ? `${normalized.slice(0, 160)}...` : normalized
 }
 
-export function createSearchChaptersTool(chaptersDir: string): Tool {
+export function createSearchChaptersTool(
+  chaptersDir: string,
+  readTextFile: ReadTextFile = readFile,
+): Tool {
   return {
     name: "search_chapters",
     description: "按关键词在所有章节中搜索匹配内容。参数 keyword 为搜索关键词。",
@@ -63,7 +67,7 @@ export function createSearchChaptersTool(chaptersDir: string): Tool {
 
         let content: string
         try {
-          content = await readFile(file.path)
+          content = await readTextFile(file.path)
         } catch {
           failedFiles.push(file.name)
           continue

+ 2 - 2
src/lib/agent/types.ts

@@ -1,5 +1,5 @@
 import type { LlmConfig } from "@/stores/wiki-store"
-import type { RequestOverrides } from "../llm-providers"
+import type { ChatMessage, RequestOverrides } from "../llm-providers"
 
 export interface ToolParameter {
   type: "string" | "number" | "boolean" | "object" | "array" | "integer"
@@ -133,7 +133,7 @@ export interface AgentRunCallbacks {
 
 export interface AgentMessage {
   role: "system" | "user" | "assistant" | "tool"
-  content: string
+  content: ChatMessage["content"]
   tool_calls?: { id: string; type: "function"; function: { name: string; arguments: string } }[]
   tool_call_id?: string
   name?: string

+ 65 - 0
src/lib/context-hub/agent-tools-cache.spec.ts

@@ -0,0 +1,65 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import { ToolRegistry } from "@/lib/agent/registry"
+import { registerAllBuiltInTools } from "@/lib/agent/tools"
+
+const listDirectory = vi.hoisted(() => vi.fn())
+
+vi.mock("@/commands/fs", () => ({
+  listDirectory,
+  readFile: vi.fn(async () => { throw new Error("不应调用默认读取") }),
+  writeFile: vi.fn(),
+  createDirectory: vi.fn(),
+}))
+
+describe("context hub read tools", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    listDirectory.mockResolvedValue([])
+  })
+
+  it("routes chapter, outline, memory and deduction reads through the injected reader", async () => {
+    const readTextFile = vi.fn(async (path: string) => `缓存内容:${path}`)
+    const registry = new ToolRegistry()
+    registerAllBuiltInTools(registry, {
+      wikiPath: "/project/wiki",
+      getSkillConfig: () => null,
+      getChatConversations: () => [],
+      getOutlineConversations: () => [],
+      readTextFile,
+      enabledToolNames: ["read_chapter", "read_outline", "read_memory", "read_deduction"],
+    })
+
+    await registry.get("read_chapter")!.execute({ path: "/project/wiki/chapters/1.md" })
+    await registry.get("read_outline")!.execute({ path: "/project/wiki/outlines/main.md" })
+    await registry.get("read_memory")!.execute({ path: "/project/wiki/memory/clue.md" })
+    await registry.get("read_deduction")!.execute({ path: "/project/.qmai/simulations/run.json" })
+
+    expect(readTextFile.mock.calls.map(([path]) => path)).toEqual([
+      "/project/wiki/chapters/1.md",
+      "/project/wiki/outlines/main.md",
+      "/project/wiki/memory/clue.md",
+      "/project/.qmai/simulations/run.json",
+    ])
+  })
+
+  it("routes chapter search content reads through the injected reader", async () => {
+    const readTextFile = vi.fn(async () => "车站里留下了旧车票。")
+    listDirectory.mockResolvedValue([
+      { name: "第1章.md", path: "/project/wiki/chapters/第1章.md", is_dir: false },
+    ])
+    const registry = new ToolRegistry()
+    registerAllBuiltInTools(registry, {
+      wikiPath: "/project/wiki",
+      getSkillConfig: () => null,
+      getChatConversations: () => [],
+      getOutlineConversations: () => [],
+      readTextFile,
+      enabledToolNames: ["search_chapters"],
+    })
+
+    const result = await registry.get("search_chapters")!.execute({ keyword: "旧车票" })
+
+    expect(result).toContain("旧车票")
+    expect(readTextFile).toHaveBeenCalledWith("/project/wiki/chapters/第1章.md")
+  })
+})

+ 38 - 0
src/lib/context-hub/ai-chat-integration.spec.ts

@@ -0,0 +1,38 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const source = readFileSync(resolve(__dirname, "../../components/chat/chat-panel.tsx"), "utf8")
+
+describe("AI chat context hub integration", () => {
+  it("prepares one context hub result and reuses its context pack in pre-plugins", () => {
+    expect(source).toContain("await contextHub.prepare({")
+    expect(source).toContain("buildContextPack: async () => contextHubResult.contextPack")
+    expect(source).toContain("contextPack = contextHubResult.contextPack")
+  })
+
+  it("uses cacheable system blocks and cache-aware read tools only for hub requests", () => {
+    expect(source).toContain("buildContextHubSystemContent(")
+    expect(source).toContain("prePluginResult?.finalSystemRulesPrompt?.trim()")
+    expect(source).toContain("contextHubSoftwareRules")
+    expect(source).toContain("readTextFile: contextHubResult.readFile")
+    expect(source).toContain("setConversationContextSummary")
+  })
+
+  it("does not resend full chat history when the system context contains a summary", () => {
+    expect(source).toContain("selectContextHistoryMessages(")
+    expect(source).toContain("contextHubResult?.sessionSummary")
+  })
+
+  it("persists a snapshot reference on the target assistant message", () => {
+    expect(source).toContain("await contextHub.saveSnapshot(assistantMessage.id, contextHubResult)")
+    expect(source).toContain("contextHubSnapshot")
+  })
+
+  it("clears the stale session summary before regenerating a chat answer", () => {
+    expect(source).toContain("const capturedConversationId = storeState.activeConversationId")
+    expect(source).toMatch(
+      /removeLastAssistantMessage\(\)[\s\S]{0,900}setConversationContextSummary\(capturedConversationId, undefined\)[\s\S]{0,300}handleSend\(/,
+    )
+  })
+})

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

@@ -0,0 +1,34 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const source = readFileSync(resolve(__dirname, "../../components/sources/outline-chat-panel.tsx"), "utf8")
+
+describe("AI outline context hub integration", () => {
+  it("prepares one shared context result in the main send flow", () => {
+    expect(source).toContain("await contextHub.prepare({")
+    expect(source).toContain('surface: "ai-outline"')
+    expect(source).toContain("forceRefresh,")
+  })
+
+  it("shares cacheable system content and cached reads with sub-agents", () => {
+    expect(source).toContain("buildOutlineRunSystemContent")
+    expect(source).toContain("readTextFile: contextHubResult.readFile")
+    expect(source).toContain("buildSessionContextSummary({")
+    expect(source).toContain("contextSummary?.text")
+  })
+
+  it("avoids stale or duplicated summaries in refresh and regeneration flows", () => {
+    expect(source).toContain("existingSummary: forceRefresh ? undefined : targetConversation?.contextSummary")
+    expect(source).toContain("summaryInSystem: true")
+    expect(source).toMatch(/task: lastUserRequest,[\s\S]{0,240}existingSummary: undefined,/)
+  })
+
+  it("persists snapshots for main send, resume, and regeneration", () => {
+    expect(source.match(/\.saveSnapshot\(/g)).toHaveLength(3)
+    expect(source).toContain("contextHubSnapshot")
+    expect(source).toContain("<ContextHubDetails")
+    expect(source).not.toContain("formatContextHubStatsForDetails")
+    expect(source).toContain("contextHub.saveSnapshot(`${messageId}:${runId}`, contextHubResult)")
+  })
+})

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

@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest"
+import type { ContextPack } from "@/lib/novel/context-engine"
+import { composeContext } from "./composer"
+
+function pack(overrides: Partial<ContextPack> = {}): ContextPack {
+  return {
+    task: "续写第二章",
+    chapterGoal: "主角发现第一条线索",
+    outline: "第一章:失踪\n第二章:旧车站\n第三章:追踪",
+    recentChapterContents: [],
+    recentSummaries: [],
+    previousChapterEnding: "列车驶入黑暗。",
+    characterStates: "林默:保持怀疑",
+    soulDoc: "克制、现实主义悬疑",
+    characterAuras: "",
+    cognitionStates: "",
+    foreshadowingStates: "旧车票尚未解释",
+    timeline: "第二天清晨",
+    relatedSettings: "旧车站已经停用十年",
+    canonRules: "死者不能复活",
+    writingStyle: "短句,限制视角",
+    searchResults: "",
+    graphSearchResults: "",
+    mustDo: "保留悬念",
+    mustAvoid: "揭露凶手",
+    nextChapterAdvice: "",
+    revisionDirectives: "",
+    ...overrides,
+  }
+}
+
+describe("composeContext", () => {
+  it("keeps the stable core byte-identical with fixed field ordering", () => {
+    const input = { contextPack: pack(), dependencies: { outline: 1 } }
+    const first = composeContext(input)
+    const second = composeContext(input)
+
+    expect(first.stableCore).toBe(second.stableCore)
+    expect(first.stableCore.indexOf("作品灵魂")).toBeLessThan(first.stableCore.indexOf("大纲骨架"))
+    expect(first.stableCore).not.toContain("updatedAt")
+  })
+
+  it("places explicit references ahead of automatically selected dynamic context", () => {
+    const result = composeContext({
+      contextPack: pack(),
+      dependencies: {},
+      referenceContext: ["@引用:人物/林默.md\n林默怕水"],
+    })
+
+    expect(result.dynamicContext.indexOf("@引用")).toBeLessThan(result.dynamicContext.indexOf("上一章结尾"))
+  })
+
+  it("expands to chapter originals when confidence is low", () => {
+    const result = composeContext({
+      contextPack: pack({ recentChapterContents: ["第一章原文"], searchResults: "补充检索" }),
+      dependencies: {},
+      confidence: 0.4,
+    })
+
+    expect(result.dynamicContext).toContain("第一章原文")
+    expect(result.dynamicContext).toContain("补充检索")
+    expect(result.stats.expanded).toBe(true)
+  })
+
+  it("trims low-priority search content before required task facts", () => {
+    const result = composeContext({
+      contextPack: pack({ searchResults: "低相关背景".repeat(500) }),
+      dependencies: {},
+      tokenBudget: 180,
+      confidence: 0.9,
+    })
+
+    expect(result.dynamicContext).toContain("续写第二章")
+    expect(result.dynamicContext).toContain("保留悬念")
+    expect(result.dynamicContext).not.toContain("低相关背景".repeat(100))
+  })
+
+  it("reduces a representative repeated-context request by at least 30 percent", () => {
+    const result = composeContext({
+      contextPack: pack({
+        recentChapterContents: Array.from({ length: 12 }, (_, index) => `第${index + 1}章原文:${"情节内容".repeat(500)}`),
+        recentSummaries: ["前情摘要:线索指向旧车站。"],
+        searchResults: "候选检索".repeat(500),
+      }),
+      sessionSummary: "当前会话已确认:继续第二章,不揭露凶手。",
+      dependencies: {},
+      confidence: 0.9,
+      tokenBudget: 6000,
+    })
+
+    expect(result.stats.estimatedSavedPercent).toBeGreaterThanOrEqual(30)
+  })
+})

+ 141 - 0
src/lib/context-hub/composer.ts

@@ -0,0 +1,141 @@
+import { contextPackToPrompt, type ContextPack } from "@/lib/novel/context-engine"
+import { estimateContextTokens } from "./token-estimator"
+import type { ContextHubStats } from "./types"
+
+export interface ComposeContextInput {
+  contextPack: ContextPack
+  sessionSummary?: string
+  dependencies: Record<string, number>
+  referenceContext?: string[]
+  confidence?: number
+  tokenBudget?: number
+}
+export interface ComposedContext {
+  stableCore: string
+  sessionSummary: string
+  dynamicContext: string
+  dependencies: Record<string, number>
+  stats: ContextHubStats
+}
+
+interface ContextFragment {
+  title: string
+  text: string
+  required?: boolean
+}
+
+function section(title: string, text: string): string {
+  const value = text.trim()
+  return value ? `### ${title}\n${value}` : ""
+}
+
+function joinSections(fragments: ContextFragment[]): string {
+  return fragments
+    .map((fragment) => section(fragment.title, fragment.text))
+    .filter(Boolean)
+    .join("\n\n")
+}
+
+function stableFragments(pack: ContextPack): ContextFragment[] {
+  return [
+    { title: "作品灵魂", text: pack.soulDoc },
+    { title: "硬性世界规则", text: pack.canonRules },
+    { title: "核心设定", text: pack.relatedSettings },
+    { title: "写作风格", text: pack.writingStyle },
+    { title: "大纲骨架", text: pack.outline },
+  ]
+}
+
+function dynamicFragments(input: ComposeContextInput, expanded: boolean): ContextFragment[] {
+  const pack = input.contextPack
+  const references = (input.referenceContext ?? []).map((value, index) => ({
+    title: `显式引用 ${index + 1}`,
+    text: value,
+    required: true,
+  }))
+  const required: ContextFragment[] = [
+    ...references,
+    { title: "本轮任务", text: pack.task, required: true },
+    { title: "章节目标", text: pack.chapterGoal, required: true },
+    { title: "必须做到", text: pack.mustDo, required: true },
+    { title: "必须避免", text: pack.mustAvoid, required: true },
+    { title: "小节简报", text: pack.sectionBriefing ?? "", required: true },
+    { title: "上一章结尾", text: pack.previousChapterEnding },
+    { title: "人物当前状态", text: pack.characterStates },
+    { title: "伏笔状态", text: pack.foreshadowingStates },
+    { title: "最近摘要", text: pack.recentSummaries.slice(-3).join("\n") },
+    { title: "修订要求", text: pack.revisionDirectives },
+  ]
+  const optional: ContextFragment[] = [
+    { title: "时间线", text: pack.timeline },
+    { title: "人物认知", text: pack.cognitionStates },
+    { title: "人物气质", text: pack.characterAuras },
+    { title: "下一章建议", text: pack.nextChapterAdvice },
+    { title: "任务检索命中", text: pack.searchResults },
+    { title: "关系图检索命中", text: pack.graphSearchResults },
+  ]
+  if (expanded) {
+    optional.unshift({
+      title: "低置信度扩展章节原文",
+      text: (pack.recentChapterContents ?? []).join("\n\n"),
+    })
+  }
+  return [...required, ...optional]
+}
+
+function applyBudget(
+  fragments: ContextFragment[],
+  availableTokens: number,
+): ContextFragment[] {
+  const selected: ContextFragment[] = []
+  let used = 0
+  for (const fragment of fragments) {
+    if (!fragment.text.trim()) continue
+    const tokens = estimateContextTokens(section(fragment.title, fragment.text))
+    if (fragment.required || used + tokens <= availableTokens) {
+      selected.push(fragment)
+      used += tokens
+    }
+  }
+  return selected
+}
+
+export function composeContext(input: ComposeContextInput): ComposedContext {
+  const stableCore = joinSections(stableFragments(input.contextPack))
+  const sessionSummary = input.sessionSummary?.trim() ?? ""
+  const expanded = (input.confidence ?? 0.8) < 0.6
+  const tokenBudget = Math.max(0, input.tokenBudget ?? 16_000)
+  const stableTokens = estimateContextTokens(stableCore)
+  const summaryTokens = estimateContextTokens(sessionSummary)
+  const availableDynamicTokens = Math.max(0, tokenBudget - stableTokens - summaryTokens)
+  const dynamicContext = joinSections(
+    applyBudget(dynamicFragments(input, expanded), availableDynamicTokens),
+  )
+  const dynamicTokens = estimateContextTokens(dynamicContext)
+  const candidateTokens = estimateContextTokens(contextPackToPrompt(input.contextPack))
+  const composedTokens = stableTokens + summaryTokens + dynamicTokens
+  const estimatedSavedTokens = Math.max(0, candidateTokens - composedTokens)
+  const estimatedSavedPercent = candidateTokens > 0
+    ? Math.round((estimatedSavedTokens / candidateTokens) * 100)
+    : 0
+
+  return {
+    stableCore,
+    sessionSummary,
+    dynamicContext,
+    dependencies: { ...input.dependencies },
+    stats: {
+      hits: 0,
+      refreshed: 0,
+      failures: 0,
+      stableTokens,
+      summaryTokens,
+      dynamicTokens,
+      candidateTokens,
+      estimatedSavedTokens,
+      estimatedSavedPercent,
+      expanded,
+      providerCacheEnabled: stableCore.length > 0,
+    },
+  }
+}

+ 234 - 0
src/lib/context-hub/context-hub.spec.ts

@@ -0,0 +1,234 @@
+import { describe, expect, it, vi } from "vitest"
+import type { ContextPack } from "@/lib/novel/context-engine"
+import { ContextHubController } from "./context-hub"
+import type { CachedArtifact, ContextHubSnapshot, StableBundle } from "./types"
+
+function pack(): ContextPack {
+  return {
+    task: "生成大纲",
+    chapterGoal: "建立第一幕冲突",
+    outline: "第一幕:失踪",
+    recentChapterContents: [],
+    recentSummaries: [],
+    previousChapterEnding: "",
+    characterStates: "",
+    soulDoc: "现实主义悬疑",
+    characterAuras: "",
+    cognitionStates: "",
+    foreshadowingStates: "",
+    timeline: "",
+    relatedSettings: "旧车站",
+    canonRules: "",
+    writingStyle: "克制",
+    searchResults: "",
+    graphSearchResults: "",
+    mustDo: "",
+    mustAvoid: "",
+    nextChapterAdvice: "",
+    revisionDirectives: "",
+  }
+}
+
+function createHarness() {
+  const artifacts = new Map<string, CachedArtifact>()
+  const bundles = new Map<string, StableBundle>()
+  const snapshots = new Map<string, ContextHubSnapshot>()
+  const registry = {
+    refresh: vi.fn(async () => ({ versions: {}, changedPaths: [] as string[] })),
+    getDependencies: vi.fn(() => ({ "E:/Novel/wiki/outlines/main.md": 1 })),
+    markDirty: vi.fn(),
+    dispose: vi.fn(),
+  }
+  const storage = {
+    readArtifact: vi.fn(async (key: string) => artifacts.get(key) ?? null),
+    writeArtifact: vi.fn(async (key: string, value: CachedArtifact) => { artifacts.set(key, value) }),
+    readStableBundle: vi.fn(async (surface: string) => bundles.get(surface) ?? null),
+    writeStableBundle: vi.fn(async (surface: string, value: StableBundle) => { bundles.set(surface, value) }),
+    readSnapshot: vi.fn(async (_surface: string, id: string) => snapshots.get(id) ?? null),
+    writeSnapshot: vi.fn(async (value: ContextHubSnapshot) => { snapshots.set(value.id, value) }),
+    pruneSnapshots: vi.fn(async () => {}),
+  }
+  const buildContextPack = vi.fn(async () => pack())
+  const readFile = vi.fn(async (path: string) => `内容:${path}:${readFile.mock.calls.length}`)
+  const controller = new ContextHubController("E:/Novel", {
+    registry,
+    storage,
+    buildContextPack,
+    readFile,
+    subscribe: () => () => {},
+  })
+  return { controller, registry, storage, buildContextPack, readFile }
+}
+
+const request = {
+  projectPath: "E:/Novel",
+  surface: "ai-chat" as const,
+  sessionId: "chat-1",
+  task: "生成大纲",
+  intent: "generate" as const,
+}
+
+describe("ContextHubController", () => {
+  it("bypasses review and lint intents", async () => {
+    const harness = createHarness()
+
+    await expect(harness.controller.prepare({ ...request, intent: "review" })).resolves.toBeNull()
+    await expect(harness.controller.prepare({ ...request, intent: "lint" })).resolves.toBeNull()
+    expect(harness.registry.refresh).not.toHaveBeenCalled()
+    expect(harness.buildContextPack).not.toHaveBeenCalled()
+  })
+
+  it("isolates the provided session summary for each request", async () => {
+    const harness = createHarness()
+    const chat = await harness.controller.prepare({
+      ...request,
+      existingSummary: { text: "AI 对话摘要", dependencies: { "E:/Novel/wiki/outlines/main.md": 1 }, updatedAt: 1 },
+    })
+    const outline = await harness.controller.prepare({
+      ...request,
+      surface: "ai-outline",
+      sessionId: "outline-1",
+      existingSummary: { text: "AI 大纲摘要", dependencies: { "E:/Novel/wiki/outlines/main.md": 1 }, updatedAt: 1 },
+    })
+
+    expect(chat?.sessionSummary).toBe("AI 对话摘要")
+    expect(outline?.sessionSummary).toBe("AI 大纲摘要")
+  })
+
+  it("does not reuse an existing summary during a forced refresh", async () => {
+    const harness = createHarness()
+
+    const result = await harness.controller.prepare({
+      ...request,
+      forceRefresh: true,
+      existingSummary: { text: "旧摘要", dependencies: { "E:/Novel/wiki/outlines/main.md": 1 }, updatedAt: 1 },
+    })
+
+    expect(result?.sessionSummary).toBe("")
+  })
+
+  it("deduplicates an identical concurrent prepare", async () => {
+    const harness = createHarness()
+
+    await Promise.all([
+      harness.controller.prepare(request),
+      harness.controller.prepare(request),
+    ])
+
+    expect(harness.buildContextPack).toHaveBeenCalledOnce()
+  })
+
+  it("reports the stable cache item with project-relative dependency paths", async () => {
+    const harness = createHarness()
+
+    const first = await harness.controller.prepare(request)
+    const second = await harness.controller.prepare({ ...request, task: "继续生成大纲" })
+
+    expect(first?.cacheItems).toEqual([
+      expect.objectContaining({
+        sourceName: "stableCore",
+        status: "refreshed",
+        dependencyPaths: ["wiki/outlines/main.md"],
+      }),
+    ])
+    expect(second?.cacheItems).toEqual([
+      expect.objectContaining({
+        sourceName: "stableCore",
+        status: "hit",
+        dependencyPaths: ["wiki/outlines/main.md"],
+      }),
+    ])
+  })
+
+  it("removes a Windows project root from dependency paths case-insensitively", async () => {
+    const harness = createHarness()
+    harness.registry.getDependencies.mockReturnValue({
+      "e:/Novel/wiki/outlines/main.md": 1,
+    })
+
+    const result = await harness.controller.prepare(request)
+
+    expect(result?.cacheItems[0].dependencyPaths).toEqual(["wiki/outlines/main.md"])
+  })
+
+  it("persists the exact composed context and returns a lightweight snapshot reference", async () => {
+    const harness = createHarness()
+    const result = await harness.controller.prepare(request)
+
+    const reference = await harness.controller.saveSnapshot("assistant:1", result!)
+
+    expect(reference).toMatchObject({
+      id: "assistant:1",
+      surface: "ai-chat",
+      stats: result?.stats,
+    })
+    expect(reference).not.toHaveProperty("stableCore")
+    expect(reference).not.toHaveProperty("items")
+    await expect(harness.controller.readSnapshot(reference)).resolves.toMatchObject({
+      items: result?.cacheItems,
+      stableCore: result?.stableCore,
+      sessionSummary: result?.sessionSummary,
+      dynamicContext: result?.dynamicContext,
+    })
+  })
+
+  it("returns the lightweight reference when snapshot persistence fails", async () => {
+    const harness = createHarness()
+    const result = await harness.controller.prepare(request)
+    harness.storage.writeSnapshot.mockRejectedValueOnce(new Error("磁盘写入失败"))
+
+    const reference = await harness.controller.saveSnapshot("assistant:failed", result!)
+
+    expect(reference).toMatchObject({
+      id: "assistant:failed",
+      stats: result?.stats,
+    })
+    expect(reference).not.toHaveProperty("items")
+    await expect(harness.controller.readSnapshot(reference)).resolves.toBeNull()
+  })
+
+  it("rejects a snapshot whose creation time does not match the persisted reference", async () => {
+    const harness = createHarness()
+    const result = await harness.controller.prepare(request)
+    const reference = await harness.controller.saveSnapshot("assistant:versioned", result!)
+
+    await expect(harness.controller.readSnapshot({ ...reference, createdAt: reference.createdAt + 1 }))
+      .resolves.toBeNull()
+  })
+
+  it("uses a read-through cache and evicts a dirty file", async () => {
+    const harness = createHarness()
+    const path = "E:/Novel/wiki/chapters/1.md"
+
+    const first = await harness.controller.readFile(path)
+    const second = await harness.controller.readFile(path)
+    harness.controller.markDirty(path)
+    const third = await harness.controller.readFile(path)
+
+    expect(first).toBe(second)
+    expect(third).not.toBe(second)
+    expect(harness.readFile).toHaveBeenCalledTimes(2)
+  })
+
+  it("evicts read-through entries changed by the external refresh", async () => {
+    const harness = createHarness()
+    const path = "E:/Novel/wiki/chapters/1.md"
+    await harness.controller.readFile(path)
+    harness.registry.refresh.mockResolvedValueOnce({ versions: {}, changedPaths: [path] })
+
+    await harness.controller.prepare(request)
+    await harness.controller.readFile(path)
+
+    expect(harness.readFile).toHaveBeenCalledTimes(2)
+  })
+
+  it("returns null so the caller can use its unchanged path when cache validation fails", async () => {
+    const harness = createHarness()
+    harness.registry.refresh.mockRejectedValueOnce(new Error("manifest 损坏"))
+
+    const result = await harness.controller.prepare(request)
+
+    expect(result).toBeNull()
+    expect(harness.buildContextPack).not.toHaveBeenCalled()
+  })
+})

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

@@ -0,0 +1,318 @@
+import {
+  readFile as readProjectFile,
+  subscribeProjectFileMutations,
+  type ProjectFileMutation,
+} from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import type { DataSourceLoadAdapter } from "@/lib/novel/context-data-source"
+import { buildContextPack as buildProjectContextPack, type ContextPack } from "@/lib/novel/context-engine"
+import type { DataSourceCategory } from "@/lib/novel/classification"
+import { composeContext } from "./composer"
+import { DataSourceCacheAdapter } from "./data-source-cache"
+import { isSessionSummaryFresh } from "./session-summary"
+import { normalizeContextPath } from "./source-paths"
+import { ContextSourceRegistry, type SourceRefreshResult } from "./source-registry"
+import { ContextHubStorage } from "./storage"
+import {
+  CONTEXT_CACHE_SCHEMA_VERSION,
+  type CachedArtifact,
+  type ContextCacheItemTrace,
+  type ContextHub,
+  type ContextHubRequest,
+  type ContextHubResult,
+  type ContextHubSnapshot,
+  type ContextHubSnapshotRef,
+  type ContextSourceKind,
+  type StableBundle,
+} from "./types"
+
+interface HubRegistry {
+  refresh(): Promise<SourceRefreshResult>
+  getDependencies(kinds?: ContextSourceKind[]): Record<string, number>
+  markDirty(path: string): void
+  dispose(): void
+}
+
+interface HubStorage {
+  readArtifact<T>(key: string): Promise<CachedArtifact<T> | null>
+  writeArtifact<T>(key: string, artifact: CachedArtifact<T>): Promise<void>
+  readStableBundle(surface: ContextHubRequest["surface"]): Promise<StableBundle | null>
+  writeStableBundle(surface: ContextHubRequest["surface"], bundle: StableBundle): Promise<void>
+  readSnapshot(surface: ContextHubRequest["surface"], id: string): Promise<ContextHubSnapshot | null>
+  writeSnapshot(snapshot: ContextHubSnapshot): Promise<void>
+  pruneSnapshots(surface: ContextHubRequest["surface"], referencedIds: string[]): Promise<void>
+}
+
+type BuildContextPack = (
+  projectPath: string,
+  task: string,
+  chapterNumber?: number,
+  options?: { categories?: DataSourceCategory[]; loadAdapter?: DataSourceLoadAdapter },
+) => Promise<ContextPack>
+
+export interface ContextHubControllerDependencies {
+  registry?: HubRegistry
+  storage?: HubStorage
+  buildContextPack?: BuildContextPack
+  readFile?: (path: string) => Promise<string>
+  subscribe?: (listener: (event: ProjectFileMutation) => void) => () => void
+}
+
+function dependenciesMatch(
+  left: Record<string, number>,
+  right: Record<string, number>,
+): boolean {
+  const entries = Object.entries(left)
+  return entries.length === Object.keys(right).length
+    && entries.every(([path, revision]) => right[path] === revision)
+}
+
+function confidenceFor(request: ContextHubRequest, pack: ContextPack): number {
+  if ((request.references?.length ?? 0) > 0) return 0.95
+  if (request.chapterNumber && !pack.chapterGoal.trim() && !pack.outline.trim()) return 0.45
+  if (!pack.outline.trim() && !pack.relatedSettings.trim() && !pack.searchResults.trim()) return 0.55
+  return 0.85
+}
+
+function prepareKey(request: ContextHubRequest): string {
+  return JSON.stringify({
+    surface: request.surface,
+    sessionId: request.sessionId,
+    task: request.task,
+    intent: request.intent,
+    chapterNumber: request.chapterNumber ?? null,
+    categories: request.categories ?? [],
+    references: request.references ?? [],
+    summary: request.existingSummary ?? null,
+    tokenBudget: request.tokenBudget ?? null,
+    forceRefresh: request.forceRefresh ?? false,
+  })
+}
+
+function toProjectRelativePath(projectPath: string, path: string): string {
+  const normalizedProject = normalizePath(projectPath).replace(/\/$/, "")
+  const normalizedPath = normalizeContextPath(path)
+  const prefix = `${normalizedProject}/`
+  const windowsPath = /^[A-Za-z]:\//.test(prefix) && /^[A-Za-z]:\//.test(normalizedPath)
+  const matchesProject = windowsPath
+    ? normalizedPath.toLowerCase().startsWith(prefix.toLowerCase())
+    : normalizedPath.startsWith(prefix)
+  return matchesProject ? normalizedPath.slice(prefix.length) : normalizedPath
+}
+
+function withRelativeDependencyPaths(
+  projectPath: string,
+  items: ContextCacheItemTrace[],
+): ContextCacheItemTrace[] {
+  return items.map((item) => ({
+    ...item,
+    dependencyPaths: item.dependencyPaths.map((path) => toProjectRelativePath(projectPath, path)),
+  }))
+}
+
+export class ContextHubController implements ContextHub {
+  private readonly projectPath: string
+  private readonly registry: HubRegistry
+  private readonly storage: HubStorage
+  private readonly buildContextPack: BuildContextPack
+  private readonly directReadFile: (path: string) => Promise<string>
+  private readonly unsubscribe: () => void
+  private readonly fileCache = new Map<string, string>()
+  private readonly pending = new Map<string, Promise<ContextHubResult | null>>()
+
+  constructor(projectPath: string, dependencies: ContextHubControllerDependencies = {}) {
+    this.projectPath = normalizePath(projectPath)
+    const concreteStorage = dependencies.storage ?? new ContextHubStorage(this.projectPath)
+    this.storage = concreteStorage
+    this.registry = dependencies.registry ?? new ContextSourceRegistry(this.projectPath, {
+      storage: concreteStorage as ContextHubStorage,
+    })
+    this.buildContextPack = dependencies.buildContextPack ?? buildProjectContextPack
+    this.directReadFile = dependencies.readFile ?? readProjectFile
+    const subscribe = dependencies.subscribe ?? subscribeProjectFileMutations
+    this.unsubscribe = subscribe((event) => this.markDirty(event.path))
+  }
+
+  prepare(request: ContextHubRequest): Promise<ContextHubResult | null> {
+    if (request.intent === "review" || request.intent === "lint") return Promise.resolve(null)
+    const key = prepareKey(request)
+    const pending = this.pending.get(key)
+    if (pending) return pending
+    const operation = this.prepareWithFallback(request).finally(() => this.pending.delete(key))
+    this.pending.set(key, operation)
+    return operation
+  }
+
+  async readFile(path: string): Promise<string> {
+    const normalized = normalizeContextPath(path)
+    const cached = this.fileCache.get(normalized)
+    if (cached !== undefined) return cached
+    const content = await this.directReadFile(normalized)
+    this.fileCache.set(normalized, content)
+    return content
+  }
+
+  async saveSnapshot(id: string, result: ContextHubResult): Promise<ContextHubSnapshotRef> {
+    const createdAt = Date.now()
+    const snapshot: ContextHubSnapshot = {
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      id,
+      surface: result.surface,
+      createdAt,
+      stats: { ...result.stats },
+      items: result.cacheItems.map((item) => ({
+        ...item,
+        dependencyPaths: [...item.dependencyPaths],
+      })),
+      stableCore: result.stableCore,
+      sessionSummary: result.sessionSummary,
+      dynamicContext: result.dynamicContext,
+    }
+    try {
+      await this.storage.writeSnapshot(snapshot)
+    } catch {
+      // The summary remains useful even when the optional full snapshot cannot be persisted.
+    }
+    return {
+      id,
+      surface: snapshot.surface,
+      createdAt,
+      stats: { ...snapshot.stats },
+    }
+  }
+
+  async readSnapshot(reference: ContextHubSnapshotRef): Promise<ContextHubSnapshot | null> {
+    const snapshot = await this.storage.readSnapshot(reference.surface, reference.id)
+    return snapshot?.createdAt === reference.createdAt ? snapshot : null
+  }
+
+  pruneSnapshots(surface: ContextHubRequest["surface"], referencedIds: string[]): Promise<void> {
+    return this.storage.pruneSnapshots(surface, referencedIds)
+  }
+
+  markDirty(path: string): void {
+    const normalized = normalizeContextPath(path)
+    this.fileCache.delete(normalized)
+    this.registry.markDirty(normalized)
+  }
+
+  dispose(): void {
+    this.unsubscribe()
+    this.registry.dispose()
+    this.fileCache.clear()
+    this.pending.clear()
+  }
+
+  private async prepareWithFallback(request: ContextHubRequest): Promise<ContextHubResult | null> {
+    try {
+      return await this.prepareCached(request)
+    } catch {
+      return null
+    }
+  }
+
+  private async prepareCached(request: ContextHubRequest): Promise<ContextHubResult> {
+    const refresh = await this.registry.refresh()
+    for (const path of refresh.changedPaths) this.fileCache.delete(normalizeContextPath(path))
+    const dependencies = this.registry.getDependencies()
+    const warnings: string[] = []
+    const cacheAdapter = new DataSourceCacheAdapter({
+      registry: this.registry,
+      storage: this.storage,
+      forceRefresh: request.forceRefresh,
+    })
+    const contextPack = await this.buildContextPack(
+      this.projectPath,
+      request.task,
+      request.chapterNumber,
+      {
+        ...(request.categories?.length ? { categories: request.categories } : {}),
+        loadAdapter: cacheAdapter,
+      },
+    )
+    const summaryFresh = !request.forceRefresh
+      && isSessionSummaryFresh(request.existingSummary, dependencies)
+    if (request.existingSummary && !summaryFresh) {
+      warnings.push("项目资料已更新,本轮未使用旧会话摘要。")
+    }
+    const composed = composeContext({
+      contextPack,
+      sessionSummary: summaryFresh ? request.existingSummary?.text : undefined,
+      dependencies,
+      referenceContext: request.references,
+      confidence: confidenceFor(request, contextPack),
+      tokenBudget: request.tokenBudget,
+    })
+    const cacheStats = cacheAdapter.getStats()
+    const cacheItems = cacheAdapter.getTraceItems()
+    let stableHits = 0
+    let stableRefreshes = 0
+    let stableFailures = 0
+    try {
+      const existing = await this.storage.readStableBundle(request.surface)
+      if (
+        existing
+        && existing.text === composed.stableCore
+        && dependenciesMatch(existing.dependencies, dependencies)
+      ) {
+        stableHits = 1
+        cacheItems.push({
+          key: `stable-core:${request.surface}`,
+          sourceName: "stableCore",
+          status: "hit",
+          dependencyPaths: Object.keys(dependencies),
+        })
+      } else {
+        await this.storage.writeStableBundle(request.surface, {
+          schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+          surface: request.surface,
+          text: composed.stableCore,
+          dependencies,
+          updatedAt: Date.now(),
+        })
+        stableRefreshes = 1
+        cacheItems.push({
+          key: `stable-core:${request.surface}`,
+          sourceName: "stableCore",
+          status: "refreshed",
+          dependencyPaths: Object.keys(dependencies),
+        })
+      }
+    } catch {
+      stableFailures = 1
+      cacheItems.push({
+        key: `stable-core:${request.surface}`,
+        sourceName: "stableCore",
+        status: "failed",
+        dependencyPaths: Object.keys(dependencies),
+      })
+      warnings.push("稳定上下文缓存写入失败,本轮已继续使用内存中的最新内容。")
+    }
+
+    return {
+      ...composed,
+      surface: request.surface,
+      contextPack,
+      stats: {
+        ...composed.stats,
+        hits: cacheStats.hits + stableHits,
+        refreshed: cacheStats.refreshed + stableRefreshes,
+        failures: cacheStats.failures + stableFailures,
+      },
+      cacheItems: withRelativeDependencyPaths(this.projectPath, cacheItems),
+      warnings,
+      readFile: (path) => this.readFile(path),
+    }
+  }
+}
+
+const projectHubs = new Map<string, ContextHubController>()
+
+export function getContextHub(projectPath: string): ContextHubController {
+  const normalized = normalizePath(projectPath)
+  const existing = projectHubs.get(normalized)
+  if (existing) return existing
+  const hub = new ContextHubController(normalized)
+  projectHubs.set(normalized, hub)
+  return hub
+}

+ 119 - 0
src/lib/context-hub/data-source-cache.spec.ts

@@ -0,0 +1,119 @@
+import { describe, expect, it, vi } from "vitest"
+import type { ContextLoadContext, DataSource } from "@/lib/novel/context-data-source"
+import { DataSourceCacheAdapter } from "./data-source-cache"
+import type { CachedArtifact, ContextSourceKind } from "./types"
+
+const context: ContextLoadContext = {
+  projectPath: "E:/Novel",
+  task: "续写第2章",
+  chapterNumber: 2,
+  config: {
+    recentSummaryWindow: 8,
+    searchTopK: 5,
+    snapshotLookback: 3,
+    revisionFeedbackWindowConfig: {},
+  },
+}
+
+function createHarness() {
+  const artifacts = new Map<string, CachedArtifact>()
+  const revisions: Partial<Record<ContextSourceKind, Record<string, number>>> = {
+    chapter: { "E:/Novel/wiki/chapters/1.md": 1 },
+    outline: { "E:/Novel/wiki/outlines/main.md": 1 },
+    setting: { "E:/Novel/wiki/settings/world.md": 1 },
+    entity: {},
+  }
+  const registry = {
+    refresh: vi.fn(async () => ({ versions: {}, changedPaths: [] })),
+    getDependencies: vi.fn((kinds?: ContextSourceKind[]) => Object.assign(
+      {},
+      ...(kinds ?? []).map((kind) => revisions[kind] ?? {}),
+    )),
+  }
+  const storage = {
+    readArtifact: vi.fn(async (key: string) => artifacts.get(key) ?? null),
+    writeArtifact: vi.fn(async (key: string, value: CachedArtifact) => { artifacts.set(key, value) }),
+  }
+  return { adapter: new DataSourceCacheAdapter({ registry, storage }), revisions, registry, storage }
+}
+
+describe("DataSourceCacheAdapter", () => {
+  it("hits a persisted artifact for an unchanged repeated load", async () => {
+    const harness = createHarness()
+    const source: DataSource<string> = { name: "outline", priority: 1, load: async () => "" }
+    const directLoad = vi.fn(async () => "大纲")
+
+    await expect(harness.adapter.load(source, context, directLoad)).resolves.toBe("大纲")
+    await expect(harness.adapter.load(source, context, directLoad)).resolves.toBe("大纲")
+
+    expect(directLoad).toHaveBeenCalledOnce()
+    expect(harness.adapter.getStats()).toMatchObject({ hits: 1, refreshed: 1, failures: 0 })
+    expect(harness.adapter.getTraceItems()).toEqual([
+      expect.objectContaining({
+        sourceName: "outline",
+        status: "refreshed",
+        dependencyPaths: ["E:/Novel/wiki/outlines/main.md"],
+      }),
+      expect.objectContaining({
+        sourceName: "outline",
+        status: "hit",
+        dependencyPaths: ["E:/Novel/wiki/outlines/main.md"],
+      }),
+    ])
+  })
+
+  it("refreshes only an artifact whose dependencies changed", async () => {
+    const harness = createHarness()
+    const chapterSource: DataSource<string> = { name: "recentChapterContents", priority: 1, load: async () => "" }
+    const settingSource: DataSource<string> = { name: "relatedSettings", priority: 1, load: async () => "" }
+    const loadChapter = vi.fn(async () => "章节")
+    const loadSetting = vi.fn(async () => "设定")
+    await harness.adapter.load(chapterSource, context, loadChapter)
+    await harness.adapter.load(settingSource, context, loadSetting)
+    harness.revisions.chapter!["E:/Novel/wiki/chapters/1.md"] = 2
+
+    await harness.adapter.load(chapterSource, context, loadChapter)
+    await harness.adapter.load(settingSource, context, loadSetting)
+
+    expect(loadChapter).toHaveBeenCalledTimes(2)
+    expect(loadSetting).toHaveBeenCalledOnce()
+  })
+
+  it("deduplicates concurrent rebuilds for the same key", async () => {
+    const harness = createHarness()
+    const source: DataSource<string> = { name: "outline", priority: 1, load: async () => "" }
+    const directLoad = vi.fn(async () => "大纲")
+
+    await Promise.all([
+      harness.adapter.load(source, context, directLoad),
+      harness.adapter.load(source, context, directLoad),
+    ])
+
+    expect(directLoad).toHaveBeenCalledOnce()
+  })
+
+  it("does not register empty values as cache hits", async () => {
+    const harness = createHarness()
+    const source: DataSource<string> = { name: "outline", priority: 1, load: async () => "" }
+    const directLoad = vi.fn(async () => "")
+
+    await harness.adapter.load(source, context, directLoad)
+    await harness.adapter.load(source, context, directLoad)
+
+    expect(directLoad).toHaveBeenCalledTimes(2)
+    expect(harness.storage.writeArtifact).not.toHaveBeenCalled()
+  })
+
+  it("returns fresh data when cache writes fail", async () => {
+    const harness = createHarness()
+    harness.storage.writeArtifact.mockRejectedValue(new Error("磁盘已满"))
+    const source: DataSource<string> = { name: "outline", priority: 1, load: async () => "" }
+
+    await expect(harness.adapter.load(source, context, async () => "新大纲")).resolves.toBe("新大纲")
+    expect(harness.adapter.getStats().failures).toBe(1)
+    expect(harness.adapter.getTraceItems().map((item) => item.status)).toEqual([
+      "refreshed",
+      "failed",
+    ])
+  })
+})

+ 180 - 0
src/lib/context-hub/data-source-cache.ts

@@ -0,0 +1,180 @@
+import type {
+  ContextLoadContext,
+  DataSource,
+  DataSourceLoadAdapter,
+} from "@/lib/novel/context-data-source"
+import { getDataSourceKinds } from "./source-paths"
+import {
+  CONTEXT_CACHE_SCHEMA_VERSION,
+  type CachedArtifact,
+  type ContextCacheItemTrace,
+  type ContextSourceKind,
+} from "./types"
+
+interface DataSourceCacheRegistry {
+  refresh(): Promise<unknown>
+  getDependencies(kinds?: ContextSourceKind[]): Record<string, number>
+}
+
+interface DataSourceCacheStorage {
+  readArtifact<T>(key: string): Promise<CachedArtifact<T> | null>
+  writeArtifact<T>(key: string, artifact: CachedArtifact<T>): Promise<void>
+}
+
+export interface DataSourceCacheAdapterOptions {
+  registry: DataSourceCacheRegistry
+  storage: DataSourceCacheStorage
+  forceRefresh?: boolean
+}
+
+export interface DataSourceCacheStats {
+  hits: number
+  refreshed: number
+  failures: number
+}
+
+const STATIC_SOURCES = new Set([
+  "canonRules",
+  "writingStyle",
+  "soulDoc",
+  "characterAuras",
+  "storyFrameworkBinding",
+])
+
+const CHAPTER_SCOPED_SOURCES = new Set([
+  "outline",
+  "chapterOutline",
+  "volumeContext",
+  "snapshots",
+  "recentChapterContents",
+  "fallbackRecentSummaries",
+  "fallbackPreviousEnding",
+  "fallbackCharacterStates",
+  "fallbackForeshadowingStates",
+  "fallbackTimeline",
+  "revisionFeedback",
+  "cognitionText",
+  "sectionBriefing",
+])
+
+function canonicalize(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(canonicalize)
+  if (!value || typeof value !== "object") return value
+  return Object.fromEntries(
+    Object.entries(value as Record<string, unknown>)
+      .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
+      .map(([key, child]) => [key, canonicalize(child)]),
+  )
+}
+
+function hashText(value: string): string {
+  let hash = 0x811c9dc5
+  for (let index = 0; index < value.length; index += 1) {
+    hash ^= value.charCodeAt(index)
+    hash = Math.imul(hash, 0x01000193)
+  }
+  return (hash >>> 0).toString(16).padStart(8, "0")
+}
+
+function sourceRequestKey(sourceName: string, context: ContextLoadContext): string {
+  const scope = STATIC_SOURCES.has(sourceName)
+    ? {}
+    : CHAPTER_SCOPED_SOURCES.has(sourceName)
+      ? { chapterNumber: context.chapterNumber ?? null, config: context.config }
+      : { task: context.task, chapterNumber: context.chapterNumber ?? null, config: context.config }
+  return `data-source:${sourceName}:${hashText(JSON.stringify(canonicalize(scope)))}`
+}
+
+function dependenciesMatch(
+  cached: Record<string, number>,
+  current: Record<string, number>,
+): boolean {
+  const cachedEntries = Object.entries(cached)
+  const currentEntries = Object.entries(current)
+  return cachedEntries.length === currentEntries.length
+    && cachedEntries.every(([path, revision]) => current[path] === revision)
+}
+
+function hasCacheableValue(value: unknown): boolean {
+  if (typeof value === "string") return value.trim().length > 0
+  if (Array.isArray(value)) return value.length > 0
+  if (value && typeof value === "object") return Object.keys(value).length > 0
+  return value !== null && value !== undefined
+}
+
+export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
+  private readonly pending = new Map<string, Promise<unknown>>()
+  private readonly stats: DataSourceCacheStats = { hits: 0, refreshed: 0, failures: 0 }
+  private readonly traceItems: ContextCacheItemTrace[] = []
+
+  constructor(private readonly options: DataSourceCacheAdapterOptions) {}
+
+  async load<T>(
+    source: DataSource<T>,
+    context: ContextLoadContext,
+    directLoad: () => Promise<T>,
+  ): Promise<T> {
+    await this.options.registry.refresh()
+    const dependencies = this.options.registry.getDependencies(getDataSourceKinds(source.name))
+    const key = sourceRequestKey(source.name, context)
+    const pending = this.pending.get(key)
+    if (pending) return pending as Promise<T>
+
+    const operation = this.loadInternal(key, source.name, dependencies, directLoad)
+      .finally(() => this.pending.delete(key))
+    this.pending.set(key, operation)
+    return operation
+  }
+
+  getStats(): DataSourceCacheStats {
+    return { ...this.stats }
+  }
+
+  getTraceItems(): ContextCacheItemTrace[] {
+    return this.traceItems.map((item) => ({
+      ...item,
+      dependencyPaths: [...item.dependencyPaths],
+    }))
+  }
+
+  private async loadInternal<T>(
+    key: string,
+    sourceName: string,
+    dependencies: Record<string, number>,
+    directLoad: () => Promise<T>,
+  ): Promise<T> {
+    const dependencyPaths = Object.keys(dependencies)
+    if (!this.options.forceRefresh) {
+      try {
+        const cached = await this.options.storage.readArtifact<T>(key)
+        if (cached && dependenciesMatch(cached.dependencies, dependencies)) {
+          this.stats.hits += 1
+          this.traceItems.push({ key, sourceName, status: "hit", dependencyPaths })
+          return cached.value
+        }
+      } catch {
+        this.stats.failures += 1
+        this.traceItems.push({ key, sourceName, status: "failed", dependencyPaths })
+      }
+    }
+
+    const value = await directLoad()
+    this.stats.refreshed += 1
+    this.traceItems.push({ key, sourceName, status: "refreshed", dependencyPaths })
+    if (!hasCacheableValue(value)) return value
+
+    try {
+      await this.options.storage.writeArtifact(key, {
+        schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+        key,
+        value,
+        dependencies,
+        createdAt: Date.now(),
+      })
+    } catch {
+      this.stats.failures += 1
+      this.traceItems.push({ key, sourceName, status: "failed", dependencyPaths })
+    }
+    return value
+  }
+}

+ 19 - 0
src/lib/context-hub/index.ts

@@ -0,0 +1,19 @@
+export { ContextHubController, getContextHub } from "./context-hub"
+export {
+  buildSessionContextSummary,
+  isSessionSummaryFresh,
+  normalizeSessionContextSummary,
+  selectContextHistoryMessages,
+} from "./session-summary"
+export { buildContextHubSystemContent, flattenContextHubSystemContent } from "./prompt-content"
+export type {
+  ContextHub,
+  ContextHubRequest,
+  ContextHubResult,
+  ContextHubSnapshot,
+  ContextHubSnapshotRef,
+  ContextHubStats,
+  ContextIntent,
+  ContextSurface,
+  SessionContextSummary,
+} from "./types"

+ 28 - 0
src/lib/context-hub/prompt-content.spec.ts

@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest"
+import { buildContextHubSystemContent, flattenContextHubSystemContent } from "./prompt-content"
+import type { ContextHubResult } from "./types"
+
+const result = {
+  stableCore: "稳定项目核心",
+  sessionSummary: "当前会话摘要",
+  dynamicContext: "任务动态片段",
+  warnings: [],
+} as ContextHubResult
+
+describe("context hub system content", () => {
+  it("places stable core after software rules and marks its end as cacheable", () => {
+    const content = buildContextHubSystemContent("软件规则", result, ["本轮任务规则"])
+
+    expect(content).toEqual([
+      { type: "text", text: "软件规则\n\n" },
+      { type: "text", text: "## 项目稳定核心\n稳定项目核心", cacheControl: true },
+      { type: "text", text: "\n\n## 当前会话摘要\n当前会话摘要\n\n## 本轮动态上下文\n任务动态片段\n\n本轮任务规则" },
+    ])
+  })
+
+  it("flattens blocks byte-for-byte for non-Anthropic provider configs", () => {
+    const content = buildContextHubSystemContent("软件规则", result, ["本轮任务规则"])
+
+    expect(flattenContextHubSystemContent(content)).toBe(content.map((block) => block.text).join(""))
+  })
+})

+ 24 - 0
src/lib/context-hub/prompt-content.ts

@@ -0,0 +1,24 @@
+import type { ContentBlock } from "@/lib/llm-providers"
+import type { ContextHubResult } from "./types"
+
+export function buildContextHubSystemContent(
+  softwareRules: string,
+  result: ContextHubResult,
+  dynamicParts: string[] = [],
+): ContentBlock[] {
+  const stableText = `## 项目稳定核心\n${result.stableCore}`
+  const dynamicText = [
+    result.sessionSummary ? `## 当前会话摘要\n${result.sessionSummary}` : "",
+    result.dynamicContext ? `## 本轮动态上下文\n${result.dynamicContext}` : "",
+    ...dynamicParts,
+  ].filter((value) => value.trim()).join("\n\n")
+
+  return [
+    { type: "text", text: softwareRules.trim() ? `${softwareRules.trim()}\n\n` : "" },
+    { type: "text", text: stableText, cacheControl: true },
+    { type: "text", text: dynamicText ? `\n\n${dynamicText}` : "" },
+  ]
+}
+export function flattenContextHubSystemContent(content: ContentBlock[]): string {
+  return content.map((block) => block.type === "text" ? block.text : "").join("")
+}

+ 40 - 0
src/lib/context-hub/session-store-integration.spec.ts

@@ -0,0 +1,40 @@
+import { beforeEach, describe, expect, it } from "vitest"
+import { useChatStore } from "@/stores/chat-store"
+import { useOutlineChatStore } from "@/stores/outline-chat-store"
+
+const summary = {
+  text: "已确认主角不能提前知道真相。",
+  dependencies: { "E:/Novel/wiki/outlines/main.md": 2 },
+  updatedAt: 100,
+}
+
+describe("session summary store isolation", () => {
+  beforeEach(() => {
+    useChatStore.setState({
+      conversations: [
+        { id: "chat-a", title: "A", createdAt: 1, updatedAt: 1, deAiMode: false },
+        { id: "chat-b", title: "B", createdAt: 1, updatedAt: 1, deAiMode: false },
+      ],
+    })
+    useOutlineChatStore.setState({
+      conversations: [
+        { id: "outline-a", title: "A", createdAt: 1, updatedAt: 1, messages: [] },
+        { id: "outline-b", title: "B", createdAt: 1, updatedAt: 1, messages: [] },
+      ],
+    })
+  })
+
+  it("updates only the selected AI chat conversation", () => {
+    useChatStore.getState().setConversationContextSummary("chat-a", summary)
+
+    expect(useChatStore.getState().conversations[0].contextSummary).toEqual(summary)
+    expect(useChatStore.getState().conversations[1].contextSummary).toBeUndefined()
+  })
+
+  it("updates only the selected AI outline conversation", () => {
+    useOutlineChatStore.getState().setConversationContextSummary("outline-a", summary)
+
+    expect(useOutlineChatStore.getState().conversations[0].contextSummary).toEqual(summary)
+    expect(useOutlineChatStore.getState().conversations[1].contextSummary).toBeUndefined()
+  })
+})

+ 60 - 0
src/lib/context-hub/session-summary.spec.ts

@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest"
+import {
+  buildSessionContextSummary,
+  isSessionSummaryFresh,
+  selectContextHistoryMessages,
+} from "./session-summary"
+
+describe("session context summary", () => {
+  it("builds a deterministic local summary without an LLM", () => {
+    const input = {
+      messages: [
+        { role: "user", content: "主角不能提前知道真相。请继续第二章。" },
+        { role: "assistant", content: "第二章将保留悬念,并让线索出现在旧车站。" },
+      ],
+      dependencies: { "E:/Novel/wiki/outlines/main.md": 2 },
+    }
+
+    const first = buildSessionContextSummary(input)
+    const second = buildSessionContextSummary(input)
+
+    expect(first.text).toContain("用户:主角不能提前知道真相")
+    expect(first.text).toContain("助手:第二章将保留悬念")
+    expect(first.text).toBe(second.text)
+    expect(first.dependencies).toEqual(input.dependencies)
+  })
+
+  it("bounds long summaries deterministically", () => {
+    const summary = buildSessionContextSummary({
+      messages: [{ role: "user", content: "约束。".repeat(100) }],
+      dependencies: {},
+      maxChars: 80,
+    })
+
+    expect(summary.text.length).toBeLessThanOrEqual(80)
+  })
+
+  it("invalidates only when a recorded dependency revision changes", () => {
+    const summary = buildSessionContextSummary({
+      messages: [],
+      dependencies: { outline: 2 },
+    })
+
+    expect(isSessionSummaryFresh(summary, { outline: 2, unrelated: 9 })).toBe(true)
+    expect(isSessionSummaryFresh(summary, { outline: 3 })).toBe(false)
+    expect(isSessionSummaryFresh(undefined, { outline: 2 })).toBe(false)
+  })
+
+  it("keeps only the latest two messages when a summary is already in system context", () => {
+    const messages = [
+      { role: "user", content: "第一问" },
+      { role: "assistant", content: "第一答" },
+      { role: "user", content: "第二问" },
+      { role: "assistant", content: "第二答" },
+    ]
+
+    expect(selectContextHistoryMessages(messages, "会话摘要")).toEqual(messages.slice(-2))
+    expect(selectContextHistoryMessages(messages, "")).toEqual(messages)
+    expect(selectContextHistoryMessages(messages, undefined)).toEqual(messages)
+  })
+})

+ 95 - 0
src/lib/context-hub/session-summary.ts

@@ -0,0 +1,95 @@
+import type { SessionContextSummary } from "./types"
+
+export interface SessionSummaryMessage {
+  role: string
+  content: unknown
+}
+
+export interface BuildSessionContextSummaryInput {
+  messages: SessionSummaryMessage[]
+  dependencies: Record<string, number>
+  maxChars?: number
+}
+
+export function selectContextHistoryMessages<T extends SessionSummaryMessage>(
+  messages: readonly T[],
+  summary: string | undefined,
+): T[] {
+  return summary?.trim() ? messages.slice(-2) : [...messages]
+}
+
+function messageText(content: unknown): string {
+  if (typeof content === "string") return content
+  if (!Array.isArray(content)) return ""
+  return content
+    .map((block) => {
+      if (!block || typeof block !== "object") return ""
+      const value = block as { type?: string; text?: unknown }
+      return value.type === "text" && typeof value.text === "string" ? value.text : ""
+    })
+    .join("")
+}
+
+function compactText(value: string): string {
+  return value.replace(/\s+/g, " ").trim()
+}
+
+function selectSentences(value: string, limit: number): string {
+  const sentences = compactText(value).match(/[^。!?!?]+[。!?!?]?/g) ?? []
+  return sentences.slice(0, limit).join("").trim()
+}
+
+export function buildSessionContextSummary(
+  input: BuildSessionContextSummaryInput,
+): SessionContextSummary {
+  const maxChars = Math.max(0, input.maxChars ?? 4000)
+  const lines = input.messages
+    .filter((message) => message.role === "user" || message.role === "assistant")
+    .slice(-12)
+    .map((message) => {
+      const text = selectSentences(messageText(message.content), message.role === "user" ? 3 : 2)
+      if (!text) return ""
+      return `${message.role === "user" ? "用户" : "助手"}:${text}`
+    })
+    .filter(Boolean)
+  const text = lines.join("\n").slice(0, maxChars)
+
+  return {
+    text,
+    dependencies: { ...input.dependencies },
+    updatedAt: Date.now(),
+  }
+}
+
+export function isSessionSummaryFresh(
+  summary: SessionContextSummary | undefined,
+  currentDependencies: Record<string, number>,
+): boolean {
+  if (!summary) return false
+  return Object.entries(summary.dependencies).every(
+    ([path, revision]) => currentDependencies[path] === revision,
+  )
+}
+
+export function normalizeSessionContextSummary(value: unknown): SessionContextSummary | undefined {
+  if (typeof value === "string") {
+    return { text: value, dependencies: {}, updatedAt: 0 }
+  }
+  if (!value || typeof value !== "object") return undefined
+  const candidate = value as Partial<SessionContextSummary>
+  if (typeof candidate.text !== "string") return undefined
+  const dependencies = candidate.dependencies && typeof candidate.dependencies === "object"
+    ? Object.fromEntries(
+        Object.entries(candidate.dependencies).filter((entry): entry is [string, number] => (
+          typeof entry[1] === "number" && Number.isFinite(entry[1])
+        )),
+      )
+    : {}
+  return {
+    text: candidate.text,
+    dependencies,
+    updatedAt: typeof candidate.updatedAt === "number" && Number.isFinite(candidate.updatedAt)
+      ? candidate.updatedAt
+      : 0,
+  }
+}

+ 45 - 0
src/lib/context-hub/source-paths.spec.ts

@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest"
+import {
+  classifyContextSourcePath,
+  getDataSourceKinds,
+  sortContextSourcePaths,
+} from "./source-paths"
+
+const projectPath = "E:/Novel"
+
+describe("context source paths", () => {
+  it.each([
+    ["E:/Novel/wiki/chapters/chapter-001.md", "chapter"],
+    ["E:/Novel/wiki/outlines/main.md", "outline"],
+    ["E:/Novel/wiki/memory/伏笔.md", "memory"],
+    ["E:/Novel/wiki/entities/林默.md", "entity"],
+    ["E:/Novel/wiki/settings/world.md", "setting"],
+    ["E:/Novel/.novel/snapshots/001.snapshot.json", "snapshot"],
+    ["E:/Novel/.novel/cognition-state.json", "entity"],
+    ["E:/Novel/.novel/revision-feedback.json", "snapshot"],
+    ["E:/Novel/.novel/timeline.json", "memory"],
+    ["E:/Novel/.qmai/writing-style.json", "setting"],
+    ["E:/Novel/.qmai/character-aura.json", "entity"],
+    ["E:/Novel/.qmai/simulations/latest.json", "deduction"],
+    ["E:/Novel/.qmai/context-cache/v1/manifest.json", "ignored"],
+  ] as const)("classifies %s as %s", (path, expected) => {
+    expect(classifyContextSourcePath(projectPath, path)).toBe(expected)
+  })
+
+  it("normalizes separators before deterministic sorting", () => {
+    expect(sortContextSourcePaths([
+      "E:\\Novel\\wiki\\outlines\\z.md",
+      "E:/Novel/wiki/outlines/a.md",
+    ])).toEqual([
+      "E:/Novel/wiki/outlines/a.md",
+      "E:/Novel/wiki/outlines/z.md",
+    ])
+  })
+
+  it("maps data sources to only their relevant source kinds", () => {
+    expect(getDataSourceKinds("outline")).toEqual(["outline"])
+    expect(getDataSourceKinds("relatedSettings")).toEqual(["entity", "setting"])
+    expect(getDataSourceKinds("recentChapterContents")).toEqual(["chapter"])
+    expect(getDataSourceKinds("storyFrameworkBinding")).toEqual(["outline", "setting", "deduction"])
+  })
+})

+ 63 - 0
src/lib/context-hub/source-paths.ts

@@ -0,0 +1,63 @@
+import type { ContextSourceKind } from "./types"
+
+const DATA_SOURCE_KINDS: Record<string, ContextSourceKind[]> = {
+  outline: ["outline"],
+  chapterOutline: ["outline"],
+  volumeContext: ["outline", "snapshot"],
+  snapshots: ["snapshot"],
+  recentChapterContents: ["chapter"],
+  fallbackRecentSummaries: ["chapter", "snapshot"],
+  fallbackPreviousEnding: ["chapter", "snapshot"],
+  fallbackCharacterStates: ["entity", "snapshot"],
+  fallbackForeshadowingStates: ["memory", "snapshot"],
+  fallbackTimeline: ["memory", "snapshot"],
+  relatedSettings: ["entity", "setting"],
+  canonRules: ["setting"],
+  writingStyle: ["setting"],
+  searchResults: ["chapter", "outline", "memory", "setting", "entity"],
+  graphSearchResults: ["chapter", "outline", "memory", "setting", "entity"],
+  revisionFeedback: ["chapter", "snapshot"],
+  cognitionText: ["entity"],
+  soulDoc: ["soul"],
+  characterAuras: ["entity"],
+  sectionBriefing: ["outline", "snapshot"],
+  storyFrameworkBinding: ["outline", "setting", "deduction"],
+  retrieval: ["chapter", "outline", "memory", "setting", "entity", "snapshot"],
+}
+
+export function normalizeContextPath(path: string): string {
+  return path.replace(/\\/g, "/").replace(/\/{2,}/g, "/").replace(/\/$/, "")
+}
+
+export function classifyContextSourcePath(projectPath: string, path: string): ContextSourceKind {
+  const project = normalizeContextPath(projectPath).toLowerCase()
+  const normalized = normalizeContextPath(path)
+  const lower = normalized.toLowerCase()
+  const relative = lower.startsWith(`${project}/`) ? lower.slice(project.length + 1) : lower
+
+  if (relative === ".qmai/context-cache" || relative.startsWith(".qmai/context-cache/")) return "ignored"
+  if (relative === ".qmai/writing-style.json") return "setting"
+  if (relative === ".qmai/character-aura.json") return "entity"
+  if (relative.startsWith("wiki/chapters/")) return "chapter"
+  if (relative.startsWith("wiki/outlines/")) return "outline"
+  if (relative.startsWith("wiki/memory/")) return "memory"
+  if (relative.startsWith("wiki/entities/") || relative.startsWith("wiki/characters/")) return "entity"
+  if (relative.startsWith("wiki/settings/") || relative === "wiki/canon.md" || relative === "wiki/writing-style.md") return "setting"
+  if (relative === "soul.md" || relative === "wiki/soul.md") return "soul"
+  if (relative === ".novel/cognition-state.json") return "entity"
+  if (relative === ".novel/revision-feedback.json") return "snapshot"
+  if (relative === ".novel/timeline.json") return "memory"
+  if (relative.startsWith(".novel/snapshots/") || relative.startsWith(".novel/community-summaries/")) return "snapshot"
+  if (relative.startsWith(".qmai/simulations/")) return "deduction"
+  return "other"
+}
+
+export function getDataSourceKinds(sourceName: string): ContextSourceKind[] {
+  return [...(DATA_SOURCE_KINDS[sourceName] ?? ["other"])]
+}
+
+export function sortContextSourcePaths(paths: string[]): string[] {
+  return paths
+    .map(normalizeContextPath)
+    .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
+}

+ 139 - 0
src/lib/context-hub/source-registry.spec.ts

@@ -0,0 +1,139 @@
+import { describe, expect, it, vi } from "vitest"
+import type { FileNode } from "@/types/wiki"
+import { ContextSourceRegistry, scanProjectContextFiles } from "./source-registry"
+import { CONTEXT_CACHE_SCHEMA_VERSION, type ContextCacheManifest } from "./types"
+
+function file(path: string, mtimeMs: number, size = 10): FileNode {
+  return {
+    name: path.split("/").at(-1) ?? path,
+    path,
+    is_dir: false,
+    mtimeMs,
+    size,
+  }
+}
+
+function createHarness(initialFiles: FileNode[]) {
+  let files = initialFiles
+  let manifest: ContextCacheManifest = {
+    schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+    sources: {},
+    artifacts: {},
+  }
+  const hashes = new Map<string, string>()
+  const scanFiles = vi.fn(async () => files)
+  const getFileMd5 = vi.fn(async (path: string) => hashes.get(path) ?? `hash:${path}`)
+  const storage = {
+    loadManifest: vi.fn(async () => structuredClone(manifest)),
+    saveManifest: vi.fn(async (next: ContextCacheManifest) => { manifest = structuredClone(next) }),
+  }
+  const registry = new ContextSourceRegistry("E:/Novel", {
+    scanFiles,
+    getFileMd5,
+    storage,
+    subscribe: () => () => {},
+  })
+  return {
+    registry,
+    hashes,
+    scanFiles,
+    getFileMd5,
+    setFiles: (next: FileNode[]) => { files = next },
+  }
+}
+
+describe("ContextSourceRegistry", () => {
+  it("scans direct .qmai files and nested simulation files", async () => {
+    const calls: Array<[string, unknown]> = []
+    const writingStyle = file("E:/Novel/.qmai/writing-style.json", 1)
+    const simulation = file("E:/Novel/.qmai/simulations/latest.json", 1)
+
+    const result = await scanProjectContextFiles("E:/Novel", {
+      fileExists: vi.fn(async () => true),
+      listDirectory: vi.fn(async (path, options) => {
+        calls.push([path, options])
+        if (path === "E:/Novel/.qmai") return [writingStyle]
+        if (path === "E:/Novel/.qmai/simulations") return [simulation]
+        return []
+      }),
+    })
+
+    expect(result).toEqual([writingStyle, simulation])
+    expect(calls).toContainEqual(["E:/Novel/.qmai", { includeHidden: true, maxDepth: 1 }])
+    expect(calls).toContainEqual(["E:/Novel/.qmai/simulations", { includeHidden: true, maxDepth: 30 }])
+  })
+
+  it("propagates a scan error when an existing directory is unreadable", async () => {
+    await expect(scanProjectContextFiles("E:/Novel", {
+      fileExists: vi.fn(async (path) => path.endsWith("/wiki")),
+      listDirectory: vi.fn(async (path) => {
+        if (path.endsWith("/wiki")) throw new Error("无权读取")
+        return []
+      }),
+    })).rejects.toThrow("无权读取")
+  })
+
+  it("does not hash unchanged metadata on a repeated refresh", async () => {
+    const path = "E:/Novel/wiki/chapters/1.md"
+    const harness = createHarness([file(path, 1)])
+
+    const first = await harness.registry.refresh()
+    const second = await harness.registry.refresh()
+
+    expect(first.versions[path].revision).toBe(1)
+    expect(second.versions[path].revision).toBe(1)
+    expect(harness.getFileMd5).toHaveBeenCalledTimes(1)
+  })
+
+  it("keeps the revision when metadata changes but content hash does not", async () => {
+    const path = "E:/Novel/wiki/outlines/main.md"
+    const harness = createHarness([file(path, 1)])
+    harness.hashes.set(path, "same")
+    await harness.registry.refresh()
+    harness.setFiles([file(path, 2)])
+
+    const result = await harness.registry.refresh()
+
+    expect(result.versions[path].revision).toBe(1)
+    expect(result.changedPaths).toEqual([])
+    expect(harness.getFileMd5).toHaveBeenCalledTimes(2)
+  })
+
+  it("increments only the changed source revision", async () => {
+    const chapter = "E:/Novel/wiki/chapters/1.md"
+    const setting = "E:/Novel/wiki/settings/world.md"
+    const harness = createHarness([file(chapter, 1), file(setting, 1)])
+    harness.hashes.set(chapter, "chapter-1")
+    harness.hashes.set(setting, "setting-1")
+    await harness.registry.refresh()
+    harness.hashes.set(chapter, "chapter-2")
+    harness.setFiles([file(chapter, 2), file(setting, 1)])
+
+    const result = await harness.registry.refresh()
+
+    expect(result.versions[chapter].revision).toBe(2)
+    expect(result.versions[setting].revision).toBe(1)
+    expect(result.changedPaths).toEqual([chapter])
+  })
+
+  it("hashes a dirty internal write even when metadata has not changed", async () => {
+    const path = "E:/Novel/wiki/memory/clue.md"
+    const harness = createHarness([file(path, 1)])
+    harness.hashes.set(path, "one")
+    await harness.registry.refresh()
+    harness.hashes.set(path, "two")
+    harness.registry.markDirty(path)
+
+    const result = await harness.registry.refresh()
+
+    expect(result.versions[path].revision).toBe(2)
+  })
+
+  it("deduplicates concurrent refreshes", async () => {
+    const harness = createHarness([file("E:/Novel/wiki/chapters/1.md", 1)])
+
+    await Promise.all([harness.registry.refresh(), harness.registry.refresh()])
+
+    expect(harness.scanFiles).toHaveBeenCalledTimes(1)
+  })
+})

+ 182 - 0
src/lib/context-hub/source-registry.ts

@@ -0,0 +1,182 @@
+import {
+  fileExists,
+  getFileMd5,
+  listDirectory,
+  subscribeProjectFileMutations,
+  type ListDirectoryOptions,
+  type ProjectFileMutation,
+} from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import type { FileNode } from "@/types/wiki"
+import { classifyContextSourcePath, normalizeContextPath, sortContextSourcePaths } from "./source-paths"
+import { ContextHubStorage } from "./storage"
+import type { ContextCacheManifest, ContextSourceKind, SourceVersion } from "./types"
+
+interface SourceRegistryStorage {
+  loadManifest(): Promise<ContextCacheManifest>
+  saveManifest(manifest: ContextCacheManifest): Promise<void>
+}
+
+export interface ContextSourceRegistryOptions {
+  scanFiles?: () => Promise<FileNode[]>
+  getFileMd5?: (path: string) => Promise<string>
+  storage?: SourceRegistryStorage
+  subscribe?: (listener: (event: ProjectFileMutation) => void) => () => void
+}
+
+export interface ContextSourceScannerIo {
+  fileExists(path: string): Promise<boolean>
+  listDirectory(path: string, options: ListDirectoryOptions): Promise<FileNode[]>
+}
+
+export interface SourceRefreshResult {
+  versions: Record<string, SourceVersion>
+  changedPaths: string[]
+}
+
+function flattenFiles(nodes: FileNode[]): FileNode[] {
+  const files: FileNode[] = []
+  const visit = (values: FileNode[]) => {
+    for (const node of values) {
+      if (node.is_dir) visit(node.children ?? [])
+      else files.push(node)
+    }
+  }
+  visit(nodes)
+  return files
+}
+
+const defaultScannerIo: ContextSourceScannerIo = {
+  fileExists,
+  listDirectory,
+}
+
+async function safeList(
+  path: string,
+  options: ListDirectoryOptions,
+  io: ContextSourceScannerIo,
+): Promise<FileNode[]> {
+  if (!await io.fileExists(path)) return []
+  return io.listDirectory(path, options)
+}
+
+export async function scanProjectContextFiles(
+  projectPath: string,
+  io: ContextSourceScannerIo = defaultScannerIo,
+): Promise<FileNode[]> {
+  const roots = await Promise.all([
+    safeList(projectPath, { maxDepth: 1 }, io),
+    safeList(`${projectPath}/wiki`, { maxDepth: 30 }, io),
+    safeList(`${projectPath}/.novel`, { includeHidden: true, maxDepth: 30 }, io),
+    safeList(`${projectPath}/.qmai`, { includeHidden: true, maxDepth: 1 }, io),
+    safeList(`${projectPath}/.qmai/simulations`, { includeHidden: true, maxDepth: 30 }, io),
+  ])
+  return flattenFiles(roots.flat())
+}
+
+function metadataMatches(left: SourceVersion, right: FileNode): boolean {
+  return left.mtimeMs === right.mtimeMs && left.size === right.size
+}
+
+function manifestsEqual(left: ContextCacheManifest, right: ContextCacheManifest): boolean {
+  return JSON.stringify(left) === JSON.stringify(right)
+}
+
+export class ContextSourceRegistry {
+  private readonly projectPath: string
+  private readonly scanFiles: () => Promise<FileNode[]>
+  private readonly hashFile: (path: string) => Promise<string>
+  private readonly storage: SourceRegistryStorage
+  private readonly unsubscribe: () => void
+  private readonly dirtyPaths = new Set<string>()
+  private pendingRefresh: Promise<SourceRefreshResult> | null = null
+  private versions: Record<string, SourceVersion> = {}
+
+  constructor(projectPath: string, options: ContextSourceRegistryOptions = {}) {
+    this.projectPath = normalizePath(projectPath)
+    this.scanFiles = options.scanFiles ?? (() => scanProjectContextFiles(this.projectPath))
+    this.hashFile = options.getFileMd5 ?? getFileMd5
+    this.storage = options.storage ?? new ContextHubStorage(this.projectPath)
+    const subscribe = options.subscribe ?? subscribeProjectFileMutations
+    this.unsubscribe = subscribe((event) => this.markDirty(event.path))
+  }
+
+  refresh(): Promise<SourceRefreshResult> {
+    if (this.pendingRefresh) return this.pendingRefresh
+    this.pendingRefresh = this.refreshInternal().finally(() => {
+      this.pendingRefresh = null
+    })
+    return this.pendingRefresh
+  }
+
+  markDirty(path: string): void {
+    const normalized = normalizeContextPath(path)
+    const kind = classifyContextSourcePath(this.projectPath, normalized)
+    if (kind !== "ignored" && kind !== "other") this.dirtyPaths.add(normalized)
+  }
+
+  getDependencies(kinds?: ContextSourceKind[]): Record<string, number> {
+    const allowed = kinds ? new Set(kinds) : null
+    return Object.fromEntries(
+      sortContextSourcePaths(Object.keys(this.versions))
+        .filter((path) => !allowed || allowed.has(this.versions[path].kind))
+        .map((path) => [path, this.versions[path].revision]),
+    )
+  }
+
+  dispose(): void {
+    this.unsubscribe()
+    this.dirtyPaths.clear()
+  }
+
+  private async refreshInternal(): Promise<SourceRefreshResult> {
+    const manifest = await this.storage.loadManifest()
+    const previous = manifest.sources
+    const scanned = await this.scanFiles()
+    const relevant = scanned
+      .map((node) => ({ ...node, path: normalizeContextPath(node.path) }))
+      .filter((node) => {
+        const kind = classifyContextSourcePath(this.projectPath, node.path)
+        return kind !== "ignored" && kind !== "other"
+      })
+    const byPath = new Map(relevant.map((node) => [node.path, node]))
+    const next: Record<string, SourceVersion> = {}
+    const changedPaths: string[] = []
+
+    for (const path of sortContextSourcePaths([...byPath.keys()])) {
+      const node = byPath.get(path)!
+      const oldVersion = previous[path]
+      const dirty = this.dirtyPaths.has(path)
+      if (oldVersion && !dirty && metadataMatches(oldVersion, node)) {
+        next[path] = oldVersion
+        continue
+      }
+
+      const hash = await this.hashFile(path)
+      const contentChanged = !oldVersion || oldVersion.hash !== hash
+      next[path] = {
+        path,
+        kind: classifyContextSourcePath(this.projectPath, path),
+        mtimeMs: node.mtimeMs,
+        size: node.size,
+        hash,
+        revision: oldVersion ? oldVersion.revision + (contentChanged ? 1 : 0) : 1,
+      }
+      if (contentChanged) changedPaths.push(path)
+    }
+
+    for (const path of Object.keys(previous)) {
+      if (!byPath.has(path)) changedPaths.push(path)
+    }
+
+    const nextManifest: ContextCacheManifest = { ...manifest, sources: next }
+    if (!manifestsEqual(manifest, nextManifest)) await this.storage.saveManifest(nextManifest)
+    this.versions = next
+    this.dirtyPaths.clear()
+
+    return {
+      versions: { ...next },
+      changedPaths: sortContextSourcePaths([...new Set(changedPaths)]),
+    }
+  }
+}

+ 245 - 0
src/lib/context-hub/storage.spec.ts

@@ -0,0 +1,245 @@
+import { describe, expect, it } from "vitest"
+import { ContextHubStorage, type ContextHubStorageIo } from "./storage"
+import {
+  CONTEXT_CACHE_SCHEMA_VERSION,
+  type CachedArtifact,
+  type ContextHubSnapshot,
+} from "./types"
+
+function createMemoryIo() {
+  const files = new Map<string, string>()
+  const directories = new Set<string>()
+  const deletedPaths: string[] = []
+  let failWrite: ((path: string) => boolean) | undefined
+  const io: ContextHubStorageIo = {
+    readFile: async (path) => {
+      const value = files.get(path)
+      if (value === undefined) throw new Error("文件不存在")
+      return value
+    },
+    writeFileAtomic: async (path, contents) => {
+      if (failWrite?.(path)) throw new Error("写入失败")
+      files.set(path, contents)
+    },
+    createDirectory: async (path) => {
+      directories.add(path)
+    },
+    listDirectory: async (path) => [...files.keys()]
+      .filter((filePath) => filePath.startsWith(`${path}/`) && !filePath.slice(path.length + 1).includes("/"))
+      .map((filePath) => ({ name: filePath.split("/").pop()!, path: filePath, is_dir: false, mtimeMs: 1 })),
+    deleteFile: async (path) => {
+      deletedPaths.push(path)
+      files.delete(path)
+    },
+  }
+  return { files, directories, deletedPaths, io, setFailWrite: (value?: (path: string) => boolean) => { failWrite = value } }
+}
+
+function artifact(value: string, key = "outline:main"): CachedArtifact<string> {
+  return {
+    schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+    key,
+    value,
+    dependencies: { "E:/Novel/wiki/outlines/main.md": 1 },
+    createdAt: 1,
+  }
+}
+
+function snapshot(id = "assistant:1"): ContextHubSnapshot {
+  return {
+    schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+    id,
+    surface: "ai-chat",
+    createdAt: 10,
+    stats: {
+      hits: 1,
+      refreshed: 2,
+      failures: 0,
+      stableTokens: 100,
+      summaryTokens: 20,
+      dynamicTokens: 80,
+      candidateTokens: 400,
+      estimatedSavedTokens: 200,
+      estimatedSavedPercent: 50,
+      expanded: false,
+      providerCacheEnabled: true,
+    },
+    items: [{
+      key: "data-source:outline",
+      sourceName: "outline",
+      status: "hit",
+      dependencyPaths: ["wiki/outlines/main.md"],
+    }],
+    stableCore: "稳定核心正文",
+    sessionSummary: "会话摘要正文",
+    dynamicContext: "动态片段正文",
+  }
+}
+
+describe("ContextHubStorage", () => {
+  it("returns an empty manifest for a first run", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+
+    await expect(storage.loadManifest()).resolves.toEqual({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      sources: {},
+      artifacts: {},
+    })
+  })
+
+  it("persists artifacts for a new storage instance", async () => {
+    const memory = createMemoryIo()
+    await new ContextHubStorage("E:/Novel", memory.io).writeArtifact("outline:main", artifact("大纲"))
+
+    const restarted = new ContextHubStorage("E:/Novel", memory.io)
+    await expect(restarted.readArtifact<string>("outline:main")).resolves.toMatchObject({ value: "大纲" })
+  })
+
+  it("preserves every manifest entry during concurrent artifact writes", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+
+    await Promise.all([
+      storage.writeArtifact("outline:main", artifact("大纲")),
+      storage.writeArtifact("chapter:1", artifact("第一章", "chapter:1")),
+    ])
+
+    const restarted = new ContextHubStorage("E:/Novel", memory.io)
+    await expect(restarted.readArtifact<string>("outline:main")).resolves.toMatchObject({ value: "大纲" })
+    await expect(restarted.readArtifact<string>("chapter:1")).resolves.toMatchObject({ value: "第一章" })
+  })
+
+  it("does not remove a newer artifact when saving a stale source snapshot", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+    const staleManifest = await storage.loadManifest()
+    await storage.writeArtifact("outline:main", artifact("大纲"))
+    staleManifest.sources["E:/Novel/wiki/outlines/main.md"] = {
+      path: "E:/Novel/wiki/outlines/main.md",
+      kind: "outline",
+      mtimeMs: 1,
+      size: 10,
+      hash: "hash",
+      revision: 1,
+    }
+
+    await storage.saveManifest(staleManifest)
+
+    const restarted = new ContextHubStorage("E:/Novel", memory.io)
+    await expect(restarted.readArtifact<string>("outline:main")).resolves.toMatchObject({ value: "大纲" })
+  })
+
+  it("treats corrupted artifacts as misses", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+    await storage.writeArtifact("outline:main", artifact("大纲"))
+    const manifest = await storage.loadManifest()
+    memory.files.set(manifest.artifacts["outline:main"].path, "{broken")
+
+    await expect(storage.readArtifact("outline:main")).resolves.toBeNull()
+  })
+
+  it("treats a different schema as an empty cache", async () => {
+    const memory = createMemoryIo()
+    memory.files.set(
+      "E:/Novel/.qmai/context-cache/v1/manifest.json",
+      JSON.stringify({ schemaVersion: 999, sources: { stale: {} }, artifacts: {} }),
+    )
+
+    await expect(new ContextHubStorage("E:/Novel", memory.io).loadManifest()).resolves.toMatchObject({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      sources: {},
+    })
+  })
+
+  it("does not publish a manifest entry when artifact writing fails", async () => {
+    const memory = createMemoryIo()
+    memory.setFailWrite((path) => path.includes("/artifacts/"))
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+
+    await expect(storage.writeArtifact("outline:main", artifact("大纲"))).rejects.toThrow("写入失败")
+    memory.setFailWrite()
+    expect((await storage.loadManifest()).artifacts).toEqual({})
+  })
+
+  it("uses one fixed stable bundle file per surface", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+    const first = { schemaVersion: 1, surface: "ai-chat" as const, text: "一", dependencies: {}, updatedAt: 1 }
+    const second = { ...first, text: "二", updatedAt: 2 }
+
+    await storage.writeStableBundle("ai-chat", first)
+    await storage.writeStableBundle("ai-chat", second)
+
+    expect([...memory.files.keys()].filter((path) => path.includes("stable-bundles"))).toEqual([
+      "E:/Novel/.qmai/context-cache/v1/stable-bundles/ai-chat.json",
+    ])
+    await expect(storage.readStableBundle("ai-chat")).resolves.toMatchObject({ text: "二" })
+  })
+
+  it("persists a context snapshot separately and reads it after restart", async () => {
+    const memory = createMemoryIo()
+    await new ContextHubStorage("E:/Novel", memory.io).writeSnapshot(snapshot())
+
+    const snapshotPaths = [...memory.files.keys()].filter((path) => path.includes("/snapshots/"))
+    expect(snapshotPaths).toHaveLength(1)
+    expect(snapshotPaths[0]).not.toContain("assistant:1")
+    await expect(new ContextHubStorage("E:/Novel", memory.io).readSnapshot("ai-chat", "assistant:1"))
+      .resolves.toEqual(snapshot())
+  })
+
+  it("returns null for a corrupted context snapshot", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+    await storage.writeSnapshot(snapshot())
+    const snapshotPath = [...memory.files.keys()].find((path) => path.includes("/snapshots/"))!
+    memory.files.set(snapshotPath, "{broken")
+
+    await expect(storage.readSnapshot("ai-chat", "assistant:1")).resolves.toBeNull()
+  })
+
+  it("prunes only old unreferenced snapshots from the selected surface", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+    await storage.writeSnapshot({ ...snapshot("kept"), createdAt: 1 })
+    await storage.writeSnapshot({ ...snapshot("orphan"), createdAt: 1 })
+    await storage.writeSnapshot({ ...snapshot("outline"), surface: "ai-outline", createdAt: 1 })
+
+    await storage.pruneSnapshots("ai-chat", ["kept"])
+
+    await expect(storage.readSnapshot("ai-chat", "kept")).resolves.not.toBeNull()
+    await expect(storage.readSnapshot("ai-chat", "orphan")).resolves.toBeNull()
+    await expect(storage.readSnapshot("ai-outline", "outline")).resolves.not.toBeNull()
+    expect(memory.deletedPaths).toHaveLength(1)
+    expect(memory.deletedPaths[0]).toContain("/snapshots/ai-chat/")
+  })
+
+  it("keeps a newly written unreferenced snapshot during the cleanup grace period", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+    await storage.writeSnapshot({ ...snapshot("pending-reference"), createdAt: Date.now() })
+
+    await storage.pruneSnapshots("ai-chat", [])
+
+    await expect(storage.readSnapshot("ai-chat", "pending-reference")).resolves.not.toBeNull()
+    expect(memory.deletedPaths).toEqual([])
+  })
+
+  it("never deletes a path returned from outside the selected snapshot directory", async () => {
+    const memory = createMemoryIo()
+    const outsidePath = "E:/Novel/.qmai/context-cache/v1/outside.json"
+    memory.files.set(outsidePath, JSON.stringify({ createdAt: 1 }))
+    memory.io.listDirectory = async () => [{
+      name: "outside.json",
+      path: outsidePath,
+      is_dir: false,
+      mtimeMs: 1,
+    }]
+
+    await new ContextHubStorage("E:/Novel", memory.io).pruneSnapshots("ai-chat", [])
+
+    expect(memory.deletedPaths).toEqual([])
+    expect(memory.files.has(outsidePath)).toBe(true)
+  })
+})

+ 314 - 0
src/lib/context-hub/storage.ts

@@ -0,0 +1,314 @@
+import { createDirectory, deleteFile, listDirectory, readFile, writeFileAtomic } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import {
+  CONTEXT_CACHE_SCHEMA_VERSION,
+  type CachedArtifact,
+  type ContextCacheManifest,
+  type ContextHubSnapshot,
+  type ContextSurface,
+  type StableBundle,
+} from "./types"
+
+export interface ContextHubStorageIo {
+  readFile(path: string): Promise<string>
+  writeFileAtomic(path: string, contents: string): Promise<void>
+  createDirectory(path: string): Promise<void>
+  listDirectory(path: string): Promise<Array<{ name: string; path: string; is_dir: boolean; mtimeMs?: number }>>
+  deleteFile(path: string): Promise<void>
+}
+
+const defaultIo: ContextHubStorageIo = {
+  readFile,
+  writeFileAtomic,
+  createDirectory,
+  listDirectory: (path) => listDirectory(path, { includeHidden: true, maxDepth: 1 }),
+  deleteFile,
+}
+
+const SNAPSHOT_CLEANUP_GRACE_MS = 60_000
+
+function emptyManifest(): ContextCacheManifest {
+  return {
+    schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+    sources: {},
+    artifacts: {},
+  }
+}
+
+function cloneManifest(manifest: ContextCacheManifest): ContextCacheManifest {
+  return JSON.parse(JSON.stringify(manifest)) as ContextCacheManifest
+}
+
+function artifactFileName(key: string): string {
+  let hash = 0x811c9dc5
+  for (let index = 0; index < key.length; index += 1) {
+    hash ^= key.charCodeAt(index)
+    hash = Math.imul(hash, 0x01000193)
+  }
+  return `${(hash >>> 0).toString(16).padStart(8, "0")}.json`
+}
+
+function parseObject(value: string): Record<string, unknown> | null {
+  try {
+    const parsed = JSON.parse(value)
+    return parsed && typeof parsed === "object" && !Array.isArray(parsed)
+      ? parsed as Record<string, unknown>
+      : null
+  } catch {
+    return null
+  }
+}
+
+export class ContextHubStorage {
+  private readonly basePath: string
+  private readonly manifestPath: string
+  private manifest: ContextCacheManifest | null = null
+  private manifestWriteQueue: Promise<void> = Promise.resolve()
+  private snapshotOperationQueue: Promise<void> = Promise.resolve()
+
+  constructor(
+    projectPath: string,
+    private readonly io: ContextHubStorageIo = defaultIo,
+  ) {
+    this.basePath = `${normalizePath(projectPath)}/.qmai/context-cache/v1`
+    this.manifestPath = `${this.basePath}/manifest.json`
+  }
+
+  async loadManifest(): Promise<ContextCacheManifest> {
+    return cloneManifest(await this.getManifest())
+  }
+
+  async saveManifest(manifest: ContextCacheManifest): Promise<void> {
+    await this.enqueueManifestWrite(async () => {
+      const current = await this.getManifest()
+      const next = cloneManifest({
+        ...manifest,
+        schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+        artifacts: {
+          ...manifest.artifacts,
+          ...current.artifacts,
+        },
+      })
+      await this.persistManifest(next)
+    })
+  }
+
+  async readArtifact<T>(key: string): Promise<CachedArtifact<T> | null> {
+    const entry = (await this.getManifest()).artifacts[key]
+    if (!entry) return null
+    try {
+      const raw = parseObject(await this.io.readFile(entry.path))
+      if (
+        !raw
+        || raw.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
+        || raw.key !== key
+        || !("value" in raw)
+      ) return null
+      return raw as unknown as CachedArtifact<T>
+    } catch {
+      return null
+    }
+  }
+
+  async writeArtifact<T>(key: string, artifact: CachedArtifact<T>): Promise<void> {
+    await this.ensureBaseDirectories()
+    const artifactPath = `${this.basePath}/artifacts/${artifactFileName(key)}`
+    const value: CachedArtifact<T> = {
+      ...artifact,
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      key,
+    }
+    await this.io.writeFileAtomic(artifactPath, JSON.stringify(value, null, 2))
+
+    await this.enqueueManifestWrite(async () => {
+      const current = await this.getManifest()
+      const next: ContextCacheManifest = {
+        ...cloneManifest(current),
+        artifacts: {
+          ...current.artifacts,
+          [key]: {
+            path: artifactPath,
+            dependencies: { ...artifact.dependencies },
+          },
+        },
+      }
+      await this.persistManifest(next)
+    })
+  }
+
+  async readStableBundle(surface: ContextSurface): Promise<StableBundle | null> {
+    try {
+      const raw = parseObject(await this.io.readFile(this.stableBundlePath(surface)))
+      if (
+        !raw
+        || raw.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
+        || raw.surface !== surface
+        || typeof raw.text !== "string"
+      ) return null
+      return raw as unknown as StableBundle
+    } catch {
+      return null
+    }
+  }
+
+  async writeStableBundle(surface: ContextSurface, bundle: StableBundle): Promise<void> {
+    await this.ensureBaseDirectories()
+    const value: StableBundle = {
+      ...bundle,
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      surface,
+    }
+    await this.io.writeFileAtomic(this.stableBundlePath(surface), JSON.stringify(value, null, 2))
+  }
+
+  async readSnapshot(surface: ContextSurface, id: string): Promise<ContextHubSnapshot | null> {
+    try {
+      const raw = parseObject(await this.io.readFile(this.snapshotPath(surface, id)))
+      if (
+        !raw
+        || raw.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
+        || raw.id !== id
+        || raw.surface !== surface
+        || typeof raw.createdAt !== "number"
+        || !raw.stats
+        || !Array.isArray(raw.items)
+        || typeof raw.stableCore !== "string"
+        || typeof raw.sessionSummary !== "string"
+        || typeof raw.dynamicContext !== "string"
+      ) return null
+      return raw as unknown as ContextHubSnapshot
+    } catch {
+      return null
+    }
+  }
+
+  async writeSnapshot(snapshot: ContextHubSnapshot): Promise<void> {
+    await this.enqueueSnapshotOperation(async () => {
+      await this.ensureBaseDirectories()
+      const value: ContextHubSnapshot = {
+        ...snapshot,
+        schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      }
+      await this.io.writeFileAtomic(
+        this.snapshotPath(snapshot.surface, snapshot.id),
+        JSON.stringify(value, null, 2),
+      )
+    })
+  }
+
+  async pruneSnapshots(surface: ContextSurface, referencedIds: string[]): Promise<void> {
+    await this.enqueueSnapshotOperation(async () => {
+      const directory = this.snapshotSurfacePath(surface)
+      let nodes: Array<{ name: string; path: string; is_dir: boolean; mtimeMs?: number }>
+      try {
+        nodes = await this.io.listDirectory(directory)
+      } catch {
+        return
+      }
+      const referencedPaths = new Set(
+        referencedIds.map((id) => this.snapshotPath(surface, id).toLowerCase()),
+      )
+      const cutoff = Date.now() - SNAPSHOT_CLEANUP_GRACE_MS
+      for (const node of nodes) {
+        if (node.is_dir) continue
+        const candidate = normalizePath(node.path)
+        if (!this.isDirectSnapshotFile(directory, candidate)) continue
+        if (referencedPaths.has(candidate.toLowerCase())) continue
+
+        let createdAt = node.mtimeMs
+        try {
+          const raw = parseObject(await this.io.readFile(candidate))
+          if (raw && typeof raw.createdAt === "number") createdAt = raw.createdAt
+        } catch {
+        }
+        if (createdAt === undefined || createdAt > cutoff) continue
+        try {
+          await this.io.deleteFile(candidate)
+        } catch {
+        }
+      }
+    })
+  }
+
+  private async getManifest(): Promise<ContextCacheManifest> {
+    if (this.manifest) return this.manifest
+    try {
+      const raw = parseObject(await this.io.readFile(this.manifestPath))
+      if (
+        !raw
+        || raw.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
+        || !raw.sources
+        || !raw.artifacts
+      ) {
+        this.manifest = emptyManifest()
+      } else {
+        this.manifest = raw as unknown as ContextCacheManifest
+      }
+    } catch {
+      this.manifest = emptyManifest()
+    }
+    return this.manifest
+  }
+
+  private async ensureBaseDirectories(): Promise<void> {
+    await this.io.createDirectory(this.basePath)
+    await this.io.createDirectory(`${this.basePath}/artifacts`)
+    await this.io.createDirectory(`${this.basePath}/stable-bundles`)
+    await this.io.createDirectory(`${this.basePath}/snapshots`)
+    await this.io.createDirectory(this.snapshotSurfacePath("ai-chat"))
+    await this.io.createDirectory(this.snapshotSurfacePath("ai-outline"))
+  }
+
+  private enqueueManifestWrite<T>(operation: () => Promise<T>): Promise<T> {
+    const result = this.manifestWriteQueue.then(
+      () => operation(),
+      () => operation(),
+    )
+    this.manifestWriteQueue = result.then(
+      () => undefined,
+      () => undefined,
+    )
+    return result
+  }
+
+  private async persistManifest(manifest: ContextCacheManifest): Promise<void> {
+    await this.ensureBaseDirectories()
+    await this.io.writeFileAtomic(this.manifestPath, JSON.stringify(manifest, null, 2))
+    this.manifest = manifest
+  }
+
+  private stableBundlePath(surface: ContextSurface): string {
+    return `${this.basePath}/stable-bundles/${surface}.json`
+  }
+
+  private snapshotSurfacePath(surface: ContextSurface): string {
+    return `${this.basePath}/snapshots/${surface}`
+  }
+
+  private snapshotPath(surface: ContextSurface, id: string): string {
+    return `${this.snapshotSurfacePath(surface)}/${artifactFileName(`snapshot:${id}`)}`
+  }
+
+  private isDirectSnapshotFile(directory: string, candidate: string): boolean {
+    const prefix = `${normalizePath(directory).replace(/\/$/, "")}/`
+    const windowsPath = /^[A-Za-z]:\//.test(prefix) && /^[A-Za-z]:\//.test(candidate)
+    const matchesDirectory = windowsPath
+      ? candidate.toLowerCase().startsWith(prefix.toLowerCase())
+      : candidate.startsWith(prefix)
+    if (!matchesDirectory) return false
+    const relative = candidate.slice(prefix.length)
+    return relative.length > 0 && !relative.includes("/") && relative.toLowerCase().endsWith(".json")
+  }
+
+  private enqueueSnapshotOperation<T>(operation: () => Promise<T>): Promise<T> {
+    const result = this.snapshotOperationQueue.then(
+      () => operation(),
+      () => operation(),
+    )
+    this.snapshotOperationQueue = result.then(
+      () => undefined,
+      () => undefined,
+    )
+    return result
+  }
+}

+ 18 - 0
src/lib/context-hub/token-estimator.spec.ts

@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest"
+import { estimateContextTokens } from "./token-estimator"
+
+describe("estimateContextTokens", () => {
+  it("counts CJK characters conservatively", () => {
+    expect(estimateContextTokens("测试")).toBe(2)
+  })
+
+  it("groups ASCII characters in fours", () => {
+    expect(estimateContextTokens("abcd")).toBe(1)
+    expect(estimateContextTokens("abcde")).toBe(2)
+  })
+
+  it("is deterministic for mixed content", () => {
+    expect(estimateContextTokens("测试abcd")).toBe(3)
+    expect(estimateContextTokens("测试abcd")).toBe(estimateContextTokens("测试abcd"))
+  })
+})

+ 9 - 0
src/lib/context-hub/token-estimator.ts

@@ -0,0 +1,9 @@
+export function estimateContextTokens(text: string): number {
+  let nonAscii = 0
+  let ascii = 0
+  for (const character of text) {
+    if (character.charCodeAt(0) <= 0x7f) ascii += 1
+    else nonAscii += 1
+  }
+  return nonAscii + Math.ceil(ascii / 4)
+}

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

@@ -0,0 +1,133 @@
+import type { AgentMessage } from "@/lib/agent/types"
+import type { DataSourceCategory } from "@/lib/novel/classification"
+import type { ContextPack } from "@/lib/novel/context-engine"
+
+export const CONTEXT_CACHE_SCHEMA_VERSION = 1
+
+export type ContextSurface = "ai-chat" | "ai-outline"
+export type ContextIntent = "generate" | "question" | "review" | "lint"
+export type ContextSourceKind =
+  | "chapter"
+  | "outline"
+  | "memory"
+  | "setting"
+  | "entity"
+  | "snapshot"
+  | "deduction"
+  | "soul"
+  | "other"
+  | "ignored"
+
+export interface SourceVersion {
+  path: string
+  kind: ContextSourceKind
+  mtimeMs?: number
+  size?: number
+  hash?: string
+  revision: number
+}
+
+export interface CachedArtifact<T = unknown> {
+  schemaVersion: number
+  key: string
+  value: T
+  dependencies: Record<string, number>
+  createdAt: number
+}
+
+export interface StableBundle {
+  schemaVersion: number
+  surface: ContextSurface
+  text: string
+  dependencies: Record<string, number>
+  updatedAt: number
+}
+
+export interface ContextCacheManifest {
+  schemaVersion: number
+  sources: Record<string, SourceVersion>
+  artifacts: Record<string, { path: string; dependencies: Record<string, number> }>
+}
+
+export interface SessionContextSummary {
+  text: string
+  dependencies: Record<string, number>
+  updatedAt: number
+}
+
+export interface ContextHubStats {
+  hits: number
+  refreshed: number
+  failures: number
+  stableTokens: number
+  summaryTokens: number
+  dynamicTokens: number
+  candidateTokens: number
+  estimatedSavedTokens: number
+  estimatedSavedPercent: number
+  expanded: boolean
+  providerCacheEnabled: boolean
+  providerCachedTokens?: number
+}
+
+export type ContextCacheItemStatus = "hit" | "refreshed" | "failed"
+
+export interface ContextCacheItemTrace {
+  key: string
+  sourceName: string
+  status: ContextCacheItemStatus
+  dependencyPaths: string[]
+}
+
+export interface ContextHubSnapshotRef {
+  id: string
+  surface: ContextSurface
+  createdAt: number
+  stats: ContextHubStats
+}
+
+export interface ContextHubSnapshot extends ContextHubSnapshotRef {
+  schemaVersion: number
+  items: ContextCacheItemTrace[]
+  stableCore: string
+  sessionSummary: string
+  dynamicContext: string
+}
+
+export interface ContextHubRequest {
+  projectPath: string
+  surface: ContextSurface
+  sessionId: string
+  task: string
+  intent: ContextIntent
+  chapterNumber?: number
+  categories?: DataSourceCategory[]
+  references?: string[]
+  messages?: AgentMessage[]
+  existingSummary?: SessionContextSummary
+  tokenBudget?: number
+  forceRefresh?: boolean
+}
+
+export interface ContextHubResult {
+  surface: ContextSurface
+  stableCore: string
+  sessionSummary: string
+  dynamicContext: string
+  contextPack: ContextPack
+  dependencies: Record<string, number>
+  stats: ContextHubStats
+  cacheItems: ContextCacheItemTrace[]
+  warnings: string[]
+  readFile: (path: string) => Promise<string>
+}
+
+export interface ContextHub {
+  prepare(request: ContextHubRequest): Promise<ContextHubResult | null>
+  readFile(path: string): Promise<string>
+  saveSnapshot(id: string, result: ContextHubResult): Promise<ContextHubSnapshotRef>
+  readSnapshot(reference: ContextHubSnapshotRef): Promise<ContextHubSnapshot | null>
+  pruneSnapshots(surface: ContextSurface, referencedIds: string[]): Promise<void>
+  markDirty(path: string): void
+  dispose(): void
+}

+ 40 - 0
src/lib/llm-providers.spec.ts

@@ -123,6 +123,46 @@ describe("prompt caching cache_control breakpoints", () => {
     ])
   })
 
+  it("preserves a cache breakpoint in Anthropic top-level system content", () => {
+    const body = getProviderConfig(customConfig({ apiMode: "anthropic_messages" }))
+      .buildBody([{
+        role: "system",
+        content: [
+          { type: "text", text: "软件规则\n" },
+          { type: "text", text: "稳定项目核心", cacheControl: true },
+          { type: "text", text: "\n动态上下文" },
+        ],
+      }]) as Record<string, unknown>
+
+    expect(body.system).toEqual([
+      { type: "text", text: "软件规则\n" },
+      { type: "text", text: "稳定项目核心", cache_control: { type: "ephemeral" } },
+      { type: "text", text: "\n动态上下文" },
+    ])
+  })
+
+  it("keeps legacy Anthropic system strings unchanged without a breakpoint", () => {
+    const body = getProviderConfig(customConfig({ apiMode: "anthropic_messages" }))
+      .buildBody([{ role: "system", content: "原有系统提示词" }]) as Record<string, unknown>
+
+    expect(body.system).toBe("原有系统提示词")
+  })
+
+  it("ignores cache markers safely on Gemini while preserving all text", () => {
+    const body = getProviderConfig(customConfig({
+      provider: "google",
+      model: "gemini-2.5-pro",
+    })).buildBody([{
+      role: "system",
+      content: [
+        { type: "text", text: "稳定项目核心", cacheControl: true },
+        { type: "text", text: "动态上下文" },
+      ],
+    }]) as Record<string, any>
+
+    expect(body.systemInstruction.parts).toEqual([{ text: "稳定项目核心动态上下文" }])
+  })
+
   it("collapses the same blocks to a byte-identical string for OpenAI-compatible wires (cache marker ignored)", () => {
     const body = getProviderConfig(customConfig({ apiMode: "chat_completions" }))
       .buildBody(cachedMessage) as Record<string, unknown>

+ 28 - 10
src/lib/llm-providers.ts

@@ -494,13 +494,7 @@ function toAnthropicContent(content: string | ContentBlock[]): unknown {
   })
 }
 
-/**
- * Anthropic's top-level `system` field is a string, not blocks.
- * If a caller puts images inside a system message we drop them —
- * Anthropic doesn't accept system-level images today, and silently
- * losing them is the lesser evil compared to the request 400ing
- * out for "Unsupported content block in system".
- */
+/** Anthropic accepts top-level system as a string or text-block array. */
 function flattenAnthropicSystem(content: string | ContentBlock[]): string {
   if (typeof content === "string") return content
   return content
@@ -508,6 +502,32 @@ function flattenAnthropicSystem(content: string | ContentBlock[]): string {
     .join("")
 }
 
+function buildAnthropicSystem(messages: ChatMessage[]): string | unknown[] | undefined {
+  const hasCacheControl = messages.some(
+    (message) => Array.isArray(message.content)
+      && message.content.some((block) => block.type === "text" && block.cacheControl),
+  )
+  if (!hasCacheControl) {
+    return messages.map((message) => flattenAnthropicSystem(message.content)).join("\n") || undefined
+  }
+
+  const blocks: unknown[] = []
+  for (const [messageIndex, message] of messages.entries()) {
+    if (messageIndex > 0) blocks.push({ type: "text", text: "\n" })
+    if (typeof message.content === "string") {
+      if (message.content) blocks.push({ type: "text", text: message.content })
+      continue
+    }
+    for (const block of message.content) {
+      if (block.type !== "text") continue
+      blocks.push(block.cacheControl
+        ? { type: "text", text: block.text, cache_control: { type: "ephemeral" } }
+        : { type: "text", text: block.text })
+    }
+  }
+  return blocks.length > 0 ? blocks : undefined
+}
+
 function buildAnthropicBody(
   messages: ChatMessage[],
   overrides?: RequestOverrides,
@@ -516,9 +536,7 @@ function buildAnthropicBody(
   const conversationMessages = messages
     .filter((m) => m.role !== "system")
     .map((m) => ({ role: m.role, content: toAnthropicContent(m.content) }))
-  const system =
-    systemMessages.map((m) => flattenAnthropicSystem(m.content)).join("\n") ||
-    undefined
+  const system = buildAnthropicSystem(systemMessages)
 
   // Anthropic Messages uses top_p / top_k (Python-style snake_case), a
   // mandatory `max_tokens`, and `stop_sequences` instead of `stop`.

+ 14 - 1
src/lib/novel/context-data-source.spec.ts

@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest"
+import { describe, expect, it, vi } from "vitest"
 import { DataSourceRegistry, type DataSource, type ContextLoadContext } from "./context-data-source"
 
 const context: ContextLoadContext = {
@@ -13,6 +13,19 @@ const context: ContextLoadContext = {
 }
 
 describe("DataSourceRegistry", () => {
+  it("uses an optional load adapter without changing the source contract", async () => {
+    const load = vi.fn(async () => "原始值")
+    const adapter = {
+      load: vi.fn(async (_source, _context, directLoad) => `缓存:${await directLoad()}`),
+    }
+    const registry = new DataSourceRegistry({ loadAdapter: adapter })
+    registry.register({ name: "outline", priority: 1, load })
+
+    await expect(registry.loadAll(context)).resolves.toMatchObject({ outline: "缓存:原始值" })
+    expect(adapter.load).toHaveBeenCalledOnce()
+    expect(load).toHaveBeenCalledOnce()
+  })
+
   it("replaces undefined snapshot payloads with default values", async () => {
     const registry = new DataSourceRegistry()
     const snapshotsSource: DataSource<unknown> = {

+ 18 - 1
src/lib/novel/context-data-source.ts

@@ -28,6 +28,18 @@ export interface DataSource<T> {
   fallback?(context: ContextLoadContext): Promise<T>
 }
 
+export interface DataSourceLoadAdapter {
+  load<T>(
+    source: DataSource<T>,
+    context: ContextLoadContext,
+    directLoad: () => Promise<T>,
+  ): Promise<T>
+}
+
+export interface DataSourceRegistryOptions {
+  loadAdapter?: DataSourceLoadAdapter
+}
+
 /**
  * 数据源加载结果
  */
@@ -44,6 +56,8 @@ interface DataSourceResult {
 export class DataSourceRegistry {
   private sources: Map<string, DataSource<any>> = new Map()
 
+  constructor(private readonly options: DataSourceRegistryOptions = {}) {}
+
   /**
    * 注册数据源
    */
@@ -79,7 +93,10 @@ export class DataSourceRegistry {
 
     const promises = sources.map(async (source): Promise<DataSourceResult> => {
       try {
-        const loadedValue = await source.load(context)
+        const directLoad = () => source.load(context)
+        const loadedValue = this.options.loadAdapter
+          ? await this.options.loadAdapter.load(source, context, directLoad)
+          : await directLoad()
         const value = loadedValue === undefined || loadedValue === null
           ? this.getDefaultValue(source.name)
           : loadedValue

+ 12 - 5
src/lib/novel/context-engine.ts

@@ -14,7 +14,11 @@ import { buildCharacterAuraContext } from "./character-aura"
 import { isAuthoritativeGenerationPath, isHistoricalProjectionSnippet, novelMixedSearch } from "./search-adapter"
 import { rerankCandidates } from "@/lib/rerank"
 import type { FileNode } from "@/types/wiki"
-import { DataSourceRegistry, type ContextLoadContext } from "./context-data-source"
+import {
+  DataSourceRegistry,
+  type ContextLoadContext,
+  type DataSourceLoadAdapter,
+} from "./context-data-source"
 import { getAllDataSources, getDataSourcesForCategories } from "./context-data-sources"
 import type { DataSourceCategory } from "./classification"
 
@@ -85,7 +89,7 @@ export async function buildContextPack(
   projectPath: string,
   task: string,
   chapterNumber?: number,
-  options?: { categories?: DataSourceCategory[] },
+  options?: { categories?: DataSourceCategory[]; loadAdapter?: DataSourceLoadAdapter },
 ): Promise<ContextPack> {
   const pp = normalizePath(projectPath)
   const novelMode = useWikiStore.getState().novelMode
@@ -97,7 +101,7 @@ export async function buildContextPack(
   const context = buildLoadContext(pp, task, chapterNumber)
   
   // 创建数据源注册器并加载所有数据
-  const registry = createDataSourceRegistry(options?.categories)
+  const registry = createDataSourceRegistry(options?.categories, options?.loadAdapter)
   const rawData = await registry.loadAll(context)
   
   // 从原始数据构建上下文包
@@ -131,8 +135,11 @@ function buildLoadContext(
 /**
  * 创建并配置数据源注册器
  */
-function createDataSourceRegistry(categories?: DataSourceCategory[]): DataSourceRegistry {
-  const registry = new DataSourceRegistry()
+function createDataSourceRegistry(
+  categories?: DataSourceCategory[],
+  loadAdapter?: DataSourceLoadAdapter,
+): DataSourceRegistry {
+  const registry = new DataSourceRegistry({ loadAdapter })
   registry.registerAll(categories?.length ? getDataSourcesForCategories(categories) : getAllDataSources())
   
   return registry

+ 22 - 0
src/lib/novel/outline-context-reuse.spec.ts

@@ -120,6 +120,28 @@ describe("AI 大纲上下文复用策略", () => {
     expect(plan.sources).toContain("摘要: 已复用上下文摘要缓存")
   })
 
+  it("摘要已在系统上下文时不重复注入摘要消息", () => {
+    const history = [
+      { role: "user" as const, content: "初始目标" },
+      { role: "assistant" as const, content: "初始结论" },
+      { role: "user" as const, content: "最近问题" },
+      { role: "assistant" as const, content: "最近回答" },
+    ]
+    const plan = planOutlineAgentHistory({
+      history,
+      contextDecision: planOutlineContextReuse({
+        hasPriorAssistantAnswer: true,
+        attachedReferenceCount: 0,
+        inputText: "继续",
+      }),
+      cachedSummary: "系统中的会话摘要",
+      summaryInSystem: true,
+    })
+
+    expect(plan.messages).toEqual(history.slice(-2))
+    expect(plan.messages.some((message) => message.content === "系统中的会话摘要")).toBe(false)
+  })
+
   it("估算上下文预算并计算压缩节省", () => {
     const original = [
       { role: "user" as const, content: "生成大纲" + "需求".repeat(500) },

+ 9 - 5
src/lib/novel/outline-context-reuse.ts

@@ -43,6 +43,7 @@ export interface OutlineAgentHistoryInput {
   history: OutlineAgentHistoryMessage[]
   contextDecision: OutlineContextReuseDecision
   cachedSummary?: string
+  summaryInSystem?: boolean
 }
 
 export interface OutlineAgentHistoryPlan {
@@ -125,11 +126,14 @@ export function planOutlineAgentHistory(input: OutlineAgentHistoryInput): Outlin
   const level: OutlineContextPressureLevel =
     history.length > 6 || totalChars > 6_000 ? "high" : totalChars > 2_500 ? "medium" : "low"
   const compactedMessages = level === "high" ? compactOutlineHistory(history) : history.slice(-4)
-  const messages = input.cachedSummary?.trim()
-    ? [
+  const cachedSummary = input.cachedSummary?.trim()
+  const messages = cachedSummary
+    ? input.summaryInSystem
+      ? compactedMessages.slice(-2)
+      : [
         {
           role: "assistant" as const,
-          content: input.cachedSummary.trim(),
+          content: cachedSummary,
         },
         ...compactedMessages.slice(-2),
       ]
@@ -138,7 +142,7 @@ export function planOutlineAgentHistory(input: OutlineAgentHistoryInput): Outlin
     level === "high"
       ? "已压缩历史上下文:仅保留首轮目标、最近关键结论和最近对话,避免重复消耗 Token。"
       : "已裁剪历史上下文:仅保留最近有效对话,避免重复发送旧过程。",
-    input.cachedSummary?.trim()
+    cachedSummary
       ? "已复用上下文摘要缓存;摘要只作为历史提要,当前用户新输入优先级更高。"
       : "",
     "不要把工具调用过程、来源列表或内部思考当成新的创作事实;以最终大纲结论为准。",
@@ -150,7 +154,7 @@ export function planOutlineAgentHistory(input: OutlineAgentHistoryInput): Outlin
     instruction,
     sources: [
       "过程: 已隐藏重复工具过程",
-      ...(input.cachedSummary?.trim() ? ["摘要: 已复用上下文摘要缓存"] : []),
+      ...(cachedSummary ? ["摘要: 已复用上下文摘要缓存"] : []),
     ],
     showThinkingProcess: false,
     showToolProcess: false,

+ 145 - 0
src/lib/persist.spec.ts

@@ -0,0 +1,145 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const fsMocks = vi.hoisted(() => ({
+  writeFile: vi.fn(),
+  readFile: vi.fn(),
+  createDirectory: vi.fn(),
+}))
+
+const contextHubMocks = vi.hoisted(() => ({
+  getContextHub: vi.fn(),
+  pruneSnapshots: vi.fn(),
+}))
+
+vi.mock("@/commands/fs", () => fsMocks)
+vi.mock("@/lib/context-hub/context-hub", () => ({
+  getContextHub: contextHubMocks.getContextHub,
+}))
+
+import { loadChatHistory, saveChatHistory } from "./persist"
+
+describe("chat context summary persistence", () => {
+  beforeEach(() => {
+    fsMocks.writeFile.mockReset().mockResolvedValue(undefined)
+    fsMocks.createDirectory.mockReset().mockResolvedValue(undefined)
+    fsMocks.readFile.mockReset()
+    contextHubMocks.pruneSnapshots.mockReset().mockResolvedValue(undefined)
+    contextHubMocks.getContextHub.mockReset().mockReturnValue({
+      pruneSnapshots: contextHubMocks.pruneSnapshots,
+    })
+  })
+
+  it("saves dependency revisions in the conversation manifest", async () => {
+    const contextSummary = { text: "摘要", dependencies: { outline: 3 }, updatedAt: 10 }
+    await saveChatHistory("E:/Novel", [{
+      id: "chat-1",
+      title: "会话",
+      createdAt: 1,
+      updatedAt: 2,
+      deAiMode: false,
+      contextSummary,
+    }], [])
+
+    const manifestCall = fsMocks.writeFile.mock.calls.find(([path]) => path.endsWith("/.qmai/conversations.json"))
+    expect(JSON.parse(manifestCall[1]).conversations[0].contextSummary).toEqual(contextSummary)
+  })
+
+  it("migrates a legacy string summary while loading", async () => {
+    fsMocks.readFile.mockImplementation(async (path: string) => {
+      if (path.endsWith("/.qmai/conversations.json")) {
+        return JSON.stringify({ conversations: [{
+          id: "chat-1",
+          title: "会话",
+          createdAt: 1,
+          updatedAt: 2,
+          deAiMode: false,
+          contextSummary: "旧摘要",
+        }] })
+      }
+      throw new Error("文件不存在")
+    })
+
+    const loaded = await loadChatHistory("E:/Novel")
+
+    expect(loaded.conversations[0].contextSummary).toEqual({
+      text: "旧摘要",
+      dependencies: {},
+      updatedAt: 0,
+    })
+  })
+
+  it("persists the context snapshot reference with an assistant message", async () => {
+    const contextHubSnapshot = {
+      id: "assistant:1",
+      surface: "ai-chat",
+      createdAt: 10,
+      stats: {
+        hits: 1, refreshed: 2, failures: 0,
+        stableTokens: 100, summaryTokens: 20, dynamicTokens: 30,
+        candidateTokens: 300, estimatedSavedTokens: 150, estimatedSavedPercent: 50,
+        expanded: false, providerCacheEnabled: true,
+      },
+    }
+    await saveChatHistory("E:/Novel", [{
+      id: "chat-1",
+      title: "会话",
+      createdAt: 1,
+      updatedAt: 2,
+      deAiMode: false,
+    }], [{
+      id: "assistant:1",
+      role: "assistant",
+      content: "正文",
+      timestamp: 10,
+      conversationId: "chat-1",
+      contextHubSnapshot,
+    }])
+
+    const messageCall = fsMocks.writeFile.mock.calls.find(([path]) => path.endsWith("/.qmai/chats/chat-1.json"))
+    expect(JSON.parse(messageCall[1])[0].contextHubSnapshot).toEqual(contextHubSnapshot)
+  })
+
+  it("prunes AI chat snapshots using only references that were actually persisted", async () => {
+    const stats = {
+      hits: 1, refreshed: 0, failures: 0,
+      stableTokens: 100, summaryTokens: 20, dynamicTokens: 30,
+      candidateTokens: 300, estimatedSavedTokens: 150, estimatedSavedPercent: 50,
+      expanded: false, providerCacheEnabled: true,
+    }
+    const messages = [
+      {
+        id: "old",
+        role: "assistant" as const,
+        content: "旧回复",
+        timestamp: 1,
+        conversationId: "chat-1",
+        contextHubSnapshot: { id: "old", surface: "ai-chat" as const, createdAt: 1, stats },
+      },
+      {
+        id: "kept",
+        role: "assistant" as const,
+        content: "新回复",
+        timestamp: 2,
+        conversationId: "chat-1",
+        contextHubSnapshot: { id: "kept", surface: "ai-chat" as const, createdAt: 2, stats },
+      },
+    ]
+
+    await saveChatHistory("E:/Novel", [{
+      id: "chat-1",
+      title: "会话",
+      createdAt: 1,
+      updatedAt: 2,
+      deAiMode: false,
+    }], messages, 1)
+
+    expect(contextHubMocks.getContextHub).toHaveBeenCalledWith("E:/Novel")
+    expect(contextHubMocks.pruneSnapshots).toHaveBeenCalledWith("ai-chat", ["kept"])
+  })
+
+  it("does not fail chat persistence when snapshot cleanup fails", async () => {
+    contextHubMocks.pruneSnapshots.mockRejectedValueOnce(new Error("清理失败"))
+
+    await expect(saveChatHistory("E:/Novel", [], [])).resolves.toBeUndefined()
+  })
+})

+ 15 - 0
src/lib/persist.ts

@@ -3,6 +3,8 @@ import type { ReviewItem } from "@/stores/review-store"
 import type { DisplayMessage, Conversation } from "@/stores/chat-store"
 import { normalizeLoadedRunStates, type ConversationRunStates } from "@/lib/conversation-run-state"
 import { normalizePath } from "@/lib/path-utils"
+import { normalizeSessionContextSummary } from "@/lib/context-hub/session-summary"
+import { getContextHub } from "@/lib/context-hub/context-hub"
 
 const MAX_RETRIES = 3
 const RETRY_DELAY_MS = 500
@@ -128,6 +130,7 @@ function normalizeConversation(conv: Conversation): Conversation {
       conv.selectedDeAiSkillId === null || typeof conv.selectedDeAiSkillId === "string"
         ? conv.selectedDeAiSkillId
         : undefined,
+    contextSummary: normalizeSessionContextSummary(conv.contextSummary),
   }
 }
 
@@ -155,6 +158,7 @@ export async function saveChatHistory(
 
     // Save each conversation's messages separately
     const byConversation = new Map<string, DisplayMessage[]>()
+    const persistedSnapshotIds = new Set<string>()
     for (const msg of messages) {
       const list = byConversation.get(msg.conversationId) ?? []
       list.push(msg)
@@ -164,6 +168,11 @@ export async function saveChatHistory(
     for (const [convId, msgs] of byConversation) {
       // Keep last N messages per conversation
       const toSave = msgs.slice(-(maxMessages || 100))
+      for (const message of toSave) {
+        if (message.contextHubSnapshot?.surface === "ai-chat") {
+          persistedSnapshotIds.add(message.contextHubSnapshot.id)
+        }
+      }
       await withRetry(
         () => writeFile(
           `${pp}/.qmai/chats/${convId}.json`,
@@ -172,6 +181,12 @@ export async function saveChatHistory(
         `saveChatHistory(chat:${convId})`,
       )
     }
+
+    try {
+      await getContextHub(pp).pruneSnapshots("ai-chat", [...persistedSnapshotIds])
+    } catch {
+      // Snapshot cleanup is optional and must not make chat history saving fail.
+    }
   } finally {
     release()
   }

+ 13 - 0
src/stores/chat-store.ts

@@ -3,6 +3,7 @@ import type { ChatMessage } from "@/lib/llm-client"
 import type { AgentRunRecord, AgentStageTrace } from "@/lib/agent/types"
 import type { ReferenceToken } from "@/lib/reference/types"
 import type { ContextTrace } from "@/lib/agent/context-trace"
+import type { ContextHubSnapshotRef, SessionContextSummary } from "@/lib/context-hub/types"
 import i18n from "@/i18n"
 import {
   canStartConversationRun as canStartRun,
@@ -21,6 +22,7 @@ export interface Conversation {
   deAiMode: boolean
   selectedDeAiSkillId?: string | null
   inputDraft?: string
+  contextSummary?: SessionContextSummary
 }
 
 export interface MessageReference {
@@ -41,6 +43,7 @@ export interface DisplayMessage {
   isAgentRunning?: boolean
   attachedReferences?: ReferenceToken[]
   contextTrace?: ContextTrace
+  contextHubSnapshot?: ContextHubSnapshotRef
 }
 
 interface ChatState {
@@ -63,6 +66,7 @@ interface ChatState {
   setConversationDeAiMode: (id: string, deAiMode: boolean) => void
   setConversationDeAiSkillId: (id: string, skillId: string | null | undefined) => void
   setConversationInputDraft: (id: string, draft: string) => void
+  setConversationContextSummary: (id: string, contextSummary: SessionContextSummary | undefined) => void
 
   // Message management
   addMessage: (role: DisplayMessage["role"], content: string) => void
@@ -201,6 +205,15 @@ export const useChatStore = create<ChatState>((set, get) => ({
       ),
     })),
 
+  setConversationContextSummary: (id, contextSummary) =>
+    set((state) => ({
+      conversations: state.conversations.map((conversation) =>
+        conversation.id === id
+          ? { ...conversation, contextSummary, updatedAt: Date.now() }
+          : conversation
+      ),
+    })),
+
   addMessage: (role, content) =>
     set((state) => {
       const { activeConversationId, conversations } = state

+ 71 - 0
src/stores/outline-chat-store.spec.ts

@@ -1,7 +1,14 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 
 const fsMocks = vi.hoisted(() => ({ createDirectory: vi.fn(), readFile: vi.fn(), writeFile: vi.fn() }))
+const contextHubMocks = vi.hoisted(() => ({
+  getContextHub: vi.fn(),
+  pruneSnapshots: vi.fn(),
+}))
 vi.mock("@/commands/fs", () => fsMocks)
+vi.mock("@/lib/context-hub/context-hub", () => ({
+  getContextHub: contextHubMocks.getContextHub,
+}))
 
 import type { OutlineChatConversation } from "./outline-chat-store"
 import { useOutlineChatStore } from "./outline-chat-store"
@@ -18,6 +25,10 @@ beforeEach(() => {
   fsMocks.createDirectory.mockReset().mockResolvedValue(undefined)
   fsMocks.readFile.mockReset()
   fsMocks.writeFile.mockReset().mockResolvedValue(undefined)
+  contextHubMocks.pruneSnapshots.mockReset().mockResolvedValue(undefined)
+  contextHubMocks.getContextHub.mockReset().mockReturnValue({
+    pruneSnapshots: contextHubMocks.pruneSnapshots,
+  })
   useWikiStore.setState({ project: null })
   useOutlineChatStore.setState({
     conversations: [], activeConversationId: null, streamingContents: {}, runStates: {}, loaded: false, pendingReferenceTokens: [],
@@ -27,6 +38,22 @@ beforeEach(() => {
 afterEach(() => { vi.clearAllTimers(); vi.useRealTimers() })
 
 describe("outline-chat-store", () => {
+  it("加载时把旧字符串上下文摘要迁移为带依赖的结构", async () => {
+    useWikiStore.setState({ project: { id: "p", name: "Novel", path: "E:/Novel" } })
+    fsMocks.readFile.mockResolvedValue(JSON.stringify({
+      conversations: [{ ...conversation("legacy-summary"), contextSummary: "旧大纲摘要" }],
+      activeConversationId: "legacy-summary",
+    }))
+
+    await useOutlineChatStore.getState().loadFromDisk()
+
+    expect(useOutlineChatStore.getState().conversations[0].contextSummary).toEqual({
+      text: "旧大纲摘要",
+      dependencies: {},
+      updatedAt: 0,
+    })
+  })
+
   it("按会话隔离流式内容,并支持追加、读取和单独清理", () => {
     useOutlineChatStore.setState({ conversations: [conversation("a"), conversation("b")] })
     const store = useOutlineChatStore.getState()
@@ -110,6 +137,33 @@ describe("outline-chat-store", () => {
     expect(saved.streamingContents).toBeUndefined()
   })
 
+  it("保存成功后使用当前大纲历史引用清理快照", async () => {
+    useWikiStore.setState({ project: { name: "项目", path: "C:/Book" } })
+    const stored = conversation("a")
+    stored.messages = [{
+      id: "assistant",
+      role: "assistant",
+      content: "大纲",
+      contextHubSnapshot: {
+        id: "outline-ref",
+        surface: "ai-outline",
+        createdAt: 10,
+        stats: {
+          hits: 1, refreshed: 0, failures: 0,
+          stableTokens: 100, summaryTokens: 20, dynamicTokens: 30,
+          candidateTokens: 300, estimatedSavedTokens: 150, estimatedSavedPercent: 50,
+          expanded: false, providerCacheEnabled: true,
+        },
+      },
+    }]
+    useOutlineChatStore.setState({ conversations: [stored] })
+
+    await useOutlineChatStore.getState().saveToDisk()
+
+    expect(contextHubMocks.getContextHub).toHaveBeenCalledWith("C:/Book")
+    expect(contextHubMocks.pruneSnapshots).toHaveBeenCalledWith("ai-outline", ["outline-ref"])
+  })
+
   it("仅运行状态变化也会自动保存", async () => {
     useWikiStore.setState({ project: { name: "项目", path: "C:/Book" } })
     useOutlineChatStore.setState({ conversations: [conversation("a")] })
@@ -168,6 +222,22 @@ describe("outline-chat-store", () => {
     stored.messages = [
       { id: "new", role: "user", content: structured.summary, novelGenerationRequest: structured },
       { id: "old", role: "user", content: "legacy body" },
+      {
+        id: "assistant",
+        role: "assistant",
+        content: "大纲正文",
+        contextHubSnapshot: {
+          id: "assistant",
+          surface: "ai-outline",
+          createdAt: 10,
+          stats: {
+            hits: 1, refreshed: 0, failures: 0,
+            stableTokens: 100, summaryTokens: 20, dynamicTokens: 30,
+            candidateTokens: 300, estimatedSavedTokens: 150, estimatedSavedPercent: 50,
+            expanded: false, providerCacheEnabled: true,
+          },
+        },
+      },
     ]
     useOutlineChatStore.setState({ conversations: [stored], activeConversationId: "persisted" })
     await useOutlineChatStore.getState().saveToDisk()
@@ -179,6 +249,7 @@ describe("outline-chat-store", () => {
     expect(messages[0].content).toBe(structured.summary)
     expect(getOutlineMessageModelContent(messages[0])).toBe("full model workflow")
     expect(getOutlineMessageModelContent(messages[1])).toBe("legacy body")
+    expect(messages[2].contextHubSnapshot).toMatchObject({ id: "assistant", createdAt: 10 })
   })
 
   it.each([

+ 21 - 2
src/stores/outline-chat-store.ts

@@ -3,6 +3,8 @@ import { readFile, writeFile, createDirectory } from "@/commands/fs"
 import { normalizePath } from "@/lib/path-utils"
 import type { AgentRunRecord } from "@/lib/agent/types"
 import type { ReferenceToken } from "@/lib/reference/types"
+import type { ContextHubSnapshotRef, SessionContextSummary } from "@/lib/context-hub/types"
+import { normalizeSessionContextSummary } from "@/lib/context-hub/session-summary"
 import { useWikiStore } from "@/stores/wiki-store"
 import type { IntentClarityResult } from "@/lib/novel/outline-intent-clarity"
 import type { NextStepRecommendation } from "@/lib/novel/outline-next-step"
@@ -87,6 +89,7 @@ export interface OutlineChatMessage {
   intentClarityResult?: IntentClarityResult | null
   nextStepRecommendation?: NextStepRecommendation | null
   novelGenerationRequest?: NovelGenerationRequestPackage
+  contextHubSnapshot?: ContextHubSnapshotRef
 }
 
 export interface OutlineChatConversation {
@@ -96,7 +99,7 @@ export interface OutlineChatConversation {
   updatedAt: number
   messages: OutlineChatMessage[]
   modelId?: string
-  contextSummary?: string
+  contextSummary?: SessionContextSummary
 }
 
 interface OutlineChatState {
@@ -114,7 +117,7 @@ interface OutlineChatState {
   removeLastMessage: (convId: string) => void
   deleteConversation: (id: string) => void
   setConversationModel: (id: string, modelId: string) => void
-  setConversationContextSummary: (id: string, contextSummary: string) => void
+  setConversationContextSummary: (id: string, contextSummary: SessionContextSummary) => void
   setStreamingContent: (conversationId: string, content: string) => void
   appendStreamingContent: (conversationId: string, content: string) => void
   clearStreamingContent: (conversationId: string) => void
@@ -174,6 +177,21 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
       const dir = path.replace(/[/\\][^/\\]+$/, "")
       await createDirectory(dir)
       await writeFile(path, JSON.stringify(snapshot, null, 2))
+      const projectPath = path.slice(0, -"/.qmai/outline-chats.json".length)
+      const referencedIds = new Set<string>()
+      for (const conversation of snapshot.conversations) {
+        for (const message of conversation.messages) {
+          if (message.contextHubSnapshot?.surface === "ai-outline") {
+            referencedIds.add(message.contextHubSnapshot.id)
+          }
+        }
+      }
+      try {
+        const { getContextHub } = await import("@/lib/context-hub/context-hub")
+        await getContextHub(projectPath).pruneSnapshots("ai-outline", [...referencedIds])
+      } catch {
+        // Snapshot cleanup is optional and must not make outline history saving fail.
+      }
     } catch {
     }
   }
@@ -371,6 +389,7 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
       }
       const conversations = (data.conversations ?? []).map((conversation) => ({
         ...conversation,
+        contextSummary: normalizeSessionContextSummary(conversation.contextSummary),
         updatedAt: conversation.updatedAt ?? conversation.createdAt ?? Date.now(),
         messages: conversation.messages.map((message) => ({
           ...message,

+ 2 - 0
src/types/wiki.ts

@@ -10,6 +10,8 @@ export interface FileNode {
   name: string
   path: string
   is_dir: boolean
+  mtimeMs?: number
+  size?: number
   children?: FileNode[]
 }