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.
- package/README.md +77 -8
- package/bin/nexo-brain.js +230 -22
- 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 +709 -37
- 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 -652
- 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 +384 -572
- 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 -336
- package/src/dashboard/templates/memory.html +317 -197
- package/src/dashboard/templates/operations.html +498 -652
- 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 -171
- 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 +25 -1
- package/src/db/_sessions.py +22 -0
- package/src/db/_skills.py +983 -252
- 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/capture-tool-logs.sh +18 -4
- package/src/hooks/post-compact.sh +5 -1
- package/src/hooks/pre-compact.sh +1 -1
- package/src/plugin_loader.py +14 -0
- 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 -33
- package/src/scripts/deep-sleep/collect.py +38 -9
- 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,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 =
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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 =
|
|
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}
|
|
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(
|
|
487
|
+
return [dict(row) for row in rows]
|
|
97
488
|
|
|
98
489
|
|
|
99
|
-
def search_skills(query: str, level: str =
|
|
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 = [
|
|
105
|
-
placeholders =
|
|
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(
|
|
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
|
-
|
|
124
|
-
conditions.append("(name LIKE ? OR description LIKE ? OR tags LIKE ? OR trigger_patterns LIKE ?)")
|
|
125
|
-
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
|
+
|
|
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(
|
|
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
|
-
"
|
|
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
|
|
150
|
-
if
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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"] =
|
|
160
|
-
set_clause = ", ".join(f"{
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
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"}:
|
|
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"{
|
|
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[
|
|
688
|
+
result["_promotion"] = promotion
|
|
249
689
|
return result
|
|
250
690
|
|
|
251
691
|
|
|
252
|
-
def match_skills(task: str, level: str =
|
|
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 = [
|
|
277
|
-
placeholders =
|
|
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}
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if
|
|
286
|
-
seen.add(
|
|
287
|
-
results.append(
|
|
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
|
|
296
|
-
|
|
725
|
+
for row in rows:
|
|
726
|
+
skill = dict(row)
|
|
727
|
+
if skill["id"] in seen:
|
|
297
728
|
continue
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
317
|
-
|
|
741
|
+
for row in rows:
|
|
742
|
+
skill = dict(row)
|
|
743
|
+
if skill["id"] in seen:
|
|
318
744
|
continue
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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 =
|
|
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[
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
411
|
-
conn.execute("
|
|
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
|
|
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[
|
|
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
|
|
456
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
(
|
|
1209
|
+
(row["id"],),
|
|
477
1210
|
)
|
|
478
|
-
actions["archived"].append(
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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,
|
|
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
|
-
(
|
|
1227
|
+
(row["id"],),
|
|
496
1228
|
)
|
|
497
|
-
actions["archived"].append(
|
|
498
|
-
actions["decayed"].append({"id":
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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 = ?", (
|
|
509
|
-
conn.execute("DELETE FROM skills WHERE id = ?", (
|
|
510
|
-
conn.execute("DELETE FROM unified_search WHERE source = 'skill' AND source_id = ?", (
|
|
511
|
-
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"])
|
|
512
1243
|
|
|
513
1244
|
if not dry_run:
|
|
514
1245
|
conn.commit()
|