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.
Files changed (52) hide show
  1. package/README.md +23 -3
  2. package/docs/cloud-backup.md +174 -0
  3. package/docs/skill-evolution.md +256 -0
  4. package/ide/hooks/tool-event-hook.sh +101 -11
  5. package/package.json +1 -1
  6. package/pyproject.toml +3 -2
  7. package/src/superlocalmemory/cli/commands.py +359 -0
  8. package/src/superlocalmemory/cli/ingest_cmd.py +81 -29
  9. package/src/superlocalmemory/cli/main.py +32 -0
  10. package/src/superlocalmemory/cli/setup_wizard.py +54 -11
  11. package/src/superlocalmemory/core/config.py +35 -0
  12. package/src/superlocalmemory/core/consolidation_engine.py +138 -0
  13. package/src/superlocalmemory/core/embedding_worker.py +1 -1
  14. package/src/superlocalmemory/core/engine.py +19 -0
  15. package/src/superlocalmemory/core/fact_consolidator.py +425 -0
  16. package/src/superlocalmemory/core/graph_pruner.py +290 -0
  17. package/src/superlocalmemory/core/maintenance_scheduler.py +44 -3
  18. package/src/superlocalmemory/core/recall_pipeline.py +9 -0
  19. package/src/superlocalmemory/core/tier_manager.py +325 -0
  20. package/src/superlocalmemory/encoding/entity_resolver.py +96 -28
  21. package/src/superlocalmemory/evolution/__init__.py +29 -0
  22. package/src/superlocalmemory/evolution/blind_verifier.py +115 -0
  23. package/src/superlocalmemory/evolution/evolution_store.py +302 -0
  24. package/src/superlocalmemory/evolution/mutation_generator.py +181 -0
  25. package/src/superlocalmemory/evolution/skill_evolver.py +555 -0
  26. package/src/superlocalmemory/evolution/triggers.py +367 -0
  27. package/src/superlocalmemory/evolution/types.py +92 -0
  28. package/src/superlocalmemory/hooks/hook_handlers.py +13 -0
  29. package/src/superlocalmemory/infra/backup.py +63 -20
  30. package/src/superlocalmemory/infra/cloud_backup.py +703 -0
  31. package/src/superlocalmemory/learning/skill_performance_miner.py +422 -0
  32. package/src/superlocalmemory/mcp/server.py +4 -0
  33. package/src/superlocalmemory/mcp/tools_evolution.py +338 -0
  34. package/src/superlocalmemory/retrieval/engine.py +64 -4
  35. package/src/superlocalmemory/retrieval/forgetting_filter.py +22 -7
  36. package/src/superlocalmemory/retrieval/strategy.py +2 -2
  37. package/src/superlocalmemory/server/routes/backup.py +512 -8
  38. package/src/superlocalmemory/server/routes/behavioral.py +39 -17
  39. package/src/superlocalmemory/server/routes/evolution.py +213 -0
  40. package/src/superlocalmemory/server/routes/tiers.py +195 -0
  41. package/src/superlocalmemory/server/unified_daemon.py +36 -5
  42. package/src/superlocalmemory/storage/schema_v3410.py +159 -0
  43. package/src/superlocalmemory/storage/schema_v3411.py +149 -0
  44. package/src/superlocalmemory/ui/index.html +59 -3
  45. package/src/superlocalmemory/ui/js/core.js +3 -0
  46. package/src/superlocalmemory/ui/js/lifecycle.js +83 -0
  47. package/src/superlocalmemory/ui/js/ng-entities.js +27 -3
  48. package/src/superlocalmemory/ui/js/ng-shell.js +33 -0
  49. package/src/superlocalmemory/ui/js/ng-skills.js +611 -0
  50. package/src/superlocalmemory/ui/js/settings.js +311 -1
  51. package/src/superlocalmemory.egg-info/PKG-INFO +16 -1
  52. 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
- # Also backup learning.db if present
173
- self._backup_learning_db(timestamp, suffix)
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 _backup_learning_db(self, timestamp: str, suffix: str) -> None:
185
- """Best-effort backup of ``learning.db`` alongside the main DB."""
186
- learning_db = self.db_path.parent / "learning.db"
187
- if not learning_db.exists():
188
- return
189
- try:
190
- name = f"learning-{timestamp}{suffix}.db"
191
- path = self.backup_dir / name
192
- src = sqlite3.connect(str(learning_db))
193
- dst = sqlite3.connect(str(path))
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
- src.backup(dst)
196
- finally:
197
- dst.close()
198
- src.close()
199
- logger.info("Learning backup: %s (%.1f MB)", name, path.stat().st_size / (1024 * 1024))
200
- except Exception as exc:
201
- logger.warning("Learning DB backup failed (non-critical): %s", exc)
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
- for pattern in ("memory-*.db", "learning-*.db"):
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,