nexo-brain 2.3.2 → 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 (83) hide show
  1. package/README.md +77 -8
  2. package/bin/nexo-brain.js +230 -22
  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 +709 -37
  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 -652
  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 +384 -572
  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 -336
  31. package/src/dashboard/templates/memory.html +317 -197
  32. package/src/dashboard/templates/operations.html +498 -652
  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 -171
  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 +25 -1
  43. package/src/db/_sessions.py +22 -0
  44. package/src/db/_skills.py +983 -252
  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/capture-tool-logs.sh +18 -4
  54. package/src/hooks/post-compact.sh +5 -1
  55. package/src/hooks/pre-compact.sh +1 -1
  56. package/src/plugin_loader.py +14 -0
  57. package/src/plugins/doctor.py +36 -0
  58. package/src/plugins/evolution.py +2 -1
  59. package/src/plugins/skills.py +135 -175
  60. package/src/requirements.txt +1 -0
  61. package/src/script_registry.py +322 -0
  62. package/src/scripts/deep-sleep/apply_findings.py +63 -33
  63. package/src/scripts/deep-sleep/collect.py +38 -9
  64. package/src/scripts/deep-sleep/extract-prompt.md +14 -0
  65. package/src/scripts/deep-sleep/synthesize-prompt.md +36 -0
  66. package/src/scripts/deep-sleep/synthesize.py +37 -1
  67. package/src/scripts/nexo-dashboard.sh +29 -0
  68. package/src/scripts/nexo-day-orchestrator.sh +139 -0
  69. package/src/scripts/nexo-evolution-run.py +2 -1
  70. package/src/scripts/nexo-learning-housekeep.py +1 -1
  71. package/src/scripts/nexo-watchdog.sh +1 -1
  72. package/src/server.py +9 -5
  73. package/src/skills/run-runtime-doctor/guide.md +12 -0
  74. package/src/skills/run-runtime-doctor/script.py +21 -0
  75. package/src/skills/run-runtime-doctor/skill.json +25 -0
  76. package/src/skills_runtime.py +347 -0
  77. package/src/tools_menu.py +3 -2
  78. package/src/tools_sessions.py +126 -0
  79. package/src/user_context.py +46 -0
  80. package/templates/nexo_helper.py +45 -0
  81. package/templates/script-template.py +44 -0
  82. package/templates/skill-script-template.py +39 -0
  83. 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,52 +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,
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 = "",
42
398
  ) -> dict:
43
399
  """Create a new skill entry."""
44
- if level not in VALID_LEVELS:
45
- return {"error": f"level must be one of: {', '.join(sorted(VALID_LEVELS))}"}
46
-
47
- tags_json = json.dumps(tags) if isinstance(tags, list) else tags
48
- trigger_json = json.dumps(trigger_patterns) if isinstance(trigger_patterns, list) else trigger_patterns
49
- sessions_json = json.dumps(source_sessions) if isinstance(source_sessions, list) else source_sessions
50
- learnings_json = json.dumps(linked_learnings) if isinstance(linked_learnings, list) else linked_learnings
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)
413
+ lines = [f"# {name}", "", description, "", "## Steps"]
414
+ for index, step in enumerate(steps_list, 1):
415
+ lines.append(f"{index}. {step}")
416
+ if gotchas_list:
417
+ lines.extend(["", "## Gotchas"])
418
+ for gotcha in gotchas_list:
419
+ lines.append(f"- {gotcha}")
420
+ content = "\n".join(lines)
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
+ )
51
434
 
52
435
  conn = get_db()
