superlocalmemory 3.4.10 → 3.4.11
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.
- package/README.md +17 -11
- package/docs/skill-evolution.md +77 -10
- package/ide/hooks/tool-event-hook.sh +4 -4
- package/package.json +1 -1
- package/pyproject.toml +3 -2
- package/src/superlocalmemory/cli/commands.py +170 -0
- package/src/superlocalmemory/cli/main.py +21 -0
- package/src/superlocalmemory/cli/setup_wizard.py +54 -11
- package/src/superlocalmemory/core/config.py +35 -0
- package/src/superlocalmemory/core/consolidation_engine.py +128 -0
- package/src/superlocalmemory/core/embedding_worker.py +1 -1
- package/src/superlocalmemory/core/engine.py +12 -0
- package/src/superlocalmemory/core/fact_consolidator.py +425 -0
- package/src/superlocalmemory/core/graph_pruner.py +290 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +20 -0
- package/src/superlocalmemory/core/recall_pipeline.py +9 -0
- package/src/superlocalmemory/core/tier_manager.py +325 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +6 -5
- package/src/superlocalmemory/evolution/__init__.py +29 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +115 -0
- package/src/superlocalmemory/evolution/evolution_store.py +302 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +181 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +555 -0
- package/src/superlocalmemory/evolution/triggers.py +367 -0
- package/src/superlocalmemory/evolution/types.py +92 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +13 -0
- package/src/superlocalmemory/learning/skill_performance_miner.py +44 -11
- package/src/superlocalmemory/mcp/server.py +4 -0
- package/src/superlocalmemory/mcp/tools_evolution.py +338 -0
- package/src/superlocalmemory/retrieval/engine.py +64 -4
- package/src/superlocalmemory/retrieval/forgetting_filter.py +22 -7
- package/src/superlocalmemory/retrieval/strategy.py +2 -2
- package/src/superlocalmemory/server/routes/behavioral.py +19 -15
- package/src/superlocalmemory/server/routes/evolution.py +213 -0
- package/src/superlocalmemory/server/routes/tiers.py +195 -0
- package/src/superlocalmemory/server/unified_daemon.py +36 -5
- package/src/superlocalmemory/storage/schema_v3411.py +149 -0
- package/src/superlocalmemory/ui/index.html +5 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +83 -0
- package/src/superlocalmemory/ui/js/ng-skills.js +394 -10
- package/src/superlocalmemory.egg-info/PKG-INFO +609 -0
- package/src/superlocalmemory.egg-info/SOURCES.txt +335 -0
- package/src/superlocalmemory.egg-info/dependency_links.txt +1 -0
- package/src/superlocalmemory.egg-info/entry_points.txt +2 -0
- package/src/superlocalmemory.egg-info/requires.txt +55 -0
- package/src/superlocalmemory.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Evolution Triggers — detect when skills need evolution.
|
|
6
|
+
|
|
7
|
+
3-trigger system (adopted from OpenSpace):
|
|
8
|
+
1. Post-session analysis: session ends, scan for skill failures
|
|
9
|
+
2. Skill degradation: behavioral assertion confidence drops
|
|
10
|
+
3. Periodic health check: consolidation cycle scans all skills
|
|
11
|
+
|
|
12
|
+
All triggers are zero-LLM, zero-embedding. Pure SQLite queries.
|
|
13
|
+
They produce EvolutionCandidates — the evolver decides what to do.
|
|
14
|
+
|
|
15
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
import sqlite3
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from superlocalmemory.evolution.types import (
|
|
29
|
+
EvolutionCandidate,
|
|
30
|
+
EvolutionType,
|
|
31
|
+
TriggerType,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# Thresholds — conservative, matching C1 design doc
|
|
37
|
+
DEGRADATION_THRESHOLD = 0.4 # Effective score below this → FIX candidate
|
|
38
|
+
RECOVERY_THRESHOLD = 0.6 # Above this → skill recovered, prune from addressed
|
|
39
|
+
DERIVED_THRESHOLD = 0.55 # Moderate effectiveness → DERIVED candidate
|
|
40
|
+
MIN_INVOCATIONS = 5 # Don't trigger on insufficient data
|
|
41
|
+
NEGATIVE_SIGNALS_THRESHOLD = 2 # Min negative signals in one session for post-session trigger
|
|
42
|
+
MAX_RSS_MB = 1024 # Skip evolution if process exceeds 1GB RSS
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check_memory_pressure() -> bool:
|
|
46
|
+
"""Return True if process RSS exceeds threshold. Skip evolution if so."""
|
|
47
|
+
try:
|
|
48
|
+
import psutil
|
|
49
|
+
rss_mb = psutil.Process().memory_info().rss / (1024 * 1024)
|
|
50
|
+
if rss_mb > MAX_RSS_MB:
|
|
51
|
+
logger.warning(
|
|
52
|
+
"Memory pressure: %dMB RSS > %dMB limit, skipping evolution",
|
|
53
|
+
int(rss_mb), MAX_RSS_MB,
|
|
54
|
+
)
|
|
55
|
+
return True
|
|
56
|
+
except ImportError:
|
|
57
|
+
pass
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PostSessionTrigger:
|
|
62
|
+
"""Trigger 1: Analyze a completed session for skill failures.
|
|
63
|
+
|
|
64
|
+
Scans tool_events for the given session_id. If any Skill call
|
|
65
|
+
had 2+ negative signals (retries, errors), creates a FIX candidate.
|
|
66
|
+
|
|
67
|
+
Zero-LLM. Runs in ~10ms on indexed table.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, db_path: str | Path):
|
|
71
|
+
self._db_path = str(db_path)
|
|
72
|
+
|
|
73
|
+
def scan(self, session_id: str, profile_id: str = "default") -> list[EvolutionCandidate]:
|
|
74
|
+
if _check_memory_pressure():
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
candidates = []
|
|
78
|
+
conn = sqlite3.connect(self._db_path, timeout=10)
|
|
79
|
+
conn.row_factory = sqlite3.Row
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
# Get all Skill events for this session
|
|
83
|
+
rows = conn.execute(
|
|
84
|
+
"SELECT id, tool_name, input_summary, output_summary, created_at "
|
|
85
|
+
"FROM tool_events "
|
|
86
|
+
"WHERE session_id = ? AND profile_id = ? AND tool_name = 'Skill' "
|
|
87
|
+
"ORDER BY id ASC",
|
|
88
|
+
(session_id, profile_id),
|
|
89
|
+
).fetchall()
|
|
90
|
+
|
|
91
|
+
if not rows:
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
# Parse skill names and count per-skill signals
|
|
95
|
+
skill_signals: dict[str, dict] = {}
|
|
96
|
+
for row in rows:
|
|
97
|
+
d = dict(row)
|
|
98
|
+
skill_name = self._extract_skill_name(d)
|
|
99
|
+
if not skill_name:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if skill_name not in skill_signals:
|
|
103
|
+
skill_signals[skill_name] = {
|
|
104
|
+
"positive": 0, "negative": 0, "event_ids": [],
|
|
105
|
+
}
|
|
106
|
+
skill_signals[skill_name]["event_ids"].append(d["id"])
|
|
107
|
+
|
|
108
|
+
# Check execution traces for each skill
|
|
109
|
+
for skill_name, signals in skill_signals.items():
|
|
110
|
+
for event_id in signals["event_ids"]:
|
|
111
|
+
trace = conn.execute(
|
|
112
|
+
"SELECT tool_name, output_summary FROM tool_events "
|
|
113
|
+
"WHERE profile_id = ? AND id > ? ORDER BY id ASC LIMIT 5",
|
|
114
|
+
(profile_id, event_id),
|
|
115
|
+
).fetchall()
|
|
116
|
+
|
|
117
|
+
has_error = any(
|
|
118
|
+
"error" in (dict(t).get("output_summary", "") or "").lower()
|
|
119
|
+
for t in trace[:3]
|
|
120
|
+
if dict(t)["tool_name"] == "Bash"
|
|
121
|
+
)
|
|
122
|
+
has_retry = any(
|
|
123
|
+
dict(t)["tool_name"] == "Skill"
|
|
124
|
+
for t in trace[:3]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if has_error or has_retry:
|
|
128
|
+
signals["negative"] += 1
|
|
129
|
+
else:
|
|
130
|
+
signals["positive"] += 1
|
|
131
|
+
|
|
132
|
+
total_invocations = len(signals["event_ids"])
|
|
133
|
+
if signals["negative"] >= NEGATIVE_SIGNALS_THRESHOLD and total_invocations >= MIN_INVOCATIONS:
|
|
134
|
+
evidence = (
|
|
135
|
+
f"{signals['negative']} negative signals in session {session_id}",
|
|
136
|
+
f"{signals['positive']} positive signals",
|
|
137
|
+
)
|
|
138
|
+
candidates.append(EvolutionCandidate(
|
|
139
|
+
skill_name=skill_name,
|
|
140
|
+
evolution_type=EvolutionType.FIX,
|
|
141
|
+
trigger=TriggerType.POST_SESSION,
|
|
142
|
+
evidence=evidence,
|
|
143
|
+
effective_score=signals["positive"] / max(1, signals["positive"] + signals["negative"]),
|
|
144
|
+
invocation_count=len(signals["event_ids"]),
|
|
145
|
+
session_id=session_id,
|
|
146
|
+
))
|
|
147
|
+
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
logger.debug("Post-session trigger scan failed: %s", exc)
|
|
150
|
+
finally:
|
|
151
|
+
conn.close()
|
|
152
|
+
|
|
153
|
+
return candidates
|
|
154
|
+
|
|
155
|
+
def _extract_skill_name(self, event: dict) -> str:
|
|
156
|
+
for field_key in ("input_summary", "output_summary"):
|
|
157
|
+
raw = event.get(field_key, "") or ""
|
|
158
|
+
if not raw:
|
|
159
|
+
continue
|
|
160
|
+
try:
|
|
161
|
+
parsed = json.loads(raw)
|
|
162
|
+
name = parsed.get("skill") or parsed.get("commandName", "")
|
|
163
|
+
if name:
|
|
164
|
+
return name
|
|
165
|
+
except (json.JSONDecodeError, TypeError):
|
|
166
|
+
# M-JSONPARSE: Fallback for non-JSON plain text
|
|
167
|
+
m = re.search(r"skill[:\s]+['\"]?(\S+)", raw, re.IGNORECASE)
|
|
168
|
+
if m:
|
|
169
|
+
return m.group(1).strip("'\"")
|
|
170
|
+
return ""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class DegradationTrigger:
|
|
174
|
+
"""Trigger 2: Detect skills with declining performance assertions.
|
|
175
|
+
|
|
176
|
+
Scans behavioral_assertions for skill_performance category.
|
|
177
|
+
If a skill's confidence dropped below threshold → FIX candidate.
|
|
178
|
+
If moderate with specific failure pattern → DERIVED candidate.
|
|
179
|
+
|
|
180
|
+
Zero-LLM. Runs in ~5ms.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, db_path: str | Path):
|
|
184
|
+
self._db_path = str(db_path)
|
|
185
|
+
|
|
186
|
+
def scan(self, profile_id: str = "default") -> list[EvolutionCandidate]:
|
|
187
|
+
if _check_memory_pressure():
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
candidates = []
|
|
191
|
+
conn = sqlite3.connect(self._db_path, timeout=10)
|
|
192
|
+
conn.row_factory = sqlite3.Row
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
rows = conn.execute(
|
|
196
|
+
"SELECT trigger_condition, action, confidence, evidence_count "
|
|
197
|
+
"FROM behavioral_assertions "
|
|
198
|
+
"WHERE profile_id = ? AND category = 'skill_performance' "
|
|
199
|
+
"AND confidence > 0",
|
|
200
|
+
(profile_id,),
|
|
201
|
+
).fetchall()
|
|
202
|
+
|
|
203
|
+
for row in rows:
|
|
204
|
+
d = dict(row)
|
|
205
|
+
# H-TRIGGERPARSE: Extract skill name robustly.
|
|
206
|
+
# Primary: regex on trigger_condition. Fallback: old replace approach.
|
|
207
|
+
skill_name = ""
|
|
208
|
+
tc = d["trigger_condition"]
|
|
209
|
+
m = re.search(r"skill\s+(\S+)", tc)
|
|
210
|
+
if m:
|
|
211
|
+
skill_name = m.group(1)
|
|
212
|
+
else:
|
|
213
|
+
skill_name = tc.replace("when considering skill ", "")
|
|
214
|
+
confidence = d["confidence"]
|
|
215
|
+
evidence = d["evidence_count"]
|
|
216
|
+
|
|
217
|
+
if evidence < MIN_INVOCATIONS:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
# Parse effective score from action text
|
|
221
|
+
effective_score = self._parse_effective_score(d["action"])
|
|
222
|
+
|
|
223
|
+
if effective_score < DEGRADATION_THRESHOLD:
|
|
224
|
+
candidates.append(EvolutionCandidate(
|
|
225
|
+
skill_name=skill_name,
|
|
226
|
+
evolution_type=EvolutionType.FIX,
|
|
227
|
+
trigger=TriggerType.DEGRADATION,
|
|
228
|
+
evidence=(d["action"],),
|
|
229
|
+
effective_score=effective_score,
|
|
230
|
+
invocation_count=evidence,
|
|
231
|
+
))
|
|
232
|
+
elif effective_score < DERIVED_THRESHOLD:
|
|
233
|
+
candidates.append(EvolutionCandidate(
|
|
234
|
+
skill_name=skill_name,
|
|
235
|
+
evolution_type=EvolutionType.DERIVED,
|
|
236
|
+
trigger=TriggerType.DEGRADATION,
|
|
237
|
+
evidence=(d["action"],),
|
|
238
|
+
effective_score=effective_score,
|
|
239
|
+
invocation_count=evidence,
|
|
240
|
+
))
|
|
241
|
+
|
|
242
|
+
except Exception as exc:
|
|
243
|
+
logger.debug("Degradation trigger scan failed: %s", exc)
|
|
244
|
+
finally:
|
|
245
|
+
conn.close()
|
|
246
|
+
|
|
247
|
+
return candidates
|
|
248
|
+
|
|
249
|
+
def get_active_degraded(self, profile_id: str = "default") -> set[str]:
|
|
250
|
+
"""Return names of currently degraded skills (for anti-loop pruning)."""
|
|
251
|
+
conn = sqlite3.connect(self._db_path, timeout=10)
|
|
252
|
+
conn.row_factory = sqlite3.Row
|
|
253
|
+
degraded = set()
|
|
254
|
+
try:
|
|
255
|
+
rows = conn.execute(
|
|
256
|
+
"SELECT trigger_condition, action FROM behavioral_assertions "
|
|
257
|
+
"WHERE profile_id = ? AND category = 'skill_performance'",
|
|
258
|
+
(profile_id,),
|
|
259
|
+
).fetchall()
|
|
260
|
+
for row in rows:
|
|
261
|
+
d = dict(row)
|
|
262
|
+
score = self._parse_effective_score(d["action"])
|
|
263
|
+
if score < RECOVERY_THRESHOLD:
|
|
264
|
+
tc = d["trigger_condition"]
|
|
265
|
+
m = re.search(r"skill\s+(\S+)", tc)
|
|
266
|
+
name = m.group(1) if m else tc.replace("when considering skill ", "")
|
|
267
|
+
degraded.add(name)
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
finally:
|
|
271
|
+
conn.close()
|
|
272
|
+
return degraded
|
|
273
|
+
|
|
274
|
+
def _parse_effective_score(self, action_text: str) -> float:
|
|
275
|
+
"""Extract effective score from assertion action text."""
|
|
276
|
+
import re
|
|
277
|
+
match = re.search(r"effective score:\s*([-\d.]+)%", action_text)
|
|
278
|
+
if match:
|
|
279
|
+
return float(match.group(1)) / 100.0
|
|
280
|
+
return 0.5
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class HealthCheckTrigger:
|
|
284
|
+
"""Trigger 3: Periodic scan of all skill entities.
|
|
285
|
+
|
|
286
|
+
Runs every N consolidation cycles. Checks Entity Explorer for
|
|
287
|
+
skill entities with low performance.
|
|
288
|
+
|
|
289
|
+
Cycle count is persisted to the evolution_cycle_state table so it
|
|
290
|
+
survives process restarts (H-CYCLECNT fix).
|
|
291
|
+
|
|
292
|
+
Zero-LLM. Runs in ~20ms.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
_STATE_KEY = "health_check_cycle_count"
|
|
296
|
+
|
|
297
|
+
def __init__(self, db_path: str | Path):
|
|
298
|
+
self._db_path = str(db_path)
|
|
299
|
+
self._check_every_n = 3 # Every 3rd consolidation (~18h)
|
|
300
|
+
|
|
301
|
+
def _read_cycle_count(self) -> int:
|
|
302
|
+
"""Read persisted cycle count from DB."""
|
|
303
|
+
conn = sqlite3.connect(self._db_path, timeout=10)
|
|
304
|
+
try:
|
|
305
|
+
row = conn.execute(
|
|
306
|
+
"SELECT value FROM evolution_cycle_state WHERE key = ?",
|
|
307
|
+
(self._STATE_KEY,),
|
|
308
|
+
).fetchone()
|
|
309
|
+
return int(row[0]) if row else 0
|
|
310
|
+
except sqlite3.OperationalError:
|
|
311
|
+
# Table may not exist yet
|
|
312
|
+
return 0
|
|
313
|
+
finally:
|
|
314
|
+
conn.close()
|
|
315
|
+
|
|
316
|
+
def _write_cycle_count(self, count: int) -> None:
|
|
317
|
+
"""Persist cycle count to DB.
|
|
318
|
+
|
|
319
|
+
Relies on evolution_cycle_state table created by EvolutionStore.
|
|
320
|
+
Falls back to CREATE IF NOT EXISTS for standalone use.
|
|
321
|
+
"""
|
|
322
|
+
conn = sqlite3.connect(self._db_path, timeout=10)
|
|
323
|
+
try:
|
|
324
|
+
# Table schema matches evolution_store.py: value INTEGER
|
|
325
|
+
conn.execute(
|
|
326
|
+
"CREATE TABLE IF NOT EXISTS evolution_cycle_state "
|
|
327
|
+
"(key TEXT PRIMARY KEY, value INTEGER DEFAULT 0, updated_at TEXT)",
|
|
328
|
+
)
|
|
329
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
330
|
+
conn.execute(
|
|
331
|
+
"INSERT OR REPLACE INTO evolution_cycle_state (key, value, updated_at) "
|
|
332
|
+
"VALUES (?, ?, ?)",
|
|
333
|
+
(self._STATE_KEY, count, now),
|
|
334
|
+
)
|
|
335
|
+
conn.commit()
|
|
336
|
+
except sqlite3.OperationalError as exc:
|
|
337
|
+
logger.warning("Failed to persist cycle count: %s", exc)
|
|
338
|
+
finally:
|
|
339
|
+
conn.close()
|
|
340
|
+
|
|
341
|
+
def should_run(self) -> bool:
|
|
342
|
+
cycle_count = self._read_cycle_count() + 1
|
|
343
|
+
self._write_cycle_count(cycle_count)
|
|
344
|
+
return cycle_count % self._check_every_n == 0
|
|
345
|
+
|
|
346
|
+
def scan(self, profile_id: str = "default") -> list[EvolutionCandidate]:
|
|
347
|
+
if _check_memory_pressure():
|
|
348
|
+
return []
|
|
349
|
+
if not self.should_run():
|
|
350
|
+
return []
|
|
351
|
+
|
|
352
|
+
# Delegate to DegradationTrigger — same logic, different trigger label
|
|
353
|
+
deg = DegradationTrigger(self._db_path)
|
|
354
|
+
deg_candidates = deg.scan(profile_id)
|
|
355
|
+
|
|
356
|
+
# Re-label as health_check trigger
|
|
357
|
+
return [
|
|
358
|
+
EvolutionCandidate(
|
|
359
|
+
skill_name=c.skill_name,
|
|
360
|
+
evolution_type=c.evolution_type,
|
|
361
|
+
trigger=TriggerType.HEALTH_CHECK,
|
|
362
|
+
evidence=c.evidence,
|
|
363
|
+
effective_score=c.effective_score,
|
|
364
|
+
invocation_count=c.invocation_count,
|
|
365
|
+
)
|
|
366
|
+
for c in deg_candidates
|
|
367
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Skill Evolution type definitions.
|
|
6
|
+
|
|
7
|
+
Immutable data classes for evolution candidates, records, and lineage.
|
|
8
|
+
All types are frozen dataclasses — no mutation after creation.
|
|
9
|
+
|
|
10
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EvolutionType(str, Enum):
|
|
21
|
+
"""How the skill is being evolved."""
|
|
22
|
+
FIX = "fix" # Repair broken skill in-place
|
|
23
|
+
DERIVED = "derived" # Create specialized variant
|
|
24
|
+
CAPTURED = "captured" # Extract new skill from patterns
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TriggerType(str, Enum):
|
|
28
|
+
"""What triggered the evolution."""
|
|
29
|
+
POST_SESSION = "post_session" # Session Stop hook analysis
|
|
30
|
+
DEGRADATION = "degradation" # Behavioral assertion confidence drop
|
|
31
|
+
HEALTH_CHECK = "health_check" # Periodic consolidation scan
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EvolutionStatus(str, Enum):
|
|
35
|
+
"""Pipeline status."""
|
|
36
|
+
CANDIDATE = "candidate" # Detected, not yet confirmed
|
|
37
|
+
CONFIRMED = "confirmed" # LLM gate passed
|
|
38
|
+
MUTATED = "mutated" # New SKILL.md generated
|
|
39
|
+
VERIFIED = "verified" # Blind verification passed
|
|
40
|
+
PROMOTED = "promoted" # Live — evolved skill active
|
|
41
|
+
REJECTED = "rejected" # Failed verification or gate
|
|
42
|
+
FAILED = "failed" # Error during evolution
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class EvolutionCandidate:
|
|
47
|
+
"""A skill flagged for potential evolution."""
|
|
48
|
+
skill_name: str
|
|
49
|
+
evolution_type: EvolutionType
|
|
50
|
+
trigger: TriggerType
|
|
51
|
+
evidence: tuple[str, ...] = ()
|
|
52
|
+
effective_score: float = 0.0
|
|
53
|
+
invocation_count: int = 0
|
|
54
|
+
session_id: str = ""
|
|
55
|
+
project_path: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class EvolutionRecord:
|
|
60
|
+
"""Persisted record of an evolution attempt."""
|
|
61
|
+
id: str
|
|
62
|
+
skill_name: str
|
|
63
|
+
parent_skill_id: Optional[str]
|
|
64
|
+
evolution_type: EvolutionType
|
|
65
|
+
trigger: TriggerType
|
|
66
|
+
generation: int = 0
|
|
67
|
+
status: EvolutionStatus = EvolutionStatus.CANDIDATE
|
|
68
|
+
mutation_summary: str = ""
|
|
69
|
+
evidence: tuple[str, ...] = ()
|
|
70
|
+
original_content: str = ""
|
|
71
|
+
evolved_content: str = ""
|
|
72
|
+
content_diff: str = ""
|
|
73
|
+
blind_verified: bool = False
|
|
74
|
+
rejection_reason: str = ""
|
|
75
|
+
created_at: str = ""
|
|
76
|
+
completed_at: str = ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class SkillLineage:
|
|
81
|
+
"""Lineage metadata for an evolved skill."""
|
|
82
|
+
skill_id: str
|
|
83
|
+
parent_skill_id: Optional[str]
|
|
84
|
+
evolution_type: EvolutionType
|
|
85
|
+
generation: int
|
|
86
|
+
trigger: TriggerType
|
|
87
|
+
mutation_summary: str = ""
|
|
88
|
+
created_at: str = ""
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_root(self) -> bool:
|
|
92
|
+
return self.parent_skill_id is None
|
|
@@ -301,6 +301,19 @@ def _hook_stop() -> None:
|
|
|
301
301
|
except Exception:
|
|
302
302
|
pass
|
|
303
303
|
|
|
304
|
+
# --- Post-session skill evolution trigger (best-effort) ---
|
|
305
|
+
try:
|
|
306
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID", "")
|
|
307
|
+
if session_id:
|
|
308
|
+
subprocess.Popen(
|
|
309
|
+
["slm", "evolve", "--session", session_id],
|
|
310
|
+
stdout=subprocess.DEVNULL,
|
|
311
|
+
stderr=subprocess.DEVNULL,
|
|
312
|
+
start_new_session=True,
|
|
313
|
+
)
|
|
314
|
+
except Exception:
|
|
315
|
+
pass
|
|
316
|
+
|
|
304
317
|
# --- Auto-consolidation (if >24h since last run) ---
|
|
305
318
|
_maybe_consolidate()
|
|
306
319
|
|
|
@@ -171,6 +171,8 @@ class SkillPerformanceMiner:
|
|
|
171
171
|
- Signal 2 (NEGATIVE): Same Skill re-invoked within 5 min
|
|
172
172
|
- Signal 3 (NEGATIVE): Bash errors in next 3 events
|
|
173
173
|
- Signal 4 (WEAK POSITIVE): Session continues 10+ events
|
|
174
|
+
|
|
175
|
+
H-N1QUERY: Batch-loads all trace events in one query instead of N+1.
|
|
174
176
|
"""
|
|
175
177
|
metrics: dict[str, dict] = defaultdict(lambda: {
|
|
176
178
|
"total_invocations": 0,
|
|
@@ -180,6 +182,29 @@ class SkillPerformanceMiner:
|
|
|
180
182
|
"projects": set(),
|
|
181
183
|
})
|
|
182
184
|
|
|
185
|
+
if not invocations:
|
|
186
|
+
return {}
|
|
187
|
+
|
|
188
|
+
# H-N1QUERY: Batch-load all potential trace events in one query.
|
|
189
|
+
# Find the min event_id across all invocations so we can fetch
|
|
190
|
+
# all subsequent events in a single SELECT.
|
|
191
|
+
min_event_id = min(inv["event_id"] for inv in invocations)
|
|
192
|
+
all_trace_rows = conn.execute(
|
|
193
|
+
"SELECT id, tool_name, event_type, output_summary, created_at "
|
|
194
|
+
"FROM tool_events "
|
|
195
|
+
"WHERE profile_id = ? AND id > ? "
|
|
196
|
+
"ORDER BY id ASC",
|
|
197
|
+
(profile_id, min_event_id),
|
|
198
|
+
).fetchall()
|
|
199
|
+
all_trace = [dict(r) for r in all_trace_rows]
|
|
200
|
+
|
|
201
|
+
# Build an index: for each event_id, find its position in all_trace
|
|
202
|
+
# so we can slice TRACE_WINDOW events after it in O(1).
|
|
203
|
+
trace_id_to_idx: dict[int, int] = {}
|
|
204
|
+
for idx, t in enumerate(all_trace):
|
|
205
|
+
if t["id"] not in trace_id_to_idx:
|
|
206
|
+
trace_id_to_idx[t["id"]] = idx
|
|
207
|
+
|
|
183
208
|
for inv in invocations:
|
|
184
209
|
skill = inv["skill_name"]
|
|
185
210
|
m = metrics[skill]
|
|
@@ -188,16 +213,24 @@ class SkillPerformanceMiner:
|
|
|
188
213
|
if inv["project_path"]:
|
|
189
214
|
m["projects"].add(inv["project_path"])
|
|
190
215
|
|
|
191
|
-
#
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
216
|
+
# Find trace window for this invocation from pre-loaded data
|
|
217
|
+
# Events with id > inv["event_id"], take first TRACE_WINDOW
|
|
218
|
+
start_idx = 0
|
|
219
|
+
eid = inv["event_id"]
|
|
220
|
+
# The first entry in all_trace with id > eid
|
|
221
|
+
# Since all_trace starts at min_event_id+1 and is sorted, we
|
|
222
|
+
# can bisect or scan. Use the index if the next id is present.
|
|
223
|
+
# Simple approach: events after eid start at the position of eid+1
|
|
224
|
+
# or the first id > eid in the sorted list.
|
|
225
|
+
for candidate_id in range(eid + 1, eid + TRACE_WINDOW + 2):
|
|
226
|
+
if candidate_id in trace_id_to_idx:
|
|
227
|
+
start_idx = trace_id_to_idx[candidate_id]
|
|
228
|
+
break
|
|
229
|
+
else:
|
|
230
|
+
# No trace events found after this invocation
|
|
231
|
+
start_idx = len(all_trace)
|
|
199
232
|
|
|
200
|
-
trace_list = [
|
|
233
|
+
trace_list = all_trace[start_idx:start_idx + TRACE_WINDOW]
|
|
201
234
|
outcome = self._evaluate_trace(skill, inv, trace_list, invocations)
|
|
202
235
|
|
|
203
236
|
if outcome > 0:
|
|
@@ -318,7 +351,7 @@ class SkillPerformanceMiner:
|
|
|
318
351
|
|
|
319
352
|
assertion_id = hashlib.sha256(
|
|
320
353
|
f"{profile_id}:skill_perf:{skill_name}".encode(),
|
|
321
|
-
).hexdigest()[:
|
|
354
|
+
).hexdigest()[:32]
|
|
322
355
|
|
|
323
356
|
existing = conn.execute(
|
|
324
357
|
"SELECT id, confidence FROM behavioral_assertions WHERE id = ?",
|
|
@@ -363,7 +396,7 @@ class SkillPerformanceMiner:
|
|
|
363
396
|
|
|
364
397
|
assertion_id = hashlib.sha256(
|
|
365
398
|
f"{profile_id}:skill_corr:{pair[0]}:{pair[1]}".encode(),
|
|
366
|
-
).hexdigest()[:
|
|
399
|
+
).hexdigest()[:32]
|
|
367
400
|
|
|
368
401
|
existing = conn.execute(
|
|
369
402
|
"SELECT id FROM behavioral_assertions WHERE id = ?",
|
|
@@ -79,6 +79,8 @@ _ESSENTIAL_TOOLS: set[str] = {
|
|
|
79
79
|
# v3.4.7: Two-way learning (4)
|
|
80
80
|
"log_tool_event", "get_assertions",
|
|
81
81
|
"reinforce_assertion", "contradict_assertion",
|
|
82
|
+
# v3.4.11: Skill evolution (3)
|
|
83
|
+
"evolve_skill", "skill_health", "skill_lineage",
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
# v3.4.4: Mesh tools — enabled if mesh_enabled in config or SLM_MCP_MESH_TOOLS=1
|
|
@@ -138,6 +140,7 @@ from superlocalmemory.mcp.resources import register_resources
|
|
|
138
140
|
from superlocalmemory.mcp.tools_code_graph import register_code_graph_tools
|
|
139
141
|
from superlocalmemory.mcp.tools_mesh import register_mesh_tools
|
|
140
142
|
from superlocalmemory.mcp.tools_learning import register_learning_tools
|
|
143
|
+
from superlocalmemory.mcp.tools_evolution import register_evolution_tools
|
|
141
144
|
|
|
142
145
|
register_core_tools(_target, get_engine)
|
|
143
146
|
register_v28_tools(_target, get_engine)
|
|
@@ -148,6 +151,7 @@ register_resources(server, get_engine) # Resources always registered (not tools
|
|
|
148
151
|
register_code_graph_tools(_target, get_engine) # CodeGraph: filtered like other tools (SLM_MCP_ALL_TOOLS=1 to show all)
|
|
149
152
|
register_mesh_tools(_target, get_engine) # v3.4.4: Mesh P2P tools — ships with SLM, no separate slm-mesh needed
|
|
150
153
|
register_learning_tools(_target, get_engine) # v3.4.7: Two-way learning tools
|
|
154
|
+
register_evolution_tools(_target, get_engine) # v3.4.11: Skill evolution tools
|
|
151
155
|
|
|
152
156
|
|
|
153
157
|
# V3.3.21: Eager engine warmup — start initializing BEFORE first tool call.
|