ソースを参照

feat: 完善全局用户记忆治理与上下文控制

Mochocyang 1 ヶ月 前
コミット
1213beecec
59 ファイル変更3515 行追加77 行削除
  1. 5 0
      src/App.tsx
  2. 18 1
      src/components/chat/chat-panel.tsx
  3. 10 0
      src/components/common/context-hub-details.tsx
  4. 65 0
      src/components/settings/sections/user-memory-section.spec.tsx
  5. 207 0
      src/components/settings/sections/user-memory-section.tsx
  6. 6 0
      src/components/settings/settings-view.tsx
  7. 79 8
      src/components/sources/outline-chat-panel.tsx
  8. 1 0
      src/i18n/en.json
  9. 1 0
      src/i18n/zh.json
  10. 71 0
      src/lib/agent/runner.spec.ts
  11. 25 4
      src/lib/agent/runner.ts
  12. 21 0
      src/lib/agent/tool-evidence-ledger.spec.ts
  13. 40 0
      src/lib/agent/tool-evidence-ledger.ts
  14. 68 0
      src/lib/chat-request-budget.test.ts
  15. 112 19
      src/lib/chat-request-budget.ts
  16. 7 0
      src/lib/context-hub/ai-chat-integration.spec.ts
  17. 24 0
      src/lib/context-hub/composer.spec.ts
  18. 69 6
      src/lib/context-hub/composer.ts
  19. 23 0
      src/lib/context-hub/provider-usage.spec.ts
  20. 10 1
      src/lib/context-hub/provider-usage.ts
  21. 33 0
      src/lib/context-hub/session-summary.spec.ts
  22. 40 9
      src/lib/context-hub/session-summary.ts
  23. 8 0
      src/lib/context-hub/types.ts
  24. 17 4
      src/lib/llm-client.ts
  25. 24 0
      src/lib/llm-client.usage.spec.ts
  26. 26 0
      src/lib/llm-providers.spec.ts
  27. 18 2
      src/lib/llm-providers.ts
  28. 52 0
      src/lib/novel/outline-agent-context.spec.ts
  29. 53 0
      src/lib/novel/outline-agent-context.ts
  30. 12 3
      src/lib/novel/outline-dynamic-agent-planner.spec.ts
  31. 6 1
      src/lib/novel/outline-dynamic-agent-planner.ts
  32. 89 0
      src/lib/novel/outline-multi-agent-orchestrator.spec.ts
  33. 99 19
      src/lib/novel/outline-multi-agent-orchestrator.ts
  34. 32 0
      src/lib/user-memory/compiler.spec.ts
  35. 30 0
      src/lib/user-memory/compiler.ts
  36. 27 0
      src/lib/user-memory/decision-trace.spec.ts
  37. 52 0
      src/lib/user-memory/decision-trace.ts
  38. 87 0
      src/lib/user-memory/extractor.spec.ts
  39. 96 0
      src/lib/user-memory/extractor.ts
  40. 29 0
      src/lib/user-memory/feedback-service.spec.ts
  41. 10 0
      src/lib/user-memory/feedback-service.ts
  42. 86 0
      src/lib/user-memory/governance.spec.ts
  43. 129 0
      src/lib/user-memory/governance.ts
  44. 13 0
      src/lib/user-memory/index.ts
  45. 31 0
      src/lib/user-memory/learning-budget.spec.ts
  46. 60 0
      src/lib/user-memory/learning-budget.ts
  47. 133 0
      src/lib/user-memory/learning-service.spec.ts
  48. 166 0
      src/lib/user-memory/learning-service.ts
  49. 31 0
      src/lib/user-memory/maintenance.spec.ts
  50. 18 0
      src/lib/user-memory/maintenance.ts
  51. 24 0
      src/lib/user-memory/prefilter.spec.ts
  52. 19 0
      src/lib/user-memory/prefilter.ts
  53. 129 0
      src/lib/user-memory/request-integration.spec.ts
  54. 73 0
      src/lib/user-memory/request-integration.ts
  55. 77 0
      src/lib/user-memory/selector.spec.ts
  56. 78 0
      src/lib/user-memory/selector.ts
  57. 236 0
      src/lib/user-memory/store.spec.ts
  58. 422 0
      src/lib/user-memory/store.ts
  59. 88 0
      src/lib/user-memory/types.ts

+ 5 - 0
src/App.tsx

@@ -26,6 +26,7 @@ import { applyVisualStyle } from "@/lib/visual-style-settings"
 import { normalizePath } from "@/lib/path-utils"
 import { countChapterBodyWords } from "@/lib/chapter-word-count"
 import { flattenMdFiles } from "@/lib/novel/chapter-utils"
+import { runUserMemoryMaintenance } from "@/lib/user-memory/maintenance"
 
 function App() {
   const project = useWikiStore((s) => s.project)
@@ -43,6 +44,10 @@ function App() {
   const [loading, setLoading] = useState(true)
   const [appTitleTotalWordCount, setAppTitleTotalWordCount] = useState<number | null>(null)
 
+  useEffect(() => {
+    runUserMemoryMaintenance()
+  }, [])
+
   function isCurrentProject(proj: WikiProject): boolean {
     const current = useWikiStore.getState().project
     if (!current || current.id !== proj.id) return false

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

@@ -128,6 +128,8 @@ import {
   type ContextHubResult,
   type ContextIntent,
 } from "@/lib/context-hub"
+import { enqueueUserMemoryLearning } from "@/lib/user-memory/learning-service"
+import { recordLatestUserMemoryFeedback } from "@/lib/user-memory/feedback-service"
 
 
 /* spec-test patterns */
@@ -1726,7 +1728,12 @@ export function ChatPanel() {
             systemPrompt: systemPromptForConfig,
             projectPath,
             taskGoal: plainText,
-            requestOverrides: agentConfig.requestOverrides,
+            requestOverrides: {
+              ...agentConfig.requestOverrides,
+              userMemorySurface: "ai-chat",
+              userMemoryProjectKey: projectPath,
+              userMemorySessionKey: capturedConvId,
+            },
           },
           enabledToolNames: prePluginResult?.enabledToolNames,
           registry: sessionRegistry,
@@ -1863,6 +1870,15 @@ export function ChatPanel() {
             }),
           )
         }
+        if (!hasAgentError) {
+          enqueueUserMemoryLearning({
+            message: plainText,
+            llmConfig: agentConfig.llmConfig,
+            surface: "ai-chat",
+            projectKey: projectPath,
+            sessionKey: capturedConvId,
+          })
+        }
         if (hasAgentError) {
           useChatStore.getState().failConversationRun(capturedConvId, lastAgentError, runId)
           toast.error(lastAgentError, {
@@ -2015,6 +2031,7 @@ export function ChatPanel() {
     const active = storeState.getActiveMessages()
     const lastUserMsg = [...active].reverse().find((m) => m.role === "user")
     if (!lastUserMsg) return
+    recordLatestUserMemoryFeedback("negative")
     // Remove the last assistant reply, then re-send
     removeLastAssistantMessage()
     // Zustand set 是同步的,无需延迟,直接读取最新状态

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

@@ -173,6 +173,16 @@ export function ContextHubDetails({
           <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}
         </span>
         {expanded
           ? <ChevronUp aria-hidden="true" className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />

+ 65 - 0
src/components/settings/sections/user-memory-section.spec.tsx

@@ -0,0 +1,65 @@
+// @vitest-environment jsdom
+import { act } from "react"
+import { createRoot } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it } from "vitest"
+import { UserMemorySection } from "./user-memory-section"
+import { loadGlobalUserMemoryConfig } from "@/lib/user-memory/store"
+import { addManualUserMemoryRule, saveGlobalUserMemoryConfig } from "@/lib/user-memory/store"
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true
+
+describe("UserMemorySection", () => {
+  let host: HTMLDivElement
+
+  beforeEach(() => {
+    window.localStorage.clear()
+    host = document.createElement("div")
+    document.body.appendChild(host)
+  })
+
+  afterEach(() => {
+    host.remove()
+  })
+
+  it("显示三个开关和记忆列表入口", async () => {
+    await act(async () => createRoot(host).render(<UserMemorySection />))
+
+    expect(document.body.textContent).toContain("全局用户记忆")
+    expect(document.body.textContent).toContain("启用全局记忆")
+    expect(document.body.textContent).toContain("自动学习")
+    expect(document.body.textContent).toContain("自动读取")
+    expect(document.body.textContent).toContain("新增规则")
+    expect(document.body.textContent).toContain("仅使用手动记忆")
+    expect(document.body.textContent).toContain("导出记忆")
+    expect(document.body.textContent).toContain("清空全部")
+    expect(document.body.textContent).toContain("存储占用")
+    expect(document.body.textContent).toContain("今日自动学习")
+  })
+
+  it("用户可以新增手动规则", async () => {
+    await act(async () => createRoot(host).render(<UserMemorySection />))
+    const add = [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("新增规则"))!
+    await act(async () => add.click())
+    const input = document.querySelector('[aria-label="规则内容"]') as HTMLTextAreaElement
+    await act(async () => {
+      Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set?.call(input, "回答时先给结论。")
+      input.dispatchEvent(new Event("input", { bubbles: true }))
+    })
+    const save = [...document.querySelectorAll("button")].find((button) => button.textContent === "保存规则")!
+    await act(async () => save.click())
+
+    expect(loadGlobalUserMemoryConfig().rules[0]?.rule).toBe("回答时先给结论。")
+  })
+
+  it("用户可以对单条规则标记有效", async () => {
+    saveGlobalUserMemoryConfig(addManualUserMemoryRule(loadGlobalUserMemoryConfig(), {
+      rule: "回答时先给结论。", category: "manual", surfaces: ["all"],
+    }, 1))
+    await act(async () => createRoot(host).render(<UserMemorySection />))
+
+    const positive = document.querySelector('[title="标记此规则有效"]') as HTMLButtonElement
+    await act(async () => positive.click())
+
+    expect(loadGlobalUserMemoryConfig().rules[0]?.positiveFeedback).toBe(1)
+  })
+})

+ 207 - 0
src/components/settings/sections/user-memory-section.tsx

@@ -0,0 +1,207 @@
+import { useEffect, useMemo, useState } from "react"
+import { Brain, Download, Eraser, Pencil, Plus, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import {
+  addManualUserMemoryRule,
+  clearGlobalUserMemoryConfig,
+  GLOBAL_USER_MEMORY_CHANGED_EVENT,
+  deleteUserMemoryRule,
+  exportGlobalUserMemoryJson,
+  getGlobalUserMemoryStats,
+  loadGlobalUserMemoryConfig,
+  saveGlobalUserMemoryConfig,
+  setUserMemoryRuleEnabled,
+  updateGlobalUserMemorySettings,
+  updateUserMemoryRule,
+} from "@/lib/user-memory/store"
+import type { GlobalUserMemoryConfig, UserMemoryCategory, UserMemoryRule } from "@/lib/user-memory/types"
+import { applyUserMemoryFeedback, governUserMemoryConfig } from "@/lib/user-memory/governance"
+import { loadUserMemoryLearningBudget } from "@/lib/user-memory/learning-budget"
+
+const CATEGORY_LABELS: Record<UserMemoryCategory, string> = {
+  output_style: "输出表达",
+  writing_preference: "写作偏好",
+  outline_preference: "大纲偏好",
+  workflow_preference: "流程偏好",
+  interaction_preference: "交互偏好",
+  format_preference: "格式要求",
+  constraint: "禁止事项",
+  manual: "手动规则",
+}
+
+interface EditorState {
+  id: string | null
+  rule: string
+  category: UserMemoryCategory
+}
+
+function settingLabel(label: string, description: string, checked: boolean, onChange: (checked: boolean) => void) {
+  return (
+    <label className="flex items-start justify-between gap-4 border-b py-3 last:border-b-0">
+      <span className="min-w-0">
+        <span className="block text-sm font-medium">{label}</span>
+        <span className="mt-0.5 block text-xs text-muted-foreground">{description}</span>
+      </span>
+      <input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} className="mt-1 h-4 w-4" />
+    </label>
+  )
+}
+
+export function UserMemorySection() {
+  const [config, setConfig] = useState<GlobalUserMemoryConfig>(() => loadGlobalUserMemoryConfig())
+  const [query, setQuery] = useState("")
+  const [category, setCategory] = useState<UserMemoryCategory | "all">("all")
+  const [editor, setEditor] = useState<EditorState | null>(null)
+
+  useEffect(() => {
+    const reload = () => setConfig(loadGlobalUserMemoryConfig())
+    window.addEventListener(GLOBAL_USER_MEMORY_CHANGED_EVENT, reload)
+    window.addEventListener("storage", reload)
+    return () => {
+      window.removeEventListener(GLOBAL_USER_MEMORY_CHANGED_EVENT, reload)
+      window.removeEventListener("storage", reload)
+    }
+  }, [])
+
+  const persist = (next: GlobalUserMemoryConfig) => {
+    setConfig(next)
+    saveGlobalUserMemoryConfig(next)
+  }
+  const updateSetting = (patch: Pick<Partial<GlobalUserMemoryConfig>, "enabled" | "autoLearn" | "autoRead" | "onlyManual">) => {
+    persist(updateGlobalUserMemorySettings(config, patch))
+  }
+  const filtered = useMemo(() => config.rules.filter((rule) => (
+    (category === "all" || rule.category === category)
+    && (!query.trim() || `${rule.rule} ${rule.evidenceSummary}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()))
+  )), [category, config.rules, query])
+  const stats = useMemo(() => getGlobalUserMemoryStats(config), [config])
+  const learningBudget = loadUserMemoryLearningBudget(typeof window === "undefined" ? null : window.localStorage)
+
+  const exportMemories = () => {
+    const blob = new Blob([exportGlobalUserMemoryJson(config)], { type: "application/json;charset=utf-8" })
+    const url = URL.createObjectURL(blob)
+    const anchor = document.createElement("a")
+    anchor.href = url
+    anchor.download = `QMaiWrite-用户记忆-${new Date().toISOString().slice(0, 10)}.json`
+    anchor.click()
+    URL.revokeObjectURL(url)
+  }
+
+  const clearAll = () => {
+    if (!window.confirm("确定清空全部用户记忆吗?此操作会删除自动规则、手动规则和学习记录,无法撤销。")) return
+    clearGlobalUserMemoryConfig()
+    setConfig(loadGlobalUserMemoryConfig())
+  }
+
+  const saveEditor = () => {
+    if (!editor?.rule.trim()) return
+    const next = editor.id
+      ? updateUserMemoryRule(config, editor.id, { rule: editor.rule, category: editor.category })
+      : addManualUserMemoryRule(config, { rule: editor.rule, category: editor.category, surfaces: ["all"] })
+    persist(next)
+    setEditor(null)
+  }
+
+  const remove = (rule: UserMemoryRule) => {
+    if (!window.confirm(`删除用户记忆“${rule.rule}”?删除后,相同自动规则不会从原来源再次生成。`)) return
+    persist(deleteUserMemoryRule(config, rule.id))
+  }
+  const feedback = (rule: UserMemoryRule, sentiment: "positive" | "negative") => {
+    persist(governUserMemoryConfig(applyUserMemoryFeedback(config, [rule.id], sentiment)))
+  }
+
+  return (
+    <div className="space-y-6">
+      <div>
+        <div className="flex items-center gap-2">
+          <Brain className="h-5 w-5" />
+          <h2 className="text-xl font-semibold">全局用户记忆</h2>
+        </div>
+        <p className="mt-1 text-sm text-muted-foreground">从用户请求中学习可复用习惯,并让所有 AI 功能按任务需要遵循这些规则。</p>
+      </div>
+
+      <section className="border-y">
+        {settingLabel("启用全局记忆", "关闭后不学习也不读取任何用户规则。", config.enabled, (enabled) => updateSetting({ enabled }))}
+        {settingLabel("自动学习", "AI 请求成功后,只分析尚未处理的新用户消息。", config.autoLearn, (autoLearn) => updateSetting({ autoLearn }))}
+        {settingLabel("自动读取", "发送 AI 请求前,按当前任务选择相关规则。", config.autoRead, (autoRead) => updateSetting({ autoRead }))}
+        {settingLabel("仅使用手动记忆", "开启后停止自动提取,只读取用户手动添加的规则。", config.onlyManual, (onlyManual) => updateSetting({ onlyManual }))}
+      </section>
+
+      <section className="space-y-3">
+        <div className="flex flex-wrap items-center justify-between gap-2">
+          <div>
+            <h3 className="text-sm font-semibold">用户规则</h3>
+            <p className="text-xs text-muted-foreground">共 {config.rules.length} 条,自动提取规则和手动规则均可管理。</p>
+          </div>
+          <div className="flex flex-wrap items-center gap-2">
+            <Button variant="outline" size="sm" onClick={exportMemories}>
+              <Download className="mr-1 h-4 w-4" />导出记忆
+            </Button>
+            <Button variant="outline" size="sm" onClick={clearAll}>
+              <Eraser className="mr-1 h-4 w-4" />清空全部
+            </Button>
+            <Button size="sm" onClick={() => setEditor({ id: null, rule: "", category: "manual" })}>
+              <Plus className="mr-1 h-4 w-4" />新增规则
+            </Button>
+          </div>
+        </div>
+        <p className="text-xs text-muted-foreground">
+          存储占用约 {(stats.estimatedBytes / 1024).toFixed(1)} KB / {(stats.maxStorageBytes / 1024).toFixed(0)} KB · 长期 {stats.activeRules} · 候选 {stats.candidateRules} · 冲突 {stats.conflictedRules}
+        </p>
+        <p className="text-xs text-muted-foreground">
+          今日自动学习 {learningBudget.calls} / {config.dailyLearningLimit} 次 · 已分析 {learningBudget.inputChars.toLocaleString()} 字符
+        </p>
+        <div className="grid gap-2 sm:grid-cols-[1fr_150px]">
+          <input aria-label="搜索用户记忆" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索规则" className="h-9 rounded-md border bg-background px-3 text-sm" />
+          <select aria-label="筛选记忆分类" value={category} onChange={(event) => setCategory(event.target.value as UserMemoryCategory | "all")} className="h-9 rounded-md border bg-background px-2 text-sm">
+            <option value="all">全部分类</option>
+            {Object.entries(CATEGORY_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
+          </select>
+        </div>
+        <div className="max-h-[52vh] overflow-y-auto border-y">
+          {filtered.length === 0 ? (
+            <p className="py-8 text-center text-sm text-muted-foreground">暂无符合条件的用户记忆。</p>
+          ) : filtered.map((rule) => (
+            <div key={rule.id} className="flex items-start gap-3 border-b py-3 last:border-b-0">
+              <input aria-label={`启用 ${rule.rule}`} type="checkbox" checked={rule.enabled} onChange={(event) => persist(setUserMemoryRuleEnabled(config, rule.id, event.target.checked))} className="mt-1 h-4 w-4" />
+              <div className="min-w-0 flex-1">
+                <p className="text-sm">{rule.rule}</p>
+                <p className="mt-1 text-xs text-muted-foreground">
+                  {CATEGORY_LABELS[rule.category]} · {rule.source === "manual" ? "用户添加" : `系统提取 · 置信度 ${Math.round(rule.confidence * 100)}%`} · {rule.scope === "session" ? "当前会话" : rule.scope === "project" ? "当前作品" : "全局"} · {rule.status === "candidate" ? "候选" : rule.status === "conflicted" ? "存在冲突" : rule.status === "expired" ? "已过期" : "长期有效"} · 已使用 {rule.usageCount ?? 0} 次
+                </p>
+                {rule.evidenceSummary && rule.source === "automatic" ? <p className="mt-1 text-xs text-muted-foreground">依据:{rule.evidenceSummary}</p> : null}
+              </div>
+              <Button variant="ghost" size="icon" title="标记此规则有效" onClick={() => feedback(rule, "positive")}><ThumbsUp className="h-4 w-4" /></Button>
+              <Button variant="ghost" size="icon" title="标记此规则无效" onClick={() => feedback(rule, "negative")}><ThumbsDown className="h-4 w-4" /></Button>
+              <Button variant="ghost" size="icon" title="编辑规则" onClick={() => setEditor({ id: rule.id, rule: rule.rule, category: rule.category })}><Pencil className="h-4 w-4" /></Button>
+              <Button variant="ghost" size="icon" title="删除规则" onClick={() => remove(rule)}><Trash2 className="h-4 w-4" /></Button>
+            </div>
+          ))}
+        </div>
+      </section>
+
+      <Dialog open={editor !== null} onOpenChange={(open) => !open && setEditor(null)}>
+        <DialogContent className="flex max-h-[85vh] flex-col sm:max-w-[560px]">
+          <DialogHeader><DialogTitle>{editor?.id ? "编辑用户规则" : "新增用户规则"}</DialogTitle></DialogHeader>
+          <div className="min-h-0 flex-1 space-y-4 overflow-y-auto py-3">
+            <label className="block space-y-1 text-sm">
+              <span>规则内容</span>
+              <textarea aria-label="规则内容" value={editor?.rule ?? ""} onChange={(event) => setEditor((current) => current ? { ...current, rule: event.target.value } : current)} rows={5} className="w-full resize-y rounded-md border bg-background p-3" placeholder="例如:回答时先给结论,再说明依据。" />
+            </label>
+            <label className="block space-y-1 text-sm">
+              <span>规则分类</span>
+              <select aria-label="规则分类" value={editor?.category ?? "manual"} onChange={(event) => setEditor((current) => current ? { ...current, category: event.target.value as UserMemoryCategory } : current)} className="h-9 w-full rounded-md border bg-background px-2">
+                {Object.entries(CATEGORY_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
+              </select>
+            </label>
+          </div>
+          <DialogFooter>
+            <Button variant="outline" onClick={() => setEditor(null)}>取消</Button>
+            <Button disabled={!editor?.rule.trim()} onClick={saveEditor}>保存规则</Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </div>
+  )
+}

+ 6 - 0
src/components/settings/settings-view.tsx

@@ -14,6 +14,7 @@ import {
   Archive,
   FileText,
   Download,
+  Brain,
 } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import i18n from "@/i18n"
@@ -42,6 +43,7 @@ import { UsageGuideSection } from "./sections/usage-guide-section"
 import { ContactSupportSection } from "./sections/contact-support-section"
 import { DataManagementSection } from "./sections/data-management-section"
 import { ExportCenterSection } from "./sections/export-center-section"
+import { UserMemorySection } from "./sections/user-memory-section"
 
 type CategoryId =
   | "llm"
@@ -51,6 +53,7 @@ type CategoryId =
   | "mcp"
   | "interface"
   | "novel"
+  | "user-memory"
   | "usage-guide"
   | "maintenance"
   | "data-management"
@@ -80,6 +83,7 @@ const CATEGORIES: Category[] = [
   { id: "mcp", labelKey: "settings.categories.mcp", icon: Network },
   { id: "interface", labelKey: "settings.categories.interface", icon: Palette },
   { id: "novel", labelKey: "settings.categories.novel", hintKey: "settings.categories.novelHint", icon: BookOpen },
+  { id: "user-memory", labelKey: "settings.categories.userMemory", icon: Brain },
   { id: "usage-guide", labelKey: "settings.categories.usageGuide", icon: HelpCircle },
   { id: "maintenance", labelKey: "settings.categories.maintenance", icon: Wrench },
   { id: "data-management", labelKey: "settings.categories.dataManagement", icon: Archive },
@@ -536,6 +540,8 @@ export function SettingsView() {
         return <InterfaceSection draft={draft} setDraft={setDraft} />
       case "novel":
         return <NovelSection draft={draft} setDraft={setDraft} />
+      case "user-memory":
+        return <UserMemorySection />
       case "classification":
         return <ClassificationSection projectPath={project?.path ?? undefined} />
       case "usage-guide":

+ 79 - 8
src/components/sources/outline-chat-panel.tsx

@@ -80,6 +80,7 @@ import {
   type NovelGenerationRequestPackage,
 } from "@/lib/novel/novel-generation-request-package";
 import {
+  buildBoundedSubAgentMergePayload,
   type OutlineSubAgentPlan,
   planOutlineSubAgents,
   resumeOutlineMultiAgentWorkflow,
@@ -90,6 +91,7 @@ import {
   buildDynamicOutlinePlannerPrompt,
   parseDynamicOutlinePlan,
 } from "@/lib/novel/outline-dynamic-agent-planner";
+import { buildScopedOutlineSubAgentContext } from "@/lib/novel/outline-agent-context";
 import { normalizeOutlineMarkdown, prepareOutlineSaveDraft } from "@/lib/outline-save";
 import {
   type CharacterSaveDraft,
@@ -179,6 +181,8 @@ import {
   type ContextHubSnapshotRef,
 } from "@/lib/context-hub";
 import { addLlmUsage, type LlmUsage } from "@/lib/llm-usage";
+import { enqueueUserMemoryLearning } from "@/lib/user-memory/learning-service";
+import { recordLatestUserMemoryFeedback } from "@/lib/user-memory/feedback-service";
 import {
   getConversationTabTitle,
   splitConversationToolbarItems,
@@ -1950,6 +1954,22 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               ])
             : [legacySystemPrompt, extraRules].filter(Boolean).join("\n\n")
         );
+        const buildSubAgentSystemContent = (plan: OutlineSubAgentPlan, extraRules: string): AgentMessage["content"] => {
+          if (!contextHubResult) return [legacySystemPrompt, extraRules].filter(Boolean).join("\n\n");
+          return [
+            { type: "text", text: baseSystemPrompt.trim() ? `${baseSystemPrompt.trim()}\n\n` : "" },
+            {
+              type: "text",
+              text: `## 子 Agent 局部上下文\n${buildScopedOutlineSubAgentContext(contextHubResult.contextPack, plan.kind)}`,
+              cacheControl: true,
+            },
+            {
+              type: "text",
+              text: [contextHubResult.sessionSummary ? `## 当前会话摘要\n${contextHubResult.sessionSummary}` : "", ...commonDynamicParts, extraRules]
+                .filter(Boolean).join("\n\n"),
+            },
+          ];
+        };
         const primarySystemContent = buildOutlineRunSystemContent();
         const systemPrompt = typeof primarySystemContent === "string"
           ? primarySystemContent
@@ -2011,7 +2031,18 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 : {}),
             },
           );
-          return { agentConfig, registry };
+          return {
+            agentConfig: {
+              ...agentConfig,
+              requestOverrides: {
+                ...agentConfig.requestOverrides,
+                userMemorySurface: "ai-outline" as const,
+                userMemoryProjectKey: normalizePath(project.path),
+                userMemorySessionKey: capturedConvId,
+              },
+            },
+            registry,
+          };
         };
 
         const runOutlineAgentOnce = async (
@@ -2127,6 +2158,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             const dynamicPlan = parseDynamicOutlinePlan(
               plannerRun.text,
               outlineWritingSkills.map((skill) => skill.name),
+              prompt,
             );
             if (dynamicPlan.ok) subAgentPlan = dynamicPlan.plan;
           } catch {
@@ -2170,14 +2202,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               const subAgentMessages: AgentMessage[] = [
                 {
                   role: "system",
-                  content: buildOutlineRunSystemContent([
+                  content: buildSubAgentSystemContent(subAgentPlan, [
                     "## 子 Agent 运行规则",
                     `当前身份:${subAgentPlan.name}`,
                     "你只能处理本 Agent 负责的维度,禁止写入文件。",
                     "必须输出符合 AI 大纲子 Agent JSON 协议的 JSON,不要输出额外说明。",
                   ].join("\n")),
                 },
-                ...historyPlan.messages,
+                ...historyPlan.messages.slice(-2),
                 {
                   role: "user",
                   content: buildOutlineAgentUserContent(
@@ -2250,7 +2282,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                     "输出必须是用户可直接阅读和保存的大纲正文,不要输出内部调度报告。",
                   ].join("\n")),
                 },
-                ...historyPlan.messages,
+                ...historyPlan.messages.slice(-2),
                 {
                   role: "user",
                   content: [
@@ -2260,7 +2292,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                     buildOutlineAgentUserContent(prompt, tokens),
                     "",
                     "## 子 Agent 结构化结果",
-                    JSON.stringify(subAgentResults, null, 2),
+                    buildBoundedSubAgentMergePayload(subAgentResults),
                   ].join("\n"),
                 },
               ];
@@ -2462,6 +2494,15 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         };
         if (!isCurrentRun()) return { started: true, sent: false };
         setConversationContextSummary(convId, nextContextSummaryPayload.contextSummary);
+        if (!options.systemGenerated) {
+          enqueueUserMemoryLearning({
+            message: prompt,
+            llmConfig: effectiveLlmConfig,
+            surface: "ai-outline",
+            projectKey: normalizePath(project.path),
+            sessionKey: capturedConvId,
+          });
+        }
         await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);
         if (!isCurrentRun()) return { started: true, sent: false };
         const firstUser = useOutlineChatStore
@@ -2741,6 +2782,18 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             ? buildContextHubSystemContent(baseSystemPrompt, contextHubResult, [extraRules])
             : [legacySystemPrompt, extraRules].filter(Boolean).join("\n\n")
         );
+        const buildResumeSubAgentSystemContent = (plan: OutlineSubAgentPlan, extraRules: string): AgentMessage["content"] => {
+          if (!contextHubResult) return [legacySystemPrompt, extraRules].filter(Boolean).join("\n\n");
+          return [
+            { type: "text", text: baseSystemPrompt.trim() ? `${baseSystemPrompt.trim()}\n\n` : "" },
+            {
+              type: "text",
+              text: `## 子 Agent 局部上下文\n${buildScopedOutlineSubAgentContext(contextHubResult.contextPack, plan.kind)}`,
+              cacheControl: true,
+            },
+            { type: "text", text: [contextHubResult.sessionSummary ? `## 当前会话摘要\n${contextHubResult.sessionSummary}` : "", extraRules].filter(Boolean).join("\n\n") },
+          ];
+        };
         const primarySystemContent = buildResumeSystemContent("");
         const systemPrompt = typeof primarySystemContent === "string"
           ? primarySystemContent
@@ -2768,7 +2821,18 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               ? { readTextFile: contextHubResult.readFile }
               : {}),
           });
