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,521 @@
1
+ """
2
+ MindForge 共享记忆冲突解决
3
+
4
+ 联邦/多 Agent 场景下,多个节点可能并发更新同一条共享记忆。
5
+ 本模块提供持久化的冲突检测与解决:
6
+
7
+ - detect_incoming:传入更新指向本地已有记忆且内容不一致时,登记冲突
8
+ - resolve:按策略解决
9
+ * lww last-write-wins,按(版本, 时间戳, peer_id)决胜,新者覆盖
10
+ * keep_both 传入内容另存分支记忆,并与本地记忆建立 conflict_branch 关联
11
+ - dismiss:人工判定无需处理时关闭冲突
12
+ - stats:冲突态势统计
13
+
14
+ 与 federated.py 配套:FederatedMemory 可注入本解析器,accept_incoming
15
+ 时自动检测并按策略解决冲突;未解决冲突保持 open 状态等待人工处理。
16
+ """
17
+
18
+ import json
19
+ import logging
20
+ import time
21
+ import uuid
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ # v6.1.2 修复:本模块 4 处降级分支(265 / 314 / 492 / 502 行)引用 `logger.warning`
25
+ # 却从未定义 logger,导致「保存旧版本失败 / 关联失败 / 删除分支失败」这些本应继续
26
+ # 走完解决流程的可恢复错误,反而以 NameError 中断——冲突已改库但后续步骤没执行。
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ # 冲突类型
31
+ CONFLICT_TYPES = ("content", "version", "concurrent")
32
+
33
+ # 冲突状态
34
+ CONFLICT_STATUS = ("open", "resolved", "dismissed", "archived")
35
+
36
+ # 解决策略
37
+ RESOLVE_STRATEGIES = ("lww", "keep_both")
38
+
39
+
40
+ class SharedConflictResolver:
41
+ """共享记忆冲突解析器
42
+
43
+ 通过 SQLite 持久化冲突记录,随主库一起备份。
44
+ 关键变更写入审计日志(复用 storage._add_audit)。
45
+ """
46
+
47
+ def __init__(self, storage):
48
+ self.storage = storage
49
+ self._init_tables()
50
+
51
+ # ===== 内部基础设施 =====
52
+
53
+ def _conn(self):
54
+ return self.storage._get_conn()
55
+
56
+ def _init_tables(self):
57
+ conn = self._conn()
58
+ conn.executescript("""
59
+ CREATE TABLE IF NOT EXISTS share_conflicts (
60
+ id TEXT PRIMARY KEY,
61
+ memory_key TEXT DEFAULT '',
62
+ local_memory_id TEXT NOT NULL,
63
+ incoming_peer TEXT DEFAULT '',
64
+ conflict_type TEXT DEFAULT 'content',
65
+ status TEXT DEFAULT 'open',
66
+ resolution TEXT DEFAULT '',
67
+ local_snapshot TEXT DEFAULT '{}',
68
+ incoming_snapshot TEXT DEFAULT '{}',
69
+ resolved_memory_id TEXT DEFAULT '',
70
+ resolved_by TEXT DEFAULT '',
71
+ resolved_at REAL DEFAULT 0.0,
72
+ created_at REAL
73
+ );
74
+ CREATE INDEX IF NOT EXISTS idx_conflict_status
75
+ ON share_conflicts(status);
76
+ CREATE INDEX IF NOT EXISTS idx_conflict_local
77
+ ON share_conflicts(local_memory_id);
78
+ CREATE INDEX IF NOT EXISTS idx_conflict_created
79
+ ON share_conflicts(created_at);
80
+ """)
81
+ conn.commit()
82
+
83
+ def _row_to_conflict(self, row) -> Dict[str, Any]:
84
+ def _loads(raw):
85
+ try:
86
+ return json.loads(raw) if raw else {}
87
+ except (json.JSONDecodeError, TypeError):
88
+ return {}
89
+ return {
90
+ "conflict_id": row["id"],
91
+ "memory_key": row["memory_key"],
92
+ "local_memory_id": row["local_memory_id"],
93
+ "incoming_peer": row["incoming_peer"],
94
+ "conflict_type": row["conflict_type"],
95
+ "status": row["status"],
96
+ "resolution": row["resolution"],
97
+ "local_snapshot": _loads(row["local_snapshot"]),
98
+ "incoming_snapshot": _loads(row["incoming_snapshot"]),
99
+ "resolved_memory_id": row["resolved_memory_id"],
100
+ "resolved_by": row["resolved_by"],
101
+ "resolved_at": row["resolved_at"],
102
+ "created_at": row["created_at"],
103
+ }
104
+
105
+ # ===== 冲突检测 =====
106
+
107
+ def detect_incoming(self, incoming: Dict[str, Any]) -> Dict[str, Any]:
108
+ """检测传入更新是否与本地记忆冲突。
109
+
110
+ Args:
111
+ incoming: {
112
+ "memory_id": 本地目标记忆 ID(必需),
113
+ "content": 传入内容(必需),
114
+ "version": 传入版本号(可选 int),
115
+ "timestamp": 传入时间戳(可选 float),
116
+ "from_peer": 来源节点(可选),
117
+ "federated_key": 逻辑键(可选,仅记录用),
118
+ }
119
+
120
+ Returns:
121
+ {"conflict": False, "action": "new", ...} 本地无对应记忆
122
+ {"conflict": False, "action": "noop", ...} 内容一致,无需处理
123
+ {"conflict": True, "conflict_id": ..., ...} 已登记冲突
124
+ """
125
+ memory_id = str(incoming.get("memory_id") or "").strip()
126
+ content = incoming.get("content")
127
+ from_peer = str(incoming.get("from_peer") or "").strip()
128
+ if not memory_id or content is None:
129
+ return {"conflict": False, "action": "new",
130
+ "reason": "缺少 memory_id 或 content,按新记忆处理"}
131
+
132
+ local = self.storage.get_memory(memory_id)
133
+ if local is None:
134
+ return {"conflict": False, "action": "new",
135
+ "reason": "本地无对应记忆,按新记忆处理",
136
+ "memory_id": memory_id}
137
+
138
+ if (local.content or "") == str(content):
139
+ return {"conflict": False, "action": "noop",
140
+ "reason": "内容一致,无需处理",
141
+ "local_memory_id": memory_id}
142
+
143
+ # 冲突类型:版本号不一致 → version;仅内容不一致 → content
144
+ try:
145
+ incoming_version = int(incoming.get("version", 0) or 0)
146
+ except (TypeError, ValueError):
147
+ incoming_version = 0
148
+ local_version = int(getattr(local, "consolidation_count", 0) or 0)
149
+ conflict_type = "version" if incoming_version != local_version else "content"
150
+
151
+ now = time.time()
152
+ conflict_id = f"cfl_{uuid.uuid4().hex[:12]}"
153
+ local_snapshot = {
154
+ "content_preview": (local.content or "")[:200],
155
+ "version": local_version,
156
+ "updated_at": getattr(local, "updated_at", 0.0),
157
+ "category": local.category,
158
+ }
159
+ # v5.6.2 P0 修复:必须存完整 content,否则 LWW 冲突解决时
160
+ # 用 200 字符 preview 覆盖完整内容 → 永久数据丢失(v5.4.4 #8 回归)。
161
+ # 数据库膨胀风险 < 数据丢失风险,完整内容保留到冲突关闭后清理。
162
+ _content_str = str(content)
163
+ import hashlib as _hl
164
+ incoming_snapshot = {
165
+ "content": _content_str,
166
+ "content_preview": _content_str[:200],
167
+ "content_hash": _hl.sha256(_content_str.encode("utf-8")).hexdigest()[:16],
168
+ "content_length": len(_content_str),
169
+ "version": incoming_version,
170
+ "timestamp": incoming.get("timestamp") or now,
171
+ "from_peer": from_peer,
172
+ }
173
+
174
+ conn = self._conn()
175
+ conn.execute(
176
+ "INSERT INTO share_conflicts (id, memory_key, local_memory_id,"
177
+ " incoming_peer, conflict_type, status, resolution,"
178
+ " local_snapshot, incoming_snapshot, resolved_memory_id,"
179
+ " resolved_by, resolved_at, created_at)"
180
+ " VALUES (?, ?, ?, ?, ?, 'open', '', ?, ?, '', '', 0.0, ?)",
181
+ (conflict_id, str(incoming.get("federated_key") or memory_id)[:256],
182
+ memory_id, from_peer, conflict_type,
183
+ json.dumps(local_snapshot, ensure_ascii=False),
184
+ json.dumps(incoming_snapshot, ensure_ascii=False), now))
185
+ conn.commit()
186
+ self.storage._add_audit("conflict_detected", memory_id, from_peer, "",
187
+ "INTERNAL",
188
+ {"conflict_id": conflict_id,
189
+ "conflict_type": conflict_type})
190
+ return {"conflict": True, "conflict_id": conflict_id,
191
+ "conflict_type": conflict_type, "local_memory_id": memory_id}
192
+
193
+ # ===== 冲突解决 =====
194
+
195
+ def resolve(self, conflict_id: str, strategy: str,
196
+ actor: str = "") -> Dict[str, Any]:
197
+ """解决一个 open 状态的冲突。
198
+
199
+ Args:
200
+ conflict_id: 冲突 ID
201
+ strategy: lww / keep_both
202
+ actor: 操作者
203
+ """
204
+ strategy = (strategy or "").strip().lower()
205
+ if strategy not in RESOLVE_STRATEGIES:
206
+ return {"success": False,
207
+ "error": f"无效策略: {strategy}(可选: {'/'.join(RESOLVE_STRATEGIES)})"}
208
+ conflict_id = (conflict_id or "").strip()
209
+ conn = self._conn()
210
+ row = conn.execute(
211
+ "SELECT * FROM share_conflicts WHERE id = ?", (conflict_id,)).fetchone()
212
+ if row is None:
213
+ return {"success": False, "error": "冲突不存在"}
214
+ if row["status"] != "open":
215
+ return {"success": False,
216
+ "error": f"冲突已处于 {row['status']} 状态,不能重复解决"}
217
+
218
+ local_id = row["local_memory_id"]
219
+ local = self.storage.get_memory(local_id)
220
+ if local is None:
221
+ # 本地记忆已被删除:直接关闭冲突
222
+ self._mark(conflict_id, "resolved", f"{strategy}_local_deleted",
223
+ "", actor)
224
+ return {"success": True, "conflict_id": conflict_id,
225
+ "resolution": f"{strategy}_local_deleted",
226
+ "resolved_memory_id": None,
227
+ "note": "本地记忆已不存在,冲突自动关闭"}
228
+
229
+ try:
230
+ incoming_snapshot = json.loads(row["incoming_snapshot"] or "{}")
231
+ except json.JSONDecodeError:
232
+ incoming_snapshot = {}
233
+ # v5.6.2 P0 修复:从完整 content 字段读取,而非 200 字符 preview
234
+ # (v5.4.4 #8 引入的回归:用 preview 覆盖完整内容导致永久数据丢失)
235
+ incoming_content = incoming_snapshot.get("content", "")
236
+ if not incoming_content:
237
+ # 数据完整性修复(N1):v5.6.2 之前的冲突行只存了 200 字符
238
+ # content_preview,没有完整正文。原实现会回退到 preview 并继续
239
+ # 走 LWW 覆盖分支 —— 等于用截断文本覆盖本地完整内容,永久丢数据,
240
+ # v5.6.2 的 P0 修复在旧数据路径上被完全绕过。
241
+ # 这里改为 fail-safe:既不覆盖本地,也不据此伪造一条截断的分支记忆,
242
+ # 保持冲突 open 交由人工/重新同步处理。
243
+ return {
244
+ "success": False,
245
+ "error": (
246
+ "冲突快照缺少完整 content(仅有 200 字符 content_preview,"
247
+ "属于 v5.6.2 之前的旧数据),为防止永久数据丢失已拒绝自动解决。"
248
+ "请让对端重新同步该记忆以生成新冲突,或人工核对后再处理。"
249
+ ),
250
+ "conflict_id": conflict_id,
251
+ "legacy_snapshot": True,
252
+ }
253
+ incoming_content = str(incoming_content)
254
+ incoming_version = int(incoming_snapshot.get("version", 0) or 0)
255
+ incoming_ts = float(incoming_snapshot.get("timestamp", 0.0) or 0.0)
256
+ from_peer = str(incoming_snapshot.get("from_peer", "") or row["incoming_peer"])
257
+
258
+ now = time.time()
259
+ if strategy == "lww":
260
+ local_version = int(getattr(local, "consolidation_count", 0) or 0)
261
+ local_ts = float(getattr(local, "updated_at", 0.0) or 0.0)
262
+ incoming_key = (incoming_version, incoming_ts, from_peer)
263
+ local_key = (local_version, local_ts, "")
264
+ if incoming_key > local_key:
265
+ # 传入方更新:先备份本地旧版本,再覆盖内容
266
+ try:
267
+ self.storage.save_version(
268
+ local_id, local.content or "", local.category,
269
+ list(local.tags or []), local.importance, actor="conflict-lww")
270
+ except Exception as _ver_err:
271
+ logger.warning("冲突解决前保存旧版本失败: %s", _ver_err)
272
+ # v5.4.2 修复:检查 update_memory 返回值,失败时回退到 keep_both
273
+ updated = self.storage.update_memory(local_id, content=incoming_content,
274
+ actor=actor or from_peer)
275
+ if not updated:
276
+ # 更新失败(如记忆已被并发删除),回退到 keep_both 策略
277
+ try:
278
+ new_entry = self.storage.add_memory(
279
+ content=incoming_content,
280
+ category=local.category or "federated",
281
+ tags=["conflict:lww_fallback", f"from:{from_peer}"] if from_peer
282
+ else ["conflict:lww_fallback"],
283
+ source_agent=f"federated:{from_peer}" if from_peer else "conflict",
284
+ metadata={
285
+ "conflict_parent": local_id,
286
+ "conflict_id": conflict_id,
287
+ "fallback": "update_failed",
288
+ })
289
+ resolved_memory_id = new_entry.id
290
+ except (ValueError, TypeError):
291
+ return {"success": False, "error": "LWW 更新失败且回退分支创建失败"}
292
+ resolution = "lww_incoming_fallback"
293
+ else:
294
+ resolution = "lww_incoming"
295
+ resolved_memory_id = local_id
296
+ else:
297
+ resolution = "lww_local"
298
+ resolved_memory_id = local_id
299
+ else: # keep_both
300
+ try:
301
+ new_entry = self.storage.add_memory(
302
+ content=incoming_content,
303
+ category=local.category or "federated",
304
+ tags=["conflict:branch", f"from:{from_peer}"] if from_peer
305
+ else ["conflict:branch"],
306
+ source_agent=f"federated:{from_peer}" if from_peer else "conflict",
307
+ metadata={
308
+ "conflict_parent": local_id,
309
+ "conflict_id": conflict_id,
310
+ "incoming_version": incoming_version,
311
+ })
312
+ except (ValueError, TypeError) as e:
313
+ return {"success": False, "error": f"分支记忆创建失败: {e}"}
314
+ resolved_memory_id = new_entry.id
315
+ try:
316
+ self.storage.link_memories(local_id, new_entry.id,
317
+ link_type="conflict_branch",
318
+ note=f"v5.4.2 冲突分支({conflict_id})")
319
+ except Exception as _link_err:
320
+ logger.warning("冲突分支关联失败: %s", _link_err)
321
+ resolution = "keep_both"
322
+
323
+ self._mark(conflict_id, "resolved", resolution, resolved_memory_id, actor)
324
+ self.storage._add_audit("conflict_resolved", local_id, actor or from_peer,
325
+ "", "INTERNAL",
326
+ {"conflict_id": conflict_id,
327
+ "strategy": strategy, "resolution": resolution})
328
+ return {"success": True, "conflict_id": conflict_id,
329
+ "strategy": strategy, "resolution": resolution,
330
+ "resolved_memory_id": resolved_memory_id}
331
+
332
+ def _mark(self, conflict_id: str, status: str, resolution: str,
333
+ resolved_memory_id: str, actor: str):
334
+ conn = self._conn()
335
+ # N2 修复(无界增长):一旦冲突不再 open,就把 incoming_snapshot 里的
336
+ # 完整正文压缩成「哈希 + 长度」摘要。该字段保存的是整条记忆正文,
337
+ # 原先从不清理,每个冲突都会在 share_conflicts 里永久留一份全文副本,
338
+ # 长期运行(月级无人值守)会导致数据库单表无界膨胀。
339
+ compact_snapshot = None
340
+ if status != "open":
341
+ row = conn.execute(
342
+ "SELECT incoming_snapshot FROM share_conflicts WHERE id = ?",
343
+ (conflict_id,)).fetchone()
344
+ if row is not None and row["incoming_snapshot"] not in (None, "", "{}"):
345
+ try:
346
+ payload = json.loads(row["incoming_snapshot"])
347
+ except (json.JSONDecodeError, TypeError):
348
+ payload = {}
349
+ content = str(payload.get("content", "") or "")
350
+ if content:
351
+ import hashlib as _hl
352
+ compact_snapshot = json.dumps({
353
+ "purged": True,
354
+ "reason": "conflict_closed",
355
+ "content_hash": _hl.sha256(
356
+ content.encode("utf-8")).hexdigest()[:16],
357
+ "content_length": len(content),
358
+ "preview": content[:200],
359
+ "version": payload.get("version", 0),
360
+ "from_peer": payload.get("from_peer", ""),
361
+ }, ensure_ascii=False)
362
+ else:
363
+ compact_snapshot = "{}"
364
+
365
+ if compact_snapshot is None:
366
+ conn.execute(
367
+ "UPDATE share_conflicts SET status = ?, resolution = ?,"
368
+ " resolved_memory_id = ?, resolved_by = ?, resolved_at = ?"
369
+ " WHERE id = ?",
370
+ (status, resolution, resolved_memory_id or "",
371
+ (actor or "")[:128], time.time(), conflict_id))
372
+ else:
373
+ conn.execute(
374
+ "UPDATE share_conflicts SET status = ?, resolution = ?,"
375
+ " resolved_memory_id = ?, resolved_by = ?, resolved_at = ?,"
376
+ " incoming_snapshot = ?"
377
+ " WHERE id = ?",
378
+ (status, resolution, resolved_memory_id or "",
379
+ (actor or "")[:128], time.time(), compact_snapshot,
380
+ conflict_id))
381
+ conn.commit()
382
+
383
+ # ===== 查询与维护 =====
384
+
385
+ def list_conflicts(self, status: Optional[str] = None,
386
+ limit: int = 50) -> List[Dict[str, Any]]:
387
+ """列出冲突记录"""
388
+ limit = max(1, min(500, int(limit)))
389
+ conn = self._conn()
390
+ if status:
391
+ status = status.strip().lower()
392
+ if status not in CONFLICT_STATUS:
393
+ return []
394
+ rows = conn.execute(
395
+ "SELECT * FROM share_conflicts WHERE status = ?"
396
+ " ORDER BY created_at DESC LIMIT ?", (status, limit)).fetchall()
397
+ else:
398
+ rows = conn.execute(
399
+ "SELECT * FROM share_conflicts"
400
+ " ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
401
+ return [self._row_to_conflict(r) for r in rows]
402
+
403
+ def dismiss(self, conflict_id: str, actor: str = "") -> Dict[str, Any]:
404
+ """关闭冲突(人工判定无需处理)"""
405
+ conflict_id = (conflict_id or "").strip()
406
+ conn = self._conn()
407
+ row = conn.execute(
408
+ "SELECT status FROM share_conflicts WHERE id = ?",
409
+ (conflict_id,)).fetchone()
410
+ if row is None:
411
+ return {"success": False, "error": "冲突不存在"}
412
+ if row["status"] != "open":
413
+ return {"success": False,
414
+ "error": f"冲突已处于 {row['status']} 状态"}
415
+ self._mark(conflict_id, "dismissed", "dismissed", "", actor)
416
+ self.storage._add_audit("conflict_dismissed", conflict_id, actor, "",
417
+ "INTERNAL", {})
418
+ return {"success": True, "conflict_id": conflict_id,
419
+ "status": "dismissed"}
420
+
421
+ def stats(self) -> Dict[str, Any]:
422
+ """冲突态势统计"""
423
+ conn = self._conn()
424
+ total = conn.execute(
425
+ "SELECT COUNT(*) AS c FROM share_conflicts").fetchone()["c"]
426
+ by_status = conn.execute(
427
+ "SELECT status, COUNT(*) AS c FROM share_conflicts"
428
+ " GROUP BY status").fetchall()
429
+ by_type = conn.execute(
430
+ "SELECT conflict_type, COUNT(*) AS c FROM share_conflicts"
431
+ " GROUP BY conflict_type").fetchall()
432
+ by_resolution = conn.execute(
433
+ "SELECT resolution, COUNT(*) AS c FROM share_conflicts"
434
+ " WHERE status = 'resolved' GROUP BY resolution").fetchall()
435
+ status_map = {r["status"]: r["c"] for r in by_status}
436
+ return {
437
+ "total_conflicts": total,
438
+ "open": status_map.get("open", 0),
439
+ "resolved": status_map.get("resolved", 0),
440
+ "dismissed": status_map.get("dismissed", 0),
441
+ "by_type": {r["conflict_type"]: r["c"] for r in by_type},
442
+ "by_resolution": {r["resolution"]: r["c"] for r in by_resolution},
443
+ }
444
+
445
+ def cleanup_branches(self, days_old: int = 30,
446
+ dry_run: bool = True,
447
+ actor: str = "") -> Dict[str, Any]:
448
+ """v5.4.4 新增 #9:清理 keep_both 策略产生的旧分支记忆。
449
+
450
+ 长期运行后 keep_both 会堆积大量分支记忆,本方法可定期清理:
451
+ - 查找 resolved 状态、resolution 为 keep_both 且超过 days_old 天的冲突
452
+ - dry_run=True 时仅返回统计,不实际删除
453
+ - dry_run=False 时删除分支记忆和 conflict_branch 关联,并将冲突标记为 archived
454
+
455
+ Args:
456
+ days_old: 清理多少天前的已解决冲突(默认 30 天)
457
+ dry_run: True 时仅统计不删除,False 时实际执行清理
458
+ actor: 操作者标识
459
+
460
+ Returns:
461
+ {"total_found": int, "cleaned": int, "freed_memory_ids": [...], ...}
462
+ """
463
+ days_old = max(1, min(3650, int(days_old)))
464
+ now = time.time()
465
+ cutoff = now - days_old * 86400
466
+
467
+ conn = self._conn()
468
+ rows = conn.execute(
469
+ "SELECT id, local_memory_id, resolved_memory_id, resolution, created_at"
470
+ " FROM share_conflicts"
471
+ " WHERE status = 'resolved' AND resolution = 'keep_both'"
472
+ " AND resolved_at > 0 AND resolved_at < ?"
473
+ " ORDER BY resolved_at ASC",
474
+ (cutoff,)).fetchall()
475
+
476
+ result = {
477
+ "total_found": len(rows),
478
+ "cleaned": 0,
479
+ "freed_memory_ids": [],
480
+ "archived_conflicts": [],
481
+ "dry_run": dry_run,
482
+ "days_old": days_old,
483
+ }
484
+
485
+ if dry_run or not rows:
486
+ return result
487
+
488
+ for row in rows:
489
+ branch_id = row["resolved_memory_id"]
490
+ conflict_id = row["id"]
491
+ if branch_id:
492
+ try:
493
+ branch = self.storage.get_memory(branch_id)
494
+ if branch is not None:
495
+ self.storage.delete_memory(branch_id)
496
+ result["freed_memory_ids"].append(branch_id)
497
+ except Exception as _del_err:
498
+ logger.warning("删除冲突分支记忆失败: %s", _del_err)
499
+ try:
500
+ conn.execute(
501
+ "DELETE FROM memory_links"
502
+ " WHERE ((source_id = ? AND target_id = ?)"
503
+ " OR (source_id = ? AND target_id = ?))"
504
+ " AND link_type = 'conflict_branch'",
505
+ (row["local_memory_id"], branch_id,
506
+ branch_id, row["local_memory_id"]))
507
+ except Exception as _link_err:
508
+ logger.warning("清理冲突分支关联失败: %s", _link_err)
509
+ conn.execute(
510
+ "UPDATE share_conflicts SET status = 'archived'"
511
+ " WHERE id = ?",
512
+ (conflict_id,))
513
+ result["archived_conflicts"].append(conflict_id)
514
+ result["cleaned"] += 1
515
+
516
+ conn.commit()
517
+ self.storage._add_audit("conflict_cleanup_branches", "", actor, "",
518
+ "INTERNAL",
519
+ {"cleaned": result["cleaned"],
520
+ "days_old": days_old})
521
+ return result