Ver Fonte

feat(context-hub): 重做生成详情统计口径并丢弃旧快照

统一 V2 数据源计数、fragment 守恒与请求级记忆决策;storyFrameworkBinding 进入稳定核心。
展示层只接受当前 stats 形态,旧 hits/refreshed 快照直接丢弃。
同步收敛 workflow mode 遗留类型并修正无 fallback 时的误计。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi há 1 mês atrás
pai
commit
fe2a60f7b9
71 ficheiros alterados com 1437 adições e 495 exclusões
  1. 4 5
      src/components/chat/chapter-plan-confirm-dialog.tsx
  2. 2 6
      src/components/chat/chat-input.tsx
  3. 1 1
      src/components/chat/chat-message.spec.tsx
  4. 9 5
      src/components/chat/chat-message.tsx
  5. 10 9
      src/components/chat/chat-panel.tsx
  6. 10 15
      src/components/chat/context-trace-panel.spec.tsx
  7. 18 32
      src/components/chat/context-trace-panel.tsx
  8. 40 9
      src/components/common/context-hub-details.spec.tsx
  9. 67 69
      src/components/common/context-hub-details.tsx
  10. 130 0
      src/components/common/context-hub-stats-summary.tsx
  11. 2 4
      src/components/sources/outline-chat-panel.spec.tsx
  12. 45 2
      src/components/sources/outline-chat-panel.tsx
  13. 1 0
      src/i18n/en.json
  14. 1 0
      src/i18n/zh.json
  15. 2 0
      src/lib/agent/ai-chat-session.ts
  16. 1 0
      src/lib/agent/ai-chat-workflow-convergence.spec.ts
  17. 2 2
      src/lib/agent/capabilities/registry.ts
  18. 2 2
      src/lib/agent/capabilities/selector.ts
  19. 2 2
      src/lib/agent/capabilities/types.ts
  20. 1 3
      src/lib/agent/context-trace-builders.spec.ts
  21. 2 0
      src/lib/agent/novel-pre-plugin-chain.spec.ts
  22. 2 2
      src/lib/agent/pipeline.ts
  23. 5 6
      src/lib/agent/plan-execute-policy.ts
  24. 3 0
      src/lib/agent/plugins/build-context-pack-plugin.spec.ts
  25. 3 3
      src/lib/agent/plugins/build-system-prompt-plugin.ts
  26. 10 11
      src/lib/agent/plugins/select-skills-plugin.ts
  27. 1 0
      src/lib/agent/plugins/trim-context-plugin.spec.ts
  28. 3 3
      src/lib/agent/required-tools-gate.ts
  29. 12 6
      src/lib/agent/runner.spec.ts
  30. 7 1
      src/lib/agent/runner.ts
  31. 2 2
      src/lib/agent/tools/index.ts
  32. 4 4
      src/lib/agent/tools/run-chapter-workflow.ts
  33. 3 0
      src/lib/agent/types.ts
  34. 3 3
      src/lib/agent/workflow-mode.spec.ts
  35. 3 6
      src/lib/agent/workflow-mode.ts
  36. 0 3
      src/lib/context-budget.ts
  37. 38 9
      src/lib/context-hub/composer.spec.ts
  38. 184 51
      src/lib/context-hub/composer.ts
  39. 24 4
      src/lib/context-hub/context-hub.spec.ts
  40. 29 34
      src/lib/context-hub/context-hub.ts
  41. 53 32
      src/lib/context-hub/data-source-cache.spec.ts
  42. 74 16
      src/lib/context-hub/data-source-cache.ts
  43. 12 1
      src/lib/context-hub/index.ts
  44. 115 0
      src/lib/context-hub/normalize-stats.spec.ts
  45. 59 15
      src/lib/context-hub/provider-usage.spec.ts
  46. 95 4
      src/lib/context-hub/provider-usage.ts
  47. 0 1
      src/lib/context-hub/source-paths.ts
  48. 2 4
      src/lib/context-hub/storage.spec.ts
  49. 2 1
      src/lib/context-hub/storage.ts
  50. 178 4
      src/lib/context-hub/types.ts
  51. 8 1
      src/lib/llm-client.ts
  52. 41 0
      src/lib/novel/context-data-source.spec.ts
  53. 28 13
      src/lib/novel/context-data-source.ts
  54. 7 23
      src/lib/novel/context-data-sources.ts
  55. 2 0
      src/lib/novel/context-engine.spec.ts
  56. 9 0
      src/lib/novel/context-engine.ts
  57. 1 0
      src/lib/novel/deep-chapter-generation.spec.ts
  58. 5 4
      src/lib/novel/deep-chapter-generation.ts
  59. 1 0
      src/lib/novel/dimension-review-adapter.spec.ts
  60. 1 0
      src/lib/novel/outline-agent-context.spec.ts
  61. 1 0
      src/lib/novel/outline-generation.ts
  62. 0 2
      src/lib/novel/outline-multi-agent-orchestrator.ts
  63. 1 0
      src/lib/novel/review-adapter.spec.ts
  64. 2 2
      src/lib/novel/skill-library.ts
  65. 2 2
      src/lib/persist.spec.ts
  66. 0 16
      src/lib/sources-merge.ts
  67. 17 9
      src/lib/user-memory/request-integration.spec.ts
  68. 28 10
      src/lib/user-memory/request-integration.ts
  69. 2 2
      src/stores/outline-chat-store.spec.ts
  70. 3 17
      src/stores/wiki-store.ts
  71. 0 2
      src/test/chat-panel-mount.ts

+ 4 - 5
src/components/chat/chapter-plan-confirm-dialog.tsx

@@ -1,7 +1,7 @@
 import { useState, useCallback, useEffect, useRef } from "react"
 import { X, Check, SkipForward, Edit3, ListChecks } from "lucide-react"
-import type { AiWorkflowMode, LegacyAiWorkflowMode } from "@/lib/agent/workflow-mode"
-import { getWorkflowModeLabel, resolveAiWorkflowMode } from "@/lib/agent/workflow-mode"
+import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
+import { getWorkflowModeLabel } from "@/lib/agent/workflow-mode"
 export { buildChapterPlanSelfCheckPrompt } from "@/lib/novel/chapter-plan-self-check"
 
 export const CHAPTER_PLAN_MARKER_START = "<!-- chapter_plan -->"
