nexo-brain 2.4.0 → 2.5.0

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 (80) hide show
  1. package/README.md +65 -2
  2. package/bin/nexo-brain.js +208 -11
  3. package/bin/nexo.js +55 -0
  4. package/community/skills/.gitkeep +1 -0
  5. package/package.json +5 -2
  6. package/src/auto_update.py +158 -8
  7. package/src/cli.py +605 -0
  8. package/src/cognitive/_ingest.py +1 -1
  9. package/src/cognitive/_memory.py +4 -4
  10. package/src/crons/manifest.json +8 -0
  11. package/src/dashboard/app.py +700 -35
  12. package/src/dashboard/templates/adaptive.html +112 -218
  13. package/src/dashboard/templates/artifacts.html +133 -0
  14. package/src/dashboard/templates/backups.html +136 -0
  15. package/src/dashboard/templates/base.html +413 -0
  16. package/src/dashboard/templates/calendar.html +523 -654
  17. package/src/dashboard/templates/chat.html +356 -0
  18. package/src/dashboard/templates/claims.html +259 -0
  19. package/src/dashboard/templates/cortex.html +262 -0
  20. package/src/dashboard/templates/credentials.html +128 -0
  21. package/src/dashboard/templates/crons.html +370 -0
  22. package/src/dashboard/templates/dashboard.html +383 -578
  23. package/src/dashboard/templates/dreams.html +252 -0
  24. package/src/dashboard/templates/email.html +160 -0
  25. package/src/dashboard/templates/evolution.html +189 -0
  26. package/src/dashboard/templates/feed.html +249 -0
  27. package/src/dashboard/templates/followup_health.html +170 -0
  28. package/src/dashboard/templates/graph.html +191 -269
  29. package/src/dashboard/templates/guard.html +259 -0
  30. package/src/dashboard/templates/inbox.html +220 -346
  31. package/src/dashboard/templates/memory.html +317 -197
  32. package/src/dashboard/templates/operations.html +521 -698
  33. package/src/dashboard/templates/plugins.html +185 -0
  34. package/src/dashboard/templates/rules.html +246 -0
  35. package/src/dashboard/templates/sentiment.html +247 -0
  36. package/src/dashboard/templates/sessions.html +215 -182
  37. package/src/dashboard/templates/skills.html +329 -0
  38. package/src/dashboard/templates/somatic.html +68 -172
  39. package/src/dashboard/templates/triggers.html +133 -0
  40. package/src/dashboard/templates/trust.html +360 -0
  41. package/src/db/__init__.py +5 -0
  42. package/src/db/_schema.py +16 -1
  43. package/src/db/_sessions.py +22 -0
  44. package/src/db/_skills.py +980 -274
  45. package/src/doctor/__init__.py +1 -0
  46. package/src/doctor/formatters.py +52 -0
  47. package/src/doctor/models.py +44 -0
  48. package/src/doctor/orchestrator.py +42 -0
  49. package/src/doctor/providers/__init__.py +1 -0
  50. package/src/doctor/providers/boot.py +206 -0
  51. package/src/doctor/providers/deep.py +292 -0
  52. package/src/doctor/providers/runtime.py +686 -0
  53. package/src/hooks/post-compact.sh +5 -1
  54. package/src/hooks/pre-compact.sh +1 -1
  55. package/src/plugins/doctor.py +36 -0
  56. package/src/plugins/evolution.py +2 -1
  57. package/src/plugins/skills.py +135 -175
  58. package/src/requirements.txt +1 -0
  59. package/src/script_registry.py +322 -0
  60. package/src/scripts/deep-sleep/apply_findings.py +63 -48
  61. package/src/scripts/deep-sleep/extract-prompt.md +14 -0
  62. package/src/scripts/deep-sleep/synthesize-prompt.md +36 -0
  63. package/src/scripts/deep-sleep/synthesize.py +37 -1
  64. package/src/scripts/nexo-dashboard.sh +29 -0
  65. package/src/scripts/nexo-day-orchestrator.sh +139 -0
  66. package/src/scripts/nexo-evolution-run.py +2 -1
  67. package/src/scripts/nexo-learning-housekeep.py +1 -1
  68. package/src/scripts/nexo-watchdog.sh +1 -1
  69. package/src/server.py +9 -5
  70. package/src/skills/run-runtime-doctor/guide.md +12 -0
  71. package/src/skills/run-runtime-doctor/script.py +21 -0
  72. package/src/skills/run-runtime-doctor/skill.json +25 -0
  73. package/src/skills_runtime.py +347 -0
  74. package/src/tools_menu.py +3 -2
  75. package/src/tools_sessions.py +126 -0
  76. package/src/user_context.py +46 -0
  77. package/templates/nexo_helper.py +45 -0
  78. package/templates/script-template.py +44 -0
  79. package/templates/skill-script-template.py +39 -0
  80. package/templates/skill-template.md +33 -0
package/src/db/_skills.py CHANGED
@@ -4,26 +4,368 @@ from __future__ import annotations
4
4
  Skill Auto-Creation system: reusable procedures extracted from complex tasks.
5
5
  Skills are procedural (step-by-step how-tos) vs learnings which are declarative.
6
6
 
