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.
Files changed (46) hide show
  1. package/README.md +17 -11
  2. package/docs/skill-evolution.md +77 -10
  3. package/ide/hooks/tool-event-hook.sh +4 -4
  4. package/package.json +1 -1
  5. package/pyproject.toml +3 -2
  6. package/src/superlocalmemory/cli/commands.py +170 -0
  7. package/src/superlocalmemory/cli/main.py +21 -0
  8. package/src/superlocalmemory/cli/setup_wizard.py +54 -11
  9. package/src/superlocalmemory/core/config.py +35 -0
  10. package/src/superlocalmemory/core/consolidation_engine.py +128 -0
  11. package/src/superlocalmemory/core/embedding_worker.py +1 -1
  12. package/src/superlocalmemory/core/engine.py +12 -0
  13. package/src/superlocalmemory/core/fact_consolidator.py +425 -0
  14. package/src/superlocalmemory/core/graph_pruner.py +290 -0
  15. package/src/superlocalmemory/core/maintenance_scheduler.py +20 -0
  16. package/src/superlocalmemory/core/recall_pipeline.py +9 -0
  17. package/src/superlocalmemory/core/tier_manager.py +325 -0
  18. package/src/superlocalmemory/encoding/entity_resolver.py +6 -5
  19. package/src/superlocalmemory/evolution/__init__.py +29 -0
  20. package/src/superlocalmemory/evolution/blind_verifier.py +115 -0
  21. package/src/superlocalmemory/evolution/evolution_store.py +302 -0
  22. package/src/superlocalmemory/evolution/mutation_generator.py +181 -0
  23. package/src/superlocalmemory/evolution/skill_evolver.py +555 -0
  24. package/src/superlocalmemory/evolution/triggers.py +367 -0
  25. package/src/superlocalmemory/evolution/types.py +92 -0
  26. package/src/superlocalmemory/hooks/hook_handlers.py +13 -0
  27. package/src/superlocalmemory/learning/skill_performance_miner.py +44 -11
  28. package/src/superlocalmemory/mcp/server.py +4 -0
  29. package/src/superlocalmemory/mcp/tools_evolution.py +338 -0
  30. package/src/superlocalmemory/retrieval/engine.py +64 -4
  31. package/src/superlocalmemory/retrieval/forgetting_filter.py +22 -7
  32. package/src/superlocalmemory/retrieval/strategy.py +2 -2
  33. package/src/superlocalmemory/server/routes/behavioral.py +19 -15
  34. package/src/superlocalmemory/server/routes/evolution.py +213 -0
  35. package/src/superlocalmemory/server/routes/tiers.py +195 -0
  36. package/src/superlocalmemory/server/unified_daemon.py +36 -5
  37. package/src/superlocalmemory/storage/schema_v3411.py +149 -0
  38. package/src/superlocalmemory/ui/index.html +5 -2
  39. package/src/superlocalmemory/ui/js/lifecycle.js +83 -0
  40. package/src/superlocalmemory/ui/js/ng-skills.js +394 -10
  41. package/src/superlocalmemory.egg-info/PKG-INFO +609 -0
  42. package/src/superlocalmemory.egg-info/SOURCES.txt +335 -0
  43. package/src/superlocalmemory.egg-info/dependency_links.txt +1 -0
  44. package/src/superlocalmemory.egg-info/entry_points.txt +2 -0
  45. package/src/superlocalmemory.egg-info/requires.txt +55 -0
  46. 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"