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,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()