7
- Pipeline: trace → draft → published → archived, fully autonomous.
8
- Trust score with decay controls quality no human approval gates.
9
-
10
- Promotion: draft + 2 successful uses in distinct contexts → published.
11
- Degradation: trust < 20 → archived. Archived + 60 days unused → purge.
7
+ Pipeline: trace → draft → published → stable archived.
8
+ Executable skills are indexed in SQLite but sourced from filesystem definitions.
12
9
  """
13
- import json
10
+
14
11
  import datetime
12
+ import json
13
+ import os
14
+ import shutil
15
+ from pathlib import Path
16
+
15
17
  from db._core import get_db
16
- from db._fts import fts_upsert, fts_search
18
+ from db._fts import fts_search, fts_upsert
19
+
20
+
21
+ # ── Paths ──────────────────────────────────────────────────────────
22
+
23
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
24
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[1])))
25
+
26
+ NEXO_ROOT = NEXO_CODE.parent
27
+ PERSONAL_SKILLS_DIR = NEXO_HOME / "skills"
28
+
29
+
30
+ def _resolve_core_skills_dir() -> Path:
31
+ """Keep packaged core skills separate from personal skills.
32
+
33
+ In development NEXO_CODE points at repo/src, so core skills live in src/skills.
34
+ In packaged installs the runtime wrapper points NEXO_CODE at NEXO_HOME, so core
35
+ skills must live in a dedicated skills-core/ directory to avoid colliding with
36
+ personal skills in NEXO_HOME/skills.
37
+ """
38
+ try:
39
+ if NEXO_CODE.resolve() == NEXO_HOME.resolve():
40
+ return NEXO_CODE / "skills-core"
41
+ except OSError:
42
+ pass
43
+ return NEXO_CODE / "skills"
44
+
45
+
46
+ CORE_SKILLS_DIR = _resolve_core_skills_dir()
47
+ COMMUNITY_SKILLS_DIR = NEXO_ROOT / "community" / "skills"
48
+ RUNTIME_SKILLS_DIR = NEXO_HOME / "skills-runtime"
17
49
 
18
50
 
19
51
  # ── Constants ──────────────────────────────────────────────────────
20
52
 
21
- VALID_LEVELS = {'trace', 'draft', 'published', 'archived'}
53
+ VALID_LEVELS = {"trace", "draft", "published", "stable", "archived"}
54
+ VALID_MODES = {"guide", "execute", "hybrid"}
55
+ VALID_EXECUTION_LEVELS = {"none", "read-only", "local", "remote"}
56
+ VALID_SOURCE_KINDS = {"personal", "core", "community"}
57
+ AUTO_APPROVER = "system:auto"
58
+
22
59
  TRUST_ON_SUCCESS = 5
23
60
  TRUST_ON_FAILURE = -10
24
61
  TRUST_INITIAL = 50
25
62
  TRUST_ARCHIVE_THRESHOLD = 20
26
63
  PROMOTION_USES_REQUIRED = 2
64
+ DEFAULT_STABLE_AFTER_USES = 10
65
+
66
+ SKILL_DEFINITION_FILENAME = "skill.json"
67
+ SOURCE_PRIORITY = {"community": 1, "core": 2, "personal": 3}
68
+
69
+
70
+ # ── Helpers ────────────────────────────────────────────────────────
71
+
72
+ def _now_text() -> str:
73
+ return datetime.datetime.now().isoformat(timespec="seconds")
74
+
75
+
76
+ def _normalize_level(value: str | None) -> str:
77
+ level = (value or "trace").strip().lower()
78
+ return level if level in VALID_LEVELS else "trace"
79
+
80
+
81
+ def _normalize_mode(value: str | None, *, has_script: bool = False, has_content: bool = False) -> str:
82
+ mode = (value or "").strip().lower()
83
+ if mode in VALID_MODES:
84
+ return mode
85
+ if has_script and has_content:
86
+ return "hybrid"
87
+ if has_script:
88
+ return "execute"
89
+ return "guide"
90
+
91
+
92
+ def _normalize_execution_level(value: str | None) -> str:
93
+ execution_level = (value or "none").strip().lower()
94
+ return execution_level if execution_level in VALID_EXECUTION_LEVELS else "none"
95
+
96
+
97
+ def _normalize_source_kind(value: str | None) -> str:
98
+ source_kind = (value or "personal").strip().lower()
99
+ return source_kind if source_kind in VALID_SOURCE_KINDS else "personal"
100
+
101
+
102
+ def _json_string(value, default):
103
+ if value in ("", None):
104
+ value = default
105
+ if isinstance(value, (list, dict)):
106
+ return json.dumps(value, ensure_ascii=False)
107
+ if isinstance(value, str):
108
+ return value
109
+ return json.dumps(value if value is not None else default, ensure_ascii=False)
110
+
111
+
112
+ def _json_list(value) -> list:
113
+ if isinstance(value, list):
114
+ return value
115
+ if isinstance(value, tuple):
116
+ return list(value)
117
+ if isinstance(value, str):
118
+ value = value.strip()
119
+ if not value:
120
+ return []
121
+ try:
122
+ parsed = json.loads(value)
123
+ if isinstance(parsed, list):
124
+ return parsed
125
+ except json.JSONDecodeError:
126
+ return [value]
127
+ return []
128
+
129
+
130
+ def _json_dict(value) -> dict:
131
+ if isinstance(value, dict):
132
+ return value
133
+ if isinstance(value, str):
134
+ value = value.strip()
135
+ if not value:
136
+ return {}
137
+ try:
138
+ parsed = json.loads(value)
139
+ if isinstance(parsed, dict):
140
+ return parsed
141
+ except json.JSONDecodeError:
142
+ return {}
143
+ return {}
144
+
145
+
146
+ def _sync_dirs() -> list[tuple[str, Path]]:
147
+ return [
148
+ ("community", COMMUNITY_SKILLS_DIR),
149
+ ("core", CORE_SKILLS_DIR),
150
+ ("personal", PERSONAL_SKILLS_DIR),
151
+ ]
152
+
153
+
154
+ def _ensure_skill_dirs():
155
+ PERSONAL_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
156
+ RUNTIME_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
157
+
158
+
159
+ def _safe_slug(value: str) -> str:
160
+ chars = []
161
+ for ch in value.lower():
162
+ if ch.isalnum():
163
+ chars.append(ch)
164
+ elif ch in {"-", "_"}:
165
+ chars.append("-")
166
+ slug = "".join(chars).strip("-")
167
+ return slug or "skill"
168
+
169
+
170
+ def _preserve_level(existing_level: str, requested_level: str) -> str:
171
+ if existing_level == "archived":
172
+ return "archived"
173
+ if existing_level == "stable" and requested_level != "archived":
174
+ return "stable"
175
+ return requested_level
176
+
177
+
178
+ def _resolve_approval(mode: str, execution_level: str, approval_required=0, approved_at: str = "", approved_by: str = "") -> tuple[int, str, str]:
179
+ """Skills are now fully autonomous: executable modes are auto-approved."""
180
+ normalized_mode = _normalize_mode(mode)
181
+ normalized_level = _normalize_execution_level(execution_level)
182
+ if normalized_mode == "guide" or normalized_level == "none":
183
+ return 0, approved_at or "", approved_by or ""
184
+ return 0, approved_at or _now_text(), approved_by or AUTO_APPROVER
185
+
186
+
187
+ def _skill_fts_body(skill: dict) -> str:
188
+ parts = [
189
+ skill.get("description", ""),
190
+ skill.get("tags", "[]"),
191
+ skill.get("trigger_patterns", "[]"),
192
+ skill.get("content", ""),
193
+ ]
194
+ return " ".join(str(p) for p in parts if p)
195
+
196
+
197
+ def _definition_script_path(skill_dir: Path, definition: dict) -> Path | None:
198
+ entry = str(definition.get("executable_entry", "") or "").strip()
199
+ if entry:
200
+ candidate = (skill_dir / entry).resolve()
201
+ if candidate.is_file():
202
+ return candidate
203
+
204
+ for default_name in ("script.py", "script.sh"):
205
+ candidate = skill_dir / default_name
206
+ if candidate.is_file():
207
+ return candidate
208
+ return None
209
+
210
+
211
+ def _stage_skill_script(skill_id: str, script_source: Path | None) -> str:
212
+ if script_source is None or not script_source.is_file():
213
+ return ""
214
+
215
+ _ensure_skill_dirs()
216
+ runtime_dir = RUNTIME_SKILLS_DIR / _safe_slug(skill_id)
217
+ runtime_dir.mkdir(parents=True, exist_ok=True)
218
+ target = runtime_dir / script_source.name
219
+ shutil.copy2(script_source, target)
220
+ try:
221
+ target.chmod(0o755)
222
+ except OSError:
223
+ pass
224
+ return str(target)
225
+
226
+
227
+ def _load_skill_definition(skill_dir: Path, source_kind: str) -> dict | None:
228
+ definition_path = skill_dir / SKILL_DEFINITION_FILENAME
229
+ if not definition_path.is_file():
230
+ return None
231
+
232
+ data = json.loads(definition_path.read_text())
233
+ skill_id = str(data.get("id", "")).strip()
234
+ name = str(data.get("name", "")).strip()
235
+ if not skill_id or not name:
236
+ return None
237
+
238
+ guide_path = skill_dir / "guide.md"
239
+ content = data.get("content", "")
240
+ if guide_path.is_file():
241
+ content = guide_path.read_text()
242
+
243
+ script_source = _definition_script_path(skill_dir, data)
244
+ file_path = _stage_skill_script(skill_id, script_source)
245
+
246
+ mode = _normalize_mode(
247
+ data.get("mode", ""),
248
+ has_script=bool(file_path),
249
+ has_content=bool(content),
250
+ )
251
+ execution_level = _normalize_execution_level(data.get("execution_level", "none"))
252
+ if mode == "guide":
253
+ execution_level = "none"
254
+ approval_required, approved_at, approved_by = _resolve_approval(
255
+ mode,
256
+ execution_level,
257
+ approval_required=data.get("approval_required", execution_level in {"local", "remote"}),
258
+ approved_at=str(data.get("approved_at", "") or ""),
259
+ approved_by=str(data.get("approved_by", "") or ""),
260
+ )
261
+ params_schema = _json_dict(data.get("params_schema", {}))
262
+ command_template = _json_dict(data.get("command_template", {}))
263
+ steps = _json_list(data.get("steps", []))
264
+ gotchas = _json_list(data.get("gotchas", []))
265
+
266
+ return {
267
+ "id": skill_id,
268
+ "name": name,
269
+ "description": str(data.get("description", "") or ""),
270
+ "level": _normalize_level(data.get("level", "published")),
271
+ "mode": mode,
272
+ "source_kind": _normalize_source_kind(source_kind),
273
+ "execution_level": execution_level,
274
+ "approval_required": approval_required,
275
+ "approved_at": approved_at,
276
+ "approved_by": approved_by,
277
+ "tags": _json_string(data.get("tags", []), []),
278
+ "trigger_patterns": _json_string(data.get("trigger_patterns", []), []),
279
+ "source_sessions": _json_string(data.get("source_sessions", []), []),
280
+ "linked_learnings": _json_string(data.get("linked_learnings", []), []),
281
+ "trust_score": int(data.get("trust_score", TRUST_INITIAL) or TRUST_INITIAL),
282
+ "file_path": file_path,
283
+ "definition_path": str(definition_path),
284
+ "content": str(content or ""),
285
+ "steps": _json_string(steps, []),
286
+ "gotchas": _json_string(gotchas, []),
287
+ "params_schema": _json_string(params_schema, {}),
288
+ "command_template": _json_string(command_template, {}),
289
+ "executable_entry": str(data.get("executable_entry", script_source.name if script_source else "") or ""),
290
+ "stable_after_uses": int(data.get("stable_after_uses", DEFAULT_STABLE_AFTER_USES) or DEFAULT_STABLE_AFTER_USES),
291
+ }
292
+
293
+
294
+ def _upsert_filesystem_skill(skill: dict) -> dict:
295
+ conn = get_db()
296
+ existing_row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill["id"],)).fetchone()
297
+ existing = dict(existing_row) if existing_row else {}
298
+
299
+ level = _preserve_level(existing.get("level", ""), skill["level"])
300
+ trust_score = existing.get("trust_score", skill["trust_score"])
301
+ approval_required, approved_at, approved_by = _resolve_approval(
302
+ skill["mode"],
303
+ skill["execution_level"],
304
+ approval_required=existing.get("approval_required", skill["approval_required"]) if existing else skill["approval_required"],
305
+ approved_at=existing.get("approved_at") or skill.get("approved_at", ""),
306
+ approved_by=existing.get("approved_by") or skill.get("approved_by", ""),
307
+ )
308
+
309
+ values = {
310
+ **skill,
311
+ "level": level,
312
+ "trust_score": trust_score,
313
+ "approved_at": approved_at,
314
+ "approved_by": approved_by,
315
+ "approval_required": approval_required,
316
+ }
317
+
318
+ conn.execute(
319
+ """INSERT INTO skills (
320
+ id, name, description, level, trust_score, file_path, tags,
321
+ trigger_patterns, source_sessions, linked_learnings, content, steps, gotchas,
322
+ mode, source_kind, execution_level, approval_required, approved_at, approved_by,
323
+ params_schema, command_template, executable_entry, stable_after_uses, definition_path
324
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
325
+ ON CONFLICT(id) DO UPDATE SET
326
+ name = excluded.name,
327
+ description = excluded.description,
328
+ level = ?,
329
+ file_path = excluded.file_path,
330
+ tags = excluded.tags,
331
+ trigger_patterns = excluded.trigger_patterns,
332
+ source_sessions = excluded.source_sessions,
333
+ linked_learnings = excluded.linked_learnings,
334
+ content = excluded.content,
335
+ steps = excluded.steps,
336
+ gotchas = excluded.gotchas,
337
+ mode = excluded.mode,
338
+ source_kind = excluded.source_kind,
339
+ execution_level = excluded.execution_level,
340
+ approval_required = excluded.approval_required,
341
+ approved_at = ?,
342
+ approved_by = ?,
343
+ params_schema = excluded.params_schema,
344
+ command_template = excluded.command_template,
345
+ executable_entry = excluded.executable_entry,
346
+ stable_after_uses = excluded.stable_after_uses,
347
+ definition_path = excluded.definition_path,
348
+ updated_at = datetime('now')""",
349
+ (
350
+ values["id"], values["name"], values["description"], values["level"], values["trust_score"],
351
+ values["file_path"], values["tags"], values["trigger_patterns"], values["source_sessions"],
352
+ values["linked_learnings"], values["content"], values["steps"], values["gotchas"],
353
+ values["mode"], values["source_kind"], values["execution_level"], values["approval_required"],
354
+ values["approved_at"], values["approved_by"], values["params_schema"], values["command_template"],
355
+ values["executable_entry"], values["stable_after_uses"], values["definition_path"],
356
+ values["level"], values["approved_at"], values["approved_by"],
357
+ ),
358
+ )
359
+ conn.commit()
360
+
361
+ row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill["id"],)).fetchone()
362
+ result = dict(row) if row else dict(values)
363
+ fts_upsert("skill", result["id"], result.get("name", ""), _skill_fts_body(result), "skill")
364
+ return result
365
+
366
+
367
+ def _definition_priority(source_kind: str) -> int:
368
+ return SOURCE_PRIORITY.get(source_kind, 0)
27
369
 
28
370
 
29
371
  # ── CRUD ───────────────────────────────────────────────────────────
@@ -31,77 +373,95 @@ PROMOTION_USES_REQUIRED = 2
31
373
  def create_skill(
32
374
  skill_id: str,
33
375
  name: str,
34
- description: str = '',
35
- level: str = 'trace',
36
- tags: list | str = '[]',
37
- trigger_patterns: list | str = '[]',
38
- source_sessions: list | str = '[]',
39
- linked_learnings: list | str = '[]',
40
- file_path: str = '',
376
+ description: str = "",
377
+ level: str = "trace",
378
+ tags: list | str = "[]",
379
+ trigger_patterns: list | str = "[]",
380
+ source_sessions: list | str = "[]",
381
+ linked_learnings: list | str = "[]",
382
+ file_path: str = "",
41
383
  trust_score: int = TRUST_INITIAL,
42
- steps: list | str = '[]',
43
- gotchas: list | str = '[]',
44
- content: str = '',
384
+ steps: list | str = "[]",
385
+ gotchas: list | str = "[]",
386
+ content: str = "",
387
+ mode: str = "",
388
+ source_kind: str = "personal",
389
+ execution_level: str = "none",
390
+ approval_required: bool | int = False,
391
+ approved_at: str = "",
392
+ approved_by: str = "",
393
+ params_schema: dict | str = "{}",
394
+ command_template: dict | str = "{}",
395
+ executable_entry: str = "",
396
+ stable_after_uses: int = DEFAULT_STABLE_AFTER_USES,
397
+ definition_path: str = "",
45
398
  ) -> dict:
46
- """Create a new skill entry.
47
-
48
- Content can be:
49
- - Markdown with numbered steps (auto-generated from steps/gotchas if empty)
50
- - A reference to a script file (set file_path)
51
- - Free-form procedure description
52
- """
53
- if level not in VALID_LEVELS:
54
- return {"error": f"level must be one of: {', '.join(sorted(VALID_LEVELS))}"}
55
-
56
- tags_json = json.dumps(tags) if isinstance(tags, list) else tags
57
- trigger_json = json.dumps(trigger_patterns) if isinstance(trigger_patterns, list) else trigger_patterns
58
- sessions_json = json.dumps(source_sessions) if isinstance(source_sessions, list) else source_sessions
59
- learnings_json = json.dumps(linked_learnings) if isinstance(linked_learnings, list) else linked_learnings
60
- steps_json = json.dumps(steps) if isinstance(steps, list) else steps
61
- gotchas_json = json.dumps(gotchas) if isinstance(gotchas, list) else gotchas
62
-
63
- # Auto-generate content from steps/gotchas if not provided
64
- if not content and steps:
65
- steps_list = steps if isinstance(steps, list) else json.loads(steps_json)
66
- gotchas_list = gotchas if isinstance(gotchas, list) else json.loads(gotchas_json)
399
+ """Create a new skill entry."""
400
+ level = _normalize_level(level)
401
+ tags_json = _json_string(tags, [])
402
+ trigger_json = _json_string(trigger_patterns, [])
403
+ sessions_json = _json_string(source_sessions, [])
404
+ learnings_json = _json_string(linked_learnings, [])
405
+ steps_json = _json_string(steps, [])
406
+ gotchas_json = _json_string(gotchas, [])
407
+ params_json = _json_string(params_schema, {})
408
+ command_json = _json_string(command_template, {})
409
+
410
+ if not content and _json_list(steps_json):
411
+ steps_list = _json_list(steps_json)
412
+ gotchas_list = _json_list(gotchas_json)
67
413
  lines = [f"# {name}", "", description, "", "## Steps"]
68
- for i, s in enumerate(steps_list, 1):
69
- lines.append(f"{i}. {s}")
414
+ for index, step in enumerate(steps_list, 1):
415
+ lines.append(f"{index}. {step}")
70
416
  if gotchas_list:
71
417
  lines.extend(["", "## Gotchas"])
72
- for g in gotchas_list:
73
- lines.append(f"- {g}")
418
+ for gotcha in gotchas_list:
419
+ lines.append(f"- {gotcha}")
74
420
  content = "\n".join(lines)
75
421
 
422
+ source_kind = _normalize_source_kind(source_kind)
423
+ mode = _normalize_mode(mode, has_script=bool(file_path), has_content=bool(content))
424
+ execution_level = _normalize_execution_level(execution_level)
425
+ if mode == "guide":
426
+ execution_level = "none"
427
+ approval_required, approved_at, approved_by = _resolve_approval(
428
+ mode,
429
+ execution_level,
430
+ approval_required=approval_required,
431
+ approved_at=approved_at,
432
+ approved_by=approved_by,
433
+ )
434
+
76
435
  conn = get_db()
77
436
  conn.execute(
78
- """INSERT INTO skills
79
- (id, name, description, level, trust_score, file_path, tags,
80
- trigger_patterns, source_sessions, linked_learnings, content, steps, gotchas)
81
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
82
- (skill_id, name, description, level, trust_score, file_path,
83
- tags_json, trigger_json, sessions_json, learnings_json,
84
- content, steps_json, gotchas_json),
437
+ """INSERT INTO skills (
438
+ id, name, description, level, trust_score, file_path, tags,
439
+ trigger_patterns, source_sessions, linked_learnings, content, steps, gotchas,
440
+ mode, source_kind, execution_level, approval_required, approved_at, approved_by,
441
+ params_schema, command_template, executable_entry, stable_after_uses, definition_path
442
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
443
+ (
444
+ skill_id, name, description, level, trust_score, file_path, tags_json,
445
+ trigger_json, sessions_json, learnings_json, content, steps_json, gotchas_json,
446
+ mode, source_kind, execution_level, approval_required, approved_at, approved_by,
447
+ params_json, command_json, executable_entry, stable_after_uses, definition_path,
448
+ ),
85
449
  )
86
450
  conn.commit()
87
451
 
88
- # FTS index
89
- body = f"{description} {tags_json} {trigger_json}"
90
- fts_upsert("skill", skill_id, name, body, "skill", commit=False)
91
-
92
452
  row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
93
- return dict(row) if row else {"id": skill_id, "status": "created"}
453
+ result = dict(row) if row else {"id": skill_id, "status": "created"}
454
+ fts_upsert("skill", skill_id, name, _skill_fts_body(result), "skill")
455
+ return result
94
456
 
95
457
 
96
458
  def get_skill(skill_id: str) -> dict | None:
97
- """Get a skill by ID."""
98
459
  conn = get_db()
99
460
  row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
100
461
  return dict(row) if row else None
101
462
 
102
463
 
103
- def list_skills(level: str = '', tag: str = '') -> list[dict]:
104
- """List skills, optionally filtered by level or tag."""
464
+ def list_skills(level: str = "", tag: str = "", source_kind: str = "") -> list[dict]:
105
465
  conn = get_db()
106
466
  conditions = []
107
467
  params = []
@@ -112,140 +472,184 @@ def list_skills(level: str = '', tag: str = '') -> list[dict]:
112
472
  if tag:
113
473
  conditions.append("tags LIKE ?")
114
474
  params.append(f'%"{tag}"%')
475
+ if source_kind:
476
+ conditions.append("source_kind = ?")
477
+ params.append(source_kind)
115
478
 
116
479
  where = "WHERE " + " AND ".join(conditions) if conditions else ""
117
480
  rows = conn.execute(
118
- f"SELECT * FROM skills {where} ORDER BY trust_score DESC, last_used_at DESC",
481
+ f"""SELECT * FROM skills {where}
482
+ ORDER BY CASE level WHEN 'stable' THEN 0 WHEN 'published' THEN 1
483
+ WHEN 'draft' THEN 2 WHEN 'trace' THEN 3 ELSE 4 END,
484
+ trust_score DESC, last_used_at DESC""",
119
485
  tuple(params),
120
486
  ).fetchall()
121
- return [dict(r) for r in rows]
487
+ return [dict(row) for row in rows]
122
488
 
123
489
 
124
- def search_skills(query: str, level: str = '') -> list[dict]:
125
- """Search skills using FTS5 for ranked results. Falls back to LIKE."""
490
+ def search_skills(query: str, level: str = "", source_kind: str = "") -> list[dict]:
126
491
  fts_results = fts_search(query, source_filter="skill", limit=20)
127
492
  if fts_results:
128
493
  conn = get_db()
129
- ids = [r['source_id'] for r in fts_results]
130
- placeholders = ','.join('?' * len(ids))
494
+ ids = [result["source_id"] for result in fts_results]
495
+ placeholders = ",".join("?" * len(ids))
131
496
  sql = f"SELECT * FROM skills WHERE id IN ({placeholders})"
132
497
  params = list(ids)
133
498
  if level:
134
499
  sql += " AND level = ?"
135
500
  params.append(level)
501
+ if source_kind:
502
+ sql += " AND source_kind = ?"
503
+ params.append(source_kind)
136
504
  sql += " ORDER BY trust_score DESC"
137
505
  rows = conn.execute(sql, params).fetchall()
138
- return [dict(r) for r in rows]
506
+ return [dict(row) for row in rows]
139
507
 
140
- # Fallback to LIKE
141
508
  conn = get_db()
142
509
  words = query.strip().split()
143
510
  if not words:
144
511
  return []
512
+
145
513
  conditions = []
146
514
  params = []
147
515
  for word in words:
148
- p = f"%{word}%"
149
- conditions.append("(name LIKE ? OR description LIKE ? OR tags LIKE ? OR trigger_patterns LIKE ?)")
150
- params.extend([p, p, p, p])
516
+ pattern = f"%{word}%"
517
+ conditions.append("(name LIKE ? OR description LIKE ? OR tags LIKE ? OR trigger_patterns LIKE ? OR content LIKE ?)")
518
+ params.extend([pattern, pattern, pattern, pattern, pattern])
519
+
151
520
  where = " AND ".join(conditions)
152
521
  if level:
153
522
  where = f"level = ? AND ({where})"
154
523
  params.insert(0, level)
524
+ if source_kind:
525
+ where = f"source_kind = ? AND ({where})"
526
+ params.insert(0 if not level else 1, source_kind)
527
+
155
528
  rows = conn.execute(
156
529
  f"SELECT * FROM skills WHERE {where} ORDER BY trust_score DESC",
157
530
  params,
158
531
  ).fetchall()
159
- return [dict(r) for r in rows]
532
+ return [dict(row) for row in rows]
160
533
 
161
534
 
162
535
  def update_skill(skill_id: str, **kwargs) -> dict:
163
- """Update any fields of a skill."""
164
536
  conn = get_db()
165
537
  row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
166
538
  if not row:
167
539
  return {"error": f"Skill {skill_id} not found"}
168
540
 
169
541
  allowed = {
170
- "name", "description", "level", "trust_score", "file_path",
171
- "tags", "trigger_patterns", "source_sessions", "linked_learnings",
542
+ "name", "description", "level", "trust_score", "file_path", "tags",
543
+ "trigger_patterns", "source_sessions", "linked_learnings", "content",
544
+ "steps", "gotchas", "mode", "source_kind", "execution_level",
545
+ "approval_required", "approved_at", "approved_by", "params_schema",
546
+ "command_template", "executable_entry", "stable_after_uses", "definition_path",
172
547
  }
173
548
  updates = {}
174
- for k, v in kwargs.items():
175
- if k in allowed:
176
- if isinstance(v, (list, dict)):
177
- updates[k] = json.dumps(v)
178
- else:
179
- updates[k] = v
549
+ for key, value in kwargs.items():
550
+ if key not in allowed:
551
+ continue
552
+ if key in {"tags", "trigger_patterns", "source_sessions", "linked_learnings", "steps", "gotchas"}:
553
+ updates[key] = _json_string(value, [])
554
+ elif key in {"params_schema", "command_template"}:
555
+ updates[key] = _json_string(value, {})
556
+ elif key == "level":
557
+ updates[key] = _normalize_level(value)
558
+ elif key == "mode":
559
+ updates[key] = _normalize_mode(value)
560
+ elif key == "source_kind":
561
+ updates[key] = _normalize_source_kind(value)
562
+ elif key == "execution_level":
563
+ updates[key] = _normalize_execution_level(value)
564
+ elif key == "approval_required":
565
+ updates[key] = int(bool(value))
566
+ else:
567
+ updates[key] = value
568
+
569
+ effective_mode = updates.get("mode", row["mode"])
570
+ effective_execution_level = updates.get("execution_level", row["execution_level"])
571
+ if effective_mode == "guide":
572
+ effective_execution_level = "none"
573
+ updates["execution_level"] = "none"
574
+
575
+ approval_required, approved_at, approved_by = _resolve_approval(
576
+ effective_mode,
577
+ effective_execution_level,
578
+ approval_required=updates.get("approval_required", row["approval_required"]),
579
+ approved_at=updates.get("approved_at", row["approved_at"] or ""),
580
+ approved_by=updates.get("approved_by", row["approved_by"] or ""),
581
+ )
582
+ updates["approval_required"] = approval_required
583
+ updates["approved_at"] = approved_at
584
+ updates["approved_by"] = approved_by
180
585
 
181
586
  if not updates:
182
587
  return dict(row)
183
588
 
184
- updates["updated_at"] = datetime.datetime.now().isoformat(timespec='seconds')
185
- set_clause = ", ".join(f"{k} = ?" for k in updates)
589
+ updates["updated_at"] = _now_text()
590
+ set_clause = ", ".join(f"{key} = ?" for key in updates)
186
591
  values = list(updates.values()) + [skill_id]
187
592
  conn.execute(f"UPDATE skills SET {set_clause} WHERE id = ?", values)
188
593
  conn.commit()
189
594
 
190
- # Update FTS
191
- row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
192
- r = dict(row)
193
- body = f"{r.get('description', '')} {r.get('tags', '[]')} {r.get('trigger_patterns', '[]')}"
194
- fts_upsert("skill", skill_id, r.get("name", ""), body, "skill", commit=False)
195
- return r
595
+ refreshed = dict(conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone())
596
+ fts_upsert("skill", skill_id, refreshed.get("name", ""), _skill_fts_body(refreshed), "skill")
597
+ return refreshed
196
598
 
197
599
 
198
600
  def delete_skill(skill_id: str) -> bool:
199
- """Delete a skill and its usage history."""
200
601
  conn = get_db()
602
+ row = conn.execute("SELECT file_path FROM skills WHERE id = ?", (skill_id,)).fetchone()
201
603
  conn.execute("DELETE FROM skill_usage WHERE skill_id = ?", (skill_id,))
202
604
  result = conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,))
203
605
  conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (skill_id,))
204
606
  conn.commit()
205
- return result.rowcount > 0
206
607
 
608
+ if row and row["file_path"]:
609
+ path = Path(row["file_path"])
610
+ if path.is_file() and RUNTIME_SKILLS_DIR in path.parents:
611
+ path.unlink(missing_ok=True)
612
+ try:
613
+ path.parent.rmdir()
614
+ except OSError:
615
+ pass
616
+ return result.rowcount > 0
207
617
 
208
- # ── Usage tracking & auto-promotion ────────────────────────────────
209
618
 
210
- def record_usage(skill_id: str, session_id: str = '', success: bool = True,
211
- context: str = '', notes: str = '') -> dict:
212
- """Record a skill usage and auto-promote/degrade based on trust rules.
619
+ # ── Usage tracking & promotion ─────────────────────────────────────
213
620
 
214
- Returns the updated skill dict with promotion info.
215
- """
621
+ def record_usage(skill_id: str, session_id: str = "", success: bool = True,
622
+ context: str = "", notes: str = "") -> dict:
216
623
  conn = get_db()
217
624
  row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
218
625
  if not row:
219
626
  return {"error": f"Skill {skill_id} not found"}
220
627
 
221
628
  skill = dict(row)
222
-
223
- # Record usage
224
629
  conn.execute(
225
630
  "INSERT INTO skill_usage (skill_id, session_id, success, context, notes) VALUES (?, ?, ?, ?, ?)",
226
631
  (skill_id, session_id, 1 if success else 0, context, notes),
227
632
  )
228
633
 
229
- # Update counters
230
634
  delta = TRUST_ON_SUCCESS if success else TRUST_ON_FAILURE
231
- new_trust = max(0, min(100, skill['trust_score'] + delta))
635
+ new_trust = max(0, min(100, skill["trust_score"] + delta))
232
636
  count_field = "success_count" if success else "fail_count"
233
637
 
234
638
  conn.execute(
235
639
  f"""UPDATE skills SET
236
- use_count = use_count + 1,
237
- {count_field} = {count_field} + 1,
238
- trust_score = ?,
239
- last_used_at = datetime('now'),
240
- updated_at = datetime('now')
241
- WHERE id = ?""",
640
+ use_count = use_count + 1,
641
+ {count_field} = {count_field} + 1,
642
+ trust_score = ?,
643
+ last_used_at = datetime('now'),
644
+ updated_at = datetime('now')
645
+ WHERE id = ?""",
242
646
  (new_trust, skill_id),
243
647
  )
