nexo-brain 5.3.13 → 5.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/nexo-brain.js +52 -1
  3. package/package.json +1 -1
  4. package/src/crons/sync.py +18 -4
  5. package/src/dashboard/static/favicon 2.svg +32 -0
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +40 -0
  8. package/src/dashboard/static/style 2.css +2458 -0
  9. package/src/dashboard/templates/adaptive 2.html +118 -0
  10. package/src/dashboard/templates/artifacts 2.html +133 -0
  11. package/src/dashboard/templates/backups 2.html +136 -0
  12. package/src/dashboard/templates/base 2.html +417 -0
  13. package/src/dashboard/templates/calendar 2.html +591 -0
  14. package/src/dashboard/templates/chat 2.html +356 -0
  15. package/src/dashboard/templates/claims 2.html +259 -0
  16. package/src/dashboard/templates/cortex 2.html +321 -0
  17. package/src/dashboard/templates/credentials 2.html +128 -0
  18. package/src/dashboard/templates/crons 2.html +370 -0
  19. package/src/dashboard/templates/dashboard 2.html +494 -0
  20. package/src/dashboard/templates/dreams 2.html +252 -0
  21. package/src/dashboard/templates/email 2.html +160 -0
  22. package/src/dashboard/templates/evolution 2.html +189 -0
  23. package/src/dashboard/templates/feed 2.html +249 -0
  24. package/src/dashboard/templates/followup_health 2.html +170 -0
  25. package/src/dashboard/templates/graph 2.html +201 -0
  26. package/src/dashboard/templates/guard 2.html +259 -0
  27. package/src/dashboard/templates/inbox 2.html +251 -0
  28. package/src/dashboard/templates/memory 2.html +420 -0
  29. package/src/dashboard/templates/operations 2.html +608 -0
  30. package/src/dashboard/templates/plugins 2.html +185 -0
  31. package/src/dashboard/templates/protocol 2.html +199 -0
  32. package/src/dashboard/templates/rules 2.html +246 -0
  33. package/src/dashboard/templates/sentiment 2.html +247 -0
  34. package/src/dashboard/templates/sessions 2.html +218 -0
  35. package/src/dashboard/templates/skills 2.html +329 -0
  36. package/src/dashboard/templates/somatic 2.html +73 -0
  37. package/src/dashboard/templates/triggers 2.html +133 -0
  38. package/src/dashboard/templates/trust 2.html +360 -0
  39. package/src/db/__init__ 2.py +259 -0
  40. package/src/db/_core 2.py +437 -0
  41. package/src/db/_credentials 2.py +124 -0
  42. package/src/db/_entities.py +1 -1
  43. package/src/db/_episodic 2.py +762 -0
  44. package/src/db/_evolution 2.py +54 -0
  45. package/src/db/_fts 2.py +406 -0
  46. package/src/db/_goal_profiles 2.py +376 -0
  47. package/src/db/_hot_context 2.py +660 -0
  48. package/src/db/_outcomes 2.py +800 -0
  49. package/src/db/_personal_scripts 2.py +582 -0
  50. package/src/db/_sessions 2.py +330 -0
  51. package/src/db/_tasks 2.py +91 -0
  52. package/src/db/_watchers 2.py +173 -0
  53. package/src/doctor/formatters 2.py +52 -0
  54. package/src/doctor/models 2.py +69 -0
  55. package/src/doctor/planes 2.py +87 -0
  56. package/src/doctor/providers/__init__ 2.py +1 -0
  57. package/src/doctor/providers/deep 2.py +367 -0
  58. package/src/evolution_cycle 2.py +519 -0
  59. package/src/hooks/auto_capture 2.py +208 -0
  60. package/src/hooks/caffeinate-guard 2.sh +8 -0
  61. package/src/hooks/capture-session 2.sh +21 -0
  62. package/src/hooks/capture-tool-logs 2.sh +158 -0
  63. package/src/hooks/daily-briefing-check 2.sh +33 -0
  64. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  65. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  66. package/src/hooks/inbox-hook 2.sh +76 -0
  67. package/src/hooks/post-compact 2.sh +152 -0
  68. package/src/hooks/pre-compact 2.sh +169 -0
  69. package/src/hooks/protocol-guardrail 2.sh +10 -0
  70. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  71. package/src/hooks/session-stop 2.sh +52 -0
  72. package/src/kg_populate 2.py +292 -0
  73. package/src/maintenance 2.py +53 -0
  74. package/src/memory_backends 2.py +71 -0
  75. package/src/migrate_embeddings 2.py +124 -0
  76. package/src/nexo_sdk 2.py +103 -0
  77. package/src/observability 2.py +199 -0
  78. package/src/plugin_loader 2.py +217 -0
  79. package/src/plugins/__init__ 2.py +0 -0
  80. package/src/plugins/agents.py +10 -3
  81. package/src/plugins/artifact_registry 2.py +450 -0
  82. package/src/plugins/backup 2.py +127 -0
  83. package/src/plugins/claims_tools 2.py +119 -0
  84. package/src/plugins/cognitive_memory 2.py +609 -0
  85. package/src/plugins/core_rules 2.py +252 -0
  86. package/src/plugins/cortex 2.py +1155 -0
  87. package/src/plugins/entities 2.py +67 -0
  88. package/src/plugins/episodic_memory 2.py +560 -0
  89. package/src/plugins/evolution 2.py +167 -0
  90. package/src/plugins/goal_engine 2.py +142 -0
  91. package/src/plugins/guard 2.py +862 -0
  92. package/src/plugins/impact 2.py +29 -0
  93. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  94. package/src/plugins/media_memory_tools 2.py +98 -0
  95. package/src/plugins/memory_export 2.py +196 -0
  96. package/src/plugins/outcomes 2.py +130 -0
  97. package/src/plugins/personal_scripts 2.py +117 -0
  98. package/src/plugins/preferences 2.py +47 -0
  99. package/src/plugins/protocol 2.py +1449 -0
  100. package/src/plugins/schedule.py +2 -1
  101. package/src/plugins/simple_api 2.py +106 -0
  102. package/src/plugins/skills 2.py +341 -0
  103. package/src/plugins/state_watchers 2.py +79 -0
  104. package/src/plugins/update 2.py +986 -0
  105. package/src/plugins/user_state_tools 2.py +43 -0
  106. package/src/plugins/workflow 2.py +588 -0
  107. package/src/protocol_settings 2.py +59 -0
  108. package/src/public_contribution 2.py +466 -0
  109. package/src/public_evolution_queue 2.py +241 -0
  110. package/src/requirements 2.txt +14 -0
  111. package/src/requirements.txt +1 -1
  112. package/src/retroactive_learnings 2.py +373 -0
  113. package/src/rules/__init__ 2.py +0 -0
  114. package/src/rules/core-rules 2.json +331 -0
  115. package/src/rules/migrate 2.py +207 -0
  116. package/src/runtime_power 2.py +874 -0
  117. package/src/runtime_power.py +18 -1
  118. package/src/script_registry 2.py +1559 -0
  119. package/src/scripts/check-context 2.py +272 -0
  120. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  121. package/src/scripts/deep-sleep/collect 2.py +928 -0
  122. package/src/scripts/deep-sleep/extract 2.py +330 -0
  123. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  124. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  125. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  126. package/src/scripts/nexo-agent-run 2.py +75 -0
  127. package/src/scripts/nexo-auto-update 2.py +6 -0
  128. package/src/scripts/nexo-backup 2.sh +25 -0
  129. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  130. package/src/scripts/nexo-catchup 2.py +300 -0
  131. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  132. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  133. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  134. package/src/scripts/nexo-cron-wrapper.sh +7 -0
  135. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  136. package/src/scripts/nexo-dashboard 2.sh +29 -0
  137. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  138. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  139. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  140. package/src/scripts/nexo-hook-record 2.py +42 -0
  141. package/src/scripts/nexo-immune 2.py +936 -0
  142. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  143. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  144. package/src/scripts/nexo-install 2.py +6 -0
  145. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  146. package/src/scripts/nexo-learning-validator 2.py +266 -0
  147. package/src/scripts/nexo-migrate 2.py +260 -0
  148. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  149. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  150. package/src/scripts/nexo-pre-commit 2.py +120 -0
  151. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  152. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  153. package/src/scripts/nexo-reflection 2.py +256 -0
  154. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  155. package/src/scripts/nexo-sleep 2.py +631 -0
  156. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  157. package/src/scripts/nexo-sync-clients 2.py +16 -0
  158. package/src/scripts/nexo-synthesis 2.py +475 -0
  159. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  160. package/src/scripts/nexo-update 2.sh +306 -0
  161. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  162. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  163. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  164. package/src/server 2.py +1296 -0
  165. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  166. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  167. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  168. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  169. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  170. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  171. package/src/skills/run-release-final-audit/script 2.py +259 -0
  172. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  173. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  174. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  175. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  176. package/src/skills_runtime 2.py +932 -0
  177. package/src/state_watchers_runtime 2.py +475 -0
  178. package/src/storage_router 2.py +32 -0
  179. package/src/system_catalog 2.py +786 -0
  180. package/src/tools_coordination 2.py +103 -0
  181. package/src/tools_credentials 2.py +68 -0
  182. package/src/tools_drive 2.py +487 -0
  183. package/src/tools_hot_context 2.py +163 -0
  184. package/src/tools_learnings 2.py +612 -0
  185. package/src/tools_menu 2.py +229 -0
  186. package/src/tools_reminders 2.py +88 -0
  187. package/src/tools_reminders_crud 2.py +363 -0
  188. package/src/tools_sessions 2.py +1054 -0
  189. package/src/tools_system_catalog 2.py +19 -0
  190. package/src/tools_task_history 2.py +57 -0
  191. package/src/tools_transcripts 2.py +98 -0
  192. package/src/transcript_utils 2.py +412 -0
  193. package/src/user_context 2.py +46 -0
  194. package/src/user_data_portability 2.py +328 -0
  195. package/src/user_state_model 2.py +170 -0
  196. package/templates/CLAUDE.md 2.template +108 -0
  197. package/templates/CODEX.AGENTS.md 2.template +66 -0
  198. package/templates/launchagents/README 2.md +132 -0
  199. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  200. package/templates/launchagents/com.nexo.auto-close-sessions.plist +1 -1
  201. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  202. package/templates/launchagents/com.nexo.catchup.plist +1 -1
  203. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  204. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  205. package/templates/launchagents/com.nexo.dashboard.plist +1 -1
  206. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  207. package/templates/launchagents/com.nexo.deep-sleep.plist +1 -1
  208. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  209. package/templates/launchagents/com.nexo.evolution.plist +1 -1
  210. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  211. package/templates/launchagents/com.nexo.followup-hygiene.plist +1 -1
  212. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  213. package/templates/launchagents/com.nexo.immune.plist +1 -1
  214. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  215. package/templates/launchagents/com.nexo.postmortem.plist +1 -1
  216. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  217. package/templates/launchagents/com.nexo.self-audit.plist +1 -1
  218. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  219. package/templates/launchagents/com.nexo.synthesis.plist +1 -1
  220. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  221. package/templates/launchagents/com.nexo.watchdog.plist +1 -1
  222. package/templates/nexo_helper 2.py +301 -0
  223. package/templates/openclaw 2.json +13 -0
  224. package/templates/plugin-template 2.py +40 -0
  225. package/templates/script-template 2.py +59 -0
  226. package/templates/script-template 2.sh +13 -0
  227. package/templates/script-template.py +5 -4
  228. package/templates/skill-script-template 2.py +48 -0
  229. package/templates/skill-script-template.py +2 -1
  230. package/templates/skill-template 2.md +33 -0