53
436
  conn.execute(
54
- """INSERT INTO skills
55
- (id, name, description, level, trust_score, file_path, tags,
56
- trigger_patterns, source_sessions, linked_learnings)
57
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
58
- (skill_id, name, description, level, trust_score, file_path,
59
- tags_json, trigger_json, sessions_json, learnings_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
+ ),
60
449
  )
61
450
  conn.commit()
62
451
 
63
- # FTS index
64
- body = f"{description} {tags_json} {trigger_json}"
65
- fts_upsert("skill", skill_id, name, body, "skill", commit=False)
66
-
67
452
  row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
68
- 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
69
456
 
70
457
 
71
458
  def get_skill(skill_id: str) -> dict | None:
72
- """Get a skill by ID."""
73
459
  conn = get_db()
74
460
  row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
75
461
  return dict(row) if row else None
76
462
 
77
463
 
78
- def list_skills(level: str = '', tag: str = '') -> list[dict]:
79
- """List skills, optionally filtered by level or tag."""
464
+ def list_skills(level: str = "", tag: str = "", source_kind: str = "") -> list[dict]:
80
465
  conn = get_db()
81
466
  conditions = []
82
467
  params = []
@@ -87,140 +472,184 @@ def list_skills(level: str = '', tag: str = '') -> list[dict]:
87
472
  if tag:
88
473
  conditions.append("tags LIKE ?")
89
474
  params.append(f'%"{tag}"%')
475
+ if source_kind:
476
+ conditions.append("source_kind = ?")
477
+ params.append(source_kind)
90
478
 
91
479
  where = "WHERE " + " AND ".join(conditions) if conditions else ""
92
480
  rows = conn.execute(
93
- 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""",
94
485
  tuple(params),
95
486
  ).fetchall()
96
- return [dict(r) for r in rows]
487
+ return [dict(row) for row in rows]
97
488
 
98
489
 
99
- def search_skills(query: str, level: str = '') -> list[dict]:
100
- """Search skills using FTS5 for ranked results. Falls back to LIKE."""
490
+ def search_skills(query: str, level: str = "", source_kind: str = "") -> list[dict]:
101
491
  fts_results = fts_search(query, source_filter="skill", limit=20)
102
492
  if fts_results:
103
493
  conn = get_db()
104
- ids = [r['source_id'] for r in fts_results]
105
- placeholders = ','.join('?' * len(ids))
494
+ ids = [result["source_id"] for result in fts_results]
495
+ placeholders = ",".join("?" * len(ids))
106
496
  sql = f"SELECT * FROM skills WHERE id IN ({placeholders})"
107
497
  params = list(ids)
108
498
  if level:
109
499
  sql += " AND level = ?"
110
500
  params.append(level)
501
+ if source_kind:
502
+ sql += " AND source_kind = ?"
503
+ params.append(source_kind)
111
504
  sql += " ORDER BY trust_score DESC"
112
505
  rows = conn.execute(sql, params).fetchall()
113
- return [dict(r) for r in rows]
506
+ return [dict(row) for row in rows]
114
507
 
115
- # Fallback to LIKE
116
508
  conn = get_db()
117
509
  words = query.strip().split()
118
510
  if not words:
119
511
  return []
512
+
120
513
  conditions = []
121
514
  params = []
122
515
  for word in words:
123
- p = f"%{word}%"
124
- conditions.append("(name LIKE ? OR description LIKE ? OR tags LIKE ? OR trigger_patterns LIKE ?)")
125
- 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
+
126
520
  where = " AND ".join(conditions)
127
521
  if level:
128
522
  where = f"level = ? AND ({where})"
129
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
+
130
528
  rows = conn.execute(
131
529
  f"SELECT * FROM skills WHERE {where} ORDER BY trust_score DESC",
132
530
  params,
133
531
  ).fetchall()
134
- return [dict(r) for r in rows]
532
+ return [dict(row) for row in rows]
135
533
 
136
534
 
137
535
  def update_skill(skill_id: str, **kwargs) -> dict:
138
- """Update any fields of a skill."""
139
536
  conn = get_db()
140
537
  row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
141
538
  if not row:
142
539
  return {"error": f"Skill {skill_id} not found"}
143
540
 
144
541
  allowed = {
145
- "name", "description", "level", "trust_score", "file_path",
146
- "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",
147
547
  }
148
548
  updates = {}
149
- for k, v in kwargs.items():
150
- if k in allowed:
151
- if isinstance(v, (list, dict)):
152
- updates[k] = json.dumps(v)
153
- else:
154
- 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
155
585
 
156
586
  if not updates:
