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.
- package/README.md +65 -2
- package/bin/nexo-brain.js +208 -11
- package/bin/nexo.js +55 -0
- package/community/skills/.gitkeep +1 -0
- package/package.json +5 -2
- package/src/auto_update.py +158 -8
- package/src/cli.py +605 -0
- package/src/cognitive/_ingest.py +1 -1
- package/src/cognitive/_memory.py +4 -4
- package/src/crons/manifest.json +8 -0
- package/src/dashboard/app.py +700 -35
- package/src/dashboard/templates/adaptive.html +112 -218
- package/src/dashboard/templates/artifacts.html +133 -0
- package/src/dashboard/templates/backups.html +136 -0
- package/src/dashboard/templates/base.html +413 -0
- package/src/dashboard/templates/calendar.html +523 -654
- package/src/dashboard/templates/chat.html +356 -0
- package/src/dashboard/templates/claims.html +259 -0
- package/src/dashboard/templates/cortex.html +262 -0
- package/src/dashboard/templates/credentials.html +128 -0
- package/src/dashboard/templates/crons.html +370 -0
- package/src/dashboard/templates/dashboard.html +383 -578
- package/src/dashboard/templates/dreams.html +252 -0
- package/src/dashboard/templates/email.html +160 -0
- package/src/dashboard/templates/evolution.html +189 -0
- package/src/dashboard/templates/feed.html +249 -0
- package/src/dashboard/templates/followup_health.html +170 -0
- package/src/dashboard/templates/graph.html +191 -269
- package/src/dashboard/templates/guard.html +259 -0
- package/src/dashboard/templates/inbox.html +220 -346
- package/src/dashboard/templates/memory.html +317 -197
- package/src/dashboard/templates/operations.html +521 -698
- package/src/dashboard/templates/plugins.html +185 -0
- package/src/dashboard/templates/rules.html +246 -0
- package/src/dashboard/templates/sentiment.html +247 -0
- package/src/dashboard/templates/sessions.html +215 -182
- package/src/dashboard/templates/skills.html +329 -0
- package/src/dashboard/templates/somatic.html +68 -172
- package/src/dashboard/templates/triggers.html +133 -0
- package/src/dashboard/templates/trust.html +360 -0
- package/src/db/__init__.py +5 -0
- package/src/db/_schema.py +16 -1
- package/src/db/_sessions.py +22 -0
- package/src/db/_skills.py +980 -274
- package/src/doctor/__init__.py +1 -0
- package/src/doctor/formatters.py +52 -0
- package/src/doctor/models.py +44 -0
- package/src/doctor/orchestrator.py +42 -0
- package/src/doctor/providers/__init__.py +1 -0
- package/src/doctor/providers/boot.py +206 -0
- package/src/doctor/providers/deep.py +292 -0
- package/src/doctor/providers/runtime.py +686 -0
- package/src/hooks/post-compact.sh +5 -1
- package/src/hooks/pre-compact.sh +1 -1
- package/src/plugins/doctor.py +36 -0
- package/src/plugins/evolution.py +2 -1
- package/src/plugins/skills.py +135 -175
- package/src/requirements.txt +1 -0
- package/src/script_registry.py +322 -0
- package/src/scripts/deep-sleep/apply_findings.py +63 -48
- package/src/scripts/deep-sleep/extract-prompt.md +14 -0
- package/src/scripts/deep-sleep/synthesize-prompt.md +36 -0
- package/src/scripts/deep-sleep/synthesize.py +37 -1
- package/src/scripts/nexo-dashboard.sh +29 -0
- package/src/scripts/nexo-day-orchestrator.sh +139 -0
- package/src/scripts/nexo-evolution-run.py +2 -1
- package/src/scripts/nexo-learning-housekeep.py +1 -1
- package/src/scripts/nexo-watchdog.sh +1 -1
- package/src/server.py +9 -5
- package/src/skills/run-runtime-doctor/guide.md +12 -0
- package/src/skills/run-runtime-doctor/script.py +21 -0
- package/src/skills/run-runtime-doctor/skill.json +25 -0
- package/src/skills_runtime.py +347 -0
- package/src/tools_menu.py +3 -2
- package/src/tools_sessions.py +126 -0
- package/src/user_context.py +46 -0
- package/templates/nexo_helper.py +45 -0
- package/templates/script-template.py +44 -0
- package/templates/skill-script-template.py +39 -0
- 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 →
|
|
8
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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 =
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
69
|
-
lines.append(f"{
|
|
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
|
|
73
|
-
lines.append(f"- {
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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 =
|
|
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}
|
|
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(
|
|
487
|
+
return [dict(row) for row in rows]
|
|
122
488
|
|
|
123
489
|
|
|
124
|
-
def search_skills(query: str, level: str =
|
|
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 = [
|
|
130
|
-
placeholders =
|
|
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(
|
|
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
|
-
|
|
149
|
-
conditions.append("(name LIKE ? OR description LIKE ? OR tags LIKE ? OR trigger_patterns LIKE ?)")
|
|
150
|
-
params.extend([
|
|
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(
|
|
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
|
-
"
|
|
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
|
|
175
|
-
if
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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"] =
|
|
185
|
-
set_clause = ", ".join(f"{
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
if
|
|
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"{
|
|
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[
|
|
688
|
+
result["_promotion"] = promotion
|
|
274
689
|
return result
|
|
275
690
|
|
|
276
691
|
|
|
277
|
-
def match_skills(task: str, level: str =
|
|
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 = [
|
|
302
|
-
placeholders =
|
|
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}
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if
|
|
311
|
-
seen.add(
|
|
312
|
-
results.append(
|
|
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
|
|
321
|
-
|
|
725
|
+
for row in rows:
|
|
726
|
+
skill = dict(row)
|
|
727
|
+
if skill["id"] in seen:
|
|
322
728
|
continue
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
342
|
-
|
|
741
|
+
for row in rows:
|
|
742
|
+
skill = dict(row)
|
|
743
|
+
if skill["id"] in seen:
|
|
343
744
|
continue
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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 =
|
|
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[
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
436
|
-
conn.execute("
|
|
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
|
|
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[
|
|
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
|
|
481
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
(
|
|
1209
|
+
(row["id"],),
|
|
502
1210
|
)
|
|
503
|
-
actions["archived"].append(
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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,
|
|
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
|
-
(
|
|
1227
|
+
(row["id"],),
|
|
521
1228
|
)
|
|
522
|
-
actions["archived"].append(
|
|
523
|
-
actions["decayed"].append({"id":
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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 = ?", (
|
|
534
|
-
conn.execute("DELETE FROM skills WHERE id = ?", (
|
|
535
|
-
conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (
|
|
536
|
-
actions["purged"].append(
|
|
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()
|