-          return { agentConfig: c, registry: r };
+          return {
+            agentConfig: {
+              ...c,
+              requestOverrides: {
+                ...c.requestOverrides,
+                userMemorySurface: "ai-outline" as const,
+                userMemoryProjectKey: normalizePath(project.path),
+                userMemorySessionKey: capturedConvId,
+              },
+            },
+            registry: r,
+          };
         };
 
         // 更新状态为续传运行中
@@ -2799,7 +2863,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             let runText = "";
             let agentError: Error | null = null;
             const record = await new AgentRunner().run(agentConfig, reg, [
-              { role: "system", content: buildResumeSystemContent(["## 子 Agent 运行规则", `当前身份:${subAgentPlan.name}`, "你只能处理本 Agent 负责的维度,禁止写入文件。", "必须输出符合 AI 大纲子 Agent JSON 协议的 JSON,不要输出额外说明。"].join("\n")) },
+              { role: "system", content: buildResumeSubAgentSystemContent(subAgentPlan, ["## 子 Agent 运行规则", `当前身份:${subAgentPlan.name}`, "你只能处理本 Agent 负责的维度,禁止写入文件。", "必须输出符合 AI 大纲子 Agent JSON 协议的 JSON,不要输出额外说明。"].join("\n")) },
               { role: "user", content: subAgentPlan.taskPrompt },
             ], {
               onText: (chunk) => { runText += chunk; },
@@ -2826,7 +2890,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             let mergeError: Error | null = null;
             const record = await new AgentRunner().run(agentConfig, reg, [
               { role: "system", content: buildResumeSystemContent(["## 合并 Agent 运行规则", "你负责合并多个子 Agent 的结构化结果,形成最终可预览的大纲草稿。", "输出必须是用户可直接阅读和保存的大纲正文,不要输出内部调度报告。"].join("\n")) },
-              { role: "user", content: ["请合并以下 AI 大纲子 Agent 结果,解决冲突并输出最终大纲草稿。", "", "## 子 Agent 结构化结果", JSON.stringify(subAgentResults, null, 2)].join("\n") },
+              { role: "user", content: ["请合并以下 AI 大纲子 Agent 结果,解决冲突并输出最终大纲草稿。", "", "## 子 Agent 结构化结果", buildBoundedSubAgentMergePayload(subAgentResults)].join("\n") },
             ], {
               onText: (chunk) => {
                 mergeText += chunk;
@@ -2997,6 +3061,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const handleRegenerate = useCallback(
     async (msgIndex: number) => {
       if (!project || isStreaming || !activeConversationId) return;
+      recordLatestUserMemoryFeedback("negative");
       let effectiveLlmConfig = resolveNovelModel(
         llmConfig,
         novelConfig,
@@ -3153,6 +3218,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               : {}),
           },
         );
+        agentConfig.requestOverrides = {
+          ...agentConfig.requestOverrides,
+          userMemorySurface: "ai-outline",
+          userMemoryProjectKey: normalizePath(project.path),
+          userMemorySessionKey: capturedConvId,
+        };
         let agentError: Error | null = null;
         const record = await new AgentRunner().run(
           agentConfig,

+ 1 - 0
src/i18n/en.json

@@ -709,6 +709,7 @@
       "interface": "Interface",
       "novel": "Novel",
       "novelHint": "Model settings",
+      "userMemory": "Global User Memory",
       "usageGuide": "Usage Guide",
       "maintenance": "Maintenance",
       "feedback": "Feedback",

+ 1 - 0
src/i18n/zh.json

@@ -416,6 +416,7 @@
       "interface": "界面",
       "novel": "小说",
       "novelHint": "设置模型",
+      "userMemory": "全局用户记忆",
       "usageGuide": "软件使用说明",
       "maintenance": "维护",
       "feedback": "反馈",

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

@@ -587,6 +587,77 @@ describe("AgentRunner", () => {
     expect(compressedToolMessage).toContain("结尾")
   })
 
+  it("每轮模型请求保留任务契约并压缩内部工作消息", async () => {
+    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)
+      expect(messages.some((message) => String(message.content).includes("任务契约"))).toBe(true)
+      expect(messages.some((message) => String(message.content).includes("完成整本小说"))).toBe(true)
+      cb.onToken("完成")
+      cb.onDone()
+    })
+
+    const callbacks = { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() }
+    await runner.run(
+      {
+        maxRounds: 2,
+        tools: [],
+        systemPrompt: "",
+        taskGoal: "完成整本小说,不能改变主角身份。",
+        llmConfig: { ...mockLlmConfig, maxContextSize: 1_000 },
+      },
+      registry,
+      [
+        { role: "system", content: "系统规则".repeat(120) },
+        { role: "assistant", content: "旧结果".repeat(180) },
+        { role: "user", content: "继续执行当前任务" },
+      ],
+      callbacks,
+    )
+    expect(callbacks.onError).not.toHaveBeenCalled()
+  })
+
+  it("15 轮重复工具调用不会让内部上下文无限增长", async () => {
+    const tool: Tool = {
+      name: "read_memory",
+      description: "read",
+      category: "read",
+      parameters: {},
+      execute: vi.fn(async () => "记忆原文".repeat(3000)),
+    }
+    registry.register(tool)
+    let round = 0
+    const injectedToolContents: string[] = []
+    mockStreamChat.mockImplementation(async (_config: unknown, messages: AgentMessage[], cb: StreamCallbacks) => {
+      round += 1
+      const total = messages.reduce((sum, message) => sum + (typeof message.content === "string" ? message.content.length : 0), 0)
+      expect(total).toBeLessThanOrEqual(2250)
+      const latestTool = [...messages].reverse().find((message) => message.role === "tool")
+      if (latestTool) injectedToolContents.push(String(latestTool.content))
+      if (round <= 15) {
+        cb.onToolCallDelta?.({ index: 0, id: `call-${round}`, name: "read_memory", arguments: '{"name":"核心目标"}' })
+      } else {
+        cb.onToken("全部完成")
+      }
+      cb.onDone()
+    })
+    const callbacks = { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() }
+
+    const result = await runner.run({
+      maxRounds: 16,
+      tools: [tool],
+      systemPrompt: "",
+      taskGoal: "持续读取并完成长期任务",
+      llmConfig: { ...mockLlmConfig, maxContextSize: 3000 },
+      toolResultContextLimit: 1200,
+    }, registry, [systemMsg, userMsg], callbacks)
+
+    expect(result.toolCalls).toHaveLength(15)
+    expect(tool.execute).toHaveBeenCalledTimes(15)
+    expect(injectedToolContents.some((content) => content.includes("工具证据引用"))).toBe(true)
+    expect(callbacks.onError).not.toHaveBeenCalled()
+  })
+
   it("merges caller request overrides with tool calling options", async () => {
     const tool: Tool = {
       name: "read_chapter",

+ 25 - 4
src/lib/agent/runner.ts

@@ -2,7 +2,6 @@ import { streamChat } from "../llm-client"
 import type { StreamCallbacks } from "../llm-client"
 import { accumulateToolCalls } from "./tool-call-parser"
 import { toOpenAITools } from "./tools-schema"
-import { formatToolResultForModel } from "./tool-result"
 import type { ToolRegistry } from "./registry"
 import type { AgentConfig, AgentMessage, AgentRunCallbacks, AgentRunRecord, ToolCall, ToolCallDelta } from "./types"
 import { DEFAULT_MAX_ROUNDS, TOOL_EXECUTE_TIMEOUT_MS } from "./types"
@@ -16,6 +15,8 @@ import {
 import type { ChatMessage } from "../llm-providers"
 import { isReasoningDisabled, isReasoningOnlyResponseError, withReasoningDisabled } from "../reasoning-retry"
 import { addLlmUsage } from "../llm-usage"
+import { trimChatMessagesToBudget } from "../chat-request-budget"
+import { ToolEvidenceLedger } from "./tool-evidence-ledger"
 
 export class ModelDoesNotSupportToolsError extends Error {
   constructor() {
@@ -59,6 +60,13 @@ export class AgentRunner {
       config.taskGoal ||
       messageContentText([...messages].reverse().find((m) => m.role === "user")?.content ?? "") ||
       "未命名任务"
+    const taskContract = `## 任务契约\n初始任务目标:${taskGoal.slice(0, 1800)}\n执行过程中不得因历史裁剪丢失该目标;当前用户新要求优先。`
+    const contractInsertIndex = workingMessages.findIndex((message) => message.role !== "system")
+    workingMessages.splice(contractInsertIndex < 0 ? workingMessages.length : contractInsertIndex, 0, {
+      role: "system",
+      content: taskContract,
+    })
+    const evidenceLedger = new ToolEvidenceLedger(config.toolResultContextLimit ?? 6000)
     let taskBreakpoint: TaskBreakpoint | null = projectPath
       ? createTaskBreakpoint({
           taskGoal,
@@ -138,6 +146,9 @@ export class AgentRunner {
           : baseOverrides
       let requestOverrides = buildRequestOverrides()
       const streamRound = async () => {
+        const internalBudget = Math.max(1, Math.floor((config.llmConfig.maxContextSize || 204_800) * 0.75))
+        const compacted = trimChatMessagesToBudget(workingMessages as ChatMessage[], internalBudget) as AgentMessage[]
+        workingMessages.splice(0, workingMessages.length, ...compacted)
         await streamChat(
           config.llmConfig,
           workingMessages as ChatMessage[],
@@ -265,7 +276,12 @@ export class AgentRunner {
             result: errorMsg,
             timestamp: toolCallRecord.finishedAt,
           })
-          workingMessages.push({ role: "tool", content: toolCallRecord.result, tool_call_id: tc.id, name: toolName })
+          workingMessages.push({
+            role: "tool",
+            content: evidenceLedger.format(toolName, params, toolCallRecord.result),
+            tool_call_id: tc.id,
+            name: toolName,
+          })
           await saveToolProgress()
           continue
         }
@@ -293,7 +309,12 @@ export class AgentRunner {
             preview,
             timestamp: toolCallRecord.finishedAt,
           })
-          workingMessages.push({ role: "tool", content: preview, tool_call_id: tc.id, name: toolName })
+          workingMessages.push({
+            role: "tool",
+            content: evidenceLedger.format(toolName, params, preview),
+            tool_call_id: tc.id,
+            name: toolName,
+          })
           await saveToolProgress()
           continue
         }
@@ -331,7 +352,7 @@ export class AgentRunner {
         await saveToolProgress()
         workingMessages.push({
           role: "tool",
-          content: formatToolResultForModel(toolName, toolCallRecord.result, config.toolResultContextLimit),
+          content: evidenceLedger.format(toolName, params, toolCallRecord.result),
           tool_call_id: tc.id,
           name: toolName,
         })

+ 21 - 0
src/lib/agent/tool-evidence-ledger.spec.ts

@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest"
+import { ToolEvidenceLedger } from "./tool-evidence-ledger"
+
+describe("ToolEvidenceLedger", () => {
+  it("相同工具、参数和结果再次出现时返回证据引用", () => {
+    const ledger = new ToolEvidenceLedger(500)
+    const first = ledger.format("read_chapter", { chapter: 1 }, "第一章完整内容")
+    const second = ledger.format("read_chapter", { chapter: 1 }, "第一章完整内容")
+
+    expect(first).toContain("第一章完整内容")
+    expect(second).toContain("工具证据引用")
+    expect(second).not.toContain("第一章完整内容")
+  })
+
+  it("不同参数不会错误复用证据", () => {
+    const ledger = new ToolEvidenceLedger(500)
+    ledger.format("read_chapter", { chapter: 1 }, "第一章")
+
+    expect(ledger.format("read_chapter", { chapter: 2 }, "第二章")).toContain("第二章")
+  })
+})

+ 40 - 0
src/lib/agent/tool-evidence-ledger.ts

@@ -0,0 +1,40 @@
+import { formatToolResultForModel } from "./tool-result"
+
+function stableValue(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(stableValue)
+  if (!value || typeof value !== "object") return value
+  return Object.fromEntries(Object.entries(value as Record<string, unknown>)
+    .sort(([left], [right]) => left.localeCompare(right))
+    .map(([key, item]) => [key, stableValue(item)]))
+}
+
+function resultHash(value: string): string {
+  let hash = 2166136261
+  for (let index = 0; index < value.length; index += 1) {
+    hash ^= value.charCodeAt(index)
+    hash = Math.imul(hash, 16777619)
+  }
+  return (hash >>> 0).toString(16).padStart(8, "0")
+}
+
+export class ToolEvidenceLedger {
+  private readonly entries = new Map<string, { id: string; resultHash: string }>()
+  private sequence = 0
+
+  constructor(private readonly resultLimit: number) {}
+
+  format(toolName: string, params: Record<string, unknown>, result: string): string {
+    const key = `${toolName}:${JSON.stringify(stableValue(params))}`
+    const hash = resultHash(result)
+    const existing = this.entries.get(key)
+    if (existing?.resultHash === hash) {
+      return `工具证据引用:${existing.id}。本次结果与先前相同,不再重复注入全文。`
+    }
+    const id = `evidence-${String(++this.sequence).padStart(3, "0")}`
+    this.entries.set(key, { id, resultHash: hash })
+    return [
+      `工具证据 ID:${id}`,
+      formatToolResultForModel(toolName, result, this.resultLimit),
+    ].join("\n")
+  }
+}

+ 68 - 0
src/lib/chat-request-budget.test.ts

@@ -48,4 +48,72 @@ describe("trimChatMessagesToBudget", () => {
     expect(totalTextLength(trimmed)).toBeLessThanOrEqual(2_000)
     expect(String(trimmed[1]?.content)).toContain("[history truncated]")
   })
+
+  it("hard-caps an oversized current user request while preserving its head and tail", () => {
+    const current = `任务目标:续写正文。${text(8_000, "中")}结尾限制:不要改变人物关系。`
+    const trimmed = trimChatMessagesToBudget([
+      { role: "system", content: text(500, "s") },
+      { role: "user", content: current },
+    ], 2_000)
+
+    expect(totalTextLength(trimmed)).toBeLessThanOrEqual(2_000)
+    expect(String(trimmed.at(-1)?.content)).toContain("任务目标")
+    expect(String(trimmed.at(-1)?.content)).toContain("不要改变人物关系")
+    expect(String(trimmed.at(-1)?.content)).toContain("内容已压缩")
+  })
+
+  it("drops assistant tool call and its tool results as one group", () => {
+    const messages: ChatMessage[] = [
+      { role: "system", content: text(200, "s") },
+      { role: "assistant", content: "", tool_calls: [{ id: "call-1", type: "function", function: { name: "read", arguments: "{}" } }] },
+      { role: "tool", content: text(4_000, "t"), tool_call_id: "call-1", name: "read" },
+      { role: "user", content: "继续完成任务" },
+    ]
+
+    const trimmed = trimChatMessagesToBudget(messages, 800)
+
+    expect(trimmed.some((message) => message.tool_call_id === "call-1")).toBe(false)
+    expect(trimmed.some((message) => message.tool_calls?.some((call) => call.id === "call-1"))).toBe(false)
+    expect(trimmed.at(-1)?.content).toBe("继续完成任务")
+  })
+
+  it("keeps the latest assistant tool call paired with a trailing tool result", () => {
+    const messages: ChatMessage[] = [
+      { role: "system", content: text(200, "s") },
+      { role: "user", content: "分析当前章节" },
+      { role: "assistant", content: "", tool_calls: [{ id: "call-latest", type: "function", function: { name: "read", arguments: "{}" } }] },
+      { role: "tool", content: text(4_000, "t"), tool_call_id: "call-latest", name: "read" },
+    ]
+
+    const trimmed = trimChatMessagesToBudget(messages, 800)
+
+    expect(trimmed.some((message) => message.tool_calls?.some((call) => call.id === "call-latest"))).toBe(true)
+    expect(trimmed.some((message) => message.tool_call_id === "call-latest")).toBe(true)
+    expect(trimmed.some((message) => message.role === "user" && message.content === "分析当前章节")).toBe(true)
+    expect(totalTextLength(trimmed)).toBeLessThanOrEqual(800)
+  })
+
+  it("counts large tool-call arguments when removing old tool protocol groups", () => {
+    const messages: ChatMessage[] = [
+      { role: "system", content: text(100, "s") },
+      { role: "user", content: "旧任务" },
+      {
+        role: "assistant",
+        content: "",
+        tool_calls: [{
+          id: "call-old",
+          type: "function",
+          function: { name: "write", arguments: JSON.stringify({ content: text(3_000, "a") }) },
+        }],
+      },
+      { role: "tool", content: "写入完成", tool_call_id: "call-old", name: "write" },
+      { role: "user", content: "现在只回答新的问题" },
+    ]
+
+    const trimmed = trimChatMessagesToBudget(messages, 500)
+
+    expect(trimmed.some((message) => message.tool_calls?.some((call) => call.id === "call-old"))).toBe(false)
+    expect(trimmed.some((message) => message.tool_call_id === "call-old")).toBe(false)
+    expect(trimmed.at(-1)?.content).toBe("现在只回答新的问题")
+  })
 })

+ 112 - 19
src/lib/chat-request-budget.ts

@@ -1,6 +1,7 @@
 import type { ChatMessage, ContentBlock } from "./llm-providers"
 
 const HISTORY_TRUNCATED_MARKER = "[history truncated]\n"
+const CONTENT_TRUNCATED_MARKER = "\n[内容已压缩,保留首尾]\n"
 
 function contentLength(content: ChatMessage["content"]): number {
   if (typeof content === "string") return content.length
@@ -11,7 +12,11 @@ function contentLength(content: ChatMessage["content"]): number {
 }
 
 function messageLength(message: ChatMessage): number {
-  return contentLength(message.content)
+  const toolArgumentsLength = message.tool_calls?.reduce(
+    (sum, call) => sum + call.function.arguments.length,
+    0,
+  ) ?? 0
+  return contentLength(message.content) + toolArgumentsLength
 }
 
 function totalLength(messages: ChatMessage[]): number {
@@ -26,8 +31,17 @@ function clampTail(text: string, maxChars: number): string {
   return HISTORY_TRUNCATED_MARKER + text.slice(-(maxChars - HISTORY_TRUNCATED_MARKER.length))
 }
 
-function trimContent(content: ChatMessage["content"], maxChars: number): ChatMessage["content"] {
-  if (typeof content === "string") return clampTail(content, maxChars)
+function clampHeadTail(text: string, maxChars: number): string {
+  if (text.length <= maxChars) return text
+  if (maxChars <= CONTENT_TRUNCATED_MARKER.length) return text.slice(0, maxChars)
+  const available = maxChars - CONTENT_TRUNCATED_MARKER.length
+  const head = Math.ceil(available * 0.55)
+  const tail = Math.max(0, available - head)
+  return `${text.slice(0, head)}${CONTENT_TRUNCATED_MARKER}${tail > 0 ? text.slice(-tail) : ""}`
+}
+
+function trimContent(content: ChatMessage["content"], maxChars: number, preserveHead = false): ChatMessage["content"] {
+  if (typeof content === "string") return preserveHead ? clampHeadTail(content, maxChars) : clampTail(content, maxChars)
 
   let remaining = maxChars
   const reversed: ContentBlock[] = []
@@ -43,7 +57,7 @@ function trimContent(content: ChatMessage["content"], maxChars: number): ChatMes
       continue
     }
 
-    const text = clampTail(block.text, remaining)
+    const text = preserveHead ? clampHeadTail(block.text, remaining) : clampTail(block.text, remaining)
     if (text.length > 0) {
       reversed.push({ ...block, text })
       remaining -= text.length
@@ -58,11 +72,53 @@ function isLeadingSystemMessage(messages: ChatMessage[], index: number): boolean
   return messages[index]?.role === "system" && messages.slice(0, index).every((message) => message.role === "system")
 }
 
-function trimMessage(message: ChatMessage, maxChars: number): ChatMessage {
+function trimMessage(message: ChatMessage, maxChars: number, preserveHead = false): ChatMessage {
+  const toolArgumentsLength = message.tool_calls?.reduce(
+    (sum, call) => sum + call.function.arguments.length,
+    0,
+  ) ?? 0
+  const contentBudget = Math.max(0, maxChars - toolArgumentsLength)
+  const content = trimContent(message.content, contentBudget, preserveHead)
+  let remainingArguments = Math.max(0, maxChars - contentLength(content))
+  const toolCalls = message.tool_calls?.map((call) => {
+    const argumentsValue = call.function.arguments
+    if (argumentsValue.length <= remainingArguments) {
+      remainingArguments -= argumentsValue.length
+      return call
+    }
+    const compactedArguments = remainingArguments >= 2 ? "{}" : argumentsValue.slice(0, remainingArguments)
+    remainingArguments = Math.max(0, remainingArguments - compactedArguments.length)
+    return {
+      ...call,
+      function: { ...call.function, arguments: compactedArguments },
+    }
+  })
   return {
     ...message,
-    content: trimContent(message.content, Math.max(0, maxChars)),
+    content,
+    ...(toolCalls ? { tool_calls: toolCalls } : {}),
+  }
+}
+
+function groupHistory(messages: ChatMessage[]): ChatMessage[][] {
+  const groups: ChatMessage[][] = []
+  for (let index = 0; index < messages.length; index += 1) {
+    const message = messages[index]!
+    if (message.role === "assistant" && message.tool_calls?.length) {
+      const callIds = new Set(message.tool_calls.map((call) => call.id))
+      const group = [message]
+      while (index + 1 < messages.length) {
+        const next = messages[index + 1]!
+        if (next.role !== "tool" || !next.tool_call_id || !callIds.has(next.tool_call_id)) break
+        group.push(next)
+        index += 1
+      }
+      groups.push(group)
+      continue
+    }
+    groups.push([message])
   }
+  return groups
 }
 
 /**
@@ -74,24 +130,52 @@ export function trimChatMessagesToBudget(messages: ChatMessage[], maxChars: numb
   if (!Number.isFinite(maxChars) || maxChars <= 0) return messages
   if (totalLength(messages) <= maxChars) return messages
 
-  let next = [...messages]
-
-  const canDrop = (message: ChatMessage, index: number) =>
-    index !== next.length - 1 && !isLeadingSystemMessage(next, index) && message.role !== "system"
+  const leadingSystems: ChatMessage[] = []
+  let firstNonSystem = 0
+  while (firstNonSystem < messages.length - 1 && messages[firstNonSystem]?.role === "system") {
+    leadingSystems.push(messages[firstNonSystem]!)
+    firstNonSystem += 1
+  }
+  const bodyGroups = groupHistory(messages.slice(firstNonSystem))
+  let latestUserGroup = -1
+  for (let index = bodyGroups.length - 1; index >= 0; index -= 1) {
+    if (bodyGroups[index]!.some((message) => message.role === "user")) {
+      latestUserGroup = index
+      break
+    }
+  }
+  const retainedGroups = bodyGroups.map((group, index) => ({
+    group,
+    protected: index === latestUserGroup || index === bodyGroups.length - 1,
+  }))
+  let next = [...leadingSystems, ...retainedGroups.flatMap((entry) => entry.group)]
 
   while (totalLength(next) > maxChars) {
-    const droppableIndices = next
-      .map((message, index) => ({ message, index }))
-      .filter(({ message, index }) => canDrop(message, index))
-
-    if (droppableIndices.length <= 1) break
-    next = next.filter((_message, index) => index !== droppableIndices[0]?.index)
+    const removableIndex = retainedGroups.findIndex((entry) => !entry.protected)
+    if (removableIndex < 0) break
+    const removableCount = retainedGroups.filter((entry) => !entry.protected).length
+    const removableGroup = retainedGroups[removableIndex]!.group
+    const containsToolProtocol = removableGroup.some(
+      (message) => message.role === "tool" || Boolean(message.tool_calls?.length),
+    )
+    if (removableCount === 1 && !containsToolProtocol) break
+    retainedGroups.splice(removableIndex, 1)
+    next = [...leadingSystems, ...retainedGroups.flatMap((entry) => entry.group)]
   }
 
   if (totalLength(next) <= maxChars) return next
 
-  for (let i = 0; i < next.length - 1 && totalLength(next) > maxChars; i += 1) {
-    if (isLeadingSystemMessage(next, i) || next[i]?.role === "system") continue
+  let latestUserIndex = -1
+  for (let index = next.length - 1; index >= 0; index -= 1) {
+    if (next[index]?.role === "user") {
+      latestUserIndex = index
+      break
+    }
+  }
+  if (latestUserIndex < 0) latestUserIndex = next.length - 1
+
+  for (let i = 0; i < next.length && totalLength(next) > maxChars; i += 1) {
+    if (i === latestUserIndex || isLeadingSystemMessage(next, i) || next[i]?.role === "system") continue
     const excess = totalLength(next) - maxChars
     const current = next[i]
     if (!current) continue
@@ -101,7 +185,8 @@ export function trimChatMessagesToBudget(messages: ChatMessage[], maxChars: numb
 
   if (totalLength(next) <= maxChars) return next
 
-  for (let i = 0; i < next.length - 1 && totalLength(next) > maxChars; i += 1) {
+  for (let i = 0; i < next.length && totalLength(next) > maxChars; i += 1) {
+    if (i === latestUserIndex) continue
     const current = next[i]
     if (!current) continue
     const excess = totalLength(next) - maxChars
@@ -109,5 +194,13 @@ export function trimChatMessagesToBudget(messages: ChatMessage[], maxChars: numb
     next[i] = trimMessage(current, targetLength)
   }
 
+  if (totalLength(next) <= maxChars) return next
+
+  const excess = totalLength(next) - maxChars
+  next[latestUserIndex] = trimMessage(
+    next[latestUserIndex]!,
+    Math.max(0, messageLength(next[latestUserIndex]!) - excess),
+    true,
+  )
   return next
 }

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

@@ -37,4 +37,11 @@ describe("AI chat context hub integration", () => {
       /removeLastAssistantMessage\(\)[\s\S]{0,900}setConversationContextSummary\(capturedConversationId, undefined\)[\s\S]{0,300}handleSend\(/,
     )
   })
+
+  it("passes project and conversation scope into global user memory", () => {
+    expect(source).toContain("userMemoryProjectKey: projectPath")
+    expect(source).toContain("userMemorySessionKey: capturedConvId")
+    expect(source).toContain("projectKey: projectPath")
+    expect(source).toContain("sessionKey: capturedConvId")
+  })
 })

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

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import { composeContext } from "./composer"
+import { estimateContextTokens } from "./token-estimator"
 
 function pack(overrides: Partial<ContextPack> = {}): ContextPack {
   return {
@@ -117,4 +118,27 @@ describe("composeContext", () => {
     expect(supplemented.stats.candidateTokens - base.stats.candidateTokens)
       .toBe(supplementedComposed - baseComposed)
   })
+
+  it("稳定核心和必需片段都不能突破总 Token 预算", () => {
+    const result = composeContext({
+      contextPack: pack({
+        soulDoc: "作品灵魂".repeat(1000),
+        canonRules: "硬规则".repeat(1000),
+        relatedSettings: "设定".repeat(1000),
+        outline: "大纲".repeat(2000),
+        task: "本轮任务".repeat(500),
+        mustDo: "必须做到".repeat(500),
+      }),
+      sessionSummary: "会话摘要".repeat(1000),
+      dependencies: {},
+      tokenBudget: 800,
+    })
+
+    expect(estimateContextTokens(result.stableCore) + estimateContextTokens(result.sessionSummary) + estimateContextTokens(result.dynamicContext))
+      .toBeLessThanOrEqual(800)
+    expect(result.dynamicContext).toContain("本轮任务")
+    expect(result.stats.budgetTokens).toBe(800)
+    expect(result.stats.composedTokens).toBeLessThanOrEqual(800)
+    expect(result.stats.utilizationPercent).toBeLessThanOrEqual(100)
+  })
 })

+ 69 - 6
src/lib/context-hub/composer.ts

@@ -95,7 +95,7 @@ function applyBudget(
   for (const fragment of fragments) {
     if (!fragment.text.trim()) continue
     const tokens = estimateContextTokens(section(fragment.title, fragment.text))
-    if (fragment.required || used + tokens <= availableTokens) {
+    if (used + tokens <= availableTokens) {
       selected.push(fragment)
       used += tokens
     }
@@ -103,18 +103,78 @@ function applyBudget(
   return selected
 }
 
+function truncateWithMarker(value: string, maxChars: number): string {
+  if (value.length <= maxChars) return value
+  const marker = "\n[内容已按上下文预算压缩]\n"
+  if (maxChars <= marker.length) return value.slice(0, maxChars)
+  const available = maxChars - marker.length
+  const head = Math.ceil(available * 0.6)
+  return `${value.slice(0, head)}${marker}${value.slice(-(available - head))}`
+}
+
+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
+  let low = 0
+  let high = fragment.text.length
+  while (low < high) {
+    const middle = Math.ceil((low + high) / 2)
+    const text = truncateWithMarker(fragment.text, middle)
+    if (estimateContextTokens(section(fragment.title, text)) <= tokenBudget) low = middle
+    else high = middle - 1
+  }
+  if (low <= 0) return null
+  return { ...fragment, text: truncateWithMarker(fragment.text, low) }
+}
+
+function fitPlainText(value: string, tokenBudget: number): string {
+  if (!value.trim() || tokenBudget <= 0) return ""
+  if (estimateContextTokens(value) <= tokenBudget) return value
+  let low = 0
+  let high = value.length
+  while (low < high) {
+    const middle = Math.ceil((low + high) / 2)
+    if (estimateContextTokens(truncateWithMarker(value, middle)) <= tokenBudget) low = middle
+    else high = middle - 1
+  }
+  return truncateWithMarker(value, low)
+}
+
+function fitFragmentsProportionally(fragments: ContextFragment[], tokenBudget: number): ContextFragment[] {
+  const available = fragments.filter((fragment) => fragment.text.trim())
+  if (estimateContextTokens(joinSections(available)) <= tokenBudget) return available
+  const result: 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))
+    }
+  }
+  return result
+}
+
 export function composeContext(input: ComposeContextInput): ComposedContext {
-  const stableCore = joinSections(stableFragments(input.contextPack))
-  const sessionSummary = input.sessionSummary?.trim() ?? ""
   const expanded = (input.confidence ?? 0.8) < 0.6
   const tokenBudget = Math.max(0, input.tokenBudget ?? 16_000)
+  const 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 stableTokens = estimateContextTokens(stableCore)
   const summaryTokens = estimateContextTokens(sessionSummary)
   const availableDynamicTokens = Math.max(0, tokenBudget - stableTokens - summaryTokens)
   const dynamicFragmentsForRequest = dynamicFragments(input, expanded)
-  const dynamicContext = joinSections(
-    applyBudget(dynamicFragmentsForRequest, availableDynamicTokens),
-  )
+  const requiredFragments = dynamicFragmentsForRequest.filter((fragment) => fragment.required)
+  const optionalFragments = dynamicFragmentsForRequest.filter((fragment) => !fragment.required)
+  const fittedRequired = fitFragmentsProportionally(requiredFragments, availableDynamicTokens)
+  const requiredTokens = estimateContextTokens(joinSections(fittedRequired))
+  const dynamicContext = joinSections([
+    ...fittedRequired,
+    ...applyBudget(optionalFragments, Math.max(0, availableDynamicTokens - requiredTokens)),
+  ])
   const dynamicTokens = estimateContextTokens(dynamicContext)
   const candidateTokens = estimateContextTokens(contextPackToPrompt(input.contextPack))
     + summaryTokens
@@ -142,6 +202,9 @@ export function composeContext(input: ComposeContextInput): ComposedContext {
       estimatedSavedPercent,
       expanded,
       providerCacheEnabled: stableCore.length > 0,
+      budgetTokens: tokenBudget,
+      composedTokens,
+      utilizationPercent: tokenBudget > 0 ? Math.min(100, Math.round((composedTokens / tokenBudget) * 100)) : 0,
     },
   }
 }

+ 23 - 0
src/lib/context-hub/provider-usage.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it, vi } from "vitest"
 import type { ContextHubResult, ContextHubSnapshotRef, ContextHubStats } from "./types"
 import { applyProviderUsageToStats, persistContextHubProviderUsage } from "./provider-usage"
+import type { UserMemoryDecision } from "@/lib/user-memory/decision-trace"
 
 const baseStats: ContextHubStats = {
   hits: 2,
@@ -32,6 +33,28 @@ describe("context hub provider usage", () => {
     })
   })
 
+  it("把用户记忆决策作为独立统计写入上下文中控", () => {
+    const decision: UserMemoryDecision = {
+      createdAt: 1,
+      surface: "ai-chat",
+      projectKey: "p1",
+      sessionKey: "s1",
+      candidateCount: 8,
+      selectedRuleIds: ["r1", "r2"],
+      filtered: [{ ruleId: "r3", reason: "candidate" }],
+      injectedChars: 240,
+      estimatedTokens: 60,
+    }
+
+    expect(applyProviderUsageToStats(baseStats, { inputTokens: 100 }, decision)).toMatchObject({
+      memoryCandidateCount: 8,
+      memorySelectedCount: 2,
+      memoryFilteredCount: 1,
+      memoryInjectedChars: 240,
+      memoryEstimatedTokens: 60,
+    })
+  })
+
   it("updates the persisted snapshot after the model response", async () => {
     const reference: ContextHubSnapshotRef = {
       id: "assistant:1",

+ 10 - 1
src/lib/context-hub/provider-usage.ts

@@ -1,4 +1,5 @@
 import type { LlmUsage } from "@/lib/llm-usage"
+import { getLatestUserMemoryDecision, type UserMemoryDecision } from "@/lib/user-memory/decision-trace"
 import type {
   ContextHub,
   ContextHubResult,
@@ -9,6 +10,7 @@ import type {
 export function applyProviderUsageToStats(
   stats: ContextHubStats,
   usage: LlmUsage,
+  memoryDecision?: UserMemoryDecision | null,
 ): ContextHubStats {
   return {
     ...stats,
@@ -18,6 +20,13 @@ export function applyProviderUsageToStats(
     ...(usage.cacheWriteInputTokens !== undefined
       ? { providerCacheWriteTokens: usage.cacheWriteInputTokens }
       : {}),
+    ...(memoryDecision ? {
+      memoryCandidateCount: memoryDecision.candidateCount,
+      memorySelectedCount: memoryDecision.selectedRuleIds.length,
+      memoryFilteredCount: memoryDecision.filtered.length,
+      memoryInjectedChars: memoryDecision.injectedChars,
+      memoryEstimatedTokens: memoryDecision.estimatedTokens,
+    } : {}),
   }
 }
 
@@ -28,6 +37,6 @@ export async function persistContextHubProviderUsage(
   usage: LlmUsage | undefined,
 ): Promise<ContextHubSnapshotRef | null> {
   if (!usage) return null
-  result.stats = applyProviderUsageToStats(result.stats, usage)
+  result.stats = applyProviderUsageToStats(result.stats, usage, getLatestUserMemoryDecision())
   return contextHub.saveSnapshot(snapshotId, result)
 }

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

@@ -57,4 +57,37 @@ describe("session context summary", () => {
     expect(selectContextHistoryMessages(messages, "")).toEqual(messages)
     expect(selectContextHistoryMessages(messages, undefined)).toEqual(messages)
   })
+
+  it("长对话始终保留首个用户任务目标和最近进展", () => {
+    const messages = [
+      { role: "user", content: "初始任务:写完整悬疑小说,禁止让主角提前知道真相。" },
+      ...Array.from({ length: 18 }, (_, index) => ({
+        role: index % 2 === 0 ? "assistant" : "user",
+        content: `中间消息 ${index + 1}`,
+      })),
+      { role: "assistant", content: "最近进展:已经完成第十章。" },
+    ]
+
+    const summary = buildSessionContextSummary({ messages, dependencies: {}, maxChars: 1000 })
+
+    expect(summary.text).toContain("初始任务")
+    expect(summary.text).toContain("禁止让主角提前知道真相")
+    expect(summary.text).toContain("最近进展")
+  })
+
+  it("摘要预算很小时仍同时保留初始任务和最新进展", () => {
+    const summary = buildSessionContextSummary({
+      messages: [
+        { role: "user", content: `初始任务:${"保持悬疑主线".repeat(80)}` },
+        { role: "assistant", content: "中间分析。".repeat(80) },
+        { role: "assistant", content: "最新进展:已经完成关键冲突设计。" },
+      ],
+      dependencies: {},
+      maxChars: 120,
+    })
+
+    expect(summary.text.length).toBeLessThanOrEqual(120)
+    expect(summary.text).toContain("初始任务")
+    expect(summary.text).toContain("最新进展")
+  })
 })

+ 40 - 9
src/lib/context-hub/session-summary.ts

@@ -39,20 +39,51 @@ function selectSentences(value: string, limit: number): string {
   return sentences.slice(0, limit).join("").trim()
 }
 
+function fitHeadTail(value: string, maxChars: number): string {
+  if (value.length <= maxChars) return value
+  if (maxChars <= 1) return value.slice(0, maxChars)
+  const available = maxChars - 1
+  const head = Math.ceil(available * 0.65)
+  return `${value.slice(0, head)}…${value.slice(-(available - head))}`
+}
+
+function fitRecentTail(value: string, maxChars: number): string {
+  if (value.length <= maxChars) return value
+  if (maxChars <= 0) return ""
+  if (maxChars === 1) return value.slice(-1)
+  return `…${value.slice(-(maxChars - 1))}`
+}
+
 export function buildSessionContextSummary(
   input: BuildSessionContextSummaryInput,
 ): SessionContextSummary {
   const maxChars = Math.max(0, input.maxChars ?? 4000)
-  const lines = input.messages
-    .filter((message) => message.role === "user" || message.role === "assistant")
-    .slice(-12)
-    .map((message) => {
-      const text = selectSentences(messageText(message.content), message.role === "user" ? 3 : 2)
-      if (!text) return ""
-      return `${message.role === "user" ? "用户" : "助手"}:${text}`
-    })
+  const eligible = input.messages.filter((message) => message.role === "user" || message.role === "assistant")
+  const firstUser = eligible.find((message) => message.role === "user")
+  const toLine = (message: SessionSummaryMessage): string => {
+    const text = selectSentences(messageText(message.content), message.role === "user" ? 3 : 2)
+    return text ? `${message.role === "user" ? "用户" : "助手"}:${text}` : ""
+  }
+  const firstLine = firstUser ? toLine(firstUser) : ""
+  const recentLines = eligible
+    .slice(-11)
+    .filter((message) => message !== firstUser)
+    .map(toLine)
     .filter(Boolean)
-  const text = lines.join("\n").slice(0, maxChars)
+  const recentText = recentLines.join("\n")
+  const fullText = [firstLine, recentText].filter(Boolean).join("\n")
+  let text = fullText
+  if (fullText.length > maxChars) {
+    if (firstLine && recentText && maxChars > 1) {
+      const firstBudget = Math.max(1, Math.floor((maxChars - 1) * 0.45))
+      const recentBudget = Math.max(0, maxChars - firstBudget - 1)
+      text = `${fitHeadTail(firstLine, firstBudget)}\n${fitRecentTail(recentText, recentBudget)}`
+    } else if (firstLine) {
+      text = fitHeadTail(firstLine, maxChars)
+    } else {
+      text = fitRecentTail(recentText, maxChars)
+    }
+  }
 
   return {
     text,

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

@@ -72,6 +72,14 @@ export interface ContextHubStats {
   providerInputTokens?: number
   providerCachedTokens?: number
   providerCacheWriteTokens?: number
+  budgetTokens?: number
+  composedTokens?: number
+  utilizationPercent?: number
+  memoryCandidateCount?: number
+  memorySelectedCount?: number
+  memoryFilteredCount?: number
+  memoryInjectedChars?: number
+  memoryEstimatedTokens?: number
 }
 
 export type ContextCacheItemStatus = "hit" | "refreshed" | "failed"

+ 17 - 4
src/lib/llm-client.ts

@@ -6,6 +6,7 @@ import { countReasoningCharsInLine, extractReasoningTextFromLine } from "./reaso
 import { resolveRuntimeLocalCliConfig } from "./local-cli-config"
 import { trimChatMessagesToBudget } from "./chat-request-budget"
 import { mergeLlmUsageSnapshot, type LlmUsage } from "./llm-usage"
+import { applyGlobalUserMemoryToMessages } from "./user-memory/request-integration"
 
 export type { ChatMessage, RequestOverrides } from "./llm-providers"
 export { isFetchNetworkError } from "./tauri-fetch"
@@ -134,6 +135,18 @@ export async function streamChat(
   requestOverrides?: RequestOverrides,
 ): Promise<void> {
   const runtimeConfig = await resolveRuntimeLocalCliConfig(config)
+  const preparedMessages = applyGlobalUserMemoryToMessages(messages, requestOverrides)
+  const configuredWindow = Number.isFinite(runtimeConfig.maxContextSize) && runtimeConfig.maxContextSize > 0
+    ? runtimeConfig.maxContextSize
+    : 204_800
+  const outputReserveChars = requestOverrides?.max_tokens
+    ? Math.max(0, requestOverrides.max_tokens * 4)
+    : Math.floor(configuredWindow * 0.15)
+  const requestInputBudget = Math.max(1, Math.min(
+    Math.floor(configuredWindow * 0.85),
+    configuredWindow - outputReserveChars,
+  ))
+  const budgetedMessages = trimChatMessagesToBudget(preparedMessages, requestInputBudget)
   const { onToken, onDone, onError } = callbacks
   const decoder = new TextDecoder()
 
@@ -141,11 +154,11 @@ export async function streamChat(
   // HTTP. Dispatch before getProviderConfig — that function throws for
   // this provider because it has no URL/headers.
   if (runtimeConfig.provider === "claude-code") {
-    return streamViaClaudeCodeCli(runtimeConfig, messages, callbacks, signal, requestOverrides)
+    return streamViaClaudeCodeCli(runtimeConfig, budgetedMessages, callbacks, signal, requestOverrides)
   }
 
   if (runtimeConfig.provider === "codex-cli") {
-    return streamViaCodexCli(runtimeConfig, messages, callbacks, signal, requestOverrides)
+    return streamViaCodexCli(runtimeConfig, budgetedMessages, callbacks, signal, requestOverrides)
   }
 
   const providerConfig = getProviderConfig(runtimeConfig)
@@ -212,7 +225,7 @@ export async function streamChat(
       }
     }
 
-    let requestInit = buildRequestInit(messages)
+    let requestInit = buildRequestInit(budgetedMessages)
     let response: Response
     try {
       response = await sendRequest(requestInit)
@@ -259,7 +272,7 @@ export async function streamChat(
       const inputLimit = parseInputLengthLimit(errorDetail)
       if (inputLimit) {
         const retryRequestInit = buildRequestInit(
-          trimChatMessagesToBudget(messages, Math.floor(inputLimit.maxLength * 0.85)),
+          trimChatMessagesToBudget(budgetedMessages, Math.floor(inputLimit.maxLength * 0.85)),
         )
         if (retryRequestInit.body === requestInit.body) {
           onError(new Error(inputLengthLimitMessage(inputLimit)))

+ 24 - 0
src/lib/llm-client.usage.spec.ts

@@ -69,4 +69,28 @@ describe("streamChat usage", () => {
     expect(onDone).toHaveBeenCalledOnce()
     expect(onError).not.toHaveBeenCalled()
   })
+
+  it("发送前把总输入限制在模型窗口的 85%", async () => {
+    mocks.fetch.mockResolvedValue(new Response([
+      'data: {"choices":[{"delta":{"content":"完成"}}]}',
+      "data: [DONE]",
+      "",
+    ].join("\n"), { status: 200 }))
+
+    await streamChat({ ...config, maxContextSize: 1_000 }, [
+      { role: "system", content: "系统".repeat(450) },
+      { role: "user", content: `任务目标:续写。${"正文".repeat(450)}结尾限制:保持人物关系。` },
+    ], {
+      onToken: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    })
+
+    const request = mocks.fetch.mock.calls[0][1] as RequestInit
+    const body = JSON.parse(String(request.body)) as { messages: Array<{ content: string }> }
+    const total = body.messages.reduce((sum, message) => sum + message.content.length, 0)
+    expect(total).toBeLessThanOrEqual(850)
+    expect(body.messages.at(-1)?.content).toContain("任务目标")
+    expect(body.messages.at(-1)?.content).toContain("保持人物关系")
+  })
 })

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

@@ -60,6 +60,32 @@ describe("llm provider reasoning options", () => {
   })
 })
 
+describe("internal request overrides", () => {
+  it.each([
+    ["OpenAI-compatible", customConfig()],
+    ["Responses API", customConfig({ apiMode: "responses" })],
+    ["Anthropic Messages", customConfig({ apiMode: "anthropic_messages" })],
+    ["Gemini", customConfig({ provider: "google", model: "gemini-2.5-pro" })],
+  ])("does not send user-memory control fields through %s", (_label, config) => {
+    const body = getProviderConfig(config).buildBody(
+      [{ role: "user", content: "测试请求" }],
+      {
+        temperature: 0.2,
+        skipUserMemory: true,
+        userMemorySurface: "ai-chat",
+        userMemoryProjectKey: "project-1",
+        userMemorySessionKey: "session-1",
+      },
+    ) as Record<string, unknown>
+    const serialized = JSON.stringify(body)
+
+    expect(serialized).not.toContain("skipUserMemory")
+    expect(serialized).not.toContain("userMemorySurface")
+    expect(serialized).not.toContain("userMemoryProjectKey")
+    expect(serialized).not.toContain("userMemorySessionKey")
+  })
+})
+
 describe("custom provider headers", () => {
   it("clears Origin for remote custom gateways", () => {
     expect(getCustomCompatibleHeaders("sk-test", "https://example.test/v1/chat/completions")).toMatchObject({

+ 18 - 2
src/lib/llm-providers.ts

@@ -6,6 +6,7 @@ import {
 } from "@/lib/azure-openai"
 import { normalizeEndpoint } from "@/lib/endpoint-normalizer"
 import type { LlmUsage } from "./llm-usage"
+import type { UserMemorySurface } from "./user-memory/types"
 
 /**
  * One piece of a multimodal message body. Text + image is the only
@@ -71,6 +72,14 @@ export interface RequestOverrides {
   reasoning?: ReasoningConfig
   tools?: { type: string; function: { name: string; description: string; parameters: object } }[]
   toolChoice?: "auto" | "none"
+  /** Internal: prevent recursive global-user-memory injection for the memory extractor itself. */
+  skipUserMemory?: boolean
+  /** Internal: explicit task surface for global-user-memory selection. */
+  userMemorySurface?: UserMemorySurface
+  /** Internal: project scope key for layered user-memory selection. */
+  userMemoryProjectKey?: string
+  /** Internal: conversation/session scope key for layered user-memory selection. */
+  userMemorySessionKey?: string
 }
 
 interface ProviderConfig {
@@ -468,8 +477,15 @@ function buildResponsesBody(
   return body
 }
 
-function stripWireAgnosticOverrides(overrides?: RequestOverrides): Omit<RequestOverrides, "reasoning"> {
-  const { reasoning: _reasoning, ...rest } = overrides ?? {}
+function stripWireAgnosticOverrides(overrides?: RequestOverrides): Omit<RequestOverrides, "reasoning" | "skipUserMemory" | "userMemorySurface" | "userMemoryProjectKey" | "userMemorySessionKey"> {
+  const {
+    reasoning: _reasoning,
+    skipUserMemory: _skipUserMemory,
+    userMemorySurface: _userMemorySurface,
+    userMemoryProjectKey: _userMemoryProjectKey,
+    userMemorySessionKey: _userMemorySessionKey,
+    ...rest
+  } = overrides ?? {}
   return rest
 }
 

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

@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest"
+import type { ContextPack } from "./context-engine"
+import { buildScopedOutlineSubAgentContext } from "./outline-agent-context"
+
+const pack: ContextPack = {
+  task: "生成大纲",
+  chapterGoal: "",
+  outline: "主线大纲",
+  recentChapterContents: [],
+  recentSummaries: ["最近摘要"],
+  previousChapterEnding: "",
+  characterStates: "角色当前状态",
+  soulDoc: "作品灵魂",
+  characterAuras: "角色气质",
+  cognitionStates: "角色认知",
+  foreshadowingStates: "伏笔状态",
+  sectionBriefing: "",
+  timeline: "故事时间线",
+  relatedSettings: "完整世界设定",
+  canonRules: "世界硬规则",
+  writingStyle: "",
+  searchResults: "",
+  graphSearchResults: "",
+  mustDo: "",
+  mustAvoid: "",
+  nextChapterAdvice: "",
+  revisionDirectives: "",
+}
+
+describe("scoped outline sub-agent context", () => {
+  it("角色 Agent 只获得人物和主线相关上下文", () => {
+    const context = buildScopedOutlineSubAgentContext(pack, "character")
+
+    expect(context).toContain("角色当前状态")
+    expect(context).toContain("角色认知")
+    expect(context).toContain("主线大纲")
+    expect(context).not.toContain("完整世界设定")
+  })
+
+  it("设定 Agent 获得世界规则但不携带角色认知", () => {
+    const context = buildScopedOutlineSubAgentContext(pack, "setting")
+
+    expect(context).toContain("世界硬规则")
+    expect(context).toContain("完整世界设定")
+    expect(context).not.toContain("角色认知")
+  })
+
+  it("局部上下文受字符预算限制", () => {
+    const context = buildScopedOutlineSubAgentContext({ ...pack, outline: "大纲".repeat(5000) }, "outline", 1200)
+    expect(context.length).toBeLessThanOrEqual(1200)
+  })
+})

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

@@ -0,0 +1,53 @@
+import type { ContextPack } from "./context-engine"
+import type { OutlineSubAgentKind } from "./outline-multi-agent-orchestrator"
+
+function section(title: string, value: string): string {
+  return value.trim() ? `## ${title}\n${value.trim()}` : ""
+}
+
+function clampContext(value: string, maxChars: number): string {
+  if (value.length <= maxChars) return value
+  const marker = "\n\n[局部上下文已压缩]\n\n"
+  if (maxChars <= marker.length) return value.slice(0, maxChars)
+  const available = maxChars - marker.length
+  const head = Math.ceil(available * 0.65)
+  return `${value.slice(0, head)}${marker}${value.slice(-(available - head))}`
+}
+
+export function buildScopedOutlineSubAgentContext(
+  pack: ContextPack,
+  kind: OutlineSubAgentKind,
+  maxChars = 8_000,
+): string {
+  const common = [section("本轮任务", pack.task), section("作品灵魂", pack.soulDoc), section("主线大纲", pack.outline)]
+  const specific: Record<OutlineSubAgentKind, string[]> = {
+    outline: [
+      section("最近摘要", pack.recentSummaries.slice(-3).join("\n")),
+      section("世界硬规则", pack.canonRules),
+      section("伏笔状态", pack.foreshadowingStates),
+    ],
+    topic: [
+      section("核心设定", pack.relatedSettings),
+      section("世界硬规则", pack.canonRules),
+      section("故事时间线", pack.timeline),
+    ],
+    character: [
+      section("角色当前状态", pack.characterStates),
+      section("角色认知", pack.cognitionStates),
+      section("角色气质", pack.characterAuras),
+      section("最近摘要", pack.recentSummaries.slice(-3).join("\n")),
+    ],
+    setting: [
+      section("核心设定", pack.relatedSettings),
+      section("世界硬规则", pack.canonRules),
+      section("故事时间线", pack.timeline),
+    ],
+    foreshadowing: [
+      section("伏笔状态", pack.foreshadowingStates),
+      section("故事时间线", pack.timeline),
+      section("最近摘要", pack.recentSummaries.slice(-3).join("\n")),
+      section("必须避免", pack.mustAvoid),
+    ],
+  }
+  return clampContext([...common, ...specific[kind]].filter(Boolean).join("\n\n"), Math.max(0, maxChars))
+}

+ 12 - 3
src/lib/novel/outline-dynamic-agent-planner.spec.ts

@@ -26,7 +26,7 @@ describe("动态大纲 Agent 规划器", () => {
     expect(prompt).toContain("检查世界规则")
     expect(prompt).toContain("planning")
     expect(prompt).toContain("knowledge")
-    expect(prompt).toContain("最多 12")
+    expect(prompt).toContain("最多 5")
     expect(prompt).toContain("最多同时运行 3")
   })
 
@@ -69,7 +69,7 @@ describe("动态大纲 Agent 规划器", () => {
     expect(result.plan[1].finalReview).toBe(true)
   })
 
-  it("拒绝使用不存在 Skill、循环依赖或超过 12 个任务的规划结果", () => {
+  it("拒绝使用不存在 Skill、循环依赖或超过 5 个任务的规划结果", () => {
     const unknownSkill = parseDynamicOutlinePlan(JSON.stringify({ tasks: [{
       id: "a", name: "A", dimension: "A", skillNames: ["不存在"], taskPrompt: "A",
     }] }), ["人物设计"])
@@ -81,9 +81,18 @@ describe("动态大纲 Agent 规划器", () => {
     ] }))
     expect(cycle.ok).toBe(false)
 
-    const tooMany = parseDynamicOutlinePlan(JSON.stringify({ tasks: Array.from({ length: 13 }, (_, index) => ({
+    const tooMany = parseDynamicOutlinePlan(JSON.stringify({ tasks: Array.from({ length: 6 }, (_, index) => ({
       id: `a${index}`, name: `A${index}`, dimension: `D${index}`, skillNames: [], taskPrompt: "执行",
     })) }))
     expect(tooMany.ok).toBe(false)
   })
+
+  it("拒绝为简单局部调整规划多个 Agent", () => {
+    const result = parseDynamicOutlinePlan(JSON.stringify({ tasks: [
+      { id: "a", name: "标题 Agent", dimension: "标题", skillNames: [], taskPrompt: "缩短标题" },
+      { id: "b", name: "审查 Agent", dimension: "审查", skillNames: [], taskPrompt: "审查标题" },
+    ] }), undefined, "把当前标题改短一些")
+
+    expect(result.ok).toBe(false)
+  })
 })

+ 6 - 1
src/lib/novel/outline-dynamic-agent-planner.ts

@@ -1,4 +1,5 @@
 import {
+  isSimpleOutlineTask,
   validateOutlineSubAgentPlan,
   type OutlineSubAgentKind,
   type OutlineSubAgentPlan,
@@ -27,7 +28,7 @@ export function buildDynamicOutlinePlannerPrompt(context: DynamicOutlinePlannerC
   return [
     "你是 AI 大纲多 Agent 动态规划器。只输出 JSON,不要输出解释。",
     "请根据任务、项目现状和全部可用 Skill 规划独立任务,而不是按固定类别机械分组。",
-    "最多 12 个 Agent,最多同时运行 3 个 Agent。最终审查任务必须依赖需要审查的前置任务。",
+    "最多 5 个 Agent,最多同时运行 3 个 Agent。简单局部任务只规划 1 个 Agent;最终审查任务必须依赖需要审查的前置任务。",
     "每个任务包含 id、中文 name、dimension、skillNames、taskPrompt、dependencies、priority、finalReview。",
     "依赖失败后下游仍会继续,因此任务提示词必须能接受缺失维度风险。",
     "",
@@ -62,6 +63,7 @@ export function buildDynamicOutlinePlannerPrompt(context: DynamicOutlinePlannerC
 export function parseDynamicOutlinePlan(
   text: string,
   availableSkillNames?: string[],
+  userTask?: string,
 ): DynamicOutlinePlanParseResult {
   const jsonText = extractJsonObject(text)
   if (!jsonText) return { ok: false, error: "规划器未返回 JSON 对象" }
@@ -111,6 +113,9 @@ export function parseDynamicOutlinePlan(
 
   const validation = validateOutlineSubAgentPlan(plan)
   if (!validation.ok) return { ok: false, error: validation.errors.join(";") }
+  if (userTask && isSimpleOutlineTask(userTask) && plan.length > 1) {
+    return { ok: false, error: "简单局部任务只能规划 1 个 Agent" }
+  }
   return { ok: true, plan }
 }
 

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

@@ -1,9 +1,12 @@
 import { describe, expect, it } from "vitest"
 import {
+  buildBoundedSubAgentMergePayload,
   planOutlineSubAgents,
+  resumeOutlineMultiAgentWorkflow,
   runOutlineMultiAgentWorkflow,
   type OutlineSubAgentPlan,
 } from "./outline-multi-agent-orchestrator"
+import type { OutlineSubAgentResult } from "./outline-result-protocol"
 
 const baseSkillNames = [
   "outline-master-builder",
@@ -111,6 +114,92 @@ describe("AI大纲多 Agent 编排器", () => {
     expect(result.finalText).toBe("单 Agent 兜底结果")
     expect(result.fallbackReason).toContain("合并 Agent 失败")
   })
+
+  it("简单调整任务只使用一个 Agent", () => {
+    const plan = planOutlineSubAgents({
+      preferredSkillNames: baseSkillNames,
+      taskPrompt: "把当前标题改短一些",
+    })
+
+    expect(plan).toHaveLength(1)
+  })
+
+  it("将成功依赖的结构化结论传给下游 Agent", async () => {
+    const parent = { ...makePlan("outline"), id: "parent" }
+    const child = { ...makePlan("character"), id: "child", dependencies: ["parent"] }
+    let childPrompt = ""
+
+    await runOutlineMultiAgentWorkflow({
+      plan: [parent, child],
+      maxConcurrency: 2,
+      runSubAgent: async (item) => {
+        if (item.id === "child") childPrompt = item.taskPrompt
+        return makeSubAgentJson(item.id, item.name)
+      },
+      runSingleAgentFallback: async () => "fallback",
+      mergeResults: async () => "merged",
+    })
+
+    expect(childPrompt).toContain("上游依赖结论")
+    expect(childPrompt).toContain("完成")
+  })
+
+  it("续传失败 Agent 时复用已完成的上游依赖", async () => {
+    const parent = { ...makePlan("outline"), id: "parent" }
+    const child = { ...makePlan("character"), id: "child", dependencies: ["parent"] }
+    const completedParent: OutlineSubAgentResult = {
+      agentId: "parent",
+      agentName: parent.name,
+      stage: "outline",
+      usedSkills: [],
+      confidence: 0.9,
+      summary: "上游大纲已经完成",
+      contentMarkdown: "## 已完成的大纲",
+      constraints: ["保持主线"],
+      writebackItems: [],
+      risks: [],
+      questions: [],
+    }
+    let resumedPrompt = ""
+
+    const result = await resumeOutlineMultiAgentWorkflow({
+      plan: [parent, child],
+      completedResults: [completedParent],
+      failedAgentIds: ["child"],
+      runSubAgent: async (item) => {
+        resumedPrompt = item.taskPrompt
+        return makeSubAgentJson(item.id, item.name)
+      },
+      mergeResults: async (items) => `合并数量:${items.length}`,
+    })
+
+    expect(resumedPrompt).toContain("上游大纲已经完成")
+    expect(result.finalText).toBe("合并数量:2")
+    expect(result.successfulAgents).toEqual(["parent", "child"])
+  })
+
+  it("合并载荷按预算压缩完整子 Agent 输出", () => {
+    const results = Array.from({ length: 5 }, (_, index) => ({
+      agentId: `a${index}`,
+      agentName: `Agent ${index}`,
+      stage: "planning",
+      usedSkills: [],
+      confidence: 0.8,
+      summary: `总结 ${index}`,
+      contentMarkdown: `内容 ${index}`.repeat(3000),
+      constraints: ["保持一致"],
+      writebackItems: [],
+      risks: ["存在冲突"],
+      questions: [],
+    }))
+
+    const payload = buildBoundedSubAgentMergePayload(results, 6000)
+
+    expect(payload.length).toBeLessThanOrEqual(6000)
+    expect(payload).toContain("总结 0")
+    expect(payload).toContain("总结 4")
+    expect(payload).toContain("冲突与风险")
+  })
 })
 
 function makePlan(kind: OutlineSubAgentPlan["kind"]): OutlineSubAgentPlan {

+ 99 - 19
src/lib/novel/outline-multi-agent-orchestrator.ts

@@ -77,10 +77,31 @@ const KIND_ORDER: OutlineSubAgentKind[] = [
   "foreshadowing",
 ]
 
-const MAX_AGENT_TASKS = 12
+const MAX_AGENT_TASKS = 5
 const DEFAULT_MAX_CONCURRENCY = 3
 
+export function isSimpleOutlineTask(task: string): boolean {
+  const value = task.trim()
+  return value.length <= 40
+    && /修改|调整|改短|改长|润色|改名|补一句|删除|替换/.test(value)
+    && !/完整|全本|长篇|重做|重新生成|世界观|人物关系|多线|多卷/.test(value)
+}
+
 export function planOutlineSubAgents(input: OutlineMultiAgentPlanInput): OutlineSubAgentPlan[] {
+  if (isSimpleOutlineTask(input.taskPrompt)) {
+    return [{
+      id: "outline-agent",
+      name: subAgentName("outline"),
+      kind: "outline",
+      dimension: "局部调整",
+      skillNames: input.preferredSkillNames.slice(0, 2),
+      taskPrompt: buildSubAgentTaskPrompt("outline", input.taskPrompt, input.preferredSkillNames.slice(0, 2)),
+      dependencies: [],
+      priority: 1,
+      finalReview: false,
+      writeToolsEnabled: false,
+    }]
+  }
   const grouped = new Map<OutlineSubAgentKind, string[]>()
   for (const name of input.preferredSkillNames) {
     const kind = inferSubAgentKind(name)
@@ -212,8 +233,9 @@ async function runDependencyGraph(
   plan: OutlineSubAgentPlan[],
   maxConcurrency: number,
   input: OutlineMultiAgentRunInput,
+  initialOutcomes: Map<string, AgentOutcome> = new Map(),
 ): Promise<Map<string, AgentOutcome>> {
-  const outcomes = new Map<string, AgentOutcome>()
+  const outcomes = new Map<string, AgentOutcome>(initialOutcomes)
   const running = new Map<string, Promise<void>>()
   const order = new Map(plan.map((task, index) => [task.id, index]))
   const pending = new Set(plan.map((task) => task.id))
@@ -233,7 +255,20 @@ async function runDependencyGraph(
         .finally(() => { running.delete(task.id) })
       running.set(task.id, promise)
     }
-    if (running.size > 0) await Promise.race(running.values())
+    if (running.size > 0) {
+      await Promise.race(running.values())
+      continue
+    }
+    if (pending.size > 0) {
+      for (const id of pending) {
+        const task = plan.find((item) => item.id === id)
+        const missing = (task?.dependencies ?? []).filter((dependency) => !outcomes.has(dependency))
+        const error = `依赖未满足:${missing.join("、") || "未知依赖"}`
+        outcomes.set(id, { error })
+        input.onStatusChange?.({ agentId: id, status: "failed", attempt: 0, error })
+      }
+      pending.clear()
+    }
   }
 
   return outcomes
@@ -247,20 +282,34 @@ async function executeWithRetry(
   const dependencyFailures = (task.dependencies ?? [])
     .map((id) => ({ id, outcome: outcomes.get(id) }))
     .filter(({ outcome }) => outcome?.error)
-  const runnableTask: OutlineSubAgentPlan = dependencyFailures.length === 0
-    ? task
-    : {
-        ...task,
-        taskPrompt: [
-          task.taskPrompt,
-          "",
-          "## ?????????",
-          ...dependencyFailures.map(({ id, outcome }) => {
-            const dependencyTask = input.plan.find((item) => item.id === id)
-            return `- ${dependencyTask?.dimension || dependencyTask?.name || id}?${outcome?.error}`
-          }),
-        ].join("\n"),
-      }
+  const dependencyResults = (task.dependencies ?? [])
+    .map((id) => ({ id, outcome: outcomes.get(id) }))
+    .filter((item): item is { id: string; outcome: AgentOutcome & { result: OutlineSubAgentResult } } => Boolean(item.outcome?.result))
+  const dependencyContext = [
+    ...(dependencyResults.length > 0 ? [
+      "## 上游依赖结论",
+      ...dependencyResults.map(({ id, outcome }) => {
+        const dependencyTask = input.plan.find((item) => item.id === id)
+        const result = outcome.result
+        return [
+          `### ${dependencyTask?.dimension || dependencyTask?.name || id}`,
+          `结论:${result.summary}`,
+          result.constraints.length > 0 ? `约束:${result.constraints.join(";")}` : "",
+          result.contentMarkdown.slice(0, 1600),
+        ].filter(Boolean).join("\n")
+      }),
+    ] : []),
+    ...(dependencyFailures.length > 0 ? [
+      "## 缺失依赖风险",
+      ...dependencyFailures.map(({ id, outcome }) => {
+        const dependencyTask = input.plan.find((item) => item.id === id)
+        return `- ${dependencyTask?.dimension || dependencyTask?.name || id}:${outcome?.error}`
+      }),
+    ] : []),
+  ]
+  const runnableTask: OutlineSubAgentPlan = dependencyContext.length > 0
+    ? { ...task, taskPrompt: [task.taskPrompt, "", ...dependencyContext].join("\n") }
+    : task
 
   let lastError = "执行失败"
   for (let attempt = 1; attempt <= 2; attempt += 1) {
@@ -293,6 +342,34 @@ function formatError(error: unknown): string {
   return error instanceof Error ? error.message : String(error)
 }
 
+function truncateMergeContent(value: string, maxChars: number): string {
+  if (value.length <= maxChars) return value
+  const marker = "\n[子 Agent 内容已压缩]\n"
+  if (maxChars <= marker.length) return value.slice(0, maxChars)
+  const available = maxChars - marker.length
+  const head = Math.ceil(available * 0.65)
+  return `${value.slice(0, head)}${marker}${value.slice(-(available - head))}`
+}
+
+export function buildBoundedSubAgentMergePayload(results: OutlineSubAgentResult[], maxChars = 24_000): string {
+  const limit = Math.max(0, Math.floor(maxChars))
+  if (limit === 0 || results.length === 0) return ""
+  const fixedSections = results.map((result) => [
+    `## ${result.agentName}`,
+    `摘要:${result.summary}`,
+    `置信度:${result.confidence}`,
+    result.constraints.length > 0 ? `约束:${result.constraints.join(";")}` : "",
+    result.risks.length > 0 ? `冲突与风险:${result.risks.join(";")}` : "冲突与风险:无",
+  ].filter(Boolean).join("\n"))
+  const fixedLength = fixedSections.reduce((sum, section) => sum + section.length + 2, 0)
+  const contentShare = Math.max(0, Math.floor((limit - fixedLength) / results.length))
+  const payload = results.map((result, index) => [
+    fixedSections[index],
+    contentShare > 0 ? truncateMergeContent(result.contentMarkdown, contentShare) : "",
+  ].filter(Boolean).join("\n")).join("\n\n")
+  return payload.slice(0, limit)
+}
+
 function inferSubAgentKind(skillName: string): OutlineSubAgentKind {
   if (/outline|story-|goal|protagonist|worldbuilding-outline/i.test(skillName)) return "outline"
   if (/male-|female-|rule-|zhihu|family|farming|western|entertainment/i.test(skillName)) return "topic"
@@ -395,7 +472,7 @@ export async function resumeOutlineMultiAgentWorkflow(
 
   // 仅执行失败 Agent
   const resumeInput: OutlineMultiAgentRunInput = {
-    plan: failedPlan,
+    plan: input.plan,
     maxConcurrency,
     runSubAgent: input.runSubAgent,
     runSingleAgentFallback: async () => "",
@@ -403,7 +480,10 @@ export async function resumeOutlineMultiAgentWorkflow(
     onStatusChange: input.onStatusChange,
   }
 
-  const outcomes = await runDependencyGraph(failedPlan, maxConcurrency, resumeInput)
+  const completedOutcomes = new Map<string, AgentOutcome>(
+    input.completedResults.map((result) => [result.agentId, { result }]),
+  )
+  const outcomes = await runDependencyGraph(failedPlan, maxConcurrency, resumeInput, completedOutcomes)
 
   // 收集本次重试的结果
   const retriedSuccessful = failedPlan

+ 32 - 0
src/lib/user-memory/compiler.spec.ts

@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest"
+import type { UserMemoryRule } from "./types"
+import { compileUserMemorySkill } from "./compiler"
+
+function rule(id: string, text: string, source: "manual" | "automatic", confidence: number): UserMemoryRule {
+  return {
+    id,
+    rule: text,
+    category: "interaction_preference",
+    source,
+    surfaces: ["all"],
+    confidence,
+    evidenceSummary: "",
+    sourceHash: null,
+    fingerprint: id,
+    enabled: true,
+    createdAt: 1,
+    updatedAt: 1,
+  }
+}
+
+describe("user memory compiler", () => {
+  it("手动规则优先并限制最终提示长度", () => {
+    const prompt = compileUserMemorySkill([
+      rule("auto", "自动规则".repeat(300), "automatic", 0.9),
+      rule("manual", "手动规则优先。", "manual", 1),
+    ], 180)
+
+    expect(prompt).toContain("手动规则优先")
+    expect(prompt.length).toBeLessThanOrEqual(180)
+  })
+})

+ 30 - 0
src/lib/user-memory/compiler.ts

@@ -0,0 +1,30 @@
+import type { UserMemoryRule } from "./types"
+
+export function compileUserMemorySkill(rules: UserMemoryRule[], maxChars = 3000): string {
+  const limit = Math.max(0, Math.floor(maxChars))
+  if (rules.length === 0 || limit === 0) return ""
+  const header = [
+    "## 全局用户规则",
+    "以下规则来自用户长期习惯。当前用户请求与历史规则冲突时,以当前请求为准。",
+  ].join("\n")
+  if (header.length >= limit) return header.slice(0, limit)
+  let result = header
+  const sorted = [...rules].sort((left, right) => {
+    const source = Number(right.source === "manual") - Number(left.source === "manual")
+    return source || right.confidence - left.confidence
+  })
+  for (const rule of sorted) {
+    const prefix = rule.source === "manual" ? "- [用户手动规则] " : "- "
+    const line = `\n${prefix}${rule.rule}`
+    if (result.length + line.length <= limit) {
+      result += line
+      continue
+    }
+    const remaining = limit - result.length
+    if (remaining > prefix.length + 2) {
+      result += `${line.slice(0, Math.max(0, remaining - 1))}…`
+    }
+    break
+  }
+  return result.slice(0, limit)
+}

+ 27 - 0
src/lib/user-memory/decision-trace.spec.ts

@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest"
+import { buildUserMemoryDecision } from "./decision-trace"
+import type { UserMemoryRule } from "./types"
+
+const baseRule: UserMemoryRule = {
+  id: "r1", rule: "回答时先给结论。", category: "interaction_preference", source: "manual",
+  surfaces: ["all"], confidence: 1, evidenceSummary: "", sourceHash: null,
+  fingerprint: "r1", enabled: true, createdAt: 1, updatedAt: 1, scope: "global", status: "active",
+}
+
+describe("user memory decision trace", () => {
+  it("记录候选、命中、过滤原因和注入成本", () => {
+    const decision = buildUserMemoryDecision({
+      rules: [baseRule, { ...baseRule, id: "r2", status: "candidate" }],
+      selected: [baseRule],
+      filtered: [{ ruleId: "r2", reason: "candidate" }],
+      prompt: "全局用户规则".repeat(10),
+      surface: "ai-chat",
+      projectKey: "p1",
+      sessionKey: "s1",
+    })
+
+    expect(decision).toMatchObject({ candidateCount: 2, selectedRuleIds: ["r1"], injectedChars: 60 })
+    expect(decision.estimatedTokens).toBeGreaterThan(0)
+    expect(decision.filtered[0]).toEqual({ ruleId: "r2", reason: "candidate" })
+  })
+})

+ 52 - 0
src/lib/user-memory/decision-trace.ts

@@ -0,0 +1,52 @@
+import type { UserMemoryFilterReason } from "./selector"
+import type { UserMemoryRule, UserMemorySurface } from "./types"
+
+export interface UserMemoryDecisionFilter {
+  ruleId: string
+  reason: UserMemoryFilterReason
+}
+
+export interface UserMemoryDecision {
+  createdAt: number
+  surface: UserMemorySurface
+  projectKey: string | null
+  sessionKey: string | null
+  candidateCount: number
+  selectedRuleIds: string[]
+  filtered: UserMemoryDecisionFilter[]
+  injectedChars: number
+  estimatedTokens: number
+}
+
+let latestDecision: UserMemoryDecision | null = null
+
+export function buildUserMemoryDecision(input: {
+  rules: UserMemoryRule[]
+  selected: UserMemoryRule[]
+  filtered: UserMemoryDecisionFilter[]
+  prompt: string
+  surface: UserMemorySurface
+  projectKey?: string
+  sessionKey?: string
+  now?: number
+}): UserMemoryDecision {
+  return {
+    createdAt: input.now ?? Date.now(),
+    surface: input.surface,
+    projectKey: input.projectKey ?? null,
+    sessionKey: input.sessionKey ?? null,
+    candidateCount: input.rules.length,
+    selectedRuleIds: input.selected.map((rule) => rule.id),
+    filtered: input.filtered,
+    injectedChars: input.prompt.length,
+    estimatedTokens: Math.ceil(input.prompt.length / 4),
+  }
+}
+
+export function setLatestUserMemoryDecision(decision: UserMemoryDecision | null): void {
+  latestDecision = decision
+}
+
+export function getLatestUserMemoryDecision(): UserMemoryDecision | null {
+  return latestDecision ? { ...latestDecision, selectedRuleIds: [...latestDecision.selectedRuleIds], filtered: [...latestDecision.filtered] } : null
+}

+ 87 - 0
src/lib/user-memory/extractor.spec.ts

@@ -0,0 +1,87 @@
+import { describe, expect, it } from "vitest"
+import {
+  buildUserMemoryExtractionPrompt,
+  parseUserMemoryExtraction,
+} from "./extractor"
+
+describe("user memory extractor", () => {
+  it("提取提示明确排除一次性章节参数和敏感信息", () => {
+    const prompt = buildUserMemoryExtractionPrompt("根据第1、3、5章生成后面四章")
+
+    expect(prompt).toContain("不要保存具体章节号")
+    expect(prompt).toContain("密码")
+    expect(prompt).toContain("跨任务复用")
+  })
+
+  it("解析时过滤包含具体章节号的一次性规则", () => {
+    const result = parseUserMemoryExtraction(JSON.stringify({
+      memories: [
+        {
+          rule: "根据第1、3、5章生成后面四章。",
+          category: "workflow_preference",
+          surfaces: ["chapter-writing"],
+          confidence: 0.9,
+          evidence_summary: "本次任务参数",
+        },
+        {
+          rule: "续写时重视用户指定章节之间的剧情承接。",
+          category: "workflow_preference",
+          surfaces: ["chapter-writing"],
+          confidence: 0.84,
+          evidence_summary: "用户要求参考非连续章节",
+        },
+      ],
+    }))
+
+    expect(result).toHaveLength(1)
+    expect(result[0]?.rule).toContain("剧情承接")
+  })
+
+  it("过滤包含密钥和密码的候选记忆", () => {
+    const result = parseUserMemoryExtraction(JSON.stringify({
+      memories: [{
+        rule: "用户的 API Key 是 sk-secret123。",
+        category: "manual",
+        surfaces: ["all"],
+        confidence: 1,
+        evidence_summary: "用户提供了密钥",
+      }],
+    }))
+
+    expect(result).toEqual([])
+  })
+
+  it("过滤包含邮箱或手机号的候选记忆", () => {
+    const result = parseUserMemoryExtraction(JSON.stringify({
+      memories: [
+        {
+          rule: "联系邮箱是 writer@example.com。",
+          category: "manual",
+          surfaces: ["all"],
+          confidence: 1,
+          evidence_summary: "用户提供了联系方式",
+        },
+        {
+          rule: "用户手机号是 13800138000。",
+          category: "manual",
+          surfaces: ["all"],
+          confidence: 1,
+          evidence_summary: "用户提供了联系方式",
+        },
+      ],
+    }))
+
+    expect(result).toEqual([])
+  })
+
+  it("过滤地址、身份、医疗和财务隐私候选", () => {
+    const result = parseUserMemoryExtraction(JSON.stringify({ memories: [
+      { rule: "用户住址是北京市朝阳区建国路88号。", category: "manual", surfaces: ["all"], confidence: 1, evidence_summary: "家庭住址" },
+      { rule: "用户患有糖尿病。", category: "manual", surfaces: ["all"], confidence: 1, evidence_summary: "医疗信息" },
+      { rule: "用户月收入是三万元。", category: "manual", surfaces: ["all"], confidence: 1, evidence_summary: "财务信息" },
+      { rule: "用户真实姓名是张三。", category: "manual", surfaces: ["all"], confidence: 1, evidence_summary: "身份信息" },
+    ] }))
+
+    expect(result).toEqual([])
+  })
+})

+ 96 - 0
src/lib/user-memory/extractor.ts

@@ -0,0 +1,96 @@
+import type { AutomaticUserMemoryRuleInput, UserMemoryCategory, UserMemorySurface } from "./types"
+
+const VALID_CATEGORIES = new Set<UserMemoryCategory>([
+  "output_style", "writing_preference", "outline_preference", "workflow_preference",
+  "interaction_preference", "format_preference", "constraint", "manual",
+])
+const VALID_SURFACES = new Set<UserMemorySurface>([
+  "all", "ai-chat", "ai-outline", "chapter-writing", "book-analysis", "review", "analysis",
+])
+const SPECIFIC_TASK_PATTERN = /第\s*[一二三四五六七八九十百千万零〇两\d、,,~~\-至到]+\s*章|(?:生成|续写|改写|参考|根据)[^。;]{0,24}[一二三四五六七八九十百千万零〇两\d]+\s*章/
+const SENSITIVE_PATTERN = /(?:api\s*key|密钥|密码|口令|token\s*[::=]|sk-[a-z0-9_-]{6,}|身份证|银行卡|真实姓名|家庭住址|住址|详细地址|患有|病史|诊断|医疗信息|月收入|工资|资产|负债|财务信息|[\w.+-]+@[\w.-]+\.[a-z]{2,}|\b(?:\+?86[-\s]?)?1[3-9]\d{9}\b)/i
+
+export interface ParsedUserMemoryRule extends Omit<AutomaticUserMemoryRuleInput, "sourceHash"> {}
+
+export function buildUserMemoryExtractionPrompt(userMessage: string): string {
+  return [
+    "你是全局用户习惯提取器。只输出 JSON,不要输出解释或 Markdown。",
+    "目标:从用户消息中提取能够跨项目、跨任务复用的稳定习惯,让后续 AI 更符合用户偏好。",
+    "严格规则:",
+    "1. 不要保存具体章节号、人物名、本次生成数量、临时文件名或一次性任务目标。",
+    "2. 不要保存密码、密钥、令牌、联系方式、身份信息或其他敏感信息。",
+    "3. 只保留能够跨任务复用的表达、写作、流程、格式、交互和禁止事项。",
+    "4. 无法确认是长期习惯时返回空数组。",
+    "5. rule 必须是可直接给 AI 执行的中文规则,不得复述原始消息。",
+    "输出结构:",
+    JSON.stringify({
+      memories: [{
+        rule: "续写时优先保持用户指定章节之间的剧情承接。",
+        category: "workflow_preference",
+        surfaces: ["chapter-writing", "ai-chat"],
+        confidence: 0.82,
+        evidence_summary: "用户要求基于选定章节生成后续内容。",
+      }],
+    }, null, 2),
+    "用户消息:",
+    userMessage.slice(0, 12_000),
+  ].join("\n")
+}
+
+function extractJson(text: string): unknown {
+  const cleaned = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "")
+  const start = cleaned.indexOf("{")
+  const end = cleaned.lastIndexOf("}")
+  if (start < 0 || end <= start) return null
+  try { return JSON.parse(cleaned.slice(start, end + 1)) } catch { return null }
+}
+
+function isReusable(rule: string, evidence: string): boolean {
+  if (rule.length < 6 || rule.length > 500) return false
+  if (SPECIFIC_TASK_PATTERN.test(rule)) return false
+  if (SENSITIVE_PATTERN.test(rule) || SENSITIVE_PATTERN.test(evidence)) return false
+  return true
+}
+
+export function parseUserMemoryExtraction(text: string): ParsedUserMemoryRule[] {
+  const parsed = extractJson(text)
+  if (!parsed || typeof parsed !== "object") return []
+  const memories = (parsed as { memories?: unknown }).memories
+  if (!Array.isArray(memories)) return []
+  return memories.flatMap((value): ParsedUserMemoryRule[] => {
+    if (!value || typeof value !== "object") return []
+    const raw = value as Record<string, unknown>
+    const rule = typeof raw.rule === "string" ? raw.rule.trim() : ""
+    const evidenceSummary = typeof raw.evidence_summary === "string" ? raw.evidence_summary.trim() : ""
+    const category = typeof raw.category === "string" && VALID_CATEGORIES.has(raw.category as UserMemoryCategory)
+      ? raw.category as UserMemoryCategory
+      : null
+    const surfaces = Array.isArray(raw.surfaces)
+      ? [...new Set(raw.surfaces.filter((item): item is UserMemorySurface => typeof item === "string" && VALID_SURFACES.has(item as UserMemorySurface)))]
+      : []
+    if (!category || !isReusable(rule, evidenceSummary)) return []
+    return [{
+      rule,
+      category,
+      surfaces: surfaces.length > 0 ? surfaces : ["all"],
+      confidence: typeof raw.confidence === "number" && Number.isFinite(raw.confidence)
+        ? Math.max(0, Math.min(1, raw.confidence))
+        : 0.5,
+      evidenceSummary,
+    }]
+  })
+}
+
+export async function computeUserMessageHash(message: string): Promise<string> {
+  const normalized = message.replace(/\s+/g, " ").trim()
+  if (typeof crypto !== "undefined" && crypto.subtle) {
+    const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(normalized))
+    return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
+  }
+  let hash = 2166136261
+  for (let index = 0; index < normalized.length; index += 1) {
+    hash ^= normalized.charCodeAt(index)
+    hash = Math.imul(hash, 16777619)
+  }
+  return `fnv1a:${(hash >>> 0).toString(16).padStart(8, "0")}`
+}

+ 29 - 0
src/lib/user-memory/feedback-service.spec.ts

@@ -0,0 +1,29 @@
+// @vitest-environment jsdom
+import { beforeEach, describe, expect, it } from "vitest"
+import { buildUserMemoryDecision, setLatestUserMemoryDecision } from "./decision-trace"
+import { recordLatestUserMemoryFeedback } from "./feedback-service"
+import { addManualUserMemoryRule, loadGlobalUserMemoryConfig, saveGlobalUserMemoryConfig } from "./store"
+
+describe("user memory feedback service", () => {
+  beforeEach(() => window.localStorage.clear())
+
+  it("只给最近一次实际命中的规则记录反馈", () => {
+    let config = loadGlobalUserMemoryConfig()
+    config = addManualUserMemoryRule(config, { rule: "先给结论。", category: "manual", surfaces: ["all"] }, 1)
+    config = addManualUserMemoryRule(config, { rule: "保持简洁。", category: "manual", surfaces: ["all"] }, 2)
+    saveGlobalUserMemoryConfig(config)
+    setLatestUserMemoryDecision(buildUserMemoryDecision({
+      rules: config.rules,
+      selected: [config.rules[0]!],
+      filtered: [],
+      prompt: "先给结论",
+      surface: "ai-chat",
+    }))
+
+    recordLatestUserMemoryFeedback("negative", 100)
+    const loaded = loadGlobalUserMemoryConfig()
+
+    expect(loaded.rules[0]?.negativeFeedback).toBe(1)
+    expect(loaded.rules[1]?.negativeFeedback).toBe(0)
+  })
+})

+ 10 - 0
src/lib/user-memory/feedback-service.ts

@@ -0,0 +1,10 @@
+import { getLatestUserMemoryDecision } from "./decision-trace"
+import { applyUserMemoryFeedback } from "./governance"
+import { loadGlobalUserMemoryConfig, saveGlobalUserMemoryConfig } from "./store"
+
+export function recordLatestUserMemoryFeedback(sentiment: "positive" | "negative", now = Date.now()): void {
+  const decision = getLatestUserMemoryDecision()
+  if (!decision || decision.selectedRuleIds.length === 0) return
+  const config = loadGlobalUserMemoryConfig()
+  saveGlobalUserMemoryConfig(applyUserMemoryFeedback(config, decision.selectedRuleIds, sentiment, now))
+}

+ 86 - 0
src/lib/user-memory/governance.spec.ts

@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vitest"
+import { addManualUserMemoryRule, loadGlobalUserMemoryConfig, upsertAutomaticUserMemoryRule } from "./store"
+import { applyUserMemoryFeedback, governUserMemoryConfig } from "./governance"
+
+describe("user memory governance", () => {
+  it("自动候选获得重复证据后升级为长期规则", () => {
+    let config = loadGlobalUserMemoryConfig(null)
+    config = upsertAutomaticUserMemoryRule(config, {
+      rule: "回答时先给结论。",
+      category: "interaction_preference",
+      surfaces: ["all"],
+      confidence: 0.8,
+      evidenceSummary: "第一次证据",
+      sourceHash: "h1",
+    }, 100)
+    expect(config.rules[0]?.status).toBe("candidate")
+    config = upsertAutomaticUserMemoryRule(config, {
+      rule: "回答时先给结论。",
+      category: "interaction_preference",
+      surfaces: ["all"],
+      confidence: 0.9,
+      evidenceSummary: "第二次证据",
+      sourceHash: "h2",
+    }, 200)
+
+    const governed = governUserMemoryConfig(config, 200)
+    expect(governed.rules[0]).toMatchObject({ status: "active", evidenceCount: 2, expiresAt: null })
+  })
+
+  it("同作用域相反规则标记为冲突", () => {
+    let config = loadGlobalUserMemoryConfig(null)
+    config = addManualUserMemoryRule(config, {
+      rule: "写作时使用幽默风格。",
+      category: "writing_preference",
+      surfaces: ["chapter-writing"],
+    }, 100)
+    config = addManualUserMemoryRule(config, {
+      rule: "写作时不要使用幽默风格。",
+      category: "writing_preference",
+      surfaces: ["chapter-writing"],
+    }, 200)
+
+    const governed = governUserMemoryConfig(config, 200)
+    expect(governed.rules.every((rule) => rule.status === "conflicted")).toBe(true)
+    expect(governed.rules[0]?.conflictsWith).toContain(governed.rules[1]?.id)
+  })
+
+  it("过期候选变为 expired,负反馈过高的自动规则降为候选", () => {
+    let config = loadGlobalUserMemoryConfig(null)
+    config = upsertAutomaticUserMemoryRule(config, {
+      rule: "大纲使用分层标题。",
+      category: "format_preference",
+      surfaces: ["ai-outline"],
+      confidence: 0.9,
+      evidenceSummary: "证据",
+      sourceHash: "h1",
+    }, 100)
+    const expired = governUserMemoryConfig(config, 100 + 31 * 24 * 60 * 60 * 1000)
+    expect(expired.rules[0]?.status).toBe("expired")
+
+    const active = governUserMemoryConfig({
+      ...config,
+      rules: config.rules.map((rule) => ({ ...rule, status: "active" as const, expiresAt: null })),
+    }, 200)
+    const disliked = applyUserMemoryFeedback(active, [active.rules[0]!.id], "negative", 300)
+    const dislikedAgain = applyUserMemoryFeedback(disliked, [active.rules[0]!.id], "negative", 400)
+    expect(governUserMemoryConfig(dislikedAgain, 400).rules[0]).toMatchObject({ status: "candidate", negativeFeedback: 2 })
+  })
+
+  it("合并同作用域中的近义自动规则并累计证据", () => {
+    let config = loadGlobalUserMemoryConfig(null)
+    config = upsertAutomaticUserMemoryRule(config, {
+      rule: "回答时先给结论。", category: "interaction_preference", surfaces: ["all"], confidence: 0.8,
+      evidenceSummary: "第一次", sourceHash: "h1",
+    }, 100)
+    config = upsertAutomaticUserMemoryRule(config, {
+      rule: "回答问题时优先给出结论。", category: "interaction_preference", surfaces: ["all"], confidence: 0.9,
+      evidenceSummary: "第二次", sourceHash: "h2",
+    }, 200)
+
+    const governed = governUserMemoryConfig(config, 200)
+
+    expect(governed.rules).toHaveLength(1)
+    expect(governed.rules[0]).toMatchObject({ evidenceCount: 2, status: "active", confidence: 0.9 })
+  })
+})

+ 129 - 0
src/lib/user-memory/governance.ts

@@ -0,0 +1,129 @@
+import { normalizeGlobalUserMemoryConfig, userMemoryRuleFingerprint } from "./store"
+import type { GlobalUserMemoryConfig, UserMemoryRule } from "./types"
+
+const NEGATIVE_PATTERN = /不要|禁止|不再|避免|无需|不能|不得/
+
+function conflictSignature(value: string): string {
+  return value
+    .toLocaleLowerCase()
+    .replace(NEGATIVE_PATTERN, "")
+    .replace(/(?:回答时|写作时|生成时|大纲|使用|采用|保持|风格|方式|内容|规则)/g, "")
+    .replace(/[\s,。!?;:,.!?;:、]/g, "")
+}
+
+function sameScope(left: UserMemoryRule, right: UserMemoryRule): boolean {
+  return (left.scope ?? "global") === (right.scope ?? "global")
+    && (left.projectKey ?? null) === (right.projectKey ?? null)
+    && (left.sessionKey ?? null) === (right.sessionKey ?? null)
+}
+
+function areOpposite(left: UserMemoryRule, right: UserMemoryRule): boolean {
+  if (left.category !== right.category || !sameScope(left, right)) return false
+  const leftSignature = conflictSignature(left.rule)
+  const rightSignature = conflictSignature(right.rule)
+  if (!leftSignature || leftSignature !== rightSignature) return false
+  return NEGATIVE_PATTERN.test(left.rule) !== NEGATIVE_PATTERN.test(right.rule)
+}
+
+function semanticSignature(value: string): string {
+  return value
+    .toLocaleLowerCase()
+    .replace(/[\s,。!?;:,.!?;:、]/g, "")
+    .replace(/(?:回答问题|回答时|写作时|生成时|问题|请|务必|需要|要|优先|先|给出|给|使用|采用|保持|一直|始终|时)/g, "")
+}
+
+function areSimilar(left: UserMemoryRule, right: UserMemoryRule): boolean {
+  if (left.source !== "automatic" || right.source !== "automatic") return false
+  if (left.category !== right.category || !sameScope(left, right)) return false
+  if (NEGATIVE_PATTERN.test(left.rule) !== NEGATIVE_PATTERN.test(right.rule)) return false
+  const leftSignature = semanticSignature(left.rule)
+  const rightSignature = semanticSignature(right.rule)
+  if (!leftSignature || !rightSignature) return false
+  if (leftSignature === rightSignature) return true
+  const leftChars = new Set(leftSignature)
+  const rightChars = new Set(rightSignature)
+  const intersection = [...leftChars].filter((char) => rightChars.has(char)).length
+  const union = new Set([...leftChars, ...rightChars]).size
+  return union > 0 && intersection / union >= 0.75
+}
+
+function mergeSimilarAutomaticRules(rules: UserMemoryRule[]): UserMemoryRule[] {
+  const merged: UserMemoryRule[] = []
+  for (const rule of rules) {
+    const existingIndex = merged.findIndex((item) => areSimilar(item, rule))
+    if (existingIndex < 0) {
+      merged.push(rule)
+      continue
+    }
+    const existing = merged[existingIndex]!
+    const preferred = rule.confidence >= existing.confidence ? rule : existing
+    merged[existingIndex] = {
+      ...existing,
+      rule: preferred.rule,
+      fingerprint: userMemoryRuleFingerprint(preferred.rule, preferred.category),
+      confidence: Math.max(existing.confidence, rule.confidence),
+      surfaces: [...new Set([...existing.surfaces, ...rule.surfaces])],
+      evidenceCount: (existing.evidenceCount ?? 1) + (rule.evidenceCount ?? 1),
+      evidenceSummary: preferred.evidenceSummary,
+      sourceHash: preferred.sourceHash,
+      lastEvidenceAt: Math.max(existing.lastEvidenceAt ?? 0, rule.lastEvidenceAt ?? 0),
+      updatedAt: Math.max(existing.updatedAt, rule.updatedAt),
+      usageCount: (existing.usageCount ?? 0) + (rule.usageCount ?? 0),
+      positiveFeedback: (existing.positiveFeedback ?? 0) + (rule.positiveFeedback ?? 0),
+      negativeFeedback: (existing.negativeFeedback ?? 0) + (rule.negativeFeedback ?? 0),
+    }
+  }
+  return merged
+}
+
+export function governUserMemoryConfig(config: GlobalUserMemoryConfig, now = Date.now()): GlobalUserMemoryConfig {
+  const promotionThreshold = Math.max(1, config.candidatePromotionThreshold)
+  const rules = mergeSimilarAutomaticRules(config.rules).map((rule): UserMemoryRule => {
+    if (rule.source === "manual") {
+      return { ...rule, status: rule.status === "expired" ? "active" : rule.status, expiresAt: null, conflictsWith: [] }
+    }
+    if (rule.expiresAt !== null && rule.expiresAt !== undefined && rule.expiresAt <= now && rule.status === "candidate") {
+      return { ...rule, status: "expired", conflictsWith: [] }
+    }
+    if ((rule.negativeFeedback ?? 0) >= (rule.positiveFeedback ?? 0) + 2) {
+      return { ...rule, status: "candidate", expiresAt: now + 30 * 24 * 60 * 60 * 1000, conflictsWith: [] }
+    }
+    if (rule.status === "candidate" && (rule.evidenceCount ?? 1) >= promotionThreshold) {
+      return { ...rule, status: "active", expiresAt: null, conflictsWith: [] }
+    }
+    return { ...rule, status: rule.status === "conflicted" ? "active" : rule.status, conflictsWith: [] }
+  })
+
+  for (let leftIndex = 0; leftIndex < rules.length; leftIndex += 1) {
+    for (let rightIndex = leftIndex + 1; rightIndex < rules.length; rightIndex += 1) {
+      const left = rules[leftIndex]!
+      const right = rules[rightIndex]!
+      if (!areOpposite(left, right)) continue
+      rules[leftIndex] = { ...left, status: "conflicted", conflictsWith: [...new Set([...(left.conflictsWith ?? []), right.id])] }
+      rules[rightIndex] = { ...right, status: "conflicted", conflictsWith: [...new Set([...(right.conflictsWith ?? []), left.id])] }
+    }
+  }
+
+  return normalizeGlobalUserMemoryConfig({ ...config, rules, updatedAt: Math.max(config.updatedAt, now) })
+}
+
+export function applyUserMemoryFeedback(
+  config: GlobalUserMemoryConfig,
+  ruleIds: string[],
+  sentiment: "positive" | "negative",
+  now = Date.now(),
+): GlobalUserMemoryConfig {
+  const ids = new Set(ruleIds)
+  return normalizeGlobalUserMemoryConfig({
+    ...config,
+    rules: config.rules.map((rule) => ids.has(rule.id)
+      ? {
+          ...rule,
+          positiveFeedback: (rule.positiveFeedback ?? 0) + (sentiment === "positive" ? 1 : 0),
+          negativeFeedback: (rule.negativeFeedback ?? 0) + (sentiment === "negative" ? 1 : 0),
+          updatedAt: now,
+        }
+      : rule),
+    updatedAt: now,
+  })
+}

+ 13 - 0
src/lib/user-memory/index.ts

@@ -0,0 +1,13 @@
+export * from "./types"
+export * from "./store"
+export * from "./extractor"
+export * from "./selector"
+export * from "./compiler"
+export * from "./learning-service"
+export * from "./request-integration"
+export * from "./prefilter"
+export * from "./learning-budget"
+export * from "./governance"
+export * from "./decision-trace"
+export * from "./feedback-service"
+export * from "./maintenance"

+ 31 - 0
src/lib/user-memory/learning-budget.spec.ts

@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest"
+import { consumeUserMemoryLearningBudget, loadUserMemoryLearningBudget } from "./learning-budget"
+
+class MemoryStorage implements Pick<Storage, "getItem" | "setItem"> {
+  private values = new Map<string, string>()
+  getItem(key: string) { return this.values.get(key) ?? null }
+  setItem(key: string, value: string) { this.values.set(key, value) }
+}
+
+describe("user memory learning budget", () => {
+  it("达到每日调用上限后拒绝继续学习", () => {
+    const storage = new MemoryStorage()
+    const now = new Date("2026-07-18T08:00:00+08:00").getTime()
+
+    expect(consumeUserMemoryLearningBudget(storage, 2, 100, now)).toBe(true)
+    expect(consumeUserMemoryLearningBudget(storage, 2, 120, now)).toBe(true)
+    expect(consumeUserMemoryLearningBudget(storage, 2, 80, now)).toBe(false)
+    expect(loadUserMemoryLearningBudget(storage, now)).toMatchObject({ calls: 2, inputChars: 220 })
+  })
+
+  it("跨自然日自动重置", () => {
+    const storage = new MemoryStorage()
+    const firstDay = new Date("2026-07-18T08:00:00+08:00").getTime()
+    const nextDay = new Date("2026-07-19T08:00:00+08:00").getTime()
+
+    expect(consumeUserMemoryLearningBudget(storage, 1, 100, firstDay)).toBe(true)
+    expect(consumeUserMemoryLearningBudget(storage, 1, 100, firstDay)).toBe(false)
+    expect(consumeUserMemoryLearningBudget(storage, 1, 50, nextDay)).toBe(true)
+    expect(loadUserMemoryLearningBudget(storage, nextDay)).toMatchObject({ calls: 1, inputChars: 50 })
+  })
+})

+ 60 - 0
src/lib/user-memory/learning-budget.ts

@@ -0,0 +1,60 @@
+export const USER_MEMORY_LEARNING_BUDGET_KEY = "qmai.user-memory-learning-budget.v1"
+
+type StorageLike = Pick<Storage, "getItem" | "setItem">
+
+export interface UserMemoryLearningBudget {
+  day: string
+  calls: number
+  inputChars: number
+}
+
+function dayKey(now: number): string {
+  const date = new Date(now)
+  return [date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0")].join("-")
+}
+
+function emptyBudget(now: number): UserMemoryLearningBudget {
+  return { day: dayKey(now), calls: 0, inputChars: 0 }
+}
+
+export function loadUserMemoryLearningBudget(storage: StorageLike | null, now = Date.now()): UserMemoryLearningBudget {
+  if (!storage) return emptyBudget(now)
+  try {
+    const parsed = JSON.parse(storage.getItem(USER_MEMORY_LEARNING_BUDGET_KEY) ?? "null") as Partial<UserMemoryLearningBudget> | null
+    if (!parsed || parsed.day !== dayKey(now)) return emptyBudget(now)
+    return {
+      day: parsed.day,
+      calls: typeof parsed.calls === "number" && Number.isFinite(parsed.calls) ? Math.max(0, parsed.calls) : 0,
+      inputChars: typeof parsed.inputChars === "number" && Number.isFinite(parsed.inputChars) ? Math.max(0, parsed.inputChars) : 0,
+    }
+  } catch {
+    return emptyBudget(now)
+  }
+}
+
+export function consumeUserMemoryLearningBudget(
+  storage: StorageLike | null,
+  dailyLimit: number,
+  inputChars: number,
+  now = Date.now(),
+): boolean {
+  const current = loadUserMemoryLearningBudget(storage, now)
+  if (current.calls >= Math.max(0, dailyLimit)) return false
+  if (!storage) return true
+  const next = { ...current, calls: current.calls + 1, inputChars: current.inputChars + Math.max(0, inputChars) }
+  try {
+    storage.setItem(USER_MEMORY_LEARNING_BUDGET_KEY, JSON.stringify(next))
+    return true
+  } catch {
+    return false
+  }
+}
+
+export function resetUserMemoryLearningBudget(storage: StorageLike | null): void {
+  if (!storage) return
+  try {
+    storage.setItem(USER_MEMORY_LEARNING_BUDGET_KEY, JSON.stringify({ day: "", calls: 0, inputChars: 0 }))
+  } catch {
+    // 清理预算失败不应阻断记忆清空。
+  }
+}

+ 133 - 0
src/lib/user-memory/learning-service.spec.ts

@@ -0,0 +1,133 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { loadGlobalUserMemoryConfig, saveGlobalUserMemoryConfig } from "./store"
+import {
+  learnUserMemoryFromMessage,
+  learnUserMemoryFromMessages,
+  resetUserMemoryLearningQueueForTests,
+} from "./learning-service"
+
+class MemoryStorage implements Pick<Storage, "getItem" | "setItem"> {
+  private values = new Map<string, string>()
+  getItem(key: string) { return this.values.get(key) ?? null }
+  setItem(key: string, value: string) { this.values.set(key, value) }
+}
+
+const llmConfig = { provider: "openai", model: "test" } as LlmConfig
+
+describe("user memory learning service", () => {
+  let storage: MemoryStorage
+
+  beforeEach(() => {
+    storage = new MemoryStorage()
+    resetUserMemoryLearningQueueForTests()
+  })
+
+  it("关闭自动学习时不调用提取模型", async () => {
+    const config = { ...loadGlobalUserMemoryConfig(storage), autoLearn: false }
+    saveGlobalUserMemoryConfig(config, storage)
+    const runExtractor = vi.fn()
+
+    const result = await learnUserMemoryFromMessage({ message: "回答简洁一些", llmConfig }, { storage, runExtractor })
+
+    expect(result.status).toBe("disabled")
+    expect(runExtractor).not.toHaveBeenCalled()
+  })
+
+  it("相同消息哈希只分析一次", async () => {
+    const runExtractor = vi.fn(async () => JSON.stringify({ memories: [] }))
+
+    const first = await learnUserMemoryFromMessage({ message: "回答时先给结论", llmConfig }, { storage, runExtractor })
+    const second = await learnUserMemoryFromMessage({ message: "回答时先给结论", llmConfig }, { storage, runExtractor })
+
+    expect(first.status).toBe("learned")
+    expect(second.status).toBe("unchanged")
+    expect(runExtractor).toHaveBeenCalledTimes(1)
+  })
+
+  it("成功提取后保存规则且失败不抛出", async () => {
+    const learned = await learnUserMemoryFromMessage({ message: "续写要重视前文承接", llmConfig }, {
+      storage,
+      runExtractor: async () => JSON.stringify({ memories: [{
+        rule: "续写时重视前文剧情承接。",
+        category: "workflow_preference",
+        surfaces: ["chapter-writing"],
+        confidence: 0.9,
+        evidence_summary: "用户明确强调承接",
+      }] }),
+    })
+    const failed = await learnUserMemoryFromMessage({ message: "换一种表达方式", llmConfig }, {
+      storage,
+      runExtractor: async () => { throw new Error("network") },
+    })
+
+    expect(learned.status).toBe("learned")
+    expect(loadGlobalUserMemoryConfig(storage).rules[0]?.rule).toContain("剧情承接")
+    expect(failed.status).toBe("failed")
+  })
+
+  it("批量消息只调用一次提取器并记录每条消息哈希", async () => {
+    const runExtractor = vi.fn(async () => JSON.stringify({ memories: [{
+      rule: "回答时先给结论。",
+      category: "interaction_preference",
+      surfaces: ["all"],
+      confidence: 0.9,
+      evidence_summary: "两条消息共同确认",
+    }] }))
+
+    const result = await learnUserMemoryFromMessages([
+      { message: "以后回答时请先给结论。", llmConfig, surface: "ai-chat" },
+      { message: "我习惯先看结论,再看依据。", llmConfig, surface: "ai-chat" },
+    ], { storage, runExtractor })
+
+    expect(result.status).toBe("learned")
+    expect(runExtractor).toHaveBeenCalledTimes(1)
+    expect(loadGlobalUserMemoryConfig(storage).analyzedSourceHashes).toHaveLength(2)
+  })
+
+  it("达到每日预算后不再调用提取器", async () => {
+    const config = { ...loadGlobalUserMemoryConfig(storage), dailyLearningLimit: 1 }
+    saveGlobalUserMemoryConfig(config, storage)
+    const runExtractor = vi.fn(async () => JSON.stringify({ memories: [] }))
+
+    const first = await learnUserMemoryFromMessage({ message: "以后回答时请先给结论。", llmConfig }, { storage, runExtractor })
+    const second = await learnUserMemoryFromMessage({ message: "以后回答时请保持简洁。", llmConfig }, { storage, runExtractor })
+
+    expect(first.status).toBe("learned")
+    expect(second.status).toBe("budget_exhausted")
+    expect(runExtractor).toHaveBeenCalledTimes(1)
+  })
+
+  it("仅手动记忆模式不调用自动提取器", async () => {
+    saveGlobalUserMemoryConfig({ ...loadGlobalUserMemoryConfig(storage), onlyManual: true }, storage)
+    const runExtractor = vi.fn()
+
+    const result = await learnUserMemoryFromMessage({ message: "以后回答时请保持简洁。", llmConfig }, { storage, runExtractor })
+
+    expect(result.status).toBe("disabled")
+    expect(runExtractor).not.toHaveBeenCalled()
+  })
+
+  it("写作偏好归入作品层,明确本次会话的偏好归入会话层", async () => {
+    await learnUserMemoryFromMessage({
+      message: "写作时一直保持幽默风格。", llmConfig, projectKey: "p1", sessionKey: "s1",
+    }, {
+      storage,
+      runExtractor: async () => JSON.stringify({ memories: [{
+        rule: "写作时保持幽默风格。", category: "writing_preference", surfaces: ["chapter-writing"], confidence: 0.9, evidence_summary: "写作偏好",
+      }] }),
+    })
+    await learnUserMemoryFromMessage({
+      message: "当前会话回答时先给结论。", llmConfig, projectKey: "p1", sessionKey: "s1",
+    }, {
+      storage,
+      runExtractor: async () => JSON.stringify({ memories: [{
+        rule: "回答时先给结论。", category: "interaction_preference", surfaces: ["ai-chat"], confidence: 0.9, evidence_summary: "本次会话偏好",
+      }] }),
+    })
+
+    const rules = loadGlobalUserMemoryConfig(storage).rules
+    expect(rules.find((rule) => rule.category === "writing_preference")).toMatchObject({ scope: "project", projectKey: "p1" })
+    expect(rules.find((rule) => rule.category === "interaction_preference")).toMatchObject({ scope: "session", projectKey: "p1", sessionKey: "s1" })
+  })
+})

+ 166 - 0
src/lib/user-memory/learning-service.ts

@@ -0,0 +1,166 @@
+import type { LlmConfig } from "@/stores/wiki-store"
+import { streamChat } from "@/lib/llm-client"
+import { buildUserMemoryExtractionPrompt, computeUserMessageHash, parseUserMemoryExtraction } from "./extractor"
+import { governUserMemoryConfig } from "./governance"
+import { consumeUserMemoryLearningBudget } from "./learning-budget"
+import { evaluateUserMemoryCandidate } from "./prefilter"
+import {
+  loadGlobalUserMemoryConfig,
+  normalizeGlobalUserMemoryConfig,
+  saveGlobalUserMemoryConfig,
+  upsertAutomaticUserMemoryRule,
+} from "./store"
+import type { ParsedUserMemoryRule } from "./extractor"
+import type { UserMemoryScope, UserMemorySurface } from "./types"
+
+type StorageLike = Pick<Storage, "getItem" | "setItem">
+
+export interface UserMemoryLearningInput {
+  message: string
+  llmConfig: LlmConfig
+  surface?: UserMemorySurface
+  projectKey?: string
+  sessionKey?: string
+  scope?: UserMemoryScope
+}
+
+export interface UserMemoryLearningResult {
+  status: "disabled" | "ignored" | "unchanged" | "learned" | "budget_exhausted" | "failed"
+  added: number
+}
+
+interface LearningDependencies {
+  storage?: StorageLike
+  runExtractor?: (prompt: string, llmConfig: LlmConfig) => Promise<string>
+}
+
+const inFlight = new Map<string, Promise<UserMemoryLearningResult>>()
+const queuedBatches = new Map<string, { items: UserMemoryLearningInput[]; timer: ReturnType<typeof setTimeout> | null }>()
+
+function runtimeStorage(): StorageLike | null {
+  try { return typeof window === "undefined" ? null : window.localStorage } catch { return null }
+}
+
+async function runDefaultExtractor(prompt: string, llmConfig: LlmConfig): Promise<string> {
+  let content = ""
+  let error: Error | null = null
+  await streamChat(llmConfig, [
+    { role: "system", content: "你只负责提取全局用户习惯,并严格返回指定 JSON。" },
+    { role: "user", content: prompt },
+  ], {
+    onToken: (token) => { content += token },
+    onDone: () => {},
+    onError: (nextError) => { error = nextError },
+  }, undefined, {
+    temperature: 0.1,
+    max_tokens: 1200,
+    skipUserMemory: true,
+  })
+  if (error) throw error
+  return content
+}
+
+function inferredScope(memory: ParsedUserMemoryRule, inputs: UserMemoryLearningInput[]): {
+  scope: UserMemoryScope
+  projectKey: string | null
+  sessionKey: string | null
+} {
+  const explicit = inputs.find((input) => input.scope)
+  const projectKey = inputs.every((input) => input.projectKey === inputs[0]?.projectKey) ? inputs[0]?.projectKey ?? null : null
+  const sessionKey = inputs.every((input) => input.sessionKey === inputs[0]?.sessionKey) ? inputs[0]?.sessionKey ?? null : null
+  const explicitlySessionScoped = inputs.some((input) => /(?:本次会话|当前会话|这次对话|本轮对话)/.test(input.message))
+  if (explicitlySessionScoped && sessionKey) return { scope: "session", projectKey, sessionKey }
+  if (explicit?.scope === "session" && sessionKey) return { scope: "session", projectKey, sessionKey }
+  if (explicit?.scope === "project" && projectKey) return { scope: "project", projectKey, sessionKey: null }
+  if (projectKey && ["writing_preference", "outline_preference", "workflow_preference"].includes(memory.category)) {
+    return { scope: "project", projectKey, sessionKey: null }
+  }
+  return { scope: "global", projectKey: null, sessionKey: null }
+}
+
+export async function learnUserMemoryFromMessages(
+  inputs: UserMemoryLearningInput[],
+  dependencies: LearningDependencies = {},
+): Promise<UserMemoryLearningResult> {
+  const storage = dependencies.storage ?? runtimeStorage()
+  const normalizedInputs = inputs
+    .map((input) => ({ ...input, message: input.message.replace(/\s+/g, " ").trim() }))
+    .filter((input) => input.message.length >= 6)
+  const config = loadGlobalUserMemoryConfig(storage)
+  if (!config.enabled || !config.autoLearn || config.onlyManual) return { status: "disabled", added: 0 }
+  if (normalizedInputs.length === 0) return { status: "ignored", added: 0 }
+
+  const withHashes = await Promise.all(normalizedInputs.map(async (input) => ({ input, hash: await computeUserMessageHash(input.message) })))
+  const pendingInputs = withHashes.filter((item) => !config.analyzedSourceHashes.includes(item.hash))
+  if (pendingInputs.length === 0) return { status: "unchanged", added: 0 }
+  const operationKey = pendingInputs.map((item) => item.hash).sort().join(":")
+  const pending = inFlight.get(operationKey)
+  if (pending) return pending
+
+  const operation = (async (): Promise<UserMemoryLearningResult> => {
+    try {
+      const inputChars = pendingInputs.reduce((sum, item) => sum + item.input.message.length, 0)
+      if (!consumeUserMemoryLearningBudget(storage, config.dailyLearningLimit, inputChars)) {
+        return { status: "budget_exhausted", added: 0 }
+      }
+      const runExtractor = dependencies.runExtractor ?? runDefaultExtractor
+      const batchMessage = pendingInputs
+        .map((item, index) => `消息 ${index + 1}:${item.input.message}`)
+        .join("\n")
+      const raw = await runExtractor(buildUserMemoryExtractionPrompt(batchMessage), pendingInputs[0]!.input.llmConfig)
+      const extracted = parseUserMemoryExtraction(raw)
+      let next = loadGlobalUserMemoryConfig(storage)
+      const before = next.rules.length
+      for (const memory of extracted) {
+        const scope = inferredScope(memory, pendingInputs.map((item) => item.input))
+        next = upsertAutomaticUserMemoryRule(next, { ...memory, ...scope, sourceHash: pendingInputs[0]!.hash })
+      }
+      next = governUserMemoryConfig(normalizeGlobalUserMemoryConfig({
+        ...next,
+        analyzedSourceHashes: [...new Set([...next.analyzedSourceHashes, ...pendingInputs.map((item) => item.hash)])],
+        updatedAt: Date.now(),
+      }))
+      saveGlobalUserMemoryConfig(next, storage)
+      return { status: "learned", added: Math.max(0, next.rules.length - before) }
+    } catch {
+      return { status: "failed", added: 0 }
+    } finally {
+      inFlight.delete(operationKey)
+    }
+  })()
+  inFlight.set(operationKey, operation)
+  return operation
+}
+
+export async function learnUserMemoryFromMessage(
+  input: UserMemoryLearningInput,
+  dependencies: LearningDependencies = {},
+): Promise<UserMemoryLearningResult> {
+  return learnUserMemoryFromMessages([input], dependencies)
+}
+
+export function enqueueUserMemoryLearning(input: UserMemoryLearningInput): void {
+  if (!evaluateUserMemoryCandidate(input.message).shouldAnalyze) return
+  const storage = runtimeStorage()
+  const config = loadGlobalUserMemoryConfig(storage)
+  if (!config.enabled || !config.autoLearn || config.onlyManual) return
+  const key = [input.llmConfig.provider, input.llmConfig.model, input.projectKey ?? "", input.sessionKey ?? "", input.surface ?? ""].join(":")
+  const current = queuedBatches.get(key) ?? { items: [], timer: null }
+  current.items.push(input)
+  const flush = () => {
+    const batch = queuedBatches.get(key)
+    if (!batch) return
+    queuedBatches.delete(key)
+    void learnUserMemoryFromMessages(batch.items)
+  }
+  if (current.timer) clearTimeout(current.timer)
+  current.timer = current.items.length >= config.batchSize ? null : setTimeout(flush, 1_500)
+  queuedBatches.set(key, current)
+  if (current.items.length >= config.batchSize) flush()
+}
+
+export function resetUserMemoryLearningQueueForTests(): void {
+  inFlight.clear()
+  for (const batch of queuedBatches.values()) if (batch.timer) clearTimeout(batch.timer)
+  queuedBatches.clear()
+}

+ 31 - 0
src/lib/user-memory/maintenance.spec.ts

@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest"
+import { runUserMemoryMaintenance } from "./maintenance"
+import { GLOBAL_USER_MEMORY_STORAGE_KEY, loadGlobalUserMemoryConfig, saveGlobalUserMemoryConfig, upsertAutomaticUserMemoryRule } from "./store"
+
+class MemoryStorage implements Pick<Storage, "getItem" | "setItem"> {
+  values = new Map<string, string>()
+  writes = 0
+  getItem(key: string) { return this.values.get(key) ?? null }
+  setItem(key: string, value: string) { this.writes += 1; this.values.set(key, value) }
+}
+
+describe("user memory startup maintenance", () => {
+  it("仅在治理结果变化时写回存储", () => {
+    const storage = new MemoryStorage()
+    let config = upsertAutomaticUserMemoryRule(loadGlobalUserMemoryConfig(storage), {
+      rule: "回答时先给结论。", category: "interaction_preference", surfaces: ["all"], confidence: 0.8,
+      evidenceSummary: "证据", sourceHash: "h1",
+    }, 100)
+    saveGlobalUserMemoryConfig(config, storage)
+    const before = storage.writes
+
+    expect(runUserMemoryMaintenance(storage, 200)).toBe(false)
+    expect(storage.writes).toBe(before)
+    expect(storage.getItem(GLOBAL_USER_MEMORY_STORAGE_KEY)).not.toBeNull()
+
+    config = { ...config, rules: config.rules.map((rule) => ({ ...rule, expiresAt: 150 })) }
+    saveGlobalUserMemoryConfig(config, storage)
+    expect(runUserMemoryMaintenance(storage, 200)).toBe(true)
+    expect(loadGlobalUserMemoryConfig(storage).rules[0]?.status).toBe("expired")
+  })
+})

+ 18 - 0
src/lib/user-memory/maintenance.ts

@@ -0,0 +1,18 @@
+import { governUserMemoryConfig } from "./governance"
+import { loadGlobalUserMemoryConfig, saveGlobalUserMemoryConfig } from "./store"
+
+type StorageLike = Pick<Storage, "getItem" | "setItem">
+
+function runtimeStorage(): StorageLike | null {
+  try { return typeof window === "undefined" ? null : window.localStorage } catch { return null }
+}
+
+export function runUserMemoryMaintenance(storage: StorageLike | null = runtimeStorage(), now = Date.now()): boolean {
+  if (!storage) return false
+  const current = loadGlobalUserMemoryConfig(storage)
+  const governed = governUserMemoryConfig(current, now)
+  const comparable = { ...governed, updatedAt: current.updatedAt }
+  if (JSON.stringify(comparable) === JSON.stringify(current)) return false
+  saveGlobalUserMemoryConfig({ ...governed, updatedAt: now }, storage)
+  return true
+}

+ 24 - 0
src/lib/user-memory/prefilter.spec.ts

@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest"
+import { evaluateUserMemoryCandidate } from "./prefilter"
+
+describe("user memory local prefilter", () => {
+  it.each(["继续", "确认", "重新生成", "可以,就这样"])("跳过短操作消息:%s", (message) => {
+    expect(evaluateUserMemoryCandidate(message).shouldAnalyze).toBe(false)
+  })
+
+  it("跳过只有本次章节范围的一次性任务", () => {
+    expect(evaluateUserMemoryCandidate("根据第1、3、5章生成后面四章").shouldAnalyze).toBe(false)
+  })
+
+  it.each([
+    "以后回答时请先给结论,再说明依据。",
+    "我习惯大纲使用分层标题,请一直保持。",
+    "写作时不要使用空泛总结,这是长期要求。",
+  ])("识别明确的长期偏好:%s", (message) => {
+    expect(evaluateUserMemoryCandidate(message)).toMatchObject({ shouldAnalyze: true, reason: "explicit_preference" })
+  })
+
+  it("普通一次性修改没有稳定偏好信号时跳过", () => {
+    expect(evaluateUserMemoryCandidate("把当前第二段改得更长一些").shouldAnalyze).toBe(false)
+  })
+})

+ 19 - 0
src/lib/user-memory/prefilter.ts

@@ -0,0 +1,19 @@
+export type UserMemoryPrefilterReason = "explicit_preference" | "too_short" | "operation_only" | "one_off_task" | "no_stable_signal"
+
+export interface UserMemoryPrefilterResult {
+  shouldAnalyze: boolean
+  reason: UserMemoryPrefilterReason
+}
+
+const OPERATION_ONLY = /^(?:继续|确认|确定|可以|好的|好|是的|不是|重新生成|再来一次|就这样|开始|执行|停止|取消)[。!!,,\s]*(?:就这样|继续|开始)?[。!!\s]*$/
+const ONE_OFF_TASK = /(?:第\s*[一二三四五六七八九十百千万零〇两\d、,,~~\-至到]+\s*章|当前第?[一二三四五六七八九十百千万零〇两\d]+段|生成(?:后面|接下来)?[一二三四五六七八九十百千万零〇两\d]+章)/
+const EXPLICIT_PREFERENCE = /(?:以后|今后|长期|一直|始终|每次|默认|习惯|偏好|请记住|都要|务必|回答时|写作时|生成时|大纲(?:要|使用)|不要再|禁止使用|长期要求)/
+
+export function evaluateUserMemoryCandidate(message: string): UserMemoryPrefilterResult {
+  const normalized = message.replace(/\s+/g, " ").trim()
+  if (normalized.length < 6) return { shouldAnalyze: false, reason: "too_short" }
+  if (OPERATION_ONLY.test(normalized)) return { shouldAnalyze: false, reason: "operation_only" }
+  if (EXPLICIT_PREFERENCE.test(normalized)) return { shouldAnalyze: true, reason: "explicit_preference" }
+  if (ONE_OFF_TASK.test(normalized)) return { shouldAnalyze: false, reason: "one_off_task" }
+  return { shouldAnalyze: false, reason: "no_stable_signal" }
+}

+ 129 - 0
src/lib/user-memory/request-integration.spec.ts

@@ -0,0 +1,129 @@
+import { describe, expect, it } from "vitest"
+import type { ChatMessage } from "@/lib/llm-providers"
+import { addManualUserMemoryRule, loadGlobalUserMemoryConfig, saveGlobalUserMemoryConfig } from "./store"
+import { applyGlobalUserMemoryToMessages } from "./request-integration"
+import { getLatestUserMemoryDecision } from "./decision-trace"
+
+class MemoryStorage implements Pick<Storage, "getItem" | "setItem"> {
+  private values = new Map<string, string>()
+  getItem(key: string) { return this.values.get(key) ?? null }
+  setItem(key: string, value: string) { this.values.set(key, value) }
+}
+
+function withRules(storage: MemoryStorage) {
+  let config = loadGlobalUserMemoryConfig(storage)
+  config = addManualUserMemoryRule(config, {
+    rule: "写作保持幽默。",
+    category: "writing_preference",
+    surfaces: ["chapter-writing"],
+  }, 1)
+  config = addManualUserMemoryRule(config, {
+    rule: "回答时先给结论。",
+    category: "interaction_preference",
+    surfaces: ["all"],
+  }, 2)
+  saveGlobalUserMemoryConfig(config, storage)
+}
+
+describe("global user memory request integration", () => {
+  it("关闭自动读取时不修改消息", () => {
+    const storage = new MemoryStorage()
+    saveGlobalUserMemoryConfig({ ...loadGlobalUserMemoryConfig(storage), autoRead: false }, storage)
+    const messages: ChatMessage[] = [{ role: "user", content: "续写下一章" }]
+
+    expect(applyGlobalUserMemoryToMessages(messages, {}, storage)).toBe(messages)
+  })
+
+  it("写作任务只注入相关规则", () => {
+    const storage = new MemoryStorage()
+    withRules(storage)
+    const result = applyGlobalUserMemoryToMessages([
+      { role: "system", content: "软件规则" },
+      { role: "user", content: "续写下一章正文" },
+    ], { userMemorySurface: "chapter-writing" }, storage)
+
+    expect(String(result[0]?.content)).toContain("写作保持幽默")
+    expect(String(result[0]?.content)).toContain("回答时先给结论")
+  })
+
+  it("审查任务不注入创作文风并保留缓存块", () => {
+    const storage = new MemoryStorage()
+    withRules(storage)
+    const messages: ChatMessage[] = [
+      { role: "system", content: [
+        { type: "text", text: "软件规则" },
+        { type: "text", text: "项目稳定核心", cacheControl: true },
+      ] },
+      { role: "user", content: "请审查这一章" },
+    ]
+    const result = applyGlobalUserMemoryToMessages(messages, { userMemorySurface: "review" }, storage)
+    const blocks = result[0]?.content
+
+    expect(Array.isArray(blocks)).toBe(true)
+    expect(blocks).toEqual(expect.arrayContaining([
+      expect.objectContaining({ text: "项目稳定核心", cacheControl: true }),
+    ]))
+    expect(JSON.stringify(blocks)).not.toContain("写作保持幽默")
+    expect(JSON.stringify(blocks)).toContain("回答时先给结论")
+  })
+
+  it("skipUserMemory 防止提取器递归注入", () => {
+    const storage = new MemoryStorage()
+    withRules(storage)
+    const messages: ChatMessage[] = [{ role: "user", content: "分析用户偏好" }]
+
+    expect(applyGlobalUserMemoryToMessages(messages, { skipUserMemory: true }, storage)).toBe(messages)
+  })
+
+  it("后台提取器跳过记忆时不覆盖最近一次用户请求决策", () => {
+    const storage = new MemoryStorage()
+    withRules(storage)
+    applyGlobalUserMemoryToMessages([{ role: "user", content: "请回答问题" }], { userMemorySurface: "ai-chat" }, storage)
+    const before = getLatestUserMemoryDecision()
+
+    applyGlobalUserMemoryToMessages([{ role: "user", content: "后台提取" }], { skipUserMemory: true }, storage)
+
+    expect(getLatestUserMemoryDecision()).toEqual(before)
+  })
+
+  it("按作品和会话上下文选择最近层级并记录决策", () => {
+    const storage = new MemoryStorage()
+    let config = loadGlobalUserMemoryConfig(storage)
+    config = addManualUserMemoryRule(config, {
+      rule: "全局规则。", category: "manual", surfaces: ["all"], scope: "global",
+    }, 1)
+    config = addManualUserMemoryRule(config, {
+      rule: "作品规则。", category: "manual", surfaces: ["all"], scope: "project", projectKey: "p1",
+    }, 2)
+    config = addManualUserMemoryRule(config, {
+      rule: "会话规则。", category: "manual", surfaces: ["all"], scope: "session", projectKey: "p1", sessionKey: "s1",
+    }, 3)
+    saveGlobalUserMemoryConfig(config, storage)
+
+    const result = applyGlobalUserMemoryToMessages([
+      { role: "user", content: "请回答" },
+    ], { userMemorySurface: "ai-chat", userMemoryProjectKey: "p1", userMemorySessionKey: "s1" }, storage)
+
+    expect(JSON.stringify(result)).toContain("会话规则")
+    expect(getLatestUserMemoryDecision()).toMatchObject({ projectKey: "p1", sessionKey: "s1" })
+    expect(getLatestUserMemoryDecision()?.selectedRuleIds.length).toBe(3)
+  })
+
+  it("仅手动模式过滤自动规则并记录过滤原因", () => {
+    const storage = new MemoryStorage()
+    const config = loadGlobalUserMemoryConfig(storage)
+    saveGlobalUserMemoryConfig({
+      ...config,
+      onlyManual: true,
+      rules: [{
+        id: "auto", rule: "自动规则。", category: "manual", source: "automatic", surfaces: ["all"], confidence: 1,
+        evidenceSummary: "", sourceHash: "h", fingerprint: "auto", enabled: true, createdAt: 1, updatedAt: 1,
+        scope: "global", status: "active",
+      }],
+    }, 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" })
+  })
+})

+ 73 - 0
src/lib/user-memory/request-integration.ts

@@ -0,0 +1,73 @@
+import type { ChatMessage, ContentBlock, RequestOverrides } from "@/lib/llm-providers"
+import { compileUserMemorySkill } from "./compiler"
+import { buildUserMemoryDecision, setLatestUserMemoryDecision } from "./decision-trace"
+import { governUserMemoryConfig } from "./governance"
+import { evaluateUserMemoryRule, inferUserMemorySurface, selectUserMemoryRules } from "./selector"
+import { loadGlobalUserMemoryConfig, saveGlobalUserMemoryConfig } from "./store"
+
+type StorageLike = Pick<Storage, "getItem" | "setItem">
+
+function contentText(content: ChatMessage["content"]): string {
+  if (typeof content === "string") return content
+  return content.map((block) => block.type === "text" ? block.text : "").join("")
+}
+
+function appendToSystem(content: ChatMessage["content"], prompt: string): ChatMessage["content"] {
+  if (typeof content === "string") return `${content.trim()}\n\n${prompt}`.trim()
+  const next: ContentBlock[] = [...content, { type: "text", text: `\n\n${prompt}` }]
+  return next
+}
+
+export function applyGlobalUserMemoryToMessages(
+  messages: ChatMessage[],
+  overrides: Pick<RequestOverrides, "skipUserMemory" | "userMemorySurface" | "userMemoryProjectKey" | "userMemorySessionKey"> = {},
+  storage?: StorageLike,
+): ChatMessage[] {
+  if (overrides.skipUserMemory) return messages
+  const loadedConfig = loadGlobalUserMemoryConfig(storage)
+  const config = governUserMemoryConfig(loadedConfig)
+  const task = contentText([...messages].reverse().find((message) => message.role === "user")?.content ?? "")
+  const surface = overrides.userMemorySurface ?? inferUserMemorySurface(task)
+  const selectionInput = {
+    task,
+    surface,
+    projectKey: overrides.userMemoryProjectKey,
+    sessionKey: overrides.userMemorySessionKey,
+    onlyManual: config.onlyManual,
+  }
+  const selected = config.enabled && config.autoRead
+    ? selectUserMemoryRules(config.rules, selectionInput)
+    : []
+  const prompt = compileUserMemorySkill(selected)
+  const selectedIds = new Set(selected.map((rule) => rule.id))
+  const filtered = config.rules.flatMap((rule) => {
+    const reason = config.enabled && config.autoRead
+      ? evaluateUserMemoryRule(rule, selectionInput)
+      : "disabled" as const
+    if (reason) return [{ ruleId: rule.id, reason }]
+    return selectedIds.has(rule.id) ? [] : [{ ruleId: rule.id, reason: "shadowed" as const }]
+  })
+  setLatestUserMemoryDecision(buildUserMemoryDecision({
+    rules: config.rules,
+    selected,
+    filtered,
+    prompt,
+    surface,
+    projectKey: overrides.userMemoryProjectKey,
+    sessionKey: overrides.userMemorySessionKey,
+  }))
+  if (!prompt) return messages
+  const now = Date.now()
+  saveGlobalUserMemoryConfig({
+    ...config,
+    rules: config.rules.map((rule) => selectedIds.has(rule.id)
+      ? { ...rule, usageCount: (rule.usageCount ?? 0) + 1, lastUsedAt: now }
+      : rule),
+    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)
+}

+ 77 - 0
src/lib/user-memory/selector.spec.ts

@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest"
+import type { UserMemoryRule } from "./types"
+import { inferUserMemorySurface, selectUserMemoryRules } from "./selector"
+
+function rule(partial: Partial<UserMemoryRule>): UserMemoryRule {
+  return {
+    id: partial.id ?? "r1",
+    rule: partial.rule ?? "回答时先给结论。",
+    category: partial.category ?? "interaction_preference",
+    source: partial.source ?? "automatic",
+    surfaces: partial.surfaces ?? ["all"],
+    confidence: partial.confidence ?? 0.8,
+    evidenceSummary: "",
+    sourceHash: null,
+    fingerprint: partial.fingerprint ?? "fp",
+    enabled: partial.enabled ?? true,
+    createdAt: 1,
+    updatedAt: 1,
+    ...partial,
+  }
+}
+
+describe("user memory selector", () => {
+  it("根据任务识别审稿、大纲和章节写作场景", () => {
+    expect(inferUserMemorySurface("请审查这一章的问题")).toBe("review")
+    expect(inferUserMemorySurface("生成完整故事大纲")).toBe("ai-outline")
+    expect(inferUserMemorySurface("续写第十章正文")).toBe("chapter-writing")
+  })
+
+  it("审查任务不注入创作文风偏好", () => {
+    const selected = selectUserMemoryRules([
+      rule({ id: "style", category: "writing_preference", rule: "写作保持幽默。", surfaces: ["all"] }),
+      rule({ id: "review", category: "constraint", rule: "审查时列出证据。", surfaces: ["review"] }),
+    ], { task: "请审查这一章", surface: "review" })
+
+    expect(selected.map((item) => item.id)).toEqual(["review"])
+  })
+
+  it("当前请求明确否定旧规则时不注入旧规则", () => {
+    const selected = selectUserMemoryRules([
+      rule({ rule: "大纲输出使用分层标题。", category: "format_preference", surfaces: ["ai-outline"] }),
+    ], { task: "本次大纲不要使用分层标题,直接连续输出。", surface: "ai-outline" })
+
+    expect(selected).toEqual([])
+  })
+
+  it("只选择 active 规则并按会话、作品、全局层级覆盖", () => {
+    const selected = selectUserMemoryRules([
+      rule({ id: "global", rule: "回答时先给结论。", fingerprint: "same", scope: "global", status: "active" }),
+      rule({ id: "project", rule: "本作品先给结论。", fingerprint: "same", scope: "project", projectKey: "p1", status: "active" }),
+      rule({ id: "session", rule: "本会话先给结论。", fingerprint: "same", scope: "session", projectKey: "p1", sessionKey: "s1", status: "active" }),
+      rule({ id: "candidate", rule: "候选规则。", status: "candidate" }),
+      rule({ id: "conflicted", rule: "冲突规则。", status: "conflicted" }),
+    ], {
+      task: "请回答问题",
+      surface: "ai-chat",
+      projectKey: "p1",
+      sessionKey: "s1",
+    })
+
+    expect(selected.map((item) => item.id)).toContain("session")
+    expect(selected.map((item) => item.id)).not.toContain("project")
+    expect(selected.map((item) => item.id)).not.toContain("global")
+    expect(selected.map((item) => item.id)).not.toContain("candidate")
+    expect(selected.map((item) => item.id)).not.toContain("conflicted")
+  })
+
+  it("没有作品或会话标识时只使用全局规则", () => {
+    const selected = selectUserMemoryRules([
+      rule({ id: "global", scope: "global", status: "active" }),
+      rule({ id: "project", scope: "project", projectKey: "p1", status: "active" }),
+      rule({ id: "session", scope: "session", sessionKey: "s1", status: "active" }),
+    ], { task: "请回答问题", surface: "ai-chat" })
+
+    expect(selected.map((item) => item.id)).toEqual(["global"])
+  })
+})

+ 78 - 0
src/lib/user-memory/selector.ts

@@ -0,0 +1,78 @@
+import type { UserMemoryCategory, UserMemoryRule, UserMemorySurface } from "./types"
+
+export type UserMemoryFilterReason = "disabled" | "candidate" | "conflicted" | "expired" | "scope_mismatch" | "surface_mismatch" | "category_blocked" | "current_task_conflict" | "shadowed"
+
+export function inferUserMemorySurface(task: string): UserMemorySurface {
+  if (/审稿|审查|检查|纠错|连贯性|一致性/.test(task)) return "review"
+  if (/大纲|章纲|故事框架|情节规划/.test(task)) return "ai-outline"
+  if (/写第|续写|生成.{0,8}章|改写.{0,8}章|章节正文/.test(task)) return "chapter-writing"
+  if (/拆书|角色\s*skill|文风提取|故事提取|作品分析/i.test(task)) return "book-analysis"
+  return "ai-chat"
+}
+
+function categoryAllowed(category: UserMemoryCategory, surface: UserMemorySurface): boolean {
+  if (surface === "review") return category !== "writing_preference" && category !== "outline_preference" && category !== "output_style"
+  if (surface === "book-analysis" || surface === "analysis") {
+    return category === "interaction_preference" || category === "format_preference" || category === "constraint" || category === "manual"
+  }
+  return true
+}
+
+function conflictsWithCurrentTask(rule: string, task: string): boolean {
+  const negatives = [...task.matchAll(/(?:不要|禁止|不再|避免|无需)(?:再|使用|采用|保持|输出)?\s*([^,。;\n]{2,24})/g)]
+  return negatives.some((match) => {
+    const phrase = match[1]?.replace(/^(?:这种|这个|该)/, "").trim() ?? ""
+    if (!phrase) return false
+    if (rule.includes(phrase)) return true
+    const key = phrase.replace(/(?:直接|连续|内容|方式|风格|规则)$/g, "").trim()
+    return key.length >= 2 && rule.includes(key)
+  })
+}
+
+export function selectUserMemoryRules(
+  rules: UserMemoryRule[],
+  input: { task: string; surface?: UserMemorySurface; limit?: number; projectKey?: string; sessionKey?: string; onlyManual?: boolean },
+): UserMemoryRule[] {
+  const surface = input.surface ?? inferUserMemorySurface(input.task)
+  const limit = Math.max(1, input.limit ?? 12)
+  const scopeRank = (rule: UserMemoryRule) => rule.scope === "session" ? 3 : rule.scope === "project" ? 2 : 1
+  const eligible = rules
+    .filter((rule) => evaluateUserMemoryRule(rule, { ...input, surface }) === null)
+    .sort((left, right) => {
+      const scope = scopeRank(right) - scopeRank(left)
+      const source = Number(right.source === "manual") - Number(left.source === "manual")
+      return scope || source || right.confidence - left.confidence || right.updatedAt - left.updatedAt
+    })
+  const selected: UserMemoryRule[] = []
+  const fingerprints = new Set<string>()
+  for (const rule of eligible) {
+    if (fingerprints.has(rule.fingerprint)) continue
+    fingerprints.add(rule.fingerprint)
+    selected.push(rule)
+    if (selected.length >= limit) break
+  }
+  return selected
+}
+
+export function evaluateUserMemoryRule(
+  rule: UserMemoryRule,
+  input: { task: string; surface: UserMemorySurface; projectKey?: string; sessionKey?: string; onlyManual?: boolean },
+): UserMemoryFilterReason | null {
+  if (!rule.enabled) return "disabled"
+  if (input.onlyManual && rule.source !== "manual") return "disabled"
+  const status = rule.status ?? "active"
+  if (status === "candidate") return "candidate"
+  if (status === "conflicted") return "conflicted"
+  if (status === "expired") return "expired"
+  const scope = rule.scope ?? "global"
+  if (scope === "project" && (!input.projectKey || rule.projectKey !== input.projectKey)) return "scope_mismatch"
+  if (scope === "session" && (
+    !input.sessionKey
+    || rule.sessionKey !== input.sessionKey
+    || (rule.projectKey && rule.projectKey !== input.projectKey)
+  )) return "scope_mismatch"
+  if (!rule.surfaces.includes("all") && !rule.surfaces.includes(input.surface)) return "surface_mismatch"
+  if (!categoryAllowed(rule.category, input.surface)) return "category_blocked"
+  if (conflictsWithCurrentTask(rule.rule, input.task)) return "current_task_conflict"
+  return null
+}

+ 236 - 0
src/lib/user-memory/store.spec.ts

@@ -0,0 +1,236 @@
+import { beforeEach, describe, expect, it } from "vitest"
+import {
+  GLOBAL_USER_MEMORY_STORAGE_KEY,
+  addManualUserMemoryRule,
+  deleteUserMemoryRule,
+  loadGlobalUserMemoryConfig,
+  saveGlobalUserMemoryConfig,
+  setUserMemoryRuleEnabled,
+  upsertAutomaticUserMemoryRule,
+  normalizeGlobalUserMemoryConfig,
+  clearGlobalUserMemoryConfig,
+  getGlobalUserMemoryStats,
+} from "./store"
+import { consumeUserMemoryLearningBudget, loadUserMemoryLearningBudget } from "./learning-budget"
+
+class MemoryStorage implements Pick<Storage, "getItem" | "setItem"> {
+  private values = new Map<string, string>()
+  getItem(key: string) { return this.values.get(key) ?? null }
+  setItem(key: string, value: string) { this.values.set(key, value) }
+}
+
+describe("global user memory store", () => {
+  let storage: MemoryStorage
+
+  beforeEach(() => {
+    storage = new MemoryStorage()
+  })
+
+  it("损坏数据回退为默认开启的空配置", () => {
+    storage.setItem(GLOBAL_USER_MEMORY_STORAGE_KEY, "{broken")
+
+    expect(loadGlobalUserMemoryConfig(storage)).toMatchObject({
+      version: 2,
+      enabled: true,
+      autoLearn: true,
+      autoRead: true,
+      rules: [],
+      analyzedSourceHashes: [],
+      deletedFingerprints: [],
+    })
+  })
+
+  it("把 v1 规则迁移为全局长期规则并补齐治理字段", () => {
+    const migrated = normalizeGlobalUserMemoryConfig({
+      version: 1,
+      enabled: true,
+      autoLearn: true,
+      autoRead: true,
+      rules: [{
+        id: "old-rule",
+        rule: "回答时先给结论。",
+        category: "interaction_preference",
+        source: "automatic",
+        surfaces: ["all"],
+        confidence: 0.8,
+        evidenceSummary: "旧数据",
+        sourceHash: "old-hash",
+        fingerprint: "interaction_preference:回答时先给结论。",
+        enabled: true,
+        createdAt: 100,
+        updatedAt: 100,
+      }],
+      analyzedSourceHashes: ["old-hash"],
+      deletedFingerprints: [],
+      updatedAt: 100,
+    })
+
+    expect(migrated.version).toBe(2)
+    expect(migrated.rules[0]).toMatchObject({
+      scope: "global",
+      status: "active",
+      evidenceCount: 1,
+      usageCount: 0,
+      positiveFeedback: 0,
+      negativeFeedback: 0,
+      conflictsWith: [],
+    })
+    expect(migrated.onlyManual).toBe(false)
+    expect(migrated.candidatePromotionThreshold).toBe(2)
+  })
+
+  it("限制分析哈希和自动规则数量但不自动删除手动规则", () => {
+    const normalized = normalizeGlobalUserMemoryConfig({
+      version: 2,
+      maxAnalyzedHashes: 2,
+      maxRules: 2,
+      rules: [
+        { id: "manual", rule: "手动规则", category: "manual", source: "manual", surfaces: ["all"], enabled: true, createdAt: 1, updatedAt: 1 },
+        { id: "auto-old", rule: "旧候选规则", category: "manual", source: "automatic", surfaces: ["all"], enabled: true, status: "candidate", confidence: 0.2, createdAt: 2, updatedAt: 2 },
+        { id: "auto-new", rule: "新长期规则", category: "manual", source: "automatic", surfaces: ["all"], enabled: true, status: "active", confidence: 0.9, createdAt: 3, updatedAt: 3 },
+      ],
+      analyzedSourceHashes: ["h1", "h2", "h3"],
+    })
+
+    expect(normalized.analyzedSourceHashes).toEqual(["h2", "h3"])
+    expect(normalized.rules.map((rule) => rule.id)).toEqual(["manual", "auto-new"])
+  })
+
+  it("手动规则可以新增并持久化", () => {
+    const config = addManualUserMemoryRule(loadGlobalUserMemoryConfig(storage), {
+      rule: "回答时先给结论,再给依据。",
+      category: "interaction_preference",
+      surfaces: ["all"],
+    }, 100)
+
+    saveGlobalUserMemoryConfig(config, storage)
+    const loaded = loadGlobalUserMemoryConfig(storage)
+
+    expect(loaded.rules).toHaveLength(1)
+    expect(loaded.rules[0]).toMatchObject({
+      source: "manual",
+      enabled: true,
+      rule: "回答时先给结论,再给依据。",
+    })
+  })
+
+  it("同一自动规则和来源哈希只保留一条", () => {
+    const first = upsertAutomaticUserMemoryRule(loadGlobalUserMemoryConfig(storage), {
+      rule: "续写时重视指定章节之间的剧情承接。",
+      category: "workflow_preference",
+      surfaces: ["chapter-writing", "ai-chat"],
+      confidence: 0.82,
+      evidenceSummary: "用户要求根据非连续章节生成后续内容。",
+      sourceHash: "source-1",
+    }, 100)
+    const second = upsertAutomaticUserMemoryRule(first, {
+      rule: "续写时重视指定章节之间的剧情承接。",
+      category: "workflow_preference",
+      surfaces: ["chapter-writing"],
+      confidence: 0.9,
+      evidenceSummary: "用户再次强调章节承接。",
+      sourceHash: "source-1",
+    }, 200)
+
+    expect(second.rules).toHaveLength(1)
+    expect(second.analyzedSourceHashes).toContain("source-1")
+    expect(second.rules[0]?.updatedAt).toBe(200)
+  })
+
+  it("同一条用户消息提取出的不同规则分别保留", () => {
+    const first = upsertAutomaticUserMemoryRule(loadGlobalUserMemoryConfig(storage), {
+      rule: "回答时先给结论。",
+      category: "interaction_preference",
+      surfaces: ["all"],
+      confidence: 0.9,
+      evidenceSummary: "用户强调先给结论。",
+      sourceHash: "shared-source",
+    }, 100)
+    const second = upsertAutomaticUserMemoryRule(first, {
+      rule: "大纲使用分层标题。",
+      category: "format_preference",
+      surfaces: ["ai-outline"],
+      confidence: 0.85,
+      evidenceSummary: "用户同时要求分层标题。",
+      sourceHash: "shared-source",
+    }, 200)
+
+    expect(second.rules.map((rule) => rule.rule)).toEqual([
+      "回答时先给结论。",
+      "大纲使用分层标题。",
+    ])
+  })
+
+  it("删除规则后写入墓碑且相同自动规则不会复活", () => {
+    const created = upsertAutomaticUserMemoryRule(loadGlobalUserMemoryConfig(storage), {
+      rule: "大纲输出使用分层标题。",
+      category: "format_preference",
+      surfaces: ["ai-outline"],
+      confidence: 0.9,
+      evidenceSummary: "用户多次要求分层标题。",
+      sourceHash: "source-2",
+    }, 100)
+    const deleted = deleteUserMemoryRule(created, created.rules[0]!.id, 200)
+    const attempted = upsertAutomaticUserMemoryRule(deleted, {
+      rule: "大纲输出使用分层标题。",
+      category: "format_preference",
+      surfaces: ["ai-outline"],
+      confidence: 0.95,
+      evidenceSummary: "相同来源再次出现。",
+      sourceHash: "source-2",
+    }, 300)
+
+    expect(attempted.rules).toEqual([])
+    expect(attempted.deletedFingerprints).toHaveLength(1)
+  })
+
+  it("规则可以停用后重新启用", () => {
+    const created = addManualUserMemoryRule(loadGlobalUserMemoryConfig(storage), {
+      rule: "避免空泛总结。",
+      category: "constraint",
+      surfaces: ["all"],
+    }, 100)
+    const id = created.rules[0]!.id
+
+    const disabled = setUserMemoryRuleEnabled(created, id, false, 200)
+    const enabled = setUserMemoryRuleEnabled(disabled, id, true, 300)
+
+    expect(disabled.rules[0]?.enabled).toBe(false)
+    expect(enabled.rules[0]?.enabled).toBe(true)
+  })
+
+  it("统计规则状态和存储占用,并可清空全部记忆", () => {
+    let config = addManualUserMemoryRule(loadGlobalUserMemoryConfig(storage), {
+      rule: "回答时先给结论。", category: "manual", surfaces: ["all"],
+    }, 100)
+    config = upsertAutomaticUserMemoryRule(config, {
+      rule: "大纲使用分层标题。", category: "format_preference", surfaces: ["ai-outline"], confidence: 0.8,
+      evidenceSummary: "证据", sourceHash: "h1",
+    }, 200)
+    saveGlobalUserMemoryConfig(config, storage)
+    consumeUserMemoryLearningBudget(storage, 10, 100, 100)
+
+    expect(getGlobalUserMemoryStats(config)).toMatchObject({ totalRules: 2, manualRules: 1, candidateRules: 1 })
+    expect(getGlobalUserMemoryStats(config).estimatedBytes).toBeGreaterThan(0)
+    clearGlobalUserMemoryConfig(storage)
+    expect(loadGlobalUserMemoryConfig(storage).rules).toEqual([])
+    expect(loadUserMemoryLearningBudget(storage, 100).calls).toBe(0)
+  })
+
+  it("超过存储字节预算时优先移除低价值自动候选", () => {
+    const normalized = normalizeGlobalUserMemoryConfig({
+      ...loadGlobalUserMemoryConfig(storage),
+      maxStorageBytes: 900,
+      rules: [
+        { id: "manual", rule: "手动规则必须保留。", category: "manual", source: "manual", surfaces: ["all"], enabled: true, createdAt: 1, updatedAt: 1 },
+        ...Array.from({ length: 8 }, (_, index) => ({
+          id: `auto-${index}`, rule: `自动候选${index}${"内容".repeat(100)}`, category: "manual", source: "automatic",
+          surfaces: ["all"], enabled: true, status: "candidate", confidence: 0.1, createdAt: index + 2, updatedAt: index + 2,
+        })),
+      ],
+    })
+
+    expect(normalized.rules.some((rule) => rule.id === "manual")).toBe(true)
+    expect(normalized.rules.length).toBeLessThan(9)
+  })
+})

+ 422 - 0
src/lib/user-memory/store.ts

@@ -0,0 +1,422 @@
+import type {
+  AutomaticUserMemoryRuleInput,
+  GlobalUserMemoryConfig,
+  ManualUserMemoryRuleInput,
+  UserMemoryCategory,
+  UserMemoryRule,
+  UserMemoryScope,
+  UserMemoryStatus,
+  UserMemorySurface,
+} from "./types"
+import { resetUserMemoryLearningBudget } from "./learning-budget"
+
+export const GLOBAL_USER_MEMORY_STORAGE_KEY = "qmai.global-user-memory.v1"
+export const GLOBAL_USER_MEMORY_CHANGED_EVENT = "qmai:global-user-memory-changed"
+
+type StorageLike = Pick<Storage, "getItem" | "setItem">
+
+const CATEGORIES = new Set<UserMemoryCategory>([
+  "output_style", "writing_preference", "outline_preference", "workflow_preference",
+  "interaction_preference", "format_preference", "constraint", "manual",
+])
+const SURFACES = new Set<UserMemorySurface>([
+  "all", "ai-chat", "ai-outline", "chapter-writing", "book-analysis", "review", "analysis",
+])
+const SCOPES = new Set<UserMemoryScope>(["global", "project", "session"])
+const STATUSES = new Set<UserMemoryStatus>(["candidate", "active", "conflicted", "expired"])
+
+const DEFAULT_MAX_RULES = 300
+const DEFAULT_MAX_ANALYZED_HASHES = 5_000
+const DEFAULT_MAX_STORAGE_BYTES = 2_000_000
+
+function defaultConfig(): GlobalUserMemoryConfig {
+  return {
+    version: 2,
+    enabled: true,
+    autoLearn: true,
+    autoRead: true,
+    rules: [],
+    analyzedSourceHashes: [],
+    deletedFingerprints: [],
+    updatedAt: 0,
+    onlyManual: false,
+    dailyLearningLimit: 20,
+    batchSize: 3,
+    candidatePromotionThreshold: 2,
+    maxRules: DEFAULT_MAX_RULES,
+    maxAnalyzedHashes: DEFAULT_MAX_ANALYZED_HASHES,
+    maxStorageBytes: DEFAULT_MAX_STORAGE_BYTES,
+  }
+}
+
+function defaultStorage(): StorageLike | null {
+  try {
+    return typeof window === "undefined" ? null : window.localStorage
+  } catch {
+    return null
+  }
+}
+
+function uniqueStrings(value: unknown): string[] {
+  if (!Array.isArray(value)) return []
+  return [...new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean))]
+}
+
+function normalizeSurfaces(value: unknown): UserMemorySurface[] {
+  const values = uniqueStrings(value).filter((item): item is UserMemorySurface => SURFACES.has(item as UserMemorySurface))
+  return values.length > 0 ? values : ["all"]
+}
+
+function normalizeCategory(value: unknown): UserMemoryCategory {
+  return typeof value === "string" && CATEGORIES.has(value as UserMemoryCategory)
+    ? value as UserMemoryCategory
+    : "manual"
+}
+
+function finiteNonNegative(value: unknown, fallback: number): number {
+  return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : fallback
+}
+
+function positiveInteger(value: unknown, fallback: number): number {
+  return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
+}
+
+function normalizeScope(value: unknown): UserMemoryScope {
+  return typeof value === "string" && SCOPES.has(value as UserMemoryScope)
+    ? value as UserMemoryScope
+    : "global"
+}
+
+function normalizeStatus(value: unknown, source: UserMemoryRule["source"]): UserMemoryStatus {
+  if (typeof value === "string" && STATUSES.has(value as UserMemoryStatus)) return value as UserMemoryStatus
+  return source === "manual" ? "active" : "active"
+}
+
+export function userMemoryRuleFingerprint(rule: string, category: UserMemoryCategory): string {
+  return `${category}:${rule.replace(/\s+/g, " ").trim().toLocaleLowerCase()}`
+}
+
+function normalizeRule(value: unknown): UserMemoryRule | null {
+  if (!value || typeof value !== "object") return null
+  const raw = value as Partial<UserMemoryRule>
+  const rule = typeof raw.rule === "string" ? raw.rule.trim() : ""
+  if (!rule) return null
+  const category = normalizeCategory(raw.category)
+  const source = raw.source === "automatic" ? "automatic" : "manual"
+  const createdAt = typeof raw.createdAt === "number" && Number.isFinite(raw.createdAt) ? raw.createdAt : 0
+  return {
+    id: typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : `memory:${createdAt}:${rule.length}`,
+    rule,
+    category,
+    source,
+    surfaces: normalizeSurfaces(raw.surfaces),
+    confidence: typeof raw.confidence === "number" && Number.isFinite(raw.confidence)
+      ? Math.max(0, Math.min(1, raw.confidence))
+      : raw.source === "automatic" ? 0.5 : 1,
+    evidenceSummary: typeof raw.evidenceSummary === "string" ? raw.evidenceSummary.trim() : "",
+    sourceHash: typeof raw.sourceHash === "string" && raw.sourceHash.trim() ? raw.sourceHash.trim() : null,
+    fingerprint: typeof raw.fingerprint === "string" && raw.fingerprint.trim()
+      ? raw.fingerprint.trim()
+      : userMemoryRuleFingerprint(rule, category),
+    enabled: raw.enabled !== false,
+    createdAt,
+    updatedAt: typeof raw.updatedAt === "number" && Number.isFinite(raw.updatedAt) ? raw.updatedAt : createdAt,
+    scope: normalizeScope(raw.scope),
+    projectKey: typeof raw.projectKey === "string" && raw.projectKey.trim() ? raw.projectKey.trim() : null,
+    sessionKey: typeof raw.sessionKey === "string" && raw.sessionKey.trim() ? raw.sessionKey.trim() : null,
+    status: normalizeStatus(raw.status, source),
+    evidenceCount: positiveInteger(raw.evidenceCount, 1),
+    lastEvidenceAt: finiteNonNegative(raw.lastEvidenceAt, createdAt),
+    expiresAt: typeof raw.expiresAt === "number" && Number.isFinite(raw.expiresAt) ? raw.expiresAt : null,
+    usageCount: finiteNonNegative(raw.usageCount, 0),
+    lastUsedAt: typeof raw.lastUsedAt === "number" && Number.isFinite(raw.lastUsedAt) ? raw.lastUsedAt : null,
+    positiveFeedback: finiteNonNegative(raw.positiveFeedback, 0),
+    negativeFeedback: finiteNonNegative(raw.negativeFeedback, 0),
+    conflictsWith: uniqueStrings(raw.conflictsWith),
+  }
+}
+
+function pruneRules(rules: UserMemoryRule[], maxRules: number): UserMemoryRule[] {
+  if (rules.length <= maxRules) return rules
+  const manual = rules.filter((rule) => rule.source === "manual")
+  const slots = Math.max(0, maxRules - manual.length)
+  const statusRank: Record<UserMemoryStatus, number> = { active: 3, conflicted: 2, candidate: 1, expired: 0 }
+  const automatic = rules
+    .filter((rule) => rule.source === "automatic")
+    .sort((left, right) => (
+      statusRank[right.status ?? "active"] - statusRank[left.status ?? "active"]
+      || right.confidence - left.confidence
+      || (right.usageCount ?? 0) - (left.usageCount ?? 0)
+      || right.updatedAt - left.updatedAt
+    ))
+    .slice(0, slots)
+  const keep = new Set([...manual, ...automatic].map((rule) => rule.id))
+  return rules.filter((rule) => keep.has(rule.id))
+}
+
+function estimatedConfigBytes(config: GlobalUserMemoryConfig): number {
+  return JSON.stringify(config).length * 2
+}
+
+function enforceStorageByteLimit(config: GlobalUserMemoryConfig): GlobalUserMemoryConfig {
+  if (estimatedConfigBytes(config) <= config.maxStorageBytes) return config
+  const removable = config.rules
+    .filter((rule) => rule.source === "automatic")
+    .sort((left, right) => {
+      const rank: Record<UserMemoryStatus, number> = { expired: 0, candidate: 1, conflicted: 2, active: 3 }
+      return rank[left.status ?? "active"] - rank[right.status ?? "active"]
+        || left.confidence - right.confidence
+        || (left.usageCount ?? 0) - (right.usageCount ?? 0)
+        || left.updatedAt - right.updatedAt
+    })
+  let rules = [...config.rules]
+  for (const rule of removable) {
+    rules = rules.filter((item) => item.id !== rule.id)
+    const next = { ...config, rules }
+    if (estimatedConfigBytes(next) <= config.maxStorageBytes) return next
+  }
+  return { ...config, rules }
+}
+
+export function normalizeGlobalUserMemoryConfig(value: unknown): GlobalUserMemoryConfig {
+  if (!value || typeof value !== "object") return defaultConfig()
+  const raw = value as Partial<GlobalUserMemoryConfig>
+  const rules = Array.isArray(raw.rules)
+    ? raw.rules.map(normalizeRule).filter((rule): rule is UserMemoryRule => Boolean(rule))
+    : []
+  const dedupedRules = rules.filter((rule, index, all) => all.findIndex((item) => item.id === rule.id) === index)
+  const maxRules = positiveInteger(raw.maxRules, DEFAULT_MAX_RULES)
+  const maxAnalyzedHashes = positiveInteger(raw.maxAnalyzedHashes, DEFAULT_MAX_ANALYZED_HASHES)
+  const normalized: GlobalUserMemoryConfig = {
+    version: 2,
+    enabled: raw.enabled !== false,
+    autoLearn: raw.autoLearn !== false,
+    autoRead: raw.autoRead !== false,
+    rules: pruneRules(dedupedRules, maxRules),
+    analyzedSourceHashes: uniqueStrings(raw.analyzedSourceHashes).slice(-maxAnalyzedHashes),
+    deletedFingerprints: uniqueStrings(raw.deletedFingerprints),
+    updatedAt: typeof raw.updatedAt === "number" && Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0,
+    onlyManual: raw.onlyManual === true,
+    dailyLearningLimit: positiveInteger(raw.dailyLearningLimit, 20),
+    batchSize: positiveInteger(raw.batchSize, 3),
+    candidatePromotionThreshold: positiveInteger(raw.candidatePromotionThreshold, 2),
+    maxRules,
+    maxAnalyzedHashes,
+    maxStorageBytes: positiveInteger(raw.maxStorageBytes, DEFAULT_MAX_STORAGE_BYTES),
+  }
+  return enforceStorageByteLimit(normalized)
+}
+
+export function loadGlobalUserMemoryConfig(storage: StorageLike | null = defaultStorage()): GlobalUserMemoryConfig {
+  if (!storage) return defaultConfig()
+  try {
+    const raw = storage.getItem(GLOBAL_USER_MEMORY_STORAGE_KEY)
+    return raw ? normalizeGlobalUserMemoryConfig(JSON.parse(raw)) : defaultConfig()
+  } catch {
+    return defaultConfig()
+  }
+}
+
+export function saveGlobalUserMemoryConfig(config: GlobalUserMemoryConfig, storage: StorageLike | null = defaultStorage()): void {
+  if (!storage) return
+  const normalized = normalizeGlobalUserMemoryConfig(config)
+  try {
+    storage.setItem(GLOBAL_USER_MEMORY_STORAGE_KEY, JSON.stringify(normalized))
+    if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(GLOBAL_USER_MEMORY_CHANGED_EVENT))
+  } catch {
+    // 用户记忆不可用不应阻断主流程。
+  }
+}
+
+function createId(now: number): string {
+  try {
+    return typeof crypto !== "undefined" && "randomUUID" in crypto
+      ? `memory:${crypto.randomUUID()}`
+      : `memory:${now}:${Math.random().toString(36).slice(2)}`
+  } catch {
+    return `memory:${now}:${Math.random().toString(36).slice(2)}`
+  }
+}
+
+export function addManualUserMemoryRule(
+  config: GlobalUserMemoryConfig,
+  input: ManualUserMemoryRuleInput,
+  now = Date.now(),
+): GlobalUserMemoryConfig {
+  const rule = input.rule.trim()
+  if (!rule) return config
+  const fingerprint = userMemoryRuleFingerprint(rule, input.category)
+  return normalizeGlobalUserMemoryConfig({
+    ...config,
+    rules: [...config.rules, {
+      id: createId(now),
+      rule,
+      category: input.category,
+      source: "manual",
+      surfaces: input.surfaces,
+      confidence: 1,
+      evidenceSummary: "用户手动添加",
+      sourceHash: null,
+      fingerprint,
+      enabled: true,
+      createdAt: now,
+      updatedAt: now,
+      scope: input.scope ?? "global",
+      projectKey: input.projectKey ?? null,
+      sessionKey: input.sessionKey ?? null,
+      status: "active",
+      evidenceCount: 1,
+      lastEvidenceAt: now,
+      expiresAt: null,
+      usageCount: 0,
+      lastUsedAt: null,
+      positiveFeedback: 0,
+      negativeFeedback: 0,
+      conflictsWith: [],
+    }],
+    updatedAt: now,
+  })
+}
+
+export function upsertAutomaticUserMemoryRule(
+  config: GlobalUserMemoryConfig,
+  input: AutomaticUserMemoryRuleInput,
+  now = Date.now(),
+): GlobalUserMemoryConfig {
+  const rule = input.rule.trim()
+  if (!rule || !input.sourceHash.trim()) return config
+  const fingerprint = userMemoryRuleFingerprint(rule, input.category)
+  const analyzedSourceHashes = [...new Set([...config.analyzedSourceHashes, input.sourceHash])]
+  if (config.deletedFingerprints.includes(fingerprint)) {
+    return { ...config, analyzedSourceHashes, updatedAt: now }
+  }
+  const existingIndex = config.rules.findIndex((item) => item.fingerprint === fingerprint)
+  const nextRule: UserMemoryRule = existingIndex >= 0
+    ? {
+        ...config.rules[existingIndex]!,
+        rule,
+        category: input.category,
+        surfaces: [...new Set([...config.rules[existingIndex]!.surfaces, ...input.surfaces])],
+        confidence: Math.max(config.rules[existingIndex]!.confidence, input.confidence),
+        evidenceSummary: input.evidenceSummary.trim(),
+        sourceHash: input.sourceHash,
+        fingerprint,
+        updatedAt: now,
+        evidenceCount: (config.rules[existingIndex]!.evidenceCount ?? 1) + 1,
+        lastEvidenceAt: now,
+      }
+    : {
+        id: createId(now),
+        rule,
+        category: input.category,
+        source: "automatic",
+        surfaces: input.surfaces,
+        confidence: Math.max(0, Math.min(1, input.confidence)),
+        evidenceSummary: input.evidenceSummary.trim(),
+        sourceHash: input.sourceHash,
+        fingerprint,
+        enabled: true,
+        createdAt: now,
+        updatedAt: now,
+        scope: input.scope ?? "global",
+        projectKey: input.projectKey ?? null,
+        sessionKey: input.sessionKey ?? null,
+        status: "candidate",
+        evidenceCount: 1,
+        lastEvidenceAt: now,
+        expiresAt: now + (input.scope === "session" ? 24 : 30 * 24) * 60 * 60 * 1000,
+        usageCount: 0,
+        lastUsedAt: null,
+        positiveFeedback: 0,
+        negativeFeedback: 0,
+        conflictsWith: [],
+      }
+  const rules = existingIndex >= 0
+    ? config.rules.map((item, index) => index === existingIndex ? nextRule : item)
+    : [...config.rules, nextRule]
+  return normalizeGlobalUserMemoryConfig({ ...config, rules, analyzedSourceHashes, updatedAt: now })
+}
+
+export function updateUserMemoryRule(
+  config: GlobalUserMemoryConfig,
+  id: string,
+  patch: Pick<Partial<UserMemoryRule>, "rule" | "category" | "surfaces" | "enabled">,
+  now = Date.now(),
+): GlobalUserMemoryConfig {
+  return normalizeGlobalUserMemoryConfig({
+    ...config,
+    rules: config.rules.map((item) => {
+      if (item.id !== id) return item
+      const rule = typeof patch.rule === "string" && patch.rule.trim() ? patch.rule.trim() : item.rule
+      const category = patch.category ?? item.category
+      return {
+        ...item,
+        ...patch,
+        rule,
+        category,
+        fingerprint: userMemoryRuleFingerprint(rule, category),
+        updatedAt: now,
+      }
+    }),
+    updatedAt: now,
+  })
+}
+
+export function setUserMemoryRuleEnabled(config: GlobalUserMemoryConfig, id: string, enabled: boolean, now = Date.now()): GlobalUserMemoryConfig {
+  return updateUserMemoryRule(config, id, { enabled }, now)
+}
+
+export function deleteUserMemoryRule(config: GlobalUserMemoryConfig, id: string, now = Date.now()): GlobalUserMemoryConfig {
+  const target = config.rules.find((item) => item.id === id)
+  if (!target) return config
+  const deletedFingerprints = target.source === "automatic"
+    ? [...new Set([...config.deletedFingerprints, target.fingerprint])]
+    : config.deletedFingerprints
+  return normalizeGlobalUserMemoryConfig({
+    ...config,
+    rules: config.rules.filter((item) => item.id !== id),
+    deletedFingerprints,
+    updatedAt: now,
+  })
+}
+
+export function updateGlobalUserMemorySettings(
+  config: GlobalUserMemoryConfig,
+  patch: Pick<Partial<GlobalUserMemoryConfig>, "enabled" | "autoLearn" | "autoRead" | "onlyManual">,
+  now = Date.now(),
+): GlobalUserMemoryConfig {
+  return normalizeGlobalUserMemoryConfig({ ...config, ...patch, updatedAt: now })
+}
+
+export interface GlobalUserMemoryStats {
+  totalRules: number
+  manualRules: number
+  candidateRules: number
+  activeRules: number
+  conflictedRules: number
+  expiredRules: number
+  estimatedBytes: number
+  maxStorageBytes: number
+}
+
+export function getGlobalUserMemoryStats(config: GlobalUserMemoryConfig): GlobalUserMemoryStats {
+  return {
+    totalRules: config.rules.length,
+    manualRules: config.rules.filter((rule) => rule.source === "manual").length,
+    candidateRules: config.rules.filter((rule) => rule.status === "candidate").length,
+    activeRules: config.rules.filter((rule) => (rule.status ?? "active") === "active").length,
+    conflictedRules: config.rules.filter((rule) => rule.status === "conflicted").length,
+    expiredRules: config.rules.filter((rule) => rule.status === "expired").length,
+    estimatedBytes: estimatedConfigBytes(config),
+    maxStorageBytes: config.maxStorageBytes,
+  }
+}
+
+export function exportGlobalUserMemoryJson(config: GlobalUserMemoryConfig): string {
+  return JSON.stringify(normalizeGlobalUserMemoryConfig(config), null, 2)
+}
+
+export function clearGlobalUserMemoryConfig(storage: StorageLike | null = defaultStorage()): void {
+  saveGlobalUserMemoryConfig(defaultConfig(), storage)
+  resetUserMemoryLearningBudget(storage)
+}

+ 88 - 0
src/lib/user-memory/types.ts

@@ -0,0 +1,88 @@
+export type UserMemoryCategory =
+  | "output_style"
+  | "writing_preference"
+  | "outline_preference"
+  | "workflow_preference"
+  | "interaction_preference"
+  | "format_preference"
+  | "constraint"
+  | "manual"
+
+export type UserMemorySource = "automatic" | "manual"
+export type UserMemoryScope = "global" | "project" | "session"
+export type UserMemoryStatus = "candidate" | "active" | "conflicted" | "expired"
+
+export type UserMemorySurface =
+  | "all"
+  | "ai-chat"
+  | "ai-outline"
+  | "chapter-writing"
+  | "book-analysis"
+  | "review"
+  | "analysis"
+
+export interface UserMemoryRule {
+  id: string
+  rule: string
+  category: UserMemoryCategory
+  source: UserMemorySource
+  surfaces: UserMemorySurface[]
+  confidence: number
+  evidenceSummary: string
+  sourceHash: string | null
+  fingerprint: string
+  enabled: boolean
+  createdAt: number
+  updatedAt: number
+  scope?: UserMemoryScope
+  projectKey?: string | null
+  sessionKey?: string | null
+  status?: UserMemoryStatus
+  evidenceCount?: number
+  lastEvidenceAt?: number
+  expiresAt?: number | null
+  usageCount?: number
+  lastUsedAt?: number | null
+  positiveFeedback?: number
+  negativeFeedback?: number
+  conflictsWith?: string[]
+}
+
+export interface GlobalUserMemoryConfig {
+  version: 2
+  enabled: boolean
+  autoLearn: boolean
+  autoRead: boolean
+  rules: UserMemoryRule[]
+  analyzedSourceHashes: string[]
+  deletedFingerprints: string[]
+  updatedAt: number
+  onlyManual: boolean
+  dailyLearningLimit: number
+  batchSize: number
+  candidatePromotionThreshold: number
+  maxRules: number
+  maxAnalyzedHashes: number
+  maxStorageBytes: number
+}
+
+export interface AutomaticUserMemoryRuleInput {
+  rule: string
+  category: UserMemoryCategory
+  surfaces: UserMemorySurface[]
+  confidence: number
+  evidenceSummary: string
+  sourceHash: string
+  scope?: UserMemoryScope
+  projectKey?: string | null
+  sessionKey?: string | null
+}
+
+export interface ManualUserMemoryRuleInput {
+  rule: string
+  category: UserMemoryCategory
+  surfaces: UserMemorySurface[]
+  scope?: UserMemoryScope
+  projectKey?: string | null
+  sessionKey?: string | null
+}