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.
- MindForge.py +20 -0
- mindforge/__init__.py +111 -0
- mindforge/adapters/__init__.py +18 -0
- mindforge/adapters/claude_adapter.py +84 -0
- mindforge/adapters/crewai_memory.py +76 -0
- mindforge/adapters/generic_api.py +183 -0
- mindforge/adapters/langgraph_store.py +463 -0
- mindforge/adapters/openclaw_adapter.py +91 -0
- mindforge/api/__init__.py +1 -0
- mindforge/api/server.py +1567 -0
- mindforge/cli/__init__.py +1 -0
- mindforge/cli/main.py +13607 -0
- mindforge/core/__init__.py +37 -0
- mindforge/core/embedding.py +847 -0
- mindforge/core/encryption.py +597 -0
- mindforge/core/indexer.py +363 -0
- mindforge/core/mindforge.py +6298 -0
- mindforge/core/paths.py +43 -0
- mindforge/core/query.py +501 -0
- mindforge/core/storage.py +18656 -0
- mindforge/core/types.py +314 -0
- mindforge/core/version.py +56 -0
- mindforge/daemon/__init__.py +0 -0
- mindforge/daemon/server.py +277 -0
- mindforge/mcp/__init__.py +4 -0
- mindforge/mcp/server.py +2559 -0
- mindforge/modules/__init__.py +138 -0
- mindforge/modules/categorizer.py +151 -0
- mindforge/modules/conflict_detector.py +356 -0
- mindforge/modules/connectors.py +331 -0
- mindforge/modules/context_window.py +337 -0
- mindforge/modules/event_bus.py +572 -0
- mindforge/modules/evolution.py +210 -0
- mindforge/modules/experience.py +402 -0
- mindforge/modules/federated.py +722 -0
- mindforge/modules/federated_acl.py +393 -0
- mindforge/modules/follow_up.py +315 -0
- mindforge/modules/hybrid_search.py +565 -0
- mindforge/modules/importer.py +374 -0
- mindforge/modules/integrator.py +196 -0
- mindforge/modules/intent_router.py +359 -0
- mindforge/modules/knowledge_graph.py +395 -0
- mindforge/modules/memory_compressor.py +592 -0
- mindforge/modules/memory_decay.py +474 -0
- mindforge/modules/memory_passport.py +250 -0
- mindforge/modules/multi_agent.py +534 -0
- mindforge/modules/multimodal.py +304 -0
- mindforge/modules/outcome_learning.py +186 -0
- mindforge/modules/personality.py +275 -0
- mindforge/modules/principled_forgetting.py +258 -0
- mindforge/modules/privacy.py +736 -0
- mindforge/modules/recall.py +263 -0
- mindforge/modules/session_focus.py +305 -0
- mindforge/modules/share_conflict.py +521 -0
- mindforge/modules/skill_extractor.py +340 -0
- mindforge/modules/skill_pack.py +205 -0
- mindforge/modules/timeline_viz.py +206 -0
- mindforge/modules/v6.py +931 -0
- mindforge_memory-6.4.1.dist-info/METADATA +281 -0
- mindforge_memory-6.4.1.dist-info/RECORD +64 -0
- mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
- mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
- mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
- mindforge_memory-6.4.1.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MindForge v6.1.5 新增:记忆压缩引擎
|
|
3
|
+
|
|
4
|
+
自动识别语义相近的记忆簇,生成合并摘要以节省检索位序,
|
|
5
|
+
同时保留原始记忆可随时解压缩(反向操作)。
|
|
6
|
+
|
|
7
|
+
核心思想:
|
|
8
|
+
- 检索时 Top-K 窗口有限,同一主题的碎片化记忆会互相挤占位次。
|
|
9
|
+
- 压缩将 N 条同主题记忆 → 1 条摘要 + N 条标记为 compressed_into 的原始条目。
|
|
10
|
+
- 摘要在检索中占 1 个位次,命中后可展开为全部原始条目(类似文档聚合)。
|
|
11
|
+
- 解压缩恢复独立状态,无数据丢失。
|
|
12
|
+
|
|
13
|
+
设计约束:
|
|
14
|
+
- 不依赖外部 LLM——压缩算法为 TF-IDF 关键短语提取 + 结构模板拼接。
|
|
15
|
+
- 与现有四层记忆模型兼容:压缩不改变 layer,不触发 decay。
|
|
16
|
+
- STRICT 隐私级别的记忆不参与自动压缩(fail-closed)。
|
|
17
|
+
- 操作原子:压缩/解压缩写入同一事务,不留中间态。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import uuid
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Dict, List, Optional, Tuple, Any
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class CompressionCluster:
|
|
31
|
+
"""一个压缩簇:多条记忆 + 摘要文本"""
|
|
32
|
+
cluster_id: str
|
|
33
|
+
summary: str
|
|
34
|
+
source_ids: List[str]
|
|
35
|
+
category: str
|
|
36
|
+
importance: str
|
|
37
|
+
created_at: str
|
|
38
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MemoryCompressor:
|
|
42
|
+
"""记忆压缩引擎
|
|
43
|
+
|
|
44
|
+
使用方式:
|
|
45
|
+
compressor = MemoryCompressor(storage, index)
|
|
46
|
+
# 自动检测并压缩
|
|
47
|
+
result = compressor.auto_compress(threshold=0.75, max_cluster=8)
|
|
48
|
+
# 手动指定一组记忆压缩
|
|
49
|
+
result = compressor.compress_group(ids=["id1", "id2", "id3"])
|
|
50
|
+
# 解压缩
|
|
51
|
+
result = compressor.decompress(cluster_id="xxx")
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, storage, index):
|
|
55
|
+
"""
|
|
56
|
+
Args:
|
|
57
|
+
storage: StorageEngine 实例
|
|
58
|
+
index: IndexEngine 实例
|
|
59
|
+
"""
|
|
60
|
+
self.storage = storage
|
|
61
|
+
self.index = index
|
|
62
|
+
|
|
63
|
+
def _hydrate_index_from_storage(self) -> int:
|
|
64
|
+
"""v6.3.6:从 storage 现场加载明文记忆到 TF-IDF 索引。
|
|
65
|
+
|
|
66
|
+
背景:门面/CLI 的搜索主路径走 FTS5 + 模糊,IndexEngine 的
|
|
67
|
+
_doc_texts 只有显式 index_memory 后才有内容;真实使用中几乎
|
|
68
|
+
恒为空,导致 find_similar_clusters 无候选可聚。
|
|
69
|
+
|
|
70
|
+
过滤规则与压缩语义一致:
|
|
71
|
+
- trash 不参与
|
|
72
|
+
- 已压缩源(metadata.compressed_into)不重复参与
|
|
73
|
+
- STRICT 隐私不参与(find 阶段仍会二次过滤,此处提前剪除)
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
实际索引的记忆条数(失败返回 0,保持旧行为:0 簇)
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
mems = self.storage.list_memories(limit=10000)
|
|
80
|
+
except Exception:
|
|
81
|
+
logger.warning("compressor 索引水合失败:list_memories 异常", exc_info=True)
|
|
82
|
+
return 0
|
|
83
|
+
count = 0
|
|
84
|
+
for m in mems:
|
|
85
|
+
content = getattr(m, "content", None)
|
|
86
|
+
mid = getattr(m, "id", None) or getattr(m, "memory_id", None)
|
|
87
|
+
if not content or not mid:
|
|
88
|
+
continue
|
|
89
|
+
if getattr(m, "category", "") == "trash":
|
|
90
|
+
continue
|
|
91
|
+
meta = getattr(m, "metadata", None)
|
|
92
|
+
if isinstance(meta, dict) and meta.get("compressed_into"):
|
|
93
|
+
continue
|
|
94
|
+
privacy = getattr(m, "privacy", None)
|
|
95
|
+
pval = getattr(privacy, "value", privacy)
|
|
96
|
+
if str(pval).upper() == "STRICT":
|
|
97
|
+
continue
|
|
98
|
+
self.index.index_memory(mid, content)
|
|
99
|
+
count += 1
|
|
100
|
+
if count:
|
|
101
|
+
# index_memory 在 >=5 条时自动 fit;小库手动补
|
|
102
|
+
if not getattr(self.index, "_fitted", False):
|
|
103
|
+
self.index._fit_vectorizer()
|
|
104
|
+
logger.info("compressor 索引水合完成:%d 条候选", count)
|
|
105
|
+
return count
|
|
106
|
+
|
|
107
|
+
def find_similar_clusters(
|
|
108
|
+
self,
|
|
109
|
+
threshold: float = 0.75,
|
|
110
|
+
min_cluster_size: int = 3,
|
|
111
|
+
max_cluster_size: int = 8,
|
|
112
|
+
categories: Optional[List[str]] = None,
|
|
113
|
+
) -> List[List[str]]:
|
|
114
|
+
"""基于 TF-IDF 余弦相似度聚类,找出相似记忆组。
|
|
115
|
+
|
|
116
|
+
使用贪心合并:按 doc_id 遍历,若与已有簇内任一条相似度
|
|
117
|
+
超阈值则合入,否则自成一簇。仅保留 >= min_cluster_size 的簇。
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
threshold: 相似度阈值 (0-1)
|
|
121
|
+
min_cluster_size: 最小簇大小
|
|
122
|
+
max_cluster_size: 最大簇大小(超过则按相似度分批)
|
|
123
|
+
categories: 可选,仅在指定类别内聚类
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
List of memory_id lists (each inner list is a cluster)
|
|
127
|
+
"""
|
|
128
|
+
docs = self.index._doc_texts
|
|
129
|
+
if not docs:
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
# Filter by category if specified
|
|
133
|
+
candidate_ids = list(docs.keys())
|
|
134
|
+
if categories:
|
|
135
|
+
candidate_ids = [
|
|
136
|
+
mid for mid in candidate_ids
|
|
137
|
+
if self._get_category(mid) in categories
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
if len(candidate_ids) < min_cluster_size:
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
# Build similarity via vectorizer
|
|
144
|
+
self._ensure_fitted()
|
|
145
|
+
if not self.index._fitted:
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
vectors = {}
|
|
149
|
+
for mid in candidate_ids:
|
|
150
|
+
text = docs.get(mid)
|
|
151
|
+
if text:
|
|
152
|
+
vectors[mid] = self.index.vectorizer.transform(text)
|
|
153
|
+
|
|
154
|
+
# Greedy clustering
|
|
155
|
+
clusters: List[List[str]] = []
|
|
156
|
+
assigned: set = set()
|
|
157
|
+
|
|
158
|
+
for mid in candidate_ids:
|
|
159
|
+
if mid in assigned:
|
|
160
|
+
continue
|
|
161
|
+
vec = vectors.get(mid)
|
|
162
|
+
if not vec:
|
|
163
|
+
continue
|
|
164
|
+
cluster = [mid]
|
|
165
|
+
assigned.add(mid)
|
|
166
|
+
|
|
167
|
+
for other in candidate_ids:
|
|
168
|
+
if other in assigned or len(cluster) >= max_cluster_size:
|
|
169
|
+
continue
|
|
170
|
+
other_vec = vectors.get(other)
|
|
171
|
+
if not other_vec:
|
|
172
|
+
continue
|
|
173
|
+
sim = self.index.vectorizer.cosine_similarity(vec, other_vec)
|
|
174
|
+
if sim >= threshold:
|
|
175
|
+
cluster.append(other)
|
|
176
|
+
assigned.add(other)
|
|
177
|
+
|
|
178
|
+
if len(cluster) >= min_cluster_size:
|
|
179
|
+
clusters.append(cluster)
|
|
180
|
+
|
|
181
|
+
return clusters
|
|
182
|
+
|
|
183
|
+
def auto_compress(
|
|
184
|
+
self,
|
|
185
|
+
threshold: float = 0.75,
|
|
186
|
+
min_cluster_size: int = 3,
|
|
187
|
+
max_cluster_size: int = 8,
|
|
188
|
+
dry_run: bool = False,
|
|
189
|
+
categories: Optional[List[str]] = None,
|
|
190
|
+
) -> Dict[str, Any]:
|
|
191
|
+
"""自动压缩:检测 + 生成摘要 + 写入。
|
|
192
|
+
|
|
193
|
+
v6.3.5:加密库入口即拒绝(此前守卫深埋在 _write_compression,
|
|
194
|
+
聚空簇时静默返回 0,用户无从知晓原因)。
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
threshold: 相似度阈值
|
|
198
|
+
min_cluster_size: 最小簇大小
|
|
199
|
+
max_cluster_size: 最大簇大小
|
|
200
|
+
dry_run: 为 True 时只返回将压缩的簇,不写入
|
|
201
|
+
categories: 限定类别
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
{
|
|
205
|
+
"compressed_count": int,
|
|
206
|
+
"clusters": [CompressionCluster dict],
|
|
207
|
+
"skipped_strict": int,
|
|
208
|
+
}
|
|
209
|
+
"""
|
|
210
|
+
# v6.3.5:加密库入口即拒绝(摘要需进 FTS,当前实现仅支持明文库)
|
|
211
|
+
if getattr(self.storage, "encrypted", False) is True:
|
|
212
|
+
from mindforge.core.encryption import SecurityError
|
|
213
|
+
raise SecurityError(
|
|
214
|
+
"加密库暂不支持记忆压缩(摘要需进入明文 FTS 索引)。"
|
|
215
|
+
"如需压缩请先解密数据库。"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# v6.3.6 修复:真实库上 _doc_texts 可能为空(搜索主路径走 FTS5,
|
|
219
|
+
# 不触发 TF-IDF 索引水合),find_similar_clusters 会静默返回 [],
|
|
220
|
+
# 压缩恒为 0 簇。此处从 storage 现场加载明文记忆并索引。
|
|
221
|
+
if not getattr(self.index, "_doc_texts", None):
|
|
222
|
+
self._hydrate_index_from_storage()
|
|
223
|
+
|
|
224
|
+
clusters = self.find_similar_clusters(
|
|
225
|
+
threshold=threshold,
|
|
226
|
+
min_cluster_size=min_cluster_size,
|
|
227
|
+
max_cluster_size=max_cluster_size,
|
|
228
|
+
categories=categories,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if not clusters:
|
|
232
|
+
return {"compressed_count": 0, "clusters": [], "skipped_strict": 0}
|
|
233
|
+
|
|
234
|
+
results = []
|
|
235
|
+
skipped_strict = 0
|
|
236
|
+
|
|
237
|
+
for cluster_ids in clusters:
|
|
238
|
+
# STRICT privacy guard: remove STRICT memories from cluster
|
|
239
|
+
safe_ids = []
|
|
240
|
+
for mid in cluster_ids:
|
|
241
|
+
entry = self.storage.get_memory(mid)
|
|
242
|
+
if entry and getattr(entry, "privacy", "PUBLIC") == "STRICT":
|
|
243
|
+
skipped_strict += 1
|
|
244
|
+
else:
|
|
245
|
+
safe_ids.append(mid)
|
|
246
|
+
if len(safe_ids) < min_cluster_size:
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
summary = self.generate_summary(safe_ids)
|
|
250
|
+
cluster_obj = CompressionCluster(
|
|
251
|
+
cluster_id=str(uuid.uuid4()),
|
|
252
|
+
summary=summary,
|
|
253
|
+
source_ids=safe_ids,
|
|
254
|
+
category=self._majority_category(safe_ids),
|
|
255
|
+
importance=self._max_importance(safe_ids),
|
|
256
|
+
created_at=self._now_iso(),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if not dry_run:
|
|
260
|
+
self._write_compression(cluster_obj)
|
|
261
|
+
|
|
262
|
+
results.append({
|
|
263
|
+
"cluster_id": cluster_obj.cluster_id,
|
|
264
|
+
"summary": cluster_obj.summary[:120],
|
|
265
|
+
"source_count": len(cluster_obj.source_ids),
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
"compressed_count": len(results),
|
|
270
|
+
"clusters": results,
|
|
271
|
+
"skipped_strict": skipped_strict,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
def compress_group(
|
|
275
|
+
self,
|
|
276
|
+
memory_ids: List[str],
|
|
277
|
+
dry_run: bool = False,
|
|
278
|
+
) -> Optional[Dict[str, Any]]:
|
|
279
|
+
"""手动指定一组记忆进行压缩。
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
memory_ids: 要压缩的记忆 id 列表(>= 2)
|
|
283
|
+
dry_run: 只返回摘要不写入
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
压缩结果 dict 或 None(ids < 2 时)
|
|
287
|
+
"""
|
|
288
|
+
if len(memory_ids) < 2:
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
# v6.3.5:加密库入口即拒绝(与 auto_compress 一致)
|
|
292
|
+
if getattr(self.storage, "encrypted", False) is True:
|
|
293
|
+
from mindforge.core.encryption import SecurityError
|
|
294
|
+
raise SecurityError(
|
|
295
|
+
"加密库暂不支持记忆压缩(摘要需进入明文 FTS 索引)。"
|
|
296
|
+
"如需压缩请先解密数据库。"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# STRICT guard
|
|
300
|
+
safe_ids = []
|
|
301
|
+
for mid in memory_ids:
|
|
302
|
+
entry = self.storage.get_memory(mid)
|
|
303
|
+
if entry and getattr(entry, "privacy", "PUBLIC") != "STRICT":
|
|
304
|
+
safe_ids.append(mid)
|
|
305
|
+
|
|
306
|
+
if len(safe_ids) < 2:
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
summary = self.generate_summary(safe_ids)
|
|
310
|
+
cluster_obj = CompressionCluster(
|
|
311
|
+
cluster_id=str(uuid.uuid4()),
|
|
312
|
+
summary=summary,
|
|
313
|
+
source_ids=safe_ids,
|
|
314
|
+
category=self._majority_category(safe_ids),
|
|
315
|
+
importance=self._max_importance(safe_ids),
|
|
316
|
+
created_at=self._now_iso(),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if not dry_run:
|
|
320
|
+
self._write_compression(cluster_obj)
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
"cluster_id": cluster_obj.cluster_id,
|
|
324
|
+
"summary": cluster_obj.summary,
|
|
325
|
+
"source_count": len(cluster_obj.source_ids),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
def generate_summary(self, memory_ids: List[str]) -> str:
|
|
329
|
+
"""基于 TF-IDF 关键短语提取 + 结构模板生成摘要。
|
|
330
|
+
|
|
331
|
+
不依赖外部 LLM,纯统计方法:
|
|
332
|
+
1. 提取各条目中 TF-IDF 权重最高的 3 个短语
|
|
333
|
+
2. 去重后按全局频率排序
|
|
334
|
+
3. 以模板 "[摘要] N 条记忆合并 | 核心: ..." 生成文本
|
|
335
|
+
"""
|
|
336
|
+
self._ensure_fitted()
|
|
337
|
+
|
|
338
|
+
# Collect content texts
|
|
339
|
+
texts = []
|
|
340
|
+
for mid in memory_ids:
|
|
341
|
+
text = self.index._doc_texts.get(mid)
|
|
342
|
+
if not text and hasattr(self.storage, "get_memory"):
|
|
343
|
+
entry = self.storage.get_memory(mid)
|
|
344
|
+
if entry and hasattr(entry, "content"):
|
|
345
|
+
text = entry.content
|
|
346
|
+
if text:
|
|
347
|
+
texts.append(text)
|
|
348
|
+
|
|
349
|
+
if not texts:
|
|
350
|
+
return f"[摘要] {len(memory_ids)} 条记忆(内容不可读)"
|
|
351
|
+
|
|
352
|
+
# Extract top terms across all texts
|
|
353
|
+
term_scores: Dict[str, float] = {}
|
|
354
|
+
if self.index._fitted:
|
|
355
|
+
for text in texts:
|
|
356
|
+
vec = self.index.vectorizer.transform(text)
|
|
357
|
+
for idx, score in vec.items():
|
|
358
|
+
term = self._idx_to_term(idx)
|
|
359
|
+
if term and len(term) >= 2:
|
|
360
|
+
term_scores[term] = max(term_scores.get(term, 0), score)
|
|
361
|
+
|
|
362
|
+
top_terms = sorted(term_scores.items(), key=lambda x: x[1], reverse=True)[:8]
|
|
363
|
+
keywords = [t[0] for t in top_terms]
|
|
364
|
+
|
|
365
|
+
# Build summary with first sentence of each text
|
|
366
|
+
lead_sentences = []
|
|
367
|
+
for text in texts[:3]:
|
|
368
|
+
sentence = text.split("。")[0].split(".")[0][:80]
|
|
369
|
+
if sentence.strip():
|
|
370
|
+
lead_sentences.append(sentence.strip())
|
|
371
|
+
|
|
372
|
+
parts = [f"[摘要] {len(memory_ids)} 条记忆合并"]
|
|
373
|
+
if keywords:
|
|
374
|
+
parts.append(f"核心词: {', '.join(keywords[:6])}")
|
|
375
|
+
if lead_sentences:
|
|
376
|
+
parts.append(f"要点: {' | '.join(lead_sentences[:2])}")
|
|
377
|
+
|
|
378
|
+
return " | ".join(parts)
|
|
379
|
+
|
|
380
|
+
def decompress(self, cluster_id: str) -> Dict[str, Any]:
|
|
381
|
+
"""解压缩:恢复原始记忆的独立检索状态。
|
|
382
|
+
|
|
383
|
+
v6.3.5 重写:与 _write_compression 对称走存储层通道。原实现裸
|
|
384
|
+
DELETE memories——摘要行在 memory_fts / memory_embeddings /
|
|
385
|
+
审计的派生数据全部残留(v6.1.2 级联删除修复前同类问题)。
|
|
386
|
+
现改为:源恢复走 update_memory(读-合并-写回去除标记),
|
|
387
|
+
摘要删除走 delete_memory(级联清理 FTS / 嵌入 / 子表)。
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
cluster_id: 压缩簇 ID
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
{"decompressed": bool, "restored_count": int, "error": str}
|
|
394
|
+
"""
|
|
395
|
+
try:
|
|
396
|
+
conn = self.storage._get_conn()
|
|
397
|
+
self._ensure_table(conn)
|
|
398
|
+
# Find compression record
|
|
399
|
+
row = conn.execute(
|
|
400
|
+
"SELECT source_ids, summary_id FROM compressions WHERE cluster_id = ?",
|
|
401
|
+
(cluster_id,)
|
|
402
|
+
).fetchone()
|
|
403
|
+
|
|
404
|
+
if not row:
|
|
405
|
+
return {"decompressed": False, "restored_count": 0,
|
|
406
|
+
"error": f"压缩簇 {cluster_id} 不存在"}
|
|
407
|
+
|
|
408
|
+
source_ids = json.loads(row[0])
|
|
409
|
+
summary_id = row[1]
|
|
410
|
+
|
|
411
|
+
# Restore source entries: remove compressed_into marker
|
|
412
|
+
# (update_memory 的 metadata 为整体替换语义——读旧、去键、写回)
|
|
413
|
+
restored = 0
|
|
414
|
+
for mid in source_ids:
|
|
415
|
+
entry = self.storage.get_memory(mid)
|
|
416
|
+
if entry is None:
|
|
417
|
+
continue # 源已被独立删除,跳过
|
|
418
|
+
meta = dict(entry.metadata or {})
|
|
419
|
+
meta.pop("compressed_into", None)
|
|
420
|
+
if self.storage.update_memory(mid, metadata=meta, actor="memory-compressor"):
|
|
421
|
+
restored += 1
|
|
422
|
+
|
|
423
|
+
# Delete summary memory (cascade: FTS / embeddings / subtables)
|
|
424
|
+
self.storage.delete_memory(summary_id, actor="memory-compressor")
|
|
425
|
+
|
|
426
|
+
# Remove mapping row
|
|
427
|
+
conn.execute("DELETE FROM compressions WHERE cluster_id = ?", (cluster_id,))
|
|
428
|
+
conn.commit()
|
|
429
|
+
|
|
430
|
+
return {"decompressed": True, "restored_count": restored, "error": ""}
|
|
431
|
+
|
|
432
|
+
except Exception as e:
|
|
433
|
+
logger.error("解压缩失败: %s", e)
|
|
434
|
+
return {"decompressed": False, "restored_count": 0, "error": str(e)}
|
|
435
|
+
|
|
436
|
+
def list_compressions(self) -> List[Dict[str, Any]]:
|
|
437
|
+
"""列出所有压缩簇"""
|
|
438
|
+
try:
|
|
439
|
+
conn = self.storage._get_conn()
|
|
440
|
+
self._ensure_table(conn)
|
|
441
|
+
rows = conn.execute(
|
|
442
|
+
"SELECT cluster_id, summary_id, source_ids, category, importance, created_at "
|
|
443
|
+
"FROM compressions ORDER BY created_at DESC"
|
|
444
|
+
).fetchall()
|
|
445
|
+
return [
|
|
446
|
+
{
|
|
447
|
+
"cluster_id": r[0],
|
|
448
|
+
"summary_id": r[1],
|
|
449
|
+
"source_ids": json.loads(r[2]),
|
|
450
|
+
"category": r[3],
|
|
451
|
+
"importance": r[4],
|
|
452
|
+
"created_at": r[5],
|
|
453
|
+
}
|
|
454
|
+
for r in rows
|
|
455
|
+
]
|
|
456
|
+
except Exception:
|
|
457
|
+
return []
|
|
458
|
+
|
|
459
|
+
# ===================================================================
|
|
460
|
+
# Private helpers
|
|
461
|
+
# ===================================================================
|
|
462
|
+
|
|
463
|
+
@staticmethod
|
|
464
|
+
def _ensure_table(conn):
|
|
465
|
+
conn.execute("""
|
|
466
|
+
CREATE TABLE IF NOT EXISTS compressions (
|
|
467
|
+
cluster_id TEXT PRIMARY KEY,
|
|
468
|
+
summary_id TEXT NOT NULL,
|
|
469
|
+
source_ids TEXT NOT NULL,
|
|
470
|
+
category TEXT DEFAULT 'general',
|
|
471
|
+
importance TEXT DEFAULT 'MEDIUM',
|
|
472
|
+
created_at TEXT NOT NULL,
|
|
473
|
+
metadata TEXT DEFAULT '{}'
|
|
474
|
+
)
|
|
475
|
+
""")
|
|
476
|
+
|
|
477
|
+
def _write_compression(self, cluster: CompressionCluster):
|
|
478
|
+
"""原子写入压缩结果:创建摘要记忆 + 标记源条目 + 记录映射。
|
|
479
|
+
|
|
480
|
+
v6.3.5 重写:写路径全部改走存储层标准通道。原实现(v6.1.5 草稿)
|
|
481
|
+
直插 memories 表存在五项缺陷——created_at 以 TEXT 写入 REAL 列、
|
|
482
|
+
FTS 索引不同步(摘要对 FTS5 路不可见)、加密库明文直插(安全缺陷)、
|
|
483
|
+
不生成嵌入向量(向量路不可见)、不落审计。现改为:
|
|
484
|
+
- 摘要走 add_memory(id 分配 / FTS / 嵌入 / 审计 自动正确)
|
|
485
|
+
- 源标记走 update_memory(读-合并-写回,metadata 为整体替换语义)
|
|
486
|
+
- compressions 映射表保留模块私有 SQL(不触碰 memories 表)
|
|
487
|
+
"""
|
|
488
|
+
if getattr(self.storage, "encrypted", False) is True:
|
|
489
|
+
from mindforge.core.encryption import SecurityError
|
|
490
|
+
raise SecurityError(
|
|
491
|
+
"加密库暂不支持记忆压缩:摘要需进入 FTS 检索索引,"
|
|
492
|
+
"而当前压缩实现仅支持明文库。如需压缩请先解密数据库,"
|
|
493
|
+
"或等待后续版本的加密库压缩支持。"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
from mindforge.core.types import Importance, MemoryLayer
|
|
497
|
+
|
|
498
|
+
# 1. 摘要记忆:标准写入通道(FTS + 嵌入 + 审计 + 完整 uuid)
|
|
499
|
+
try:
|
|
500
|
+
importance_enum = Importance(cluster.importance)
|
|
501
|
+
except ValueError:
|
|
502
|
+
importance_enum = Importance.MEDIUM
|
|
503
|
+
|
|
504
|
+
summary_entry = self.storage.add_memory(
|
|
505
|
+
content=cluster.summary,
|
|
506
|
+
category=cluster.category,
|
|
507
|
+
importance=importance_enum,
|
|
508
|
+
layer=MemoryLayer.LONG_TERM,
|
|
509
|
+
tags=["compression-summary"],
|
|
510
|
+
metadata={
|
|
511
|
+
"is_compression_summary": True,
|
|
512
|
+
"cluster_id": cluster.cluster_id,
|
|
513
|
+
},
|
|
514
|
+
source_session="memory-compressor",
|
|
515
|
+
)
|
|
516
|
+
summary_id = summary_entry.id
|
|
517
|
+
|
|
518
|
+
# 2. 源条目标记 compressed_into(合并语义:读旧 → 合并 → 写回)
|
|
519
|
+
for mid in cluster.source_ids:
|
|
520
|
+
entry = self.storage.get_memory(mid)
|
|
521
|
+
if entry is None:
|
|
522
|
+
continue # 源已不存在(并发删除)——摘要仍成立
|
|
523
|
+
meta = dict(entry.metadata or {})
|
|
524
|
+
meta["compressed_into"] = cluster.cluster_id
|
|
525
|
+
self.storage.update_memory(mid, metadata=meta, actor="memory-compressor")
|
|
526
|
+
|
|
527
|
+
# 3. 映射记录(模块私有表,不触碰 memories)
|
|
528
|
+
conn = self.storage._get_conn()
|
|
529
|
+
self._ensure_table(conn)
|
|
530
|
+
conn.execute("""
|
|
531
|
+
INSERT INTO compressions (cluster_id, summary_id, source_ids, category, importance, created_at)
|
|
532
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
533
|
+
""", (
|
|
534
|
+
cluster.cluster_id,
|
|
535
|
+
summary_id,
|
|
536
|
+
json.dumps(cluster.source_ids),
|
|
537
|
+
cluster.category,
|
|
538
|
+
cluster.importance,
|
|
539
|
+
cluster.created_at,
|
|
540
|
+
))
|
|
541
|
+
conn.commit()
|
|
542
|
+
|
|
543
|
+
def _ensure_fitted(self):
|
|
544
|
+
if not self.index._fitted and self.index._doc_texts:
|
|
545
|
+
self.index._fit_vectorizer()
|
|
546
|
+
|
|
547
|
+
@staticmethod
|
|
548
|
+
def _idx_to_term(idx: int, vectorizer=None):
|
|
549
|
+
"""TFIDFVectorizer 反向查找:从 vocab 索引取词"""
|
|
550
|
+
if vectorizer is None:
|
|
551
|
+
return None
|
|
552
|
+
for term, i in vectorizer.vocab.items():
|
|
553
|
+
if i == idx:
|
|
554
|
+
return term
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
def _idx_to_term(self, idx: int) -> Optional[str]:
|
|
558
|
+
"""Instance-level reverse lookup using self.index.vectorizer"""
|
|
559
|
+
for term, i in self.index.vectorizer.vocab.items():
|
|
560
|
+
if i == idx:
|
|
561
|
+
return term
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
def _get_category(self, mid: str) -> Optional[str]:
|
|
565
|
+
entry = self.storage.get_memory(mid)
|
|
566
|
+
return getattr(entry, "category", None) if entry else None
|
|
567
|
+
|
|
568
|
+
def _majority_category(self, ids: List[str]) -> str:
|
|
569
|
+
cats: Dict[str, int] = {}
|
|
570
|
+
for mid in ids:
|
|
571
|
+
c = self._get_category(mid)
|
|
572
|
+
if c:
|
|
573
|
+
cats[c] = cats.get(c, 0) + 1
|
|
574
|
+
return max(cats, key=cats.get) if cats else "general"
|
|
575
|
+
|
|
576
|
+
def _max_importance(self, ids: List[str]) -> str:
|
|
577
|
+
rank = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3}
|
|
578
|
+
best = "MEDIUM"
|
|
579
|
+
for mid in ids:
|
|
580
|
+
entry = self.storage.get_memory(mid)
|
|
581
|
+
if entry:
|
|
582
|
+
imp = getattr(entry, "importance", "MEDIUM")
|
|
583
|
+
# Handle both enum and string
|
|
584
|
+
imp_val = getattr(imp, "value", str(imp)).upper() if imp else "MEDIUM"
|
|
585
|
+
if rank.get(imp_val, 1) > rank.get(best, 1):
|
|
586
|
+
best = imp_val
|
|
587
|
+
return best
|
|
588
|
+
|
|
589
|
+
@staticmethod
|
|
590
|
+
def _now_iso() -> str:
|
|
591
|
+
from datetime import datetime, timezone
|
|
592
|
+
return datetime.now(timezone.utc).isoformat()
|