157
587
  return dict(row)
158
588
 
159
- updates["updated_at"] = datetime.datetime.now().isoformat(timespec='seconds')
160
- 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)
161
591
  values = list(updates.values()) + [skill_id]
162
592
  conn.execute(f"UPDATE skills SET {set_clause} WHERE id = ?", values)
163
593
  conn.commit()
164
594
 
165
- # Update FTS
166
- row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
167
- r = dict(row)
168
- body = f"{r.get('description', '')} {r.get('tags', '[]')} {r.get('trigger_patterns', '[]')}"
169
- fts_upsert("skill", skill_id, r.get("name", ""), body, "skill", commit=False)
170
- 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
171
598
 
172
599
 
173
600
  def delete_skill(skill_id: str) -> bool:
174
- """Delete a skill and its usage history."""
175
601
  conn = get_db()
602
+ row = conn.execute("SELECT file_path FROM skills WHERE id = ?", (skill_id,)).fetchone()
176
603
  conn.execute("DELETE FROM skill_usage WHERE skill_id = ?", (skill_id,))
177
604
  result = conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,))
178
605
  conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (skill_id,))
179
606
  conn.commit()
180
- return result.rowcount > 0
181
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
182
617
 
183
- # ── Usage tracking & auto-promotion ────────────────────────────────
184
618
 
185
- def record_usage(skill_id: str, session_id: str = '', success: bool = True,
186
- context: str = '', notes: str = '') -> dict:
187
- """Record a skill usage and auto-promote/degrade based on trust rules.
619
+ # ── Usage tracking & promotion ─────────────────────────────────────
188
620
 
189
- Returns the updated skill dict with promotion info.
190
- """
621
+ def record_usage(skill_id: str, session_id: str = "", success: bool = True,
622
+ context: str = "", notes: str = "") -> dict:
191
623
  conn = get_db()
192
624
  row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone()
193
625
  if not row:
194
626
  return {"error": f"Skill {skill_id} not found"}
195
627
 
196
628
  skill = dict(row)
197
-
198
- # Record usage
199
629
  conn.execute(
200
630
  "INSERT INTO skill_usage (skill_id, session_id, success, context, notes) VALUES (?, ?, ?, ?, ?)",
201
631
  (skill_id, session_id, 1 if success else 0, context, notes),
202
632
  )
203
633
 
204
- # Update counters
205
634
  delta = TRUST_ON_SUCCESS if success else TRUST_ON_FAILURE
206
- new_trust = max(0, min(100, skill['trust_score'] + delta))
635
+ new_trust = max(0, min(100, skill["trust_score"] + delta))
207
636
  count_field = "success_count" if success else "fail_count"
208
637
 
209
638
  conn.execute(
210
639
  f"""UPDATE skills SET
211
- use_count = use_count + 1,
212
- {count_field} = {count_field} + 1,
213
- trust_score = ?,
214
- last_used_at = datetime('now'),
215
- updated_at = datetime('now')
216
- 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 = ?""",
217
646
  (new_trust, skill_id),
218
647
  )
219
648
  conn.commit()
220
649
 
221
- # Auto-promotion: draft → published if 2+ successful uses in distinct contexts
222
650
  promotion = None
