superlocalmemory 3.4.10 → 3.4.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -11
- package/docs/skill-evolution.md +77 -10
- package/ide/hooks/tool-event-hook.sh +4 -4
- package/package.json +1 -1
- package/pyproject.toml +3 -2
- package/src/superlocalmemory/cli/commands.py +170 -0
- package/src/superlocalmemory/cli/main.py +21 -0
- package/src/superlocalmemory/cli/setup_wizard.py +54 -11
- package/src/superlocalmemory/core/config.py +35 -0
- package/src/superlocalmemory/core/consolidation_engine.py +128 -0
- package/src/superlocalmemory/core/embedding_worker.py +1 -1
- package/src/superlocalmemory/core/engine.py +12 -0
- package/src/superlocalmemory/core/fact_consolidator.py +425 -0
- package/src/superlocalmemory/core/graph_pruner.py +290 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +20 -0
- package/src/superlocalmemory/core/recall_pipeline.py +9 -0
- package/src/superlocalmemory/core/tier_manager.py +325 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +6 -5
- package/src/superlocalmemory/evolution/__init__.py +29 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +115 -0
- package/src/superlocalmemory/evolution/evolution_store.py +302 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +181 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +555 -0
- package/src/superlocalmemory/evolution/triggers.py +367 -0
- package/src/superlocalmemory/evolution/types.py +92 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +13 -0
- package/src/superlocalmemory/learning/skill_performance_miner.py +44 -11
- package/src/superlocalmemory/mcp/server.py +4 -0
- package/src/superlocalmemory/mcp/tools_evolution.py +338 -0
- package/src/superlocalmemory/retrieval/engine.py +64 -4
- package/src/superlocalmemory/retrieval/forgetting_filter.py +22 -7
- package/src/superlocalmemory/retrieval/strategy.py +2 -2
- package/src/superlocalmemory/server/routes/behavioral.py +19 -15
- package/src/superlocalmemory/server/routes/evolution.py +213 -0
- package/src/superlocalmemory/server/routes/tiers.py +195 -0
- package/src/superlocalmemory/server/unified_daemon.py +36 -5
- package/src/superlocalmemory/storage/schema_v3411.py +149 -0
- package/src/superlocalmemory/ui/index.html +5 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +83 -0
- package/src/superlocalmemory/ui/js/ng-skills.js +394 -10
- package/src/superlocalmemory.egg-info/PKG-INFO +609 -0
- package/src/superlocalmemory.egg-info/SOURCES.txt +335 -0
- package/src/superlocalmemory.egg-info/dependency_links.txt +1 -0
- package/src/superlocalmemory.egg-info/entry_points.txt +2 -0
- package/src/superlocalmemory.egg-info/requires.txt +55 -0
- package/src/superlocalmemory.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,555 @@
|
|
|
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 Evolver — orchestrates the full evolution pipeline.
|
|
6
|
+
|
|
7
|
+
Pipeline: Trigger → Screen → LLM Confirm → Mutate → Blind Verify → Persist
|
|
8
|
+
|
|
9
|
+
Performance: NEVER runs on recall/remember hot path. Only during
|
|
10
|
+
consolidation (every 6h) or explicit trigger. Zero impact on latency.
|
|
11
|
+
|
|
12
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import dataclasses
|
|
18
|
+
import difflib
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
from superlocalmemory.evolution.types import (
|
|
29
|
+
EvolutionCandidate,
|
|
30
|
+
EvolutionRecord,
|
|
31
|
+
EvolutionStatus,
|
|
32
|
+
EvolutionType,
|
|
33
|
+
TriggerType,
|
|
34
|
+
)
|
|
35
|
+
from superlocalmemory.evolution.evolution_store import EvolutionStore
|
|
36
|
+
from superlocalmemory.evolution.triggers import (
|
|
37
|
+
DegradationTrigger,
|
|
38
|
+
HealthCheckTrigger,
|
|
39
|
+
PostSessionTrigger,
|
|
40
|
+
)
|
|
41
|
+
from superlocalmemory.evolution import mutation_generator as mutgen
|
|
42
|
+
from superlocalmemory.evolution import blind_verifier as verifier
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
EVOLVED_SKILLS_DIR = Path.home() / ".claude" / "skills" / "evolved"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def detect_backend() -> str:
|
|
50
|
+
"""Auto-detect best available LLM backend.
|
|
51
|
+
|
|
52
|
+
Priority: claude CLI → Ollama → API key → none
|
|
53
|
+
"""
|
|
54
|
+
import shutil
|
|
55
|
+
|
|
56
|
+
# 1. Claude CLI available?
|
|
57
|
+
if shutil.which("claude"):
|
|
58
|
+
return "claude"
|
|
59
|
+
|
|
60
|
+
# 2. Ollama running?
|
|
61
|
+
try:
|
|
62
|
+
import urllib.request
|
|
63
|
+
req = urllib.request.Request("http://127.0.0.1:11434/api/tags", method="GET")
|
|
64
|
+
with urllib.request.urlopen(req, timeout=2):
|
|
65
|
+
return "ollama"
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
# 3. API key set?
|
|
70
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
71
|
+
return "anthropic"
|
|
72
|
+
if os.environ.get("OPENAI_API_KEY"):
|
|
73
|
+
return "openai"
|
|
74
|
+
|
|
75
|
+
return "none"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SkillEvolver:
|
|
79
|
+
"""Main orchestrator for skill evolution.
|
|
80
|
+
|
|
81
|
+
Call `run_consolidation_cycle()` from the consolidation pipeline.
|
|
82
|
+
Call `run_post_session()` from the Stop hook.
|
|
83
|
+
|
|
84
|
+
Respects EvolutionConfig.enabled — does nothing if disabled.
|
|
85
|
+
Auto-detects LLM backend: claude CLI → Ollama → API → none.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, db_path: str | Path, config: object | None = None):
|
|
89
|
+
self._db_path = str(db_path)
|
|
90
|
+
self._store = EvolutionStore(db_path)
|
|
91
|
+
self._degradation = DegradationTrigger(db_path)
|
|
92
|
+
self._health = HealthCheckTrigger(db_path)
|
|
93
|
+
self._config = config
|
|
94
|
+
self._backend: str | None = None
|
|
95
|
+
|
|
96
|
+
def _is_enabled(self) -> bool:
|
|
97
|
+
"""Check if evolution is enabled in config."""
|
|
98
|
+
if self._config and hasattr(self._config, "evolution"):
|
|
99
|
+
return self._config.evolution.enabled
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
def _get_backend(self) -> str:
|
|
103
|
+
"""Get or detect the LLM backend."""
|
|
104
|
+
if self._backend:
|
|
105
|
+
return self._backend
|
|
106
|
+
|
|
107
|
+
configured = "auto"
|
|
108
|
+
if self._config and hasattr(self._config, "evolution"):
|
|
109
|
+
configured = self._config.evolution.backend
|
|
110
|
+
|
|
111
|
+
if configured == "auto":
|
|
112
|
+
self._backend = detect_backend()
|
|
113
|
+
else:
|
|
114
|
+
self._backend = configured
|
|
115
|
+
|
|
116
|
+
logger.info("Evolution backend: %s", self._backend)
|
|
117
|
+
return self._backend
|
|
118
|
+
|
|
119
|
+
def run_consolidation_cycle(self, profile_id: str = "default") -> dict:
|
|
120
|
+
"""Run during consolidation. Checks triggers 2 and 3."""
|
|
121
|
+
if not self._is_enabled():
|
|
122
|
+
return {"enabled": False, "message": "Evolution disabled. Enable via: slm config set evolution.enabled true"}
|
|
123
|
+
|
|
124
|
+
backend = self._get_backend()
|
|
125
|
+
if backend == "none":
|
|
126
|
+
return {"enabled": True, "backend": "none",
|
|
127
|
+
"message": "No LLM backend available. Install Claude Code, Ollama, or set an API key."}
|
|
128
|
+
|
|
129
|
+
self._store.reset_cycle()
|
|
130
|
+
results = {"candidates": 0, "evolved": 0, "rejected": 0, "skipped": 0, "backend": backend}
|
|
131
|
+
|
|
132
|
+
# Prune recovered skills from anti-loop tracking
|
|
133
|
+
active_degraded = self._degradation.get_active_degraded(profile_id)
|
|
134
|
+
self._store.prune_recovered(active_degraded)
|
|
135
|
+
|
|
136
|
+
# Trigger 2: Degradation
|
|
137
|
+
candidates = self._degradation.scan(profile_id)
|
|
138
|
+
# Trigger 3: Health check (runs every Nth cycle)
|
|
139
|
+
candidates.extend(self._health.scan(profile_id))
|
|
140
|
+
|
|
141
|
+
results["candidates"] = len(candidates)
|
|
142
|
+
|
|
143
|
+
for candidate in candidates:
|
|
144
|
+
if not self._store.can_evolve():
|
|
145
|
+
results["skipped"] += len(candidates) - results["evolved"] - results["rejected"]
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
outcome = self._process_candidate(candidate, profile_id)
|
|
149
|
+
if outcome == "evolved":
|
|
150
|
+
results["evolved"] += 1
|
|
151
|
+
elif outcome == "rejected":
|
|
152
|
+
results["rejected"] += 1
|
|
153
|
+
else:
|
|
154
|
+
results["skipped"] += 1
|
|
155
|
+
|
|
156
|
+
logger.info(
|
|
157
|
+
"Evolution cycle: %d candidates, %d evolved, %d rejected, %d skipped",
|
|
158
|
+
results["candidates"], results["evolved"],
|
|
159
|
+
results["rejected"], results["skipped"],
|
|
160
|
+
)
|
|
161
|
+
return results
|
|
162
|
+
|
|
163
|
+
def run_post_session(
|
|
164
|
+
self, session_id: str, profile_id: str = "default",
|
|
165
|
+
) -> dict:
|
|
166
|
+
"""Run after a session ends. Checks trigger 1."""
|
|
167
|
+
if not self._is_enabled():
|
|
168
|
+
return {"enabled": False, "candidates": 0, "evolved": 0, "rejected": 0}
|
|
169
|
+
|
|
170
|
+
results = {"candidates": 0, "evolved": 0, "rejected": 0}
|
|
171
|
+
|
|
172
|
+
trigger = PostSessionTrigger(self._db_path)
|
|
173
|
+
candidates = trigger.scan(session_id, profile_id)
|
|
174
|
+
results["candidates"] = len(candidates)
|
|
175
|
+
|
|
176
|
+
for candidate in candidates:
|
|
177
|
+
if not self._store.can_evolve():
|
|
178
|
+
break
|
|
179
|
+
outcome = self._process_candidate(candidate, profile_id)
|
|
180
|
+
if outcome == "evolved":
|
|
181
|
+
results["evolved"] += 1
|
|
182
|
+
elif outcome == "rejected":
|
|
183
|
+
results["rejected"] += 1
|
|
184
|
+
|
|
185
|
+
return results
|
|
186
|
+
|
|
187
|
+
def _process_candidate(
|
|
188
|
+
self, candidate: EvolutionCandidate, profile_id: str,
|
|
189
|
+
) -> str:
|
|
190
|
+
"""Process a single evolution candidate through the full pipeline.
|
|
191
|
+
|
|
192
|
+
Returns: "evolved", "rejected", or "skipped"
|
|
193
|
+
"""
|
|
194
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
195
|
+
record_id = hashlib.sha256(
|
|
196
|
+
f"{candidate.skill_name}:{candidate.trigger.value}:{now}".encode(),
|
|
197
|
+
).hexdigest()[:16]
|
|
198
|
+
|
|
199
|
+
# Anti-loop: check if already addressed
|
|
200
|
+
context_hash = hashlib.sha256(
|
|
201
|
+
json.dumps(list(candidate.evidence)).encode(),
|
|
202
|
+
).hexdigest()[:12]
|
|
203
|
+
|
|
204
|
+
if self._store.is_addressed(candidate.skill_name, context_hash):
|
|
205
|
+
return "skipped"
|
|
206
|
+
|
|
207
|
+
if self._store.has_exceeded_attempts(candidate.skill_name):
|
|
208
|
+
logger.info("Skill %s exceeded max attempts, flagging for review", candidate.skill_name)
|
|
209
|
+
return "skipped"
|
|
210
|
+
|
|
211
|
+
# Mark as addressed (even if we reject — prevents repeated checks)
|
|
212
|
+
self._store.mark_addressed(candidate.skill_name, context_hash)
|
|
213
|
+
|
|
214
|
+
# Step 1: Read original skill content
|
|
215
|
+
original_content = self._read_skill_content(candidate.skill_name)
|
|
216
|
+
|
|
217
|
+
# Create initial record
|
|
218
|
+
record = EvolutionRecord(
|
|
219
|
+
id=record_id,
|
|
220
|
+
skill_name=candidate.skill_name,
|
|
221
|
+
parent_skill_id=candidate.skill_name,
|
|
222
|
+
evolution_type=candidate.evolution_type,
|
|
223
|
+
trigger=candidate.trigger,
|
|
224
|
+
status=EvolutionStatus.CANDIDATE,
|
|
225
|
+
evidence=candidate.evidence,
|
|
226
|
+
original_content=original_content[:2000],
|
|
227
|
+
created_at=now,
|
|
228
|
+
)
|
|
229
|
+
self._store.save_record(record)
|
|
230
|
+
|
|
231
|
+
# Step 2: LLM confirmation gate (uses Haiku for cost)
|
|
232
|
+
confirmed = self._llm_confirm(candidate, original_content)
|
|
233
|
+
if not confirmed:
|
|
234
|
+
record = dataclasses.replace(
|
|
235
|
+
record,
|
|
236
|
+
status=EvolutionStatus.REJECTED,
|
|
237
|
+
rejection_reason="LLM confirmation gate rejected",
|
|
238
|
+
completed_at=datetime.now(timezone.utc).isoformat(),
|
|
239
|
+
)
|
|
240
|
+
self._store.save_record(record)
|
|
241
|
+
return "rejected"
|
|
242
|
+
|
|
243
|
+
# Step 3: Generate mutation (uses Sonnet for quality)
|
|
244
|
+
prompt = mutgen.build_mutation_prompt(candidate, original_content)
|
|
245
|
+
evolved_content = self._generate_mutation(prompt)
|
|
246
|
+
if not evolved_content:
|
|
247
|
+
record = dataclasses.replace(
|
|
248
|
+
record,
|
|
249
|
+
status=EvolutionStatus.FAILED,
|
|
250
|
+
rejection_reason="Mutation generation failed",
|
|
251
|
+
completed_at=datetime.now(timezone.utc).isoformat(),
|
|
252
|
+
)
|
|
253
|
+
self._store.save_record(record)
|
|
254
|
+
return "rejected"
|
|
255
|
+
|
|
256
|
+
# Step 4: Blind verification (uses Haiku — different model from generator)
|
|
257
|
+
description = self._extract_description(evolved_content)
|
|
258
|
+
v_prompt = verifier.build_verification_prompt(
|
|
259
|
+
candidate.skill_name, description, evolved_content,
|
|
260
|
+
)
|
|
261
|
+
v_result = self._blind_verify(v_prompt)
|
|
262
|
+
if not v_result.passed:
|
|
263
|
+
record = dataclasses.replace(
|
|
264
|
+
record,
|
|
265
|
+
status=EvolutionStatus.REJECTED,
|
|
266
|
+
rejection_reason=f"Blind verification failed: {v_result.reasoning}",
|
|
267
|
+
evolved_content=evolved_content[:2000],
|
|
268
|
+
blind_verified=False,
|
|
269
|
+
completed_at=datetime.now(timezone.utc).isoformat(),
|
|
270
|
+
)
|
|
271
|
+
self._store.save_record(record)
|
|
272
|
+
return "rejected"
|
|
273
|
+
|
|
274
|
+
# Step 5: Persist evolved skill
|
|
275
|
+
diff = self._compute_diff(original_content, evolved_content)
|
|
276
|
+
skill_path = self._write_evolved_skill(candidate, evolved_content, record_id)
|
|
277
|
+
|
|
278
|
+
# M-GENERATION: Compute generation from parent's history
|
|
279
|
+
parent_history = self._store.get_skill_history(candidate.skill_name, limit=1)
|
|
280
|
+
parent_gen = (
|
|
281
|
+
parent_history[0].generation
|
|
282
|
+
if parent_history and parent_history[0].status == EvolutionStatus.PROMOTED
|
|
283
|
+
else 0
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
record = dataclasses.replace(
|
|
287
|
+
record,
|
|
288
|
+
status=EvolutionStatus.PROMOTED,
|
|
289
|
+
evolved_content=evolved_content[:2000],
|
|
290
|
+
content_diff=diff[:2000],
|
|
291
|
+
mutation_summary=self._summarize_diff(diff),
|
|
292
|
+
blind_verified=True,
|
|
293
|
+
generation=parent_gen + 1,
|
|
294
|
+
completed_at=datetime.now(timezone.utc).isoformat(),
|
|
295
|
+
)
|
|
296
|
+
self._store.save_record(record)
|
|
297
|
+
self._store.record_evolution_attempt()
|
|
298
|
+
|
|
299
|
+
logger.info(
|
|
300
|
+
"Evolved skill: %s (%s via %s) → %s",
|
|
301
|
+
candidate.skill_name, candidate.evolution_type.value,
|
|
302
|
+
candidate.trigger.value, skill_path,
|
|
303
|
+
)
|
|
304
|
+
return "evolved"
|
|
305
|
+
|
|
306
|
+
# ------------------------------------------------------------------
|
|
307
|
+
# LLM calls — isolated, easy to mock in tests
|
|
308
|
+
# ------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
def _llm_call(self, prompt: str, max_tokens: int = 500, model: str = "haiku") -> str:
|
|
311
|
+
"""Make an LLM call using the detected backend.
|
|
312
|
+
|
|
313
|
+
Priority: claude CLI → Ollama → API → empty string
|
|
314
|
+
The `model` parameter differentiates generator ("sonnet") from
|
|
315
|
+
verifier ("haiku") calls so mutations use a stronger model.
|
|
316
|
+
"""
|
|
317
|
+
backend = self._get_backend()
|
|
318
|
+
|
|
319
|
+
if backend == "claude":
|
|
320
|
+
return self._call_claude_cli(prompt, max_tokens, model=model)
|
|
321
|
+
elif backend == "ollama":
|
|
322
|
+
return self._call_ollama(prompt, max_tokens)
|
|
323
|
+
elif backend in ("anthropic", "openai"):
|
|
324
|
+
return self._call_api(prompt, max_tokens, backend, model=model)
|
|
325
|
+
return ""
|
|
326
|
+
|
|
327
|
+
def _call_claude_cli(self, prompt: str, max_tokens: int, model: str = "haiku") -> str:
|
|
328
|
+
"""Spawn `claude --model <model>` for a single completion (ECC pattern)."""
|
|
329
|
+
import subprocess
|
|
330
|
+
import tempfile
|
|
331
|
+
|
|
332
|
+
# Write prompt to temp file (avoids shell escaping issues)
|
|
333
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
|
334
|
+
f.write(prompt)
|
|
335
|
+
prompt_file = f.name
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
result = subprocess.run(
|
|
339
|
+
["claude", "--model", model, "--print", "--no-input",
|
|
340
|
+
"--max-tokens", str(max_tokens),
|
|
341
|
+
"--prompt-file", prompt_file],
|
|
342
|
+
capture_output=True, text=True, timeout=120,
|
|
343
|
+
env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "cli",
|
|
344
|
+
"ECC_SKIP_OBSERVE": "1"}, # Don't observe our own evolution calls
|
|
345
|
+
)
|
|
346
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
347
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
348
|
+
logger.debug("Claude CLI call failed: %s", exc)
|
|
349
|
+
return ""
|
|
350
|
+
finally:
|
|
351
|
+
try:
|
|
352
|
+
os.unlink(prompt_file)
|
|
353
|
+
except OSError:
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
def _call_ollama(self, prompt: str, max_tokens: int) -> str:
|
|
357
|
+
"""Call Ollama API for local LLM completion."""
|
|
358
|
+
import urllib.request
|
|
359
|
+
import json as _json
|
|
360
|
+
|
|
361
|
+
payload = _json.dumps({
|
|
362
|
+
"model": "llama3",
|
|
363
|
+
"prompt": prompt,
|
|
364
|
+
"stream": False,
|
|
365
|
+
"options": {"num_predict": max_tokens},
|
|
366
|
+
}).encode()
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
req = urllib.request.Request(
|
|
370
|
+
"http://127.0.0.1:11434/api/generate",
|
|
371
|
+
data=payload,
|
|
372
|
+
headers={"Content-Type": "application/json"},
|
|
373
|
+
method="POST",
|
|
374
|
+
)
|
|
375
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
376
|
+
data = _json.loads(resp.read())
|
|
377
|
+
return data.get("response", "")
|
|
378
|
+
except Exception as exc:
|
|
379
|
+
logger.debug("Ollama call failed: %s", exc)
|
|
380
|
+
return ""
|
|
381
|
+
|
|
382
|
+
def _call_api(self, prompt: str, max_tokens: int, provider: str, model: str = "haiku") -> str:
|
|
383
|
+
"""Call Anthropic or OpenAI API directly."""
|
|
384
|
+
try:
|
|
385
|
+
if provider == "anthropic":
|
|
386
|
+
import anthropic
|
|
387
|
+
client = anthropic.Anthropic()
|
|
388
|
+
api_model = "claude-sonnet-4-6-20250514" if model == "sonnet" else "claude-haiku-4-5-20251001"
|
|
389
|
+
msg = client.messages.create(
|
|
390
|
+
model=api_model,
|
|
391
|
+
max_tokens=max_tokens,
|
|
392
|
+
messages=[{"role": "user", "content": prompt}],
|
|
393
|
+
)
|
|
394
|
+
return msg.content[0].text if msg.content else ""
|
|
395
|
+
elif provider == "openai":
|
|
396
|
+
import openai
|
|
397
|
+
client = openai.OpenAI()
|
|
398
|
+
api_model = "gpt-4o" if model == "sonnet" else "gpt-4o-mini"
|
|
399
|
+
resp = client.chat.completions.create(
|
|
400
|
+
model=api_model,
|
|
401
|
+
max_tokens=max_tokens,
|
|
402
|
+
messages=[{"role": "user", "content": prompt}],
|
|
403
|
+
)
|
|
404
|
+
return resp.choices[0].message.content or ""
|
|
405
|
+
except Exception as exc:
|
|
406
|
+
logger.debug("API call failed (%s): %s", provider, exc)
|
|
407
|
+
return ""
|
|
408
|
+
return "" # Safety net: unmatched provider returns empty string
|
|
409
|
+
|
|
410
|
+
def _llm_confirm(self, candidate: EvolutionCandidate, original: str) -> bool:
|
|
411
|
+
"""LLM confirmation gate."""
|
|
412
|
+
prompt = (
|
|
413
|
+
f"A skill '{candidate.skill_name}' has effective score "
|
|
414
|
+
f"{candidate.effective_score:.0%} over {candidate.invocation_count} invocations.\n"
|
|
415
|
+
f"Evidence: {'; '.join(candidate.evidence)}\n\n"
|
|
416
|
+
f"Should this skill be evolved ({candidate.evolution_type.value})? "
|
|
417
|
+
f"Reply YES or NO with brief reason."
|
|
418
|
+
)
|
|
419
|
+
response = self._llm_call(prompt, max_tokens=100)
|
|
420
|
+
if not response:
|
|
421
|
+
logger.warning("LLM confirmation gate: empty response, skipping evolution for %s", candidate.skill_name)
|
|
422
|
+
return False # Fail-closed: no LLM = no evolution
|
|
423
|
+
return "yes" in response.lower()
|
|
424
|
+
|
|
425
|
+
def _generate_mutation(self, prompt: str) -> Optional[str]:
|
|
426
|
+
"""Generate evolved SKILL.md (uses sonnet for quality)."""
|
|
427
|
+
for attempt in range(mutgen.MAX_APPLY_RETRIES):
|
|
428
|
+
response = self._llm_call(prompt, max_tokens=4000, model="sonnet")
|
|
429
|
+
if not response:
|
|
430
|
+
return None
|
|
431
|
+
content = mutgen.parse_mutation_output(response)
|
|
432
|
+
if content:
|
|
433
|
+
error = mutgen.validate_skill_content(content)
|
|
434
|
+
if error is None:
|
|
435
|
+
return content
|
|
436
|
+
prompt = mutgen.build_retry_prompt(prompt, error, attempt + 1)
|
|
437
|
+
else:
|
|
438
|
+
prompt = mutgen.build_retry_prompt(
|
|
439
|
+
prompt, "No valid SKILL.md content found in output", attempt + 1,
|
|
440
|
+
)
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
def _blind_verify(self, prompt: str) -> verifier.VerificationResult:
|
|
444
|
+
"""Blind verification."""
|
|
445
|
+
response = self._llm_call(prompt, max_tokens=500)
|
|
446
|
+
if not response:
|
|
447
|
+
return verifier.VerificationResult(
|
|
448
|
+
passed=False, confidence=0.0, reasoning="No LLM response",
|
|
449
|
+
)
|
|
450
|
+
return verifier.parse_verification_response(response)
|
|
451
|
+
|
|
452
|
+
# ------------------------------------------------------------------
|
|
453
|
+
# Skill I/O
|
|
454
|
+
# ------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
def _read_skill_content(self, skill_name: str) -> str:
|
|
457
|
+
"""Read a skill's SKILL.md content. Searches known skill directories."""
|
|
458
|
+
search_dirs = [
|
|
459
|
+
Path.home() / ".claude" / "skills",
|
|
460
|
+
Path.home() / ".claude" / "plugins",
|
|
461
|
+
EVOLVED_SKILLS_DIR,
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
# Handle namespaced skills (e.g., "superpowers:brainstorming")
|
|
465
|
+
if ":" in skill_name:
|
|
466
|
+
parts = skill_name.split(":")
|
|
467
|
+
search_patterns = [
|
|
468
|
+
f"**/{parts[-1]}/SKILL.md",
|
|
469
|
+
f"**/{skill_name.replace(':', '/')}/SKILL.md",
|
|
470
|
+
f"**/skills/{parts[-1]}/SKILL.md",
|
|
471
|
+
]
|
|
472
|
+
else:
|
|
473
|
+
search_patterns = [f"**/{skill_name}/SKILL.md"]
|
|
474
|
+
|
|
475
|
+
for search_dir in search_dirs:
|
|
476
|
+
if not search_dir.exists():
|
|
477
|
+
continue
|
|
478
|
+
for pattern in search_patterns:
|
|
479
|
+
matches = list(search_dir.glob(pattern))
|
|
480
|
+
if matches:
|
|
481
|
+
try:
|
|
482
|
+
return matches[0].read_text(encoding="utf-8")
|
|
483
|
+
except OSError:
|
|
484
|
+
continue
|
|
485
|
+
|
|
486
|
+
return ""
|
|
487
|
+
|
|
488
|
+
def _write_evolved_skill(
|
|
489
|
+
self,
|
|
490
|
+
candidate: EvolutionCandidate,
|
|
491
|
+
content: str,
|
|
492
|
+
record_id: str,
|
|
493
|
+
) -> Path:
|
|
494
|
+
"""Write evolved SKILL.md to ~/.claude/skills/evolved/."""
|
|
495
|
+
EVOLVED_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
|
|
496
|
+
|
|
497
|
+
# Build directory name
|
|
498
|
+
base_name = candidate.skill_name.replace(":", "-")
|
|
499
|
+
if candidate.evolution_type == EvolutionType.FIX:
|
|
500
|
+
dir_name = f"{base_name}-v{record_id[:6]}"
|
|
501
|
+
elif candidate.evolution_type == EvolutionType.DERIVED:
|
|
502
|
+
# Extract name from evolved content frontmatter
|
|
503
|
+
name_match = re.search(r"name:\s*(.+)", content)
|
|
504
|
+
dir_name = name_match.group(1).strip() if name_match else f"{base_name}-derived"
|
|
505
|
+
dir_name = re.sub(r"[^a-zA-Z0-9_-]", "-", dir_name).lower()[:50]
|
|
506
|
+
else:
|
|
507
|
+
dir_name = base_name
|
|
508
|
+
|
|
509
|
+
skill_dir = EVOLVED_SKILLS_DIR / dir_name
|
|
510
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
511
|
+
|
|
512
|
+
skill_path = skill_dir / "SKILL.md"
|
|
513
|
+
skill_path.write_text(content, encoding="utf-8")
|
|
514
|
+
|
|
515
|
+
# Write metadata sidecar
|
|
516
|
+
meta = {
|
|
517
|
+
"skill_id": dir_name,
|
|
518
|
+
"parent_skill_id": candidate.skill_name,
|
|
519
|
+
"evolution_type": candidate.evolution_type.value,
|
|
520
|
+
"trigger": candidate.trigger.value,
|
|
521
|
+
"record_id": record_id,
|
|
522
|
+
"evidence": list(candidate.evidence),
|
|
523
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
524
|
+
}
|
|
525
|
+
meta_path = skill_dir / ".skill_meta.json"
|
|
526
|
+
meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
527
|
+
|
|
528
|
+
return skill_path
|
|
529
|
+
|
|
530
|
+
# ------------------------------------------------------------------
|
|
531
|
+
# Utilities
|
|
532
|
+
# ------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
def _extract_description(self, content: str) -> str:
|
|
535
|
+
"""Extract description from SKILL.md frontmatter."""
|
|
536
|
+
match = re.search(r"description:\s*(.+?)(?:\n|---)", content)
|
|
537
|
+
return match.group(1).strip() if match else "Skill for AI agent tasks"
|
|
538
|
+
|
|
539
|
+
def _compute_diff(self, original: str, evolved: str) -> str:
|
|
540
|
+
if not original:
|
|
541
|
+
return "(new skill — no original to diff)"
|
|
542
|
+
diff = difflib.unified_diff(
|
|
543
|
+
original.splitlines(keepends=True),
|
|
544
|
+
evolved.splitlines(keepends=True),
|
|
545
|
+
fromfile="original",
|
|
546
|
+
tofile="evolved",
|
|
547
|
+
n=3,
|
|
548
|
+
)
|
|
549
|
+
return "".join(diff)
|
|
550
|
+
|
|
551
|
+
def _summarize_diff(self, diff: str) -> str:
|
|
552
|
+
"""Count additions and removals."""
|
|
553
|
+
additions = sum(1 for line in diff.splitlines() if line.startswith("+") and not line.startswith("+++"))
|
|
554
|
+
removals = sum(1 for line in diff.splitlines() if line.startswith("-") and not line.startswith("---"))
|
|
555
|
+
return f"+{additions}/-{removals} lines"
|