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
@@ -52,6 +52,9 @@ _PLACE_MARKERS = ("City", "State", "County", "Island", "River", "Mountain",
52
52
  "Lake", "Park", "Street", "Avenue", "Road", "District")
53
53
  _EVENT_MARKERS = ("Festival", "Conference", "Summit", "Workshop", "Meeting",
54
54
  "Election", "War", "Match", "Game", "Concert", "Wedding")
55
+ # v3.4.10: Skill entity type — skills, commands, agents, plugins
56
+ _SKILL_MARKERS_RE = re.compile(r'\b(?:skill|command|agent|plugin|hook|mcp)\b', re.IGNORECASE)
57
+ _SKILL_NAMESPACE_RE = re.compile(r"^[\w-]+:[\w-]+$") # e.g., "superpowers:brainstorming"
55
58
 
56
59
 
57
60
  # ---------------------------------------------------------------------------
@@ -113,25 +116,67 @@ def jaro_winkler(s1: str, s2: str, prefix_weight: float = 0.1) -> float:
113
116
 
114
117
 
115
118
  _COMMON_WORDS = frozenset({
116
- "april", "may", "june", "march", "august", "phase", "test", "gap",
117
- "dashboard", "remaining", "session", "results", "tools", "projects",
118
- "prompts", "integration", "cli", "engagement", "mode", "error",
119
- "step", "fix", "build", "check", "run", "start", "stop", "config",
120
- "status", "version", "query", "data", "file", "path", "node", "edge",
121
- "table", "index", "schema", "model", "type", "class", "function",
122
- "module", "package", "import", "export", "default", "pattern",
119
+ # Months / time words (biggest source of garbage entities)
120
+ "january", "february", "march", "april", "may", "june", "july",
121
+ "august", "september", "october", "november", "december",
122
+ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
123
+ "today", "tomorrow", "yesterday", "morning", "evening", "night",
124
+ # English stop words that get capitalized at sentence start
125
+ "a", "an", "the", "all", "not", "no", "yes", "and", "or", "but",
126
+ "if", "is", "are", "was", "were", "be", "been", "being", "have",
127
+ "has", "had", "do", "does", "did", "will", "would", "shall", "should",
128
+ "can", "could", "just", "also", "only", "very", "too", "so", "then",
129
+ "than", "that", "this", "these", "those", "here", "there", "where",
130
+ "when", "what", "which", "who", "whom", "how", "why", "each", "every",
131
+ "both", "few", "more", "most", "other", "some", "such", "any", "many",
132
+ "much", "own", "same", "new", "old", "first", "last", "next", "now",
133
+ "still", "already", "always", "never", "often", "sometimes", "about",
134
+ "above", "after", "again", "against", "along", "among", "around",
135
+ "before", "below", "between", "beyond", "during", "except", "from",
136
+ "into", "near", "off", "onto", "out", "over", "past", "since",
137
+ "through", "under", "until", "upon", "with", "within", "without",
138
+ # Technical stop words (common in dev sessions)
139
+ "phase", "test", "gap", "dashboard", "remaining", "session", "results",
140
+ "tools", "projects", "prompts", "integration", "cli", "engagement",
141
+ "mode", "error", "step", "fix", "build", "check", "run", "start",
142
+ "stop", "config", "status", "version", "query", "data", "file", "path",
143
+ "node", "edge", "table", "index", "schema", "model", "type", "class",
144
+ "function", "module", "package", "import", "export", "default", "pattern",
123
145
  "memory", "profile", "context", "pipeline", "worker", "daemon",
124
146
  "server", "client", "route", "endpoint", "handler", "hook",
147
+ "feature", "release", "update", "upgrade", "deploy", "debug", "log",
148
+ "output", "input", "key", "value", "true", "false", "null", "none",
149
+ "ready", "done", "todo", "complete", "pending", "active", "failed",
150
+ "success", "warning", "critical", "high", "medium", "low",
151
+ "total", "count", "list", "item", "entry", "record", "row", "column",
152
+ "source", "target", "origin", "destination", "backup", "restore",
153
+ "create", "read", "delete", "remove", "add", "set", "get", "put",
154
+ "push", "pull", "fetch", "send", "receive", "request", "response",
155
+ "enable", "disable", "open", "close", "load", "save", "reset",
156
+ # Abstract nouns often misclassified as people
157
+ "completeness", "correctness", "limitations", "requirements",
158
+ "dependencies", "performance", "security", "quality", "coverage",
159
+ "progress", "analysis", "research", "implementation", "verification",
160
+ "overview", "summary", "details", "notes", "changes", "issues",
161
+ "approach", "strategy", "solution", "problem", "question", "answer",
125
162
  })