223
- 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:
224
653
  distinct_contexts = conn.execute(
225
654
  """SELECT COUNT(DISTINCT context) FROM skill_usage
226
655
  WHERE skill_id = ? AND success = 1 AND context != ''""",
@@ -234,113 +663,103 @@ def record_usage(skill_id: str, session_id: str = '', success: bool = True,
234
663
  conn.commit()
235
664
  promotion = "draft → published"
236
665
 
237
- # Auto-archive: trust < 20 archived
238
- 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"}:
239
679
  conn.execute(
240
680
  "UPDATE skills SET level = 'archived', updated_at = datetime('now') WHERE id = ?",
241
681
  (skill_id,),
242
682
  )
243
683
  conn.commit()
244
- promotion = f"{skill['level']} → archived (trust={new_trust})"
684
+ promotion = f"{refreshed['level']} → archived (trust={new_trust})"
245
685
 
246
686
  result = dict(conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone())
247
687
  if promotion:
248
- result['_promotion'] = promotion
688
+ result["_promotion"] = promotion
249
689
  return result
250
690
 
251
691
 
252
- def match_skills(task: str, level: str = '', top_n: int = 3) -> list[dict]:
253
- """Find skills matching a task description.
254
-
255
- Search strategy:
256
- 1. FTS5 on skill name/description/tags
257
- 2. Trigger pattern matching
258
- 3. Keyword overlap
259
-
260
- Returns top-N matches sorted by relevance × trust.
261
- """
692
+ def match_skills(task: str, level: str = "", top_n: int = 3) -> list[dict]:
262
693
  if not task or not task.strip():
263
694
  return []
264
695
 
265
696
  conn = get_db()
266
697
  seen = set()
267
698
  results = []
268
-
269
- # Level filter
270
- 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')"
271
700
  level_params = (level,) if level else ()
272
701
 
273
- # Strategy 1: FTS5 search
274
702
  fts_results = fts_search(task, source_filter="skill", limit=10)
275
703
  if fts_results:
276
- ids = [r['source_id'] for r in fts_results]
277
- placeholders = ','.join('?' * len(ids))
704
+ ids = [result["source_id"] for result in fts_results]
705
+ placeholders = ",".join("?" * len(ids))
278
706
  rows = conn.execute(
279
- 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""",
280
711
  tuple(ids) + level_params,
281
712
  ).fetchall()
282
- for r in rows:
283
- d = dict(r)
284
- d['_match'] = 'fts'
285
- if d['id'] not in seen:
286
- seen.add(d['id'])
287
- results.append(d)
288
-
289
- # 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
+
290
720
  task_lower = task.lower()
291
721
  rows = conn.execute(
292
722
  f"SELECT * FROM skills WHERE trigger_patterns != '[]' {level_filter}",
293
723
  level_params,
294
724
  ).fetchall()
295
- for r in rows:
296
- if r['id'] in seen:
725
+ for row in rows:
726
+ skill = dict(row)
727
+ if skill["id"] in seen:
297
728
  continue
298
- try:
299
- patterns = json.loads(r['trigger_patterns'])
300
- for pattern in patterns:
301
- if pattern.lower() in task_lower or task_lower in pattern.lower():
302
- d = dict(r)
303
- d['_match'] = f'trigger:{pattern}'
304
- seen.add(d['id'])
305
- results.append(d)
306
- break
307
- except (json.JSONDecodeError, TypeError):
308
- 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
309
735
 
310
- # Strategy 3: Tag keyword overlap
311
736
  task_words = set(task_lower.split())
312
737
  rows = conn.execute(
313
738
  f"SELECT * FROM skills WHERE tags != '[]' {level_filter}",
314
739
  level_params,
315
740
  ).fetchall()
316
- for r in rows:
317
- if r['id'] in seen:
741
+ for row in rows:
742
+ skill = dict(row)
743
+ if skill["id"] in seen:
318
744
  continue
319
- try:
320
- tags = json.loads(r['tags'])
321
- tag_words = set(t.lower() for t in tags)
322
- overlap = task_words & tag_words
323
- if overlap:
324
- d = dict(r)
325
- d['_match'] = f'tags:{",".join(overlap)}'
326
- seen.add(d['id'])
327
- results.append(d)
328
- except (json.JSONDecodeError, TypeError):
329
- pass
330
-
331
- # Sort by trust_score descending, then return top N
332
- 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
+ )
333
759
  return results[:top_n]
334
760
 
335
761
 
336
- def merge_skills(id1: str, id2: str, keep_id: str = '') -> dict:
337
- """Merge two similar skills into one. The survivor gets combined metadata.
338
-
339
- Args:
340
- id1: First skill ID
341
- id2: Second skill ID
342
- keep_id: Which one to keep (default: higher trust). The other is deleted.
343
- """
762
+ def merge_skills(id1: str, id2: str, keep_id: str = "") -> dict:
344
763
  conn = get_db()
345
764
  s1 = conn.execute("SELECT * FROM skills WHERE id = ?", (id1,)).fetchone()
346
765
  s2 = conn.execute("SELECT * FROM skills WHERE id = ?", (id2,)).fetchone()
@@ -350,94 +769,59 @@ def merge_skills(id1: str, id2: str, keep_id: str = '') -> dict:
350
769
  return {"error": f"Skill {id2} not found"}
351
770
 
352
771
  s1, s2 = dict(s1), dict(s2)
353
-
354
- # Decide which to keep
355
772
  if not keep_id:
356
- 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
357
774
  survivor = s1 if keep_id == id1 else s2
358
775
  donor = s2 if keep_id == id1 else s1
359
- donor_id = donor['id']
360
-
361
- # Merge tags
362
- try:
363
- tags1 = set(json.loads(survivor.get('tags', '[]')))
364
- tags2 = set(json.loads(donor.get('tags', '[]')))
365
- merged_tags = json.dumps(sorted(tags1 | tags2))
366
- except (json.JSONDecodeError, TypeError):
367
- merged_tags = survivor.get('tags', '[]')
368
776
 
369
- # Merge trigger patterns
370
- try:
371
- tp1 = set(json.loads(survivor.get('trigger_patterns', '[]')))
372
- tp2 = set(json.loads(donor.get('trigger_patterns', '[]')))
373
- merged_tp = json.dumps(sorted(tp1 | tp2))
374
- except (json.JSONDecodeError, TypeError):
375
- merged_tp = survivor.get('trigger_patterns', '[]')
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))
376
781
 
377
- # Merge source sessions
378
- try:
379
- ss1 = set(json.loads(survivor.get('source_sessions', '[]')))
380
- ss2 = set(json.loads(donor.get('source_sessions', '[]')))
381
- merged_ss = json.dumps(sorted(ss1 | ss2, key=str))
382
- except (json.JSONDecodeError, TypeError):
383
- merged_ss = survivor.get('source_sessions', '[]')
384
-
385
- # Merge linked learnings
386
- try:
387
- ll1 = set(json.loads(survivor.get('linked_learnings', '[]')))
388
- ll2 = set(json.loads(donor.get('linked_learnings', '[]')))
389
- merged_ll = json.dumps(sorted(ll1 | ll2, key=str))
390
- except (json.JSONDecodeError, TypeError):
391
- merged_ll = survivor.get('linked_learnings', '[]')
392
-
393
- # Merge counters
394
- merged_use = survivor['use_count'] + donor['use_count']
395
- merged_success = survivor['success_count'] + donor['success_count']
396
- merged_fail = survivor['fail_count'] + donor['fail_count']
397
- merged_trust = max(survivor['trust_score'], donor['trust_score'])
398
-
399
- # Update survivor
400
782
  conn.execute(
401
783
  """UPDATE skills SET
402
- tags = ?, trigger_patterns = ?, source_sessions = ?, linked_learnings = ?,
403
- use_count = ?, success_count = ?, fail_count = ?, trust_score = ?,
404
- updated_at = datetime('now')
405
- WHERE id = ?""",
406
- (merged_tags, merged_tp, merged_ss, merged_ll,
407
- 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
+ ),
408
798
  )
409
-
410
- # Move usage records from donor to survivor
411
- conn.execute("UPDATE skill_usage SET skill_id = ? WHERE skill_id = ?", (keep_id, donor_id))
412
-
413
- # Delete donor
414
- conn.execute("DELETE FROM skills WHERE id = ?", (donor_id,))
415
- 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"],))
416
802
  conn.commit()
417
803
 
418
804
  result = dict(conn.execute("SELECT * FROM skills WHERE id = ?", (keep_id,)).fetchone())
419
- 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"]
420
807
  return result
421
808
 
422
809
 
423
810
  def get_skill_stats() -> dict:
424
- """Get aggregate skill statistics."""
425
811
  conn = get_db()
426
812
  total = conn.execute("SELECT COUNT(*) FROM skills").fetchone()[0]
427
813
  by_level = {}
428
814
  for row in conn.execute("SELECT level, COUNT(*) as cnt FROM skills GROUP BY level").fetchall():
429
- by_level[row['level']] = row['cnt']
815
+ by_level[row["level"]] = row["cnt"]
430
816
 
431
817
  avg_trust = conn.execute(
432
818
  "SELECT AVG(trust_score) FROM skills WHERE level != 'archived'"
433
819
  ).fetchone()[0] or 0
434
-
435
820
  total_uses = conn.execute("SELECT COUNT(*) FROM skill_usage").fetchone()[0]
436
821
  success_rate = 0
437
822
  if total_uses > 0:
438
823
  successes = conn.execute("SELECT COUNT(*) FROM skill_usage WHERE success = 1").fetchone()[0]
439
824
  success_rate = round(successes / total_uses * 100, 1)
440
-
441
825
  recent_uses = conn.execute(
442
826
  "SELECT COUNT(*) FROM skill_usage WHERE created_at >= datetime('now', '-7 days')"
443
827
  ).fetchone()[0]
@@ -452,63 +836,410 @@ def get_skill_stats() -> dict:
452
836
  }
453
837
 
454
838
 
455
- def decay_unused_skills(dry_run: bool = False) -> dict:
456
- """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]
457
853
 
458
- Rules:
459
- - draft: no use in 30 days → trust = 0 → archived
460
- - published: no use in 90 days → trust -= 5
461
- - archived: no use in 60 days → purge (delete)
462
- """
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:
463
1197
  conn = get_db()
464
1198
  actions = {"decayed": [], "archived": [], "purged": []}
465
1199
 
466
- # Draft: 30 days no use → archive
467
- rows = conn.execute("""
468
- SELECT * FROM skills WHERE level = 'draft'
469
- AND (last_used_at IS NULL OR last_used_at < datetime('now', '-30 days'))
470
- AND created_at < datetime('now', '-30 days')
471
- """).fetchall()
472
- 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:
473
1206
  if not dry_run:
474
1207
  conn.execute(
475
1208
  "UPDATE skills SET level = 'archived', trust_score = 0, updated_at = datetime('now') WHERE id = ?",
476
- (r['id'],),
1209
+ (row["id"],),
477
1210
  )
478
- actions["archived"].append(r['id'])
479
-
480
- # Published: 90 days no use → trust -= 5
481
- rows = conn.execute("""
482
- SELECT * FROM skills WHERE level = 'published'
483
- AND (last_used_at IS NULL OR last_used_at < datetime('now', '-90 days'))
484
- """).fetchall()
485
- for r in rows:
486
- 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)
487
1219
  if not dry_run:
488
1220
  conn.execute(
489
1221
  "UPDATE skills SET trust_score = ?, updated_at = datetime('now') WHERE id = ?",
490
- (new_trust, r['id']),
1222
+ (new_trust, row["id"]),
491
1223
  )
492
1224
  if new_trust < TRUST_ARCHIVE_THRESHOLD:
493
1225
  conn.execute(
494
1226
  "UPDATE skills SET level = 'archived', updated_at = datetime('now') WHERE id = ?",
495
- (r['id'],),
1227
+ (row["id"],),
496
1228
  )
497
- actions["archived"].append(r['id'])
498
- actions["decayed"].append({"id": r['id'], "trust": f"{r['trust_score']} → {new_trust}"})
499
-
500
- # Archived: 60 days → purge
501
- rows = conn.execute("""
502
- SELECT * FROM skills WHERE level = 'archived'
503
- AND (last_used_at IS NULL OR last_used_at < datetime('now', '-60 days'))
504
- AND updated_at < datetime('now', '-60 days')
505
- """).fetchall()
506
- 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:
507
1238
  if not dry_run:
508
- conn.execute("DELETE FROM skill_usage WHERE skill_id = ?", (r['id'],))
509
- conn.execute("DELETE FROM skills WHERE id = ?", (r['id'],))
510
- conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (r['id'],))
511
- 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"])
512
1243
 
513
1244
  if not dry_run:
514
1245
  conn.commit()