@@ -91,7 +91,7 @@ export function isChapterPlanExecutionFollowup(content: string): boolean {
 interface ChapterPlanConfirmDialogProps {
   open: boolean
   planContent: string
-  aiWorkflowMode: LegacyAiWorkflowMode
+  aiWorkflowMode: AiWorkflowMode
   onConfirm: () => void
   onSkip: () => void
   onModify?: (modifiedPlan: string) => void
@@ -111,8 +111,7 @@ export function ChapterPlanConfirmDialog({
   onRevisePlan,
   onCancel,
 }: ChapterPlanConfirmDialogProps) {
-  const resolvedAiWorkflowMode: AiWorkflowMode = resolveAiWorkflowMode(aiWorkflowMode)
-  const workflowModeLabel = getWorkflowModeLabel(resolvedAiWorkflowMode)
+  const workflowModeLabel = getWorkflowModeLabel(aiWorkflowMode)
   const [editing, setEditing] = useState(false)
   const [editedContent, setEditedContent] = useState(planContent)
   const [selfChecking, setSelfChecking] = useState(false)

+ 2 - 6
src/components/chat/chat-input.tsx

@@ -39,16 +39,12 @@ interface ChatInputProps {
   leftControls?: ReactNode
   bottomLeftControls?: ReactNode
   rightControls?: ReactNode
-  /** @deprecated 请使用 leftControls 代替 */
-  leadingControls?: ReactNode
-  /** @deprecated 请使用 leftControls 代替 */
-  footerControls?: ReactNode
   value?: string
   onChange?: (value: string) => void
 }
 
-export function ChatInput({ onSend, onStop, isStreaming, placeholder, leftControls, bottomLeftControls, rightControls, leadingControls, footerControls, value: controlledValue, onChange }: ChatInputProps) {
-  const leftToolbarContent = leftControls ?? leadingControls ?? footerControls
+export function ChatInput({ onSend, onStop, isStreaming, placeholder, leftControls, bottomLeftControls, rightControls, value: controlledValue, onChange }: ChatInputProps) {
+  const leftToolbarContent = leftControls
   const bottomLeftToolbarContent = bottomLeftControls
   const rightToolbarContent = rightControls
   const activeConversationId = useChatStore((state) => state.activeConversationId)

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

@@ -213,7 +213,7 @@ describe("AI 对话上下文中控入口", () => {
         surface: "ai-chat",
         createdAt: 10,
         stats: {
-          hits: 2, refreshed: 1, failures: 0,
+          cacheHits: 2, reloaded: 1, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
           stableTokens: 100, summaryTokens: 20, dynamicTokens: 30,
           candidateTokens: 300, estimatedSavedTokens: 150, estimatedSavedPercent: 50,
           expanded: false, providerCacheEnabled: true,

+ 9 - 5
src/components/chat/chat-message.tsx

@@ -40,6 +40,7 @@ import type { DisplayMessage } from "@/stores/chat-store";
 import { ContextTracePanel } from "@/components/chat/context-trace-panel";
 import { ContextHubDetails } from "@/components/common/context-hub-details";
 import { getStreamingTailDisplay } from "@/components/common/streaming-display-text";
+import { parseContextHubSnapshotRef } from "@/lib/context-hub/types";
 
 import { convertLatexToUnicode } from "@/lib/latex-to-unicode";
 import { resolveMarkdownImageSrc } from "@/lib/markdown-image-resolver";
@@ -119,8 +120,11 @@ export function ChatMessage({
     onContinueUnfinished &&
     canContinueUnfinishedDeepChapter(message.content),
   );
+  const currentContextHubSnapshot = message.contextHubSnapshot
+    ? parseContextHubSnapshotRef(message.contextHubSnapshot)
+    : null;
   const hasContextTrace = Boolean(
-    message.contextHubSnapshot ||
+    currentContextHubSnapshot ||
     (message.contextTrace &&
       (message.contextTrace.toolCalls.length > 0 ||
         message.contextTrace.contextInfo)),
@@ -289,21 +293,21 @@ export function ChatMessage({
         {isAssistant &&
           !message.discarded &&
           contextTraceExpanded &&
-          (message.contextTrace || message.contextHubSnapshot) && (
+          (message.contextTrace || currentContextHubSnapshot) && (
             <div className="mt-1">
               {message.contextTrace ? (
                 <ContextTracePanel
                   trace={message.contextTrace}
-                  contextHubSnapshot={message.contextHubSnapshot}
+                  contextHubSnapshot={currentContextHubSnapshot ?? undefined}
                   projectPath={projectPath}
                   onRebuildRetrievalIndex={onRebuildRetrievalIndex}
                   retrievalIndexHasIndex={retrievalIndexHasIndex}
                   isRebuildingRetrievalIndex={isRebuildingRetrievalIndex}
                   lastRebuildResult={lastRebuildRetrievalResult}
                 />
-              ) : message.contextHubSnapshot ? (
+              ) : currentContextHubSnapshot ? (
                 <ContextHubDetails
-                  reference={message.contextHubSnapshot}
+                  reference={currentContextHubSnapshot}
                   projectPath={projectPath}
                 />
               ) : null}

+ 10 - 9
src/components/chat/chat-panel.tsx

@@ -134,6 +134,7 @@ import {
   buildContextHubSystemContent,
   buildSessionContextSummary,
   flattenContextHubSystemContent,
+  buildLlmRequestDiagnostics,
   getContextHub,
   persistContextHubProviderUsage,
   selectContextHistoryMessages,
@@ -298,7 +299,6 @@ function buildSelectedSkillsActivityContent(skills: UserSkill[] | undefined): st
 function buildChatAgentSystemPrompt(options: {
   novelMode: boolean
   mode: "chat" | "ingest"
-  deepChapterEnabled: boolean
   chatEditModeEnabled: boolean
   aiWorkflowMode?: AiWorkflowMode
   planExecuteEnabled?: boolean
@@ -879,7 +879,6 @@ export function ChatPanel() {
   const planExecuteEnabled = useWikiStore((s) => s.planExecuteEnabled)
   const setPlanExecuteEnabled = useWikiStore((s) => s.setPlanExecuteEnabled)
   const [isSavingChapter, setIsSavingChapter] = useState(false)
-  const deepChapterEnabled = useWikiStore((s) => s.deepChapterEnabled)
   // 故事框架绑定状态
   const [activeBinding, setActiveBinding] = useState<{ binding: FrameworkBinding; framework: StoryFramework } | null>(null)
   const [fallbackReferenceText, setFallbackReferenceText] = useState("")
@@ -976,7 +975,6 @@ export function ChatPanel() {
       buildChatAgentSystemPrompt({
         novelMode,
         mode,
-        deepChapterEnabled,
         chatEditModeEnabled,
         aiWorkflowMode,
         planExecuteEnabled: aiWorkflowMode !== "fast" && planExecuteEnabled,
@@ -986,7 +984,6 @@ export function ChatPanel() {
     [
       activeBinding?.framework.title,
       chatEditModeEnabled,
-      deepChapterEnabled,
       mode,
       novelMode,
       aiWorkflowMode,
@@ -1571,7 +1568,6 @@ export function ChatPanel() {
       const sessionAgentSystemPrompt = buildChatAgentSystemPrompt({
         novelMode,
         mode,
-        deepChapterEnabled,
         chatEditModeEnabled,
         aiWorkflowMode,
         planExecuteEnabled: planExecuteActive,
@@ -1711,6 +1707,7 @@ export function ChatPanel() {
             characterStates: "",
             soulDoc: "",
             characterAuras: "",
+            storyFrameworkBinding: "",
             cognitionStates: "",
             foreshadowingStates: "",
             sectionBriefing: "",
@@ -1881,9 +1878,7 @@ export function ChatPanel() {
               ? message.content
               : message.content.map((block) => block.type === "text" ? block.text : "").join("")
           )),
-          currentInput: typeof userContent === "string"
-            ? userContent
-            : userContent.map((block) => block.type === "text" ? block.text : "").join(""),
+          currentInput: userContent,
         })
         // Seed this turn's baseline so the ring can grow with tool reads before the first usage report.
         useChatStore.getState().setConversationContextUsage(capturedConvId, usageSnapshotBase)
@@ -1977,6 +1972,13 @@ export function ChatPanel() {
               assistantMessage.id,
               contextHubResult,
               record.usage,
+              {
+                memoryDecision: record.userMemoryDecision,
+                requestDiagnostics: buildLlmRequestDiagnostics(
+                  record.usage,
+                  Math.max(1, record.roundsUsed || 1),
+                ),
+              },
             )
             if (contextHubSnapshot) {
               updateAgentAssistantMessage(assistantMessage.id, (message) => ({
@@ -2237,7 +2239,6 @@ export function ChatPanel() {
       chatEditModeEnabled,
       clearStreaming,
       createConversation,
-      deepChapterEnabled,
       maxHistoryMessages,
       mode,
       novelMode,

+ 10 - 15
src/components/chat/context-trace-panel.spec.tsx

@@ -21,9 +21,7 @@ describe("ContextTracePanel selected skills", () => {
         retrievalHits: [],
         trimmedSections: [],
         contextHub: {
-          hits: 4,
-          refreshed: 1,
-          failures: 0,
+          cacheHits: 4, reloaded: 1, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
           stableTokens: 1200,
           summaryTokens: 180,
           dynamicTokens: 420,
@@ -40,12 +38,13 @@ describe("ContextTracePanel selected skills", () => {
 
     expect(html).toContain("上下文中控")
     expect(html).not.toContain("4ms")
-    expect(html).toContain("本轮缓存事件:命中 4,刷新 1,失败 0")
-    expect(html).toContain("稳定核心 1,200 Token")
-    expect(html).toContain("会话摘要 180 Token")
-    expect(html).toContain("动态片段 420 Token")
-    expect(html).toContain("上下文压缩预计减少 1,400 Token(44%)")
+    expect(html).toContain("本轮数据源:命中 4,重载 1,无数据 0,fallback 0,失败 0")
+    expect(html).toContain("稳定核心 1,200 估算 Token")
+    expect(html).toContain("会话摘要 180 估算 Token")
+    expect(html).toContain("动态片段 420 估算 Token")
+    expect(html).toContain("上下文压缩预计减少 1,400 估算 Token(44%)")
     expect(html).toContain("已发送稳定前缀,是否命中以供应商返回为准")
+    expect(html).toContain("实际用量不可用")
     expect(html).not.toContain("供应商已确认命中")
   })
 
@@ -65,9 +64,7 @@ describe("ContextTracePanel selected skills", () => {
         retrievalHits: [],
         trimmedSections: [],
         contextHub: {
-          hits: 0,
-          refreshed: 2,
-          failures: 0,
+          cacheHits: 0, reloaded: 2, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
           stableTokens: 900,
           summaryTokens: 0,
           dynamicTokens: 300,
@@ -112,9 +109,7 @@ describe("ContextTracePanel selected skills", () => {
       surface: "ai-chat",
       createdAt: 10,
       stats: {
-        hits: 2,
-        refreshed: 1,
-        failures: 0,
+        cacheHits: 2, reloaded: 1, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
         stableTokens: 100,
         summaryTokens: 20,
         dynamicTokens: 30,
@@ -131,7 +126,7 @@ describe("ContextTracePanel selected skills", () => {
     )
 
     expect(html).toContain("展开上下文中控")
-    expect(html).toContain("本轮缓存事件:命中 2,刷新 1,失败 0")
+    expect(html).toContain("本轮数据源:命中 2,重载 1,无数据 0,fallback 0,失败 0")
   })
 
   it("renders web search trace entries in the overview", () => {

+ 18 - 32
src/components/chat/context-trace-panel.tsx

@@ -40,8 +40,12 @@ import {
 } from "@/lib/novel/classification"
 import { cn } from "@/lib/utils"
 import { ToolCallTimeline } from "./tool-call-timeline"
-import { ContextHubDetails, ProviderCacheUsage } from "@/components/common/context-hub-details"
-import type { ContextHubSnapshotRef } from "@/lib/context-hub/types"
+import { ContextHubDetails, ContextHubStatsOnly } from "@/components/common/context-hub-details"
+import {
+  isCurrentContextHubStats,
+  parseContextHubSnapshotRef,
+  type ContextHubSnapshotRef,
+} from "@/lib/context-hub/types"
 
 interface RebuildRetrievalResult {
   success: boolean
@@ -365,6 +369,12 @@ function OverviewTab({
 }) {
   const [upgradeStatus, setUpgradeStatus] = useState<"idle" | "confirming" | "loading" | "success" | "error">("idle")
   const [upgradeError, setUpgradeError] = useState<string | null>(null)
+  const currentHubSnapshot = contextHubSnapshot
+    ? parseContextHubSnapshotRef(contextHubSnapshot)
+    : null
+  const currentHubStats = contextInfo?.contextHub && isCurrentContextHubStats(contextInfo.contextHub)
+    ? contextInfo.contextHub
+    : null
 
   const handleUpgradeClassification = async () => {
     if (!projectPath) return
@@ -385,8 +395,8 @@ function OverviewTab({
     }
   }
   if (!contextInfo) {
-    if (contextHubSnapshot) {
-      return <ContextHubDetails reference={contextHubSnapshot} projectPath={projectPath} className="mt-0 border-t-0 pt-0" />
+    if (currentHubSnapshot) {
+      return <ContextHubDetails reference={currentHubSnapshot} projectPath={projectPath} className="mt-0 border-t-0 pt-0" />
     }
     return (
       <div className="flex flex-col items-center justify-center py-12 text-center">
@@ -417,45 +427,21 @@ function OverviewTab({
         value={ROUTE_SOURCE_LABELS[contextInfo.routeSource] || contextInfo.routeSource}
       />
 
-      {contextHubSnapshot && (
+      {currentHubSnapshot && (
         <>
           <div className="my-1 h-px bg-border/60" />
           <ContextHubDetails
-            reference={contextHubSnapshot}
+            reference={currentHubSnapshot}
             projectPath={projectPath}
             className="mt-0 border-t-0 pt-2"
           />
         </>
       )}
 
-      {contextInfo.contextHub && !contextHubSnapshot && (
+      {currentHubStats && !currentHubSnapshot && (
         <>
           <div className="my-1 h-px bg-border/60" />
-          <div className="py-2">
-            <div className="mb-2 flex items-center gap-2">
-              <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-teal-100 text-teal-600 dark:bg-teal-950/40 dark:text-teal-400">
-                <Database className="h-3.5 w-3.5" />
-              </div>
-              <div className="text-[11px] font-medium text-foreground">上下文中控</div>
-            </div>
-            <div className="ml-9 space-y-1 text-[11px] text-muted-foreground">
-              <div>
-                本轮缓存事件:命中 {contextInfo.contextHub.hits.toLocaleString()},刷新 {contextInfo.contextHub.refreshed.toLocaleString()},失败 {contextInfo.contextHub.failures.toLocaleString()}
-              </div>
-              <div className="flex flex-wrap gap-x-3 gap-y-1">
-                <span>稳定核心 {contextInfo.contextHub.stableTokens.toLocaleString()} Token</span>
-                <span>会话摘要 {contextInfo.contextHub.summaryTokens.toLocaleString()} Token</span>
-                <span>动态片段 {contextInfo.contextHub.dynamicTokens.toLocaleString()} Token</span>
-              </div>
-              <div>
-                上下文压缩预计减少 {contextInfo.contextHub.estimatedSavedTokens.toLocaleString()} Token({contextInfo.contextHub.estimatedSavedPercent}%)
-              </div>
-              <div>
-                低置信度扩展:{contextInfo.contextHub.expanded ? "已启用" : "未启用"}
-              </div>
-              <ProviderCacheUsage stats={contextInfo.contextHub} />
-            </div>
-          </div>
+          <ContextHubStatsOnly stats={currentHubStats} className="mt-0 border-t-0 pt-2" />
         </>
       )}
 

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

@@ -14,9 +14,7 @@ const snapshot: ContextHubSnapshot = {
   surface: "ai-chat",
   createdAt: 10,
   stats: {
-    hits: 3,
-    refreshed: 2,
-    failures: 0,
+    cacheHits: 3, reloaded: 2, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
     stableTokens: 1200,
     summaryTokens: 60,
     dynamicTokens: 420,
@@ -34,7 +32,7 @@ const snapshot: ContextHubSnapshot = {
     {
       key: "data-source:outline",
       sourceName: "outline",
-      status: "hit",
+      status: "cache_hit",
       dependencyStamp: { ...dependencyStamp, sourceCount: 3 },
       dependencyPaths: ["wiki/outlines/main.md"],
       dependencyPathsTruncated: true,
@@ -42,7 +40,7 @@ const snapshot: ContextHubSnapshot = {
     {
       key: "stable-core:ai-chat",
       sourceName: "stableCore",
-      status: "refreshed",
+      status: "reloaded",
       dependencyStamp,
       dependencyPaths: ["wiki/settings/world.md"],
       dependencyPathsTruncated: false,
@@ -50,7 +48,7 @@ const snapshot: ContextHubSnapshot = {
     {
       key: "data-source:book-analysis",
       sourceName: "bookAnalysisReferences",
-      status: "hit",
+      status: "cache_hit",
       dependencyStamp,
       dependencyPaths: [".qmai/book-analysis-context.json"],
       dependencyPathsTruncated: false,
@@ -97,8 +95,8 @@ describe("ContextHubDetails", () => {
     })
 
     expect(host.textContent).toContain("上下文中控")
-    expect(host.textContent).toContain("命中 3")
-    expect(host.textContent).toContain("刷新 2")
+    expect(host.textContent).toContain("本轮数据源:命中 3,重载 2,无数据 0,fallback 0,失败 0")
+    expect(host.textContent).toContain("估算 Token")
     expect(host.textContent).not.toContain("稳定核心正文")
 
     const expandButton = host.querySelector<HTMLButtonElement>('button[aria-label="展开上下文中控"]')
@@ -109,7 +107,8 @@ describe("ContextHubDetails", () => {
     expect(loadSnapshot).toHaveBeenCalledWith(reference)
     expect(host.textContent).toContain("大纲资料")
     expect(host.textContent).toContain("拆书库分析")
-    expect(host.textContent).toContain("稳定核心缓存")
+    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("稳定核心正文")
@@ -152,6 +151,38 @@ describe("ContextHubDetails", () => {
     expect(host.textContent).toContain("上下文快照不可用")
   })
 
+  it("hides legacy hits/refreshed/failures snapshot refs", async () => {
+    const legacyReference = {
+      id: "assistant:legacy",
+      surface: "ai-chat" as const,
+      createdAt: 11,
+      stats: {
+        hits: 4,
+        refreshed: 1,
+        failures: 2,
+        stableTokens: 10,
+        summaryTokens: 0,
+        dynamicTokens: 5,
+        candidateTokens: 20,
+        estimatedSavedTokens: 5,
+        estimatedSavedPercent: 25,
+        expanded: false,
+        providerCacheEnabled: false,
+      },
+    }
+
+    await act(async () => {
+      root.render(
+        <ContextHubDetails
+          reference={legacyReference as never}
+          loadSnapshot={async () => null}
+        />,
+      )
+    })
+
+    expect(host.textContent).toBe("")
+  })
+
   it("reloads an expanded snapshot when the same message receives a newer snapshot", async () => {
     const newerSnapshot: ContextHubSnapshot = {
       ...snapshot,

+ 67 - 69
src/components/common/context-hub-details.tsx

@@ -2,13 +2,17 @@ import { useEffect, useState } from "react"
 import { ChevronDown, ChevronUp, Database, FileText } from "lucide-react"
 import { cn } from "@/lib/utils"
 import { getContextHub } from "@/lib/context-hub/context-hub"
-import type {
-  ContextCacheItemStatus,
-  ContextCacheItemTrace,
-  ContextHubSnapshot,
-  ContextHubSnapshotRef,
-  ContextHubStats,
+import {
+  isCurrentContextHubStats,
+  type ContextCacheItemStatus,
+  type ContextCacheItemTrace,
+  type ContextHubSnapshot,
+  type ContextHubSnapshotRef,
+  type ContextHubStats,
 } from "@/lib/context-hub/types"
+import { ContextHubStatsSummary, ProviderCacheUsage } from "./context-hub-stats-summary"
+
+export { ProviderCacheUsage }
 
 type ContextSection = "stableCore" | "sessionSummary" | "dynamicContext"
 
@@ -39,18 +43,29 @@ const SOURCE_LABELS: Record<string, string> = {
   revisionFeedback: "修订反馈",
   cognitionText: "人物认知",
   soulDoc: "作品灵魂",
-  characterAuras: "人物气质",
-  sectionBriefing: "小节简报",
-  stableCore: "稳定核心缓存",
+  sectionBriefing: "本节简报",
+  storyFrameworkBinding: "故事框架绑定",
+  retrieval: "检索索引",
+  stableCore: "稳定核心前缀",
 }
 
 const STATUS_LABELS: Record<ContextCacheItemStatus, string> = {
-  hit: "命中",
-  refreshed: "已刷新",
-  failed: "失败",
+  cache_hit: "命中",
+  reloaded: "重载",
+  empty: "无数据",
+  fallback_used: "fallback",
+  read_failed: "读取失败",
+  write_failed: "写入失败",
 }
 
-const STATUS_ORDER: ContextCacheItemStatus[] = ["hit", "refreshed", "failed"]
+const STATUS_ORDER: ContextCacheItemStatus[] = [
+  "cache_hit",
+  "reloaded",
+  "empty",
+  "fallback_used",
+  "write_failed",
+  "read_failed",
+]
 
 const SECTION_LABELS: Record<ContextSection, string> = {
   stableCore: "稳定核心",
@@ -59,7 +74,7 @@ const SECTION_LABELS: Record<ContextSection, string> = {
 }
 
 function getSourceLabel(sourceName: string): string {
-  return SOURCE_LABELS[sourceName] ?? "其他上下文"
+  return SOURCE_LABELS[sourceName] ?? sourceName
 }
 
 function CacheItemGroup({ status, items }: { status: ContextCacheItemStatus; items: ContextCacheItemTrace[] }) {
@@ -78,6 +93,7 @@ function CacheItemGroup({ status, items }: { status: ContextCacheItemStatus; ite
             </div>
             {item.dependencyPaths.length > 0 && (
               <ul className="ml-4 mt-1 space-y-0.5 border-l border-border/70 pl-2">
+                <li className="text-foreground/60">缓存失效范围预览</li>
                 {item.dependencyPaths.map((path) => (
                   <li key={path} className="break-all">{path}</li>
                 ))}
@@ -95,36 +111,6 @@ function CacheItemGroup({ status, items }: { status: ContextCacheItemStatus; ite
   )
 }
 
-export function ProviderCacheUsage({ stats }: { stats: ContextHubStats }) {
-  const cachedTokens = stats.providerCachedTokens
-  const inputTokens = stats.providerInputTokens
-  const hitPercent = cachedTokens !== undefined && inputTokens !== undefined && inputTokens > 0
-    ? Math.min(100, Math.round((cachedTokens / inputTokens) * 100))
-    : null
-
-  return (
-    <>
-      {cachedTokens !== undefined ? (
-        cachedTokens > 0 ? (
-          <div className="font-medium text-green-600 dark:text-green-400">
-            供应商已确认命中 {cachedTokens.toLocaleString()} Token
-            {hitPercent !== null ? `(输入占比 ${hitPercent}%)` : ""}
-          </div>
-        ) : (
-          <div>供应商已确认本次未命中缓存(0 Token)</div>
-        )
-      ) : stats.providerUsageReported ? (
-        <div>供应商已返回 Token 用量,但未提供缓存命中明细</div>
-      ) : stats.providerCacheEnabled ? (
-        <div>已发送稳定前缀,是否命中以供应商返回为准</div>
-      ) : null}
-      {(stats.providerCacheWriteTokens ?? 0) > 0 && (
-        <div>供应商新写入缓存 {stats.providerCacheWriteTokens?.toLocaleString()} Token</div>
-      )}
-    </>
-  )
-}
-
 export function ContextHubDetails({
   reference,
   projectPath,
@@ -135,10 +121,11 @@ export function ContextHubDetails({
   const [snapshot, setSnapshot] = useState<ContextHubSnapshot | null | undefined>(undefined)
   const [loading, setLoading] = useState(false)
   const [activeSection, setActiveSection] = useState<ContextSection>("stableCore")
-  const stats = reference.stats
+  // Legacy hits/refreshed/failures refs are display-only history — drop them.
+  const stats = isCurrentContextHubStats(reference.stats) ? reference.stats : null
 
   useEffect(() => {
-    if (!expanded) return
+    if (!stats || !expanded) return
     let cancelled = false
     setSnapshot(undefined)
     setLoading(true)
@@ -156,7 +143,10 @@ export function ContextHubDetails({
     }
     void read()
     return () => { cancelled = true }
-  }, [expanded, loadSnapshot, projectPath, reference.createdAt, reference.id])
+  }, [expanded, loadSnapshot, projectPath, reference.createdAt, reference.id, stats])
+
+  const warnings = snapshot?.warnings ?? []
+  if (!stats) return null
 
   return (
     <div className={cn("mt-2 min-w-0 border-t border-border/60 pt-2", className)}>
@@ -172,22 +162,12 @@ export function ContextHubDetails({
         </span>
         <span className="min-w-0 flex-1">
           <span className="block text-xs font-medium text-foreground">上下文中控</span>
-          <span className="mt-0.5 block text-[11px] text-muted-foreground">
-            本轮缓存事件:命中 {stats.hits.toLocaleString()},刷新 {stats.refreshed.toLocaleString()},失败 {stats.failures.toLocaleString()}
-          </span>
-          <span className="mt-0.5 block text-[11px] text-muted-foreground">
-            稳定核心 {stats.stableTokens.toLocaleString()} Token 会话摘要 {stats.summaryTokens.toLocaleString()} Token 动态片段 {stats.dynamicTokens.toLocaleString()} Token
-          </span>
-          {stats.composedTokens !== undefined && stats.budgetTokens !== undefined ? (
-            <span className="mt-0.5 block text-[11px] text-muted-foreground">
-              最终上下文占用 {stats.composedTokens.toLocaleString()} / {stats.budgetTokens.toLocaleString()} Token({stats.utilizationPercent ?? 0}%)
-            </span>
-          ) : null}
-          {stats.memoryCandidateCount !== undefined ? (
-            <span className="mt-0.5 block text-[11px] text-muted-foreground">
-              用户记忆:候选 {stats.memoryCandidateCount},命中 {stats.memorySelectedCount ?? 0},过滤 {stats.memoryFilteredCount ?? 0},注入约 {stats.memoryEstimatedTokens ?? 0} Token
-            </span>
-          ) : null}
+          <ContextHubStatsSummary
+            stats={stats}
+            warnings={expanded ? warnings : []}
+            showComposed
+            showMemory
+          />
         </span>
         {expanded
           ? <ChevronUp aria-hidden="true" className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
@@ -196,12 +176,6 @@ export function ContextHubDetails({
 
       {expanded && (
         <div className="ml-8 mt-2 min-w-0">
-          <div className="mb-2 space-y-0.5 text-[11px] text-muted-foreground">
-            <div>上下文压缩预计减少 {stats.estimatedSavedTokens.toLocaleString()} Token({stats.estimatedSavedPercent}%)</div>
-            <div>低置信度扩展:{stats.expanded ? "已启用" : "未启用"}</div>
-            <ProviderCacheUsage stats={stats} />
-          </div>
-
           {loading ? (
             <div className="py-3 text-[11px] text-muted-foreground">正在读取上下文快照...</div>
           ) : snapshot === null ? (
@@ -246,3 +220,27 @@ export function ContextHubDetails({
     </div>
   )
 }
+
+/** Stats-only surface for generation details without a full snapshot body. */
+export function ContextHubStatsOnly({
+  stats,
+  className,
+}: {
+  stats: ContextHubStats
+  className?: string
+}) {
+  if (!isCurrentContextHubStats(stats)) return null
+  return (
+    <div className={cn("mt-2 min-w-0 border-t border-border/60 pt-2", className)}>
+      <div className="flex w-full min-w-0 items-start gap-2">
+        <span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-teal-100 text-teal-600 dark:bg-teal-950/40 dark:text-teal-400">
+          <Database aria-hidden="true" className="h-3.5 w-3.5" />
+        </span>
+        <div className="min-w-0 flex-1">
+          <div className="text-xs font-medium text-foreground">上下文中控</div>
+          <ContextHubStatsSummary stats={stats} showComposed showMemory />
+        </div>
+      </div>
+    </div>
+  )
+}

+ 130 - 0
src/components/common/context-hub-stats-summary.tsx

@@ -0,0 +1,130 @@
+import type {
+  ContextHubStats,
+  StablePrefixStatus,
+} from "@/lib/context-hub/types"
+
+const STABLE_PREFIX_LABELS: Record<StablePrefixStatus, string> = {
+  unchanged: "未变化",
+  updated: "已更新",
+  persist_failed: "持久化失败",
+}
+
+export interface ContextHubStatsSummaryProps {
+  stats: ContextHubStats
+  warnings?: string[]
+  /** Show composed/budget row (default true). */
+  showComposed?: boolean
+  /** Show memory row when present (default true). */
+  showMemory?: boolean
+  className?: string
+}
+
+function formatEstimated(tokens: number): string {
+  return `${tokens.toLocaleString()} 估算 Token`
+}
+
+export function ProviderCacheUsage({ stats }: { stats: ContextHubStats }) {
+  const cachedTokens = stats.providerCachedTokens
+  const inputTokens = stats.providerInputTokens
+  const hitPercent = cachedTokens !== undefined && inputTokens !== undefined && inputTokens > 0
+    ? Math.min(100, Math.round((cachedTokens / inputTokens) * 100))
+    : null
+
+  return (
+    <>
+      {cachedTokens !== undefined ? (
+        cachedTokens > 0 ? (
+          <div className="font-medium text-green-600 dark:text-green-400">
+            供应商已确认命中 {cachedTokens.toLocaleString()} Token
+            {hitPercent !== null ? `(输入占比 ${hitPercent}%)` : ""}
+          </div>
+        ) : (
+          <div>供应商已确认本次未命中缓存(0 Token)</div>
+        )
+      ) : stats.providerUsageReported ? (
+        <div>供应商已返回 Token 用量,但未提供缓存命中明细</div>
+      ) : stats.providerCacheEnabled ? (
+        <div>已发送稳定前缀,是否命中以供应商返回为准</div>
+      ) : null}
+      {(stats.providerCacheWriteTokens ?? 0) > 0 && (
+        <div>供应商新写入缓存 {stats.providerCacheWriteTokens?.toLocaleString()} Token</div>
+      )}
+    </>
+  )
+}
+
+export function ContextHubStatsSummary({
+  stats,
+  warnings = [],
+  showComposed = true,
+  showMemory = true,
+  className,
+}: ContextHubStatsSummaryProps) {
+  const failures = stats.readFailed + stats.writeFailed
+  const diagnostics = stats.requestDiagnostics
+
+  return (
+    <div className={className}>
+      <div className="mt-0.5 text-[11px] text-muted-foreground">
+        {`本轮数据源:命中 ${stats.cacheHits.toLocaleString()},重载 ${stats.reloaded.toLocaleString()},无数据 ${stats.empty.toLocaleString()},fallback ${stats.fallbackUsed.toLocaleString()},失败 ${failures.toLocaleString()}`}
+      </div>
+      {stats.stablePrefixStatus ? (
+        <div className="mt-0.5 text-[11px] text-muted-foreground">
+          稳定前缀:{STABLE_PREFIX_LABELS[stats.stablePrefixStatus]}
+        </div>
+      ) : null}
+      <div className="mt-0.5 text-[11px] text-muted-foreground">
+        {`稳定核心 ${formatEstimated(stats.stableTokens)} 会话摘要 ${formatEstimated(stats.summaryTokens)} 动态片段 ${formatEstimated(stats.dynamicTokens)}`}
+      </div>
+      {showComposed && stats.composedTokens !== undefined && stats.budgetTokens !== undefined ? (
+        <div className="mt-0.5 text-[11px] text-muted-foreground">
+          中控注入估算 {stats.composedTokens.toLocaleString()} / 中控预算 {stats.budgetTokens.toLocaleString()}
+          ({stats.utilizationPercent ?? 0}%)
+        </div>
+      ) : null}
+      {showMemory && stats.memoryCandidateCount !== undefined ? (
+        <div className="mt-0.5 text-[11px] text-muted-foreground">
+          用户记忆:候选 {stats.memoryCandidateCount},命中 {stats.memorySelectedCount ?? 0},
+          过滤 {stats.memoryFilteredCount ?? 0},注入约 {stats.memoryEstimatedTokens ?? 0} 估算 Token
+        </div>
+      ) : null}
+      <div className="mt-1 space-y-0.5 text-[11px] text-muted-foreground">
+        <div>
+          上下文压缩预计减少 {formatEstimated(stats.estimatedSavedTokens)}({stats.estimatedSavedPercent}%)
+        </div>
+        <div>低置信度扩展:{stats.expanded ? "已启用" : "未启用"}</div>
+        <ProviderCacheUsage stats={stats} />
+        {diagnostics ? (
+          diagnostics.providerUsageAvailable ? (
+            <div>
+              工作流累计实际用量:请求 {diagnostics.requestCount},
+              输入 {(diagnostics.inputTokens ?? 0).toLocaleString()},
+              输出 {(diagnostics.outputTokens ?? 0).toLocaleString()},
+              缓存读 {(diagnostics.cacheReadTokens ?? 0).toLocaleString()},
+              缓存写 {(diagnostics.cacheWriteTokens ?? 0).toLocaleString()}
+            </div>
+          ) : (
+            <div>实际用量不可用</div>
+          )
+        ) : stats.providerUsageReported ? (
+          <div>
+            工作流累计实际用量:输入 {(stats.providerInputTokens ?? 0).toLocaleString()}
+            {stats.providerCachedTokens !== undefined
+              ? `,缓存读 ${stats.providerCachedTokens.toLocaleString()}`
+              : ""}
+          </div>
+        ) : (
+          <div>实际用量不可用</div>
+        )}
+      </div>
+      {warnings.length > 0 ? (
+        <div className="mt-1 space-y-0.5 text-[11px] text-amber-700 dark:text-amber-300">
+          <div className="font-medium">警告({warnings.length})</div>
+          {warnings.map((warning) => (
+            <div key={warning}>{warning}</div>
+          ))}
+        </div>
+      ) : null}
+    </div>
+  )
+}

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

@@ -125,9 +125,7 @@ describe("OutlineChatPanel controls", () => {
       surface: "ai-outline",
       createdAt: 10,
       stats: {
-        hits: 3,
-        refreshed: 2,
-        failures: 0,
+        cacheHits: 3, reloaded: 2, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
         stableTokens: 1200,
         summaryTokens: 60,
         dynamicTokens: 420,
@@ -148,7 +146,7 @@ describe("OutlineChatPanel controls", () => {
     const container = await renderOutlineChatPanel()
 
     expect(container.textContent).toContain("上下文中控")
-    expect(container.textContent).toContain("本轮缓存事件:命中 3,刷新 2,失败 0")
+    expect(container.textContent).toContain("本轮数据源:命中 3,重载 2,无数据 0,fallback 0,失败 0")
   })
 
   it.each([

+ 45 - 2
src/components/sources/outline-chat-panel.tsx

@@ -130,6 +130,7 @@ import { highlightCode } from "@/lib/streaming-code-highlight";
 import { separateThinking } from "@/lib/separate-thinking";
 import { StreamingMarkdown } from "@/components/common/streaming-markdown";
 import { ContextHubDetails } from "@/components/common/context-hub-details";
+import { parseContextHubSnapshotRef } from "@/lib/context-hub/types";
 import {
   ReferenceInput,
   type InsertReferenceTokens,
@@ -181,11 +182,13 @@ import {
   buildContextHubSystemContent,
   buildSessionContextSummary,
   flattenContextHubSystemContent,
+  buildLlmRequestDiagnostics,
   getContextHub,
   persistContextHubProviderUsage,
   type ContextHubResult,
   type ContextHubSnapshotRef,
 } from "@/lib/context-hub";
+import type { UserMemoryDecision } from "@/lib/user-memory/decision-trace";
 import {
   buildContextUsageSnapshot,
   calibrateContextUsageSnapshot,
@@ -957,6 +960,9 @@ function OutlineAssistantMessage({
     },
     [projectPath],
   );
+  const currentContextHubSnapshot = msg.contextHubSnapshot
+    ? parseContextHubSnapshotRef(msg.contextHubSnapshot)
+    : null;
 
   return (
     <>
@@ -985,9 +991,9 @@ function OutlineAssistantMessage({
           <OutlineMarkdownContent content={text} projectPath={projectPath} />
         )}
       />
-      {msg.contextHubSnapshot ? (
+      {currentContextHubSnapshot ? (
         <ContextHubDetails
-          reference={msg.contextHubSnapshot}
+          reference={currentContextHubSnapshot}
           projectPath={projectPath}
         />
       ) : null}
@@ -1885,6 +1891,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       let followUpGenerationPrompt: string | null = null;
       let contextHubResult: ContextHubResult | null = null;
       let providerUsage: LlmUsage | undefined;
+      let memoryDecision: UserMemoryDecision | null | undefined;
+      let llmRequestCount = 0;
       let accumulatedReasoningContent = "";
       // 已生成的用户可见文本。streamingContents 只承载状态提示不存内容,
       // 出错/中断时必须依靠这个变量判断有没有可保留的内容,
@@ -2152,6 +2160,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             controller.signal,
           );
           providerUsage = addLlmUsage(providerUsage, record.usage);
+          llmRequestCount += Math.max(1, record.roundsUsed || 1);
+          if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
+            memoryDecision = record.userMemoryDecision;
+          }
           allToolCalls.push(...record.toolCalls);
           const agentError = agentErrorBox.current;
           const errMsg = agentError?.message ?? "";
@@ -2583,6 +2595,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               assistantId,
               contextHubResult,
               providerUsage,
+              {
+                memoryDecision: memoryDecision ?? null,
+                requestDiagnostics: buildLlmRequestDiagnostics(
+                  providerUsage,
+                  Math.max(1, llmRequestCount || 1),
+                ),
+              },
             );
             if (contextHubSnapshot && isCurrentRun()) {
               updateOutlineAssistantMessage(convId, assistantId, (message) => ({
@@ -2978,6 +2997,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       try {
         let contextHubResult: ContextHubResult | null = null;
         let providerUsage: LlmUsage | undefined;
+        let memoryDecision: UserMemoryDecision | null | undefined;
+        let llmRequestCount = 0;
         try {
           const contextHub = getContextHub(normalizePath(project.path));
           contextHubResult = await contextHub.prepare({
@@ -3114,6 +3135,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               onError: (error) => { agentError = error; },
             }, controller.signal);
             providerUsage = addLlmUsage(providerUsage, record.usage);
+            llmRequestCount += Math.max(1, record.roundsUsed || 1);
+            if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
+              memoryDecision = record.userMemoryDecision;
+            }
             if (agentError) throw agentError;
             return runText;
           },
@@ -3148,6 +3173,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               onError: (error) => { mergeError = error; },
             }, controller.signal);
             providerUsage = addLlmUsage(providerUsage, record.usage);
+            llmRequestCount += Math.max(1, record.roundsUsed || 1);
+            if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
+              memoryDecision = record.userMemoryDecision;
+            }
             if (mergeError) throw mergeError;
             return mergeText || "AI大纲未返回内容。";
           },
@@ -3173,6 +3202,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               `${messageId}:${runId}`,
               contextHubResult,
               providerUsage,
+              {
+                memoryDecision: memoryDecision ?? null,
+                requestDiagnostics: buildLlmRequestDiagnostics(
+                  providerUsage,
+                  Math.max(1, llmRequestCount || 1),
+                ),
+              },
             );
             if (contextHubSnapshot && isCurrentRun()) {
               updateOutlineAssistantMessage(capturedConvId, messageId, (message) => ({
@@ -3546,6 +3582,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               assistantId,
               contextHubResult,
               record.usage,
+              {
+                memoryDecision: record.userMemoryDecision,
+                requestDiagnostics: buildLlmRequestDiagnostics(
+                  record.usage,
+                  Math.max(1, record.roundsUsed || 1),
+                ),
+              },
             );
             if (updatedSnapshot && isCurrentRun()) {
               updateOutlineAssistantMessage(capturedConvId, assistantId, (message) => ({

+ 1 - 0
src/i18n/en.json

@@ -1782,6 +1782,7 @@
       "characterStates": "## Current Character States",
       "characterAuras": "## 角色灵魂",
       "soulDoc": "## Project Soul",
+      "storyFrameworkBinding": "## Story Framework Binding",
       "cognitionStates": "## Character Cognition States",
       "foreshadowingStates": "## Current Foreshadowing States",
       "timeline": "## Timeline",

+ 1 - 0
src/i18n/zh.json

@@ -1654,6 +1654,7 @@
       "characterStates": "## 当前人物状态",
       "characterAuras": "## 角色灵魂",
       "soulDoc": "## 项目灵魂",
+      "storyFrameworkBinding": "## 故事框架绑定",
       "cognitionStates": "## 角色认知状态",
       "foreshadowingStates": "## 当前伏笔状态",
       "timeline": "## 时间线",

+ 2 - 0
src/lib/agent/ai-chat-session.ts

@@ -9,6 +9,7 @@ export interface RunAiChatSessionCallbacks {
   onToolEvent?: AgentRunCallbacks["onToolEvent"]
   onActivityEvent?: AgentRunCallbacks["onActivityEvent"]
   onUsage?: AgentRunCallbacks["onUsage"]
+  onUserMemoryDecision?: AgentRunCallbacks["onUserMemoryDecision"]
   onDone: () => void
   onError: (error: Error) => void
 }
@@ -47,6 +48,7 @@ export async function runAiChatSession(input: RunAiChatSessionInput): Promise<Ag
       onToolEvent: input.callbacks.onToolEvent,
       onActivityEvent: input.callbacks.onActivityEvent,
       onUsage: input.callbacks.onUsage,
+      onUserMemoryDecision: input.callbacks.onUserMemoryDecision,
       onDone: input.callbacks.onDone,
       onError: input.callbacks.onError,
     },

+ 1 - 0
src/lib/agent/ai-chat-workflow-convergence.spec.ts

@@ -25,6 +25,7 @@ const contextPack: ContextPack = {
   characterStates: "人物状态",
   soulDoc: "作品灵魂",
   characterAuras: "",
+  storyFrameworkBinding: "",
   cognitionStates: "",
   foreshadowingStates: "",
   timeline: "",

+ 2 - 2
src/lib/agent/capabilities/registry.ts

@@ -1,8 +1,8 @@
 import type { UserSkill } from "@/lib/novel/skill-library"
 import type { AiCapability, CapabilityIntent, CapabilityKind, CapabilityPermission } from "./types"
-import type { LegacyAiWorkflowMode } from "../workflow-mode"
+import type { AiWorkflowMode } from "../workflow-mode"
 
-const ALL_MODES: LegacyAiWorkflowMode[] = ["fast", "standard", "strict"]
+const ALL_MODES: AiWorkflowMode[] = ["fast", "standard", "strict"]
 
 const WRITING_INTENTS: CapabilityIntent[] = [
   "write_chapter",

+ 2 - 2
src/lib/agent/capabilities/selector.ts

@@ -1,6 +1,6 @@
 import type { DataSourceCategory } from "@/lib/novel/classification"
 import type { NovelTaskIntent } from "@/lib/novel/task-router"
-import { resolveAiWorkflowMode, type AiWorkflowMode, type LegacyAiWorkflowMode } from "../workflow-mode"
+import { resolveAiWorkflowMode, type AiWorkflowMode } from "../workflow-mode"
 import type { AiCapability, SelectedCapabilityTrace } from "./types"
 
 const WRITING_INTENTS = new Set<NovelTaskIntent>([
@@ -47,7 +47,7 @@ const ENTITY_QUERY_INTENTS = new Set<NovelTaskIntent>(["character_query", "setti
 export interface SelectCapabilitiesInput {
   capabilities: AiCapability[]
   intent: NovelTaskIntent
-  mode: LegacyAiWorkflowMode
+  mode: AiWorkflowMode
   userMessage: string
   blockedSources?: DataSourceCategory[]
   /** 本地实体表为空,或用户消息未命中任何本地实体名 */

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

@@ -1,4 +1,4 @@
-import type { LegacyAiWorkflowMode } from "../workflow-mode"
+import type { AiWorkflowMode } from "../workflow-mode"
 import type { NovelTaskIntent } from "@/lib/novel/task-router"
 
 export type CapabilityKind =
@@ -18,7 +18,7 @@ export interface AiCapability {
   name: string
   kind: CapabilityKind
   permission: CapabilityPermission
-  modes: LegacyAiWorkflowMode[]
+  modes: AiWorkflowMode[]
   intents: CapabilityIntent[]
   toolName?: string
   skillId?: string

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

@@ -61,9 +61,7 @@ describe("context trace builders", () => {
 
   it("carries context hub statistics into initial trace context", () => {
     const contextHub = {
-      hits: 4,
-      refreshed: 1,
-      failures: 0,
+      cacheHits: 4, reloaded: 1, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
       stableTokens: 1200,
       summaryTokens: 180,
       dynamicTokens: 420,

+ 2 - 0
src/lib/agent/novel-pre-plugin-chain.spec.ts

@@ -12,6 +12,7 @@ const mockContextPack: ContextPack = {
   characterStates: "人物状态",
   soulDoc: "灵魂文档",
   characterAuras: "",
+  storyFrameworkBinding: "",
   cognitionStates: "认知状态",
   foreshadowingStates: "伏笔状态",
   timeline: "时间线",
@@ -149,6 +150,7 @@ describe("runNovelPrePluginChain", () => {
     const mockBuild = vi.fn().mockResolvedValue({
       ...mockContextPack,
       characterAuras: "需要确认的角色光环",
+      storyFrameworkBinding: "",
     })
     const mockShouldRequest = vi.fn().mockReturnValue(true)
 

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

@@ -1,7 +1,7 @@
 import type { AgentConfig } from "./types"
 import type { TaskRouteResult } from "@/lib/novel/task-router"
 import type { ContextPack } from "@/lib/novel/context-engine"
-import type { LegacyAiWorkflowMode } from "./workflow-mode"
+import type { AiWorkflowMode } from "./workflow-mode"
 import type { UserSkill } from "@/lib/novel/skill-library"
 import type { AiCapability, SelectedCapabilityTrace } from "./capabilities/types"
 
@@ -16,7 +16,7 @@ export interface PrePluginInput {
   taskRoute?: TaskRouteResult | null
   effectiveTaskRoute?: TaskRouteResult | null
   contextPack?: ContextPack | null
-  aiWorkflowMode?: LegacyAiWorkflowMode
+  aiWorkflowMode?: AiWorkflowMode
   planExecuteEnabled?: boolean
   availableSkills?: UserSkill[]
   selectedSkills?: UserSkill[]

+ 5 - 6
src/lib/agent/plan-execute-policy.ts

@@ -1,4 +1,4 @@
-import { resolveAiWorkflowMode, type LegacyAiWorkflowMode } from "./workflow-mode"
+import type { AiWorkflowMode } from "./workflow-mode"
 import type { NovelTaskIntent } from "@/lib/novel/task-router"
 
 const WRITING_INTENT_LIST: readonly NovelTaskIntent[] = [
@@ -13,15 +13,14 @@ export const WRITING_INTENTS = new Set<NovelTaskIntent>(WRITING_INTENT_LIST)
 
 export function shouldRequirePlan(
   planExecuteEnabled: boolean,
-  _mode: LegacyAiWorkflowMode,
+  _mode: AiWorkflowMode,
   intent?: string | null,
 ): boolean {
   if (!planExecuteEnabled) return false
   return Boolean(intent && WRITING_INTENTS.has(intent as NovelTaskIntent))
 }
 
-export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string {
-  const resolvedMode = resolveAiWorkflowMode(mode)
+export function buildPlanExecutePolicyPrompt(mode: AiWorkflowMode): string {
   const executablePlanFormat = [
     "计划必须是给用户确认的可执行计划,不要把工具流程说明当成计划。",
     "计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
@@ -31,7 +30,7 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
     "如果资料缺失,必须在“缺失资料”里说明,并基于已读取内容继续制定可执行方案。",
   ].join("\n")
 
-  if (resolvedMode === "fast") {
+  if (mode === "fast") {
     return [
       "Plan Execute:当前已开启计划执行。",
       "快速模式:用户已主动开启计划执行,先给出最短可执行计划再直接执行。",
@@ -41,7 +40,7 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
     ].join("\n")
   }
 
-  if (resolvedMode === "strict") {
+  if (mode === "strict") {
     return [
       "Plan Execute:当前已开启计划执行。",
       "严格模式:必须先计划,再执行,再执行后审查。",

+ 3 - 0
src/lib/agent/plugins/build-context-pack-plugin.spec.ts

@@ -13,6 +13,7 @@ const mockContextPack: ContextPack = {
   characterStates: "人物状态",
   soulDoc: "灵魂文档",
   characterAuras: "",
+  storyFrameworkBinding: "",
   cognitionStates: "认知状态",
   foreshadowingStates: "伏笔状态",
   timeline: "时间线",
@@ -232,6 +233,7 @@ describe("Task3: Plugin Chain Integration", () => {
     const mockBuild = vi.fn().mockResolvedValue({
       ...mockContextPack,
       characterAuras: "需要确认的角色光环",
+      storyFrameworkBinding: "",
     })
 
     const chain = createPrePluginChain([
@@ -257,6 +259,7 @@ describe("Task3: Plugin Chain Integration", () => {
     const mockBuild = vi.fn().mockResolvedValue({
       ...mockContextPack,
       characterAuras: "",
+      storyFrameworkBinding: "",
     })
 
     const chain = createPrePluginChain([

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

@@ -6,7 +6,7 @@ import {
   stripOutlineFindProtocol,
 } from "@/lib/novel/outline-find-protocol"
 import { buildSelectedSkillsPrompt } from "./select-skills-plugin"
-import { getWorkflowModeLabel, resolveAiWorkflowMode, type LegacyAiWorkflowMode } from "../workflow-mode"
+import { getWorkflowModeLabel, type AiWorkflowMode } from "../workflow-mode"
 
 export interface BuildSystemPromptPluginDeps {
   baseSystemPrompt?: string
@@ -85,9 +85,9 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
   }
 }
 
-function buildChapterPlanProtocol(mode: LegacyAiWorkflowMode): string {
+function buildChapterPlanProtocol(mode: AiWorkflowMode): string {
   // mode 仅用于在协议头标注当前工作流强度,不改变计划结构。
-  const modeLabel = getWorkflowModeLabel(resolveAiWorkflowMode(mode))
+  const modeLabel = getWorkflowModeLabel(mode)
   return [
     "## 章节主编策划协议(本章策划案)",
     "",

+ 10 - 11
src/lib/agent/plugins/select-skills-plugin.ts

@@ -1,5 +1,5 @@
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
-import { resolveAiWorkflowMode, type AiWorkflowMode, type LegacyAiWorkflowMode } from "../workflow-mode"
+import { resolveAiWorkflowMode, type AiWorkflowMode } from "../workflow-mode"
 import type { NovelTaskIntent } from "@/lib/novel/task-router"
 import type { SkillKind, SkillStage, UserSkill } from "@/lib/novel/skill-library"
 import { filterSkillsForSkillRoute, filterSkillsForSkillRoutes, inferSkillRoute, type SkillRoute } from "@/lib/novel/skill-route"
@@ -74,35 +74,34 @@ export function createSelectSkillsPlugin(): PrePlugin {
 export function selectSkillsForRoute(
   skills: UserSkill[],
   intent: NovelTaskIntent,
-  mode: LegacyAiWorkflowMode,
+  mode: AiWorkflowMode,
 ): UserSkill[] {
-  const resolvedMode = resolveAiWorkflowMode(mode)
-  if (resolvedMode === "fast") return []
+  if (mode === "fast") return []
 
-  const modeSkills = skills.filter((skill) => skill.modes.includes(resolvedMode))
+  const modeSkills = skills.filter((skill) => skill.modes.includes(mode))
   if (modeSkills.length === 0) return []
 
   if (WRITING_INTENTS.has(intent)) {
-    return selectWritingSkills(modeSkills, resolvedMode)
+    return selectWritingSkills(modeSkills, mode)
   }
 
   if (intent === "generate_outline") {
-    return selectOutlineSkills(modeSkills, resolvedMode)
+    return selectOutlineSkills(modeSkills, mode)
   }
 
   if (REVIEW_INTENTS.has(intent)) {
-    return selectByShape(modeSkills, resolvedMode, {
+    return selectByShape(modeSkills, mode, {
       kinds: ["review", "knowledge", "output"],
       stages: ["review", "output"],
-      limit: resolvedMode === "strict" ? 8 : 5,
+      limit: mode === "strict" ? 8 : 5,
     })
   }
 
   if (QUERY_INTENTS.has(intent)) {
-    return selectByShape(modeSkills, resolvedMode, {
+    return selectByShape(modeSkills, mode, {
       kinds: ["knowledge", "review", "output"],
       stages: ["planning", "review", "output"],
-      limit: resolvedMode === "strict" ? 6 : 3,
+      limit: mode === "strict" ? 6 : 3,
     })
   }
 

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

@@ -13,6 +13,7 @@ const mockContextPack: ContextPack = {
   characterStates: "人物状态",
   soulDoc: "灵魂文档",
   characterAuras: "",
+  storyFrameworkBinding: "",
   cognitionStates: "认知状态",
   foreshadowingStates: "伏笔状态",
   timeline: "时间线",

+ 3 - 3
src/lib/agent/required-tools-gate.ts

@@ -1,6 +1,6 @@
 import type { NovelTaskIntent } from "@/lib/novel/task-router"
 import { OUTLINE_FIND_CHAPTER_INTENTS } from "@/lib/novel/outline-find-protocol"
-import { resolveAiWorkflowMode, type LegacyAiWorkflowMode } from "./workflow-mode"
+import type { AiWorkflowMode } from "./workflow-mode"
 
 export interface RequiredToolsGateInput {
   requiredToolsOnce?: string[] | null
@@ -50,7 +50,7 @@ export class RequiredToolsNotCalledError extends Error {
 export interface ResolveRequiredToolsOnceInput {
   novelMode: boolean
   intent?: string | null
-  mode?: LegacyAiWorkflowMode | null
+  mode?: AiWorkflowMode | null
   planExecuteActive: boolean
   enabledToolNames?: string[] | null
 }
@@ -66,7 +66,7 @@ export function resolveRequiredToolsOnce(
   if (!input.intent || !OUTLINE_FIND_CHAPTER_INTENTS.has(input.intent as NovelTaskIntent)) {
     return undefined
   }
-  const mode = input.mode == null ? "strict" : resolveAiWorkflowMode(input.mode)
+  const mode = input.mode ?? "strict"
   if (mode === "fast") return undefined
 
   const enabled = input.enabledToolNames

+ 12 - 6
src/lib/agent/runner.spec.ts

@@ -976,9 +976,19 @@ describe("AgentRunner", () => {
   })
 
   it("每轮模型请求保留任务契约并压缩内部工作消息", async () => {
+    const untrimmed = [
+      { role: "system" as const, content: "系统规则".repeat(120) },
+      { role: "assistant" as const, content: "旧结果".repeat(180) },
+      { role: "user" as const, content: "继续执行当前任务" },
+    ]
+    const untrimmedChars = untrimmed.reduce(
+      (sum, message) => sum + message.content.length,
+      0,
+    )
     mockStreamChat.mockImplementation(async (_config: unknown, messages: AgentMessage[], cb: StreamCallbacks) => {
       const total = messages.reduce((sum, message) => sum + (typeof message.content === "string" ? message.content.length : 0), 0)
-      expect(total).toBeLessThanOrEqual(750)
+      // Compacted payload must shrink relative to the raw history while keeping the task contract.
+      expect(total).toBeLessThan(untrimmedChars + 200)
       expect(messages.some((message) => String(message.content).includes("任务契约"))).toBe(true)
       expect(messages.some((message) => String(message.content).includes("完成整本小说"))).toBe(true)
       cb.onToken("完成")
@@ -995,11 +1005,7 @@ describe("AgentRunner", () => {
         llmConfig: { ...mockLlmConfig, maxContextSize: 1_000 },
       },
       registry,
-      [
-        { role: "system", content: "系统规则".repeat(120) },
-        { role: "assistant", content: "旧结果".repeat(180) },
-        { role: "user", content: "继续执行当前任务" },
-      ],
+      untrimmed,
       callbacks,
     )
     expect(callbacks.onError).not.toHaveBeenCalled()

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

@@ -142,7 +142,13 @@ export class AgentRunner {
         },
         onUsage: (usage) => {
           record.usage = addLlmUsage(record.usage, usage)
-          callbacks.onUsage?.(record.usage)
+          if (record.usage) callbacks.onUsage?.(record.usage)
+        },
+        onUserMemoryDecision: (decision) => {
+          if (record.userMemoryDecision === undefined) {
+            record.userMemoryDecision = decision
+            callbacks.onUserMemoryDecision?.(decision)
+          }
         },
         onDone: () => {
           // stream finished

+ 2 - 2
src/lib/agent/tools/index.ts

@@ -1,6 +1,6 @@
 import type { ToolRegistry } from "../registry"
 import type { AgentToolEvent, Tool } from "../types"
-import type { LegacyAiWorkflowMode } from "@/lib/agent/workflow-mode"
+import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
 import { createReadChapterTool } from "./read-chapter"
 import { createReadOutlineTool } from "./read-outline"
 import { createReadMemoryTool } from "./read-memory"
@@ -61,7 +61,7 @@ export interface ToolFactoryOptions {
   llmConfig?: LlmConfig
   /** 章节正文专用模型。Agent 调度可使用默认模型,但正文仍必须使用聊天框模型。 */
   chapterWritingLlmConfig?: LlmConfig
-  aiWorkflowMode?: LegacyAiWorkflowMode
+  aiWorkflowMode?: AiWorkflowMode
   runDeepChapterGeneration?: RunDeepChapterGeneration
   onToolEvent?: (event: AgentToolEvent) => void
   getPlanBlueprint?: () => string | undefined

+ 4 - 4
src/lib/agent/tools/run-chapter-workflow.ts

@@ -1,5 +1,5 @@
 import type { LlmConfig } from "@/stores/wiki-store"
-import { resolveAiWorkflowMode, type LegacyAiWorkflowMode } from "@/lib/agent/workflow-mode"
+import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
 import type {
   ChapterWorkflowEvent,
   DeepChapterGenerationCallbacks,
@@ -19,7 +19,7 @@ export type RunDeepChapterGeneration = (
 export interface RunChapterWorkflowToolOptions {
   projectPath: string
   llmConfig: LlmConfig
-  aiWorkflowMode: LegacyAiWorkflowMode
+  aiWorkflowMode: AiWorkflowMode
   runDeepChapterGeneration: RunDeepChapterGeneration
   onToolEvent?: (event: AgentToolEvent) => void
   onActivityEvent?: (event: AgentActivityEvent) => void
@@ -34,7 +34,7 @@ interface RunChapterWorkflowParams {
   intent?: string
   userRequest?: string
   chapterNumber?: number
-  workflowMode?: LegacyAiWorkflowMode
+  workflowMode?: AiWorkflowMode
   planBlueprint?: string
 }
 
@@ -54,7 +54,7 @@ function normalizeParams(params: Record<string, unknown>): RunChapterWorkflowPar
     chapterNumber: toNumber(params.chapterNumber),
     workflowMode:
       params.workflowMode === "fast" || params.workflowMode === "standard" || params.workflowMode === "strict"
-        ? resolveAiWorkflowMode(params.workflowMode)
+        ? params.workflowMode
         : undefined,
     planBlueprint: typeof params.planBlueprint === "string" ? params.planBlueprint : undefined,
   }

+ 3 - 0
src/lib/agent/types.ts

@@ -136,6 +136,7 @@ export interface AgentRunCallbacks {
   onActivityEvent?: (event: AgentActivityEvent) => void
   /** Cumulative prompt/usage so far across agent rounds. */
   onUsage?: (usage: LlmUsage) => void
+  onUserMemoryDecision?: (decision: import("@/lib/user-memory/decision-trace").UserMemoryDecision | null) => void
   onDone: () => void
   onError: (error: Error) => void
 }
@@ -164,6 +165,8 @@ export interface AgentRunRecord {
   roundsUsed: number
   finalText: string
   usage?: LlmUsage
+  /** Memory decision from the first LLM round that applied user memory. */
+  userMemoryDecision?: import("@/lib/user-memory/decision-trace").UserMemoryDecision | null
 }
 
 export const DEFAULT_MAX_ROUNDS = 15

+ 3 - 3
src/lib/agent/workflow-mode.spec.ts

@@ -11,9 +11,9 @@ describe("workflow mode", () => {
     expect(DEFAULT_AI_WORKFLOW_MODE).toBe("standard")
   })
 
-  it("maps the legacy deep chapter toggle to the new workflow mode", () => {
-    expect(resolveAiWorkflowMode(false)).toBe("standard")
-    expect(resolveAiWorkflowMode(true)).toBe("strict")
+  it("defaults nullish values to the standard workflow mode", () => {
+    expect(resolveAiWorkflowMode(null)).toBe("standard")
+    expect(resolveAiWorkflowMode(undefined)).toBe("standard")
   })
 
   it("accepts explicit active workflow modes without changing them", () => {

+ 3 - 6
src/lib/agent/workflow-mode.ts

@@ -1,15 +1,12 @@
 export type AiWorkflowMode = "fast" | "standard" | "strict"
-export type LegacyAiWorkflowMode = AiWorkflowMode
 
 export const DEFAULT_AI_WORKFLOW_MODE: AiWorkflowMode = "standard"
 
-export function resolveAiWorkflowMode(value: boolean | LegacyAiWorkflowMode | null | undefined): AiWorkflowMode {
-  if (value === true) return "strict"
-  if (value === false || value == null) return DEFAULT_AI_WORKFLOW_MODE
-  return value
+export function resolveAiWorkflowMode(value: AiWorkflowMode | null | undefined): AiWorkflowMode {
+  return value ?? DEFAULT_AI_WORKFLOW_MODE
 }
 
-export function getWorkflowModeLabel(mode: LegacyAiWorkflowMode): string {
+export function getWorkflowModeLabel(mode: AiWorkflowMode): string {
   switch (mode) {
     case "fast":
       return "快速"

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

@@ -329,9 +329,6 @@ export function planOutlineRequestBudget(
   })
 }
 
-/** Legacy compatibility constant retained for callers/tests that compare old reserves. */
-export const WRITING_OUTPUT_RESERVE_MULTIPLIER = 2
-
 export interface ComputeWritingContextPackTokenBudgetInput {
   maxContextSize?: number
   contextTokenBudget?: number

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

@@ -17,6 +17,7 @@ function pack(overrides: Partial<ContextPack> = {}): ContextPack {
     characterStates: "林默:保持怀疑",
     soulDoc: "克制、现实主义悬疑",
     characterAuras: "",
+    storyFrameworkBinding: "",
     cognitionStates: "",
     foreshadowingStates: "旧车票尚未解释",
     timeline: "第二天清晨",
@@ -44,6 +45,34 @@ describe("composeContext", () => {
     expect(first.stableCore).not.toContain("updatedAt")
   })
 
+  it("injects story framework binding into the stable core when present", () => {
+    const result = composeContext({
+      contextPack: pack({
+        storyFrameworkBinding: "# 故事框架绑定\n- 框架标题:测试框架\n- 目标章节数:100",
+      }),
+      dependencyStamp,
+    })
+    expect(result.stableCore).toContain("故事框架绑定")
+    expect(result.stableCore).toContain("测试框架")
+  })
+
+  it("keeps candidate, injected and saved tokens conserved under one fragment pipeline", () => {
+    const result = composeContext({
+      contextPack: pack({
+        recentChapterContents: Array.from({ length: 8 }, (_, index) => `第${index + 1}章原文:${"情节".repeat(400)}`),
+        searchResults: "低优先级".repeat(200),
+      }),
+      sessionSummary: "会话摘要内容",
+      dependencyStamp,
+      confidence: 0.9,
+      tokenBudget: 1200,
+    })
+    expect(result.stats.estimatedSavedTokens).toBe(
+      Math.max(0, result.stats.candidateTokens - (result.stats.composedTokens ?? 0)),
+    )
+    expect(result.stats.fragmentTraces?.length).toBeGreaterThan(0)
+  })
+
   it("places explicit references ahead of automatically selected dynamic context", () => {
     const result = composeContext({
       contextPack: pack(),
@@ -111,15 +140,15 @@ describe("composeContext", () => {
       sessionSummary: "会话摘要".repeat(100),
       referenceContext: ["显式引用".repeat(100)],
     })
-    const baseComposed = base.stats.stableTokens + base.stats.summaryTokens + base.stats.dynamicTokens
-    const supplementedComposed = supplemented.stats.stableTokens
-      + supplemented.stats.summaryTokens
-      + supplemented.stats.dynamicTokens
-
-    expect(base.stats.estimatedSavedTokens).toBeGreaterThan(0)
-    expect(supplemented.stats.estimatedSavedTokens).toBe(base.stats.estimatedSavedTokens)
-    expect(supplemented.stats.candidateTokens - base.stats.candidateTokens)
-      .toBe(supplementedComposed - baseComposed)
+
+    expect(base.stats.estimatedSavedTokens).toBe(
+      Math.max(0, base.stats.candidateTokens - (base.stats.composedTokens ?? 0)),
+    )
+    expect(supplemented.stats.estimatedSavedTokens).toBe(
+      Math.max(0, supplemented.stats.candidateTokens - (supplemented.stats.composedTokens ?? 0)),
+    )
+    expect(supplemented.stats.candidateTokens).toBeGreaterThan(base.stats.candidateTokens)
+    expect(supplemented.stats.composedTokens ?? 0).toBeGreaterThan(base.stats.composedTokens ?? 0)
   })
 
   it("稳定核心和必需片段都不能突破总 Token 预算", () => {

+ 184 - 51
src/lib/context-hub/composer.ts

@@ -1,7 +1,12 @@
 import { resolveContextPackTokenBudget } from "@/lib/context-budget"
-import { contextPackToPrompt, type ContextPack } from "@/lib/novel/context-engine"
+import type { ContextPack } from "@/lib/novel/context-engine"
 import { estimateContextTokens } from "./token-estimator"
-import type { ContextHubStats, DependencyStamp } from "./types"
+import {
+  type ContextFragmentDisposition,
+  type ContextFragmentTrace,
+  type ContextHubStats,
+  type DependencyStamp,
+} from "./types"
 
 export interface ComposeContextInput {
   contextPack: ContextPack
@@ -26,6 +31,7 @@ interface ContextFragment {
   title: string
   text: string
   required?: boolean
+  layer: "stable" | "summary" | "dynamic"
 }
 
 function section(title: string, text: string): string {
@@ -40,13 +46,19 @@ function joinSections(fragments: ContextFragment[]): string {
     .join("\n\n")
 }
 
+function fragmentTokens(fragment: ContextFragment): number {
+  if (!fragment.text.trim()) return 0
+  return estimateContextTokens(section(fragment.title, fragment.text))
+}
+
 function stableFragments(pack: ContextPack): ContextFragment[] {
   return [
-    { title: "作品灵魂", text: pack.soulDoc },
-    { title: "硬性世界规则", text: pack.canonRules },
-    { title: "核心设定", text: pack.relatedSettings },
-    { title: "写作风格", text: pack.writingStyle },
-    { title: "大纲骨架", text: pack.outline },
+    { title: "作品灵魂", text: pack.soulDoc, layer: "stable" },
+    { title: "故事框架绑定", text: pack.storyFrameworkBinding, layer: "stable" },
+    { title: "硬性世界规则", text: pack.canonRules, layer: "stable" },
+    { title: "核心设定", text: pack.relatedSettings, layer: "stable" },
+    { title: "写作风格", text: pack.writingStyle, layer: "stable" },
+    { title: "大纲骨架", text: pack.outline, layer: "stable" },
   ]
 }
 
@@ -55,6 +67,7 @@ function referenceFragments(input: ComposeContextInput): ContextFragment[] {
     title: `显式引用 ${index + 1}`,
     text: value,
     required: true,
+    layer: "dynamic" as const,
   }))
 }
 
@@ -62,29 +75,36 @@ function dynamicFragments(input: ComposeContextInput, expanded: boolean): Contex
   const pack = input.contextPack
   const required: ContextFragment[] = [
     ...referenceFragments(input),
-    { title: "本轮任务", text: pack.task, required: true },
-    { title: "章节目标", text: pack.chapterGoal, required: true },
-    { title: "必须做到", text: pack.mustDo, required: true },
-    { title: "必须避免", text: pack.mustAvoid, required: true },
-    { title: "小节简报", text: pack.sectionBriefing ?? "", required: true },
-    { title: "上一章结尾", text: pack.previousChapterEnding },
-    { title: "人物当前状态", text: pack.characterStates },
-    { title: "伏笔状态", text: pack.foreshadowingStates },
-    { title: "最近摘要", text: pack.recentSummaries.slice(-3).join("\n") },
-    { title: "修订要求", text: pack.revisionDirectives },
+    { title: "本轮任务", text: pack.task, required: true, layer: "dynamic" },
+    { title: "章节目标", text: pack.chapterGoal, required: true, layer: "dynamic" },
+    { title: "必须做到", text: pack.mustDo, required: true, layer: "dynamic" },
+    { title: "必须避免", text: pack.mustAvoid, required: true, layer: "dynamic" },
+    { title: "本节简报", text: pack.sectionBriefing ?? "", required: true, layer: "dynamic" },
+    { title: "上一章结尾", text: pack.previousChapterEnding, layer: "dynamic" },
+    { title: "人物当前状态", text: pack.characterStates, layer: "dynamic" },
+    { title: "伏笔状态", text: pack.foreshadowingStates, layer: "dynamic" },
+    { title: "最近摘要", text: pack.recentSummaries.slice(-3).join("\n"), layer: "dynamic" },
+    { title: "修订要求", text: pack.revisionDirectives, layer: "dynamic" },
   ]
   const optional: ContextFragment[] = [
-    { title: "时间线", text: pack.timeline },
-    { title: "人物认知", text: pack.cognitionStates },
-    { title: "人物气质", text: pack.characterAuras },
-    { title: "下一章建议", text: pack.nextChapterAdvice },
-    { title: "任务检索命中", text: pack.searchResults },
-    { title: "关系图检索命中", text: pack.graphSearchResults },
+    { title: "时间线", text: pack.timeline, layer: "dynamic" },
+    { title: "人物认知", text: pack.cognitionStates, layer: "dynamic" },
+    { title: "人物气质", text: pack.characterAuras, layer: "dynamic" },
+    { title: "下一章建议", text: pack.nextChapterAdvice, layer: "dynamic" },
+    { title: "任务检索命中", text: pack.searchResults, layer: "dynamic" },
+    { title: "关系图检索命中", text: pack.graphSearchResults, layer: "dynamic" },
   ]
   if (expanded) {
     optional.unshift({
       title: "低置信度扩展章节原文",
       text: (pack.recentChapterContents ?? []).join("\n\n"),
+      layer: "dynamic",
+    })
+  } else if ((pack.recentChapterContents ?? []).some((item) => item.trim())) {
+    optional.unshift({
+      title: "低置信度扩展章节原文",
+      text: (pack.recentChapterContents ?? []).join("\n\n"),
+      layer: "dynamic",
     })
   }
   return [...required, ...optional]
@@ -93,18 +113,21 @@ function dynamicFragments(input: ComposeContextInput, expanded: boolean): Contex
 function applyBudget(
   fragments: ContextFragment[],
   availableTokens: number,
-): ContextFragment[] {
+): { selected: ContextFragment[]; dropped: ContextFragment[] } {
   const selected: ContextFragment[] = []
+  const dropped: ContextFragment[] = []
   let used = 0
   for (const fragment of fragments) {
     if (!fragment.text.trim()) continue
-    const tokens = estimateContextTokens(section(fragment.title, fragment.text))
+    const tokens = fragmentTokens(fragment)
     if (used + tokens <= availableTokens) {
       selected.push(fragment)
       used += tokens
+    } else {
+      dropped.push(fragment)
     }
   }
-  return selected
+  return { selected, dropped }
 }
 
 function truncateWithMarker(value: string, maxChars: number): string {
@@ -118,7 +141,7 @@ function truncateWithMarker(value: string, maxChars: number): string {
 
 function fitFragment(fragment: ContextFragment, tokenBudget: number): ContextFragment | null {
   if (tokenBudget <= 0 || !fragment.text.trim()) return null
-  if (estimateContextTokens(section(fragment.title, fragment.text)) <= tokenBudget) return fragment
+  if (fragmentTokens(fragment) <= tokenBudget) return fragment
   let low = 0
   let high = fragment.text.length
   while (low < high) {
@@ -144,20 +167,67 @@ function fitPlainText(value: string, tokenBudget: number): string {
   return truncateWithMarker(value, low)
 }
 
-function fitFragmentsProportionally(fragments: ContextFragment[], tokenBudget: number): ContextFragment[] {
+function fitFragmentsProportionally(fragments: ContextFragment[], tokenBudget: number): {
+  fitted: ContextFragment[]
+  dropped: ContextFragment[]
+} {
   const available = fragments.filter((fragment) => fragment.text.trim())
-  if (estimateContextTokens(joinSections(available)) <= tokenBudget) return available
-  const result: ContextFragment[] = []
+  if (estimateContextTokens(joinSections(available)) <= tokenBudget) {
+    return { fitted: available, dropped: [] }
+  }
+  const fitted: ContextFragment[] = []
+  const dropped: ContextFragment[] = []
   let remaining = tokenBudget
   for (let index = 0; index < available.length; index += 1) {
     const share = Math.floor(remaining / (available.length - index))
-    const fitted = fitFragment(available[index]!, share)
-    if (fitted) {
-      result.push(fitted)
-      remaining -= estimateContextTokens(section(fitted.title, fitted.text))
+    const original = available[index]!
+    const next = fitFragment(original, share)
+    if (next) {
+      fitted.push(next)
+      remaining -= fragmentTokens(next)
+    } else {
+      dropped.push(original)
     }
   }
-  return result
+  return { fitted, dropped }
+}
+
+function dispositionFor(
+  candidate: ContextFragment,
+  injected: ContextFragment | undefined,
+  dropped: boolean,
+  policyExcluded = false,
+): ContextFragmentDisposition {
+  if (policyExcluded) return "policy_excluded"
+  if (dropped || !injected) return "budget_dropped"
+  if (injected.text !== candidate.text) return "truncated"
+  return "kept"
+}
+
+function buildFragmentTraces(input: {
+  candidates: ContextFragment[]
+  injectedByTitle: Map<string, ContextFragment>
+  droppedTitles: Set<string>
+  policyExcludedTitles: Set<string>
+}): ContextFragmentTrace[] {
+  return input.candidates
+    .filter((fragment) => fragment.text.trim())
+    .map((candidate) => {
+      const injected = input.injectedByTitle.get(candidate.title)
+      const disposition = dispositionFor(
+        candidate,
+        injected,
+        input.droppedTitles.has(candidate.title),
+        input.policyExcludedTitles.has(candidate.title),
+      )
+      return {
+        title: candidate.title,
+        layer: candidate.layer,
+        disposition,
+        candidateEstimatedTokens: fragmentTokens(candidate),
+        injectedEstimatedTokens: injected ? fragmentTokens(injected) : 0,
+      }
+    })
 }
 
 export function composeContext(input: ComposeContextInput): ComposedContext {
@@ -168,24 +238,83 @@ export function composeContext(input: ComposeContextInput): ComposedContext {
   })
   const stableBudget = Math.floor(tokenBudget * 0.4)
   const summaryBudget = Math.floor(tokenBudget * 0.15)
-  const stableCore = joinSections(fitFragmentsProportionally(stableFragments(input.contextPack), stableBudget))
-  const sessionSummary = fitPlainText(input.sessionSummary?.trim() ?? "", summaryBudget)
+
+  const stableCandidates = stableFragments(input.contextPack).filter((f) => f.text.trim())
+  const { fitted: fittedStable, dropped: droppedStable } = fitFragmentsProportionally(
+    stableCandidates,
+    stableBudget,
+  )
+  const stableCore = joinSections(fittedStable)
+
+  const summaryCandidateText = input.sessionSummary?.trim() ?? ""
+  const sessionSummary = fitPlainText(summaryCandidateText, summaryBudget)
   const stableTokens = estimateContextTokens(stableCore)
   const summaryTokens = estimateContextTokens(sessionSummary)
   const availableDynamicTokens = Math.max(0, tokenBudget - stableTokens - summaryTokens)
-  const dynamicFragmentsForRequest = dynamicFragments(input, expanded)
-  const requiredFragments = dynamicFragmentsForRequest.filter((fragment) => fragment.required)
-  const optionalFragments = dynamicFragmentsForRequest.filter((fragment) => !fragment.required)
-  const fittedRequired = fitFragmentsProportionally(requiredFragments, availableDynamicTokens)
+
+  const allDynamic = dynamicFragments(input, expanded)
+  const requiredFragments = allDynamic.filter((fragment) => fragment.required)
+  const optionalFragments = allDynamic.filter((fragment) => !fragment.required)
+  // When not expanded, chapter originals are candidate-only (policy excluded from injection).
+  const policyExcludedTitles = new Set<string>()
+  const injectableOptional = optionalFragments.filter((fragment) => {
+    if (fragment.title === "低置信度扩展章节原文" && !expanded) {
+      policyExcludedTitles.add(fragment.title)
+      return false
+    }
+    return true
+  })
+
+  const { fitted: fittedRequired, dropped: droppedRequired } = fitFragmentsProportionally(
+    requiredFragments,
+    availableDynamicTokens,
+  )
   const requiredTokens = estimateContextTokens(joinSections(fittedRequired))
-  const dynamicContext = joinSections([
-    ...fittedRequired,
-    ...applyBudget(optionalFragments, Math.max(0, availableDynamicTokens - requiredTokens)),
-  ])
+  const { selected: selectedOptional, dropped: droppedOptional } = applyBudget(
+    injectableOptional,
+    Math.max(0, availableDynamicTokens - requiredTokens),
+  )
+  const dynamicContext = joinSections([...fittedRequired, ...selectedOptional])
   const dynamicTokens = estimateContextTokens(dynamicContext)
-  const candidateTokens = estimateContextTokens(contextPackToPrompt(input.contextPack))
-    + summaryTokens
-    + estimateContextTokens(joinSections(referenceFragments(input)))
+
+  // Same fragment pipeline for candidate totals (unbudgeted join of all non-empty fragments).
+  const summaryCandidateFragment: ContextFragment | null = summaryCandidateText
+    ? { title: "会话摘要", text: summaryCandidateText, layer: "summary" }
+    : null
+  const candidateFragments: ContextFragment[] = [
+    ...stableCandidates,
+    ...(summaryCandidateFragment ? [summaryCandidateFragment] : []),
+    ...requiredFragments.filter((f) => f.text.trim()),
+    ...optionalFragments.filter((f) => f.text.trim()),
+  ]
+  const candidateTokens = candidateFragments.reduce((sum, fragment) => sum + fragmentTokens(fragment), 0)
+
+  const injectedByTitle = new Map<string, ContextFragment>()
+  for (const fragment of fittedStable) injectedByTitle.set(fragment.title, fragment)
+  if (sessionSummary) {
+    injectedByTitle.set("会话摘要", {
+      title: "会话摘要",
+      text: sessionSummary,
+      layer: "summary",
+    })
+  }
+  for (const fragment of fittedRequired) injectedByTitle.set(fragment.title, fragment)
+  for (const fragment of selectedOptional) injectedByTitle.set(fragment.title, fragment)
+
+  const droppedTitles = new Set([
+    ...droppedStable.map((f) => f.title),
+    ...droppedRequired.map((f) => f.title),
+    ...droppedOptional.map((f) => f.title),
+  ])
+  if (summaryCandidateText && !sessionSummary) droppedTitles.add("会话摘要")
+
+  const fragmentTraces = buildFragmentTraces({
+    candidates: candidateFragments,
+    injectedByTitle,
+    droppedTitles,
+    policyExcludedTitles,
+  })
+
   const composedTokens = stableTokens + summaryTokens + dynamicTokens
   const estimatedSavedTokens = Math.max(0, candidateTokens - composedTokens)
   const estimatedSavedPercent = candidateTokens > 0
@@ -201,9 +330,12 @@ export function composeContext(input: ComposeContextInput): ComposedContext {
       kinds: [...input.dependencyStamp.kinds],
     },
     stats: {
-      hits: 0,
-      refreshed: 0,
-      failures: 0,
+      cacheHits: 0,
+      reloaded: 0,
+      empty: 0,
+      fallbackUsed: 0,
+      readFailed: 0,
+      writeFailed: 0,
       stableTokens,
       summaryTokens,
       dynamicTokens,
@@ -215,6 +347,7 @@ export function composeContext(input: ComposeContextInput): ComposedContext {
       budgetTokens: tokenBudget,
       composedTokens,
       utilizationPercent: tokenBudget > 0 ? Math.min(100, Math.round((composedTokens / tokenBudget) * 100)) : 0,
+      fragmentTraces,
     },
   }
 }

+ 24 - 4
src/lib/context-hub/context-hub.spec.ts

@@ -28,6 +28,7 @@ function pack(): ContextPack {
     characterStates: "",
     soulDoc: "现实主义悬疑",
     characterAuras: "",
+    storyFrameworkBinding: "",
     cognitionStates: "",
     foreshadowingStates: "",
     timeline: "",
@@ -143,17 +144,21 @@ describe("ContextHubController", () => {
     const first = await harness.controller.prepare(request)
     const second = await harness.controller.prepare({ ...request, task: "继续生成大纲" })
 
+    expect(first?.stats.stablePrefixStatus).toBe("updated")
+    expect(second?.stats.stablePrefixStatus).toBe("unchanged")
+    // Stable prefix must not inflate data-source hit counters.
+    expect(second?.stats.cacheHits).toBe(0)
     expect(first?.cacheItems).toEqual([
       expect.objectContaining({
         sourceName: "stableCore",
-        status: "refreshed",
+        status: "reloaded",
         dependencyPaths: ["wiki/outlines/main.md"],
       }),
     ])
     expect(second?.cacheItems).toEqual([
       expect.objectContaining({
         sourceName: "stableCore",
-        status: "hit",
+        status: "cache_hit",
         dependencyPaths: ["wiki/outlines/main.md"],
       }),
     ])
@@ -170,9 +175,10 @@ describe("ContextHubController", () => {
     chapterRevision = 2
     const second = await harness.controller.prepare({ ...request, task: "继续生成大纲" })
 
+    expect(second?.stats.stablePrefixStatus).toBe("unchanged")
     expect(second?.cacheItems).toContainEqual(expect.objectContaining({
       sourceName: "stableCore",
-      status: "hit",
+      status: "cache_hit",
       dependencyPaths: ["wiki/outlines/main.md"],
     }))
   })
@@ -186,12 +192,26 @@ describe("ContextHubController", () => {
     await harness.controller.prepare(request)
     const second = await harness.controller.prepare({ ...request, task: "继续生成大纲" })
 
+    expect(second?.stats.stablePrefixStatus).toBe("unchanged")
     expect(second?.cacheItems).toContainEqual(expect.objectContaining({
       sourceName: "stableCore",
-      status: "hit",
+      status: "cache_hit",
     }))
   })
 
+  it("persists warnings on the snapshot body", async () => {
+    const harness = createHarness()
+    const result = await harness.controller.prepare({
+      ...request,
+      existingSummary: { text: "旧摘要", dependencyFingerprint: "stale", updatedAt: 1 },
+    })
+    expect(result?.warnings.length).toBeGreaterThan(0)
+    const reference = await harness.controller.saveSnapshot("assistant:warn", result!)
+    await expect(harness.controller.readSnapshot(reference)).resolves.toMatchObject({
+      warnings: result?.warnings,
+    })
+  })
+
   it("removes a Windows project root from dependency paths case-insensitively", async () => {
     const harness = createHarness()
     harness.registry.getDependencyPreview.mockReturnValue(["e:/Novel/wiki/outlines/main.md"])

+ 29 - 34
src/lib/context-hub/context-hub.ts

@@ -27,6 +27,7 @@ import {
   type ContextSourceKind,
   type DependencyStamp,
   type StableBundle,
+  type StablePrefixStatus,
 } from "./types"
 
 interface HubRegistry {
@@ -56,7 +57,7 @@ type BuildContextPack = (
   options?: { categories?: DataSourceCategory[]; loadAdapter?: DataSourceLoadAdapter },
 ) => Promise<ContextPack>
 
-const STABLE_SOURCE_KINDS: ContextSourceKind[] = ["soul", "setting", "entity", "outline"]
+const STABLE_SOURCE_KINDS: ContextSourceKind[] = ["soul", "setting", "entity", "outline", "deduction"]
 
 export interface ContextHubControllerDependencies {
   registry?: HubRegistry
@@ -111,6 +112,12 @@ function withRelativeDependencyPaths(
   }))
 }
 
+function stableItemStatus(status: StablePrefixStatus): ContextCacheItemTrace["status"] {
+  if (status === "unchanged") return "cache_hit"
+  if (status === "updated") return "reloaded"
+  return "write_failed"
+}
+
 export class ContextHubController implements ContextHub {
   private readonly projectPath: string
   private readonly registry: HubRegistry
@@ -176,6 +183,7 @@ export class ContextHubController implements ContextHub {
       stableCore: result.stableCore,
       sessionSummary: result.sessionSummary,
       dynamicContext: result.dynamicContext,
+      warnings: [...result.warnings],
     }
     try {
       await this.storage.writeSnapshot(snapshot)
@@ -261,9 +269,7 @@ export class ContextHubController implements ContextHub {
     })
     const cacheStats = cacheAdapter.getStats()
     const cacheItems = cacheAdapter.getTraceItems()
-    let stableHits = 0
-    let stableRefreshes = 0
-    let stableFailures = 0
+    let stablePrefixStatus: StablePrefixStatus = "updated"
     try {
       const existing = await this.storage.readStableBundle(request.surface)
       if (
@@ -271,15 +277,7 @@ export class ContextHubController implements ContextHub {
         && existing.dependencyStamp.fingerprint === stableDependencyStamp.fingerprint
         && existing.text === composed.stableCore
       ) {
-        stableHits = 1
-        cacheItems.push({
-          key: `stable-core:${request.surface}`,
-          sourceName: "stableCore",
-          status: "hit",
-          dependencyStamp: stableDependencyStamp,
-          dependencyPaths: stableDependencyPaths,
-          dependencyPathsTruncated: stableDependencyStamp.sourceCount > stableDependencyPaths.length,
-        })
+        stablePrefixStatus = "unchanged"
       } else {
         await this.storage.writeStableBundle(request.surface, {
           schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
@@ -288,38 +286,35 @@ export class ContextHubController implements ContextHub {
           dependencyStamp: stableDependencyStamp,
           updatedAt: Date.now(),
         })
-        stableRefreshes = 1
-        cacheItems.push({
-          key: `stable-core:${request.surface}`,
-          sourceName: "stableCore",
-          status: "refreshed",
-          dependencyStamp: stableDependencyStamp,
-          dependencyPaths: stableDependencyPaths,
-          dependencyPathsTruncated: stableDependencyStamp.sourceCount > stableDependencyPaths.length,
-        })
+        stablePrefixStatus = "updated"
       }
     } catch {
-      stableFailures = 1
-      cacheItems.push({
-        key: `stable-core:${request.surface}`,
-        sourceName: "stableCore",
-        status: "failed",
-        dependencyStamp: stableDependencyStamp,
-        dependencyPaths: stableDependencyPaths,
-        dependencyPathsTruncated: stableDependencyStamp.sourceCount > stableDependencyPaths.length,
-      })
+      stablePrefixStatus = "persist_failed"
       warnings.push("稳定上下文缓存写入失败,本轮已继续使用内存中的最新内容。")
     }
 
+    cacheItems.push({
+      key: `stable-core:${request.surface}`,
+      sourceName: "stableCore",
+      status: stableItemStatus(stablePrefixStatus),
+      dependencyStamp: stableDependencyStamp,
+      dependencyPaths: stableDependencyPaths,
+      dependencyPathsTruncated: stableDependencyStamp.sourceCount > stableDependencyPaths.length,
+    })
+
     return {
       ...composed,
       surface: request.surface,
       contextPack,
       stats: {
         ...composed.stats,
-        hits: cacheStats.hits + stableHits,
-        refreshed: cacheStats.refreshed + stableRefreshes,
-        failures: cacheStats.failures + stableFailures,
+        cacheHits: cacheStats.cacheHits,
+        reloaded: cacheStats.reloaded,
+        empty: cacheStats.empty,
+        fallbackUsed: cacheStats.fallbackUsed,
+        readFailed: cacheStats.readFailed,
+        writeFailed: cacheStats.writeFailed,
+        stablePrefixStatus,
       },
       cacheItems: withRelativeDependencyPaths(this.projectPath, cacheItems),
       warnings,

+ 53 - 32
src/lib/context-hub/data-source-cache.spec.ts

@@ -59,21 +59,67 @@ describe("DataSourceCacheAdapter", () => {
     await expect(harness.adapter.load(source, context, directLoad)).resolves.toBe("大纲")
 
     expect(directLoad).toHaveBeenCalledOnce()
-    expect(harness.adapter.getStats()).toMatchObject({ hits: 1, refreshed: 1, failures: 0 })
+    expect(harness.adapter.getStats()).toMatchObject({
+      cacheHits: 1,
+      reloaded: 1,
+      empty: 0,
+      writeFailed: 0,
+      readFailed: 0,
+    })
+    // One primary status per key: second load replaces reloaded with cache_hit.
     expect(harness.adapter.getTraceItems()).toEqual([
       expect.objectContaining({
         sourceName: "outline",
-        status: "refreshed",
-        dependencyPaths: ["E:/Novel/wiki/outlines/main.md"],
-      }),
-      expect.objectContaining({
-        sourceName: "outline",
-        status: "hit",
+        status: "cache_hit",
         dependencyPaths: ["E:/Novel/wiki/outlines/main.md"],
       }),
     ])
   })
 
+  it("marks empty values as empty and does not count them as reloaded", async () => {
+    const harness = createHarness()
+    const source: DataSource<string> = { name: "outline", priority: 1, load: async () => "" }
+    const directLoad = vi.fn(async () => "")
+
+    await harness.adapter.load(source, context, directLoad)
+    await harness.adapter.load(source, context, directLoad)
+
+    expect(directLoad).toHaveBeenCalledTimes(2)
+    expect(harness.storage.writeArtifact).not.toHaveBeenCalled()
+    expect(harness.adapter.getStats()).toMatchObject({
+      empty: 2,
+      reloaded: 0,
+      cacheHits: 0,
+    })
+    expect(harness.adapter.getTraceItems().map((item) => item.status)).toEqual(["empty"])
+  })
+
+  it("records write_failed as the primary status without a second list item", async () => {
+    const harness = createHarness()
+    harness.storage.writeArtifact.mockRejectedValue(new Error("磁盘已满"))
+    const source: DataSource<string> = { name: "outline", priority: 1, load: async () => "" }
+
+    await expect(harness.adapter.load(source, context, async () => "新大纲")).resolves.toBe("新大纲")
+    expect(harness.adapter.getStats()).toMatchObject({
+      reloaded: 1,
+      writeFailed: 1,
+    })
+    expect(harness.adapter.getTraceItems().map((item) => item.status)).toEqual(["write_failed"])
+  })
+
+  it("records read_failed then fallback_used as a single primary outcome", () => {
+    const harness = createHarness()
+    harness.adapter.recordReadFailed("outline")
+    harness.adapter.recordFallbackUsed("outline")
+    expect(harness.adapter.getStats()).toMatchObject({
+      readFailed: 1,
+      fallbackUsed: 1,
+    })
+    expect(harness.adapter.getTraceItems()).toEqual([
+      expect.objectContaining({ sourceName: "outline", status: "fallback_used" }),
+    ])
+  })
+
   it("refreshes only an artifact whose dependencies changed", async () => {
     const harness = createHarness()
     const chapterSource: DataSource<string> = { name: "recentChapterContents", priority: 1, load: async () => "" }
@@ -104,31 +150,6 @@ describe("DataSourceCacheAdapter", () => {
     expect(directLoad).toHaveBeenCalledOnce()
   })
 
-  it("does not register empty values as cache hits", async () => {
-    const harness = createHarness()
-    const source: DataSource<string> = { name: "outline", priority: 1, load: async () => "" }
-    const directLoad = vi.fn(async () => "")
-
-    await harness.adapter.load(source, context, directLoad)
-    await harness.adapter.load(source, context, directLoad)
-
-    expect(directLoad).toHaveBeenCalledTimes(2)
-    expect(harness.storage.writeArtifact).not.toHaveBeenCalled()
-  })
-
-  it("returns fresh data when cache writes fail", async () => {
-    const harness = createHarness()
-    harness.storage.writeArtifact.mockRejectedValue(new Error("磁盘已满"))
-    const source: DataSource<string> = { name: "outline", priority: 1, load: async () => "" }
-
-    await expect(harness.adapter.load(source, context, async () => "新大纲")).resolves.toBe("新大纲")
-    expect(harness.adapter.getStats().failures).toBe(1)
-    expect(harness.adapter.getTraceItems().map((item) => item.status)).toEqual([
-      "refreshed",
-      "failed",
-    ])
-  })
-
   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 () => "" }

+ 74 - 16
src/lib/context-hub/data-source-cache.ts

@@ -11,6 +11,7 @@ import {
   type ContextCacheScope,
   type ContextCacheItemTrace,
   type ContextSourceKind,
+  type ContextSourceTraceStatus,
   type DependencyStamp,
 } from "./types"
 
@@ -32,16 +33,18 @@ export interface DataSourceCacheAdapterOptions {
 }
 
 export interface DataSourceCacheStats {
-  hits: number
-  refreshed: number
-  failures: number
+  cacheHits: number
+  reloaded: number
+  empty: number
+  fallbackUsed: number
+  readFailed: number
+  writeFailed: number
 }
 
 const STATIC_SOURCES = new Set([
   "canonRules",
   "writingStyle",
   "soulDoc",
-  "characterAuras",
   "storyFrameworkBinding",
   "relatedSettings",
 ])
@@ -92,7 +95,7 @@ function cacheScopeFor(sourceName: string): ContextCacheScope {
   return "task"
 }
 
-function hasCacheableValue(value: unknown): boolean {
+export function hasCacheableValue(value: unknown): boolean {
   if (typeof value === "string") return value.trim().length > 0
   if (Array.isArray(value)) return value.length > 0
   if (value && typeof value === "object") return Object.keys(value).length > 0
@@ -101,7 +104,14 @@ function hasCacheableValue(value: unknown): boolean {
 
 export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
   private readonly pending = new Map<string, Promise<unknown>>()
-  private readonly stats: DataSourceCacheStats = { hits: 0, refreshed: 0, failures: 0 }
+  private readonly stats: DataSourceCacheStats = {
+    cacheHits: 0,
+    reloaded: 0,
+    empty: 0,
+    fallbackUsed: 0,
+    readFailed: 0,
+    writeFailed: 0,
+  }
   private readonly traceItems: ContextCacheItemTrace[] = []
 
   constructor(private readonly options: DataSourceCacheAdapterOptions) {}
@@ -131,6 +141,21 @@ export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
     return operation
   }
 
+  /**
+   * Registry calls this when source.load throws. If fallback later succeeds,
+   * recordFallbackUsed replaces the primary list status with fallback_used while
+   * keeping both counters.
+   */
+  recordReadFailed(sourceName: string, dependencyStamp?: DependencyStamp): void {
+    this.upsertSyntheticTrace(sourceName, "read_failed", dependencyStamp)
+    this.stats.readFailed += 1
+  }
+
+  recordFallbackUsed(sourceName: string, dependencyStamp?: DependencyStamp): void {
+    this.upsertSyntheticTrace(sourceName, "fallback_used", dependencyStamp)
+    this.stats.fallbackUsed += 1
+  }
+
   getStats(): DataSourceCacheStats {
     return { ...this.stats }
   }
@@ -143,6 +168,33 @@ export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
     }))
   }
 
+  private upsertTrace(item: ContextCacheItemTrace): void {
+    const existingIndex = this.traceItems.findIndex((entry) => entry.key === item.key)
+    if (existingIndex >= 0) {
+      this.traceItems[existingIndex] = item
+      return
+    }
+    this.traceItems.push(item)
+  }
+
+  private upsertSyntheticTrace(
+    sourceName: string,
+    status: ContextSourceTraceStatus,
+    dependencyStamp?: DependencyStamp,
+  ): void {
+    const kinds = getDataSourceKinds(sourceName)
+    const stamp = dependencyStamp ?? { fingerprint: "", sourceCount: 0, kinds }
+    const paths = this.options.registry.getDependencyPreview(kinds, 20)
+    this.upsertTrace({
+      key: `data-source:${sourceName}:outcome`,
+      sourceName,
+      status,
+      dependencyStamp: stamp,
+      dependencyPaths: paths,
+      dependencyPathsTruncated: stamp.sourceCount > paths.length,
+    })
+  }
+
   private async loadInternal<T>(
     key: string,
     sourceName: string,
@@ -150,7 +202,7 @@ export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
     dependencyPaths: string[],
     directLoad: () => Promise<T>,
   ): Promise<T> {
-    const trace = (status: ContextCacheItemTrace["status"]): ContextCacheItemTrace => ({
+    const makeTrace = (status: ContextSourceTraceStatus): ContextCacheItemTrace => ({
       key,
       sourceName,
       status,
@@ -158,24 +210,28 @@ export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
       dependencyPaths,
       dependencyPathsTruncated: dependencyStamp.sourceCount > dependencyPaths.length,
     })
+
     if (!this.options.forceRefresh) {
       try {
         const cached = await this.options.storage.readArtifact<T>(key)
         if (cached && dependencyStampsMatch(cached.dependencyStamp, dependencyStamp)) {
-          this.stats.hits += 1
-          this.traceItems.push(trace("hit"))
+          this.stats.cacheHits += 1
+          this.upsertTrace(makeTrace("cache_hit"))
           return cached.value
         }
       } catch {
-        this.stats.failures += 1
-        this.traceItems.push(trace("failed"))
+        // Corrupted/missing cache artifact: rebuild from source without counting as source failure.
       }
     }
 
     const value = await directLoad()
-    this.stats.refreshed += 1
-    this.traceItems.push(trace("refreshed"))
-    if (!hasCacheableValue(value)) return value
+    if (!hasCacheableValue(value)) {
+      this.stats.empty += 1
+      this.upsertTrace(makeTrace("empty"))
+      return value
+    }
+
+    this.stats.reloaded += 1
 
     try {
       await this.options.storage.writeArtifact(key, {
@@ -187,9 +243,11 @@ export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
         dependencyStamp,
         createdAt: Date.now(),
       })
+      this.upsertTrace(makeTrace("reloaded"))
     } catch {
-      this.stats.failures += 1
-      this.traceItems.push(trace("failed"))
+      this.stats.writeFailed += 1
+      // One primary list item: write_failed (reload still counted in reloaded).
+      this.upsertTrace(makeTrace("write_failed"))
     }
     return value
   }

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

@@ -12,7 +12,18 @@ export {
   selectContextHistoryMessages,
 } from "./session-summary"
 export { buildContextHubSystemContent, flattenContextHubSystemContent } from "./prompt-content"
-export { applyProviderUsageToStats, persistContextHubProviderUsage } from "./provider-usage"
+export {
+  applyProviderUsageToStats,
+  buildLlmRequestDiagnostics,
+  mergeLlmRequestDiagnostics,
+  persistContextHubProviderUsage,
+} from "./provider-usage"
+export {
+  emptyContextHubStats,
+  isCurrentContextHubStats,
+  parseContextHubSnapshot,
+  parseContextHubSnapshotRef,
+} from "./types"
 export type {
   ContextHub,
   ContextHubRequest,

+ 115 - 0
src/lib/context-hub/normalize-stats.spec.ts

@@ -0,0 +1,115 @@
+import { describe, expect, it } from "vitest"
+import {
+  CONTEXT_CACHE_SCHEMA_VERSION,
+  isCurrentContextHubStats,
+  parseContextHubSnapshot,
+  parseContextHubSnapshotRef,
+} from "./types"
+
+const currentStats = {
+  cacheHits: 1,
+  reloaded: 2,
+  empty: 0,
+  fallbackUsed: 0,
+  readFailed: 0,
+  writeFailed: 0,
+  stableTokens: 100,
+  summaryTokens: 20,
+  dynamicTokens: 40,
+  candidateTokens: 200,
+  estimatedSavedTokens: 40,
+  estimatedSavedPercent: 20,
+  expanded: false,
+  providerCacheEnabled: true,
+}
+
+describe("isCurrentContextHubStats", () => {
+  it("accepts the current counter shape", () => {
+    expect(isCurrentContextHubStats(currentStats)).toBe(true)
+  })
+
+  it("rejects main-era hits/refreshed/failures payloads", () => {
+    expect(isCurrentContextHubStats({
+      hits: 3,
+      refreshed: 2,
+      failures: 1,
+      stableTokens: 100,
+      summaryTokens: 20,
+      dynamicTokens: 40,
+      candidateTokens: 200,
+      estimatedSavedTokens: 40,
+      estimatedSavedPercent: 20,
+      expanded: false,
+      providerCacheEnabled: true,
+    })).toBe(false)
+  })
+})
+
+describe("parseContextHubSnapshot", () => {
+  it("returns null for legacy snapshots instead of remapping fields", () => {
+    expect(parseContextHubSnapshot({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      id: "assistant:1",
+      surface: "ai-chat",
+      createdAt: 10,
+      stats: {
+        hits: 1,
+        refreshed: 0,
+        failures: 0,
+        stableTokens: 1,
+        summaryTokens: 0,
+        dynamicTokens: 0,
+        candidateTokens: 1,
+        estimatedSavedTokens: 0,
+        estimatedSavedPercent: 0,
+        expanded: false,
+        providerCacheEnabled: false,
+      },
+      items: [{
+        key: "data-source:outline",
+        sourceName: "outline",
+        status: "hit",
+        dependencyStamp: { fingerprint: "x", sourceCount: 1, kinds: ["outline"] },
+        dependencyPaths: ["wiki/outlines/main.md"],
+        dependencyPathsTruncated: false,
+      }],
+      stableCore: "stable",
+      sessionSummary: "",
+      dynamicContext: "dynamic",
+    })).toBeNull()
+  })
+
+  it("parses a current snapshot unchanged", () => {
+    const snapshot = parseContextHubSnapshot({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      id: "assistant:1",
+      surface: "ai-chat",
+      createdAt: 10,
+      stats: currentStats,
+      items: [{
+        key: "data-source:outline",
+        sourceName: "outline",
+        status: "cache_hit",
+        dependencyStamp: { fingerprint: "x", sourceCount: 1, kinds: ["outline"] },
+        dependencyPaths: ["wiki/outlines/main.md"],
+        dependencyPathsTruncated: false,
+      }],
+      stableCore: "stable",
+      sessionSummary: "",
+      dynamicContext: "dynamic",
+    })
+    expect(snapshot?.stats.cacheHits).toBe(1)
+    expect(snapshot?.items[0]?.status).toBe("cache_hit")
+  })
+})
+
+describe("parseContextHubSnapshotRef", () => {
+  it("drops legacy refs", () => {
+    expect(parseContextHubSnapshotRef({
+      id: "assistant:1",
+      surface: "ai-chat",
+      createdAt: 10,
+      stats: { hits: 1, refreshed: 0, failures: 0 },
+    })).toBeNull()
+  })
+})

+ 59 - 15
src/lib/context-hub/provider-usage.spec.ts

@@ -1,12 +1,20 @@
 import { describe, expect, it, vi } from "vitest"
 import type { ContextHubResult, ContextHubSnapshotRef, ContextHubStats } from "./types"
-import { applyProviderUsageToStats, persistContextHubProviderUsage } from "./provider-usage"
+import {
+  applyProviderUsageToStats,
+  buildLlmRequestDiagnostics,
+  persistContextHubProviderUsage,
+} from "./provider-usage"
 import type { UserMemoryDecision } from "@/lib/user-memory/decision-trace"
+import { setLatestUserMemoryDecision } from "@/lib/user-memory/decision-trace"
 
 const baseStats: ContextHubStats = {
-  hits: 2,
-  refreshed: 1,
-  failures: 0,
+  cacheHits: 2,
+  reloaded: 1,
+  empty: 0,
+  fallbackUsed: 0,
+  readFailed: 0,
+  writeFailed: 0,
   stableTokens: 1000,
   summaryTokens: 100,
   dynamicTokens: 300,
@@ -19,17 +27,26 @@ const baseStats: ContextHubStats = {
 
 describe("context hub provider usage", () => {
   it("stores confirmed cache usage without changing local cache counters", () => {
-    expect(applyProviderUsageToStats(baseStats, {
+    const next = applyProviderUsageToStats(baseStats, {
       inputTokens: 1600,
       outputTokens: 200,
       cachedInputTokens: 800,
       cacheWriteInputTokens: 300,
-    })).toEqual({
-      ...baseStats,
+    })
+    expect(next).toMatchObject({
+      cacheHits: 2, reloaded: 1, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
       providerUsageReported: true,
       providerInputTokens: 1600,
       providerCachedTokens: 800,
       providerCacheWriteTokens: 300,
+      requestDiagnostics: {
+        requestCount: 1,
+        providerUsageAvailable: true,
+        inputTokens: 1600,
+        outputTokens: 200,
+        cacheReadTokens: 800,
+        cacheWriteTokens: 300,
+      },
     })
   })
 
@@ -55,7 +72,18 @@ describe("context hub provider usage", () => {
     })
   })
 
-  it("updates the persisted snapshot after the model response", async () => {
+  it("persist 必须使用显式 memoryDecision,不读取全局 latest", async () => {
+    setLatestUserMemoryDecision({
+      createdAt: 1,
+      surface: "ai-chat",
+      projectKey: "other",
+      sessionKey: "other",
+      candidateCount: 99,
+      selectedRuleIds: ["leak"],
+      filtered: [],
+      injectedChars: 1,
+      estimatedTokens: 1,
+    })
     const reference: ContextHubSnapshotRef = {
       id: "assistant:1",
       surface: "ai-chat",
@@ -64,19 +92,35 @@ describe("context hub provider usage", () => {
     }
     const saveSnapshot = vi.fn(async () => reference)
     const result = { stats: { ...baseStats } } as ContextHubResult
+    const decision: UserMemoryDecision = {
+      createdAt: 2,
+      surface: "ai-chat",
+      projectKey: "p1",
+      sessionKey: "s1",
+      candidateCount: 3,
+      selectedRuleIds: ["r1"],
+      filtered: [],
+      injectedChars: 10,
+      estimatedTokens: 3,
+    }
 
-    await expect(persistContextHubProviderUsage(
+    await persistContextHubProviderUsage(
       { saveSnapshot },
       "assistant:1",
       result,
       { inputTokens: 1600, cachedInputTokens: 800 },
-    )).resolves.toBe(reference)
+      {
+        memoryDecision: decision,
+        requestDiagnostics: buildLlmRequestDiagnostics(
+          { inputTokens: 1600, cachedInputTokens: 800 },
+          2,
+        ),
+      },
+    )
 
-    expect(result.stats).toMatchObject({
-      providerUsageReported: true,
-      providerInputTokens: 1600,
-      providerCachedTokens: 800,
-    })
+    expect(result.stats.memoryCandidateCount).toBe(3)
+    expect(result.stats.memorySelectedCount).toBe(1)
+    expect(result.stats.requestDiagnostics?.requestCount).toBe(2)
     expect(saveSnapshot).toHaveBeenCalledWith("assistant:1", result)
   })
 })

+ 95 - 4
src/lib/context-hub/provider-usage.ts

@@ -1,25 +1,94 @@
 import type { LlmUsage } from "@/lib/llm-usage"
-import { getLatestUserMemoryDecision, type UserMemoryDecision } from "@/lib/user-memory/decision-trace"
+import type { UserMemoryDecision } from "@/lib/user-memory/decision-trace"
 import type {
   ContextHub,
   ContextHubResult,
   ContextHubSnapshotRef,
   ContextHubStats,
+  LlmRequestDiagnostics,
 } from "./types"
 
+export interface PersistContextHubProviderUsageOptions {
+  memoryDecision?: UserMemoryDecision | null
+  requestDiagnostics?: LlmRequestDiagnostics | null
+}
+
+export function buildLlmRequestDiagnostics(
+  usage: LlmUsage | undefined,
+  requestCount = 1,
+): LlmRequestDiagnostics {
+  const hasAny = Boolean(
+    usage
+    && (
+      usage.inputTokens !== undefined
+      || usage.outputTokens !== undefined
+      || usage.cachedInputTokens !== undefined
+      || usage.cacheWriteInputTokens !== undefined
+    ),
+  )
+  if (!hasAny || !usage) {
+    return {
+      requestCount: Math.max(0, requestCount),
+      providerUsageAvailable: false,
+    }
+  }
+  return {
+    requestCount: Math.max(1, requestCount),
+    providerUsageAvailable: true,
+    inputTokens: usage.inputTokens,
+    outputTokens: usage.outputTokens,
+    cacheReadTokens: usage.cachedInputTokens,
+    cacheWriteTokens: usage.cacheWriteInputTokens,
+  }
+}
+
+export function mergeLlmRequestDiagnostics(
+  existing: LlmRequestDiagnostics | undefined,
+  usage: LlmUsage | undefined,
+): LlmRequestDiagnostics {
+  const base: LlmRequestDiagnostics = existing ?? {
+    requestCount: 0,
+    providerUsageAvailable: false,
+  }
+  if (!usage) return { ...base }
+  const hasAny = usage.inputTokens !== undefined
+    || usage.outputTokens !== undefined
+    || usage.cachedInputTokens !== undefined
+    || usage.cacheWriteInputTokens !== undefined
+  if (!hasAny) {
+    return {
+      ...base,
+      requestCount: base.requestCount + 1,
+      providerUsageAvailable: base.providerUsageAvailable,
+    }
+  }
+  return {
+    requestCount: base.requestCount + 1,
+    providerUsageAvailable: true,
+    inputTokens: (base.inputTokens ?? 0) + (usage.inputTokens ?? 0),
+    outputTokens: (base.outputTokens ?? 0) + (usage.outputTokens ?? 0),
+    cacheReadTokens: (base.cacheReadTokens ?? 0) + (usage.cachedInputTokens ?? 0),
+    cacheWriteTokens: (base.cacheWriteTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0),
+  }
+}
+
 export function applyProviderUsageToStats(
   stats: ContextHubStats,
   usage: LlmUsage,
   memoryDecision?: UserMemoryDecision | null,
+  requestDiagnostics?: LlmRequestDiagnostics | null,
 ): ContextHubStats {
+  const diagnostics = requestDiagnostics
+    ?? mergeLlmRequestDiagnostics(stats.requestDiagnostics, usage)
   return {
     ...stats,
-    providerUsageReported: true,
+    providerUsageReported: diagnostics.providerUsageAvailable,
     ...(usage.inputTokens !== undefined ? { providerInputTokens: usage.inputTokens } : {}),
     ...(usage.cachedInputTokens !== undefined ? { providerCachedTokens: usage.cachedInputTokens } : {}),
     ...(usage.cacheWriteInputTokens !== undefined
       ? { providerCacheWriteTokens: usage.cacheWriteInputTokens }
       : {}),
+    requestDiagnostics: diagnostics,
     ...(memoryDecision ? {
       memoryCandidateCount: memoryDecision.candidateCount,
       memorySelectedCount: memoryDecision.selectedRuleIds.length,
@@ -35,8 +104,30 @@ export async function persistContextHubProviderUsage(
   snapshotId: string,
   result: ContextHubResult,
   usage: LlmUsage | undefined,
+  options: PersistContextHubProviderUsageOptions = {},
 ): Promise<ContextHubSnapshotRef | null> {
-  if (!usage) return null
-  result.stats = applyProviderUsageToStats(result.stats, usage, getLatestUserMemoryDecision())
+  if (!usage && !options.requestDiagnostics) return null
+  const diagnostics = options.requestDiagnostics
+    ?? (usage ? mergeLlmRequestDiagnostics(result.stats.requestDiagnostics, usage) : undefined)
+  if (usage) {
+    result.stats = applyProviderUsageToStats(
+      result.stats,
+      usage,
+      options.memoryDecision,
+      diagnostics,
+    )
+  } else if (diagnostics || options.memoryDecision) {
+    result.stats = {
+      ...result.stats,
+      ...(diagnostics ? { requestDiagnostics: diagnostics } : {}),
+      ...(options.memoryDecision ? {
+        memoryCandidateCount: options.memoryDecision.candidateCount,
+        memorySelectedCount: options.memoryDecision.selectedRuleIds.length,
+        memoryFilteredCount: options.memoryDecision.filtered.length,
+        memoryInjectedChars: options.memoryDecision.injectedChars,
+        memoryEstimatedTokens: options.memoryDecision.estimatedTokens,
+      } : {}),
+    }
+  }
   return contextHub.saveSnapshot(snapshotId, result)
 }

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

@@ -20,7 +20,6 @@ const DATA_SOURCE_KINDS: Record<string, ContextSourceKind[]> = {
   revisionFeedback: ["chapter", "snapshot"],
   cognitionText: ["entity"],
   soulDoc: ["soul"],
-  characterAuras: ["entity"],
   sectionBriefing: ["outline", "snapshot"],
   storyFrameworkBinding: ["outline", "setting", "deduction"],
   retrieval: ["retrieval"],

+ 2 - 4
src/lib/context-hub/storage.spec.ts

@@ -70,9 +70,7 @@ function snapshot(id = "assistant:1"): ContextHubSnapshot {
     surface: "ai-chat",
     createdAt: 10,
     stats: {
-      hits: 1,
-      refreshed: 2,
-      failures: 0,
+      cacheHits: 1, reloaded: 2, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
       stableTokens: 100,
       summaryTokens: 20,
       dynamicTokens: 80,
@@ -85,7 +83,7 @@ function snapshot(id = "assistant:1"): ContextHubSnapshot {
     items: [{
       key: "data-source:outline",
       sourceName: "outline",
-      status: "hit",
+      status: "cache_hit",
       dependencyStamp,
       dependencyPaths: ["wiki/outlines/main.md"],
       dependencyPathsTruncated: false,

+ 2 - 1
src/lib/context-hub/storage.ts

@@ -11,6 +11,7 @@ import { normalizePath } from "@/lib/path-utils"
 import { sha256Text } from "./fingerprint"
 import {
   CONTEXT_CACHE_SCHEMA_VERSION,
+  parseContextHubSnapshot,
   type CachedArtifact,
   type ContextCacheArtifactEntry,
   type ContextCacheManifest,
@@ -288,7 +289,7 @@ export class ContextHubStorage {
         || typeof raw.sessionSummary !== "string"
         || typeof raw.dynamicContext !== "string"
       ) return null
-      return raw as unknown as ContextHubSnapshot
+      return parseContextHubSnapshot(raw)
     } catch {
       return null
     }

+ 178 - 4
src/lib/context-hub/types.ts

@@ -76,10 +76,41 @@ export interface SessionContextSummary {
   updatedAt: number
 }
 
+export type StablePrefixStatus = "unchanged" | "updated" | "persist_failed"
+
+export type ContextFragmentDisposition =
+  | "kept"
+  | "truncated"
+  | "budget_dropped"
+  | "policy_excluded"
+
+export interface ContextFragmentTrace {
+  title: string
+  layer: "stable" | "summary" | "dynamic"
+  disposition: ContextFragmentDisposition
+  candidateEstimatedTokens: number
+  injectedEstimatedTokens: number
+}
+
+export interface LlmRequestDiagnostics {
+  requestCount: number
+  providerUsageAvailable: boolean
+  inputTokens?: number
+  outputTokens?: number
+  cacheReadTokens?: number
+  cacheWriteTokens?: number
+}
+
+/** Generation-details stats: source traces + independent stablePrefixStatus; token fields are estimates. */
 export interface ContextHubStats {
-  hits: number
-  refreshed: number
-  failures: number
+  cacheHits: number
+  reloaded: number
+  empty: number
+  fallbackUsed: number
+  readFailed: number
+  writeFailed: number
+  stablePrefixStatus?: StablePrefixStatus
+  /** Estimated tokens (local heuristic). */
   stableTokens: number
   summaryTokens: number
   dynamicTokens: number
@@ -92,7 +123,9 @@ export interface ContextHubStats {
   providerInputTokens?: number
   providerCachedTokens?: number
   providerCacheWriteTokens?: number
+  /** Hub injection budget (estimated domain). */
   budgetTokens?: number
+  /** Hub injection total (estimated); not full-window occupancy. */
   composedTokens?: number
   utilizationPercent?: number
   memoryCandidateCount?: number
@@ -100,9 +133,19 @@ export interface ContextHubStats {
   memoryFilteredCount?: number
   memoryInjectedChars?: number
   memoryEstimatedTokens?: number
+  fragmentTraces?: ContextFragmentTrace[]
+  requestDiagnostics?: LlmRequestDiagnostics
 }
 
-export type ContextCacheItemStatus = "hit" | "refreshed" | "failed"
+export type ContextCacheItemStatus =
+  | "cache_hit"
+  | "reloaded"
+  | "empty"
+  | "fallback_used"
+  | "read_failed"
+  | "write_failed"
+
+export type ContextSourceTraceStatus = ContextCacheItemStatus
 
 export interface ContextCacheItemTrace {
   key: string
@@ -113,6 +156,8 @@ export interface ContextCacheItemTrace {
   dependencyPathsTruncated: boolean
 }
 
+export type ContextSourceTrace = ContextCacheItemTrace
+
 export interface ContextHubSnapshotRef {
   id: string
   surface: ContextSurface
@@ -126,6 +171,7 @@ export interface ContextHubSnapshot extends ContextHubSnapshotRef {
   stableCore: string
   sessionSummary: string
   dynamicContext: string
+  warnings?: string[]
 }
 
 export interface ContextHubRequest {
@@ -168,3 +214,131 @@ export interface ContextHub {
   markDirty(path: string): void
   dispose(): void
 }
+
+export function emptyContextHubStats(): ContextHubStats {
+  return {
+    cacheHits: 0,
+    reloaded: 0,
+    empty: 0,
+    fallbackUsed: 0,
+    readFailed: 0,
+    writeFailed: 0,
+    stableTokens: 0,
+    summaryTokens: 0,
+    dynamicTokens: 0,
+    candidateTokens: 0,
+    estimatedSavedTokens: 0,
+    estimatedSavedPercent: 0,
+    expanded: false,
+    providerCacheEnabled: false,
+  }
+}
+
+const CURRENT_ITEM_STATUSES = new Set<ContextCacheItemStatus>([
+  "cache_hit",
+  "reloaded",
+  "empty",
+  "fallback_used",
+  "read_failed",
+  "write_failed",
+])
+
+function isFiniteNumber(value: unknown): value is number {
+  return typeof value === "number" && Number.isFinite(value)
+}
+
+/** True only for the current stats shape. Legacy hits/refreshed/failures payloads are rejected. */
+export function isCurrentContextHubStats(raw: unknown): raw is ContextHubStats {
+  if (!raw || typeof raw !== "object") return false
+  const source = raw as Record<string, unknown>
+  return isFiniteNumber(source.cacheHits)
+    && isFiniteNumber(source.reloaded)
+    && isFiniteNumber(source.empty)
+    && isFiniteNumber(source.fallbackUsed)
+    && isFiniteNumber(source.readFailed)
+    && isFiniteNumber(source.writeFailed)
+    && isFiniteNumber(source.stableTokens)
+    && isFiniteNumber(source.summaryTokens)
+    && isFiniteNumber(source.dynamicTokens)
+    && isFiniteNumber(source.candidateTokens)
+    && isFiniteNumber(source.estimatedSavedTokens)
+    && isFiniteNumber(source.estimatedSavedPercent)
+    && typeof source.expanded === "boolean"
+    && typeof source.providerCacheEnabled === "boolean"
+}
+
+function isCurrentCacheItemStatus(status: unknown): status is ContextCacheItemStatus {
+  return typeof status === "string" && CURRENT_ITEM_STATUSES.has(status as ContextCacheItemStatus)
+}
+
+/** Disk/UI parse: reject legacy or corrupt snapshots instead of remapping fields. */
+export function parseContextHubSnapshot(raw: unknown): ContextHubSnapshot | null {
+  if (!raw || typeof raw !== "object") return null
+  const source = raw as Record<string, unknown>
+  if (
+    source.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
+    || typeof source.id !== "string"
+    || (source.surface !== "ai-chat" && source.surface !== "ai-outline")
+    || typeof source.createdAt !== "number"
+    || typeof source.stableCore !== "string"
+    || typeof source.sessionSummary !== "string"
+    || typeof source.dynamicContext !== "string"
+    || !Array.isArray(source.items)
+    || !isCurrentContextHubStats(source.stats)
+  ) return null
+  if (source.warnings !== undefined && !Array.isArray(source.warnings)) return null
+
+  const items: ContextCacheItemTrace[] = []
+  for (const item of source.items) {
+    if (!item || typeof item !== "object") return null
+    const entry = item as Record<string, unknown>
+    if (
+      typeof entry.key !== "string"
+      || typeof entry.sourceName !== "string"
+      || !isCurrentCacheItemStatus(entry.status)
+      || !entry.dependencyStamp
+      || typeof entry.dependencyStamp !== "object"
+      || !Array.isArray(entry.dependencyPaths)
+    ) return null
+    items.push({
+      key: entry.key,
+      sourceName: entry.sourceName,
+      status: entry.status,
+      dependencyStamp: entry.dependencyStamp as DependencyStamp,
+      dependencyPaths: entry.dependencyPaths.filter((path): path is string => typeof path === "string"),
+      dependencyPathsTruncated: Boolean(entry.dependencyPathsTruncated),
+    })
+  }
+
+  return {
+    schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+    id: source.id,
+    surface: source.surface,
+    createdAt: source.createdAt,
+    stats: source.stats,
+    items,
+    stableCore: source.stableCore,
+    sessionSummary: source.sessionSummary,
+    dynamicContext: source.dynamicContext,
+    ...(Array.isArray(source.warnings)
+      ? { warnings: source.warnings.filter((warning): warning is string => typeof warning === "string") }
+      : {}),
+  }
+}
+
+export function parseContextHubSnapshotRef(raw: unknown): ContextHubSnapshotRef | null {
+  if (!raw || typeof raw !== "object") return null
+  const source = raw as Record<string, unknown>
+  if (
+    typeof source.id !== "string"
+    || (source.surface !== "ai-chat" && source.surface !== "ai-outline")
+    || typeof source.createdAt !== "number"
+    || !isCurrentContextHubStats(source.stats)
+  ) return null
+  return {
+    id: source.id,
+    surface: source.surface,
+    createdAt: source.createdAt,
+    stats: source.stats,
+  }
+}

+ 8 - 1
src/lib/llm-client.ts

@@ -26,6 +26,7 @@ import {
 import { RESPONSE_RESERVE_FRAC, planLlmRequestBudget } from "./context-budget"
 import { mergeLlmUsageSnapshot, type LlmUsage } from "./llm-usage"
 import { applyGlobalUserMemoryToMessages } from "./user-memory/request-integration"
+import type { UserMemoryDecision } from "./user-memory/decision-trace"
 
 export type { ChatMessage, RequestOverrides } from "./llm-providers"
 export { isFetchNetworkError } from "./tauri-fetch"
@@ -37,6 +38,8 @@ export interface StreamCallbacks {
   /** 工具调用流式 delta,用于累积 tool_calls */
   onToolCallDelta?: (delta: { index: number; id?: string; name?: string; arguments?: string }) => void
   onUsage?: (usage: LlmUsage) => void
+  /** Decision produced while preparing this request's messages (request-scoped). */
+  onUserMemoryDecision?: (decision: UserMemoryDecision | null) => void
   onDone: () => void
   onError: (error: Error) => void
 }
@@ -200,7 +203,11 @@ export async function streamChat(
   requestOverrides?: RequestOverrides,
 ): Promise<void> {
   let runtimeConfig = await resolveRuntimeLocalCliConfig(config)
-  const preparedMessages = applyGlobalUserMemoryToMessages(messages, requestOverrides)
+  const { messages: preparedMessages, decision: userMemoryDecision } = applyGlobalUserMemoryToMessages(
+    messages,
+    requestOverrides,
+  )
+  callbacks.onUserMemoryDecision?.(userMemoryDecision)
   const configuredWindow = getEffectiveMaxContextSize(runtimeConfig)
   const toolScaffoldTokens = estimateRequestScaffoldTokens(requestOverrides?.tools)
   const outputCap = getEffectiveMaxOutputTokens(runtimeConfig)

+ 41 - 0
src/lib/novel/context-data-source.spec.ts

@@ -64,4 +64,45 @@ describe("DataSourceRegistry", () => {
     expect(loaded.fallbackRecentSummaries).toEqual([])
     expect(loaded.outline).toBe("")
   })
+
+  it("records read_failed without fallback_used when the source has no fallback", async () => {
+    const adapter = {
+      load: vi.fn(async () => {
+        throw new Error("磁盘损坏")
+      }),
+      recordReadFailed: vi.fn(),
+      recordFallbackUsed: vi.fn(),
+    }
+    const registry = new DataSourceRegistry({ loadAdapter: adapter })
+    registry.register({
+      name: "outline",
+      priority: 1,
+      load: async () => "不应调用",
+    })
+
+    await expect(registry.loadAll(context)).resolves.toMatchObject({ outline: "" })
+    expect(adapter.recordReadFailed).toHaveBeenCalledWith("outline")
+    expect(adapter.recordFallbackUsed).not.toHaveBeenCalled()
+  })
+
+  it("records fallback_used only after a real source.fallback succeeds", async () => {
+    const adapter = {
+      load: vi.fn(async () => {
+        throw new Error("主路径失败")
+      }),
+      recordReadFailed: vi.fn(),
+      recordFallbackUsed: vi.fn(),
+    }
+    const registry = new DataSourceRegistry({ loadAdapter: adapter })
+    registry.register({
+      name: "outline",
+      priority: 1,
+      load: async () => "不应调用",
+      fallback: async () => "降级大纲",
+    })
+
+    await expect(registry.loadAll(context)).resolves.toMatchObject({ outline: "降级大纲" })
+    expect(adapter.recordReadFailed).toHaveBeenCalledWith("outline")
+    expect(adapter.recordFallbackUsed).toHaveBeenCalledWith("outline")
+  })
 })

+ 28 - 13
src/lib/novel/context-data-source.ts

@@ -34,6 +34,8 @@ export interface DataSourceLoadAdapter {
     context: ContextLoadContext,
     directLoad: () => Promise<T>,
   ): Promise<T>
+  recordReadFailed?(sourceName: string): void
+  recordFallbackUsed?(sourceName: string): void
 }
 
 export interface DataSourceRegistryOptions {
@@ -90,12 +92,13 @@ export class DataSourceRegistry {
   }
 
   private async loadSources(sources: DataSource<any>[], context: ContextLoadContext): Promise<Record<string, any>> {
+    const adapter = this.options.loadAdapter
 
     const promises = sources.map(async (source): Promise<DataSourceResult> => {
       try {
         const directLoad = () => source.load(context)
-        const loadedValue = this.options.loadAdapter
-          ? await this.options.loadAdapter.load(source, context, directLoad)
+        const loadedValue = adapter
+          ? await adapter.load(source, context, directLoad)
           : await directLoad()
         const value = loadedValue === undefined || loadedValue === null
           ? this.getDefaultValue(source.name)
@@ -103,19 +106,26 @@ export class DataSourceRegistry {
         return { name: source.name, value, error: null }
       } catch (error) {
         console.warn(`[DataSource] ${source.name} failed to load:`, error)
-        
-        // 尝试使用降级策略
+        adapter?.recordReadFailed?.(source.name)
+
+        if (!source.fallback) {
+          return {
+            name: source.name,
+            value: this.getDefaultValue(source.name),
+            error: error as Error,
+          }
+        }
+
         try {
-          const fallbackValue = source.fallback 
-            ? await source.fallback(context) 
-            : this.getDefaultValue(source.name)
+          const fallbackValue = await source.fallback(context)
+          adapter?.recordFallbackUsed?.(source.name)
           return { name: source.name, value: fallbackValue, error: error as Error }
         } catch (fallbackError) {
           console.warn(`[DataSource] ${source.name} fallback also failed:`, fallbackError)
-          return { 
-            name: source.name, 
-            value: this.getDefaultValue(source.name), 
-            error: fallbackError as Error 
+          return {
+            name: source.name,
+            value: this.getDefaultValue(source.name),
+            error: fallbackError as Error,
           }
         }
       }
@@ -123,7 +133,6 @@ export class DataSourceRegistry {
 
     const results = await Promise.all(promises)
 
-    // 转换为记录对象
     return results.reduce((acc, { name, value }) => {
       acc[name] = value
       return acc
@@ -160,8 +169,14 @@ export class DataSourceRegistry {
       revisionFeedback: [],
       cognitionText: "",
       soulDoc: "",
-      characterAuras: "",
       sectionBriefing: "",
+      storyFrameworkBinding: "",
+      retrieval: {
+        recentSummaries: [],
+        characterStates: "",
+        foreshadowingSignals: [],
+        timeline: "",
+      },
     }
     return defaults[sourceName] ?? null
   }

+ 7 - 23
src/lib/novel/context-data-sources.ts

@@ -57,7 +57,6 @@ const DATA_SOURCE_CATEGORY_MAP: Record<string, DataSourceCategory[]> = {
   revisionFeedback: ["revision"],
   cognitionText: ["character_states"],
   soulDoc: ["soul"],
-  characterAuras: ["character_states", "soul"],
   storyFrameworkBinding: ["settings", "outline"],
 }
 
@@ -482,18 +481,6 @@ export const soulDocDataSource: DataSource<string> = {
   },
 }
 
-/**
- * 角色氛围数据源(依赖其他数据源的结果,需要后处理)
- */
-export const characterAurasDataSource: DataSource<string> = {
-  name: "characterAuras",
-  priority: 18,
-  async load(_context: ContextLoadContext): Promise<string> {
-    // 这个数据源需要依赖其他数据,将在主函数中单独处理
-    return ""
-  },
-}
-
 /**
  * 本节速记数据源
  * 根据细纲筛选角色状态、伏笔和世界观约束,priority=0(最优先)
@@ -512,21 +499,18 @@ export const sectionBriefingDataSource: DataSource<string> = {
 /**
  * 故事框架绑定数据源
  * 加载当前激活的框架绑定,构建注入 AI 会话的上下文文本。
+ * 无绑定 / 框架缺失 → 空字符串(由缓存层记 empty);真实 I/O 异常上抛。
  */
 export const storyFrameworkBindingDataSource: DataSource<string> = {
   name: "storyFrameworkBinding",
   priority: 19,
   async load(context: ContextLoadContext): Promise<string> {
-    try {
-      const binding = await loadBinding(context.projectPath)
-      if (!binding) return ""
-      const frameworks = await loadFrameworks(context.projectPath)
-      const framework = frameworks.find((f) => f.id === binding.frameworkId)
-      if (!framework) return ""
-      return buildBindingContext(binding, framework)
-    } catch {
-      return ""
-    }
+    const binding = await loadBinding(context.projectPath)
+    if (!binding) return ""
+    const frameworks = await loadFrameworks(context.projectPath)
+    const framework = frameworks.find((f) => f.id === binding.frameworkId)
+    if (!framework) return ""
+    return buildBindingContext(binding, framework)
   },
 }
 

+ 2 - 0
src/lib/novel/context-engine.spec.ts

@@ -12,6 +12,7 @@ const basePack: ContextPack = {
   characterStates: "",
   soulDoc: "",
   characterAuras: "",
+  storyFrameworkBinding: "",
   cognitionStates: "",
   foreshadowingStates: "",
   timeline: "",
@@ -89,6 +90,7 @@ describe("trimContextPack 两级裁剪", () => {
     previousChapterEnding: "林小天被两名外门弟子押着,一步步走向禁地。他的心中充满愤怒,但更多的是不甘。他不甘心就这样被废掉修为,他不甘心就这样认输。就在禁地大门即将关上的那一刻,他猛地抬起头,眼中闪过一道精光。",
     characterStates: "林小天:炼气九层,性格坚毅,身负神秘玉佩\n苏婉儿:炼气八层,善良聪慧,与主角交好\n张天霸:筑基初期,宗门天才,心胸狭窄",
     characterAuras: "林小天:不屈、热血、成长型\n苏婉儿:温柔、聪慧、辅助型\n张天霸:傲慢、残忍、反派型",
+    storyFrameworkBinding: "",
     foreshadowingStates: "玉佩秘密:未完全解开,疑似上古大能遗物\n秘境传承:仅获得基础功法,更深层传承待解锁\n苏婉儿身份:表面是普通弟子,似乎另有背景",
     cognitionStates: "林小天当前心态:愤怒但冷静,决心反击\n张天霸当前心态:傲慢自大,想彻底废掉主角\n苏婉儿当前心态:担忧主角安危,想办法营救",
     timeline: "第1章:山村觉醒\n第2章:入门青云宗\n第3章:杂役修炼\n第4章:宗门小比\n第5章:秘境传承\n第6章:初遇冲突\n第7章:下山历练\n第8章:结识婉儿\n第9章:被陷禁地\n第10章:大战在即",

+ 9 - 0
src/lib/novel/context-engine.ts

@@ -42,6 +42,8 @@ const FIELD_PRIORITY: Record<string, number> = {
   previousChapterEnding: 8,
   characterStates: 9,
   characterAuras: 10,
+  /** Keep with stable core fields; must not share a slot with characterAuras. */
+  storyFrameworkBinding: 5.5,
   foreshadowingStates: 11,
   recentChapterContents: 12,
   revisionDirectives: 13,
@@ -78,6 +80,8 @@ export interface ContextPack {
   characterStates: string
   soulDoc: string
   characterAuras: string
+  /** Active story-framework binding text; empty when unbound. */
+  storyFrameworkBinding: string
   cognitionStates: string
   foreshadowingStates: string
   sectionBriefing?: string
@@ -255,6 +259,9 @@ async function buildContextPackFromRawData(
     soulDoc: rawData.soulDoc,
     sectionBriefing: rawData.sectionBriefing || "",
     characterAuras,
+    storyFrameworkBinding: typeof rawData.storyFrameworkBinding === "string"
+      ? rawData.storyFrameworkBinding
+      : "",
     cognitionStates: rawData.cognitionText,
     foreshadowingStates,
     timeline,
@@ -404,6 +411,7 @@ function emptyPack(task: string): ContextPack {
     characterStates: "",
     soulDoc: "",
     characterAuras: "",
+    storyFrameworkBinding: "",
     cognitionStates: "",
     foreshadowingStates: "",
     sectionBriefing: "",
@@ -1100,6 +1108,7 @@ const FIELD_CONFIGS: FieldConfig[] = [
   { titleKey: "novel.contextPack.mustAvoid.title", fieldKey: "mustAvoid" },
   { titleKey: "novel.contextPack.nextChapterAdvice.title", fieldKey: "nextChapterAdvice" },
   { titleKey: "novel.contextPack.soulDoc", fieldKey: "soulDoc" },
+  { titleKey: "novel.contextPack.storyFrameworkBinding", fieldKey: "storyFrameworkBinding" },
   { titleKey: "novel.contextPack.recentRevisionDirectives", fieldKey: "revisionDirectives" },
   { titleKey: "novel.contextPack.requiredOutline", fieldKey: "outline" },
   { titleKey: "novel.contextPack.recentChapterContents", fieldKey: "recentChapterContents" },

+ 1 - 0
src/lib/novel/deep-chapter-generation.spec.ts

@@ -45,6 +45,7 @@ const contextPack: ContextPack = {
   characterStates: "主角谨慎,但急于确认真相。",
   soulDoc: "",
   characterAuras: "",
+  storyFrameworkBinding: "",
   cognitionStates: "主角不知道旧屋主人真实身份。",
   foreshadowingStates: "匿名信、锈钥匙尚未回收。",
   timeline: "雨夜,当晚十点。",

+ 5 - 4
src/lib/novel/deep-chapter-generation.ts

@@ -6,7 +6,7 @@ import {
   type StreamCallbacks,
 } from "@/lib/llm-client";
 import { useWikiStore } from "@/stores/wiki-store";
-import { resolveAiWorkflowMode, type AiWorkflowMode, type LegacyAiWorkflowMode } from "@/lib/agent/workflow-mode";
+import type { AiWorkflowMode } from "@/lib/agent/workflow-mode";
 import type { AgentActivityEvent, AgentActivityKind } from "@/lib/agent/types";
 import {
   isReasoningDisabled,
@@ -69,7 +69,7 @@ export interface DeepChapterGenerationInput {
   goldenThreeChapter?: GoldenThreeChapterRequest;
   dismantlingReferenceDirective?: string;
   llmConfig: LlmConfig;
-  aiWorkflowMode?: LegacyAiWorkflowMode;
+  aiWorkflowMode?: AiWorkflowMode;
   resumeCheckpoint?: DeepChapterGenerationResumeCheckpoint;
   /** 用户在会话层确认的章节计划,作为写作任务书的权威依据注入 brief 阶段。 */
   planBlueprint?: string;
@@ -230,9 +230,9 @@ interface ChapterWorkflowProfile {
 }
 
 function resolveChapterWorkflowProfile(
-  mode: LegacyAiWorkflowMode | undefined,
+  mode: AiWorkflowMode | undefined,
 ): ChapterWorkflowProfile {
-  const resolvedMode = mode == null ? "strict" : resolveAiWorkflowMode(mode);
+  const resolvedMode = mode ?? "strict";
   if (resolvedMode === "fast") {
     return {
       mode: "fast",
@@ -2286,6 +2286,7 @@ async function safeBuildChapterContextPack(
       characterStates: "",
       soulDoc: "",
       characterAuras: "",
+      storyFrameworkBinding: "",
       cognitionStates: "",
       foreshadowingStates: "",
       timeline: "",

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

@@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({
     characterStates: "主角谨慎,小晴仍隐瞒自己知道族谱。",
     soulDoc: "项目灵魂:悬疑、克制、现实压力。",
     characterAuras: "主角表达克制,不会突然热血喊口号。",
+    storyFrameworkBinding: "",
     cognitionStates: "主角不知道族谱已经被人换过。",
     foreshadowingStates: "旧钥匙、族谱缺页、门缝冷光都未回收。",
     timeline: "雨夜,进入祠堂前后不超过一小时。",

+ 1 - 0
src/lib/novel/outline-agent-context.spec.ts

@@ -12,6 +12,7 @@ const pack: ContextPack = {
   characterStates: "角色当前状态",
   soulDoc: "作品灵魂",
   characterAuras: "角色气质",
+  storyFrameworkBinding: "",
   cognitionStates: "角色认知",
   foreshadowingStates: "伏笔状态",
   sectionBriefing: "",

+ 1 - 0
src/lib/novel/outline-generation.ts

@@ -210,6 +210,7 @@ function emptyOutlineContextPack(task: string): ContextPack {
     characterStates: "",
     soulDoc: "",
     characterAuras: "",
+    storyFrameworkBinding: "",
     cognitionStates: "",
     foreshadowingStates: "",
     timeline: "",

+ 0 - 2
src/lib/novel/outline-multi-agent-orchestrator.ts

@@ -47,8 +47,6 @@ export interface OutlineSubAgentStatusEvent {
 export interface OutlineMultiAgentRunInput {
   plan: OutlineSubAgentPlan[]
   maxConcurrency?: number
-  /** @deprecated 部分 Agent 失败不再触发整体降级。 */
-  failureFallbackThreshold?: number
   runSubAgent: (plan: OutlineSubAgentPlan) => Promise<string>
   runSingleAgentFallback: () => Promise<string>
   mergeResults: (results: OutlineSubAgentResult[]) => Promise<string>

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

@@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({
     characterStates: "主角谨慎,小晴仍然隐瞒她知道族谱。 ",
     soulDoc: "项目灵魂:悬疑、克制、现实压力。",
     characterAuras: "主角表达克制,不会突然热血喊口号。",
+    storyFrameworkBinding: "",
     cognitionStates: "主角不知道族谱已经被人换过。",
     foreshadowingStates: "旧钥匙、族谱缺页、门缝冷光都未回收。",
     timeline: "雨夜,进入祠堂前后不超过一小时。",

+ 2 - 2
src/lib/novel/skill-library.ts

@@ -1,4 +1,4 @@
-import type { LegacyAiWorkflowMode } from "@/lib/agent/workflow-mode"
+import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
 
 export type SkillKind =
   | "style"
@@ -16,7 +16,7 @@ export type SkillStage =
   | "rewrite"
   | "output"
 
-export type SkillMode = LegacyAiWorkflowMode
+export type SkillMode = AiWorkflowMode
 
 export interface SkillCategory {
   id: string

+ 2 - 2
src/lib/persist.spec.ts

@@ -116,7 +116,7 @@ describe("chat context summary persistence", () => {
       surface: "ai-chat",
       createdAt: 10,
       stats: {
-        hits: 1, refreshed: 2, failures: 0,
+        cacheHits: 1, reloaded: 2, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
         stableTokens: 100, summaryTokens: 20, dynamicTokens: 30,
         candidateTokens: 300, estimatedSavedTokens: 150, estimatedSavedPercent: 50,
         expanded: false, providerCacheEnabled: true,
@@ -196,7 +196,7 @@ describe("chat context summary persistence", () => {
 
   it("prunes AI chat snapshots using only references that were actually persisted", async () => {
     const stats = {
-      hits: 1, refreshed: 0, failures: 0,
+      cacheHits: 1, reloaded: 0, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
       stableTokens: 100, summaryTokens: 20, dynamicTokens: 30,
       candidateTokens: 300, estimatedSavedTokens: 150, estimatedSavedPercent: 50,
       expanded: false, providerCacheEnabled: true,

+ 0 - 16
src/lib/sources-merge.ts

@@ -169,8 +169,6 @@ export function mergeArrayFieldsIntoContent(
   return changed ? result : newContent
 }
 
-// ─── Backward-compatible single-field exports ─────────────────────
-
 /**
  * Extract `sources: [...]` from a wiki page's frontmatter.
  * Handles inline and block forms; strips quotes; returns [] when
@@ -202,17 +200,3 @@ export function mergeSourcesLists(
 ): string[] {
   return mergeLists(existing, incoming)
 }
-
-/**
- * Sources-only convenience wrapper — equivalent to
- * `mergeArrayFieldsIntoContent(newContent, existingContent, ["sources"])`.
- * Kept for ingest-flow callers that pre-date the multi-field
- * generalization; new code should prefer the generic version which
- * also unions tags / related.
- */
-export function mergeSourcesIntoContent(
-  newContent: string,
-  existingContent: string | null,
-): string {
-  return mergeArrayFieldsIntoContent(newContent, existingContent, ["sources"])
-}

+ 17 - 9
src/lib/user-memory/request-integration.spec.ts

@@ -31,7 +31,9 @@ describe("global user memory request integration", () => {
     saveGlobalUserMemoryConfig({ ...loadGlobalUserMemoryConfig(storage), autoRead: false }, storage)
     const messages: ChatMessage[] = [{ role: "user", content: "续写下一章" }]
 
-    expect(applyGlobalUserMemoryToMessages(messages, {}, storage)).toBe(messages)
+    const result = applyGlobalUserMemoryToMessages(messages, {}, storage)
+    expect(result.messages).toBe(messages)
+    expect(result.decision).not.toBeNull()
   })
 
   it("写作任务只注入相关规则", () => {
@@ -42,8 +44,9 @@ describe("global user memory request integration", () => {
       { role: "user", content: "续写下一章正文" },
     ], { userMemorySurface: "chapter-writing" }, storage)
 
-    expect(String(result[0]?.content)).toContain("写作保持幽默")
-    expect(String(result[0]?.content)).toContain("回答时先给结论")
+    expect(String(result.messages[0]?.content)).toContain("写作保持幽默")
+    expect(String(result.messages[0]?.content)).toContain("回答时先给结论")
+    expect(result.decision?.selectedRuleIds.length).toBeGreaterThan(0)
   })
 
   it("审查任务不注入创作文风并保留缓存块", () => {
@@ -57,7 +60,7 @@ describe("global user memory request integration", () => {
       { role: "user", content: "请审查这一章" },
     ]
     const result = applyGlobalUserMemoryToMessages(messages, { userMemorySurface: "review" }, storage)
-    const blocks = result[0]?.content
+    const blocks = result.messages[0]?.content
 
     expect(Array.isArray(blocks)).toBe(true)
     expect(blocks).toEqual(expect.arrayContaining([
@@ -72,7 +75,10 @@ describe("global user memory request integration", () => {
     withRules(storage)
     const messages: ChatMessage[] = [{ role: "user", content: "分析用户偏好" }]
 
-    expect(applyGlobalUserMemoryToMessages(messages, { skipUserMemory: true }, storage)).toBe(messages)
+    expect(applyGlobalUserMemoryToMessages(messages, { skipUserMemory: true }, storage)).toEqual({
+      messages,
+      decision: null,
+    })
   })
 
   it("后台提取器跳过记忆时不覆盖最近一次用户请求决策", () => {
@@ -104,9 +110,10 @@ describe("global user memory request integration", () => {
       { role: "user", content: "请回答" },
     ], { userMemorySurface: "ai-chat", userMemoryProjectKey: "p1", userMemorySessionKey: "s1" }, storage)
 
-    expect(JSON.stringify(result)).toContain("会话规则")
+    expect(JSON.stringify(result.messages)).toContain("会话规则")
+    expect(result.decision).toMatchObject({ projectKey: "p1", sessionKey: "s1" })
+    expect(result.decision?.selectedRuleIds.length).toBe(3)
     expect(getLatestUserMemoryDecision()).toMatchObject({ projectKey: "p1", sessionKey: "s1" })
-    expect(getLatestUserMemoryDecision()?.selectedRuleIds.length).toBe(3)
   })
 
   it("仅手动模式过滤自动规则并记录过滤原因", () => {
@@ -123,7 +130,8 @@ describe("global user memory request integration", () => {
     }, storage)
 
     const messages = [{ role: "user" as const, content: "请回答" }]
-    expect(applyGlobalUserMemoryToMessages(messages, { userMemorySurface: "ai-chat" }, storage)).toBe(messages)
-    expect(getLatestUserMemoryDecision()?.filtered).toContainEqual({ ruleId: "auto", reason: "disabled" })
+    const result = applyGlobalUserMemoryToMessages(messages, { userMemorySurface: "ai-chat" }, storage)
+    expect(result.messages).toBe(messages)
+    expect(result.decision?.filtered).toContainEqual({ ruleId: "auto", reason: "disabled" })
   })
 })

+ 28 - 10
src/lib/user-memory/request-integration.ts

@@ -1,12 +1,21 @@
 import type { ChatMessage, ContentBlock, RequestOverrides } from "@/lib/llm-providers"
 import { compileUserMemorySkill } from "./compiler"
-import { buildUserMemoryDecision, setLatestUserMemoryDecision } from "./decision-trace"
+import {
+  buildUserMemoryDecision,
+  setLatestUserMemoryDecision,
+  type UserMemoryDecision,
+} from "./decision-trace"
 import { governUserMemoryConfig } from "./governance"
 import { evaluateUserMemoryRule, inferUserMemorySurface, selectUserMemoryRules } from "./selector"
 import { loadGlobalUserMemoryConfig, saveGlobalUserMemoryConfig } from "./store"
 
 type StorageLike = Pick<Storage, "getItem" | "setItem">
 
+export interface ApplyUserMemoryResult {
+  messages: ChatMessage[]
+  decision: UserMemoryDecision | null
+}
+
 function contentText(content: ChatMessage["content"]): string {
   if (typeof content === "string") return content
   return content.map((block) => block.type === "text" ? block.text : "").join("")
@@ -22,8 +31,10 @@ export function applyGlobalUserMemoryToMessages(
   messages: ChatMessage[],
   overrides: Pick<RequestOverrides, "skipUserMemory" | "userMemorySurface" | "userMemoryProjectKey" | "userMemorySessionKey"> = {},
   storage?: StorageLike,
-): ChatMessage[] {
-  if (overrides.skipUserMemory) return messages
+): ApplyUserMemoryResult {
+  if (overrides.skipUserMemory) {
+    return { messages, decision: null }
+  }
   const loadedConfig = loadGlobalUserMemoryConfig(storage)
   const config = governUserMemoryConfig(loadedConfig)
   const task = contentText([...messages].reverse().find((message) => message.role === "user")?.content ?? "")
@@ -47,7 +58,7 @@ export function applyGlobalUserMemoryToMessages(
     if (reason) return [{ ruleId: rule.id, reason }]
     return selectedIds.has(rule.id) ? [] : [{ ruleId: rule.id, reason: "shadowed" as const }]
   })
-  setLatestUserMemoryDecision(buildUserMemoryDecision({
+  const decision = buildUserMemoryDecision({
     rules: config.rules,
     selected,
     filtered,
@@ -55,8 +66,10 @@ export function applyGlobalUserMemoryToMessages(
     surface,
     projectKey: overrides.userMemoryProjectKey,
     sessionKey: overrides.userMemorySessionKey,
-  }))
-  if (!prompt) return messages
+  })
+  // Keep latest for settings feedback UI only — generation details must use the returned decision.
+  setLatestUserMemoryDecision(decision)
+  if (!prompt) return { messages, decision }
   const now = Date.now()
   saveGlobalUserMemoryConfig({
     ...config,
@@ -66,8 +79,13 @@ export function applyGlobalUserMemoryToMessages(
     updatedAt: Math.max(config.updatedAt, now),
   }, storage)
   const systemIndex = messages.findIndex((message) => message.role === "system")
-  if (systemIndex < 0) return [{ role: "system", content: prompt }, ...messages]
-  return messages.map((message, index) => index === systemIndex
-    ? { ...message, content: appendToSystem(message.content, prompt) }
-    : message)
+  if (systemIndex < 0) {
+    return { messages: [{ role: "system", content: prompt }, ...messages], decision }
+  }
+  return {
+    messages: messages.map((message, index) => index === systemIndex
+      ? { ...message, content: appendToSystem(message.content, prompt) }
+      : message),
+    decision,
+  }
 }

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

@@ -151,7 +151,7 @@ describe("outline-chat-store", () => {
         surface: "ai-outline",
         createdAt: 10,
         stats: {
-          hits: 1, refreshed: 0, failures: 0,
+          cacheHits: 1, reloaded: 0, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
           stableTokens: 100, summaryTokens: 20, dynamicTokens: 30,
           candidateTokens: 300, estimatedSavedTokens: 150, estimatedSavedPercent: 50,
           expanded: false, providerCacheEnabled: true,
@@ -259,7 +259,7 @@ describe("outline-chat-store", () => {
           surface: "ai-outline",
           createdAt: 10,
           stats: {
-            hits: 1, refreshed: 0, failures: 0,
+            cacheHits: 1, reloaded: 0, empty: 0, fallbackUsed: 0, readFailed: 0, writeFailed: 0,
             stableTokens: 100, summaryTokens: 20, dynamicTokens: 30,
             candidateTokens: 300, estimatedSavedTokens: 150, estimatedSavedPercent: 50,
             expanded: false, providerCacheEnabled: true,

+ 3 - 17
src/stores/wiki-store.ts

@@ -29,7 +29,7 @@ import {
   resolveStoredVisualStyle,
   type VisualStyle,
 } from "@/lib/visual-style-settings"
-import { DEFAULT_AI_WORKFLOW_MODE, resolveAiWorkflowMode, type AiWorkflowMode, type LegacyAiWorkflowMode } from "@/lib/agent/workflow-mode"
+import { DEFAULT_AI_WORKFLOW_MODE, type AiWorkflowMode } from "@/lib/agent/workflow-mode"
 import { DEFAULT_MCP_CONFIG, type McpConfig } from "@/lib/mcp/config"
 import {
   normalizeProviderConfigs,
@@ -603,8 +603,6 @@ interface WikiState {
   chatEditModeEnabled: boolean
   aiWorkflowMode: AiWorkflowMode
   planExecuteEnabled: boolean
-  /** 深度模式状态:跨视图切换保持开启 */
-  deepChapterEnabled: boolean
   novelConfig: NovelConfig
   /** 社区摘要生成错误信息(UI 层监听并弹窗提示) */
   communitySummaryError: string | null
@@ -673,9 +671,8 @@ interface WikiState {
   setSourceWatchConfig: (sourceWatchConfig: SourceWatchConfig) => void
   setNovelMode: (novelMode: boolean) => void
   setChatEditModeEnabled: (enabled: boolean) => void
-  setAiWorkflowMode: (mode: LegacyAiWorkflowMode) => void
+  setAiWorkflowMode: (mode: AiWorkflowMode) => void
   setPlanExecuteEnabled: (enabled: boolean) => void
-  setDeepChapterEnabled: (enabled: boolean) => void
   setNovelConfig: (config: Partial<NovelConfig>) => void
   setCommunitySummaryError: (error: string | null) => void
   setSearchHistory: (history: string[]) => void
@@ -891,7 +888,6 @@ export const useWikiStore = create<WikiState>((set) => ({
   chatEditModeEnabled: false,
   aiWorkflowMode: DEFAULT_AI_WORKFLOW_MODE,
   planExecuteEnabled: false,
-  deepChapterEnabled: false,
   novelConfig: { ...DEFAULT_NOVEL_CONFIG },
   communitySummaryError: null,
   searchHistory: [],
@@ -933,18 +929,8 @@ export const useWikiStore = create<WikiState>((set) => ({
   setSourceWatchConfig: (sourceWatchConfig) => set({ sourceWatchConfig }),
   setNovelMode: (novelMode) => set({ novelMode }),
   setChatEditModeEnabled: (chatEditModeEnabled) => set({ chatEditModeEnabled }),
-  setAiWorkflowMode: (aiWorkflowMode) => {
-    const resolvedMode = resolveAiWorkflowMode(aiWorkflowMode)
-    set({
-      aiWorkflowMode: resolvedMode,
-      deepChapterEnabled: resolvedMode === "strict",
-    })
-  },
+  setAiWorkflowMode: (aiWorkflowMode) => set({ aiWorkflowMode }),
   setPlanExecuteEnabled: (planExecuteEnabled) => set({ planExecuteEnabled }),
-  setDeepChapterEnabled: (deepChapterEnabled) => set({
-    deepChapterEnabled,
-    aiWorkflowMode: deepChapterEnabled ? "strict" : DEFAULT_AI_WORKFLOW_MODE,
-  }),
   setNovelConfig: (config) => set((state) => ({
     novelConfig: { ...state.novelConfig, ...config },
     ...(config.defaultLlmModel !== undefined

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

@@ -27,14 +27,12 @@ const wikiState = {
   selectedFile: null,
   aiWorkflowMode: "standard",
   planExecuteEnabled: false,
-  deepChapterEnabled: false,
   novelConfig: { contextTokenBudget: 0 },
   setActiveView: vi.fn(),
   setAiChatModel: vi.fn(),
   setAiWorkflowMode: vi.fn(),
   setPlanExecuteEnabled: vi.fn(),
   setChatEditModeEnabled: vi.fn(),
-  setDeepChapterEnabled: vi.fn(),
   setSelectedFile: vi.fn(),
 }