126
163
 
127
164
 
128
165
  def _guess_entity_type(name: str) -> str:
129
166
  """Heuristic entity type classification from name string.
130
167
 
131
- v3.4.8: Fixed false-positive "person" classification. Single capitalized
132
- common words (April, Phase, Dashboard) are concepts, not people.
133
- Only classify as "person" when it looks like a real human name.
168
+ v3.4.10: Aggressive false-positive prevention. "person" is assigned ONLY
169
+ when the name looks like a real human name (2-3 capitalized words, none
170
+ in the stop list). Everything else defaults to "concept".
134
171
  """
172
+ # Reject very short or very long names
173
+ if len(name) <= 2 or len(name) > 100:
174
+ return "concept"
175
+
176
+ # Reject pure numbers, dates, version strings
177
+ if re.match(r"^[\d.v\-/]+$", name):
178
+ return "concept"
179
+
135
180
  if any(m in name for m in _ORG_MARKERS):
136
181
  return "organization"
137
182
  if any(m in name for m in _PLACE_MARKERS):
@@ -139,32 +184,38 @@ def _guess_entity_type(name: str) -> str:
139
184
  if any(m in name for m in _EVENT_MARKERS):
140
185
  return "event"
141
186
 
142
- # Filter out common words that aren't people
143
- if name.lower() in _COMMON_WORDS:
187
+ # v3.4.10: Skill entities namespaced skills always skill type
188
+ if _SKILL_NAMESPACE_RE.match(name):
189
+ return "skill"
190
+
191
+ # Check ALL words against the stop list (not just the full name)
192
+ words = name.lower().split()
193
+ if any(w in _COMMON_WORDS for w in words):
144
194
  return "concept"
145
195
 
146
- # Two capitalized words = likely a person name (e.g. "Varun Bhardwaj")
147
- if re.match(r"^[A-Z][a-z]+ [A-Z][a-z]+$", name):
148
- # But not if either word is a common term
149
- parts = name.lower().split()
150
- if not any(p in _COMMON_WORDS for p in parts):
196
+ # v3.4.10: Skill entities word-boundary match AFTER common-word filter
197
+ if _SKILL_MARKERS_RE.search(name):
198
+ return "skill"
199
+
200
+ # Multi-word entity: "person" only if 2-3 capitalized words, no stop words
201
+ if re.match(r"^[A-Z][a-z]+ [A-Z][a-z]+( [A-Z][a-z]+)?$", name):
202
+ if not any(p in _COMMON_WORDS for p in words):
151
203
  return "person"
152
204
 
153
- # Single short capitalized word with no digits or dots = concept, not person
154
- # "person" should only be assigned for real names, not generic terms
205
+ # Single capitalized word almost never a person in our context
206
+ # Only known first names should get "person" but we can't maintain
207
+ # a name dictionary, so default to "concept"
155
208
  if re.match(r"^[A-Z][a-z]+$", name):
156
- if name.lower() in _COMMON_WORDS:
157
- return "concept"
158
- # Only classify as person if it's a plausible first name
159
- # (short word not in common terms — still a heuristic)
160
- if len(name) <= 3:
161
- return "concept"
162
- return "person"
163
-
164
- # Contains dots/slashes/hyphens = likely a technical term
209
+ return "concept"
210
+
211
+ # Contains dots/slashes/hyphens/underscores = technical term
165
212
  if re.search(r"[./\-_]", name):
166
213
  return "concept"
167
214
 
215
+ # ALL-CAPS or mixed case with numbers = technical/concept
216
+ if re.match(r"^[A-Z]+$", name) or re.search(r"\d", name):
217
+ return "concept"
218
+
168
219
  return "concept"
169
220
 
170
221
 
@@ -211,6 +262,23 @@ class EntityResolver:
211
262
  if not name or name.lower() in PRONOUNS:
212
263
  continue
213
264
 
265
+ # Skip very short/long entities
266
+ if len(name) <= 2 or len(name) > 100:
267
+ continue
268
+
269
+ # Skip single-word stop words
270
+ words = name.lower().split()
271
+ if len(words) == 1 and name.lower() in _COMMON_WORDS:
272
+ continue
273
+
274
+ # Skip multi-word entities where ALL words are stop words or <=2 chars
275
+ if len(words) > 1 and all(w in _COMMON_WORDS or len(w) <= 2 for w in words):
276
+ continue
277
+
278
+ # Skip pure numbers/versions
279
+ if re.match(r"^[\d.v\-/]+$", name):
280
+ continue
281
+
214
282
  # Tier a: exact match on canonical_name
