mindforge-memory 6.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. MindForge.py +20 -0
  2. mindforge/__init__.py +111 -0
  3. mindforge/adapters/__init__.py +18 -0
  4. mindforge/adapters/claude_adapter.py +84 -0
  5. mindforge/adapters/crewai_memory.py +76 -0
  6. mindforge/adapters/generic_api.py +183 -0
  7. mindforge/adapters/langgraph_store.py +463 -0
  8. mindforge/adapters/openclaw_adapter.py +91 -0
  9. mindforge/api/__init__.py +1 -0
  10. mindforge/api/server.py +1567 -0
  11. mindforge/cli/__init__.py +1 -0
  12. mindforge/cli/main.py +13607 -0
  13. mindforge/core/__init__.py +37 -0
  14. mindforge/core/embedding.py +847 -0
  15. mindforge/core/encryption.py +597 -0
  16. mindforge/core/indexer.py +363 -0
  17. mindforge/core/mindforge.py +6298 -0
  18. mindforge/core/paths.py +43 -0
  19. mindforge/core/query.py +501 -0
  20. mindforge/core/storage.py +18656 -0
  21. mindforge/core/types.py +314 -0
  22. mindforge/core/version.py +56 -0
  23. mindforge/daemon/__init__.py +0 -0
  24. mindforge/daemon/server.py +277 -0
  25. mindforge/mcp/__init__.py +4 -0
  26. mindforge/mcp/server.py +2559 -0
  27. mindforge/modules/__init__.py +138 -0
  28. mindforge/modules/categorizer.py +151 -0
  29. mindforge/modules/conflict_detector.py +356 -0
  30. mindforge/modules/connectors.py +331 -0
  31. mindforge/modules/context_window.py +337 -0
  32. mindforge/modules/event_bus.py +572 -0
  33. mindforge/modules/evolution.py +210 -0
  34. mindforge/modules/experience.py +402 -0
  35. mindforge/modules/federated.py +722 -0
  36. mindforge/modules/federated_acl.py +393 -0
  37. mindforge/modules/follow_up.py +315 -0
  38. mindforge/modules/hybrid_search.py +565 -0
  39. mindforge/modules/importer.py +374 -0
  40. mindforge/modules/integrator.py +196 -0
  41. mindforge/modules/intent_router.py +359 -0
  42. mindforge/modules/knowledge_graph.py +395 -0
  43. mindforge/modules/memory_compressor.py +592 -0
  44. mindforge/modules/memory_decay.py +474 -0
  45. mindforge/modules/memory_passport.py +250 -0
  46. mindforge/modules/multi_agent.py +534 -0
  47. mindforge/modules/multimodal.py +304 -0
  48. mindforge/modules/outcome_learning.py +186 -0
  49. mindforge/modules/personality.py +275 -0
  50. mindforge/modules/principled_forgetting.py +258 -0
  51. mindforge/modules/privacy.py +736 -0
  52. mindforge/modules/recall.py +263 -0
  53. mindforge/modules/session_focus.py +305 -0
  54. mindforge/modules/share_conflict.py +521 -0
  55. mindforge/modules/skill_extractor.py +340 -0
  56. mindforge/modules/skill_pack.py +205 -0
  57. mindforge/modules/timeline_viz.py +206 -0
  58. mindforge/modules/v6.py +931 -0
  59. mindforge_memory-6.4.1.dist-info/METADATA +281 -0
  60. mindforge_memory-6.4.1.dist-info/RECORD +64 -0
  61. mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
  62. mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
  63. mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
  64. mindforge_memory-6.4.1.dist-info/top_level.txt +2 -0