244
648
  conn.commit()
245
649
 
246
- # Auto-promotion: draft → published if 2+ successful uses in distinct contexts
247
650
  promotion = None
248
- if skill['level'] == 'draft' and success:
651
+ refreshed = dict(conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone())
652
+ if skill["level"] == "draft" and success:
249
653
  distinct_contexts = conn.execute(
250
654
  """SELECT COUNT(DISTINCT context) FROM skill_usage
251
655
  WHERE skill_id = ? AND success = 1 AND context != ''""",
@@ -259,113 +663,103 @@ def record_usage(skill_id: str, session_id: str = '', success: bool = True,
259
663
  conn.commit()
260
664
  promotion = "draft → published"
261
665
 
262
- # Auto-archive: trust < 20 archived
263
- if new_trust < TRUST_ARCHIVE_THRESHOLD and skill['level'] in ('draft', 'published'):
666
+ refreshed = dict(conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone())
667
+ if refreshed["level"] == "published" and success:
668
+ stable_after = int(refreshed.get("stable_after_uses", DEFAULT_STABLE_AFTER_USES) or DEFAULT_STABLE_AFTER_USES)
669
+ if refreshed["success_count"] >= stable_after and refreshed["fail_count"] == 0:
670
+ conn.execute(
671
+ "UPDATE skills SET level = 'stable', last_reviewed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
672
+ (skill_id,),
673
+ )
674
+ conn.commit()
675
+ promotion = "published → stable"
676
+
677
+ refreshed = dict(conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone())
678
+ if new_trust < TRUST_ARCHIVE_THRESHOLD and refreshed["level"] in {"draft", "published", "stable"}:
264
679
  conn.execute(
265
680
  "UPDATE skills SET level = 'archived', updated_at = datetime('now') WHERE id = ?",
266
681
  (skill_id,),
267
682
  )
268
683
  conn.commit()
269
- promotion = f"{skill['level']} → archived (trust={new_trust})"
684
+ promotion = f"{refreshed['level']} → archived (trust={new_trust})"
270
685
 
271
686
  result = dict(conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone())
272
687
  if promotion:
273
- result['_promotion'] = promotion
688
+ result["_promotion"] = promotion
274
689
  return result
275
690
 
276
691
 
277
- def match_skills(task: str, level: str = '', top_n: int = 3) -> list[dict]:
278
- """Find skills matching a task description.
279
-
280
- Search strategy:
281
- 1. FTS5 on skill name/description/tags
282
- 2. Trigger pattern matching
283
- 3. Keyword overlap
284
-
285
- Returns top-N matches sorted by relevance × trust.
286
- """
692
+ def match_skills(task: str, level: str = "", top_n: int = 3) -> list[dict]:
287
693
  if not task or not task.strip():
288
694
  return []
289
695
 
290
696
  conn = get_db()
291
697
  seen = set()
292
698
  results = []
293
-
294
- # Level filter
295
- level_filter = "AND level = ?" if level else "AND level IN ('draft', 'published')"
699
+ level_filter = "AND level = ?" if level else "AND level IN ('draft', 'published', 'stable')"
296
700
  level_params = (level,) if level else ()
297
701
 
298
- # Strategy 1: FTS5 search
299
702
  fts_results = fts_search(task, source_filter="skill", limit=10)
300
703
  if fts_results:
301
- ids = [r['source_id'] for r in fts_results]
302
- placeholders = ','.join('?' * len(ids))
704
+ ids = [result["source_id"] for result in fts_results]
705
+ placeholders = ",".join("?" * len(ids))
303
706
  rows = conn.execute(
304
- f"SELECT * FROM skills WHERE id IN ({placeholders}) {level_filter} ORDER BY trust_score DESC",
707
+ f"""SELECT * FROM skills WHERE id IN ({placeholders}) {level_filter}
708
+ ORDER BY CASE source_kind WHEN 'personal' THEN 0 WHEN 'core' THEN 1 ELSE 2 END,
709
+ CASE level WHEN 'stable' THEN 0 WHEN 'published' THEN 1 ELSE 2 END,
710
+ trust_score DESC""",
305
711
  tuple(ids) + level_params,
306
712
  ).fetchall()
307
- for r in rows:
308
- d = dict(r)
309
- d['_match'] = 'fts'
310
- if d['id'] not in seen:
311
- seen.add(d['id'])
312
- results.append(d)
313
-
314
- # Strategy 2: Trigger pattern matching
713
+ for row in rows:
714
+ skill = dict(row)
715
+ skill["_match"] = "fts"
716
+ if skill["id"] not in seen:
717
+ seen.add(skill["id"])
718
+ results.append(skill)
719
+
315
720
  task_lower = task.lower()
316
721
  rows = conn.execute(
317
722
  f"SELECT * FROM skills WHERE trigger_patterns != '[]' {level_filter}",
318
723
  level_params,
319
724
  ).fetchall()
320
- for r in rows:
321
- if r['id'] in seen:
725
+ for row in rows:
726
+ skill = dict(row)
727
+ if skill["id"] in seen:
322
728
  continue
323
- try:
324
- patterns = json.loads(r['trigger_patterns'])
325
- for pattern in patterns:
326
- if pattern.lower() in task_lower or task_lower in pattern.lower():
327
- d = dict(r)
328
- d['_match'] = f'trigger:{pattern}'
329
- seen.add(d['id'])
330
- results.append(d)
331
- break
332
- except (json.JSONDecodeError, TypeError):
333
- pass
729
+ for pattern in _json_list(skill.get("trigger_patterns", "[]")):
730
+ if pattern.lower() in task_lower or task_lower in pattern.lower():
731
+ skill["_match"] = f"trigger:{pattern}"
732
+ seen.add(skill["id"])
733
+ results.append(skill)
734
+ break
334
735
 
335
- # Strategy 3: Tag keyword overlap
336
736
  task_words = set(task_lower.split())
337
737
  rows = conn.execute(
338
738
  f"SELECT * FROM skills WHERE tags != '[]' {level_filter}",
339
739
  level_params,
340
740
  ).fetchall()
341
- for r in rows:
342
- if r['id'] in seen:
741
+ for row in rows:
742
+ skill = dict(row)
743
+ if skill["id"] in seen:
343
744
  continue
344
- try:
345
- tags = json.loads(r['tags'])
346
- tag_words = set(t.lower() for t in tags)
347
- overlap = task_words & tag_words
348
- if overlap:
349
- d = dict(r)
350
- d['_match'] = f'tags:{",".join(overlap)}'
351
- seen.add(d['id'])
352
- results.append(d)
353
- except (json.JSONDecodeError, TypeError):
354
- pass
355
-
356
- # Sort by trust_score descending, then return top N
357
- results.sort(key=lambda x: x.get('trust_score', 0), reverse=True)
745
+ tags = {tag.lower() for tag in _json_list(skill.get("tags", "[]"))}
746
+ overlap = task_words & tags
747
+ if overlap:
748
+ skill["_match"] = f"tags:{','.join(sorted(overlap))}"
749
+ seen.add(skill["id"])
750
+ results.append(skill)
751
+
752
+ results.sort(
753
+ key=lambda skill: (
754
+ 0 if skill.get("source_kind") == "personal" else 1 if skill.get("source_kind") == "core" else 2,
755
+ 0 if skill.get("level") == "stable" else 1 if skill.get("level") == "published" else 2,
756
+ -int(skill.get("trust_score", 0)),
757
+ )
758
+ )
358
759
  return results[:top_n]
359
760
 
360
761
 
361
- def merge_skills(id1: str, id2: str, keep_id: str = '') -> dict:
362
- """Merge two similar skills into one. The survivor gets combined metadata.
363
-
364
- Args:
365
- id1: First skill ID
366
- id2: Second skill ID
367
- keep_id: Which one to keep (default: higher trust). The other is deleted.
368
- """
762
+ def merge_skills(id1: str, id2: str, keep_id: str = "") -> dict:
369
763
  conn = get_db()
370
764
  s1 = conn.execute("SELECT * FROM skills WHERE id = ?", (id1,)).fetchone()
371
765
  s2 = conn.execute("SELECT * FROM skills WHERE id = ?", (id2,)).fetchone()
@@ -375,94 +769,59 @@ def merge_skills(id1: str, id2: str, keep_id: str = '') -> dict:
375
769
  return {"error": f"Skill {id2} not found"}
376
770
 
377
771
  s1, s2 = dict(s1), dict(s2)
378
-
379
- # Decide which to keep
380
772
  if not keep_id:
381
- keep_id = id1 if s1['trust_score'] >= s2['trust_score'] else id2
773
+ keep_id = id1 if s1["trust_score"] >= s2["trust_score"] else id2
382
774
  survivor = s1 if keep_id == id1 else s2
383
775
  donor = s2 if keep_id == id1 else s1
384
- donor_id = donor['id']
385
776
 
386
- # Merge tags
387
- try:
388
- tags1 = set(json.loads(survivor.get('tags', '[]')))
389
- tags2 = set(json.loads(donor.get('tags', '[]')))
390
- merged_tags = json.dumps(sorted(tags1 | tags2))
391
- except (json.JSONDecodeError, TypeError):
392
- merged_tags = survivor.get('tags', '[]')
777
+ merged_tags = json.dumps(sorted(set(_json_list(survivor.get("tags", "[]"))) | set(_json_list(donor.get("tags", "[]")))))
778
+ merged_triggers = json.dumps(sorted(set(_json_list(survivor.get("trigger_patterns", "[]"))) | set(_json_list(donor.get("trigger_patterns", "[]")))))
779
+ merged_sessions = json.dumps(sorted(set(_json_list(survivor.get("source_sessions", "[]"))) | set(_json_list(donor.get("source_sessions", "[]"))), key=str))
780
+ merged_learnings = json.dumps(sorted(set(_json_list(survivor.get("linked_learnings", "[]"))) | set(_json_list(donor.get("linked_learnings", "[]"))), key=str))
393
781
 
394
- # Merge trigger patterns
395
- try:
396
- tp1 = set(json.loads(survivor.get('trigger_patterns', '[]')))
397
- tp2 = set(json.loads(donor.get('trigger_patterns', '[]')))
398
- merged_tp = json.dumps(sorted(tp1 | tp2))
399
- except (json.JSONDecodeError, TypeError):
400
- merged_tp = survivor.get('trigger_patterns', '[]')
401
-
402
- # Merge source sessions
403
- try:
404
- ss1 = set(json.loads(survivor.get('source_sessions', '[]')))
405
- ss2 = set(json.loads(donor.get('source_sessions', '[]')))
406
- merged_ss = json.dumps(sorted(ss1 | ss2, key=str))
407
- except (json.JSONDecodeError, TypeError):
408
- merged_ss = survivor.get('source_sessions', '[]')
409
-
410
- # Merge linked learnings
411
- try:
412
- ll1 = set(json.loads(survivor.get('linked_learnings', '[]')))
413
- ll2 = set(json.loads(donor.get('linked_learnings', '[]')))
414
- merged_ll = json.dumps(sorted(ll1 | ll2, key=str))
415
- except (json.JSONDecodeError, TypeError):
416
- merged_ll = survivor.get('linked_learnings', '[]')
417
-
418
- # Merge counters
419
- merged_use = survivor['use_count'] + donor['use_count']
420
- merged_success = survivor['success_count'] + donor['success_count']
421
- merged_fail = survivor['fail_count'] + donor['fail_count']
422
- merged_trust = max(survivor['trust_score'], donor['trust_score'])
423
-
424
- # Update survivor
425
782
  conn.execute(
426
783
  """UPDATE skills SET
427
- tags = ?, trigger_patterns = ?, source_sessions = ?, linked_learnings = ?,
428
- use_count = ?, success_count = ?, fail_count = ?, trust_score = ?,
429
- updated_at = datetime('now')
430
- WHERE id = ?""",
431
- (merged_tags, merged_tp, merged_ss, merged_ll,
432
- merged_use, merged_success, merged_fail, merged_trust, keep_id),
784
+ tags = ?, trigger_patterns = ?, source_sessions = ?, linked_learnings = ?,
785
+ use_count = ?, success_count = ?, fail_count = ?, trust_score = ?, updated_at = datetime('now')
786
+ WHERE id = ?""",
787
+ (
788
+ merged_tags,
789
+ merged_triggers,
790
+ merged_sessions,
791
+ merged_learnings,
792
+ survivor["use_count"] + donor["use_count"],
793
+ survivor["success_count"] + donor["success_count"],
794
+ survivor["fail_count"] + donor["fail_count"],
795
+ max(survivor["trust_score"], donor["trust_score"]),
796
+ keep_id,
797
+ ),
433
798
  )
434
-
435
- # Move usage records from donor to survivor
436
- conn.execute("UPDATE skill_usage SET skill_id = ? WHERE skill_id = ?", (keep_id, donor_id))
437
-
438
- # Delete donor
439
- conn.execute("DELETE FROM skills WHERE id = ?", (donor_id,))
440
- conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (donor_id,))
799
+ conn.execute("UPDATE skill_usage SET skill_id = ? WHERE skill_id = ?", (keep_id, donor["id"]))
800
+ conn.execute("DELETE FROM skills WHERE id = ?", (donor["id"],))
801
+ conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (donor["id"],))
441
802
  conn.commit()
442
803
 
443
804
  result = dict(conn.execute("SELECT * FROM skills WHERE id = ?", (keep_id,)).fetchone())
444
- result['_merged_from'] = donor_id
805
+ fts_upsert("skill", keep_id, result.get("name", ""), _skill_fts_body(result), "skill")
806
+ result["_merged_from"] = donor["id"]
445
807
  return result
446
808
 
447
809
 
448
810
  def get_skill_stats() -> dict:
449
- """Get aggregate skill statistics."""
450
811
  conn = get_db()
451
812
  total = conn.execute("SELECT COUNT(*) FROM skills").fetchone()[0]
452
813
  by_level = {}
453
814
  for row in conn.execute("SELECT level, COUNT(*) as cnt FROM skills GROUP BY level").fetchall():
454
- by_level[row['level']] = row['cnt']
815
+ by_level[row["level"]] = row["cnt"]
455
816
 
456
817
  avg_trust = conn.execute(
457
818
  "SELECT AVG(trust_score) FROM skills WHERE level != 'archived'"
458
819
  ).fetchone()[0] or 0
459
-
460
820
  total_uses = conn.execute("SELECT COUNT(*) FROM skill_usage").fetchone()[0]
461
821
  success_rate = 0
462
822
  if total_uses > 0:
463
823
  successes = conn.execute("SELECT COUNT(*) FROM skill_usage WHERE success = 1").fetchone()[0]
464
824
  success_rate = round(successes / total_uses * 100, 1)
465
-
466
825
  recent_uses = conn.execute(
467
826
  "SELECT COUNT(*) FROM skill_usage WHERE created_at >= datetime('now', '-7 days')"
468
827
  ).fetchone()[0]
@@ -477,63 +836,410 @@ def get_skill_stats() -> dict:
477
836
  }
478
837
 
479
838
 
480
- def decay_unused_skills(dry_run: bool = False) -> dict:
481
- """Decay and purge unused skills. Called by immune.py or maintenance cron.
839
+ def get_featured_skills(limit: int = 5) -> list[dict]:
840
+ sync_skill_directories()
841
+ conn = get_db()
842
+ rows = conn.execute(
843
+ """SELECT * FROM skills
844
+ WHERE level IN ('published', 'stable')
845
+ ORDER BY CASE source_kind WHEN 'personal' THEN 0 WHEN 'core' THEN 1 ELSE 2 END,
846
+ CASE level WHEN 'stable' THEN 0 ELSE 1 END,
847
+ trust_score DESC,
848
+ COALESCE(last_used_at, created_at) DESC
849
+ LIMIT ?""",
850
+ (limit,),
851
+ ).fetchall()
852
+ return [dict(row) for row in rows]
482
853
 
483
- Rules:
484
- - draft: no use in 30 days → trust = 0 → archived
485
- - published: no use in 90 days → trust -= 5
486
- - archived: no use in 60 days → purge (delete)
487
- """
854
+
855
+ def get_skill_execution_spec(skill_id: str) -> dict:
856
+ skill = get_skill(skill_id)
857
+ if not skill:
858
+ return {"error": f"Skill {skill_id} not found"}
859
+ return {
860
+ "id": skill["id"],
861
+ "mode": _normalize_mode(skill.get("mode", ""), has_script=bool(skill.get("file_path")), has_content=bool(skill.get("content"))),
862
+ "execution_level": _normalize_execution_level(skill.get("execution_level", "none")),
863
+ "approval_required": bool(skill.get("approval_required", 0)),
864
+ "approved_at": skill.get("approved_at", ""),
865
+ "file_path": skill.get("file_path", ""),
866
+ "params_schema": _json_dict(skill.get("params_schema", "{}")),
867
+ "command_template": _json_dict(skill.get("command_template", "{}")),
868
+ }
869
+
870
+
871
+ def resolve_skill_paths(skill: dict) -> dict:
872
+ return {
873
+ "definition_path": skill.get("definition_path", ""),
874
+ "file_path": skill.get("file_path", ""),
875
+ "executable_entry": skill.get("executable_entry", ""),
876
+ }
877
+
878
+
879
+ def validate_skill_params(skill: dict, params: dict | str | None) -> dict:
880
+ params = _json_dict(params or {})
881
+ schema = _json_dict(skill.get("params_schema", "{}"))
882
+ resolved = dict(params)
883
+ errors = []
884
+
885
+ for name, spec in schema.items():
886
+ if not isinstance(spec, dict):
887
+ errors.append(f"params_schema.{name} is not an object")
888
+ continue
889
+ required = bool(spec.get("required"))
890
+ if name not in resolved and "default" in spec:
891
+ resolved[name] = spec["default"]
892
+ if required and name not in resolved:
893
+ errors.append(f"Missing required param: {name}")
894
+ continue
895
+ if name not in resolved:
896
+ continue
897
+ value = resolved[name]
898
+ expected_type = spec.get("type", "")
899
+ if expected_type == "string" and not isinstance(value, str):
900
+ errors.append(f"Param {name} must be a string")
901
+ elif expected_type == "integer" and not isinstance(value, int):
902
+ errors.append(f"Param {name} must be an integer")
903
+ elif expected_type == "number" and not isinstance(value, (int, float)):
904
+ errors.append(f"Param {name} must be a number")
905
+ elif expected_type == "boolean" and not isinstance(value, bool):
906
+ errors.append(f"Param {name} must be a boolean")
907
+
908
+ enum = spec.get("enum")
909
+ if enum and value not in enum:
910
+ errors.append(f"Param {name} must be one of: {', '.join(str(item) for item in enum)}")
911
+
912
+ return {"ok": not errors, "params": resolved, "errors": errors}
913
+
914
+
915
+ def render_command_template(skill: dict, params: dict | str | None) -> dict:
916
+ validation = validate_skill_params(skill, params)
917
+ if not validation["ok"]:
918
+ return validation
919
+
920
+ template = _json_dict(skill.get("command_template", "{}"))
921
+ argv_template = template.get("argv") or []
922
+ resolved_params = {
923
+ **validation["params"],
924
+ "file_path": skill.get("file_path", ""),
925
+ "skill_id": skill.get("id", ""),
926
+ "skill_name": skill.get("name", ""),
927
+ }
928
+
929
+ def render_token(token: str):
930
+ rendered = token
931
+ for key, value in resolved_params.items():
932
+ rendered = rendered.replace(f"{{{{{key}}}}}", str(value))
933
+ if rendered == token and token.startswith("{{") and token.endswith("}}"):
934
+ return ""
935
+ return rendered
936
+
937
+ if not argv_template:
938
+ file_path = skill.get("file_path", "")
939
+ argv = [file_path] if file_path else []
940
+ else:
941
+ argv = []
942
+ for item in argv_template:
943
+ if not isinstance(item, str):
944
+ continue
945
+ rendered = render_token(item)
946
+ if rendered != "":
947
+ argv.append(rendered)
948
+
949
+ return {"ok": True, "params": resolved_params, "argv": argv}
950
+
951
+
952
+ def sync_skill_directories() -> dict:
953
+ _ensure_skill_dirs()
954
+ discovered = {}
955
+ issues = []
956
+
957
+ for source_kind, root in _sync_dirs():
958
+ if not root.is_dir():
959
+ continue
960
+ for child in sorted(root.iterdir()):
961
+ if not child.is_dir() or child.name.startswith("."):
962
+ continue
963
+ try:
964
+ definition = _load_skill_definition(child, source_kind)
965
+ except Exception as exc:
966
+ issues.append(f"{child}: {exc}")
967
+ continue
968
+ if not definition:
969
+ continue
970
+ existing = discovered.get(definition["id"])
971
+ if not existing or _definition_priority(source_kind) >= _definition_priority(existing["source_kind"]):
972
+ discovered[definition["id"]] = definition
973
+
974
+ synced = []
975
+ for skill in discovered.values():
976
+ synced.append(_upsert_filesystem_skill(skill)["id"])
977
+
978
+ return {"synced": len(synced), "ids": sorted(synced), "issues": issues}
979
+
980
+
981
+ def import_skill_from_directory(path: str, source_kind: str = "personal") -> dict:
982
+ skill_dir = Path(path)
983
+ definition = _load_skill_definition(skill_dir, _normalize_source_kind(source_kind))
984
+ if not definition:
985
+ return {"error": f"No {SKILL_DEFINITION_FILENAME} found in {skill_dir}"}
986
+ return _upsert_filesystem_skill(definition)
987
+
988
+
989
+ def approve_skill(skill_id: str, execution_level: str = "", approved_by: str = "") -> dict:
990
+ skill = get_skill(skill_id)
991
+ if not skill:
992
+ return {"error": f"Skill {skill_id} not found"}
993
+
994
+ updates = {
995
+ "approved_at": _now_text(),
996
+ "approved_by": approved_by or skill.get("approved_by", "") or AUTO_APPROVER,
997
+ "approval_required": 0,
998
+ }
999
+ if execution_level:
1000
+ updates["execution_level"] = _normalize_execution_level(execution_level)
1001
+ return update_skill(skill_id, **updates)
1002
+
1003
+
1004
+ def collect_scriptable_skill_candidates() -> list[dict]:
1005
+ conn = get_db()
1006
+ rows = conn.execute(
1007
+ """SELECT * FROM skills
1008
+ WHERE file_path = ''
1009
+ AND level IN ('draft', 'published', 'stable')
1010
+ AND success_count >= 3
1011
+ ORDER BY trust_score DESC, success_count DESC""",
1012
+ ).fetchall()
1013
+
1014
+ candidates = []
1015
+ for row in rows:
1016
+ skill = dict(row)
1017
+ text = " ".join(
1018
+ [
1019
+ skill.get("name", ""),
1020
+ skill.get("description", ""),
1021
+ skill.get("content", ""),
1022
+ skill.get("trigger_patterns", ""),
1023
+ ]
1024
+ ).lower()
1025
+ if any(word in text for word in ("deploy", "ssh", "server", "remote", "api call")):
1026
+ suggested = "remote"
1027
+ elif any(word in text for word in ("edit", "commit", "patch", "write", "refactor", "fix")):
1028
+ suggested = "local"
1029
+ else:
1030
+ suggested = "read-only"
1031
+
1032
+ candidates.append(
1033
+ {
1034
+ "id": skill["id"],
1035
+ "name": skill["name"],
1036
+ "description": skill.get("description", ""),
1037
+ "content": skill.get("content", ""),
1038
+ "steps": _json_list(skill.get("steps", "[]")),
1039
+ "gotchas": _json_list(skill.get("gotchas", "[]")),
1040
+ "trigger_patterns": _json_list(skill.get("trigger_patterns", "[]")),
1041
+ "source_sessions": _json_list(skill.get("source_sessions", "[]")),
1042
+ "suggested_mode": "hybrid",
1043
+ "suggested_execution_level": suggested,
1044
+ "success_count": skill["success_count"],
1045
+ "trust_score": skill["trust_score"],
1046
+ }
1047
+ )
1048
+ return candidates
1049
+
1050
+
1051
+ def collect_skill_improvement_candidates() -> list[dict]:
1052
+ conn = get_db()
1053
+ rows = conn.execute(
1054
+ """SELECT skill_id,
1055
+ SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS failures,
1056
+ SUM(CASE WHEN notes != '' THEN 1 ELSE 0 END) AS noted_runs
1057
+ FROM skill_usage
1058
+ GROUP BY skill_id
1059
+ HAVING failures > 0 OR noted_runs > 0
1060
+ ORDER BY failures DESC, noted_runs DESC""",
1061
+ ).fetchall()
1062
+
1063
+ candidates = []
1064
+ for row in rows:
1065
+ skill = get_skill(row["skill_id"])
1066
+ if not skill:
1067
+ continue
1068
+ candidates.append(
1069
+ {
1070
+ "id": skill["id"],
1071
+ "name": skill["name"],
1072
+ "failures": row["failures"],
1073
+ "noted_runs": row["noted_runs"],
1074
+ "trust_score": skill["trust_score"],
1075
+ }
1076
+ )
1077
+ return candidates
1078
+
1079
+
1080
+ def materialize_personal_skill_definition(skill_data: dict) -> dict:
1081
+ """Write a personal skill definition to NEXO_HOME/skills and sync it into DB."""
1082
+ _ensure_skill_dirs()
1083
+ skill_id = str(skill_data.get("id", "")).strip()
1084
+ name = str(skill_data.get("name", "")).strip()
1085
+ if not skill_id or not name:
1086
+ return {"error": "skill_data requires id and name"}
1087
+
1088
+ skill_dir = PERSONAL_SKILLS_DIR / _safe_slug(skill_id)
1089
+ skill_dir.mkdir(parents=True, exist_ok=True)
1090
+
1091
+ metadata = {
1092
+ "id": skill_id,
1093
+ "name": name,
1094
+ "description": str(skill_data.get("description", "") or ""),
1095
+ "level": _normalize_level(skill_data.get("level", "draft")),
1096
+ "mode": _normalize_mode(
1097
+ skill_data.get("mode", ""),
1098
+ has_script=bool(skill_data.get("script_body") or skill_data.get("executable_entry")),
1099
+ has_content=bool(skill_data.get("content") or skill_data.get("steps")),
1100
+ ),
1101
+ "source_kind": "personal",
1102
+ "execution_level": _normalize_execution_level(skill_data.get("execution_level", "none")),
1103
+ "approval_required": False,
1104
+ "approved_at": str(skill_data.get("approved_at", "") or ""),
1105
+ "approved_by": str(skill_data.get("approved_by", "") or ""),
1106
+ "tags": _json_list(skill_data.get("tags", [])),
1107
+ "trigger_patterns": _json_list(skill_data.get("trigger_patterns", [])),
1108
+ "source_sessions": _json_list(skill_data.get("source_sessions", [])),
1109
+ "steps": _json_list(skill_data.get("steps", [])),
1110
+ "gotchas": _json_list(skill_data.get("gotchas", [])),
1111
+ "params_schema": _json_dict(skill_data.get("params_schema", {})),
1112
+ "command_template": _json_dict(skill_data.get("command_template", {})),
1113
+ "stable_after_uses": int(skill_data.get("stable_after_uses", DEFAULT_STABLE_AFTER_USES) or DEFAULT_STABLE_AFTER_USES),
1114
+ }
1115
+
1116
+ executable_entry = str(skill_data.get("executable_entry", "") or "").strip()
1117
+ script_body = str(skill_data.get("script_body", "") or "")
1118
+ if script_body and not executable_entry:
1119
+ executable_entry = "script.py"
1120
+ if executable_entry:
1121
+ metadata["executable_entry"] = executable_entry
1122
+
1123
+ approval_required, approved_at, approved_by = _resolve_approval(
1124
+ metadata["mode"],
1125
+ metadata["execution_level"],
1126
+ approval_required=metadata["approval_required"],
1127
+ approved_at=metadata["approved_at"],
1128
+ approved_by=metadata["approved_by"],
1129
+ )
1130
+ metadata["approval_required"] = bool(approval_required)
1131
+ metadata["approved_at"] = approved_at
1132
+ metadata["approved_by"] = approved_by
1133
+
1134
+ guide_content = str(skill_data.get("content", "") or "")
1135
+ if not guide_content:
1136
+ steps = metadata["steps"]
1137
+ gotchas = metadata["gotchas"]
1138
+ lines = [f"# {name}", "", metadata["description"], "", "## Steps"]
1139
+ for index, step in enumerate(steps, 1):
1140
+ lines.append(f"{index}. {step}")
1141
+ if gotchas:
1142
+ lines.extend(["", "## Gotchas"])
1143
+ for gotcha in gotchas:
1144
+ lines.append(f"- {gotcha}")
1145
+ guide_content = "\n".join(lines).strip() + "\n"
1146
+
1147
+ (skill_dir / SKILL_DEFINITION_FILENAME).write_text(json.dumps(metadata, indent=2, ensure_ascii=False) + "\n")
1148
+ (skill_dir / "guide.md").write_text(guide_content)
1149
+ if script_body and executable_entry:
1150
+ script_path = skill_dir / executable_entry
1151
+ script_path.write_text(script_body)
1152
+ try:
1153
+ script_path.chmod(0o755)
1154
+ except OSError:
1155
+ pass
1156
+
1157
+ return import_skill_from_directory(str(skill_dir), source_kind="personal")
1158
+
1159
+
1160
+ def get_skill_health_report(fix: bool = False) -> dict:
1161
+ if fix:
1162
+ sync_skill_directories()
1163
+
1164
+ conn = get_db()
1165
+ rows = conn.execute("SELECT * FROM skills ORDER BY id").fetchall()
1166
+ issues = []
1167
+ checked = 0
1168
+ for row in rows:
1169
+ skill = dict(row)
1170
+ checked += 1
1171
+ mode = _normalize_mode(skill.get("mode", ""), has_script=bool(skill.get("file_path")), has_content=bool(skill.get("content")))
1172
+ execution_level = _normalize_execution_level(skill.get("execution_level", "none"))
1173
+
1174
+ if skill.get("source_kind") in VALID_SOURCE_KINDS and skill.get("definition_path"):
1175
+ if not Path(skill["definition_path"]).is_file():
1176
+ issues.append({"severity": "error", "skill_id": skill["id"], "message": f"Definition missing: {skill['definition_path']}"})
1177
+
1178
+ if mode in {"execute", "hybrid"}:
1179
+ file_path = skill.get("file_path", "")
1180
+ if not file_path:
1181
+ issues.append({"severity": "error", "skill_id": skill["id"], "message": "Executable skill without file_path"})
1182
+ elif not Path(file_path).is_file():
1183
+ issues.append({"severity": "error", "skill_id": skill["id"], "message": f"Script missing: {file_path}"})
1184
+
1185
+ params_schema = _json_dict(skill.get("params_schema", "{}"))
1186
+ command_template = _json_dict(skill.get("command_template", "{}"))
1187
+ if skill.get("params_schema", "{}") and not isinstance(params_schema, dict):
1188
+ issues.append({"severity": "error", "skill_id": skill["id"], "message": "Invalid params_schema"})
1189
+ argv = command_template.get("argv")
1190
+ if command_template and argv is not None and not isinstance(argv, list):
1191
+ issues.append({"severity": "error", "skill_id": skill["id"], "message": "command_template.argv must be a list"})
1192
+
1193
+ return {"checked": checked, "issues": issues}
1194
+
1195
+
1196
+ def decay_unused_skills(dry_run: bool = False) -> dict:
488
1197
  conn = get_db()
489
1198
  actions = {"decayed": [], "archived": [], "purged": []}
490
1199
 
491
- # Draft: 30 days no use → archive
492
- rows = conn.execute("""
493
- SELECT * FROM skills WHERE level = 'draft'
494
- AND (last_used_at IS NULL OR last_used_at < datetime('now', '-30 days'))
495
- AND created_at < datetime('now', '-30 days')
496
- """).fetchall()
497
- for r in rows:
1200
+ rows = conn.execute(
1201
+ """SELECT * FROM skills WHERE level = 'draft'
1202
+ AND (last_used_at IS NULL OR last_used_at < datetime('now', '-30 days'))
1203
+ AND created_at < datetime('now', '-30 days')"""
1204
+ ).fetchall()
1205
+ for row in rows:
498
1206
  if not dry_run:
499
1207
  conn.execute(
500
1208
  "UPDATE skills SET level = 'archived', trust_score = 0, updated_at = datetime('now') WHERE id = ?",
501
- (r['id'],),
1209
+ (row["id"],),
502
1210
  )
503
- actions["archived"].append(r['id'])
504
-
505
- # Published: 90 days no use → trust -= 5
506
- rows = conn.execute("""
507
- SELECT * FROM skills WHERE level = 'published'
508
- AND (last_used_at IS NULL OR last_used_at < datetime('now', '-90 days'))
509
- """).fetchall()
510
- for r in rows:
511
- new_trust = max(0, r['trust_score'] - 5)
1211
+ actions["archived"].append(row["id"])
1212
+
1213
+ rows = conn.execute(
1214
+ """SELECT * FROM skills WHERE level IN ('published', 'stable')
1215
+ AND (last_used_at IS NULL OR last_used_at < datetime('now', '-90 days'))"""
1216
+ ).fetchall()
1217
+ for row in rows:
1218
+ new_trust = max(0, row["trust_score"] - 5)
512
1219
  if not dry_run:
513
1220
  conn.execute(
514
1221
  "UPDATE skills SET trust_score = ?, updated_at = datetime('now') WHERE id = ?",
515
- (new_trust, r['id']),
1222
+ (new_trust, row["id"]),
516
1223
  )
517
1224
  if new_trust < TRUST_ARCHIVE_THRESHOLD:
518
1225
  conn.execute(
519
1226
  "UPDATE skills SET level = 'archived', updated_at = datetime('now') WHERE id = ?",
520
- (r['id'],),
1227
+ (row["id"],),
521
1228
  )
522
- actions["archived"].append(r['id'])
523
- actions["decayed"].append({"id": r['id'], "trust": f"{r['trust_score']} → {new_trust}"})
524
-
525
- # Archived: 60 days → purge
526
- rows = conn.execute("""
527
- SELECT * FROM skills WHERE level = 'archived'
528
- AND (last_used_at IS NULL OR last_used_at < datetime('now', '-60 days'))
529
- AND updated_at < datetime('now', '-60 days')
530
- """).fetchall()
531
- for r in rows:
1229
+ actions["archived"].append(row["id"])
1230
+ actions["decayed"].append({"id": row["id"], "trust": f"{row['trust_score']} → {new_trust}"})
1231
+
1232
+ rows = conn.execute(
1233
+ """SELECT * FROM skills WHERE level = 'archived'
1234
+ AND (last_used_at IS NULL OR last_used_at < datetime('now', '-60 days'))
1235
+ AND updated_at < datetime('now', '-60 days')"""
1236
+ ).fetchall()
1237
+ for row in rows:
532
1238
  if not dry_run:
533
- conn.execute("DELETE FROM skill_usage WHERE skill_id = ?", (r['id'],))
534
- conn.execute("DELETE FROM skills WHERE id = ?", (r['id'],))
535
- conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (r['id'],))
536
- actions["purged"].append(r['id'])
1239
+ conn.execute("DELETE FROM skill_usage WHERE skill_id = ?", (row["id"],))
1240
+ conn.execute("DELETE FROM skills WHERE id = ?", (row["id"],))
1241
+ conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (row["id"],))
1242
+ actions["purged"].append(row["id"])
537
1243
 
538
1244
  if not dry_run:
539
1245
  conn.commit()