215
283
  entity = self._db.get_entity_by_name(name, profile_id)
216
284
  if entity is not None:
@@ -0,0 +1,29 @@
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 Engine — track, analyze, and evolve AI agent skills.
6
+
7
+ 3-trigger system (post-session + degradation + health check) with
8
+ LLM confirmation gate and blind verification.
9
+
10
+ Inspired by: HKUDS/OpenSpace (arXiv:2604.01687), ECC continuous learning.
11
+
12
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
13
+ """
14
+
15
+ from superlocalmemory.evolution.types import (
16
+ EvolutionCandidate,
17
+ EvolutionRecord,
18
+ EvolutionType,
19
+ TriggerType,
20
+ EvolutionStatus,
21
+ )
22
+
23
+ __all__ = [
24
+ "EvolutionCandidate",
25
+ "EvolutionRecord",
26
+ "EvolutionType",
27
+ "TriggerType",
28
+ "EvolutionStatus",
29
+ ]
@@ -0,0 +1,115 @@
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
+ """Blind Verifier — information-isolated skill verification.
6
+
7
+ The key insight from EvoSkills (arXiv:2604.01687): when a generator
8
+ creates a skill and the same model verifies it, confirmation bias is
9
+ nearly guaranteed. The verifier must be BLIND to the generator's reasoning.
10
+
11
+ This verifier:
12
+ - Uses a DIFFERENT model from the generator (Haiku vs Sonnet)
13
+ - CANNOT see: original skill, mutation rationale, generator's reasoning
14
+ - CAN see: task description (what the skill should do), evolved SKILL.md
15
+ - Evaluates independently: "Does this skill correctly address the task?"
16
+
17
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import re
25
+ from dataclasses import dataclass
26
+ from typing import Optional
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class VerificationResult:
33
+ """Result of blind verification."""
34
+ passed: bool
35
+ confidence: float # 0.0-1.0
36
+ issues: tuple[str, ...] = ()
37
+ reasoning: str = ""
38
+
39
+
40
+ def build_verification_prompt(
41
+ skill_name: str,
42
+ skill_description: str,
43
+ evolved_content: str,
44
+ ) -> str:
45
+ """Build blind verification prompt.
46
+
47
+ The verifier sees ONLY:
48
+ - What the skill is supposed to do (name + description)
49
+ - The evolved skill content
50
+
51
+ The verifier does NOT see:
52
+ - The original skill
53
+ - Why it was evolved
54
+ - What evidence triggered evolution
55
+ - The generator's reasoning
56
+ """
57
+ return f"""You are an independent skill quality reviewer. You have NOT seen the original
58
+ version of this skill or why it was modified. Evaluate it purely on its merits.
59
+
60
+ SKILL PURPOSE: {skill_name}
61
+ EXPECTED BEHAVIOR: {skill_description}
62
+
63
+ SKILL CONTENT TO REVIEW:
64
+ {evolved_content[:8000]}
65
+
66
+ EVALUATE:
67
+ 1. Does the skill clearly explain what to do? (clarity)
68
+ 2. Are the instructions specific and actionable? (specificity)
69
+ 3. Are there any obvious errors, contradictions, or missing steps? (correctness)
70
+ 4. Would an AI agent be able to follow these instructions? (executability)
71
+
72
+ RESPOND IN JSON FORMAT:
73
+ {{
74
+ "passed": true/false,
75
+ "confidence": 0.0-1.0,
76
+ "issues": ["issue1", "issue2"],
77
+ "reasoning": "brief explanation"
78
+ }}
79
+
80
+ Be strict. Only pass skills that are genuinely clear, correct, and actionable.
81
+ A mediocre skill that might work sometimes should FAIL — evolution should produce
82
+ clear improvements, not marginal changes."""
83
+
84
+
85
+ def parse_verification_response(response: str) -> VerificationResult:
86
+ """Parse the verifier's JSON response."""
87
+ # Try parsing JSON from response
88
+ json_match = re.search(r"\{[^{}]*\"passed\"[^{}]*\}", response, re.DOTALL)
89
+ if json_match:
90
+ try:
91
+ data = json.loads(json_match.group(0))
92
+ return VerificationResult(
93
+ passed=bool(data.get("passed", False)),
94
+ confidence=float(data.get("confidence", 0.5)),
95
+ issues=tuple(data.get("issues", [])),
96
+ reasoning=str(data.get("reasoning", "")),
97
+ )
98
+ except (json.JSONDecodeError, TypeError, ValueError):
99
+ pass
100
+
101
+ # Fallback: keyword detection
102
+ lower = response.lower()
103
+ if any(kw in lower for kw in ("\"passed\": true", "passed: true", "approve", "looks good")):
104
+ return VerificationResult(passed=True, confidence=0.6, reasoning="keyword match")
105
+
106
+ if any(kw in lower for kw in ("\"passed\": false", "passed: false", "reject", "fail")):
107
+ return VerificationResult(passed=False, confidence=0.6, reasoning="keyword match")
108
+
109
+ # Default: reject if can't parse (conservative)
110
+ return VerificationResult(
111
+ passed=False,
112
+ confidence=0.3,
113
+ reasoning="Could not parse verification response",
114
+ issues=("Unparseable response",),
115
+ )
@@ -0,0 +1,302 @@
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 Store — SQLite persistence for skill evolution history.
6
+
7
+ Stores evolution records, lineage DAG, and anti-loop state.
8
+ Uses the same memory.db as the rest of SLM — no separate database.
9
+
10
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import sqlite3
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ from superlocalmemory.evolution.types import (
23
+ EvolutionCandidate,
24
+ EvolutionRecord,
25
+ EvolutionStatus,
26
+ EvolutionType,
27
+ TriggerType,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _SCHEMA_DDL = """
33
+ CREATE TABLE IF NOT EXISTS skill_evolution_log (
34
+ id TEXT PRIMARY KEY,
35
+ skill_name TEXT NOT NULL,
36
+ parent_skill_id TEXT,
37
+ evolution_type TEXT NOT NULL,
38
+ trigger_type TEXT NOT NULL,
39
+ generation INTEGER DEFAULT 0,
40
+ status TEXT DEFAULT 'candidate',
41
+ mutation_summary TEXT DEFAULT '',
42
+ evidence TEXT DEFAULT '[]',
43
+ original_content TEXT DEFAULT '',
44
+ evolved_content TEXT DEFAULT '',
45
+ content_diff TEXT DEFAULT '',
46
+ blind_verified INTEGER DEFAULT 0,
47
+ rejection_reason TEXT DEFAULT '',
48
+ created_at TEXT NOT NULL,
49
+ completed_at TEXT
50
+ );
51
+
52
+ CREATE INDEX IF NOT EXISTS idx_evo_skill ON skill_evolution_log(skill_name);
53
+ CREATE INDEX IF NOT EXISTS idx_evo_status ON skill_evolution_log(status);
54
+ CREATE INDEX IF NOT EXISTS idx_evo_created ON skill_evolution_log(created_at);
55
+
56
+ CREATE TABLE IF NOT EXISTS evolution_cycle_state (
57
+ key TEXT PRIMARY KEY,
58
+ value INTEGER DEFAULT 0,
59
+ updated_at TEXT
60
+ );
61
+ """
62
+
63
+ # Anti-loop budget
64
+ MAX_EVOLUTIONS_PER_CYCLE = 3
65
+ MAX_ATTEMPTS_PER_SKILL = 3
66
+ MIN_FRESH_INVOCATIONS = 5
67
+
68
+
69
+ class EvolutionStore:
70
+ """SQLite persistence for evolution history and anti-loop state."""
71
+
72
+ def __init__(self, db_path: str | Path):
73
+ self._db_path = str(db_path)
74
+ self._ensure_schema()
75
+ self._addressed_degradations: dict[str, set[str]] = {}
76
+
77
+ def _ensure_schema(self) -> None:
78
+ conn = sqlite3.connect(self._db_path, timeout=10)
79
+ try:
80
+ conn.executescript(_SCHEMA_DDL)
81
+ conn.commit()
82
+ except sqlite3.OperationalError as exc:
83
+ logger.warning("Evolution schema creation failed: %s", exc)
84
+ finally:
85
+ conn.close()
86
+
87
+ def reset_cycle(self) -> None:
88
+ """Reset per-cycle counters. Call at start of each consolidation."""
89
+ now = datetime.now(timezone.utc).isoformat()
90
+ conn = sqlite3.connect(self._db_path, timeout=10)
91
+ try:
92
+ conn.execute(
93
+ "INSERT OR REPLACE INTO evolution_cycle_state (key, value, updated_at) "
94
+ "VALUES ('cycle_count', 0, ?)",
95
+ (now,),
96
+ )
97
+ conn.commit()
98
+ finally:
99
+ conn.close()
100
+
101
+ def can_evolve(self) -> bool:
102
+ """Check if budget allows another evolution this cycle."""
103
+ conn = sqlite3.connect(self._db_path, timeout=10)
104
+ try:
105
+ row = conn.execute(
106
+ "SELECT value FROM evolution_cycle_state WHERE key = 'cycle_count'",
107
+ ).fetchone()
108
+ count = row[0] if row else 0
109
+ return count < MAX_EVOLUTIONS_PER_CYCLE
110
+ finally:
111
+ conn.close()
112
+
113
+ def record_evolution_attempt(self) -> None:
114
+ """Increment cycle counter in DB."""
115
+ now = datetime.now(timezone.utc).isoformat()
116
+ conn = sqlite3.connect(self._db_path, timeout=10)
117
+ try:
118
+ row = conn.execute(
119
+ "SELECT value FROM evolution_cycle_state WHERE key = 'cycle_count'",
120
+ ).fetchone()
121
+ current = row[0] if row else 0
122
+ conn.execute(
123
+ "INSERT OR REPLACE INTO evolution_cycle_state (key, value, updated_at) "
124
+ "VALUES ('cycle_count', ?, ?)",
125
+ (current + 1, now),
126
+ )
127
+ conn.commit()
128
+ finally:
129
+ conn.close()
130
+
131
+ def _get_cycle_count(self) -> int:
132
+ """Read current cycle count from DB."""
133
+ conn = sqlite3.connect(self._db_path, timeout=10)
134
+ try:
135
+ row = conn.execute(
136
+ "SELECT value FROM evolution_cycle_state WHERE key = 'cycle_count'",
137
+ ).fetchone()
138
+ return row[0] if row else 0
139
+ finally:
140
+ conn.close()
141
+
142
+ # ------------------------------------------------------------------
143
+ # Anti-loop: addressed degradations (adopted from OpenSpace)
144
+ # ------------------------------------------------------------------
145
+
146
+ def is_addressed(self, skill_name: str, context_hash: str) -> bool:
147
+ return context_hash in self._addressed_degradations.get(skill_name, set())
148
+
149
+ def mark_addressed(self, skill_name: str, context_hash: str) -> None:
150
+ self._addressed_degradations.setdefault(skill_name, set()).add(context_hash)
151
+
152
+ def prune_recovered(self, active_degraded_skills: set[str]) -> None:
153
+ """Remove tracking for skills that recovered."""
154
+ recovered = [
155
+ k for k in self._addressed_degradations
156
+ if k not in active_degraded_skills
157
+ ]
158
+ for k in recovered:
159
+ del self._addressed_degradations[k]
160
+
161
+ # ------------------------------------------------------------------
162
+ # CRUD
163
+ # ------------------------------------------------------------------
164
+
165
+ def save_record(self, record: EvolutionRecord) -> None:
166
+ conn = sqlite3.connect(self._db_path, timeout=10)
167
+ try:
168
+ conn.execute(
169
+ "INSERT OR REPLACE INTO skill_evolution_log "
170
+ "(id, skill_name, parent_skill_id, evolution_type, trigger_type, "
171
+ " generation, status, mutation_summary, evidence, "
172
+ " original_content, evolved_content, content_diff, "
173
+ " blind_verified, rejection_reason, created_at, completed_at) "
174
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
175
+ (
176
+ record.id,
177
+ record.skill_name,
178
+ record.parent_skill_id,
179
+ record.evolution_type.value,
180
+ record.trigger.value,
181
+ record.generation,
182
+ record.status.value,
183
+ record.mutation_summary,
184
+ json.dumps(list(record.evidence)),
185
+ record.original_content,
186
+ record.evolved_content,
187
+ record.content_diff,
188
+ 1 if record.blind_verified else 0,
189
+ record.rejection_reason,
190
+ record.created_at,
191
+ record.completed_at,
192
+ ),
193
+ )
194
+ conn.commit()
195
+ finally:
196
+ conn.close()
197
+
198
+ def get_record(self, record_id: str) -> Optional[EvolutionRecord]:
199
+ conn = sqlite3.connect(self._db_path, timeout=10)
200
+ conn.row_factory = sqlite3.Row
201
+ try:
202
+ row = conn.execute(
203
+ "SELECT * FROM skill_evolution_log WHERE id = ?",
204
+ (record_id,),
205
+ ).fetchone()
206
+ if not row:
207
+ return None
208
+ return self._row_to_record(dict(row))
209
+ finally:
210
+ conn.close()
211
+
212
+ def get_skill_history(self, skill_name: str, limit: int = 20) -> list[EvolutionRecord]:
213
+ conn = sqlite3.connect(self._db_path, timeout=10)
214
+ conn.row_factory = sqlite3.Row
215
+ try:
216
+ rows = conn.execute(
217
+ "SELECT * FROM skill_evolution_log "
218
+ "WHERE skill_name = ? ORDER BY created_at DESC LIMIT ?",
219
+ (skill_name, limit),
220
+ ).fetchall()
221
+ return [self._row_to_record(dict(r)) for r in rows]
222
+ finally:
223
+ conn.close()
224
+
225
+ def get_recent(self, limit: int = 10) -> list[EvolutionRecord]:
226
+ conn = sqlite3.connect(self._db_path, timeout=10)
227
+ conn.row_factory = sqlite3.Row
228
+ try:
229
+ rows = conn.execute(
230
+ "SELECT * FROM skill_evolution_log "
231
+ "ORDER BY created_at DESC LIMIT ?",
232
+ (limit,),
233
+ ).fetchall()
234
+ return [self._row_to_record(dict(r)) for r in rows]
235
+ finally:
236
+ conn.close()
237
+
238
+ def count_attempts(self, skill_name: str) -> int:
239
+ conn = sqlite3.connect(self._db_path, timeout=10)
240
+ try:
241
+ row = conn.execute(
242
+ "SELECT COUNT(*) FROM skill_evolution_log "
243
+ "WHERE skill_name = ? AND status NOT IN ('promoted')",
244
+ (skill_name,),
245
+ ).fetchone()
246
+ return row[0] if row else 0
247
+ finally:
248
+ conn.close()
249
+
250
+ def has_exceeded_attempts(self, skill_name: str) -> bool:
251
+ return self.count_attempts(skill_name) >= MAX_ATTEMPTS_PER_SKILL
252
+
253
+ def get_stats(self) -> dict:
254
+ conn = sqlite3.connect(self._db_path, timeout=10)
255
+ try:
256
+ total = conn.execute(
257
+ "SELECT COUNT(*) FROM skill_evolution_log",
258
+ ).fetchone()[0]
259
+ by_status = {}
260
+ for row in conn.execute(
261
+ "SELECT status, COUNT(*) FROM skill_evolution_log GROUP BY status",
262
+ ).fetchall():
263
+ by_status[row[0]] = row[1]
264
+ by_type = {}
265
+ for row in conn.execute(
266
+ "SELECT evolution_type, COUNT(*) FROM skill_evolution_log GROUP BY evolution_type",
267
+ ).fetchall():
268
+ by_type[row[0]] = row[1]
269
+ return {
270
+ "total": total,
271
+ "by_status": by_status,
272
+ "by_type": by_type,
273
+ "cycle_budget_remaining": MAX_EVOLUTIONS_PER_CYCLE - self._get_cycle_count(),
274
+ }
275
+ finally:
276
+ conn.close()
277
+
278
+ def _row_to_record(self, row: dict) -> EvolutionRecord:
279
+ evidence_raw = row.get("evidence", "[]")
280
+ try:
281
+ evidence = tuple(json.loads(evidence_raw))
282
+ except (json.JSONDecodeError, TypeError):
283
+ evidence = ()
284
+
285
+ return EvolutionRecord(
286
+ id=row["id"],
287
+ skill_name=row["skill_name"],
288
+ parent_skill_id=row.get("parent_skill_id"),
289
+ evolution_type=EvolutionType(row["evolution_type"]),
290
+ trigger=TriggerType(row["trigger_type"]),
291
+ generation=row.get("generation", 0),
292
+ status=EvolutionStatus(row.get("status", "candidate")),
293
+ mutation_summary=row.get("mutation_summary", ""),
294
+ evidence=evidence,
295
+ original_content=row.get("original_content", ""),
296
+ evolved_content=row.get("evolved_content", ""),
297
+ content_diff=row.get("content_diff", ""),
298
+ blind_verified=bool(row.get("blind_verified", 0)),
299
+ rejection_reason=row.get("rejection_reason", ""),
300
+ created_at=row.get("created_at", ""),
301
+ completed_at=row.get("completed_at"),
302
+ )