Преглед изворни кода

fix(context-hub): 修复缓存膨胀并自动迁移 v1

新增 Context Cache v2,以 SHA-256 依赖指纹替代全量路径表,并增加容量限制、故障重建与淘汰策略。

项目打开时后台清理 v1,旧聊天摘要自动瘦身并失效重建,项目切换时显式释放 Context Hub 资源。
darknessomi пре 1 месец
родитељ
комит
8feb0ed71d
32 измењених фајлова са 1117 додато и 232 уклоњено
  1. 2 0
      src/App.tsx
  2. 1 1
      src/components/chat/chat-panel.tsx
  3. 9 0
      src/components/common/context-hub-details.spec.tsx
  4. 5 0
      src/components/common/context-hub-details.tsx
  5. 1 1
      src/components/sources/outline-chat-panel.spec.tsx
  6. 3 3
      src/components/sources/outline-chat-panel.tsx
  7. 11 9
      src/lib/context-hub/composer.spec.ts
  8. 7 4
      src/lib/context-hub/composer.ts
  9. 42 21
      src/lib/context-hub/context-hub.spec.ts
  10. 57 9
      src/lib/context-hub/context-hub.ts
  11. 67 3
      src/lib/context-hub/data-source-cache.spec.ts
  12. 47 31
      src/lib/context-hub/data-source-cache.ts
  13. 5 0
      src/lib/context-hub/fingerprint.ts
  14. 9 1
      src/lib/context-hub/index.ts
  15. 52 0
      src/lib/context-hub/migration.spec.ts
  16. 1 1
      src/lib/context-hub/session-store-integration.spec.ts
  17. 24 10
      src/lib/context-hub/session-summary.spec.ts
  18. 19 15
      src/lib/context-hub/session-summary.ts
  19. 4 0
      src/lib/context-hub/source-paths.spec.ts
  20. 4 3
      src/lib/context-hub/source-paths.ts
  21. 52 0
      src/lib/context-hub/source-registry.spec.ts
  22. 76 11
      src/lib/context-hub/source-registry.ts
  23. 218 5
      src/lib/context-hub/storage.spec.ts
  24. 268 87
      src/lib/context-hub/storage.ts
  25. 28 6
      src/lib/context-hub/types.ts
  26. 45 3
      src/lib/persist.spec.ts
  27. 34 5
      src/lib/persist.ts
  28. 8 0
      src/lib/reset-project-state.spec.ts
  29. 2 0
      src/lib/reset-project-state.ts
  30. 5 2
      src/stores/outline-chat-store.spec.ts
  31. 10 1
      src/stores/outline-chat-store.ts
  32. 1 0
      src/test/chat-panel-mount.ts

+ 2 - 0
src/App.tsx

@@ -28,6 +28,7 @@ import { isChapterPathInProject, normalizePath } from "@/lib/path-utils"
 import { countChapterBodyWords } from "@/lib/chapter-word-count"
 import { flattenMdFiles } from "@/lib/novel/chapter-utils"
 import { runUserMemoryMaintenance } from "@/lib/user-memory/maintenance"
+import { initializeProjectContextCache } from "@/lib/context-hub/context-hub"
 
 function App() {
   const project = useWikiStore((s) => s.project)
@@ -417,6 +418,7 @@ function App() {
 
   async function handleProjectOpened(proj: WikiProject) {
     await resetProjectState()
+    await initializeProjectContextCache(proj.path)
 
     setProject(proj)
     useWikiStore.getState().clearTransientTaskState()

+ 1 - 1
src/components/chat/chat-panel.tsx

@@ -2000,7 +2000,7 @@ export function ChatPanel() {
             capturedConvId,
             buildSessionContextSummary({
               messages: completedMessages,
-              dependencies: contextHubResult.dependencies,
+              dependencyFingerprint: contextHubResult.dependencyStamp.fingerprint,
             }),
           )
         }

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

@@ -6,6 +6,8 @@ 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 dependencyStamp = { fingerprint: "test", sourceCount: 1, kinds: ["outline" as const] }
+
 const snapshot: ContextHubSnapshot = {
   schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
   id: "assistant:1",
@@ -33,19 +35,25 @@ const snapshot: ContextHubSnapshot = {
       key: "data-source:outline",
       sourceName: "outline",
       status: "hit",
+      dependencyStamp: { ...dependencyStamp, sourceCount: 3 },
       dependencyPaths: ["wiki/outlines/main.md"],
+      dependencyPathsTruncated: true,
     },
     {
       key: "stable-core:ai-chat",
       sourceName: "stableCore",
       status: "refreshed",
+      dependencyStamp,
       dependencyPaths: ["wiki/settings/world.md"],
+      dependencyPathsTruncated: false,
     },
     {
       key: "data-source:book-analysis",
       sourceName: "bookAnalysisReferences",
       status: "hit",
+      dependencyStamp,
       dependencyPaths: [".qmai/book-analysis-context.json"],
+      dependencyPathsTruncated: false,
     },
   ],
   stableCore: "稳定核心正文",
@@ -103,6 +111,7 @@ describe("ContextHubDetails", () => {
     expect(host.textContent).toContain("拆书库分析")
     expect(host.textContent).toContain("稳定核心缓存")
     expect(host.textContent).toContain("wiki/outlines/main.md")
+    expect(host.textContent).toContain("另有 2 个文件")
     expect(host.textContent).toContain("稳定核心正文")
     expect(host.textContent).toContain("供应商已确认命中 800 Token(输入占比 50%)")
     expect(host.textContent).toContain("供应商新写入缓存 200 Token")

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

@@ -81,6 +81,11 @@ function CacheItemGroup({ status, items }: { status: ContextCacheItemStatus; ite
                 {item.dependencyPaths.map((path) => (
                   <li key={path} className="break-all">{path}</li>
                 ))}
+                {item.dependencyPathsTruncated ? (
+                  <li className="text-foreground/60">
+                    另有 {Math.max(0, item.dependencyStamp.sourceCount - item.dependencyPaths.length).toLocaleString()} 个文件,按集合指纹校验
+                  </li>
+                ) : null}
               </ul>
             )}
           </div>

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

@@ -533,7 +533,7 @@ describe("OutlineChatPanel controls", () => {
   it("将 AI 大纲上下文摘要持久化到会话字段而不是组件内存缓存", () => {
     expect(source).toContain("contextSummary:")
     expect(source).toContain("buildSessionContextSummary")
-    expect(source).toContain("dependencies: contextHubResult?.dependencies")
+    expect(source).toContain("dependencyFingerprint: contextHubResult?.dependencyStamp.fingerprint")
     // 上下文摘要已通过 setConversationContextSummary 持久化到会话字段
     expect(source).toContain("setConversationContextSummary")
     expect(source).not.toContain("contextSummaryByConversation")

+ 3 - 3
src/components/sources/outline-chat-panel.tsx

@@ -2603,7 +2603,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               { role: "user", content: prompt },
               { role: "assistant", content: finalContent },
             ],
-            dependencies: contextHubResult?.dependencies ?? {},
+            dependencyFingerprint: contextHubResult?.dependencyStamp.fingerprint ?? "",
           }),
         };
         if (!isCurrentRun()) return { started: true, sent: false };
@@ -3109,7 +3109,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         if (completedConversation) {
           setConversationContextSummary(capturedConvId, buildSessionContextSummary({
             messages: completedConversation.messages,
-            dependencies: contextHubResult?.dependencies ?? {},
+            dependencyFingerprint: contextHubResult?.dependencyStamp.fingerprint ?? "",
           }));
           void useOutlineChatStore.getState().saveToDisk();
         }
@@ -3464,7 +3464,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             { role: "user", content: lastUserRequest },
             { role: "assistant", content: finalContent },
           ],
-          dependencies: contextHubResult?.dependencies ?? {},
+          dependencyFingerprint: contextHubResult?.dependencyStamp.fingerprint ?? "",
         }));
         if (!isCurrentRun()) return;
         await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);

+ 11 - 9
src/lib/context-hub/composer.spec.ts

@@ -4,6 +4,8 @@ import type { ContextPack } from "@/lib/novel/context-engine"
 import { composeContext } from "./composer"
 import { estimateContextTokens } from "./token-estimator"
 