@@ -0,0 +1,43 @@
1
+ """
2
+ MindForge 默认文件系统路径(v5.6.5 P2 #18)
3
+
4
+ 统一 CLI 与 MCP 两个入口的默认数据库位置,消除此前 CLI 用 cwd 相对的
5
+ ``./data/memory.db``、MCP 用 ``~/.MindForge/data/store/memory.db`` 导致的
6
+ “同一份数据在不同入口看不到”的问题。
7
+
8
+ 解析优先级:
9
+ 1. 环境变量 ``MINDFORGE_DB_PATH``(显式指定,最高优先级);
10
+ 2. 跨 cwd 稳定的用户级目录 ``~/.MindForge/data/store/memory.db``。
11
+
12
+ 注意:``core.storage.StorageEngine`` / ``core.types.MemoryConfig`` 的库级默认
13
+ 仍是 ``./data/memory.db``(文档化的库用法,不在本次入口统一范围内改动);
14
+ 本模块只统一**应用入口**(CLI / MCP)的默认值。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from pathlib import Path
21
+
22
+ #: 当入口未显式指定数据库、也未设置环境变量时使用的用户级默认目录(相对 home)。
23
+ _DEFAULT_DB_REL = (".MindForge", "data", "store")
24
+ _DEFAULT_DB_NAME = "memory.db"
25
+
26
+
27
+ def default_data_dir() -> Path:
28
+ """返回用户级默认数据目录(必要时创建)。"""
29
+ root = Path(os.path.expanduser("~")).joinpath(*_DEFAULT_DB_REL)
30
+ root.mkdir(parents=True, exist_ok=True)
31
+ return root
32
+
33
+
34
+ def get_default_db_path() -> str:
35
+ """返回入口层默认数据库路径。
36
+
37
+ 优先采用 ``MINDFORGE_DB_PATH`` 环境变量;否则返回
38
+ ``~/.MindForge/data/store/memory.db``(绝对路径,与当前工作目录无关)。
39
+ """
40
+ env = os.environ.get("MINDFORGE_DB_PATH", "").strip()
41
+ if env:
42
+ return env
43
+ return str(default_data_dir() / _DEFAULT_DB_NAME)
@@ -0,0 +1,501 @@
1
+ """
2
+ MindForge 查询引擎
3
+ 语义检索 + 知识图谱查询 + 上下文优化
4
+ """
5
+
6
+ import logging
7
+ from dataclasses import dataclass, field
8
+ from typing import List, Optional, Dict, Any
9
+ from .storage import StorageEngine, MemoryEntry
10
+ from .indexer import IndexEngine
11
+ from .types import MemoryLayer
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # v5.4.8 P2-002/P3-003 修复:向量降级只警告一次
16
+ _vector_degradation_warned = False
17
+
18
+ # v5.5.5 P0 修复:向量最低分数阈值,低于此值的向量结果不参与融合
19
+ # 防止低质量向量结果干扰文本检索
20
+ VECTOR_MIN_SCORE_THRESHOLD = 0.40
21
+
22
+ # v5.5.5 P0 修复:文本+向量加权融合权重
23
+ # 文本检索更精准,权重更高;向量检索补充语义,权重较低
24
+ TEXT_WEIGHT = 0.6
25
+ VECTOR_WEIGHT = 0.4
26
+
27
+ # v6.3.0 explain:explain=False 时的占位 breakdown(写入被丢弃,零逻辑分叉)
28
+ _NO_BREAKDOWN: Dict[str, Any] = {}
29
+
30
+
31
+ @dataclass
32
+ class MemoryChunk:
33
+ """记忆片段"""
34
+ memory_id: str
35
+ content: str
36
+ category: str = "general"
37
+ relevance_score: float = 0.0
38
+ layer: MemoryLayer = MemoryLayer.SHORT_TERM
39
+ tags: List[str] = field(default_factory=list)
40
+ metadata: Dict[str, Any] = field(default_factory=dict)
41
+ # v6.3.0 新增:检索可解释性——各召回路的原始分数与最终胜出路径。
42
+ # 仅在 search(explain=True) 时填充,其余路径恒为 None(序列化兼容)。
43
+ # 结构: {"tfidf": 0.42, "fts5": 0.31, "fuzzy": 0.0, "vector": 0.0,
44
+ # "final": 0.42, "winner": "tfidf",
45
+ # "fusion": {"text":…, "vector":…, "fused":…}(仅融合时存在)}
46
+ score_breakdown: Optional[Dict[str, Any]] = None
47
+
48
+
49
+ @dataclass
50
+ class RecallResult:
51
+ """召回结果"""
52
+ chunks: List[MemoryChunk]
53
+ total_found: int
54
+ query_time_ms: float
55
+ strategy_used: str
56
+ token_estimate: int
57
+ layers_used: List[str] = field(default_factory=list)
58
+ # v5.7.0 P1:加密兜底启用时为 True,表示部分结果来自全量解密扫描
59
+ approximate: bool = False
60
+
61
+
62
+ # v5.5.5 P1 新增:中文技术同义词词典(50+ 组)
63
+ # 用于查询扩展,提升中文检索召回率
64
+ _CHINESE_SYNONYMS: Dict[str, List[str]] = {
65
+ # 性能/速度
66
+ "慢": ["延迟", "卡顿", "性能差", "速度慢", "响应慢"],
67
+ "延迟": ["慢", "卡顿", "响应时间", "等待时间", "耗时"],
68
+ "卡顿": ["慢", "延迟", "不流畅", "掉帧", "响应慢"],
69
+ "性能": ["速度", "效率", "吞吐量", "响应速度", "并发能力"],
70
+ "速度慢": ["性能差", "慢", "卡顿", "延迟高", "效率低"],
71
+ "性能差": ["速度慢", "慢", "低效", "吞吐低", "响应慢"],
72
+
73
+ # 数据库/存储
74
+ "数据库": ["db", "mysql", "postgresql", "sql server", "存储"],
75
+ "慢查询": ["查询慢", "sql 慢", "数据库慢", "查询性能差"],
76
+ "索引": ["index", "检索优化", "查询加速", "数据库索引"],
77
+ "缓存": ["cache", "redis", "memcached", "缓冲"],
78
+ "备份": ["backup", "快照", "容灾", "数据备份"],
79
+ "恢复": ["restore", "回滚", "数据恢复", "灾备恢复"],
80
+
81
+ # 安全
82
+ "安全": ["security", "防护", "安全防护", "信息安全"],
83
+ "加密": ["encryption", "密文", "数据加密", "加密保护"],
84
+ "权限": ["permission", "access control", "访问控制", "授权"],
85
+ "漏洞": ["vulnerability", "bug", "安全漏洞", "缺陷"],
86
+ "攻击": ["attack", "入侵", "黑客", "网络攻击"],
87
+ "防护": ["protection", "防御", "安全防护", "防护措施"],
88
+
89
+ # 监控/运维
90
+ "监控": ["monitor", "监控系统", "观测", "告警"],
91
+ "告警": ["alert", "报警", "通知", "预警"],
92
+ "日志": ["log", "logging", "系统日志", "运行日志"],
93
+ "运维": ["ops", "devops", "系统运维", "运维管理"],
94
+ "部署": ["deploy", "发布", "上线", "部署上线"],
95
+ "扩容": ["scaling", "扩展", "弹性伸缩", "加机器"],
96
+
97
+ # 错误/异常
98
+ "错误": ["error", "bug", "异常", "问题"],
99
+ "异常": ["exception", "错误", "不正常", "出问题"],
100
+ "崩溃": ["crash", "挂了", "宕机", "退出"],
101
+ "失败": ["fail", "失败了", "不成功", "出错"],
102
+ "超时": ["timeout", "超时了", "连接超时", "请求超时"],
103
+
104
+ # 网络
105
+ "网络": ["network", "网络连接", "联网", "通信"],
106
+ "连接": ["connection", "链接", "连接失败", "连接超时"],
107
+ "带宽": ["bandwidth", "网速", "网络速度", "网络带宽"],
108
+ "延迟高": ["网络慢", "高延迟", "ping 高", "网络延迟"],
109
+
110
+ # 代码/开发
111
+ "代码": ["code", "源码", "程序", "脚本"],
112
+ "bug": ["错误", "缺陷", "问题", "漏洞"],
113
+ "重构": ["refactor", "代码重构", "重写", "优化代码"],
114
+ "测试": ["test", "测试用例", "单元测试", "自动化测试"],
115
+ "调试": ["debug", "排错", "调试代码", "问题定位"],
116
+
117
+ # 服务器/基础设施
118
+ "服务器": ["server", "主机", "机器", "服务端"],
119
+ "容器": ["container", "docker", "k8s", "容器化"],
120
+ "集群": ["cluster", "集群部署", "分布式集群", "节点集群"],
121
+ "负载均衡": ["load balancer", "lb", "nginx", "流量分发"],
122
+
123
+ # 接口/API
124
+ "接口": ["api", "接口调用", "服务接口", "rest api"],
125
+ "请求": ["request", "调用", "http 请求", "api 请求"],
126
+ "响应": ["response", "返回", "返回结果", "响应结果"],
127
+
128
+ # AI/大模型
129
+ "大模型": ["llm", "ai 模型", "语言模型", "gpt"],
130
+ "向量": ["vector", "embedding", "嵌入向量", "向量化"],
131
+ "提示词": ["prompt", "提示", "指令", "system prompt"],
132
+ "token": ["令牌", "词元", "tokens", "token 数"],
133
+ }
134
+
135
+
136
+ class QueryEngine:
137
+ """查询引擎"""
138
+
139
+ def __init__(self, storage: StorageEngine, index: IndexEngine):
140
+ self.storage = storage
141
+ self.index = index
142
+
143
+ @staticmethod
144
+ def expand_query(query: str, max_expansions: int = 3) -> List[str]:
145
+ """查询扩展:基于中文技术同义词词典扩展查询词
146
+
147
+ v5.5.5 P1 新增。提升中文检索召回率。
148
+
149
+ Args:
150
+ query: 原始查询词
151
+ max_expansions: 每个词最多扩展的同义词数量
152
+
153
+ Returns:
154
+ 扩展后的查询词列表(含原始查询词)
155
+ """
156
+ if not query or not query.strip():
157
+ return [query] if query else []
158
+
159
+ # v5.6.2 安全修复:超长输入截断,防止 DoS
160
+ _MAX_QUERY_LEN = 1000
161
+ if len(query) > _MAX_QUERY_LEN:
162
+ query = query[:_MAX_QUERY_LEN]
163
+
164
+ # 简单分词:按空格、标点分割,保留中文词
165
+ import re
166
+ tokens = re.findall(r'[\u4e00-\u9fff]+|[a-zA-Z0-9_-]+', query.lower())
167
+ if not tokens:
168
+ return [query]
169
+
170
+ expanded_terms = set()
171
+ expanded_terms.add(query) # 保留原始查询
172
+
173
+ for token in tokens:
174
+ if token in _CHINESE_SYNONYMS:
175
+ synonyms = _CHINESE_SYNONYMS[token][:max_expansions]
176
+ for syn in synonyms:
177
+ expanded_terms.add(syn)
178
+ # 同时添加 "原词 + 同义词" 的组合
179
+ if token != syn:
180
+ expanded_terms.add(f"{token} {syn}")
181
+
182
+ return list(expanded_terms)
183
+
184
+ def search(self,
185
+ query: str,
186
+ max_results: int = 10,
187
+ min_relevance: float = 0.3,
188
+ categories: Optional[List[str]] = None,
189
+ layers: Optional[List[MemoryLayer]] = None,
190
+ agent_id: str = "",
191
+ session_id: str = "",
192
+ use_embedding: bool = True,
193
+ explain: bool = False,
194
+ *, _update_access: bool = True,
195
+ _record_search: bool = True) -> RecallResult:
196
+ """搜索记忆
197
+
198
+ v5.4.5 新增向量检索两阶段搜索:
199
+ 1. 向量召回:通过嵌入向量余弦相似度召回语义相近的记忆
200
+ 2. 多路融合:TF-IDF + FTS5 + Fuzzy + 向量,按 id 合并取最高分
201
+ 3. 精排:按综合分数排序,取 top_k
202
+
203
+ 当 use_embedding=False 或 EmbeddingEngine 不可用时,
204
+ 自动降级为 TF-IDF + Fuzzy 两路搜索(v5.4.5 之前的行为)。
205
+
206
+ v6.3.0 新增 explain 参数(检索可解释性):True 时每个
207
+ MemoryChunk.score_breakdown 携带各召回路原始分数、融合
208
+ 明细与最终胜出路径,用于 CLI `search --explain` 与检索
209
+ 质量调试。默认 False,热路径零额外开销。
210
+ """
211
+ import time
212
+ start = time.time()
213
+ if (isinstance(max_results, bool) or not isinstance(max_results, int)
214
+ or max_results < 0):
215
+ raise ValueError("max_results must be a non-negative integer")
216
+ if max_results == 0:
217
+ if _record_search:
218
+ try:
219
+ self.storage.record_search(query)
220
+ except Exception:
221
+ pass
222
+ return RecallResult(
223
+ chunks=[], total_found=0,
224
+ query_time_ms=round((time.time() - start) * 1000, 2),
225
+ strategy_used="none", token_estimate=0, layers_used=[],
226
+ )
227
+
228
+ # v5.2.8 修复:CLI 等短生命周期进程中,内存 TF-IDF 索引为空,
229
+ # 导致跨进程搜索永远返回 0 结果。搜索前先从持久层水合索引。
230
+ if self.index.needs_hydration:
231
+ self.index.hydrate(self.storage.get_indexable_documents())
232
+
233
+ # ===== 第一阶段:多路召回 =====
234
+ # score_map: {memory_id: score},多路结果按 id 合并取最高分
235
+ score_map = {}
236
+ # v6.3.0 explain:各路原始分数与胜出路径(仅 explain=True 时填充)
237
+ breakdown_map: Dict[str, Dict[str, Any]] = {}
238
+
239
+ def _bd(mid: str) -> Dict[str, Any]:
240
+ if not explain:
241
+ return _NO_BREAKDOWN
242
+ bd = breakdown_map.setdefault(mid, {})
243
+ bd.setdefault("tfidf", 0.0)
244
+ bd.setdefault("fts5", 0.0)
245
+ bd.setdefault("fuzzy", 0.0)
246
+ bd.setdefault("vector", 0.0)
247
+ return bd
248
+
249
+ # 路 1:TF-IDF
250
+ tfidf_results = self.index.search(query, top_k=max_results * 3)
251
+ for doc_id, s in tfidf_results:
252
+ if explain:
253
+ _bd(doc_id)["tfidf"] = max(s, _bd(doc_id)["tfidf"])
254
+ if s >= score_map.get(doc_id, 0.0):
255
+ score_map[doc_id] = s
256
+ if explain:
257
+ _bd(doc_id)["winner"] = "tfidf"
258
+ _bd(doc_id)["final"] = s
259
+
260
+ # 路 2:Fuzzy(TF-IDF 召回不足时补充;v5.7.1:加密模式下总是跑,
261
+ # 供加密兜底复用结果,避免二次全表扫描)
262
+ positive = sum(1 for _, s in tfidf_results if s >= min_relevance)
263
+ fuzzy_hits = []
264
+ if self.storage.encrypted or positive < max_results:
265
+ fuzzy_hits = self.storage.fuzzy_search(
266
+ query, limit=max_results * 2, threshold=0.1)
267
+ for item in fuzzy_hits:
268
+ entry = item["entry"]
269
+ s = min(0.95, float(item["score"]))
270
+ if explain:
271
+ _bd(entry.id)["fuzzy"] = max(s, _bd(entry.id)["fuzzy"])
272
+ if s >= score_map.get(entry.id, 0.0):
273
+ score_map[entry.id] = s
274
+ if explain:
275
+ _bd(entry.id)["winner"] = "fuzzy"
276
+ _bd(entry.id)["final"] = s
277
+
278
+ # 路 3:FTS5 全文检索
279
+ fts5_used = False
280
+ # v5.6.2 安全修复:初始化默认值,防止异常路径下 UnboundLocalError
281
+ strategy_used = "tfidf+fuzzy"
282
+ try:
283
+ conn = self.storage._get_conn()
284
+ fts_results = self.index.fts_search(conn, query, top_k=max_results * 3)
285
+ for doc_id, s in fts_results:
286
+ if explain:
287
+ _bd(doc_id)["fts5"] = max(s, _bd(doc_id)["fts5"])
288
+ if s >= score_map.get(doc_id, 0.0):
289
+ score_map[doc_id] = s
290
+ if explain:
291
+ _bd(doc_id)["winner"] = "fts5"
292
+ _bd(doc_id)["final"] = s
293
+ if fts_results:
294
+ fts5_used = True
295
+ strategy_used = "tfidf+fuzzy+fts5"
296
+ else:
297
+ strategy_used = "tfidf+fuzzy"
298
+ except Exception:
299
+ strategy_used = "tfidf+fuzzy"
300
+
301
+ # 路 4:向量召回(v5.4.5 新增,v5.4.8 P2-002/P3-003 修复)
302
+ global _vector_degradation_warned
303
+ if use_embedding:
304
+ # 先检查 engine 是否可用,避免无谓的异常
305
+ engine = self.storage.embedding_engine
306
+ engine_available = engine is not None and getattr(engine, 'is_available', False)
307
+
308
+ if engine_available:
309
+ try:
310
+ vector_results = self.storage.vector_search(
311
+ query, top_k=max_results * 3,
312
+ categories=categories, layers=layers)
313
+ if vector_results:
314
+ strategy_used = "vector+tfidf+fuzzy" + ("+fts5" if fts5_used else "")
315
+ for item in vector_results:
316
+ entry = item["entry"]
317
+ s = float(item["score"])
318
+ # v5.5.5 P0 修复:低于阈值的向量结果不参与融合
319
+ if s < VECTOR_MIN_SCORE_THRESHOLD:
320
+ continue
321
+ existing = score_map.get(entry.id, 0.0)
322
+ if existing > 0:
323
+ # 文本 + 向量均命中 → 加权融合
324
+ fused = existing * TEXT_WEIGHT + s * VECTOR_WEIGHT
325
+ if explain:
326
+ bd = _bd(entry.id)
327
+ bd["vector"] = max(s, bd["vector"])
328
+ bd["fusion"] = {
329
+ "text": round(existing, 4),
330
+ "vector": round(s, 4),
331
+ "text_weight": TEXT_WEIGHT,
332
+ "vector_weight": VECTOR_WEIGHT,
333
+ "fused": round(fused, 4),
334
+ }
335
+ # 融合后分数应高于文本单独分数(体现向量补充价值)
336
+ score_map[entry.id] = max(fused, existing)
337
+ if explain:
338
+ _bd(entry.id)["winner"] = "text+vector"
339
+ _bd(entry.id)["final"] = score_map[entry.id]
340
+ else:
341
+ # 只有向量命中 → 直接使用向量分数
342
+ if explain:
343
+ _bd(entry.id)["vector"] = s
344
+ score_map[entry.id] = s
345
+ if explain:
346
+ _bd(entry.id)["winner"] = "vector"
347
+ _bd(entry.id)["final"] = s
348
+ except Exception as e:
349
+ # 向量搜索异常(非 engine 不可用情况)
350
+ if not _vector_degradation_warned:
351
+ _vector_degradation_warned = True
352
+ logger.warning(
353
+ "向量搜索异常,降级为纯文本检索: %s。"
354
+ "此警告仅显示一次。", e
355
+ )
356
+ else:
357
+ # engine 不可用,只警告一次
358
+ if not _vector_degradation_warned:
359
+ _vector_degradation_warned = True
360
+ logger.warning(
361
+ "向量引擎不可用(可能未安装 sentence-transformers),"
362
+ "降级为 TF-IDF + FTS5 + Fuzzy 三路检索。"
363
+ "语义相似但用词不同的记忆可能无法召回。"
364
+ "安装命令: pip install sentence-transformers"
365
+ )
366
+
367
+ # v5.7.0 P1(v5.5.7 遗留):加密模式兜底标记。
368
+ # v5.7.1 重构:加密模式下路 2 fuzzy_search 必然跑过(见上),其结果
369
+ # 已对加密条目内存解密打分并合并进 score_map。这里只需判断本次召回
370
+ # 是否含加密条目来设置 approximate 标记,不再二次全表扫描。
371
+ approximate = False
372
+ if self.storage.encrypted and fuzzy_hits:
373
+ if any(h["entry"].encrypted for h in fuzzy_hits):
374
+ approximate = True
375
+
376
+ # v5.5.2 perf: entry cache to avoid double-fetch in pre-filter + result build
377
+ # Note: intentionally NOT passing agent_id/session_id to get_memory here,
378
+ # to avoid updating access stats for memories that are filtered out.
379
+ _entry_cache: Dict[str, Any] = {}
380
+
381
+ def _cached_get(mid: str) -> Optional[Any]:
382
+ if mid not in _entry_cache:
383
+ _entry_cache[mid] = self.storage.get_memory(mid)
384
+ return _entry_cache[mid]
385
+
386
+ # 预过滤:按 categories/layers 筛选 score_map,避免非匹配记忆占据排序位
387
+ if categories or layers:
388
+ filtered_map = {}
389
+ for doc_id, score in list(score_map.items()):
390
+ entry = _cached_get(doc_id)
391
+ if not entry:
392
+ continue
393
+ if categories and entry.category not in categories:
394
+ continue
395
+ if layers and entry.layer not in layers:
396
+ continue
397
+ filtered_map[doc_id] = score
398
+ score_map = filtered_map
399
+
400
+ # 合并排序
401
+ raw_results = sorted(score_map.items(), key=lambda x: x[1], reverse=True)
402
+
403
+ # ===== 第二阶段:构建结果 =====
404
+ chunks = []
405
+ seen_contents: set = set()
406
+ for doc_id, score in raw_results:
407
+ if score < min_relevance:
408
+ break
409
+
410
+ entry = _cached_get(doc_id)
411
+ if not entry:
412
+ continue
413
+
414
+ if categories and entry.category not in categories:
415
+ continue
416
+ if layers and entry.layer not in layers:
417
+ continue
418
+
419
+ content_text = entry.content
420
+ # v5.7.1:fuzzy_search 已对加密条目回填明文(entry.content 非空),
421
+ # 直接用;仅未解密(content 为空串,来自 get_memory)时才解密。
422
+ if entry.encrypted and not content_text:
423
+ # v5.6.2 安全修复:单条解密失败不中断整个搜索,跳过坏数据
424
+ try:
425
+ content_text = self.storage.decrypt_content(entry)
426
+ except Exception:
427
+ logger.warning("记忆 %s 解密失败,已跳过", entry.id)
428
+ continue
429
+
430
+ # v5.7.2:内容去重——不同 ID 但 content 完全相同的记忆只保留
431
+ # 分数最高的一条(raw_results 已按分数降序),避免用户重复 add
432
+ # 相同内容时搜索结果出现重复卡片。
433
+ content_key = hash(content_text)
434
+ if content_key in seen_contents:
435
+ continue
436
+ seen_contents.add(content_key)
437
+
438
+ # v6.3.5:已被压缩合并的源记忆不再独立参与召回(由其摘要代表)。
439
+ # 摘要本身无 compressed_into 标记,不受此过滤影响;解压后标记
440
+ # 移除,源恢复独立召回。过滤位于 chunk 构建点,对 query_engine
441
+ # 直调 / recall / CLI / REST / MCP 全部检索路径生效。
442
+ if (entry.metadata or {}).get("compressed_into"):
443
+ continue
444
+
445
+ chunk = MemoryChunk(
446
+ memory_id=entry.id,
447
+ content=content_text,
448
+ category=entry.category,
449
+ relevance_score=score,
450
+ layer=entry.layer,
451
+ tags=entry.tags,
452
+ metadata=entry.metadata,
453
+ # v6.3.0 explain:仅 explain=True 时携带得分分解
454
+ score_breakdown=breakdown_map.get(entry.id) if explain else None,
455
+ )
456
+ chunks.append(chunk)
457
+ # v5.5.2 fix: update access stats for returned results.
458
+ # The cached fetch intentionally skips access updates, so we
459
+ # update them here for memories that actually make it to results.
460
+ if _update_access:
461
+ self.storage._update_access(entry, agent_id, session_id)
462
+
463
+ if len(chunks) >= max_results:
464
+ break
465
+
466
+ # v5.7.0 P2:记录搜索历史(审计写入失败不阻断主路径)
467
+ if _record_search:
468
+ try:
469
+ self.storage.record_search(query)
470
+ except Exception:
471
+ pass
472
+
473
+ elapsed = (time.time() - start) * 1000
474
+ token_estimate = sum(len(c.content) for c in chunks) // 4
475
+
476
+ used_layers = list(set(c.layer.value for c in chunks))
477
+
478
+ return RecallResult(
479
+ chunks=chunks,
480
+ total_found=len(chunks),
481
+ query_time_ms=round(elapsed, 2),
482
+ strategy_used=strategy_used,
483
+ token_estimate=token_estimate,
484
+ layers_used=used_layers,
485
+ approximate=approximate,
486
+ )
487
+
488
+ def get_session_context(self,
489
+ session_id: str,
490
+ max_items: int = 20) -> List[MemoryEntry]:
491
+ """获取会话上下文记忆"""
492
+ all_memories = self.storage.list_memories(limit=max_items * 2)
493
+ session_memories = [
494
+ m for m in all_memories
495
+ if m.source_session == session_id
496
+ ]
497
+ return session_memories[:max_items]
498
+
499
+ def get_by_layer(self, layer: MemoryLayer, limit: int = 50) -> List[MemoryEntry]:
500
+ """按层级获取记忆"""
501
+ return self.storage.list_memories(layer=layer, limit=limit)