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,210 @@
1
+ """
2
+ MindForge v5.0 记忆演化引擎
3
+ 艾宾浩斯遗忘曲线 · 记忆巩固 · 记忆重组
4
+ """
5
+
6
+ import math
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import List, Dict, Tuple
10
+
11
+ from mindforge.core.types import MemoryLayer, Importance
12
+ from mindforge.core.storage import StorageEngine, MemoryEntry
13
+
14
+
15
+ @dataclass
16
+ class ForgettingCurve:
17
+ """艾宾浩斯遗忘曲线参数"""
18
+ initial_strength: float = 1.0
19
+ decay_rate: float = 0.1
20
+ review_boost: float = 0.3
21
+ importance_multiplier: Dict[str, float] = field(default_factory=lambda: {
22
+ "LOW": 0.7,
23
+ "MEDIUM": 1.0,
24
+ "HIGH": 1.5,
25
+ "CRITICAL": 2.0,
26
+ })
27
+
28
+ def calculate_strength(self, initial_strength: float,
29
+ elapsed_hours: float,
30
+ importance: Importance = Importance.MEDIUM) -> float:
31
+ multiplier = self.importance_multiplier.get(importance.value, 1.0)
32
+ adjusted_decay = self.decay_rate / multiplier
33
+ strength = initial_strength * math.exp(-adjusted_decay * elapsed_hours)
34
+ return max(0.01, min(1.0, strength))
35
+
36
+ def next_review_time(self, current_strength: float,
37
+ target_strength: float = 0.6,
38
+ importance: Importance = Importance.MEDIUM) -> float:
39
+ if current_strength <= target_strength:
40
+ return 0
41
+ multiplier = self.importance_multiplier.get(importance.value, 1.0)
42
+ adjusted_decay = self.decay_rate / multiplier
43
+ hours = -math.log(target_strength / current_strength) / adjusted_decay
44
+ return max(0, hours)
45
+
46
+
47
+ class MemoryEvolution:
48
+ """记忆演化引擎"""
49
+
50
+ def __init__(self, storage: StorageEngine):
51
+ self.storage = storage
52
+ self.forgetting_curve = ForgettingCurve()
53
+ self._consolidation_threshold = 0.7
54
+ self._permanent_threshold = 0.9
55
+ self._short_term_capacity = 100
56
+
57
+ def update_forgetting_scores(self):
58
+ all_memories = self.storage.list_memories(limit=10000)
59
+ now = time.time()
60
+ bulk_updates = []
61
+ for entry in all_memories:
62
+ elapsed_hours = (now - entry.last_accessed_at) / 3600
63
+ if elapsed_hours < 1:
64
+ continue
65
+ new_strength = self.forgetting_curve.calculate_strength(
66
+ entry.strength, elapsed_hours, entry.importance,
67
+ )
68
+ forgetting_score = 1.0 - new_strength
69
+ new_metadata = {
70
+ **entry.metadata,
71
+ "current_strength": new_strength,
72
+ "forgetting_score": forgetting_score,
73
+ }
74
+ bulk_updates.append({
75
+ "id": entry.id,
76
+ "strength": new_strength,
77
+ "forgetting_score": forgetting_score,
78
+ "metadata": new_metadata,
79
+ })
80
+ if bulk_updates:
81
+ self.storage.bulk_update_memory_fields(
82
+ bulk_updates,
83
+ ["strength", "forgetting_score", "metadata"],
84
+ )
85
+
86
+ def consolidate(self, agent_id: str = "", session_id: str = "") -> Dict:
87
+ short_term = self.storage.list_memories(
88
+ layer=MemoryLayer.SHORT_TERM,
89
+ limit=self._short_term_capacity,
90
+ )
91
+ consolidated = []
92
+ promoted = 0
93
+ demoted = 0
94
+ now = time.time()
95
+ for entry in short_term:
96
+ elapsed_hours = (now - entry.created_at) / 3600
97
+ strength = self.forgetting_curve.calculate_strength(
98
+ entry.strength, elapsed_hours, entry.importance
99
+ )
100
+ access_factor = min(entry.access_count / 5.0, 1.0)
101
+ importance_factor = entry.importance.to_int() / 3.0
102
+ consolidation_score = (strength + access_factor + importance_factor) / 3.0
103
+ if consolidation_score >= self._consolidation_threshold:
104
+ new_count = entry.consolidation_count + 1
105
+ self.storage.update_memory(
106
+ entry_id=entry.id,
107
+ layer=MemoryLayer.LONG_TERM,
108
+ consolidation_count=new_count,
109
+ metadata={
110
+ **entry.metadata,
111
+ "consolidated_at": now,
112
+ "consolidation_score": consolidation_score,
113
+ "consolidation_count": new_count,
114
+ },
115
+ actor=agent_id,
116
+ session_id=session_id,
117
+ )
118
+ entry.consolidation_count = new_count
119
+ promoted += 1
120
+ consolidated.append(entry.id)
121
+ elif strength < 0.2 and entry.importance == Importance.LOW:
122
+ self.storage.update_memory(
123
+ entry_id=entry.id,
124
+ layer=MemoryLayer.SENSORY,
125
+ actor=agent_id,
126
+ session_id=session_id,
127
+ )
128
+ demoted += 1
129
+ return {
130
+ "processed": len(short_term),
131
+ "promoted_to_long_term": promoted,
132
+ "demoted_to_sensory": demoted,
133
+ "consolidated_ids": consolidated,
134
+ }
135
+
136
+ def promote_to_permanent(self, memory_id: str,
137
+ agent_id: str = "", session_id: str = "") -> bool:
138
+ entry = self.storage.get_memory(memory_id, agent_id, session_id)
139
+ if not entry:
140
+ return False
141
+ if entry.layer != MemoryLayer.LONG_TERM:
142
+ return False
143
+ if entry.consolidation_count < 3:
144
+ return False
145
+ self.storage.update_memory(
146
+ entry_id=memory_id,
147
+ layer=MemoryLayer.PERMANENT,
148
+ metadata={**entry.metadata, "permanent_at": time.time()},
149
+ actor=agent_id,
150
+ session_id=session_id,
151
+ )
152
+ return True
153
+
154
+ def review_schedule(self, limit: int = 20) -> List[Tuple[MemoryEntry, float]]:
155
+ all_memories = self.storage.list_memories(limit=1000)
156
+ now = time.time()
157
+ schedule = []
158
+ for entry in all_memories:
159
+ if entry.layer in (MemoryLayer.SENSORY, MemoryLayer.PERMANENT):
160
+ continue
161
+ elapsed_hours = (now - entry.last_accessed_at) / 3600
162
+ current_strength = self.forgetting_curve.calculate_strength(
163
+ entry.strength, elapsed_hours, entry.importance
164
+ )
165
+ next_review = self.forgetting_curve.next_review_time(
166
+ current_strength, 0.6, entry.importance
167
+ )
168
+ urgency = 1.0 - current_strength
169
+ if urgency > 0.3:
170
+ schedule.append((entry, urgency))
171
+ schedule.sort(key=lambda x: x[1], reverse=True)
172
+ return schedule[:limit]
173
+
174
+ def reactivate(self, memory_id: str, agent_id: str = "", session_id: str = "") -> bool:
175
+ entry = self.storage.get_memory(memory_id, agent_id, session_id)
176
+ if not entry:
177
+ return False
178
+ boost = self.forgetting_curve.review_boost
179
+ new_strength = min(1.0, entry.strength + boost)
180
+ self.storage.update_memory(
181
+ entry_id=memory_id,
182
+ metadata={
183
+ **entry.metadata,
184
+ "strength": new_strength,
185
+ "last_review_at": time.time(),
186
+ "review_count": entry.metadata.get("review_count", 0) + 1,
187
+ },
188
+ actor=agent_id,
189
+ session_id=session_id,
190
+ )
191
+ return True
192
+
193
+ def get_evolution_stats(self) -> Dict:
194
+ stats = self.storage.get_stats()
195
+ by_layer = stats.get("by_layer", {})
196
+ return {
197
+ "sensory": by_layer.get("sensory", 0),
198
+ "short_term": by_layer.get("short_term", 0),
199
+ "long_term": by_layer.get("long_term", 0),
200
+ "permanent": by_layer.get("permanent", 0),
201
+ "consolidation_rate": self._calc_consolidation_rate(),
202
+ }
203
+
204
+ def _calc_consolidation_rate(self) -> float:
205
+ short_term = self.storage.count_memories(layer=MemoryLayer.SHORT_TERM)
206
+ long_term = self.storage.count_memories(layer=MemoryLayer.LONG_TERM)
207
+ total = short_term + long_term
208
+ if total == 0:
209
+ return 0.0
210
+ return long_term / total
@@ -0,0 +1,402 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ MindForge v5.8.0 程序性记忆 / 经验蒸馏引擎(Cases → Skills 自进化)
4
+ ====================================
5
+ 在 v5.3.9 SkillExtractor(记忆→技能模板抽取)基础上补齐闭环:
6
+
7
+ 1. 案例记录:Agent 把一次成功任务存为 ExperienceCase(任务/内容/结果/耗时/来源记忆),
8
+ 持久化到 experience_cases 表;
9
+ 2. 蒸馏落库:distill() 从案例批量提取技能模板(复用 SkillExtractor),
10
+ UPSERT 到 experience_skills 表;
11
+ 3. 匹配复用:match_skills() 按新任务查询已存技能,render_skill() 用参数渲染
12
+ 可执行流程(复用 SkillTemplate.render)。
13
+
14
+ 设计原则:全部零外部依赖、SQLite 单文件、显式调用(不自动写主流程)。
15
+ """
16
+
17
+ import json
18
+ import re
19
+ import sqlite3
20
+ import time
21
+ import uuid
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ from mindforge.modules.skill_extractor import SkillExtractor, SkillTemplate
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # 数据类
29
+ # ---------------------------------------------------------------------------
30
+
31
+ class ExperienceCase:
32
+ """一次可复用的任务经验(Case)"""
33
+
34
+ __slots__ = ("id", "task", "content", "result", "outcome", "duration_seconds",
35
+ "tags", "source_memory_ids", "agent_id", "created_at")
36
+
37
+ def __init__(self, id: str = "", task: str = "", content: str = "",
38
+ result: str = "", outcome: str = "success",
39
+ duration_seconds: float = 0.0, tags: Optional[List[str]] = None,
40
+ source_memory_ids: Optional[List[str]] = None,
41
+ agent_id: str = "", created_at: float = 0.0):
42
+ self.id = id or str(uuid.uuid4())
43
+ self.task = task
44
+ self.content = content
45
+ self.result = result
46
+ self.outcome = outcome
47
+ self.duration_seconds = duration_seconds
48
+ self.tags = list(tags or [])
49
+ self.source_memory_ids = list(source_memory_ids or [])
50
+ self.agent_id = agent_id
51
+ self.created_at = created_at or time.time()
52
+
53
+ def to_dict(self) -> Dict[str, Any]:
54
+ return {
55
+ "id": self.id, "task": self.task, "content": self.content,
56
+ "result": self.result, "outcome": self.outcome,
57
+ "duration_seconds": round(self.duration_seconds, 3),
58
+ "tags": self.tags[:20], "source_memory_ids": self.source_memory_ids[:50],
59
+ "agent_id": self.agent_id, "created_at": round(self.created_at, 3),
60
+ }
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # 持久化存储
65
+ # ---------------------------------------------------------------------------
66
+
67
+ class SkillStore:
68
+ """案例 + 技能模板的持久化存储(SQLite 单文件)"""
69
+
70
+ def __init__(self, storage=None):
71
+ self.storage = storage
72
+ self._skills: Dict[str, SkillTemplate] = {}
73
+ if self.storage is not None:
74
+ self._ensure_tables()
75
+ self._load_skills()
76
+
77
+ # -- schema -----------------------------------------------------------
78
+
79
+ def _ensure_tables(self) -> None:
80
+ try:
81
+ conn = self.storage._get_conn()
82
+ conn.executescript("""
83
+ CREATE TABLE IF NOT EXISTS experience_cases (
84
+ id TEXT PRIMARY KEY,
85
+ task TEXT NOT NULL,
86
+ content TEXT,
87
+ result TEXT DEFAULT '',
88
+ outcome TEXT DEFAULT 'success',
89
+ duration_seconds REAL DEFAULT 0.0,
90
+ tags TEXT DEFAULT '[]',
91
+ source_memory_ids TEXT DEFAULT '[]',
92
+ agent_id TEXT DEFAULT '',
93
+ created_at REAL
94
+ );
95
+ CREATE TABLE IF NOT EXISTS experience_skills (
96
+ name TEXT PRIMARY KEY,
97
+ description TEXT DEFAULT '',
98
+ triggers TEXT DEFAULT '[]',
99
+ slots TEXT DEFAULT '[]',
100
+ steps TEXT DEFAULT '[]',
101
+ examples TEXT DEFAULT '[]',
102
+ tags TEXT DEFAULT '[]',
103
+ source_memory_ids TEXT DEFAULT '[]',
104
+ confidence REAL DEFAULT 0.0,
105
+ cluster_size INTEGER DEFAULT 0,
106
+ updated_at REAL
107
+ );
108
+ """)
109
+ conn.commit()
110
+ except sqlite3.OperationalError as _err:
111
+ import logging
112
+ logging.getLogger(__name__).warning("创建经验表失败: %s", _err)
113
+
114
+ def _load_skills(self) -> None:
115
+ """重启后从 experience_skills 表恢复内存态"""
116
+ try:
117
+ conn = self.storage._get_conn()
118
+ for row in conn.execute(
119
+ "SELECT name, description, triggers, slots, steps, examples, tags, "
120
+ "source_memory_ids, confidence, cluster_size, updated_at "
121
+ "FROM experience_skills"
122
+ ):
123
+ (name, desc, triggers, slots, steps, examples, tags,
124
+ source_ids, confidence, cluster_size, _updated) = row
125
+ from mindforge.modules.skill_extractor import SkillSlot
126
+ parsed_slots = []
127
+ for s in json.loads(slots) if slots else []:
128
+ try:
129
+ parsed_slots.append(SkillSlot(
130
+ name=s.get("name", ""),
131
+ examples=list(s.get("examples", [])),
132
+ required=bool(s.get("required", False)),
133
+ default=s.get("default"),
134
+ ))
135
+ except Exception:
136
+ continue
137
+ self._skills[name] = SkillTemplate(
138
+ name=name, description=desc or "",
139
+ triggers=json.loads(triggers) if triggers else [],
140
+ slots=parsed_slots,
141
+ steps=json.loads(steps) if steps else [],
142
+ examples=json.loads(examples) if examples else [],
143
+ tags=json.loads(tags) if tags else [],
144
+ source_memory_ids=json.loads(source_ids) if source_ids else [],
145
+ confidence=float(confidence or 0.0),
146
+ cluster_size=int(cluster_size or 0),
147
+ )
148
+ except sqlite3.OperationalError as _err:
149
+ import logging
150
+ logging.getLogger(__name__).warning("加载技能失败: %s", _err)
151
+
152
+ # -- cases ------------------------------------------------------------
153
+
154
+ def record_case(self, task: str, content: str = "", result: str = "",
155
+ outcome: str = "success", duration_seconds: float = 0.0,
156
+ tags: Optional[List[str]] = None,
157
+ source_memory_ids: Optional[List[str]] = None,
158
+ agent_id: str = "") -> ExperienceCase:
159
+ """记录一次任务经验(成功案例)
160
+
161
+ v6.1.2:文本入参在落库前做类型校验。此前 `content` / `result` / `task` /
162
+ `agent_id` 直传 SQLite 绑定,非字符串(如 REST 传进来一个 dict)会抛
163
+ `sqlite3.ProgrammingError: Error binding parameter ...`——该异常既不是
164
+ ValueError 也不是 TypeError,REST 与 MCP 的 400/友好错误分支都接不住,
165
+ 对外表现为 500 崩溃(v5.9.0 给 `get()` 修过同类的「入参类型」问题,
166
+ 经验链路一直漏着)。现在统一抛 ValueError,三层接口都能给出可读报错。
167
+
168
+ None 按空串处理(与修复前的 SQLite NULL 相比,读回时形态更一致);
169
+ 非字符串一律拒绝,不做 `str()` 静默强转——那会把 Python repr 写进库。
170
+ """
171
+ if task is None or not isinstance(task, str):
172
+ if task is None:
173
+ raise ValueError("task 不能为空")
174
+ raise ValueError(
175
+ f"task 必须是字符串,收到 {type(task).__name__}")
176
+ for field, value in (("content", content), ("result", result),
177
+ ("agent_id", agent_id)):
178
+ if value is not None and not isinstance(value, str):
179
+ raise ValueError(
180
+ f"{field} 必须是字符串,收到 {type(value).__name__}"
181
+ "(如需存结构化数据,请先序列化为文本)")
182
+ content = content or ""
183
+ result = result or ""
184
+ agent_id = agent_id or ""
185
+ outcome = str(outcome or "success").lower()
186
+ if outcome not in ("success", "partial", "failure"):
187
+ raise ValueError(f"无效 outcome: {outcome}(可选 success/partial/failure)")
188
+ case = ExperienceCase(
189
+ task=task, content=content, result=result, outcome=outcome,
190
+ duration_seconds=max(0.0, float(duration_seconds or 0.0)),
191
+ tags=tags, source_memory_ids=source_memory_ids, agent_id=agent_id,
192
+ )
193
+ if self.storage is not None:
194
+ conn = self.storage._get_conn()
195
+ conn.execute(
196
+ "INSERT INTO experience_cases "
197
+ "(id, task, content, result, outcome, duration_seconds, tags, "
198
+ " source_memory_ids, agent_id, created_at) "
199
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
200
+ (case.id, case.task, case.content, case.result, case.outcome,
201
+ case.duration_seconds,
202
+ json.dumps(case.tags, ensure_ascii=False),
203
+ json.dumps(case.source_memory_ids, ensure_ascii=False),
204
+ case.agent_id, case.created_at))
205
+ conn.commit()
206
+ return case
207
+
208
+ def list_cases(self, limit: int = 100, outcome: str = "") -> List[Dict[str, Any]]:
209
+ if self.storage is None:
210
+ return []
211
+ limit = max(1, min(10000, int(limit)))
212
+ conn = self.storage._get_conn()
213
+ if outcome:
214
+ rows = conn.execute(
215
+ "SELECT id, task, content, result, outcome, duration_seconds, tags, "
216
+ "source_memory_ids, agent_id, created_at FROM experience_cases "
217
+ "WHERE outcome = ? ORDER BY created_at DESC LIMIT ?",
218
+ (str(outcome), limit)).fetchall()
219
+ else:
220
+ rows = conn.execute(
221
+ "SELECT id, task, content, result, outcome, duration_seconds, tags, "
222
+ "source_memory_ids, agent_id, created_at FROM experience_cases "
223
+ "ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
224
+ out = []
225
+ for r in rows:
226
+ out.append({
227
+ "id": r[0], "task": r[1], "content": r[2] or "", "result": r[3] or "",
228
+ "outcome": r[4], "duration_seconds": round(r[5] or 0.0, 3),
229
+ "tags": json.loads(r[6]) if r[6] else [],
230
+ "source_memory_ids": json.loads(r[7]) if r[7] else [],
231
+ "agent_id": r[8] or "", "created_at": round(r[9] or 0.0, 3),
232
+ })
233
+ return out
234
+
235
+ def delete_case(self, case_id: str) -> bool:
236
+ if self.storage is None:
237
+ return False
238
+ conn = self.storage._get_conn()
239
+ cur = conn.execute("DELETE FROM experience_cases WHERE id = ?", (case_id,))
240
+ conn.commit()
241
+ return cur.rowcount > 0
242
+
243
+ def delete_skill(self, name: str) -> bool:
244
+ """删除指定技能(v6.0.0 技能包功能:replace 模式先删后写)"""
245
+ if self.storage is None or not name:
246
+ return False
247
+ conn = self.storage._get_conn()
248
+ cur = conn.execute("DELETE FROM experience_skills WHERE name = ?", (name,))
249
+ conn.commit()
250
+ if cur.rowcount > 0:
251
+ self._skills.pop(name, None)
252
+ return True
253
+ # 大小写不敏感兜底:get_skill 已按小写匹配,这里同步删内存态
254
+ for key in [k for k in self._skills if k.lower() == name.lower()]:
255
+ self._skills.pop(key, None)
256
+ return True
257
+ return False
258
+
259
+ # -- skills -----------------------------------------------------------
260
+
261
+ def save_skills(self, templates: List[SkillTemplate]) -> int:
262
+ """UPSERT 技能模板(按 name 合并 source_memory_ids)"""
263
+ saved = 0
264
+ if self.storage is None:
265
+ return 0
266
+ conn = self.storage._get_conn()
267
+ for t in templates:
268
+ existing = self._skills.get(t.name)
269
+ source_ids = list(t.source_memory_ids)
270
+ if existing:
271
+ for sid in existing.source_memory_ids:
272
+ if sid not in source_ids:
273
+ source_ids.append(sid)
274
+ now = time.time()
275
+ conn.execute(
276
+ "INSERT INTO experience_skills (name, description, triggers, slots, "
277
+ " steps, examples, tags, source_memory_ids, confidence, cluster_size, "
278
+ " updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
279
+ "ON CONFLICT(name) DO UPDATE SET "
280
+ " description=excluded.description, triggers=excluded.triggers, "
281
+ " slots=excluded.slots, steps=excluded.steps, examples=excluded.examples, "
282
+ " tags=excluded.tags, source_memory_ids=excluded.source_memory_ids, "
283
+ " confidence=excluded.confidence, cluster_size=excluded.cluster_size, "
284
+ " updated_at=excluded.updated_at",
285
+ (t.name, t.description,
286
+ json.dumps(t.triggers, ensure_ascii=False),
287
+ json.dumps([s.to_dict() for s in t.slots], ensure_ascii=False),
288
+ json.dumps(t.steps, ensure_ascii=False),
289
+ json.dumps(t.examples, ensure_ascii=False),
290
+ json.dumps(t.tags, ensure_ascii=False),
291
+ json.dumps(source_ids, ensure_ascii=False),
292
+ float(t.confidence), int(t.cluster_size), now))
293
+ self._skills[t.name] = t
294
+ saved += 1
295
+ conn.commit()
296
+ return saved
297
+
298
+ def distill(self, min_cluster_size: int = 2, max_skills: int = 50,
299
+ outcome: str = "success") -> Dict[str, Any]:
300
+ """从成功案例批量蒸馏技能模板并落库"""
301
+ cases = self.list_cases(limit=10000, outcome=outcome)
302
+ mem_dicts = []
303
+ for c in cases:
304
+ content = c["content"] or c["task"] or ""
305
+ tags = list(c["tags"]) or []
306
+ if c["task"] and c["task"] != content:
307
+ content = c["task"] + ":" + content
308
+ if c["outcome"] == "success":
309
+ tags = list(set(tags) | {"success"})
310
+ mem_dicts.append({
311
+ "id": c["id"], "content": content, "tags": tags,
312
+ "category": "experience",
313
+ })
314
+ extractor = SkillExtractor(min_cluster_size=max(1, int(min_cluster_size)),
315
+ max_skills=max_skills)
316
+ templates = extractor.extract(mem_dicts)
317
+ saved = self.save_skills(templates)
318
+ return {
319
+ "cases_processed": len(mem_dicts),
320
+ "skills_found": len(templates),
321
+ "skills_saved": saved,
322
+ "skills": [t.to_dict() for t in templates[:50]],
323
+ }
324
+
325
+ def list_skills(self, limit: int = 50) -> List[Dict[str, Any]]:
326
+ # v6.1.2 修复:原来是「先按插入序截前 limit,再排序」,于是返回的是
327
+ # 「插入顺序前 N 名内部有序」,而不是「全局 top-N」——limit 小于技能总数
328
+ # 时,置信度更高但入库更晚的技能会被整批漏掉。改为先排序后截断。
329
+ limit = max(1, min(1000, int(limit)))
330
+ out = [t.to_dict() for t in self._skills.values()]
331
+ out.sort(key=lambda s: (-s["confidence"], -s["cluster_size"]))
332
+ return out[:limit]
333
+
334
+ def get_skill(self, name: str) -> Optional[SkillTemplate]:
335
+ for key, t in self._skills.items():
336
+ if key.lower() == name.lower():
337
+ return t
338
+ return None
339
+
340
+ def match_skills(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
341
+ """按新任务查询最匹配的已存技能(触发词/标签/bigram 三重评分)"""
342
+ q = str(query or "").strip().lower()
343
+ if not q:
344
+ return []
345
+ qn = self._ngrams(q)
346
+ scored = []
347
+ for t in self._skills.values():
348
+ score = 0.0
349
+ if any(tg.lower() in q or q in tg.lower() for tg in t.triggers):
350
+ score += 0.6
351
+ if any(tg.lower() in q for tg in t.tags):
352
+ score += 0.3
353
+ if qn:
354
+ hay = t.description + " " + " ".join(t.triggers) + " " + t.name
355
+ sn = self._ngrams(hay)
356
+ score += (len(qn & sn) / max(1, len(qn))) * 0.4
357
+ if score > 0:
358
+ scored.append((score, t))
359
+ scored.sort(key=lambda x: (-x[0], -x[1].confidence))
360
+ return [t.to_dict() for _, t in scored[:max(1, min(50, int(limit)))]]
361
+
362
+ def render_skill(self, name: str, **kwargs) -> Optional[str]:
363
+ t = self.get_skill(name)
364
+ if t is None:
365
+ return None
366
+ return t.render(**kwargs)
367
+
368
+ def stats(self) -> Dict[str, Any]:
369
+ conn = self.storage._get_conn() if self.storage is not None else None
370
+ case_count = 0
371
+ skill_count = len(self._skills)
372
+ if conn is not None:
373
+ try:
374
+ case_count = conn.execute(
375
+ "SELECT COUNT(*) FROM experience_cases").fetchone()[0]
376
+ except sqlite3.OperationalError:
377
+ case_count = 0
378
+ outcome_counts: Dict[str, int] = {}
379
+ if conn is not None:
380
+ try:
381
+ for row in conn.execute(
382
+ "SELECT outcome, COUNT(*) FROM experience_cases GROUP BY outcome"):
383
+ outcome_counts[row[0]] = row[1]
384
+ except sqlite3.OperationalError:
385
+ pass
386
+ return {
387
+ "total_cases": int(case_count),
388
+ "total_skills": int(skill_count),
389
+ "outcome_counts": outcome_counts,
390
+ }
391
+
392
+ # -- helpers ----------------------------------------------------------
393
+
394
+ @staticmethod
395
+ def _ngrams(text: str, n: int = 2) -> set:
396
+ s = re.sub(r"[\W_]+", "", text)
397
+ if len(s) < n:
398
+ return {s} if s else set()
399
+ return {s[i:i + n] for i in range(len(s) - n + 1)}
400
+
401
+
402
+ __all__ = ["ExperienceCase", "SkillStore"]