superlocalmemory 3.4.9 → 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 +23 -3
- package/docs/cloud-backup.md +174 -0
- package/docs/skill-evolution.md +256 -0
- package/ide/hooks/tool-event-hook.sh +101 -11
- package/package.json +1 -1
- package/pyproject.toml +3 -2
- package/src/superlocalmemory/cli/commands.py +359 -0
- package/src/superlocalmemory/cli/ingest_cmd.py +81 -29
- package/src/superlocalmemory/cli/main.py +32 -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 +138 -0
- package/src/superlocalmemory/core/embedding_worker.py +1 -1
- package/src/superlocalmemory/core/engine.py +19 -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 +44 -3
- 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 +96 -28
- 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/infra/backup.py +63 -20
- package/src/superlocalmemory/infra/cloud_backup.py +703 -0
- package/src/superlocalmemory/learning/skill_performance_miner.py +422 -0
- 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/backup.py +512 -8
- package/src/superlocalmemory/server/routes/behavioral.py +39 -17
- 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_v3410.py +159 -0
- package/src/superlocalmemory/storage/schema_v3411.py +149 -0
- package/src/superlocalmemory/ui/index.html +59 -3
- package/src/superlocalmemory/ui/js/core.js +3 -0
- package/src/superlocalmemory/ui/js/lifecycle.js +83 -0
- package/src/superlocalmemory/ui/js/ng-entities.js +27 -3
- package/src/superlocalmemory/ui/js/ng-shell.js +33 -0
- package/src/superlocalmemory/ui/js/ng-skills.js +611 -0
- package/src/superlocalmemory/ui/js/settings.js +311 -1
- package/src/superlocalmemory.egg-info/PKG-INFO +16 -1
- package/src/superlocalmemory.egg-info/SOURCES.txt +18 -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
|
|
|
@@ -34,6 +34,26 @@ DEFAULT_INTERVAL_HOURS = 168 # 7 days
|
|
|
34
34
|
DEFAULT_MAX_BACKUPS = 10
|
|
35
35
|
MIN_INTERVAL_HOURS = 1
|
|
36
36
|
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# SLM Managed Database Registry
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Every database that SLM creates and manages. The backup system backs up
|
|
41
|
+
# ONLY these databases — nothing else. When a new SLM module creates a new
|
|
42
|
+
# database file, add it here so it gets included in backups.
|
|
43
|
+
#
|
|
44
|
+
# Each user may have a different subset (e.g., some don't have code_graph.db
|
|
45
|
+
# if they never used the code graph feature). The backup system checks which
|
|
46
|
+
# ones exist and only backs up what's present.
|
|
47
|
+
|
|
48
|
+
MANAGED_DATABASES: tuple[str, ...] = (
|
|
49
|
+
"memory.db", # Core: facts, entities, graph, embeddings, sessions
|
|
50
|
+
"learning.db", # Learning pipeline: signals, patterns, ranker
|
|
51
|
+
"audit_chain.db", # Audit trail: compliance, provenance chain
|
|
52
|
+
"code_graph.db", # Code knowledge graph: symbols, references
|
|
53
|
+
"pending.db", # Pending operations queue
|
|
54
|
+
"audit.db", # Legacy audit (pre-v3.4)
|
|
55
|
+
)
|
|
56
|
+
|
|
37
57
|
|
|
38
58
|
class BackupManager:
|
|
39
59
|
"""Automated backup manager for SuperLocalMemory V3.
|
|
@@ -169,8 +189,8 @@ class BackupManager:
|
|
|
169
189
|
self._save_config()
|
|
170
190
|
logger.info("Backup created: %s (%.1f MB)", backup_name, size_mb)
|
|
171
191
|
|
|
172
|
-
#
|
|
173
|
-
self.
|
|
192
|
+
# v3.4.10: Backup ALL .db files in the SLM directory
|
|
193
|
+
self._backup_all_dbs(timestamp, suffix)
|
|
174
194
|
|
|
175
195
|
self._enforce_retention()
|
|
176
196
|
return backup_name
|
|
@@ -181,29 +201,52 @@ class BackupManager:
|
|
|
181
201
|
backup_path.unlink()
|
|
182
202
|
return ""
|
|
183
203
|
|
|
184
|
-
def
|
|
185
|
-
"""
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
204
|
+
def _backup_all_dbs(self, timestamp: str, suffix: str) -> None:
|
|
205
|
+
"""Backup all SLM-managed databases alongside the main memory.db.
|
|
206
|
+
|
|
207
|
+
Uses the managed database registry — only backs up databases that
|
|
208
|
+
SLM knows about. Add new databases to MANAGED_DATABASES when new
|
|
209
|
+
modules create them.
|
|
210
|
+
"""
|
|
211
|
+
slm_dir = self.db_path.parent
|
|
212
|
+
backed_up = 0
|
|
213
|
+
for db_name in MANAGED_DATABASES:
|
|
214
|
+
if db_name == "memory.db":
|
|
215
|
+
continue # Already backed up by create_backup()
|
|
216
|
+
db_file = slm_dir / db_name
|
|
217
|
+
if not db_file.exists():
|
|
218
|
+
continue # This user doesn't have this DB — skip
|
|
219
|
+
|
|
194
220
|
try:
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
src.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
221
|
+
prefix = db_file.stem
|
|
222
|
+
name = f"{prefix}-{timestamp}{suffix}.db"
|
|
223
|
+
path = self.backup_dir / name
|
|
224
|
+
src = sqlite3.connect(str(db_file))
|
|
225
|
+
dst = sqlite3.connect(str(path))
|
|
226
|
+
try:
|
|
227
|
+
src.backup(dst)
|
|
228
|
+
finally:
|
|
229
|
+
dst.close()
|
|
230
|
+
src.close()
|
|
231
|
+
backed_up += 1
|
|
232
|
+
logger.info(
|
|
233
|
+
"Backup: %s (%.1f MB)", name,
|
|
234
|
+
path.stat().st_size / (1024 * 1024),
|
|
235
|
+
)
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
logger.warning(
|
|
238
|
+
"%s backup failed (non-critical): %s",
|
|
239
|
+
db_name, exc,
|
|
240
|
+
)
|
|
241
|
+
if backed_up:
|
|
242
|
+
logger.info("Backed up %d companion databases", backed_up)
|
|
202
243
|
|
|
203
244
|
def _enforce_retention(self) -> None:
|
|
204
245
|
"""Remove old backups exceeding the configured max."""
|
|
205
246
|
max_backups = self.config.get("max_backups", DEFAULT_MAX_BACKUPS)
|
|
206
|
-
|
|
247
|
+
# Build patterns from the managed database registry
|
|
248
|
+
patterns = [f"{Path(db).stem}-*.db" for db in MANAGED_DATABASES]
|
|
249
|
+
for pattern in patterns:
|
|
207
250
|
backups = sorted(
|
|
208
251
|
self.backup_dir.glob(pattern),
|
|
209
252
|
key=lambda f: f.stat().st_mtime,
|