+const dependencyStamp = { fingerprint: "test", sourceCount: 0, kinds: [] }
+
 function pack(overrides: Partial<ContextPack> = {}): ContextPack {
   return {
     task: "续写第二章",
@@ -33,7 +35,7 @@ function pack(overrides: Partial<ContextPack> = {}): ContextPack {
 
 describe("composeContext", () => {
   it("keeps the stable core byte-identical with fixed field ordering", () => {
-    const input = { contextPack: pack(), dependencies: { outline: 1 } }
+    const input = { contextPack: pack(), dependencyStamp }
     const first = composeContext(input)
     const second = composeContext(input)
 
@@ -45,7 +47,7 @@ describe("composeContext", () => {
   it("places explicit references ahead of automatically selected dynamic context", () => {
     const result = composeContext({
       contextPack: pack(),
-      dependencies: {},
+      dependencyStamp,
       referenceContext: ["@引用:人物/林默.md\n林默怕水"],
     })
 
@@ -55,7 +57,7 @@ describe("composeContext", () => {
   it("expands to chapter originals when confidence is low", () => {
     const result = composeContext({
       contextPack: pack({ recentChapterContents: ["第一章原文"], searchResults: "补充检索" }),
-      dependencies: {},
+      dependencyStamp,
       confidence: 0.4,
     })
 
@@ -67,7 +69,7 @@ describe("composeContext", () => {
   it("trims low-priority search content before required task facts", () => {
     const result = composeContext({
       contextPack: pack({ searchResults: "低相关背景".repeat(500) }),
-      dependencies: {},
+      dependencyStamp,
       tokenBudget: 180,
       confidence: 0.9,
     })
@@ -85,7 +87,7 @@ describe("composeContext", () => {
         searchResults: "候选检索".repeat(500),
       }),
       sessionSummary: "当前会话已确认:继续第二章,不揭露凶手。",
-      dependencies: {},
+      dependencyStamp,
       confidence: 0.9,
       tokenBudget: 6000,
     })
@@ -99,7 +101,7 @@ describe("composeContext", () => {
         recentChapterContents: ["章节原文".repeat(1000)],
         searchResults: "低优先级检索".repeat(100),
       }),
-      dependencies: {},
+      dependencyStamp,
       confidence: 0.9,
       tokenBudget: 100_000,
     }
@@ -131,7 +133,7 @@ describe("composeContext", () => {
         mustDo: "必须做到".repeat(500),
       }),
       sessionSummary: "会话摘要".repeat(1000),
-      dependencies: {},
+      dependencyStamp,
       tokenBudget: 800,
     })
 
@@ -146,13 +148,13 @@ describe("composeContext", () => {
   it("无显式预算时按模型上下文窗口安全比例计算,而不是写死上限", () => {
     const large = composeContext({
       contextPack: pack(),
-      dependencies: {},
+      dependencyStamp,
       maxContextSize: 204_800,
       tokenBudget: 0,
     })
     const small = composeContext({
       contextPack: pack(),
-      dependencies: {},
+      dependencyStamp,
       maxContextSize: 32_000,
       tokenBudget: 0,
     })

+ 7 - 4
src/lib/context-hub/composer.ts

@@ -1,12 +1,12 @@
 import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 import { contextPackToPrompt, type ContextPack } from "@/lib/novel/context-engine"
 import { estimateContextTokens } from "./token-estimator"
-import type { ContextHubStats } from "./types"
+import type { ContextHubStats, DependencyStamp } from "./types"
 
 export interface ComposeContextInput {
   contextPack: ContextPack
   sessionSummary?: string
-  dependencies: Record<string, number>
+  dependencyStamp: DependencyStamp
   referenceContext?: string[]
   confidence?: number
   /** Explicit token budget; 0 / undefined = window-derived safe cap. */
@@ -18,7 +18,7 @@ export interface ComposedContext {
   stableCore: string
   sessionSummary: string
   dynamicContext: string
-  dependencies: Record<string, number>
+  dependencyStamp: DependencyStamp
   stats: ContextHubStats
 }
 
@@ -196,7 +196,10 @@ export function composeContext(input: ComposeContextInput): ComposedContext {
     stableCore,
     sessionSummary,
     dynamicContext,
-    dependencies: { ...input.dependencies },
+    dependencyStamp: {
+      ...input.dependencyStamp,
+      kinds: [...input.dependencyStamp.kinds],
+    },
     stats: {
       hits: 0,
       refreshed: 0,

+ 42 - 21
src/lib/context-hub/context-hub.spec.ts

@@ -1,7 +1,21 @@
 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"
+import type {
+  CachedArtifact,
+  ContextHubSnapshot,
+  ContextSourceKind,
+  DependencyStamp,
+  StableBundle,
+} from "./types"
+
+function stamp(
+  fingerprint = "project-v1",
+  kinds: ContextSourceKind[] = ["outline"],
+  sourceCount = 1,
+): DependencyStamp {
+  return { fingerprint, kinds, sourceCount }
+}
 
 function pack(): ContextPack {
   return {
@@ -35,7 +49,11 @@ function createHarness() {
   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 })),
+    getDependencyStamp: vi.fn(async (kinds?: ContextSourceKind[]) => stamp(
+      kinds ? "stable-v1" : "project-v1",
+      kinds ?? ["outline"],
+    )),
+    getDependencyPreview: vi.fn(() => ["E:/Novel/wiki/outlines/main.md"]),
     markDirty: vi.fn(),
     dispose: vi.fn(),
   }
@@ -47,6 +65,7 @@ function createHarness() {
     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 () => {}),
+    dispose: vi.fn(),
   }
   const buildContextPack = vi.fn(async () => pack())
   const readFile = vi.fn(async (path: string) => `内容:${path}:${readFile.mock.calls.length}`)
@@ -82,13 +101,13 @@ describe("ContextHubController", () => {
     const harness = createHarness()
     const chat = await harness.controller.prepare({
       ...request,
-      existingSummary: { text: "AI 对话摘要", dependencies: { "E:/Novel/wiki/outlines/main.md": 1 }, updatedAt: 1 },
+      existingSummary: { text: "AI 对话摘要", dependencyFingerprint: "project-v1", 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 },
+      existingSummary: { text: "AI 大纲摘要", dependencyFingerprint: "project-v1", updatedAt: 1 },
     })
 
     expect(chat?.sessionSummary).toBe("AI 对话摘要")
@@ -101,7 +120,7 @@ describe("ContextHubController", () => {
     const result = await harness.controller.prepare({
       ...request,
       forceRefresh: true,
-      existingSummary: { text: "旧摘要", dependencies: { "E:/Novel/wiki/outlines/main.md": 1 }, updatedAt: 1 },
+      existingSummary: { text: "旧摘要", dependencyFingerprint: "project-v1", updatedAt: 1 },
     })
 
     expect(result?.sessionSummary).toBe("")
@@ -143,13 +162,8 @@ describe("ContextHubController", () => {
   it("keeps the stable core cached when only an unrelated chapter changes", async () => {
     const harness = createHarness()
     let chapterRevision = 1
-    harness.registry.getDependencies.mockImplementation((kinds?: string[]) => (
-      kinds
-        ? { "E:/Novel/wiki/outlines/main.md": 1 }
-        : {
-            "E:/Novel/wiki/outlines/main.md": 1,
-            "E:/Novel/wiki/chapters/chapter-1.md": chapterRevision,
-          }
+    harness.registry.getDependencyStamp.mockImplementation(async (kinds?: ContextSourceKind[]) => (
+      kinds ? stamp("stable-v1", kinds) : stamp(`project-${chapterRevision}`, ["outline", "chapter"], 2)
     ))
 
     await harness.controller.prepare(request)
@@ -163,15 +177,13 @@ describe("ContextHubController", () => {
     }))
   })
 
-  it("keeps the stable core cached when source revisions change but its bytes stay identical", async () => {
+  it("keeps the stable core cached when a refresh leaves its content fingerprint unchanged", async () => {
     const harness = createHarness()
-    let outlineRevision = 1
-    harness.registry.getDependencies.mockImplementation(() => ({
-      "E:/Novel/wiki/outlines/main.md": outlineRevision,
-    }))
+    harness.registry.getDependencyStamp.mockImplementation(async (kinds?: ContextSourceKind[]) => (
+      stamp("outline-content-hash", kinds ?? ["outline"])
+    ))
 
     await harness.controller.prepare(request)
-    outlineRevision = 2
     const second = await harness.controller.prepare({ ...request, task: "继续生成大纲" })
 
     expect(second?.cacheItems).toContainEqual(expect.objectContaining({
@@ -182,9 +194,7 @@ describe("ContextHubController", () => {
 
   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,
-    })
+    harness.registry.getDependencyPreview.mockReturnValue(["e:/Novel/wiki/outlines/main.md"])
 
     const result = await harness.controller.prepare(request)
 
@@ -271,4 +281,15 @@ describe("ContextHubController", () => {
     expect(result).toBeNull()
     expect(harness.buildContextPack).not.toHaveBeenCalled()
   })
+
+  it("releases storage and refuses new prepares after disposal", async () => {
+    const harness = createHarness()
+
+    harness.controller.dispose()
+
+    await expect(harness.controller.prepare(request)).resolves.toBeNull()
+    expect(harness.registry.dispose).toHaveBeenCalledOnce()
+    expect(harness.storage.dispose).toHaveBeenCalledOnce()
+    expect(harness.buildContextPack).not.toHaveBeenCalled()
+  })
 })

+ 57 - 9
src/lib/context-hub/context-hub.ts

@@ -1,4 +1,6 @@
 import {
+  deleteFile,
+  fileExists,
   readFile as readProjectFile,
   subscribeProjectFileMutations,
   type ProjectFileMutation,
@@ -23,17 +25,21 @@ import {
   type ContextHubSnapshot,
   type ContextHubSnapshotRef,
   type ContextSourceKind,
+  type DependencyStamp,
   type StableBundle,
 } from "./types"
 
 interface HubRegistry {
   refresh(): Promise<SourceRefreshResult>
-  getDependencies(kinds?: ContextSourceKind[]): Record<string, number>
+  getDependencyStamp(kinds?: ContextSourceKind[]): Promise<DependencyStamp>
+  getDependencyPreview(kinds?: ContextSourceKind[], limit?: number): string[]
   markDirty(path: string): void
   dispose(): void
 }
 
 interface HubStorage {
+  initialize?(): Promise<void>
+  dispose?(): void
   readArtifact<T>(key: string): Promise<CachedArtifact<T> | null>
   writeArtifact<T>(key: string, artifact: CachedArtifact<T>): Promise<void>
   readStableBundle(surface: ContextHubRequest["surface"]): Promise<StableBundle | null>
@@ -100,6 +106,7 @@ function withRelativeDependencyPaths(
 ): ContextCacheItemTrace[] {
   return items.map((item) => ({
     ...item,
+    dependencyStamp: { ...item.dependencyStamp, kinds: [...item.dependencyStamp.kinds] },
     dependencyPaths: item.dependencyPaths.map((path) => toProjectRelativePath(projectPath, path)),
   }))
 }
@@ -113,6 +120,7 @@ export class ContextHubController implements ContextHub {
   private readonly unsubscribe: () => void
   private readonly fileCache = new Map<string, string>()
   private readonly pending = new Map<string, Promise<ContextHubResult | null>>()
+  private disposed = false
 
   constructor(projectPath: string, dependencies: ContextHubControllerDependencies = {}) {
     this.projectPath = normalizePath(projectPath)
@@ -128,6 +136,7 @@ export class ContextHubController implements ContextHub {
   }
 
   prepare(request: ContextHubRequest): Promise<ContextHubResult | null> {
+    if (this.disposed) return Promise.resolve(null)
     if (request.intent === "review" || request.intent === "lint") return Promise.resolve(null)
     const key = prepareKey(request)
     const pending = this.pending.get(key)
@@ -137,7 +146,12 @@ export class ContextHubController implements ContextHub {
     return operation
   }
 
+  initialize(): Promise<void> {
+    return this.storage.initialize?.() ?? Promise.resolve()
+  }
+
   async readFile(path: string): Promise<string> {
+    if (this.disposed) throw new Error("Context Hub 已释放")
     const normalized = normalizeContextPath(path)
     const cached = this.fileCache.get(normalized)
     if (cached !== undefined) return cached
@@ -156,6 +170,7 @@ export class ContextHubController implements ContextHub {
       stats: { ...result.stats },
       items: result.cacheItems.map((item) => ({
         ...item,
+        dependencyStamp: { ...item.dependencyStamp, kinds: [...item.dependencyStamp.kinds] },
         dependencyPaths: [...item.dependencyPaths],
       })),
       stableCore: result.stableCore,
@@ -185,14 +200,18 @@ export class ContextHubController implements ContextHub {
   }
 
   markDirty(path: string): void {
+    if (this.disposed) return
     const normalized = normalizeContextPath(path)
     this.fileCache.delete(normalized)
     this.registry.markDirty(normalized)
   }
 
   dispose(): void {
+    if (this.disposed) return
+    this.disposed = true
     this.unsubscribe()
     this.registry.dispose()
+    this.storage.dispose?.()
     this.fileCache.clear()
     this.pending.clear()
   }
@@ -208,8 +227,9 @@ export class ContextHubController implements ContextHub {
   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 stableDependencies = this.registry.getDependencies(STABLE_SOURCE_KINDS)
+    const dependencyStamp = await this.registry.getDependencyStamp()
+    const stableDependencyStamp = await this.registry.getDependencyStamp(STABLE_SOURCE_KINDS)
+    const stableDependencyPaths = this.registry.getDependencyPreview(STABLE_SOURCE_KINDS, 20)
     const warnings: string[] = []
     const cacheAdapter = new DataSourceCacheAdapter({
       registry: this.registry,
@@ -226,14 +246,14 @@ export class ContextHubController implements ContextHub {
       },
     )
     const summaryFresh = !request.forceRefresh
-      && isSessionSummaryFresh(request.existingSummary, dependencies)
+      && isSessionSummaryFresh(request.existingSummary, dependencyStamp.fingerprint)
     if (request.existingSummary && !summaryFresh) {
       warnings.push("项目资料已更新,本轮未使用旧会话摘要。")
     }
     const composed = composeContext({
       contextPack,
       sessionSummary: summaryFresh ? request.existingSummary?.text : undefined,
-      dependencies,
+      dependencyStamp,
       referenceContext: request.references,
       confidence: confidenceFor(request, contextPack),
       tokenBudget: request.tokenBudget,
@@ -248,6 +268,7 @@ export class ContextHubController implements ContextHub {
       const existing = await this.storage.readStableBundle(request.surface)
       if (
         existing
+        && existing.dependencyStamp.fingerprint === stableDependencyStamp.fingerprint
         && existing.text === composed.stableCore
       ) {
         stableHits = 1
@@ -255,14 +276,16 @@ export class ContextHubController implements ContextHub {
           key: `stable-core:${request.surface}`,
           sourceName: "stableCore",
           status: "hit",
-          dependencyPaths: Object.keys(stableDependencies),
+          dependencyStamp: stableDependencyStamp,
+          dependencyPaths: stableDependencyPaths,
+          dependencyPathsTruncated: stableDependencyStamp.sourceCount > stableDependencyPaths.length,
         })
       } else {
         await this.storage.writeStableBundle(request.surface, {
           schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
           surface: request.surface,
           text: composed.stableCore,
-          dependencies: stableDependencies,
+          dependencyStamp: stableDependencyStamp,
           updatedAt: Date.now(),
         })
         stableRefreshes = 1
@@ -270,7 +293,9 @@ export class ContextHubController implements ContextHub {
           key: `stable-core:${request.surface}`,
           sourceName: "stableCore",
           status: "refreshed",
-          dependencyPaths: Object.keys(stableDependencies),
+          dependencyStamp: stableDependencyStamp,
+          dependencyPaths: stableDependencyPaths,
+          dependencyPathsTruncated: stableDependencyStamp.sourceCount > stableDependencyPaths.length,
         })
       }
     } catch {
@@ -279,7 +304,9 @@ export class ContextHubController implements ContextHub {
         key: `stable-core:${request.surface}`,
         sourceName: "stableCore",
         status: "failed",
-        dependencyPaths: Object.keys(stableDependencies),
+        dependencyStamp: stableDependencyStamp,
+        dependencyPaths: stableDependencyPaths,
+        dependencyPathsTruncated: stableDependencyStamp.sourceCount > stableDependencyPaths.length,
       })
       warnings.push("稳定上下文缓存写入失败,本轮已继续使用内存中的最新内容。")
     }
@@ -311,3 +338,24 @@ export function getContextHub(projectPath: string): ContextHubController {
   projectHubs.set(normalized, hub)
   return hub
 }
+
+export function disposeAllContextHubs(): void {
+  for (const hub of projectHubs.values()) hub.dispose()
+  projectHubs.clear()
+}
+
+export async function initializeProjectContextCache(projectPath: string): Promise<void> {
+  const normalized = normalizePath(projectPath)
+  try {
+    await getContextHub(normalized).initialize()
+  } catch (error) {
+    console.warn("[Context Hub] v2 缓存初始化失败,将在首次使用时重试:", error)
+  }
+
+  const legacyPath = `${normalized}/.qmai/context-cache/v1`
+  void fileExists(legacyPath)
+    .then((exists) => exists ? deleteFile(legacyPath) : undefined)
+    .catch((error) => {
+      console.warn("[Context Hub] 旧版缓存后台清理失败,下次打开项目时将重试:", error)
+    })
+}

+ 67 - 3
src/lib/context-hub/data-source-cache.spec.ts

@@ -1,7 +1,7 @@
 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"
+import type { CachedArtifact, ContextSourceKind, DependencyStamp } from "./types"
 
 const context: ContextLoadContext = {
   projectPath: "E:/Novel",
@@ -22,13 +22,25 @@ function createHarness() {
     outline: { "E:/Novel/wiki/outlines/main.md": 1 },
     setting: { "E:/Novel/wiki/settings/world.md": 1 },
     entity: {},
+    snapshot: {},
   }
   const registry = {
     refresh: vi.fn(async () => ({ versions: {}, changedPaths: [] })),
-    getDependencies: vi.fn((kinds?: ContextSourceKind[]) => Object.assign(
+    getDependencyStamp: vi.fn(async (kinds?: ContextSourceKind[]): Promise<DependencyStamp> => {
+      const dependencies = Object.assign(
+        {},
+        ...(kinds ?? []).map((kind) => revisions[kind] ?? {}),
+      ) as Record<string, number>
+      return {
+        fingerprint: JSON.stringify(dependencies),
+        sourceCount: Object.keys(dependencies).length,
+        kinds: [...(kinds ?? [])],
+      }
+    }),
+    getDependencyPreview: vi.fn((kinds?: ContextSourceKind[]) => Object.keys(Object.assign(
       {},
       ...(kinds ?? []).map((kind) => revisions[kind] ?? {}),
-    )),
+    ))),
   }
   const storage = {
     readArtifact: vi.fn(async (key: string) => artifacts.get(key) ?? null),
@@ -116,4 +128,56 @@ describe("DataSourceCacheAdapter", () => {
       "failed",
     ])
   })
+
+  it("uses chapter scope for retrieval and project scope for related settings", async () => {
+    const harness = createHarness()
+    const retrieval: DataSource<string> = { name: "retrieval", priority: 1, load: async () => "" }
+    const relatedSettings: DataSource<string> = { name: "relatedSettings", priority: 1, load: async () => "" }
+    const loadRetrieval = vi.fn(async () => "检索索引")
+    const loadSettings = vi.fn(async () => "设定")
+
+    await harness.adapter.load(retrieval, context, loadRetrieval)
+    await harness.adapter.load(retrieval, { ...context, task: "完全不同的提示词" }, loadRetrieval)
+    await harness.adapter.load(relatedSettings, context, loadSettings)
+    await harness.adapter.load(relatedSettings, { ...context, task: "另一个任务", chapterNumber: 99 }, loadSettings)
+
+    expect(loadRetrieval).toHaveBeenCalledOnce()
+    expect(loadSettings).toHaveBeenCalledOnce()
+  })
+
+  it("returns a deeply equal value on a cache hit and a forced rebuild", async () => {
+    const harness = createHarness()
+    const source: DataSource<{ outline: string; chapters: number[] }> = {
+      name: "outline",
+      priority: 1,
+      load: async () => ({ outline: "", chapters: [] }),
+    }
+    const expected = { outline: "第一卷", chapters: [1, 2, 3] }
+    const directLoad = vi.fn(async () => ({ ...expected, chapters: [...expected.chapters] }))
+
+    const refreshed = await harness.adapter.load(source, context, directLoad)
+    const hit = await harness.adapter.load(source, context, directLoad)
+    const forcedAdapter = new DataSourceCacheAdapter({
+      registry: harness.registry,
+      storage: harness.storage,
+      forceRefresh: true,
+    })
+    const forced = await forcedAdapter.load(source, context, directLoad)
+
+    expect(hit).toEqual(refreshed)
+    expect(forced).toEqual(refreshed)
+  })
+
+  it("invalidates search results when a snapshot or community-summary file is added", async () => {
+    const harness = createHarness()
+    const source: DataSource<string> = { name: "searchResults", priority: 1, load: async () => "" }
+    const directLoad = vi.fn(async () => `结果-${directLoad.mock.calls.length}`)
+
+    await harness.adapter.load(source, context, directLoad)
+    await harness.adapter.load(source, context, directLoad)
+    harness.revisions.snapshot!["E:/Novel/.novel/community-summaries/new.json"] = 1
+    await harness.adapter.load(source, context, directLoad)
+
+    expect(directLoad).toHaveBeenCalledTimes(2)
+  })
 })

+ 47 - 31
src/lib/context-hub/data-source-cache.ts

@@ -4,16 +4,20 @@ import type {
   DataSourceLoadAdapter,
 } from "@/lib/novel/context-data-source"
 import { getDataSourceKinds } from "./source-paths"
+import { sha256Text } from "./fingerprint"
 import {
   CONTEXT_CACHE_SCHEMA_VERSION,
   type CachedArtifact,
+  type ContextCacheScope,
   type ContextCacheItemTrace,
   type ContextSourceKind,
+  type DependencyStamp,
 } from "./types"
 
 interface DataSourceCacheRegistry {
   refresh(): Promise<unknown>
-  getDependencies(kinds?: ContextSourceKind[]): Record<string, number>
+  getDependencyStamp(kinds?: ContextSourceKind[]): Promise<DependencyStamp>
+  getDependencyPreview(kinds?: ContextSourceKind[], limit?: number): string[]
 }
 
 interface DataSourceCacheStorage {
@@ -39,6 +43,7 @@ const STATIC_SOURCES = new Set([
   "soulDoc",
   "characterAuras",
   "storyFrameworkBinding",
+  "relatedSettings",
 ])
 
 const CHAPTER_SCOPED_SOURCES = new Set([
@@ -55,6 +60,7 @@ const CHAPTER_SCOPED_SOURCES = new Set([
   "revisionFeedback",
   "cognitionText",
   "sectionBriefing",
+  "retrieval",
 ])
 
 function canonicalize(value: unknown): unknown {
@@ -67,32 +73,23 @@ function canonicalize(value: unknown): unknown {
   )
 }
 
-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 {
+async function sourceRequestKey(sourceName: string, context: ContextLoadContext): Promise<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)))}`
+  return `data-source:${sourceName}:${await sha256Text(JSON.stringify(canonicalize(scope)))}`
+}
+
+function dependencyStampsMatch(cached: DependencyStamp, current: DependencyStamp): boolean {
+  return cached.fingerprint === current.fingerprint
 }
 
-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 cacheScopeFor(sourceName: string): ContextCacheScope {
+  if (STATIC_SOURCES.has(sourceName)) return "static"
+  if (CHAPTER_SCOPED_SOURCES.has(sourceName)) return "chapter"
+  return "task"
 }
 
 function hasCacheableValue(value: unknown): boolean {
@@ -115,12 +112,20 @@ export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
     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 kinds = getDataSourceKinds(source.name)
+    const dependencyStamp = await this.options.registry.getDependencyStamp(kinds)
+    const dependencyPaths = this.options.registry.getDependencyPreview(kinds, 20)
+    const key = await 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)
+    const operation = this.loadInternal(
+      key,
+      source.name,
+      dependencyStamp,
+      dependencyPaths,
+      directLoad,
+    )
       .finally(() => this.pending.delete(key))
     this.pending.set(key, operation)
     return operation
@@ -133,6 +138,7 @@ export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
   getTraceItems(): ContextCacheItemTrace[] {
     return this.traceItems.map((item) => ({
       ...item,
+      dependencyStamp: { ...item.dependencyStamp, kinds: [...item.dependencyStamp.kinds] },
       dependencyPaths: [...item.dependencyPaths],
     }))
   }
@@ -140,40 +146,50 @@ export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
   private async loadInternal<T>(
     key: string,
     sourceName: string,
-    dependencies: Record<string, number>,
+    dependencyStamp: DependencyStamp,
+    dependencyPaths: string[],
     directLoad: () => Promise<T>,
   ): Promise<T> {
-    const dependencyPaths = Object.keys(dependencies)
+    const trace = (status: ContextCacheItemTrace["status"]): ContextCacheItemTrace => ({
+      key,
+      sourceName,
+      status,
+      dependencyStamp,
+      dependencyPaths,
+      dependencyPathsTruncated: dependencyStamp.sourceCount > dependencyPaths.length,
+    })
     if (!this.options.forceRefresh) {
       try {
         const cached = await this.options.storage.readArtifact<T>(key)
-        if (cached && dependenciesMatch(cached.dependencies, dependencies)) {
+        if (cached && dependencyStampsMatch(cached.dependencyStamp, dependencyStamp)) {
           this.stats.hits += 1
-          this.traceItems.push({ key, sourceName, status: "hit", dependencyPaths })
+          this.traceItems.push(trace("hit"))
           return cached.value
         }
       } catch {
         this.stats.failures += 1
-        this.traceItems.push({ key, sourceName, status: "failed", dependencyPaths })
+        this.traceItems.push(trace("failed"))
       }
     }
 
     const value = await directLoad()
     this.stats.refreshed += 1
-    this.traceItems.push({ key, sourceName, status: "refreshed", dependencyPaths })
+    this.traceItems.push(trace("refreshed"))
     if (!hasCacheableValue(value)) return value
 
     try {
       await this.options.storage.writeArtifact(key, {
         schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
         key,
+        sourceName,
+        scope: cacheScopeFor(sourceName),
         value,
-        dependencies,
+        dependencyStamp,
         createdAt: Date.now(),
       })
     } catch {
       this.stats.failures += 1
-      this.traceItems.push({ key, sourceName, status: "failed", dependencyPaths })
+      this.traceItems.push(trace("failed"))
     }
     return value
   }

+ 5 - 0
src/lib/context-hub/fingerprint.ts

@@ -0,0 +1,5 @@
+export async function sha256Text(value: string): Promise<string> {
+  const bytes = new TextEncoder().encode(value)
+  const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes)
+  return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
+}

+ 9 - 1
src/lib/context-hub/index.ts

@@ -1,6 +1,12 @@
-export { ContextHubController, getContextHub } from "./context-hub"
+export {
+  ContextHubController,
+  disposeAllContextHubs,
+  getContextHub,
+  initializeProjectContextCache,
+} from "./context-hub"
 export {
   buildSessionContextSummary,
+  isLegacySessionContextSummary,
   isSessionSummaryFresh,
   normalizeSessionContextSummary,
   selectContextHistoryMessages,
@@ -14,7 +20,9 @@ export type {
   ContextHubSnapshot,
   ContextHubSnapshotRef,
   ContextHubStats,
+  ContextCacheScope,
   ContextIntent,
   ContextSurface,
+  DependencyStamp,
   SessionContextSummary,
 } from "./types"

+ 52 - 0
src/lib/context-hub/migration.spec.ts

@@ -0,0 +1,52 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const fsMocks = vi.hoisted(() => ({
+  createDirectory: vi.fn(async () => {}),
+  deleteFile: vi.fn(async () => {}),
+  fileExists: vi.fn(async (path: string) => path.endsWith("/context-cache/v1")),
+  getFileSize: vi.fn(async () => 0),
+  getFileMd5: vi.fn(async () => "hash"),
+  listDirectory: vi.fn(async () => []),
+  readFile: vi.fn(async () => { throw new Error("文件不存在") }),
+  subscribeProjectFileMutations: vi.fn(() => () => {}),
+  writeFileAtomic: vi.fn(async () => {}),
+}))
+
+vi.mock("@/commands/fs", () => fsMocks)
+
+import {
+  disposeAllContextHubs,
+  initializeProjectContextCache,
+} from "./context-hub"
+
+describe("context cache v1 migration", () => {
+  beforeEach(() => {
+    disposeAllContextHubs()
+    fsMocks.createDirectory.mockClear()
+    fsMocks.deleteFile.mockReset().mockResolvedValue(undefined)
+    fsMocks.fileExists.mockReset().mockImplementation(async (path: string) => path.endsWith("/context-cache/v1"))
+    fsMocks.listDirectory.mockReset().mockResolvedValue([])
+    fsMocks.readFile.mockReset().mockRejectedValue(new Error("文件不存在"))
+  })
+
+  it("initializes v2 and deletes v1 in the background without reading its manifest", async () => {
+    await initializeProjectContextCache("E:/Novel")
+
+    await vi.waitFor(() => {
+      expect(fsMocks.deleteFile).toHaveBeenCalledWith("E:/Novel/.qmai/context-cache/v1")
+    })
+    expect(fsMocks.createDirectory).toHaveBeenCalledWith("E:/Novel/.qmai/context-cache/v2")
+    expect(fsMocks.readFile).not.toHaveBeenCalledWith("E:/Novel/.qmai/context-cache/v1/manifest.json")
+  })
+
+  it("does not block opening when legacy deletion fails and retries on the next open", async () => {
+    fsMocks.deleteFile.mockRejectedValueOnce(new Error("文件占用"))
+
+    await expect(initializeProjectContextCache("E:/Novel")).resolves.toBeUndefined()
+    await vi.waitFor(() => expect(fsMocks.deleteFile).toHaveBeenCalledTimes(1))
+
+    fsMocks.deleteFile.mockResolvedValue(undefined)
+    await expect(initializeProjectContextCache("E:/Novel")).resolves.toBeUndefined()
+    await vi.waitFor(() => expect(fsMocks.deleteFile).toHaveBeenCalledTimes(2))
+  })
+})

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

@@ -4,7 +4,7 @@ import { useOutlineChatStore } from "@/stores/outline-chat-store"
 
 const summary = {
   text: "已确认主角不能提前知道真相。",
-  dependencies: { "E:/Novel/wiki/outlines/main.md": 2 },
+  dependencyFingerprint: "outline-v2",
   updatedAt: 100,
 }
 

+ 24 - 10
src/lib/context-hub/session-summary.spec.ts

@@ -1,7 +1,9 @@
 import { describe, expect, it } from "vitest"
 import {
   buildSessionContextSummary,
+  isLegacySessionContextSummary,
   isSessionSummaryFresh,
+  normalizeSessionContextSummary,
   selectContextHistoryMessages,
 } from "./session-summary"
 
@@ -12,7 +14,7 @@ describe("session context summary", () => {
         { role: "user", content: "主角不能提前知道真相。请继续第二章。" },
         { role: "assistant", content: "第二章将保留悬念,并让线索出现在旧车站。" },
       ],
-      dependencies: { "E:/Novel/wiki/outlines/main.md": 2 },
+      dependencyFingerprint: "outline-v2",
     }
 
     const first = buildSessionContextSummary(input)
@@ -21,28 +23,40 @@ describe("session context summary", () => {
     expect(first.text).toContain("用户:主角不能提前知道真相")
     expect(first.text).toContain("助手:第二章将保留悬念")
     expect(first.text).toBe(second.text)
-    expect(first.dependencies).toEqual(input.dependencies)
+    expect(first.dependencyFingerprint).toBe(input.dependencyFingerprint)
   })
 
   it("bounds long summaries deterministically", () => {
     const summary = buildSessionContextSummary({
       messages: [{ role: "user", content: "约束。".repeat(100) }],
-      dependencies: {},
+      dependencyFingerprint: "empty",
       maxChars: 80,
     })
 
     expect(summary.text.length).toBeLessThanOrEqual(80)
   })
 
-  it("invalidates only when a recorded dependency revision changes", () => {
+  it("invalidates whenever the project dependency fingerprint changes", () => {
     const summary = buildSessionContextSummary({
       messages: [],
-      dependencies: { outline: 2 },
+      dependencyFingerprint: "outline-v2",
     })
 
-    expect(isSessionSummaryFresh(summary, { outline: 2, unrelated: 9 })).toBe(true)
-    expect(isSessionSummaryFresh(summary, { outline: 3 })).toBe(false)
-    expect(isSessionSummaryFresh(undefined, { outline: 2 })).toBe(false)
+    expect(isSessionSummaryFresh(summary, "outline-v2")).toBe(true)
+    expect(isSessionSummaryFresh(summary, "outline-v3")).toBe(false)
+    expect(isSessionSummaryFresh(undefined, "outline-v2")).toBe(false)
+  })
+
+  it("preserves legacy summary text while dropping the full dependency table", () => {
+    const legacy = {
+      text: "旧摘要",
+      dependencies: { "E:/Novel/wiki/entities/one.md": 7 },
+      updatedAt: 10,
+    }
+
+    expect(isLegacySessionContextSummary(legacy)).toBe(true)
+    expect(normalizeSessionContextSummary(legacy)).toEqual({ text: "旧摘要", updatedAt: 10 })
+    expect(isSessionSummaryFresh(normalizeSessionContextSummary(legacy), "project-v2")).toBe(false)
   })
 
   it("keeps only the latest two messages when a summary is already in system context", () => {
@@ -68,7 +82,7 @@ describe("session context summary", () => {
       { role: "assistant", content: "最近进展:已经完成第十章。" },
     ]
 
-    const summary = buildSessionContextSummary({ messages, dependencies: {}, maxChars: 1000 })
+    const summary = buildSessionContextSummary({ messages, dependencyFingerprint: "test", maxChars: 1000 })
 
     expect(summary.text).toContain("初始任务")
     expect(summary.text).toContain("禁止让主角提前知道真相")
@@ -82,7 +96,7 @@ describe("session context summary", () => {
         { role: "assistant", content: "中间分析。".repeat(80) },
         { role: "assistant", content: "最新进展:已经完成关键冲突设计。" },
       ],
-      dependencies: {},
+      dependencyFingerprint: "test",
       maxChars: 120,
     })
 

+ 19 - 15
src/lib/context-hub/session-summary.ts

@@ -7,7 +7,7 @@ export interface SessionSummaryMessage {
 
 export interface BuildSessionContextSummaryInput {
   messages: SessionSummaryMessage[]
-  dependencies: Record<string, number>
+  dependencyFingerprint: string
   maxChars?: number
 }
 
@@ -87,38 +87,42 @@ export function buildSessionContextSummary(
 
   return {
     text,
-    dependencies: { ...input.dependencies },
+    dependencyFingerprint: input.dependencyFingerprint,
     updatedAt: Date.now(),
   }
 }
 
 export function isSessionSummaryFresh(
   summary: SessionContextSummary | undefined,
-  currentDependencies: Record<string, number>,
+  currentDependencyFingerprint: string,
 ): boolean {
-  if (!summary) return false
-  return Object.entries(summary.dependencies).every(
-    ([path, revision]) => currentDependencies[path] === revision,
+  return Boolean(
+    summary?.dependencyFingerprint
+    && summary.dependencyFingerprint === currentDependencyFingerprint,
   )
 }
 
+export function isLegacySessionContextSummary(value: unknown): boolean {
+  if (typeof value === "string") return true
+  if (!value || typeof value !== "object") return false
+  const candidate = value as { text?: unknown; dependencies?: unknown; dependencyFingerprint?: unknown }
+  return typeof candidate.text === "string"
+    && typeof candidate.dependencyFingerprint !== "string"
+    && candidate.dependencies !== undefined
+}
+
 export function normalizeSessionContextSummary(value: unknown): SessionContextSummary | undefined {
   if (typeof value === "string") {
-    return { text: value, dependencies: {}, updatedAt: 0 }
+    return { text: value, 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,
+    ...(typeof candidate.dependencyFingerprint === "string" && candidate.dependencyFingerprint
+      ? { dependencyFingerprint: candidate.dependencyFingerprint }
+      : {}),
     updatedAt: typeof candidate.updatedAt === "number" && Number.isFinite(candidate.updatedAt)
       ? candidate.updatedAt
       : 0,

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

@@ -22,6 +22,7 @@ describe("context source paths", () => {
     ["E:/Novel/.qmai/book-analysis-context.json", "book-analysis"],
     ["E:/Novel/.qmai/character-aura.json", "entity"],
     ["E:/Novel/.qmai/simulations/latest.json", "deduction"],
+    ["E:/Novel/retrieval/index.md", "retrieval"],
     ["E:/Novel/.qmai/context-cache/v1/manifest.json", "ignored"],
   ] as const)("classifies %s as %s", (path, expected) => {
     expect(classifyContextSourcePath(projectPath, path)).toBe(expected)
@@ -43,5 +44,8 @@ describe("context source paths", () => {
     expect(getDataSourceKinds("recentChapterContents")).toEqual(["chapter"])
     expect(getDataSourceKinds("storyFrameworkBinding")).toEqual(["outline", "setting", "deduction"])
     expect(getDataSourceKinds("bookAnalysisReferences")).toEqual(["book-analysis"])
+    expect(getDataSourceKinds("retrieval")).toEqual(["retrieval"])
+    expect(getDataSourceKinds("searchResults")).toContain("snapshot")
+    expect(getDataSourceKinds("graphSearchResults")).toContain("snapshot")
   })
 })

+ 4 - 3
src/lib/context-hub/source-paths.ts

@@ -15,15 +15,15 @@ const DATA_SOURCE_KINDS: Record<string, ContextSourceKind[]> = {
   canonRules: ["setting"],
   writingStyle: ["setting"],
   bookAnalysisReferences: ["book-analysis"],
-  searchResults: ["chapter", "outline", "memory", "setting", "entity"],
-  graphSearchResults: ["chapter", "outline", "memory", "setting", "entity"],
+  searchResults: ["chapter", "outline", "memory", "setting", "entity", "snapshot"],
+  graphSearchResults: ["chapter", "outline", "memory", "setting", "entity", "snapshot"],
   revisionFeedback: ["chapter", "snapshot"],
   cognitionText: ["entity"],
   soulDoc: ["soul"],
   characterAuras: ["entity"],
   sectionBriefing: ["outline", "snapshot"],
   storyFrameworkBinding: ["outline", "setting", "deduction"],
-  retrieval: ["chapter", "outline", "memory", "setting", "entity", "snapshot"],
+  retrieval: ["retrieval"],
 }
 
 export function normalizeContextPath(path: string): string {
@@ -51,6 +51,7 @@ export function classifyContextSourcePath(projectPath: string, path: string): Co
   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"
+  if (relative.startsWith("retrieval/")) return "retrieval"
   return "other"
 }
 

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

@@ -61,6 +61,7 @@ describe("ContextSourceRegistry", () => {
     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 }])
+    expect(calls).toContainEqual(["E:/Novel/retrieval", { maxDepth: 30 }])
   })
 
   it("propagates a scan error when an existing directory is unreadable", async () => {
@@ -136,4 +137,55 @@ describe("ContextSourceRegistry", () => {
 
     expect(harness.scanFiles).toHaveBeenCalledTimes(1)
   })
+
+  it("changes a collection fingerprint for additions, deletions, and delete-then-recreate", async () => {
+    const firstPath = "E:/Novel/wiki/entities/one.md"
+    const secondPath = "E:/Novel/wiki/entities/two.md"
+    const harness = createHarness([file(firstPath, 1)])
+    harness.hashes.set(firstPath, "one-v1")
+    await harness.registry.refresh()
+    const initial = await harness.registry.getDependencyStamp(["entity"])
+
+    harness.hashes.set(secondPath, "two-v1")
+    harness.setFiles([file(firstPath, 1), file(secondPath, 1)])
+    await harness.registry.refresh()
+    const added = await harness.registry.getDependencyStamp(["entity"])
+    expect(added.sourceCount).toBe(2)
+    expect(added.fingerprint).not.toBe(initial.fingerprint)
+
+    harness.setFiles([])
+    await harness.registry.refresh()
+    const deleted = await harness.registry.getDependencyStamp(["entity"])
+    expect(deleted.sourceCount).toBe(0)
+    expect(deleted.fingerprint).not.toBe(initial.fingerprint)
+
+    harness.hashes.set(firstPath, "one-v2")
+    harness.setFiles([file(firstPath, 2)])
+    await harness.registry.refresh()
+    const recreated = await harness.registry.getDependencyStamp(["entity"])
+    expect(recreated.sourceCount).toBe(1)
+    expect(recreated.fingerprint).not.toBe(initial.fingerprint)
+  })
+
+  it("isolates retrieval fingerprints from unrelated entity changes", async () => {
+    const retrieval = "E:/Novel/retrieval/index.md"
+    const entity = "E:/Novel/wiki/entities/one.md"
+    const harness = createHarness([file(retrieval, 1), file(entity, 1)])
+    harness.hashes.set(retrieval, "retrieval-v1")
+    harness.hashes.set(entity, "entity-v1")
+    await harness.registry.refresh()
+    const initial = await harness.registry.getDependencyStamp(["retrieval"])
+
+    harness.hashes.set(entity, "entity-v2")
+    harness.setFiles([file(retrieval, 1), file(entity, 2)])
+    await harness.registry.refresh()
+    const unchanged = await harness.registry.getDependencyStamp(["retrieval"])
+    expect(unchanged.fingerprint).toBe(initial.fingerprint)
+
+    harness.hashes.set(retrieval, "retrieval-v2")
+    harness.setFiles([file(retrieval, 2), file(entity, 2)])
+    await harness.registry.refresh()
+    const changed = await harness.registry.getDependencyStamp(["retrieval"])
+    expect(changed.fingerprint).not.toBe(initial.fingerprint)
+  })
 })

+ 76 - 11
src/lib/context-hub/source-registry.ts

@@ -9,8 +9,9 @@ import {
 import { normalizePath } from "@/lib/path-utils"
 import type { FileNode } from "@/types/wiki"
 import { classifyContextSourcePath, normalizeContextPath, sortContextSourcePaths } from "./source-paths"
+import { sha256Text } from "./fingerprint"
 import { ContextHubStorage } from "./storage"
-import type { ContextCacheManifest, ContextSourceKind, SourceVersion } from "./types"
+import type { ContextCacheManifest, ContextSourceKind, DependencyStamp, SourceVersion } from "./types"
 
 interface SourceRegistryStorage {
   loadManifest(): Promise<ContextCacheManifest>
@@ -70,6 +71,7 @@ export async function scanProjectContextFiles(
     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),
+    safeList(`${projectPath}/retrieval`, { maxDepth: 30 }, io),
   ])
   return flattenFiles(roots.flat())
 }
@@ -78,8 +80,15 @@ 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)
+function toFingerprintPath(projectPath: string, path: string): string {
+  const normalizedProject = normalizeContextPath(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
 }
 
 export class ContextSourceRegistry {
@@ -91,6 +100,8 @@ export class ContextSourceRegistry {
   private readonly dirtyPaths = new Set<string>()
   private pendingRefresh: Promise<SourceRefreshResult> | null = null
   private versions: Record<string, SourceVersion> = {}
+  private readonly kindStampCache = new Map<ContextSourceKind, Promise<DependencyStamp>>()
+  private readonly combinedStampCache = new Map<string, Promise<DependencyStamp>>()
 
   constructor(projectPath: string, options: ContextSourceRegistryOptions = {}) {
     this.projectPath = normalizePath(projectPath)
@@ -115,18 +126,29 @@ export class ContextSourceRegistry {
     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]),
-    )
+  getDependencyStamp(kinds?: ContextSourceKind[]): Promise<DependencyStamp> {
+    const selectedKinds = this.normalizeKinds(kinds)
+    const key = selectedKinds.join("|")
+    const cached = this.combinedStampCache.get(key)
+    if (cached) return cached
+    const pending = this.buildCombinedStamp(selectedKinds)
+    this.combinedStampCache.set(key, pending)
+    return pending
+  }
+
+  getDependencyPreview(kinds?: ContextSourceKind[], limit = 20): string[] {
+    const allowed = new Set(this.normalizeKinds(kinds))
+    return sortContextSourcePaths(Object.keys(this.versions))
+      .filter((path) => allowed.has(this.versions[path].kind))
+      .slice(0, Math.max(0, limit))
   }
 
   dispose(): void {
     this.unsubscribe()
     this.dirtyPaths.clear()
+    this.versions = {}
+    this.kindStampCache.clear()
+    this.combinedStampCache.clear()
   }
 
   private async refreshInternal(): Promise<SourceRefreshResult> {
@@ -142,6 +164,7 @@ export class ContextSourceRegistry {
     const byPath = new Map(relevant.map((node) => [node.path, node]))
     const next: Record<string, SourceVersion> = {}
     const changedPaths: string[] = []
+    let manifestChanged = Object.keys(previous).length !== byPath.size
 
     for (const path of sortContextSourcePaths([...byPath.keys()])) {
       const node = byPath.get(path)!
@@ -162,6 +185,7 @@ export class ContextSourceRegistry {
         hash,
         revision: oldVersion ? oldVersion.revision + (contentChanged ? 1 : 0) : 1,
       }
+      manifestChanged = true
       if (contentChanged) changedPaths.push(path)
     }
 
@@ -170,8 +194,10 @@ export class ContextSourceRegistry {
     }
 
     const nextManifest: ContextCacheManifest = { ...manifest, sources: next }
-    if (!manifestsEqual(manifest, nextManifest)) await this.storage.saveManifest(nextManifest)
+    if (manifestChanged) await this.storage.saveManifest(nextManifest)
     this.versions = next
+    this.kindStampCache.clear()
+    this.combinedStampCache.clear()
     this.dirtyPaths.clear()
 
     return {
@@ -179,4 +205,43 @@ export class ContextSourceRegistry {
       changedPaths: sortContextSourcePaths([...new Set(changedPaths)]),
     }
   }
+
+  private normalizeKinds(kinds?: ContextSourceKind[]): ContextSourceKind[] {
+    const values = kinds ?? Object.values(this.versions).map((version) => version.kind)
+    return [...new Set(values)]
+      .filter((kind) => kind !== "ignored" && kind !== "other")
+      .sort()
+  }
+
+  private getKindStamp(kind: ContextSourceKind): Promise<DependencyStamp> {
+    const cached = this.kindStampCache.get(kind)
+    if (cached) return cached
+    const pending = (async () => {
+      const paths = sortContextSourcePaths(Object.keys(this.versions))
+        .filter((path) => this.versions[path].kind === kind)
+      const canonical = paths.map((path) => {
+        const version = this.versions[path]
+        const relative = toFingerprintPath(this.projectPath, path)
+        return `${relative}\u0000${version.hash ?? `revision:${version.revision}`}`
+      }).join("\n")
+      return {
+        fingerprint: await sha256Text(canonical),
+        sourceCount: paths.length,
+        kinds: [kind],
+      }
+    })()
+    this.kindStampCache.set(kind, pending)
+    return pending
+  }
+
+  private async buildCombinedStamp(kinds: ContextSourceKind[]): Promise<DependencyStamp> {
+    const stamps = await Promise.all(kinds.map((kind) => this.getKindStamp(kind)))
+    return {
+      fingerprint: await sha256Text(
+        stamps.map((stamp) => `${stamp.kinds[0]}:${stamp.sourceCount}:${stamp.fingerprint}`).join("\n"),
+      ),
+      sourceCount: stamps.reduce((sum, stamp) => sum + stamp.sourceCount, 0),
+      kinds: [...kinds],
+    }
+  }
 }

+ 218 - 5
src/lib/context-hub/storage.spec.ts

@@ -3,9 +3,17 @@ import { ContextHubStorage, type ContextHubStorageIo } from "./storage"
 import {
   CONTEXT_CACHE_SCHEMA_VERSION,
   type CachedArtifact,
+  type ContextCacheManifest,
   type ContextHubSnapshot,
+  type DependencyStamp,
 } from "./types"
 
+const dependencyStamp: DependencyStamp = {
+  fingerprint: "outline-v1",
+  sourceCount: 1,
+  kinds: ["outline"],
+}
+
 function createMemoryIo() {
   const files = new Map<string, string>()
   const directories = new Set<string>()
@@ -30,7 +38,15 @@ function createMemoryIo() {
     deleteFile: async (path) => {
       deletedPaths.push(path)
       files.delete(path)
+      for (const filePath of [...files.keys()]) {
+        if (filePath.startsWith(`${path}/`)) files.delete(filePath)
+      }
+      for (const directory of [...directories]) {
+        if (directory === path || directory.startsWith(`${path}/`)) directories.delete(directory)
+      }
     },
+    fileExists: async (path) => files.has(path) || directories.has(path),
+    getFileSize: async (path) => new TextEncoder().encode(files.get(path) ?? "").byteLength,
   }
   return { files, directories, deletedPaths, io, setFailWrite: (value?: (path: string) => boolean) => { failWrite = value } }
 }
@@ -39,8 +55,10 @@ function artifact(value: string, key = "outline:main"): CachedArtifact<string> {
   return {
     schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
     key,
+    sourceName: "outline",
+    scope: "static",
     value,
-    dependencies: { "E:/Novel/wiki/outlines/main.md": 1 },
+    dependencyStamp,
     createdAt: 1,
   }
 }
@@ -68,7 +86,9 @@ function snapshot(id = "assistant:1"): ContextHubSnapshot {
       key: "data-source:outline",
       sourceName: "outline",
       status: "hit",
+      dependencyStamp,
       dependencyPaths: ["wiki/outlines/main.md"],
+      dependencyPathsTruncated: false,
     }],
     stableCore: "稳定核心正文",
     sessionSummary: "会话摘要正文",
@@ -143,7 +163,7 @@ describe("ContextHubStorage", () => {
   it("treats a different schema as an empty cache", async () => {
     const memory = createMemoryIo()
     memory.files.set(
-      "E:/Novel/.qmai/context-cache/v1/manifest.json",
+      "E:/Novel/.qmai/context-cache/v2/manifest.json",
       JSON.stringify({ schemaVersion: 999, sources: { stale: {} }, artifacts: {} }),
     )
 
@@ -166,14 +186,20 @@ describe("ContextHubStorage", () => {
   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 first = {
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      surface: "ai-chat" as const,
+      text: "一",
+      dependencyStamp,
+      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",
+      "E:/Novel/.qmai/context-cache/v2/stable-bundles/ai-chat.json",
     ])
     await expect(storage.readStableBundle("ai-chat")).resolves.toMatchObject({ text: "二" })
   })
@@ -228,7 +254,7 @@ describe("ContextHubStorage", () => {
 
   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"
+    const outsidePath = "E:/Novel/.qmai/context-cache/v2/outside.json"
     memory.files.set(outsidePath, JSON.stringify({ createdAt: 1 }))
     memory.io.listDirectory = async () => [{
       name: "outside.json",
@@ -242,4 +268,191 @@ describe("ContextHubStorage", () => {
     expect(memory.deletedPaths).toEqual([])
     expect(memory.files.has(outsidePath)).toBe(true)
   })
+
+  it("keeps a 10,000-source and 2,000-artifact v2 manifest below 10 MiB", async () => {
+    const memory = createMemoryIo()
+    const manifestPath = "E:/Novel/.qmai/context-cache/v2/manifest.json"
+    const sources = Object.fromEntries(Array.from({ length: 10_000 }, (_, index) => {
+      const path = `E:/Novel/wiki/entities/entity-${String(index).padStart(5, "0")}.md`
+      return [path, {
+        path,
+        kind: "entity" as const,
+        mtimeMs: index,
+        size: 100,
+        hash: `hash-${index}`,
+        revision: 1,
+      }]
+    }))
+    const artifacts = Object.fromEntries(Array.from({ length: 2_000 }, (_, index) => [
+      `data-source:searchResults:${index}`,
+      {
+        path: `E:/Novel/.qmai/context-cache/v2/artifacts/${index}.json`,
+        sourceName: `searchResults-${index % 16}`,
+        scope: "task" as const,
+        dependencyStamp: { fingerprint: `fingerprint-${index}`, sourceCount: 10_000, kinds: ["entity" as const] },
+        createdAt: index,
+        byteSize: 100,
+      },
+    ]))
+    const manifest: ContextCacheManifest = {
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      sources,
+      artifacts,
+    }
+    const serialized = JSON.stringify(manifest)
+    memory.files.set(manifestPath, serialized)
+
+    expect(new TextEncoder().encode(serialized).byteLength).toBeLessThan(10 * 1024 * 1024)
+    const loaded = await new ContextHubStorage("E:/Novel", memory.io).loadManifest()
+    expect(Object.keys(loaded.sources)).toHaveLength(10_000)
+    expect(Object.keys(loaded.artifacts)).toHaveLength(2_000)
+  })
+
+  it("keeps dependency metadata bounded and does not serialize source paths into artifacts", async () => {
+    const serialized = JSON.stringify(artifact("短值"))
+
+    expect(new TextEncoder().encode(serialized).byteLength).toBeLessThan(1024)
+    expect(serialized).not.toContain("wiki/outlines/main.md")
+  })
+
+  it("prunes task-scoped artifacts beyond the per-source limit", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+    for (let index = 0; index < 129; index += 1) {
+      await storage.writeArtifact(
+        `search:${index}`,
+        { ...artifact(`结果${index}`, `search:${index}`), sourceName: "searchResults", scope: "task", createdAt: index },
+      )
+    }
+
+    const manifest = await storage.loadManifest()
+    expect(Object.keys(manifest.artifacts)).toHaveLength(128)
+    expect(manifest.artifacts["search:0"]).toBeUndefined()
+    expect(memory.deletedPaths.some((path) => path.includes("/artifacts/"))).toBe(true)
+  })
+
+  it("enforces the global artifact count limit during initialization", async () => {
+    const memory = createMemoryIo()
+    const manifestPath = "E:/Novel/.qmai/context-cache/v2/manifest.json"
+    const artifacts = Object.fromEntries(Array.from({ length: 2_050 }, (_, index) => [
+      `chapter:${index}`,
+      {
+        path: `E:/Novel/.qmai/context-cache/v2/artifacts/${index}.json`,
+        sourceName: "chapterOutline",
+        scope: "chapter" as const,
+        dependencyStamp,
+        createdAt: index,
+        byteSize: 1,
+      },
+    ]))
+    memory.files.set(manifestPath, JSON.stringify({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      sources: {},
+      artifacts,
+    }))
+
+    const manifest = await new ContextHubStorage("E:/Novel", memory.io).loadManifest()
+
+    expect(Object.keys(manifest.artifacts)).toHaveLength(2_048)
+    expect(manifest.artifacts["chapter:0"]).toBeUndefined()
+  })
+
+  it("enforces the global artifact byte limit during initialization", async () => {
+    const memory = createMemoryIo()
+    const manifestPath = "E:/Novel/.qmai/context-cache/v2/manifest.json"
+    const artifacts = Object.fromEntries(Array.from({ length: 130 }, (_, index) => [
+      `chapter:${index}`,
+      {
+        path: `E:/Novel/.qmai/context-cache/v2/artifacts/${index}.json`,
+        sourceName: "chapterOutline",
+        scope: "chapter" as const,
+        dependencyStamp,
+        createdAt: index,
+        byteSize: 1024 * 1024,
+      },
+    ]))
+    memory.files.set(manifestPath, JSON.stringify({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      sources: {},
+      artifacts,
+    }))
+
+    const manifest = await new ContextHubStorage("E:/Novel", memory.io).loadManifest()
+    const totalBytes = Object.values(manifest.artifacts).reduce((sum, entry) => sum + entry.byteSize, 0)
+
+    expect(totalBytes).toBeLessThanOrEqual(128 * 1024 * 1024)
+    expect(Object.keys(manifest.artifacts)).toHaveLength(128)
+  })
+
+  it("removes a newly written orphan when manifest persistence fails", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+    await storage.initialize()
+    memory.setFailWrite((path) => path.endsWith("/manifest.json"))
+
+    await expect(storage.writeArtifact("outline:orphan", artifact("大纲", "outline:orphan")))
+      .rejects.toThrow("写入失败")
+
+    expect([...memory.files.keys()].filter((path) => path.includes("/artifacts/"))).toEqual([])
+  })
+
+  it("keeps the previous artifact when replacing it fails at manifest persistence", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+    await storage.writeArtifact("outline:main", artifact("旧大纲"))
+    memory.setFailWrite((path) => path.endsWith("/manifest.json"))
+
+    await expect(storage.writeArtifact("outline:main", artifact("新大纲"))).rejects.toThrow("写入失败")
+    memory.setFailWrite()
+
+    await expect(new ContextHubStorage("E:/Novel", memory.io).readArtifact<string>("outline:main"))
+      .resolves.toMatchObject({ value: "旧大纲" })
+    expect([...memory.files.keys()].filter((path) => path.includes("/artifacts/"))).toHaveLength(1)
+  })
+
+  it("does not cache an artifact larger than 8 MiB", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+
+    await storage.writeArtifact("outline:huge", artifact("x".repeat(8 * 1024 * 1024), "outline:huge"))
+
+    expect((await storage.loadManifest()).artifacts).toEqual({})
+    expect([...memory.files.keys()].some((path) => path.includes("/artifacts/"))).toBe(false)
+  })
+
+  it("resets an oversized v2 manifest without parsing it", async () => {
+    const memory = createMemoryIo()
+    const manifestPath = "E:/Novel/.qmai/context-cache/v2/manifest.json"
+    memory.files.set(manifestPath, "x".repeat(32 * 1024 * 1024 + 1))
+
+    const manifest = await new ContextHubStorage("E:/Novel", memory.io).loadManifest()
+
+    expect(manifest).toEqual({ schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION, sources: {}, artifacts: {} })
+    expect(memory.deletedPaths).toContain("E:/Novel/.qmai/context-cache/v2")
+  })
+
+  it("resets a structurally invalid v2 manifest", async () => {
+    const memory = createMemoryIo()
+    const manifestPath = "E:/Novel/.qmai/context-cache/v2/manifest.json"
+    memory.files.set(manifestPath, JSON.stringify({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      sources: {},
+      artifacts: [{ path: "E:/outside.json" }],
+    }))
+
+    const manifest = await new ContextHubStorage("E:/Novel", memory.io).loadManifest()
+
+    expect(manifest).toEqual({ schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION, sources: {}, artifacts: {} })
+    expect(memory.deletedPaths).toContain("E:/Novel/.qmai/context-cache/v2")
+  })
+
+  it("cleans an unindexed artifact during initialization", async () => {
+    const memory = createMemoryIo()
+    const orphan = "E:/Novel/.qmai/context-cache/v2/artifacts/orphan.json"
+    memory.files.set(orphan, "{}")
+
+    await new ContextHubStorage("E:/Novel", memory.io).initialize()
+
+    expect(memory.deletedPaths).toContain(orphan)
+  })
 })

+ 268 - 87
src/lib/context-hub/storage.ts

@@ -1,8 +1,18 @@
-import { createDirectory, deleteFile, listDirectory, readFile, writeFileAtomic } from "@/commands/fs"
+import {
+  createDirectory,
+  deleteFile,
+  fileExists,
+  getFileSize,
+  listDirectory,
+  readFile,
+  writeFileAtomic,
+} from "@/commands/fs"
 import { normalizePath } from "@/lib/path-utils"
+import { sha256Text } from "./fingerprint"
 import {
   CONTEXT_CACHE_SCHEMA_VERSION,
   type CachedArtifact,
+  type ContextCacheArtifactEntry,
   type ContextCacheManifest,
   type ContextHubSnapshot,
   type ContextSurface,
@@ -15,6 +25,8 @@ export interface ContextHubStorageIo {
   createDirectory(path: string): Promise<void>
   listDirectory(path: string): Promise<Array<{ name: string; path: string; is_dir: boolean; mtimeMs?: number }>>
   deleteFile(path: string): Promise<void>
+  fileExists(path: string): Promise<boolean>
+  getFileSize(path: string): Promise<number>
 }
 
 const defaultIo: ContextHubStorageIo = {
@@ -23,29 +35,19 @@ const defaultIo: ContextHubStorageIo = {
   createDirectory,
   listDirectory: (path) => listDirectory(path, { includeHidden: true, maxDepth: 1 }),
   deleteFile,
+  fileExists,
+  getFileSize,
 }
 
 const SNAPSHOT_CLEANUP_GRACE_MS = 60_000
+const MAX_MANIFEST_BYTES = 32 * 1024 * 1024
+const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024
+const MAX_TOTAL_ARTIFACT_BYTES = 128 * 1024 * 1024
+const MAX_ARTIFACT_COUNT = 2048
+const MAX_TASK_ARTIFACTS_PER_SOURCE = 128
 
 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`
+  return { schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION, sources: {}, artifacts: {} }
 }
 
 function parseObject(value: string): Record<string, unknown> | null {
@@ -59,42 +61,134 @@ function parseObject(value: string): Record<string, unknown> | null {
   }
 }
 
+async function hashedFileName(key: string): Promise<string> {
+  return `${await sha256Text(key)}.json`
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return Boolean(value) && typeof value === "object" && !Array.isArray(value)
+}
+
+function isDependencyStamp(value: unknown): boolean {
+  if (!isRecord(value)) return false
+  return typeof value.fingerprint === "string"
+    && value.fingerprint.length > 0
+    && Number.isInteger(value.sourceCount)
+    && (value.sourceCount as number) >= 0
+    && Array.isArray(value.kinds)
+    && value.kinds.every((kind) => typeof kind === "string")
+}
+
+function cloneManifest(manifest: ContextCacheManifest): ContextCacheManifest {
+  return {
+    schemaVersion: manifest.schemaVersion,
+    sources: { ...manifest.sources },
+    artifacts: { ...manifest.artifacts },
+  }
+}
+
+function artifactSort(left: ContextCacheArtifactEntry, right: ContextCacheArtifactEntry): number {
+  if (left.scope === "static" && right.scope !== "static") return 1
+  if (right.scope === "static" && left.scope !== "static") return -1
+  return left.createdAt - right.createdAt
+}
+
+function pruneArtifactEntries(
+  entries: Record<string, ContextCacheArtifactEntry>,
+): { artifacts: Record<string, ContextCacheArtifactEntry>; removed: ContextCacheArtifactEntry[] } {
+  const artifacts = { ...entries }
+  const removed: ContextCacheArtifactEntry[] = []
+  const removeKey = (key: string) => {
+    const entry = artifacts[key]
+    if (!entry) return
+    removed.push(entry)
+    delete artifacts[key]
+  }
+
+  const taskGroups = new Map<string, Array<[string, ContextCacheArtifactEntry]>>()
+  for (const pair of Object.entries(artifacts)) {
+    const [key, entry] = pair
+    if (entry.scope !== "task") continue
+    const group = taskGroups.get(entry.sourceName) ?? []
+    group.push([key, entry])
+    taskGroups.set(entry.sourceName, group)
+  }
+  for (const group of taskGroups.values()) {
+    group.sort((left, right) => left[1].createdAt - right[1].createdAt)
+    for (const [key] of group.slice(0, Math.max(0, group.length - MAX_TASK_ARTIFACTS_PER_SOURCE))) {
+      removeKey(key)
+    }
+  }
+
+  const remaining = () => Object.entries(artifacts)
+  let totalBytes = remaining().reduce((sum, [, entry]) => sum + entry.byteSize, 0)
+  const candidates = remaining().sort((left, right) => artifactSort(left[1], right[1]))
+  while (
+    candidates.length > 0
+    && (Object.keys(artifacts).length > MAX_ARTIFACT_COUNT || totalBytes > MAX_TOTAL_ARTIFACT_BYTES)
+  ) {
+    const [key, entry] = candidates.shift()!
+    if (!artifacts[key]) continue
+    totalBytes -= entry.byteSize
+    removeKey(key)
+  }
+  return { artifacts, removed }
+}
+
 export class ContextHubStorage {
   private readonly basePath: string
   private readonly manifestPath: string
   private manifest: ContextCacheManifest | null = null
+  private initialization: Promise<void> | null = null
   private manifestWriteQueue: Promise<void> = Promise.resolve()
   private snapshotOperationQueue: Promise<void> = Promise.resolve()
+  private disposed = false
 
   constructor(
     projectPath: string,
     private readonly io: ContextHubStorageIo = defaultIo,
   ) {
-    this.basePath = `${normalizePath(projectPath)}/.qmai/context-cache/v1`
+    this.basePath = `${normalizePath(projectPath)}/.qmai/context-cache/v2`
     this.manifestPath = `${this.basePath}/manifest.json`
   }
 
+  initialize(): Promise<void> {
+    if (this.disposed) return Promise.reject(new Error("Context Hub storage 已释放"))
+    if (!this.initialization) {
+      this.initialization = this.initializeInternal().catch((error) => {
+        this.initialization = null
+        throw error
+      })
+    }
+    return this.initialization
+  }
+
+  dispose(): void {
+    this.disposed = true
+    this.manifest = null
+    this.initialization = null
+  }
+
   async loadManifest(): Promise<ContextCacheManifest> {
-    return cloneManifest(await this.getManifest())
+    await this.initialize()
+    return cloneManifest(this.manifest ?? emptyManifest())
   }
 
   async saveManifest(manifest: ContextCacheManifest): Promise<void> {
+    await this.initialize()
     await this.enqueueManifestWrite(async () => {
-      const current = await this.getManifest()
-      const next = cloneManifest({
-        ...manifest,
+      const current = this.manifest ?? emptyManifest()
+      await this.persistManifest({
         schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
-        artifacts: {
-          ...manifest.artifacts,
-          ...current.artifacts,
-        },
+        sources: { ...manifest.sources },
+        artifacts: { ...current.artifacts },
       })
-      await this.persistManifest(next)
     })
   }
 
   async readArtifact<T>(key: string): Promise<CachedArtifact<T> | null> {
-    const entry = (await this.getManifest()).artifacts[key]
+    await this.initialize()
+    const entry = this.manifest?.artifacts[key]
     if (!entry) return null
     try {
       const raw = parseObject(await this.io.readFile(entry.path))
@@ -103,6 +197,7 @@ export class ContextHubStorage {
         || raw.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
         || raw.key !== key
         || !("value" in raw)
+        || !isDependencyStamp(raw.dependencyStamp)
       ) return null
       return raw as unknown as CachedArtifact<T>
     } catch {
@@ -111,32 +206,47 @@ export class ContextHubStorage {
   }
 
   async writeArtifact<T>(key: string, artifact: CachedArtifact<T>): Promise<void> {
-    await this.ensureBaseDirectories()
-    const artifactPath = `${this.basePath}/artifacts/${artifactFileName(key)}`
+    await this.initialize()
     const value: CachedArtifact<T> = {
       ...artifact,
       schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
       key,
     }
-    await this.io.writeFileAtomic(artifactPath, JSON.stringify(value, null, 2))
+    const serialized = JSON.stringify(value)
+    const byteSize = new TextEncoder().encode(serialized).byteLength
+    if (byteSize > MAX_ARTIFACT_BYTES) return
+    const artifactPath = `${this.basePath}/artifacts/${await hashedFileName(`${key}:${await sha256Text(serialized)}`)}`
 
-    await this.enqueueManifestWrite(async () => {
-      const current = await this.getManifest()
-      const next: ContextCacheManifest = {
-        ...cloneManifest(current),
-        artifacts: {
+    const previous = this.manifest?.artifacts[key]
+    await this.io.writeFileAtomic(artifactPath, serialized)
+    try {
+      await this.enqueueManifestWrite(async () => {
+        const current = this.manifest ?? emptyManifest()
+        const candidate = {
           ...current.artifacts,
           [key]: {
             path: artifactPath,
-            dependencies: { ...artifact.dependencies },
+            sourceName: artifact.sourceName,
+            scope: artifact.scope,
+            dependencyStamp: artifact.dependencyStamp,
+            createdAt: artifact.createdAt,
+            byteSize,
           },
-        },
-      }
-      await this.persistManifest(next)
-    })
+        }
+        const pruned = pruneArtifactEntries(candidate)
+        await this.persistManifest({ ...current, artifacts: pruned.artifacts })
+        const obsoletePaths = pruned.removed.map((entry) => entry.path)
+        if (previous?.path && previous.path !== artifactPath) obsoletePaths.push(previous.path)
+        await Promise.all(obsoletePaths.map((path) => this.safeDelete(path)))
+      })
+    } catch (error) {
+      if (previous?.path !== artifactPath) await this.safeDelete(artifactPath)
+      throw error
+    }
   }
 
   async readStableBundle(surface: ContextSurface): Promise<StableBundle | null> {
+    await this.initialize()
     try {
       const raw = parseObject(await this.io.readFile(this.stableBundlePath(surface)))
       if (
@@ -144,6 +254,7 @@ export class ContextHubStorage {
         || raw.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
         || raw.surface !== surface
         || typeof raw.text !== "string"
+        || !isDependencyStamp(raw.dependencyStamp)
       ) return null
       return raw as unknown as StableBundle
     } catch {
@@ -152,18 +263,19 @@ export class ContextHubStorage {
   }
 
   async writeStableBundle(surface: ContextSurface, bundle: StableBundle): Promise<void> {
-    await this.ensureBaseDirectories()
+    await this.initialize()
     const value: StableBundle = {
       ...bundle,
       schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
       surface,
     }
-    await this.io.writeFileAtomic(this.stableBundlePath(surface), JSON.stringify(value, null, 2))
+    await this.io.writeFileAtomic(this.stableBundlePath(surface), JSON.stringify(value))
   }
 
   async readSnapshot(surface: ContextSurface, id: string): Promise<ContextHubSnapshot | null> {
+    await this.initialize()
     try {
-      const raw = parseObject(await this.io.readFile(this.snapshotPath(surface, id)))
+      const raw = parseObject(await this.io.readFile(await this.snapshotPath(surface, id)))
       if (
         !raw
         || raw.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
@@ -183,20 +295,21 @@ export class ContextHubStorage {
   }
 
   async writeSnapshot(snapshot: ContextHubSnapshot): Promise<void> {
+    await this.initialize()
     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),
+        await this.snapshotPath(snapshot.surface, snapshot.id),
+        JSON.stringify(value),
       )
     })
   }
 
   async pruneSnapshots(surface: ContextSurface, referencedIds: string[]): Promise<void> {
+    await this.initialize()
     await this.enqueueSnapshotOperation(async () => {
       const directory = this.snapshotSurfacePath(surface)
       let nodes: Array<{ name: string; path: string; is_dir: boolean; mtimeMs?: number }>
@@ -206,7 +319,8 @@ export class ContextHubStorage {
         return
       }
       const referencedPaths = new Set(
-        referencedIds.map((id) => this.snapshotPath(surface, id).toLowerCase()),
+        (await Promise.all(referencedIds.map((id) => this.snapshotPath(surface, id))))
+          .map((path) => path.toLowerCase()),
       )
       const cutoff = Date.now() - SNAPSHOT_CLEANUP_GRACE_MS
       for (const node of nodes) {
@@ -222,32 +336,70 @@ export class ContextHubStorage {
         } catch {
         }
         if (createdAt === undefined || createdAt > cutoff) continue
-        try {
-          await this.io.deleteFile(candidate)
-        } catch {
-        }
+        await this.safeDelete(candidate)
       }
     })
   }
 
-  private async getManifest(): Promise<ContextCacheManifest> {
-    if (this.manifest) return this.manifest
+  private async initializeInternal(): Promise<void> {
+    await this.ensureBaseDirectories()
+    const exists = await this.io.fileExists(this.manifestPath).catch(() => false)
+    if (!exists) {
+      this.manifest = emptyManifest()
+      await this.cleanupOrphanArtifacts()
+      return
+    }
+    const size = await this.io.getFileSize(this.manifestPath).catch(() => MAX_MANIFEST_BYTES + 1)
+    if (size > MAX_MANIFEST_BYTES) {
+      await this.resetCache()
+      return
+    }
     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
+      if (!raw || !this.isValidManifest(raw)) {
+        await this.resetCache()
+        return
       }
+      this.manifest = raw as unknown as ContextCacheManifest
     } catch {
-      this.manifest = emptyManifest()
+      await this.resetCache()
+      return
     }
-    return this.manifest
+
+    const current = this.manifest ?? emptyManifest()
+    const pruned = pruneArtifactEntries(current.artifacts)
+    if (pruned.removed.length > 0) {
+      await this.persistManifest({ ...current, artifacts: pruned.artifacts })
+      await Promise.all(pruned.removed.map((entry) => this.safeDelete(entry.path)))
+    }
+    await this.cleanupOrphanArtifacts()
+  }
+
+  private async resetCache(): Promise<void> {
+    await this.safeDelete(this.basePath)
+    this.manifest = emptyManifest()
+    await this.ensureBaseDirectories()
+    await this.cleanupOrphanArtifacts()
+  }
+
+  private async cleanupOrphanArtifacts(): Promise<void> {
+    const directory = `${this.basePath}/artifacts`
+    let nodes: Array<{ name: string; path: string; is_dir: boolean }>
+    try {
+      nodes = await this.io.listDirectory(directory)
+    } catch {
+      return
+    }
+    const referenced = new Set(
+      Object.values(this.manifest?.artifacts ?? {}).map((entry) => normalizePath(entry.path).toLowerCase()),
+    )
+    await Promise.all(nodes
+      .filter((node) => !node.is_dir && node.name.toLowerCase().endsWith(".json"))
+      .map(async (node) => {
+        const path = normalizePath(node.path)
+        if (!this.isDirectSnapshotFile(directory, path)) return
+        if (!referenced.has(path.toLowerCase())) await this.safeDelete(path)
+      }))
   }
 
   private async ensureBaseDirectories(): Promise<void> {
@@ -260,23 +412,51 @@ export class ContextHubStorage {
   }
 
   private enqueueManifestWrite<T>(operation: () => Promise<T>): Promise<T> {
-    const result = this.manifestWriteQueue.then(
-      () => operation(),
-      () => operation(),
-    )
-    this.manifestWriteQueue = result.then(
-      () => undefined,
-      () => undefined,
-    )
+    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))
+    if (this.disposed) throw new Error("Context Hub storage 已释放")
+    await this.io.writeFileAtomic(this.manifestPath, JSON.stringify(manifest))
     this.manifest = manifest
   }
 
+  private isValidManifest(raw: Record<string, unknown>): boolean {
+    if (
+      raw.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
+      || !isRecord(raw.sources)
+      || !isRecord(raw.artifacts)
+    ) return false
+    for (const source of Object.values(raw.sources)) {
+      if (
+        !isRecord(source)
+        || typeof source.path !== "string"
+        || typeof source.kind !== "string"
+        || !Number.isInteger(source.revision)
+        || (source.revision as number) < 0
+      ) return false
+    }
+    const artifactDirectory = `${this.basePath}/artifacts`
+    for (const artifact of Object.values(raw.artifacts)) {
+      if (
+        !isRecord(artifact)
+        || typeof artifact.path !== "string"
+        || !this.isDirectSnapshotFile(artifactDirectory, normalizePath(artifact.path))
+        || typeof artifact.sourceName !== "string"
+        || !["static", "chapter", "task"].includes(String(artifact.scope))
+        || !isDependencyStamp(artifact.dependencyStamp)
+        || typeof artifact.createdAt !== "number"
+        || !Number.isFinite(artifact.createdAt)
+        || typeof artifact.byteSize !== "number"
+        || !Number.isFinite(artifact.byteSize)
+        || artifact.byteSize < 0
+      ) return false
+    }
+    return true
+  }
+
   private stableBundlePath(surface: ContextSurface): string {
     return `${this.basePath}/stable-bundles/${surface}.json`
   }
@@ -285,8 +465,8 @@ export class ContextHubStorage {
     return `${this.basePath}/snapshots/${surface}`
   }
 
-  private snapshotPath(surface: ContextSurface, id: string): string {
-    return `${this.snapshotSurfacePath(surface)}/${artifactFileName(`snapshot:${id}`)}`
+  private async snapshotPath(surface: ContextSurface, id: string): Promise<string> {
+    return `${this.snapshotSurfacePath(surface)}/${await hashedFileName(`snapshot:${id}`)}`
   }
 
   private isDirectSnapshotFile(directory: string, candidate: string): boolean {
@@ -301,14 +481,15 @@ export class ContextHubStorage {
   }
 
   private enqueueSnapshotOperation<T>(operation: () => Promise<T>): Promise<T> {
-    const result = this.snapshotOperationQueue.then(
-      () => operation(),
-      () => operation(),
-    )
-    this.snapshotOperationQueue = result.then(
-      () => undefined,
-      () => undefined,
-    )
+    const result = this.snapshotOperationQueue.then(operation, operation)
+    this.snapshotOperationQueue = result.then(() => undefined, () => undefined)
     return result
   }
+
+  private async safeDelete(path: string): Promise<void> {
+    try {
+      await this.io.deleteFile(path)
+    } catch {
+    }
+  }
 }

+ 28 - 6
src/lib/context-hub/types.ts

@@ -2,7 +2,7 @@ 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 const CONTEXT_CACHE_SCHEMA_VERSION = 2
 
 export type ContextSurface = "ai-chat" | "ai-outline"
 export type ContextIntent = "generate" | "question" | "review" | "lint"
@@ -16,9 +16,18 @@ export type ContextSourceKind =
   | "deduction"
   | "soul"
   | "book-analysis"
+  | "retrieval"
   | "other"
   | "ignored"
 
+export interface DependencyStamp {
+  fingerprint: string
+  sourceCount: number
+  kinds: ContextSourceKind[]
+}
+
+export type ContextCacheScope = "static" | "chapter" | "task"
+
 export interface SourceVersion {
   path: string
   kind: ContextSourceKind
@@ -31,8 +40,10 @@ export interface SourceVersion {
 export interface CachedArtifact<T = unknown> {
   schemaVersion: number
   key: string
+  sourceName: string
+  scope: ContextCacheScope
   value: T
-  dependencies: Record<string, number>
+  dependencyStamp: DependencyStamp
   createdAt: number
 }
 
@@ -40,19 +51,28 @@ export interface StableBundle {
   schemaVersion: number
   surface: ContextSurface
   text: string
-  dependencies: Record<string, number>
+  dependencyStamp: DependencyStamp
   updatedAt: number
 }
 
+export interface ContextCacheArtifactEntry {
+  path: string
+  sourceName: string
+  scope: ContextCacheScope
+  dependencyStamp: DependencyStamp
+  createdAt: number
+  byteSize: number
+}
+
 export interface ContextCacheManifest {
   schemaVersion: number
   sources: Record<string, SourceVersion>
-  artifacts: Record<string, { path: string; dependencies: Record<string, number> }>
+  artifacts: Record<string, ContextCacheArtifactEntry>
 }
 
 export interface SessionContextSummary {
   text: string
-  dependencies: Record<string, number>
+  dependencyFingerprint?: string
   updatedAt: number
 }
 
@@ -88,7 +108,9 @@ export interface ContextCacheItemTrace {
   key: string
   sourceName: string
   status: ContextCacheItemStatus
+  dependencyStamp: DependencyStamp
   dependencyPaths: string[]
+  dependencyPathsTruncated: boolean
 }
 
 export interface ContextHubSnapshotRef {
@@ -130,7 +152,7 @@ export interface ContextHubResult {
   sessionSummary: string
   dynamicContext: string
   contextPack: ContextPack
-  dependencies: Record<string, number>
+  dependencyStamp: DependencyStamp
   stats: ContextHubStats
   cacheItems: ContextCacheItemTrace[]
   warnings: string[]

+ 45 - 3
src/lib/persist.spec.ts

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
 
 const fsMocks = vi.hoisted(() => ({
   writeFile: vi.fn(),
+  writeFileAtomic: vi.fn(),
   readFile: vi.fn(),
   createDirectory: vi.fn(),
 }))
@@ -21,6 +22,7 @@ import { loadChatHistory, saveChatHistory } from "./persist"
 describe("chat context summary persistence", () => {
   beforeEach(() => {
     fsMocks.writeFile.mockReset().mockResolvedValue(undefined)
+    fsMocks.writeFileAtomic.mockReset().mockResolvedValue(undefined)
     fsMocks.createDirectory.mockReset().mockResolvedValue(undefined)
     fsMocks.readFile.mockReset()
     contextHubMocks.pruneSnapshots.mockReset().mockResolvedValue(undefined)
@@ -29,8 +31,8 @@ describe("chat context summary persistence", () => {
     })
   })
 
-  it("saves dependency revisions in the conversation manifest", async () => {
-    const contextSummary = { text: "摘要", dependencies: { outline: 3 }, updatedAt: 10 }
+  it("saves the dependency fingerprint in the conversation manifest", async () => {
+    const contextSummary = { text: "摘要", dependencyFingerprint: "outline-v3", updatedAt: 10 }
     await saveChatHistory("E:/Novel", [{
       id: "chat-1",
       title: "会话",
@@ -63,9 +65,49 @@ describe("chat context summary persistence", () => {
 
     expect(loaded.conversations[0].contextSummary).toEqual({
       text: "旧摘要",
-      dependencies: {},
       updatedAt: 0,
     })
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      "E:/Novel/.qmai/conversations.json",
+      expect.not.stringContaining("dependencies"),
+    )
+  })
+
+  it("migrates a legacy combined chat file and removes its dependency table", async () => {
+    fsMocks.readFile.mockImplementation(async (path: string) => {
+      if (path.endsWith("/.qmai/chat-history.json")) {
+        return JSON.stringify({
+          conversations: [{
+            id: "chat-legacy",
+            title: "旧会话",
+            createdAt: 1,
+            updatedAt: 2,
+            deAiMode: false,
+            contextSummary: {
+              text: "保留的旧摘要",
+              updatedAt: 2,
+              dependencies: { "E:/Novel/wiki/entities/a.md": 1 },
+            },
+          }],
+          messages: [{
+            id: "message-1",
+            role: "assistant",
+            content: "旧消息",
+            timestamp: 2,
+            conversationId: "chat-legacy",
+          }],
+        })
+      }
+      throw new Error("文件不存在")
+    })
+
+    const loaded = await loadChatHistory("E:/Novel")
+
+    expect(loaded.conversations[0].contextSummary).toEqual({ text: "保留的旧摘要", updatedAt: 2 })
+    const manifestCall = fsMocks.writeFile.mock.calls.find(([path]) => path.endsWith("/.qmai/conversations.json"))
+    expect(manifestCall).toBeDefined()
+    expect(manifestCall![1]).not.toContain("dependencies")
+    expect(manifestCall![1]).toContain("保留的旧摘要")
   })
 
   it("persists the context snapshot reference with an assistant message", async () => {

+ 34 - 5
src/lib/persist.ts

@@ -1,9 +1,12 @@
-import { writeFile, readFile, createDirectory } from "@/commands/fs"
+import { writeFile, writeFileAtomic, readFile, createDirectory } from "@/commands/fs"
 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 {
+  isLegacySessionContextSummary,
+  normalizeSessionContextSummary,
+} from "@/lib/context-hub/session-summary"
 import { getContextHub } from "@/lib/context-hub/context-hub"
 
 const MAX_RETRIES = 3
@@ -201,6 +204,9 @@ export async function loadChatHistory(projectPath: string): Promise<PersistedCha
       : Array.isArray(parsedManifest?.conversations)
         ? parsedManifest.conversations
         : []
+    const hasLegacyContextSummary = rawConversations.some((conversation) => (
+      isLegacySessionContextSummary(conversation.contextSummary)
+    ))
     const conversations = rawConversations.map(normalizeConversation)
     const rawRunStates = Array.isArray(parsedManifest) ? {} : parsedManifest.runStates
     const runStates = normalizeConversationRunStates(conversations, rawRunStates)
@@ -216,6 +222,17 @@ export async function loadChatHistory(projectPath: string): Promise<PersistedCha
       }
     }
 
+    if (hasLegacyContextSummary) {
+      try {
+        await writeFileAtomic(
+          `${pp}/.qmai/conversations.json`,
+          JSON.stringify({ conversations, runStates }, null, 2),
+        )
+      } catch (error) {
+        console.warn("persist: 旧版上下文摘要自动瘦身失败,下次加载时将重试", error)
+      }
+    }
+
     return { conversations, messages: allMessages, runStates }
   } catch {
     // Fall back to old format
@@ -244,14 +261,26 @@ export async function loadChatHistory(projectPath: string): Promise<PersistedCha
       // Old combined format
       if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
         const data = parsed as PersistedChatData
-        const conversations = Array.isArray(data.conversations)
-          ? data.conversations.map(normalizeConversation)
+        const rawConversations = Array.isArray(data.conversations) ? data.conversations : []
+        const hasLegacyContextSummary = rawConversations.some((conversation) => (
+          isLegacySessionContextSummary(conversation.contextSummary)
+        ))
+        const conversations = rawConversations.length > 0
+          ? rawConversations.map(normalizeConversation)
           : []
-        return {
+        const migrated = {
           conversations,
           messages: Array.isArray(data.messages) ? data.messages : [],
           runStates: normalizeConversationRunStates(conversations, data.runStates),
         }
+        if (hasLegacyContextSummary) {
+          try {
+            await saveChatHistory(pp, migrated.conversations, migrated.messages, undefined, migrated.runStates)
+          } catch (error) {
+            console.warn("persist: 旧版合并聊天记录自动迁移失败,下次加载时将重试", error)
+          }
+        }
+        return migrated
       }
       console.warn("persist: 聊天历史数据格式无效")
       return { conversations: [], messages: [], runStates: {} }

+ 8 - 0
src/lib/reset-project-state.spec.ts

@@ -1,8 +1,14 @@
 import { beforeEach, describe, expect, it, vi } from "vitest"
 
+const contextHubMocks = vi.hoisted(() => ({
+  disposeAllContextHubs: vi.fn(),
+  getContextHub: vi.fn(() => ({ pruneSnapshots: vi.fn(async () => {}) })),
+}))
+
 vi.mock("@/lib/ingest-queue", () => ({
   pauseQueue: vi.fn().mockResolvedValue(undefined),
 }))
+vi.mock("@/lib/context-hub/context-hub", () => contextHubMocks)
 
 import { resetProjectState, resetProjectStores } from "./reset-project-state"
 import { useActivityStore } from "@/stores/activity-store"
@@ -11,6 +17,7 @@ import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { useReviewStore } from "@/stores/review-store"
 
 beforeEach(() => {
+  contextHubMocks.disposeAllContextHubs.mockClear()
   useChatStore.setState({
     conversations: [{ id: "chat-a", title: "chat-a", createdAt: 1, updatedAt: 1, deAiMode: false }],
     messages: [{ id: "m1", role: "user", content: "hi", timestamp: 1, conversationId: "chat-a" }],
@@ -69,6 +76,7 @@ describe("resetProjectState", () => {
     await resetProjectState()
 
     expect(sessionStorage.getItem("lk-last-chapter-path")).toBeNull()
+    expect(contextHubMocks.disposeAllContextHubs).toHaveBeenCalledOnce()
     vi.unstubAllGlobals()
   })
 })

+ 2 - 0
src/lib/reset-project-state.ts

@@ -14,6 +14,7 @@ import { useChatStore } from "@/stores/chat-store"
 import { useFavoriteSkillStore } from "@/stores/favorite-skill-store"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { useReviewStore } from "@/stores/review-store"
+import { disposeAllContextHubs } from "@/lib/context-hub/context-hub"
 
 export function resetProjectStores(): void {
   useChatStore.setState({
@@ -51,6 +52,7 @@ export function resetProjectStores(): void {
 }
 
 export async function resetProjectState(): Promise<void> {
+  disposeAllContextHubs()
   resetProjectStores()
 
   // View-switch restore key is process-global; clear so the next project cannot

+ 5 - 2
src/stores/outline-chat-store.spec.ts

@@ -38,7 +38,7 @@ beforeEach(() => {
 afterEach(() => { vi.clearAllTimers(); vi.useRealTimers() })
 
 describe("outline-chat-store", () => {
-  it("加载时把旧字符串上下文摘要迁移为带依赖的结构", async () => {
+  it("加载时保留旧字符串摘要并自动写回瘦身结构", async () => {
     useWikiStore.setState({ project: { id: "p", name: "Novel", path: "E:/Novel" } })
     fsMocks.readFile.mockResolvedValue(JSON.stringify({
       conversations: [{ ...conversation("legacy-summary"), contextSummary: "旧大纲摘要" }],
@@ -49,9 +49,12 @@ describe("outline-chat-store", () => {
 
     expect(useOutlineChatStore.getState().conversations[0].contextSummary).toEqual({
       text: "旧大纲摘要",
-      dependencies: {},
       updatedAt: 0,
     })
+    expect(fsMocks.writeFile).toHaveBeenCalledWith(
+      "E:/Novel/.qmai/outline-chats.json",
+      expect.not.stringContaining("dependencies"),
+    )
   })
 
   it("按会话隔离流式内容,并支持追加、读取和单独清理", () => {

+ 10 - 1
src/stores/outline-chat-store.ts

@@ -4,7 +4,10 @@ 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 {
+  isLegacySessionContextSummary,
+  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"
@@ -391,6 +394,9 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
         activeConversationId: string | null
         runStates?: ConversationRunStates
       }
+      const hasLegacyContextSummary = (data.conversations ?? []).some((conversation) => (
+        isLegacySessionContextSummary(conversation.contextSummary)
+      ))
       const conversations = (data.conversations ?? []).map((conversation) => ({
         ...conversation,
         contextSummary: normalizeSessionContextSummary(conversation.contextSummary),
@@ -430,6 +436,9 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
         pendingReferenceTokens: [],
         loaded: true,
       })
+      if (hasLegacyContextSummary && generation === loadGeneration && getStoragePath() === path) {
+        await doSave(path, { conversations, activeConversationId, runStates })
+      }
     } catch {
       if (generation !== loadGeneration || getStoragePath() !== path) return
       set({

+ 1 - 0
src/test/chat-panel-mount.ts

@@ -132,6 +132,7 @@ vi.mock("@/commands/fs", () => ({
   deleteFile: vi.fn(async () => {}),
   readFile: vi.fn(async () => ""),
   fileExists: vi.fn(async () => false),
+  getFileSize: vi.fn(async () => 0),
   writeFileAtomic: vi.fn(async () => {}),
   listDirectory: vi.fn(async () => []),
 }))