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,331 @@
1
+ # -*- coding: utf-8 -*-
2
+ """数据连接器框架(v5.7.7 新增)
3
+
4
+ 插件式 Connector 接口:统一「连接 → 摄取 → 返回统计」契约。
5
+ 内置 File / Json / Csv / Markdown / Url 五类连接器,可注册自定义实现。
6
+
7
+ 与既有导入能力的关系:连接器是统一入口与扩展点,
8
+ JSON / CSV 摄取复用 facade 的 import_json / import_csv(不重复实现),
9
+ Markdown / 文本按段落摄取,URL 摄取带基础 SSRF 防护。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import http.client
15
+ import re
16
+ import socket
17
+ import ipaddress
18
+ import ssl
19
+ import urllib.parse
20
+ import urllib.request
21
+ from abc import ABC, abstractmethod
22
+ from typing import Any, Dict, List, Optional, Type
23
+
24
+
25
+ class BaseConnector(ABC):
26
+ """连接器基类:实现 name / description / ingest 即可注册。"""
27
+
28
+ name: str = ""
29
+ description: str = ""
30
+
31
+ @abstractmethod
32
+ def ingest(self, mf: Any, source: str, **kwargs: Any) -> Dict[str, Any]:
33
+ """摄取入口。
34
+
35
+ Args:
36
+ mf: MindForge 门面实例(用于调用导入能力)
37
+ source: 数据源(文件路径 / URL 等)
38
+ **kwargs: 连接器特定参数
39
+
40
+ Returns:
41
+ 统计结果 dict(至少含 imported / skipped / failed / deduped)
42
+ """
43
+
44
+
45
+ _CONNECTORS: Dict[str, Type[BaseConnector]] = {}
46
+
47
+
48
+ def register_connector(cls: Type[BaseConnector]) -> Type[BaseConnector]:
49
+ """注册连接器(可作装饰器使用)。"""
50
+ if not getattr(cls, "name", ""):
51
+ raise ValueError("连接器必须定义 name")
52
+ _CONNECTORS[cls.name.lower()] = cls
53
+ return cls
54
+
55
+
56
+ def get_connector(name: str) -> Optional[Type[BaseConnector]]:
57
+ """按名称查找连接器(大小写不敏感)。"""
58
+ return _CONNECTORS.get(str(name or "").lower())
59
+
60
+
61
+ def list_connector_names() -> List[str]:
62
+ return sorted(_CONNECTORS.keys())
63
+
64
+
65
+ def list_connectors() -> List[Dict[str, str]]:
66
+ return [{"name": n, "description": _CONNECTORS[n].description}
67
+ for n in list_connector_names()]
68
+
69
+
70
+ # ---------------- 内置连接器 ----------------
71
+
72
+ @register_connector
73
+ class JsonConnector(BaseConnector):
74
+ name = "json"
75
+ description = "从 JSON 文件导入记忆(复用 import_json,支持去重/目标层级)"
76
+
77
+ def ingest(self, mf, source, **kwargs):
78
+ # import_json 不支持 category/source_agent,过滤后再透传
79
+ kwargs.pop("category", None)
80
+ kwargs.pop("source_agent", None)
81
+ return mf.import_json(source, **kwargs)
82
+
83
+
84
+ @register_connector
85
+ class CsvConnector(BaseConnector):
86
+ name = "csv"
87
+ description = "从 CSV 文件导入记忆(复用 import_csv)"
88
+
89
+ def ingest(self, mf, source, **kwargs):
90
+ kwargs.pop("category", None)
91
+ kwargs.pop("source_agent", None)
92
+ return mf.import_csv(source, **kwargs)
93
+
94
+
95
+ @register_connector
96
+ class MarkdownConnector(BaseConnector):
97
+ name = "markdown"
98
+ description = "从 Markdown / 文本文件按段落导入记忆"
99
+
100
+ def ingest(self, mf, source, **kwargs):
101
+ import os
102
+ path = os.path.abspath(source)
103
+ if not os.path.isfile(path):
104
+ raise FileNotFoundError(f"文件不存在: {path}")
105
+ with open(path, "r", encoding="utf-8") as f:
106
+ text = f.read()
107
+ target_layer = kwargs.get("target_layer")
108
+ category = kwargs.get("category") or "import"
109
+ source_agent = kwargs.get("source_agent") or "connector:markdown"
110
+ stats = {"imported": 0, "skipped": 0, "failed": 0, "deduped": 0}
111
+ for para in [p.strip() for p in text.split("\n\n") if p.strip()]:
112
+ if len(para) < 3:
113
+ continue
114
+ try:
115
+ mf.add(para, category=category, source_agent=source_agent,
116
+ layer=target_layer)
117
+ stats["imported"] += 1
118
+ except Exception:
119
+ stats["failed"] += 1
120
+ return stats
121
+
122
+
123
+ @register_connector
124
+ class FileConnector(BaseConnector):
125
+ name = "file"
126
+ description = "按扩展名自动分派(.json / .csv / .md / .txt)"
127
+
128
+ def ingest(self, mf, source, **kwargs):
129
+ import os
130
+ ext = os.path.splitext(str(source))[1].lower()
131
+ mapping = {".json": "json", ".csv": "csv",
132
+ ".md": "markdown", ".txt": "markdown"}
133
+ name = mapping.get(ext)
134
+ if not name:
135
+ raise ValueError(
136
+ f"不支持的文件类型: {ext}(支持 .json / .csv / .md / .txt)")
137
+ return _CONNECTORS[name]().ingest(mf, source, **kwargs)
138
+
139
+
140
+ def _is_private_ip(host: str) -> bool:
141
+ """SSRF 防护:拒绝内网/回环/链路本地/保留/组播/未指定地址(v5.8.3 P1-1)。
142
+
143
+ 用 ipaddress 标准属性判定替代字符串前缀黑名单,覆盖此前可稳定绕过的:
144
+ - 十进制整数 / 八进制编码 IP(getaddrinfo 解析后即真实内网地址)
145
+ - IPv4-mapped IPv6(::ffff:a9fe:a9fe 解包后按 IPv4 判定)
146
+ - CGNAT 100.64.0.0/10(Python<3.13 的 is_private 不含该段)
147
+ - 0.0.0.0(Linux 上 connect 它即访问 127.0.0.1)
148
+ - IPv6 链路本地 fe80::/10 与 ULA fc00::/7(除 ::1 外的内网 IPv6)
149
+ """
150
+ try:
151
+ infos = socket.getaddrinfo(host, None)
152
+ except Exception:
153
+ return True # 解析失败按不安全处理(fail-closed)
154
+ for info in infos:
155
+ raw = info[4][0]
156
+ try:
157
+ ip = ipaddress.ip_address(raw)
158
+ except ValueError:
159
+ return True
160
+ # IPv4-mapped IPv6:解包后按 IPv4 判定(::ffff:169.254.169.254)
161
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
162
+ ip = ip.ipv4_mapped
163
+ if (ip.is_private or ip.is_loopback or ip.is_link_local
164
+ or ip.is_multicast or ip.is_reserved or ip.is_unspecified):
165
+ return True
166
+ # CGNAT 100.64.0.0/10:低版本 is_private 不含,显式补判
167
+ if ip in ipaddress.ip_network("100.64.0.0/10"):
168
+ return True
169
+ return False
170
+
171
+
172
+ def _read_limited(resp, max_bytes: int = 5 * 1024 * 1024) -> bytes:
173
+ """流式分块读取远端响应,累计超过 max_bytes 即中止(v5.8.3 P2-1)。
174
+
175
+ 此前 resp.read() 无上限:攻击者可返回超大 body 造成进程内存膨胀,
176
+ 入库截断(100000 字符)发生在完整读入内存之后。现在超限直接拒绝。
177
+ """
178
+ chunks = []
179
+ total = 0
180
+ while True:
181
+ chunk = resp.read(65536)
182
+ if not chunk:
183
+ break
184
+ total += len(chunk)
185
+ if total > max_bytes:
186
+ raise ValueError(
187
+ f"响应体超过 {max_bytes} 字节上限,已中止读取(防内存放大)")
188
+ chunks.append(chunk)
189
+ return b"".join(chunks)
190
+
191
+
192
+ class _FixedIPHTTPConnection(http.client.HTTPConnection):
193
+ """connect 时用预解析并校验过的 IP 直连,Host header 仍用原域名(v5.8.9 防 DNS rebinding)。"""
194
+ def __init__(self, host, port=None, timeout=15, fixed_ip=None, **kw):
195
+ super().__init__(host, port=port, timeout=timeout, **kw)
196
+ self._fixed_ip = fixed_ip
197
+
198
+ def connect(self):
199
+ sock = socket.create_connection((self._fixed_ip or self.host, self.port),
200
+ timeout=self.timeout)
201
+ self.sock = sock
202
+ if self._tunnel_host:
203
+ self._tunnel()
204
+
205
+
206
+ class _FixedIPHTTPSConnection(http.client.HTTPSConnection):
207
+ """HTTPS 固定 IP 直连:TCP 连 IP,TLS SNI/证书校验仍用原域名。"""
208
+ def __init__(self, host, port=None, timeout=15, fixed_ip=None, **kw):
209
+ super().__init__(host, port=port, timeout=timeout, **kw)
210
+ self._fixed_ip = fixed_ip
211
+
212
+ def connect(self):
213
+ sock = socket.create_connection((self._fixed_ip or self.host, self.port),
214
+ timeout=self.timeout)
215
+ if self._tunnel_host:
216
+ self.sock = sock
217
+ self._tunnel()
218
+ else:
219
+ ctx = getattr(self, "_context", None) or ssl.create_default_context()
220
+ self.sock = ctx.wrap_socket(sock, server_hostname=self.host)
221
+
222
+
223
+ class _FixedIPHTTPHandler(urllib.request.HTTPHandler):
224
+ def __init__(self, fixed_ip):
225
+ super().__init__()
226
+ self._fixed_ip = fixed_ip
227
+
228
+ def http_open(self, req):
229
+ return self.do_open(
230
+ lambda host, **kw: _FixedIPHTTPConnection(host, fixed_ip=self._fixed_ip, **kw), req)
231
+
232
+
233
+ class _FixedIPHTTPSHandler(urllib.request.HTTPSHandler):
234
+ def __init__(self, fixed_ip):
235
+ super().__init__()
236
+ self._fixed_ip = fixed_ip
237
+
238
+ def https_open(self, req):
239
+ return self.do_open(
240
+ lambda host, **kw: _FixedIPHTTPSConnection(host, fixed_ip=self._fixed_ip, **kw), req)
241
+
242
+
243
+ def _resolve_safe_ip(host: str) -> Optional[str]:
244
+ """解析 host,校验所有 A/AAAA 记录均为公网,返回第一个可用 IP。"""
245
+ try:
246
+ infos = socket.getaddrinfo(host, None)
247
+ except Exception:
248
+ return None
249
+ for info in infos:
250
+ raw = info[4][0]
251
+ try:
252
+ ip = ipaddress.ip_address(raw)
253
+ except ValueError:
254
+ return None
255
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
256
+ ip = ip.ipv4_mapped
257
+ if (ip.is_private or ip.is_loopback or ip.is_link_local
258
+ or ip.is_multicast or ip.is_reserved or ip.is_unspecified):
259
+ return None
260
+ if ip in ipaddress.ip_network("100.64.0.0/10"):
261
+ return None
262
+ return raw
263
+ return None
264
+
265
+
266
+ class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
267
+ """重定向逐跳 SSRF 复核(v5.8.3 P1-2)。
268
+
269
+ 默认 HTTPRedirectHandler 自动跟随 3xx 且不重新校验目标地址,攻击者
270
+ 可用公网 URL 302 到内网(如 http://169.254.169.254/latest/meta-data/)。
271
+ 本 handler 对每个跳转目标重新做 scheme/host 校验,并限制最大跳转数。
272
+ """
273
+
274
+ max_redirections = 3
275
+
276
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
277
+ parsed = urllib.parse.urlparse(newurl)
278
+ if parsed.scheme not in ("http", "https"):
279
+ return None # 拒绝跟随到非 http(s) 协议
280
+ newhost = parsed.hostname or ""
281
+ if _is_private_ip(newhost):
282
+ raise ValueError(
283
+ f"拒绝重定向到内网/回环地址: {newhost}(SSRF 防护)")
284
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
285
+
286
+
287
+ @register_connector
288
+ class UrlConnector(BaseConnector):
289
+ name = "url"
290
+ description = ("从网页 URL 抓取文本导入(基础 SSRF 防护;"
291
+ "生产环境建议使用 CLI import-url 的严格 DNS 校验)")
292
+
293
+ def ingest(self, mf, source, **kwargs):
294
+ import urllib.request
295
+ parsed = urllib.parse.urlparse(source)
296
+ if parsed.scheme not in ("http", "https"):
297
+ raise ValueError(f"仅支持 http/https URL,收到: {parsed.scheme}")
298
+ host = parsed.hostname or ""
299
+ if _is_private_ip(host):
300
+ raise ValueError(f"拒绝访问内网/回环地址: {host}(SSRF 防护)")
301
+ # v5.8.9:解析一次、校验全部 IP、用校验过的 IP 直连,消除检查与连接之间的 DNS rebinding TOCTOU
302
+ fixed_ip = _resolve_safe_ip(host)
303
+ if fixed_ip is None:
304
+ raise ValueError(f"无法解析或解析到内网地址: {host}(SSRF 防护)")
305
+ opener = urllib.request.build_opener(
306
+ _SafeRedirectHandler(),
307
+ _FixedIPHTTPHandler(fixed_ip),
308
+ _FixedIPHTTPSHandler(fixed_ip),
309
+ )
310
+ req = urllib.request.Request(
311
+ source, headers={"User-Agent": "MindForge-Connector/5.8.9"})
312
+ with opener.open(req, timeout=kwargs.get("timeout", 15)) as resp:
313
+ content_type = resp.headers.get("Content-Type", "")
314
+ data = _read_limited(resp)
315
+ if "json" in content_type or source.rstrip("/").endswith(".json"):
316
+ raise ValueError(
317
+ "URL 返回 JSON:请先下载后使用 json 连接器导入")
318
+ text = data.decode("utf-8", "replace")
319
+ # 粗略去除 HTML 标签与脚本样式
320
+ text = re.sub(r"<script.*?</script>", " ", text, flags=re.S | re.I)
321
+ text = re.sub(r"<style.*?</style>", " ", text, flags=re.S | re.I)
322
+ text = re.sub(r"<[^>]+>", " ", text)
323
+ text = re.sub(r"\s+", " ", text).strip()
324
+ stats = {"imported": 0, "skipped": 0, "failed": 0, "deduped": 0}
325
+ if len(text) < 3:
326
+ return stats
327
+ mf.add(text[:100000] if len(text) > 100000 else text,
328
+ category=kwargs.get("category") or "url",
329
+ source_agent=kwargs.get("source_agent") or f"connector:url:{host}")
330
+ stats["imported"] = 1
331
+ return stats
@@ -0,0 +1,337 @@
1
+ """
2
+ MindForge v6.1.5 新增:自适应上下文窗口 (Adaptive Context Window)
3
+
4
+ 跨会话上下文连续性引擎:
5
+ - 追踪会话中的查询模式(query pattern),预测下一步可能需要的高频记忆
6
+ - 维护一个「预热候选池」,后续搜索时优先召回已预热条目
7
+ - 基于记忆距离(时间衰减 × 访问间隔 × 图谱关联深度)动态调整 min_relevance
8
+ - 自动管理:会话结束清空,异常恢复
9
+
10
+ 设计约束:
11
+ - 纯本地统计——不依赖外部模型
12
+ - 零侵入:通过 _warm_pool 附加分实现,不改变原有检索逻辑
13
+ - 内存安全:候选池上限 200 条,超限按分数淘汰
14
+ """
15
+
16
+ import logging
17
+ import re
18
+ import time
19
+ from collections import OrderedDict
20
+ from dataclasses import dataclass, field
21
+ from typing import Dict, List, Optional, Any, Set, Tuple
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # 候选池配置
26
+ MAX_POOL_SIZE = 200
27
+ WARM_BOOST = 0.15 # 预热条目额外加分
28
+ PATTERN_WINDOW = 30 # 保留最近 N 次查询模式
29
+
30
+
31
+ @dataclass
32
+ class QueryPattern:
33
+ """单次查询的模式记录"""
34
+ query: str
35
+ timestamp: float
36
+ result_ids: List[str]
37
+ categories_hit: List[str]
38
+ layers_hit: List[str]
39
+
40
+
41
+ @dataclass
42
+ class ContextWindow:
43
+ """会话级上下文窗口"""
44
+ session_id: str
45
+ patterns: List[QueryPattern] = field(default_factory=list)
46
+ warm_pool: "OrderedDict[str, float]" = field(
47
+ default_factory=lambda: OrderedDict()
48
+ )
49
+ _created_at: float = field(default_factory=time.time)
50
+ _last_active: float = field(default_factory=time.time)
51
+
52
+ @property
53
+ def age_seconds(self) -> float:
54
+ return time.time() - self._created_at
55
+
56
+ @property
57
+ def idle_seconds(self) -> float:
58
+ return time.time() - self._last_active
59
+
60
+ def touch(self):
61
+ self._last_active = time.time()
62
+
63
+
64
+ class AdaptiveContextEngine:
65
+ """自适应上下文窗口引擎
66
+
67
+ 使用方式:
68
+ ctx = AdaptiveContextEngine(storage, index)
69
+ # 每次搜索后更新模式
70
+ ctx.record_search(session_id="s1", query="数据库", results=chunks)
71
+ # 下次搜索时获取 boost 加分
72
+ boosts = ctx.get_warm_boosts(session_id="s1", candidates=candidate_ids)
73
+ # 自适应阈值
74
+ threshold = ctx.adaptive_min_relevance(session_id="s1", base=0.3)
75
+ """
76
+
77
+ def __init__(self, storage, index):
78
+ """
79
+ Args:
80
+ storage: StorageEngine 实例
81
+ index: IndexEngine 实例
82
+ """
83
+ self.storage = storage
84
+ self.index = index
85
+ self._windows: Dict[str, ContextWindow] = {}
86
+
87
+ def get_or_create_window(self, session_id: str) -> ContextWindow:
88
+ """获取或创建会话的上下文窗口"""
89
+ if session_id not in self._windows:
90
+ self._windows[session_id] = ContextWindow(session_id=session_id)
91
+ window = self._windows[session_id]
92
+ window.touch()
93
+ return window
94
+
95
+ def record_search(
96
+ self,
97
+ session_id: str,
98
+ query: str,
99
+ results: List[Any],
100
+ categories: Optional[List[str]] = None,
101
+ layers: Optional[List[str]] = None,
102
+ ):
103
+ """记录一次搜索的结果模式,更新候选池。
104
+
105
+ Args:
106
+ session_id: 会话 ID
107
+ query: 原始查询词
108
+ results: MemoryChunk 列表或任何含 memory_id 的对象列表
109
+ categories: 指定过滤类别
110
+ layers: 指定过滤层级
111
+ """
112
+ window = self.get_or_create_window(session_id)
113
+
114
+ # Extract result metadata
115
+ result_ids: List[str] = []
116
+ cats_hit: Set[str] = set()
117
+ layers_hit: Set[str] = set()
118
+
119
+ for r in results:
120
+ mid = getattr(r, "memory_id", None) or (
121
+ r.get("memory_id") if isinstance(r, dict) else None
122
+ )
123
+ if mid:
124
+ result_ids.append(str(mid))
125
+ cat = getattr(r, "category", None) or (
126
+ r.get("category") if isinstance(r, dict) else None
127
+ )
128
+ if cat:
129
+ cats_hit.add(str(cat))
130
+ layer = getattr(r, "layer", None) or (
131
+ r.get("layer") if isinstance(r, dict) else None
132
+ )
133
+ if layer:
134
+ layers_hit.add(str(layer))
135
+
136
+ pattern = QueryPattern(
137
+ query=query,
138
+ timestamp=time.time(),
139
+ result_ids=result_ids,
140
+ categories_hit=sorted(cats_hit),
141
+ layers_hit=sorted(layers_hit),
142
+ )
143
+
144
+ window.patterns.append(pattern)
145
+ # Trim to window size
146
+ if len(window.patterns) > PATTERN_WINDOW:
147
+ window.patterns = window.patterns[-PATTERN_WINDOW:]
148
+
149
+ # Update warm pool: results from this search get boosted
150
+ self._update_warm_pool(window, result_ids)
151
+
152
+ def get_warm_boosts(
153
+ self, session_id: str, candidate_ids: List[str]
154
+ ) -> Dict[str, float]:
155
+ """获取候选记忆的预热加分
156
+
157
+ Args:
158
+ session_id: 会话 ID
159
+ candidate_ids: 本次搜索候选记忆 ID
160
+
161
+ Returns:
162
+ {memory_id: boost_score},只含命中候选池的条目
163
+ """
164
+ if session_id not in self._windows:
165
+ return {}
166
+ window = self._windows[session_id]
167
+ if not window.warm_pool:
168
+ return {}
169
+
170
+ candidate_set = set(candidate_ids)
171
+ boosts: Dict[str, float] = {}
172
+ for mid in candidate_set:
173
+ if mid in window.warm_pool:
174
+ boosts[mid] = window.warm_pool[mid] * WARM_BOOST
175
+ return boosts
176
+
177
+ def adaptive_min_relevance(
178
+ self,
179
+ session_id: str,
180
+ base: float = 0.3,
181
+ min_floor: float = 0.15,
182
+ max_ceiling: float = 0.5,
183
+ ) -> float:
184
+ """根据会话活跃度自适应调整最低相关度阈值。
185
+
186
+ 策略:
187
+ - 刚创建窗口(pattern < 2):用 base
188
+ - 连续快速查询(density >= 5/30s):降低阈值(用户正在探索,宁可多召回)
189
+ - 长时间空闲后重新查询:提高阈值(聚焦性查询)
190
+
191
+ Args:
192
+ session_id: 会话 ID
193
+ base: 基准阈值
194
+ min_floor: 最低允许阈值
195
+ max_ceiling: 最高允许阈值
196
+
197
+ Returns:
198
+ 调整后的 min_relevance
199
+ """
200
+ if session_id not in self._windows:
201
+ return base
202
+
203
+ window = self._windows[session_id]
204
+
205
+ if len(window.patterns) < 2:
206
+ return base
207
+
208
+ # Calculate query density (queries in last 30 seconds)
209
+ recent_cutoff = time.time() - 30
210
+ recent_count = sum(1 for p in window.patterns if p.timestamp > recent_cutoff)
211
+
212
+ if recent_count >= 5:
213
+ # Dense exploration mode: lower threshold to recall more
214
+ return max(min_floor, base - 0.08)
215
+ elif recent_count <= 1 and window.idle_seconds > 60:
216
+ # Cold-start after idle: raise threshold to focus
217
+ return min(max_ceiling, base + 0.05)
218
+ else:
219
+ return base
220
+
221
+ def predict_next(
222
+ self,
223
+ session_id: str,
224
+ current_query: str,
225
+ top_n: int = 5,
226
+ ) -> List[str]:
227
+ """基于历史模式预测下一次搜索最可能需要哪些记忆。
228
+
229
+ 策略:
230
+ 1. 找到与当前查询语义相似的过往模式
231
+ 2. 收集「紧随相似模式之后」的查询结果
232
+ 3. 按出现频率排序返回
233
+
234
+ Args:
235
+ session_id: 会话 ID
236
+ current_query: 当前查询词
237
+ top_n: 返回预测条数
238
+
239
+ Returns:
240
+ 预测的 memory_id 列表
241
+ """
242
+ if session_id not in self._windows:
243
+ return []
244
+ window = self._windows[session_id]
245
+ if len(window.patterns) < 3:
246
+ return []
247
+
248
+ # Tokenize for simple overlap similarity
249
+ current_tokens: Set[str] = set(self._tokenize(current_query))
250
+ if not current_tokens:
251
+ return []
252
+
253
+ # Find similar past patterns (exclude last one)
254
+ similar_follow_up_ids: Dict[str, int] = {}
255
+
256
+ for i, pattern in enumerate(window.patterns[:-1]):
257
+ past_tokens = set(self._tokenize(pattern.query))
258
+ if not past_tokens:
259
+ continue
260
+ overlap = len(current_tokens & past_tokens) / max(
261
+ len(current_tokens | past_tokens), 1
262
+ )
263
+ if overlap >= 0.3:
264
+ # Collect results from the NEXT pattern
265
+ next_pattern = window.patterns[i + 1]
266
+ for mid in next_pattern.result_ids:
267
+ similar_follow_up_ids[mid] = similar_follow_up_ids.get(mid, 0) + 1
268
+
269
+ # Sort by frequency
270
+ sorted_ids = sorted(
271
+ similar_follow_up_ids.items(), key=lambda x: x[1], reverse=True
272
+ )
273
+ return [mid for mid, _ in sorted_ids[:top_n]]
274
+
275
+ def close_session(self, session_id: str) -> Dict[str, Any]:
276
+ """关闭并清理会话的上下文窗口"""
277
+ window = self._windows.pop(session_id, None)
278
+ if window:
279
+ return {
280
+ "closed": True,
281
+ "patterns_count": len(window.patterns),
282
+ "warm_pool_size": len(window.warm_pool),
283
+ "session_age_seconds": round(window.age_seconds, 1),
284
+ }
285
+ return {"closed": False, "reason": "session not found"}
286
+
287
+ def session_stats(self, session_id: str) -> Optional[Dict[str, Any]]:
288
+ """查看会话状态"""
289
+ window = self._windows.get(session_id)
290
+ if not window:
291
+ return None
292
+ return {
293
+ "session_id": session_id,
294
+ "patterns": len(window.patterns),
295
+ "warm_pool_size": len(window.warm_pool),
296
+ "age_seconds": round(window.age_seconds, 1),
297
+ "idle_seconds": round(window.idle_seconds, 1),
298
+ "top_categories": self._get_top_categories(window),
299
+ }
300
+
301
+ # ===================================================================
302
+ # Private
303
+ # ===================================================================
304
+
305
+ def _update_warm_pool(self, window: ContextWindow, result_ids: List[str]):
306
+ """更新候选池:新命中的条目分数递增,最旧的淘汰"""
307
+ for mid in result_ids:
308
+ current = window.warm_pool.get(mid, 0.0)
309
+ window.warm_pool[mid] = current + 1.0
310
+ # Move to end (most recent access)
311
+ window.warm_pool.move_to_end(mid)
312
+
313
+ # Evict oldest entries over capacity
314
+ while len(window.warm_pool) > MAX_POOL_SIZE:
315
+ window.warm_pool.popitem(last=False)
316
+
317
+ def _get_top_categories(self, window: ContextWindow) -> List[str]:
318
+ cats: Dict[str, int] = {}
319
+ for p in window.patterns:
320
+ for c in p.categories_hit:
321
+ cats[c] = cats.get(c, 0) + 1
322
+ return sorted(cats, key=cats.get, reverse=True)[:5] if cats else []
323
+
324
+ @staticmethod
325
+ def _tokenize(text: str) -> List[str]:
326
+ """简单分词:中文按 bigram,英文按单词"""
327
+ tokens: List[str] = []
328
+ for seg in re.findall(r'[\u4e00-\u9fff]+|[a-zA-Z0-9_]+', text.lower()):
329
+ if re.match(r'[\u4e00-\u9fff]', seg):
330
+ if len(seg) <= 2:
331
+ tokens.append(seg)
332
+ else:
333
+ for i in range(len(seg) - 1):
334
+ tokens.append(seg[i:i + 2])
335
+ elif len(seg) > 1:
336
+ tokens.append(seg)
337
+ return tokens