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
|
@@ -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
|
-
|
|
117
|
-
"
|
|
118
|
-
"
|
|
119
|
-
"
|
|
120
|
-
"
|
|
121
|
-
|
|
122
|
-
"
|
|
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.
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
#
|
|
143
|
-
if
|
|
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
|
-
#
|
|
147
|
-
if
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
154
|
-
#
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
)
|