@@ -0,0 +1,932 @@
1
+ from __future__ import annotations
2
+ """Runtime helpers for Skills v2.
3
+
4
+ This module is the single execution gate for skills. It decides:
5
+ - guide vs execute vs hybrid mode
6
+ - whether a skill is allowed to run
7
+ - how parameters are validated and rendered
8
+ - how execution is routed through the stable `nexo scripts run` CLI
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from db import (
19
+ approve_skill,
20
+ capture_outcome_pattern,
21
+ collect_skill_improvement_candidates,
22
+ collect_scriptable_skill_candidates,
23
+ create_skill,
24
+ get_featured_skills,
25
+ get_skill,
26
+ get_skill_outcome_evidence,
27
+ get_skill_execution_spec,
28
+ init_db,
29
+ list_outcome_pattern_candidates,
30
+ list_skill_outcome_reviews,
31
+ materialize_personal_skill_definition,
32
+ record_skill_usage,
33
+ render_command_template,
34
+ sync_skill_directories,
35
+ update_skill,
36
+ )
37
+ from script_registry import doctor_script
38
+
39
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
40
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
41
+ VALID_LEVELS = {"trace", "draft", "published", "stable", "archived"}
42
+
43
+
44
+ def _parse_params(params) -> dict:
45
+ if isinstance(params, dict):
46
+ return params
47
+ if isinstance(params, str):
48
+ text = params.strip()
49
+ if not text:
50
+ return {}
51
+ return json.loads(text)
52
+ return {}
53
+
54
+
55
+ def _ensure_ready():
56
+ init_db()
57
+
58
+
59
+ def _resolve_mode(requested: str, skill: dict) -> str:
60
+ mode = (requested or "auto").strip().lower()
61
+ if mode in {"guide", "execute", "hybrid"}:
62
+ return mode
63
+ effective = str(skill.get("mode", "") or "").strip().lower()
64
+ if effective in {"guide", "execute", "hybrid"}:
65
+ return effective
66
+ if skill.get("file_path") and skill.get("content"):
67
+ return "hybrid"
68
+ if skill.get("file_path"):
69
+ return "execute"
70
+ return "guide"
71
+
72
+
73
+ def _summarize_skill(skill: dict) -> str:
74
+ steps = []
75
+ gotchas = []
76
+ try:
77
+ steps = json.loads(skill.get("steps", "[]"))
78
+ except json.JSONDecodeError:
79
+ pass
80
+ try:
81
+ gotchas = json.loads(skill.get("gotchas", "[]"))
82
+ except json.JSONDecodeError:
83
+ pass
84
+
85
+ lines = [
86
+ f"[{skill['id']}] {skill['name']}",
87
+ skill.get("description", "") or "(no description)",
88
+ ]
89
+ if steps:
90
+ lines.append("Steps:")
91
+ for index, step in enumerate(steps[:6], 1):
92
+ lines.append(f"{index}. {step}")
93
+ elif skill.get("content"):
94
+ lines.append(skill["content"][:800])
95
+ if gotchas:
96
+ lines.append("Gotchas:")
97
+ for gotcha in gotchas[:4]:
98
+ lines.append(f"- {gotcha}")
99
+ return "\n".join(lines).strip()
100
+
101
+
102
+ def _resolve_cli_command() -> list[str]:
103
+ installed = NEXO_HOME / "bin" / "nexo"
104
+ if installed.is_file():
105
+ return [str(installed)]
106
+ return [sys.executable, str(NEXO_CODE / "cli.py")]
107
+
108
+
109
+ def _run_skill_script(skill: dict, argv: list[str], timeout: int = 300) -> dict:
110
+ if not argv:
111
+ return {"returncode": 1, "stdout": "", "stderr": "No command to execute"}
112
+
113
+ env = {
114
+ **os.environ,
115
+ "NEXO_HOME": str(NEXO_HOME),
116
+ "NEXO_CODE": str(NEXO_CODE),
117
+ "NEXO_SKILL_ID": skill["id"],
118
+ "NEXO_SKILL_NAME": skill["name"],
119
+ }
120
+
121
+ cli_cmd = _resolve_cli_command()
122
+ cmd = [*cli_cmd, "scripts", "run", argv[0], *argv[1:]]
123
+ try:
124
+ result = subprocess.run(
125
+ cmd,
126
+ capture_output=True,
127
+ text=True,
128
+ timeout=timeout,
129
+ env=env,
130
+ )
131
+ return {
132
+ "returncode": result.returncode,
133
+ "stdout": result.stdout,
134
+ "stderr": result.stderr,
135
+ "command": cmd,
136
+ }
137
+ except subprocess.TimeoutExpired:
138
+ return {
139
+ "returncode": 124,
140
+ "stdout": "",
141
+ "stderr": f"Skill execution timed out after {timeout}s",
142
+ "command": cmd,
143
+ }
144
+
145
+
146
+ def get_featured_skill_summaries(limit: int = 5) -> list[dict]:
147
+ _ensure_ready()
148
+ sync_skill_directories()
149
+ featured = []
150
+ for skill in get_featured_skills(limit=limit):
151
+ triggers = []
152
+ try:
153
+ triggers = json.loads(skill.get("trigger_patterns", "[]"))
154
+ except json.JSONDecodeError:
155
+ pass
156
+ featured.append(
157
+ {
158
+ "id": skill["id"],
159
+ "name": skill["name"],
160
+ "mode": skill.get("mode", "guide"),
161
+ "execution_level": skill.get("execution_level", "none"),
162
+ "source_kind": skill.get("source_kind", "personal"),
163
+ "trust_score": skill.get("trust_score", 0),
164
+ "trigger_patterns": triggers[:3],
165
+ "outcome_review": {
166
+ "has_evidence": bool((skill.get("_outcome_review") or {}).get("has_evidence")),
167
+ "recommended_action": (skill.get("_outcome_review") or {}).get("recommended_action", "observe"),
168
+ "success_rate": (skill.get("_outcome_review") or {}).get("success_rate"),
169
+ "resolved_outcomes": (skill.get("_outcome_review") or {}).get("resolved_outcomes", 0),
170
+ "ranking_weight": float(skill.get("_outcome_rank", 0.0)),
171
+ },
172
+ }
173
+ )
174
+ return featured
175
+
176
+
177
+ def _skill_json_list(skill: dict, key: str) -> list:
178
+ try:
179
+ value = json.loads(skill.get(key, "[]"))
180
+ except json.JSONDecodeError:
181
+ value = []
182
+ return value if isinstance(value, list) else []
183
+
184
+
185
+ def _outcome_skill_id(candidate: dict) -> str:
186
+ raw_parts = [
187
+ candidate.get("area", ""),
188
+ candidate.get("task_type", ""),
189
+ candidate.get("goal_profile_id", ""),
190
+ candidate.get("selected_choice", ""),
191
+ ]
192
+ chunks = []
193
+ for part in raw_parts:
194
+ cleaned = re.sub(r"[^A-Z0-9]+", "-", str(part or "").upper()).strip("-")
195
+ if cleaned:
196
+ chunks.append(cleaned[:12])
197
+ suffix = "-".join(chunks[:4]) or "GENERAL"
198
+ return f"SK-OUTCOME-{suffix}"
199
+
200
+
201
+ def _outcome_pattern_to_skill_payload(candidate: dict, learning_id: int) -> dict:
202
+ selected_choice = candidate.get("selected_choice", "")
203
+ context_label = candidate.get("context_label", "contexto general")
204
+ evidence = candidate.get("evidence") or []
205
+ evidence_summary = ", ".join(
206
+ f"eval#{item.get('evaluation_id')}/outcome#{item.get('outcome_id')}:{item.get('status')}"
207
+ for item in evidence[:5]
208
+ )
209
+ description = (
210
+ f"Draft skill candidate derived from {candidate.get('resolved_outcomes', 0)} resolved outcomes "
211
+ f"for '{selected_choice}' in {context_label}."
212
+ )
213
+ steps = [
214
+ f"Confirm that the current case matches {context_label}.",
215
+ f"Use '{selected_choice}' as the default starting strategy.",
216
+ "Check fresh constraints or evidence that could invalidate the historical pattern.",
217
+ "Link and evaluate the resulting outcome so the evidence base keeps improving.",
218
+ ]
219
+ gotchas = [
220
+ "Do not apply this skill if current evidence contradicts the pattern.",
221
+ "Keep outcomes linked; a pattern without fresh feedback should not gain trust forever.",
222
+ ]
223
+ content = "\n".join(
224
+ [
225
+ f"# Outcome pattern skill candidate — {selected_choice}",
226
+ "",
227
+ description,
228
+ "",
229
+ "## Evidence",
230
+ f"- Success rate: {candidate.get('success_rate', 0.0):.3f}",
231
+ f"- Resolved outcomes: {candidate.get('resolved_outcomes', 0)}",
232
+ f"- Context: {context_label}",
233
+ f"- Evidence refs: {evidence_summary or 'none'}",
234
+ "",
235
+ "## Steps",
236
+ *(f"{index}. {step}" for index, step in enumerate(steps, 1)),
237
+ "",
238
+ "## Gotchas",
239
+ *(f"- {gotcha}" for gotcha in gotchas),
240
+ "",
241
+ ]
242
+ )
243
+ tags = [
244
+ "outcomes-derived",
245
+ str(candidate.get("area") or "").strip(),
246
+ str(candidate.get("task_type") or "").strip(),
247
+ str(candidate.get("goal_profile_id") or "").strip(),
248
+ ]
249
+ trigger_patterns = [
250
+ str(candidate.get("selected_choice") or "").strip(),
251
+ str(candidate.get("area") or "").strip(),
252
+ str(candidate.get("task_type") or "").strip(),
253
+ ]
254
+ return {
255
+ "id": _outcome_skill_id(candidate),
256
+ "name": f"Outcome Pattern: {selected_choice}",
257
+ "description": description,
258
+ "level": "draft",
259
+ "mode": "guide",
260
+ "tags": [item for item in tags if item],
261
+ "trigger_patterns": [item for item in trigger_patterns if item],
262
+ "linked_learnings": [int(learning_id)],
263
+ "steps": steps,
264
+ "gotchas": gotchas,
265
+ "content": content,
266
+ }
267
+
268
+
269
+ def test_skill(skill_id: str, params=None, mode: str = "auto", context: str = "") -> dict:
270
+ result = apply_skill(skill_id, params=params, mode=mode, dry_run=True, context=context or "skill_test")
271
+ result["tested"] = True
272
+ result["test_kind"] = "dry_run"
273
+ return result
274
+
275
+
276
+ def promote_skill(skill_id: str, target_level: str = "published", reason: str = "") -> dict:
277
+ _ensure_ready()
278
+ sync_skill_directories()
279
+ skill = get_skill(skill_id)
280
+ if not skill:
281
+ return {"ok": False, "error": f"Skill {skill_id} not found"}
282
+ clean_target = str(target_level or "published").strip().lower()
283
+ if clean_target not in VALID_LEVELS:
284
+ return {"ok": False, "error": f"Unsupported target_level: {target_level}"}
285
+ if clean_target == "archived":
286
+ return {"ok": False, "error": "Use retire_skill to archive skills explicitly"}
287
+ outcome_review = get_skill_outcome_evidence(skill_id)
288
+ if outcome_review.get("has_evidence"):
289
+ recommended = outcome_review.get("recommended_action")
290
+ if clean_target == "published" and recommended not in {"promote_published", "promote_stable"}:
291
+ return {
292
+ "ok": False,
293
+ "error": "Outcome evidence does not yet support promotion to published",
294
+ "outcome_review": outcome_review,
295
+ }
296
+ if clean_target == "stable" and recommended != "promote_stable":
297
+ return {
298
+ "ok": False,
299
+ "error": "Outcome evidence does not yet support promotion to stable",
300
+ "outcome_review": outcome_review,
301
+ }
302
+ updated = update_skill(skill_id, level=clean_target)
303
+ if "error" in updated:
304
+ return {"ok": False, "error": updated["error"]}
305
+ return {
306
+ "ok": True,
307
+ "skill_id": skill_id,
308
+ "previous_level": skill.get("level", ""),
309
+ "level": updated.get("level", clean_target),
310
+ "reason": str(reason or "").strip(),
311
+ "outcome_review": outcome_review,
312
+ }
313
+
314
+
315
+ def retire_skill(skill_id: str, replacement_id: str = "", reason: str = "") -> dict:
316
+ _ensure_ready()
317
+ sync_skill_directories()
318
+ skill = get_skill(skill_id)
319
+ if not skill:
320
+ return {"ok": False, "error": f"Skill {skill_id} not found"}
321
+ replacement = None
322
+ clean_replacement = str(replacement_id or "").strip()
323
+ if clean_replacement:
324
+ replacement = get_skill(clean_replacement)
325
+ if not replacement:
326
+ return {"ok": False, "error": f"Replacement skill {clean_replacement} not found"}
327
+ updated = update_skill(skill_id, level="archived")
328
+ if "error" in updated:
329
+ return {"ok": False, "error": updated["error"]}
330
+ return {
331
+ "ok": True,
332
+ "skill_id": skill_id,
333
+ "level": updated.get("level", "archived"),
334
+ "replacement_id": clean_replacement,
335
+ "reason": str(reason or "").strip(),
336
+ "outcome_review": get_skill_outcome_evidence(skill_id),
337
+ }
338
+
339
+
340
+ def review_skill_outcomes(skill_id: str, auto_apply: bool = False) -> dict:
341
+ _ensure_ready()
342
+ sync_skill_directories()
343
+ skill = get_skill(skill_id)
344
+ if not skill:
345
+ return {"ok": False, "error": f"Skill {skill_id} not found"}
346
+
347
+ review = get_skill_outcome_evidence(skill_id)
348
+ if "error" in review:
349
+ return {"ok": False, "error": review["error"]}
350
+
351
+ result = {
352
+ "ok": True,
353
+ "skill_id": skill_id,
354
+ "level": skill.get("level", ""),
355
+ "review": review,
356
+ "auto_applied": False,
357
+ "applied_action": "",
358
+ }
359
+ if not auto_apply:
360
+ return result
361
+
362
+ action = review.get("recommended_action")
363
+ target_level = ""
364
+ if action == "promote_published":
365
+ target_level = "published"
366
+ elif action == "promote_stable":
367
+ target_level = "stable"
368
+ elif action == "retire":
369
+ target_level = "archived"
370
+ if not target_level:
371
+ return result
372
+
373
+ updated = update_skill(skill_id, level=target_level)
374
+ if "error" in updated:
375
+ return {"ok": False, "error": updated["error"], "review": review}
376
+ result["auto_applied"] = True
377
+ result["applied_action"] = action
378
+ result["level"] = updated.get("level", target_level)
379
+ result["skill"] = updated
380
+ return result
381
+
382
+
383
+ def compose_skills(
384
+ *,
385
+ new_skill_id: str,
386
+ name: str,
387
+ component_ids: list[str],
388
+ description: str = "",
389
+ level: str = "draft",
390
+ mode: str = "guide",
391
+ tags: list[str] | None = None,
392
+ trigger_patterns: list[str] | None = None,
393
+ ) -> dict:
394
+ _ensure_ready()
395
+ sync_skill_directories()
396
+ if get_skill(new_skill_id):
397
+ return {"ok": False, "error": f"Skill {new_skill_id} already exists"}
398
+ components = []
399
+ for skill_id in component_ids:
400
+ skill = get_skill(skill_id)
401
+ if not skill:
402
+ return {"ok": False, "error": f"Component skill {skill_id} not found"}
403
+ components.append(skill)
404
+ if not components:
405
+ return {"ok": False, "error": "At least one component skill is required"}
406
+
407
+ merged_steps: list[str] = []
408
+ merged_gotchas: list[str] = []
409
+ merged_tags = set(tags or [])
410
+ merged_triggers = set(trigger_patterns or [])
411
+ linked_learnings = set()
412
+ source_sessions = set()
413
+ content_lines = [f"# {name}", "", description or "Composite skill built from existing NEXO skills.", "", "## Components"]
414
+ for skill in components:
415
+ content_lines.append(f"- {skill['id']}: {skill['name']}")
416
+ for step in _skill_json_list(skill, "steps"):
417
+ if step and step not in merged_steps:
418
+ merged_steps.append(step)
419
+ for gotcha in _skill_json_list(skill, "gotchas"):
420
+ if gotcha and gotcha not in merged_gotchas:
421
+ merged_gotchas.append(gotcha)
422
+ for trigger in _skill_json_list(skill, "trigger_patterns"):
423
+ if trigger:
424
+ merged_triggers.add(trigger)
425
+ for tag in _skill_json_list(skill, "tags"):
426
+ if tag:
427
+ merged_tags.add(tag)
428
+ for item in _skill_json_list(skill, "linked_learnings"):
429
+ if item:
430
+ linked_learnings.add(item)
431
+ for item in _skill_json_list(skill, "source_sessions"):
432
+ if item:
433
+ source_sessions.add(item)
434
+
435
+ if merged_steps:
436
+ content_lines.extend(["", "## Steps"])
437
+ for index, step in enumerate(merged_steps, 1):
438
+ content_lines.append(f"{index}. {step}")
439
+ if merged_gotchas:
440
+ content_lines.extend(["", "## Gotchas"])
441
+ for gotcha in merged_gotchas:
442
+ content_lines.append(f"- {gotcha}")
443
+
444
+ created = create_skill(
445
+ skill_id=new_skill_id,
446
+ name=name,
447
+ description=description or f"Composite skill built from {', '.join(component_ids)}",
448
+ level=level,
449
+ tags=sorted(merged_tags),
450
+ trigger_patterns=sorted(merged_triggers),
451
+ source_sessions=sorted(source_sessions),
452
+ linked_learnings=sorted(linked_learnings),
453
+ steps=merged_steps,
454
+ gotchas=merged_gotchas,
455
+ content="\n".join(content_lines).strip() + "\n",
456
+ mode=mode,
457
+ source_kind="personal",
458
+ )
459
+ if "error" in created:
460
+ return {"ok": False, "error": created["error"]}
461
+ return {
462
+ "ok": True,
463
+ "skill_id": new_skill_id,
464
+ "component_ids": component_ids,
465
+ "level": created.get("level", level),
466
+ "mode": created.get("mode", mode),
467
+ }
468
+
469
+
470
+ def apply_skill(skill_id: str, params=None, mode: str = "auto", dry_run: bool = False, context: str = "") -> dict:
471
+ _ensure_ready()
472
+ sync_skill_directories()
473
+ skill = get_skill(skill_id)
474
+ if not skill:
475
+ return {"ok": False, "error": f"Skill {skill_id} not found"}
476
+
477
+ effective_mode = _resolve_mode(mode, skill)
478
+ response = {
479
+ "ok": True,
480
+ "skill_id": skill["id"],
481
+ "skill_name": skill["name"],
482
+ "requested_mode": mode,
483
+ "resolved_mode": effective_mode,
484
+ "approval_state": {
485
+ "approval_required": bool(skill.get("approval_required", 0)),
486
+ "approved_at": skill.get("approved_at", ""),
487
+ "execution_level": skill.get("execution_level", "none"),
488
+ },
489
+ }
490
+
491
+ if effective_mode in {"guide", "hybrid"}:
492
+ response["guide_summary"] = _summarize_skill(skill)
493
+
494
+ if effective_mode in {"execute", "hybrid"}:
495
+ exec_spec = get_skill_execution_spec(skill_id)
496
+ if "error" in exec_spec:
497
+ response["ok"] = False
498
+ response["error"] = exec_spec["error"]
499
+ return response
500
+
501
+ if not skill.get("file_path"):
502
+ response["ok"] = False
503
+ response["error"] = f"Skill {skill_id} has no executable script"
504
+ return response
505
+
506
+ if exec_spec["execution_level"] in {"read-only", "local", "remote"} and not skill.get("approved_at"):
507
+ skill = approve_skill(skill_id, execution_level=exec_spec["execution_level"], approved_by="system:auto")
508
+ response["approval_state"] = {
509
+ "approval_required": bool(skill.get("approval_required", 0)),
510
+ "approved_at": skill.get("approved_at", ""),
511
+ "execution_level": skill.get("execution_level", exec_spec["execution_level"]),
512
+ }
513
+
514
+ doctor = doctor_script(skill["file_path"])
515
+ response["script_doctor"] = doctor
516
+ if doctor["status"] == "fail":
517
+ response["ok"] = False
518
+ response["error"] = "Skill script failed validation"
519
+ return response
520
+
521
+ rendered = render_command_template(skill, _parse_params(params))
522
+ if not rendered.get("ok"):
523
+ response["ok"] = False
524
+ response["error"] = "Invalid skill parameters"
525
+ response["param_errors"] = rendered.get("errors", [])
526
+ return response
527
+
528
+ argv = rendered["argv"] or [skill["file_path"]]
529
+ response["resolved_params"] = rendered["params"]
530
+ response["script_command"] = argv
531
+ if dry_run:
532
+ response["dry_run"] = True
533
+ return response
534
+
535
+ execution = _run_skill_script(skill, argv)
536
+ response["execution_result"] = execution
537
+ success = execution["returncode"] == 0
538
+ record = record_skill_usage(
539
+ skill_id=skill_id,
540
+ success=success,
541
+ context=context or skill["name"],
542
+ notes=(execution["stderr"] or execution["stdout"])[:500],
543
+ )
544
+ response["usage_recorded"] = {
545
+ "success": success,
546
+ "trust_score": record.get("trust_score"),
547
+ "level": record.get("level"),
548
+ "promotion": record.get("_promotion"),
549
+ }
550
+ if not success:
551
+ response["ok"] = False
552
+ response["error"] = f"Skill execution failed with exit {execution['returncode']}"
553
+
554
+ return response
555
+
556
+
557
+ def sync_skills() -> dict:
558
+ _ensure_ready()
559
+ return sync_skill_directories()
560
+
561
+
562
+ def approve_skill_execution(skill_id: str, execution_level: str = "", approved_by: str = "") -> dict:
563
+ _ensure_ready()
564
+ return approve_skill(skill_id, execution_level=execution_level, approved_by=approved_by)
565
+
566
+
567
+ def list_evolution_candidates() -> dict:
568
+ _ensure_ready()
569
+ sync_skill_directories()
570
+ outcome_patterns = [
571
+ candidate
572
+ for candidate in list_outcome_pattern_candidates(limit=20)
573
+ if candidate.get("candidate_type") == "reinforce_strategy" and candidate.get("suggested_skill_candidate")
574
+ ]
575
+ return {
576
+ "scriptable": collect_scriptable_skill_candidates(),
577
+ "improvements": collect_skill_improvement_candidates(),
578
+ "outcome_patterns": outcome_patterns,
579
+ "outcome_lifecycle": list_skill_outcome_reviews(limit=20, actionable_only=True),
580
+ }
581
+
582
+
583
+ def materialize_outcome_pattern_skill(pattern_key: str) -> dict:
584
+ _ensure_ready()
585
+ sync_skill_directories()
586
+ candidates = list_outcome_pattern_candidates(limit=200)
587
+ candidate = next((item for item in candidates if item.get("pattern_key") == pattern_key), None)
588
+ if not candidate:
589
+ return {"ok": False, "error": "Outcome pattern candidate not found"}
590
+ if candidate.get("candidate_type") != "reinforce_strategy":
591
+ return {"ok": False, "error": "Only reinforce_strategy patterns can seed a skill"}
592
+ if not candidate.get("suggested_skill_candidate"):
593
+ return {"ok": False, "error": "Pattern is not strong enough yet to seed a skill draft"}
594
+
595
+ learning_result = capture_outcome_pattern(pattern_key, target="learning", category="outcomes")
596
+ if "error" in learning_result:
597
+ return {"ok": False, "error": learning_result["error"]}
598
+ learning = learning_result.get("learning") or {}
599
+ skill_id = _outcome_skill_id(candidate)
600
+ existing = get_skill(skill_id)
601
+ if existing:
602
+ return {
603
+ "ok": True,
604
+ "created": False,
605
+ "skill": existing,
606
+ "candidate": candidate,
607
+ "learning": learning,
608
+ }
609
+
610
+ payload = _outcome_pattern_to_skill_payload(candidate, int(learning.get("id", 0) or 0))
611
+ created = materialize_personal_skill_definition(payload)
612
+ if "error" in created:
613
+ return {"ok": False, "error": created["error"]}
614
+ return {
615
+ "ok": True,
616
+ "created": True,
617
+ "skill": created,
618
+ "candidate": candidate,
619
+ "learning": learning,
620
+ }
621
+
622
+
623
+ def auto_promote_outcome_patterns_to_skills(
624
+ *,
625
+ min_success_rate: float = 0.8,
626
+ max_promotions: int = 3,
627
+ ) -> dict:
628
+ """Promote mature outcome patterns to draft skills without manual approval.
629
+
630
+ Closes Fase 2 item 2 of NEXO-AUDIT-2026-04-11. Until this function ran
631
+ automatically, every outcome pattern that crossed the suggested-skill
632
+ threshold sat in the candidates table waiting for an explicit
633
+ nexo_skill_seed_from_outcome_pattern call. The materialization helper
634
+ already existed (see materialize_outcome_pattern_skill above), but no
635
+ process invoked it on a schedule.
636
+
637
+ This wrapper:
638
+ - Lists current outcome pattern candidates (top 20).
639
+ - Filters to reinforce_strategy candidates that the analyzer already
640
+ flagged as suggested_skill_candidate=True (resolved >= 4) AND whose
641
+ success_rate is at or above min_success_rate (default 0.8).
642
+ - Calls materialize_outcome_pattern_skill() per qualifying candidate,
643
+ capped at max_promotions per invocation so a sudden flood does not
644
+ materialize dozens of skills in one cycle.
645
+ - materialize_outcome_pattern_skill() is itself idempotent: if the
646
+ target skill id already exists it returns created=False without
647
+ re-creating, so this function is safe to run repeatedly.
648
+
649
+ Returns a stats dict:
650
+ {
651
+ "promoted": [list of {pattern_key, skill_id, created}],
652
+ "skipped": [list of {pattern_key, reason}],
653
+ "errors": [list of {pattern_key, error}],
654
+ "scanned": int, # number of candidates inspected
655
+ }
656
+
657
+ Best-effort: never raises. A single failing pattern logs an error entry
658
+ but lets the loop continue, so one bad row never blocks the queue.
659
+ """
660
+ _ensure_ready()
661
+ sync_skill_directories()
662
+
663
+ promoted: list[dict] = []
664
+ skipped: list[dict] = []
665
+ errors: list[dict] = []
666
+
667
+ try:
668
+ candidates = list_outcome_pattern_candidates(limit=20)
669
+ except Exception as e:
670
+ return {
671
+ "promoted": [],
672
+ "skipped": [],
673
+ "errors": [{"pattern_key": "*", "error": f"list_outcome_pattern_candidates raised: {e}"}],
674
+ "scanned": 0,
675
+ }
676
+
677
+ scanned = 0
678
+ promote_budget = max(0, int(max_promotions))
679
+ for candidate in candidates:
680
+ scanned += 1
681
+ pattern_key = (candidate.get("pattern_key") or "").strip()
682
+ if not pattern_key:
683
+ skipped.append({"pattern_key": "", "reason": "missing pattern_key"})
684
+ continue
685
+ if candidate.get("candidate_type") != "reinforce_strategy":
686
+ skipped.append({"pattern_key": pattern_key, "reason": "not reinforce_strategy"})
687
+ continue
688
+ if not candidate.get("suggested_skill_candidate"):
689
+ skipped.append({"pattern_key": pattern_key, "reason": "below suggested_skill_candidate threshold"})
690
+ continue
691
+ success_rate = float(candidate.get("success_rate") or 0.0)
692
+ if success_rate < float(min_success_rate):
693
+ skipped.append({
694
+ "pattern_key": pattern_key,
695
+ "reason": f"success_rate {success_rate:.3f} < {min_success_rate:.3f}",
696
+ })
697
+ continue
698
+ if promote_budget <= 0:
699
+ skipped.append({"pattern_key": pattern_key, "reason": "promotion budget exhausted"})
700
+ continue
701
+
702
+ try:
703
+ result = materialize_outcome_pattern_skill(pattern_key)
704
+ except Exception as e:
705
+ errors.append({"pattern_key": pattern_key, "error": str(e)})
706
+ continue
707
+
708
+ if not result.get("ok"):
709
+ errors.append({
710
+ "pattern_key": pattern_key,
711
+ "error": result.get("error", "materialize_outcome_pattern_skill failed"),
712
+ })
713
+ continue
714
+
715
+ skill = result.get("skill") or {}
716
+ promoted.append({
717
+ "pattern_key": pattern_key,
718
+ "skill_id": skill.get("id") or skill.get("skill_id"),
719
+ "created": bool(result.get("created")),
720
+ "success_rate": success_rate,
721
+ "resolved_outcomes": int(candidate.get("resolved_outcomes") or 0),
722
+ })
723
+ # Only newly-created skills consume the budget. Idempotent re-use of
724
+ # an existing skill costs nothing because it didn't materialize a new
725
+ # one — letting other candidates through.
726
+ if result.get("created"):
727
+ promote_budget -= 1
728
+
729
+ return {
730
+ "promoted": promoted,
731
+ "skipped": skipped,
732
+ "errors": errors,
733
+ "scanned": scanned,
734
+ }
735
+
736
+
737
+ def detect_skill_coactivation_candidates(
738
+ *,
739
+ min_co_occurrence: int = 3,
740
+ min_success_rate: float = 0.6,
741
+ limit: int = 20,
742
+ ) -> list[dict]:
743
+ """Detect pairs of skills that fire together and could be composed.
744
+
745
+ Closes Fase 5 item 5 of NEXO-AUDIT-2026-04-11. NEXO already has
746
+ compose_skills (manual composer), auto_promote_outcome_patterns_to_skills
747
+ (Fase 2 item 2), and auto_promote_skill_evolution (guide → executable
748
+ draft). What it lacked is the Voyager-style observation pass: when
749
+ the same two skills are used inside the same session multiple times,
750
+ a composite skill probably wants to exist.
751
+
752
+ This function reads skill_usage rows, groups by session_id to get
753
+ the set of skills per session, builds a co-occurrence count over
754
+ all unordered pairs, and returns candidates whose count is at or
755
+ above min_co_occurrence and whose joint success rate is at or
756
+ above min_success_rate.
757
+
758
+ Args:
759
+ min_co_occurrence: minimum number of sessions both skills must
760
+ have co-occurred in. Default 3 — same threshold the
761
+ outcome_pattern detector uses.
762
+ min_success_rate: minimum joint success rate (success rows /
763
+ total rows for the pair). Default 0.6.
764
+ limit: max candidates to return. Default 20.
765
+
766
+ Returns a list (newest co-occurrence first):
767
+ [
768
+ {
769
+ "skill_a": str, "skill_b": str,
770
+ "co_occurrence": int,
771
+ "joint_success_rate": float,
772
+ "sessions": [session_id, ...],
773
+ "suggested_skill_id": str, # canonical id for the composite
774
+ },
775
+ ...
776
+ ]
777
+
778
+ Pure DB read, never raises. Empty list when skill_usage table is
779
+ missing or no candidates qualify.
780
+ """
781
+ _ensure_ready()
782
+ try:
783
+ from db import get_db
784
+ conn = get_db()
785
+ # Sanity check the table exists.
786
+ conn.execute("SELECT 1 FROM skill_usage LIMIT 1").fetchone()
787
+ except Exception:
788
+ return []
789
+
790
+ try:
791
+ rows = conn.execute(
792
+ "SELECT skill_id, session_id, success FROM skill_usage "
793
+ "WHERE session_id IS NOT NULL AND session_id != '' "
794
+ "ORDER BY created_at DESC LIMIT 5000"
795
+ ).fetchall()
796
+ except Exception:
797
+ return []
798
+
799
+ by_session: dict[str, list[tuple[str, int]]] = {}
800
+ for row in rows:
801
+ sid = (row["session_id"] or "").strip() if hasattr(row, "keys") else (row[1] or "").strip()
802
+ if not sid:
803
+ continue
804
+ skill_id = row["skill_id"] if hasattr(row, "keys") else row[0]
805
+ success = row["success"] if hasattr(row, "keys") else row[2]
806
+ by_session.setdefault(sid, []).append((skill_id, int(success or 0)))
807
+
808
+ pair_stats: dict[tuple[str, str], dict] = {}
809
+ for sid, usages in by_session.items():
810
+ # Distinct skills with their best (any) success in this session.
811
+ seen_in_session: dict[str, int] = {}
812
+ for skill_id, success in usages:
813
+ seen_in_session[skill_id] = max(seen_in_session.get(skill_id, 0), success)
814
+ skills_in_session = sorted(seen_in_session.keys())
815
+ if len(skills_in_session) < 2:
816
+ continue
817
+ for i in range(len(skills_in_session)):
818
+ for j in range(i + 1, len(skills_in_session)):
819
+ pair = (skills_in_session[i], skills_in_session[j])
820
+ stats = pair_stats.setdefault(
821
+ pair,
822
+ {"co_occurrence": 0, "joint_success": 0, "sessions": []},
823
+ )
824
+ stats["co_occurrence"] += 1
825
+ # Joint success: both skills succeeded in this session.
826
+ if seen_in_session[pair[0]] and seen_in_session[pair[1]]:
827
+ stats["joint_success"] += 1
828
+ stats["sessions"].append(sid)
829
+
830
+ candidates: list[dict] = []
831
+ for (skill_a, skill_b), stats in pair_stats.items():
832
+ co = stats["co_occurrence"]
833
+ if co < int(min_co_occurrence):
834
+ continue
835
+ rate = stats["joint_success"] / co if co > 0 else 0.0
836
+ if rate < float(min_success_rate):
837
+ continue
838
+ # Deterministic suggested id derived from the pair so re-running
839
+ # the detector points at the same composite skill.
840
+ suggested_id = "SK-COMPOSE-" + "+".join(sorted([skill_a, skill_b]))
841
+ candidates.append({
842
+ "skill_a": skill_a,
843
+ "skill_b": skill_b,
844
+ "co_occurrence": co,
845
+ "joint_success_rate": round(rate, 3),
846
+ "sessions": stats["sessions"][:5],
847
+ "suggested_skill_id": suggested_id,
848
+ })
849
+
850
+ candidates.sort(key=lambda c: (-c["co_occurrence"], -c["joint_success_rate"]))
851
+ return candidates[: max(1, int(limit))]
852
+
853
+
854
+ def auto_promote_skill_evolution(approved_by: str = "system:auto") -> dict:
855
+ """Convert mature guide skills into executable drafts without manual approval."""
856
+ _ensure_ready()
857
+ sync_skill_directories()
858
+ promoted = []
859
+ skipped = []
860
+ for candidate in collect_scriptable_skill_candidates():
861
+ skill = get_skill(candidate["id"])
862
+ if not skill or skill.get("file_path"):
863
+ continue
864
+
865
+ steps = candidate.get("steps") or []
866
+ gotchas = candidate.get("gotchas") or []
867
+ description = candidate.get("description", "") or "Automated skill generated from repeated successful usage."
868
+ lines = [
869
+ "#!/usr/bin/env python3",
870
+ '"""Auto-generated executable skill draft."""',
871
+ "import json",
872
+ "import sys",
873
+ "",
874
+ "def main() -> int:",
875
+ " payload = {",
876
+ f" 'skill_id': {json.dumps(candidate['id'])},",
877
+ f" 'skill_name': {json.dumps(candidate['name'])},",
878
+ f" 'description': {json.dumps(description)},",
879
+ f" 'steps': {json.dumps(steps, ensure_ascii=False)},",
880
+ f" 'gotchas': {json.dumps(gotchas, ensure_ascii=False)},",
881
+ " 'argv': sys.argv[1:],",
882
+ " }",
883
+ " print(json.dumps(payload, ensure_ascii=False))",
884
+ " return 0",
885
+ "",
886
+ 'if __name__ == "__main__":',
887
+ " raise SystemExit(main())",
888
+ "",
889
+ ]
890
+ update = update_skill(
891
+ candidate["id"],
892
+ mode=candidate.get("suggested_mode", "hybrid"),
893
+ execution_level=candidate.get("suggested_execution_level", "read-only"),
894
+ approval_required=0,
895
+ approved_by=approved_by,
896
+ )
897
+ if "error" in update:
898
+ skipped.append({"id": candidate["id"], "reason": update["error"]})
899
+ continue
900
+
901
+ materialized = materialize_personal_skill_definition(
902
+ {
903
+ "id": candidate["id"],
904
+ "name": candidate["name"],
905
+ "description": description,
906
+ "level": skill.get("level", "published"),
907
+ "mode": candidate.get("suggested_mode", "hybrid"),
908
+ "execution_level": candidate.get("suggested_execution_level", "read-only"),
909
+ "approved_by": approved_by,
910
+ "tags": json.loads(skill.get("tags", "[]")) if skill.get("tags") else [],
911
+ "trigger_patterns": candidate.get("trigger_patterns", []),
912
+ "source_sessions": candidate.get("source_sessions", []),
913
+ "steps": steps,
914
+ "gotchas": gotchas,
915
+ "content": skill.get("content", ""),
916
+ "command_template": {"argv": ["{{file_path}}"]},
917
+ "executable_entry": "script.py",
918
+ "script_body": "\n".join(lines),
919
+ }
920
+ )
921
+ if "error" in materialized:
922
+ skipped.append({"id": candidate["id"], "reason": materialized["error"]})
923
+ continue
924
+
925
+ promoted.append(
926
+ {
927
+ "id": candidate["id"],
928
+ "mode": candidate.get("suggested_mode", "hybrid"),
929
+ "execution_level": candidate.get("suggested_execution_level", "read-only"),
930
+ }
931
+ )
932
+ return {"promoted": promoted, "skipped": skipped}