nexo-brain 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -1
  3. package/scripts/migrate-v1.7-to-v1.8.py +2 -2
  4. package/scripts/nexo-preflight.sh +236 -0
  5. package/src/__pycache__/auto_close_sessions.cpython-314.pyc +0 -0
  6. package/src/__pycache__/auto_update.cpython-310.pyc +0 -0
  7. package/src/__pycache__/hnsw_index.cpython-314.pyc +0 -0
  8. package/src/__pycache__/plugin_loader.cpython-314.pyc +0 -0
  9. package/src/__pycache__/tools_reminders_crud.cpython-310.pyc +0 -0
  10. package/src/auto_update.py +25 -0
  11. package/src/cognitive/__pycache__/__init__.cpython-312.pyc +0 -0
  12. package/src/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  13. package/src/cognitive/__pycache__/_core.cpython-312.pyc +0 -0
  14. package/src/cognitive/__pycache__/_core.cpython-314.pyc +0 -0
  15. package/src/cognitive/__pycache__/_decay.cpython-312.pyc +0 -0
  16. package/src/cognitive/__pycache__/_decay.cpython-314.pyc +0 -0
  17. package/src/cognitive/__pycache__/_ingest.cpython-312.pyc +0 -0
  18. package/src/cognitive/__pycache__/_ingest.cpython-314.pyc +0 -0
  19. package/src/cognitive/__pycache__/_memory.cpython-312.pyc +0 -0
  20. package/src/cognitive/__pycache__/_memory.cpython-314.pyc +0 -0
  21. package/src/cognitive/__pycache__/_search.cpython-312.pyc +0 -0
  22. package/src/cognitive/__pycache__/_search.cpython-314.pyc +0 -0
  23. package/src/cognitive/__pycache__/_trust.cpython-310.pyc +0 -0
  24. package/src/cognitive/__pycache__/_trust.cpython-312.pyc +0 -0
  25. package/src/cognitive/__pycache__/_trust.cpython-314.pyc +0 -0
  26. package/src/crons/__pycache__/sync.cpython-314.pyc +0 -0
  27. package/src/crons/manifest.json +6 -13
  28. package/src/crons/sync.py +151 -6
  29. package/src/db/__init__.py +13 -0
  30. package/src/db/__pycache__/__init__.cpython-310.pyc +0 -0
  31. package/src/db/__pycache__/__init__.cpython-312.pyc +0 -0
  32. package/src/db/__pycache__/__init__.cpython-314.pyc +0 -0
  33. package/src/db/__pycache__/_cron_runs.cpython-310.pyc +0 -0
  34. package/src/db/__pycache__/_cron_runs.cpython-314.pyc +0 -0
  35. package/src/db/__pycache__/_episodic.cpython-310.pyc +0 -0
  36. package/src/db/__pycache__/_episodic.cpython-312.pyc +0 -0
  37. package/src/db/__pycache__/_episodic.cpython-314.pyc +0 -0
  38. package/src/db/__pycache__/_schema.cpython-310.pyc +0 -0
  39. package/src/db/__pycache__/_schema.cpython-312.pyc +0 -0
  40. package/src/db/__pycache__/_schema.cpython-314.pyc +0 -0
  41. package/src/db/__pycache__/_skills.cpython-310.pyc +0 -0
  42. package/src/db/__pycache__/_skills.cpython-312.pyc +0 -0
  43. package/src/db/__pycache__/_skills.cpython-314.pyc +0 -0
  44. package/src/db/_cron_runs.py +74 -0
  45. package/src/db/_episodic.py +40 -6
  46. package/src/db/_schema.py +64 -0
  47. package/src/db/_skills.py +514 -0
  48. package/src/hooks/session-stop.sh +13 -101
  49. package/src/plugins/__pycache__/__init__.cpython-314.pyc +0 -0
  50. package/src/plugins/__pycache__/adaptive_mode.cpython-314.pyc +0 -0
  51. package/src/plugins/__pycache__/episodic_memory.cpython-310.pyc +0 -0
  52. package/src/plugins/__pycache__/schedule.cpython-310.pyc +0 -0
  53. package/src/plugins/__pycache__/schedule.cpython-314.pyc +0 -0
  54. package/src/plugins/__pycache__/skills.cpython-310.pyc +0 -0
  55. package/src/plugins/__pycache__/skills.cpython-314.pyc +0 -0
  56. package/src/plugins/episodic_memory.py +5 -3
  57. package/src/plugins/schedule.py +212 -0
  58. package/src/plugins/skills.py +264 -0
  59. package/src/scripts/__pycache__/nexo-auto-update.cpython-314.pyc +0 -0
  60. package/src/scripts/__pycache__/nexo-catchup.cpython-314.pyc +0 -0
  61. package/src/scripts/__pycache__/nexo-cognitive-decay.cpython-314.pyc +0 -0
  62. package/src/scripts/__pycache__/nexo-daily-self-audit.cpython-314.pyc +0 -0
  63. package/src/scripts/__pycache__/nexo-evolution-run.cpython-314.pyc +0 -0
  64. package/src/scripts/__pycache__/nexo-followup-hygiene.cpython-314.pyc +0 -0
  65. package/src/scripts/__pycache__/nexo-immune.cpython-314.pyc +0 -0
  66. package/src/scripts/__pycache__/nexo-install.cpython-314.pyc +0 -0
  67. package/src/scripts/__pycache__/nexo-learning-housekeep.cpython-314.pyc +0 -0
  68. package/src/scripts/__pycache__/nexo-learning-validator.cpython-314.pyc +0 -0
  69. package/src/scripts/__pycache__/nexo-migrate.cpython-314.pyc +0 -0
  70. package/src/scripts/__pycache__/nexo-postmortem-consolidator.cpython-314.pyc +0 -0
  71. package/src/scripts/__pycache__/nexo-pre-commit.cpython-314.pyc +0 -0
  72. package/src/scripts/__pycache__/nexo-proactive-dashboard.cpython-314.pyc +0 -0
  73. package/src/scripts/__pycache__/nexo-reflection.cpython-314.pyc +0 -0
  74. package/src/scripts/__pycache__/nexo-runtime-preflight.cpython-314.pyc +0 -0
  75. package/src/scripts/__pycache__/nexo-send-email.cpython-314.pyc +0 -0
  76. package/src/scripts/__pycache__/nexo-send-reply.cpython-314.pyc +0 -0
  77. package/src/scripts/__pycache__/nexo-sleep.cpython-314.pyc +0 -0
  78. package/src/scripts/__pycache__/nexo-synthesis.cpython-314.pyc +0 -0
  79. package/src/scripts/__pycache__/nexo-watchdog-smoke.cpython-314.pyc +0 -0
  80. package/src/scripts/deep-sleep/apply_findings.py +110 -8
  81. package/src/scripts/deep-sleep/collect.py +33 -11
  82. package/src/scripts/deep-sleep/extract-prompt.md +38 -0
  83. package/src/scripts/deep-sleep/extract.py +80 -8
  84. package/src/scripts/deep-sleep/synthesize-prompt.md +29 -1
  85. package/src/scripts/deep-sleep/synthesize.py +3 -1
  86. package/src/scripts/nexo-catchup.py +65 -29
  87. package/src/scripts/nexo-cron-wrapper.sh +53 -0
  88. package/src/scripts/nexo-daily-self-audit.py +4 -2
  89. package/src/scripts/nexo-deep-sleep.sh +66 -77
  90. package/src/scripts/nexo-evolution-run.py +13 -0
  91. package/src/scripts/nexo-learning-housekeep.py +156 -1
  92. package/src/scripts/nexo-learning-validator.py +19 -0
  93. package/src/scripts/nexo-postmortem-consolidator.py +3 -2
  94. package/src/scripts/nexo-sleep.py +16 -11
  95. package/src/scripts/nexo-synthesis.py +46 -3
  96. package/src/scripts/nexo-watchdog.sh +72 -19
  97. package/src/server.py +5 -1
  98. package/src/scripts/nexo-github-monitor.py +0 -256
@@ -166,7 +166,8 @@ def handle_session_diary_write(decisions: str, summary: str,
166
166
  user_signals: str = '',
167
167
  domain: str = '',
168
168
  session_id: str = '',
169
- self_critique: str = '') -> str:
169
+ self_critique: str = '',
170
+ source: str = 'claude') -> str:
170
171
  """Write session diary entry at end of session. OBLIGATORIO antes de cerrar.
171
172
 
172
173
  Args:
@@ -179,13 +180,14 @@ def handle_session_diary_write(decisions: str, summary: str,
179
180
  user_signals: Observable signals from user during session — response speed (fast='s' vs detailed explanations), tone (direct, frustrated, exploratory, excited), corrections given, topics he initiated vs topics NEXO initiated. Factual observations only, not interpretations.
180
181
  domain: Project context: ecommerce, project-a, nexo, project-b, server, other
181
182
  session_id: Current session ID
182
- self_critique: REQUIRED. Honest post-mortem: What should I have done proactively? Did user have to ask me something I should have detected? Did I repeat known errors? What concrete rule would prevent repetition? If clean session: 'No self-critique — clean session.'
183
+ self_critique: REQUIRED. Honest post-mortem.
184
+ source: Session type. 'claude' for human-interactive sessions (default), 'cron' for automated cron jobs. Affects visibility at startup.
183
185
  """
184
186
  sid = session_id or 'unknown'
185
187
  # Clean up draft — manual diary supersedes it
186
188
  from db import delete_diary_draft
187
189
  delete_diary_draft(sid)
188
- result = write_session_diary(sid, decisions, summary, discarded, pending, context_next, mental_state, domain=domain, user_signals=user_signals, self_critique=self_critique)
190
+ result = write_session_diary(sid, decisions, summary, discarded, pending, context_next, mental_state, domain=domain, user_signals=user_signals, self_critique=self_critique, source=source)
189
191
  if "error" in result:
190
192
  return f"ERROR: {result['error']}"
191
193
  _cognitive_ingest_safe(summary, "diary", f"diary#{result.get('id','')}", f"Session {sid} summary", domain)
@@ -0,0 +1,212 @@
1
+ """NEXO Schedule — Cron execution history, status, and management tools."""
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from db import cron_runs_recent, cron_runs_summary
10
+
11
+
12
+ def handle_schedule_status(hours: int = 24, cron_id: str = '') -> str:
13
+ """Show cron execution status — what ran, what failed, durations.
14
+
15
+ Args:
16
+ hours: How far back to look (default 24h).
17
+ cron_id: Filter to a specific cron (optional). E.g. 'deep-sleep', 'immune'.
18
+ """
19
+ if cron_id:
20
+ runs = cron_runs_recent(hours, cron_id)
21
+ if not runs:
22
+ return f"No runs for '{cron_id}' in the last {hours}h."
23
+ lines = [f"CRON RUNS — {cron_id} (last {hours}h): {len(runs)} executions"]
24
+ for r in runs:
25
+ status = "✅" if r.get("exit_code") == 0 else "❌"
26
+ dur = f"{r['duration_secs']:.0f}s" if r.get("duration_secs") else "running"
27
+ summary = f" — {r['summary'][:100]}" if r.get("summary") else ""
28
+ error = f" ERROR: {r['error'][:100]}" if r.get("error") else ""
29
+ lines.append(f" {status} {r['started_at']} ({dur}){summary}{error}")
30
+ return "\n".join(lines)
31
+
32
+ # Summary view — one line per cron
33
+ summary = cron_runs_summary(hours)
34
+ if not summary:
35
+ return f"No cron executions recorded in the last {hours}h."
36
+
37
+ lines = [f"CRON STATUS (last {hours}h):"]
38
+ for s in summary:
39
+ status = "✅" if s.get("last_exit_code") == 0 else "❌"
40
+ rate = f"{s['succeeded']}/{s['total_runs']}"
41
+ dur = f"{s['avg_duration']:.0f}s avg" if s.get("avg_duration") else ""
42
+ summary_txt = f" — {s['last_summary'][:80]}" if s.get("last_summary") else ""
43
+ lines.append(f" {status} {s['cron_id']}: {rate} OK, {dur}{summary_txt}")
44
+
45
+ return "\n".join(lines)
46
+
47
+
48
+ def handle_schedule_add(cron_id: str, script: str, schedule: str = '',
49
+ interval_seconds: int = 0, description: str = '',
50
+ script_type: str = 'python') -> str:
51
+ """Add a new personal cron job. Generates and installs the LaunchAgent (macOS) or systemd timer (Linux).
52
+
53
+ Args:
54
+ cron_id: Unique ID for this cron (e.g. 'my-backup', 'report-daily'). Must be lowercase with hyphens.
55
+ script: Path to the script to run (absolute or relative to NEXO_HOME/scripts/).
56
+ schedule: Time-based schedule as 'HH:MM' (daily) or 'HH:MM:weekday' (e.g. '08:00:1' for Monday 8AM). Mutually exclusive with interval_seconds.
57
+ interval_seconds: Run every N seconds (e.g. 300 for every 5 min). Mutually exclusive with schedule.
58
+ description: What this cron does (for logs and status).
59
+ script_type: 'python' (default) or 'shell'.
60
+ """
61
+ if not cron_id or not script:
62
+ return "ERROR: cron_id and script are required."
63
+ if not schedule and not interval_seconds:
64
+ return "ERROR: either schedule (e.g. '08:00') or interval_seconds (e.g. 300) is required."
65
+
66
+ nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
67
+ script_path = Path(script)
68
+ if not script_path.is_absolute():
69
+ script_path = nexo_home / "scripts" / script
70
+ if not script_path.exists():
71
+ return f"ERROR: script not found: {script_path}"
72
+
73
+ wrapper_path = nexo_home / "scripts" / "nexo-cron-wrapper.sh"
74
+ if not wrapper_path.exists():
75
+ return f"ERROR: wrapper not found at {wrapper_path}. Run crons/sync.py first."
76
+
77
+ system = platform.system()
78
+
79
+ if system == "Darwin":
80
+ return _add_launchagent(cron_id, str(script_path), str(wrapper_path),
81
+ schedule, interval_seconds, description, script_type, nexo_home)
82
+ elif system == "Linux":
83
+ return _add_systemd_timer(cron_id, str(script_path), str(wrapper_path),
84
+ schedule, interval_seconds, description, script_type, nexo_home)
85
+ else:
86
+ return f"ERROR: unsupported platform: {system}"
87
+
88
+
89
+ def _add_launchagent(cron_id, script_path, wrapper_path, schedule, interval_seconds,
90
+ description, script_type, nexo_home):
91
+ """Create and load a macOS LaunchAgent."""
92
+ import plistlib
93
+
94
+ label = f"com.nexo.{cron_id}"
95
+ plist_path = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
96
+
97
+ if plist_path.exists():
98
+ return f"ERROR: cron '{cron_id}' already exists at {plist_path}. Use a different ID or remove it first."
99
+
100
+ python_bin = "/opt/homebrew/bin/python3"
101
+ for p in ["/opt/homebrew/bin/python3", "/usr/local/bin/python3", "/usr/bin/python3"]:
102
+ if Path(p).exists():
103
+ python_bin = p
104
+ break
105
+
106
+ if script_type == "shell":
107
+ program_args = ["/bin/bash", wrapper_path, cron_id, "/bin/bash", script_path]
108
+ else:
109
+ program_args = ["/bin/bash", wrapper_path, cron_id, python_bin, script_path]
110
+
111
+ plist = {
112
+ "Label": label,
113
+ "ProgramArguments": program_args,
114
+ "StandardOutPath": str(nexo_home / "logs" / f"{cron_id}-stdout.log"),
115
+ "StandardErrorPath": str(nexo_home / "logs" / f"{cron_id}-stderr.log"),
116
+ "EnvironmentVariables": {
117
+ "HOME": str(Path.home()),
118
+ "NEXO_HOME": str(nexo_home),
119
+ "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:" + str(Path.home() / ".local/bin"),
120
+ },
121
+ }
122
+
123
+ if interval_seconds:
124
+ plist["StartInterval"] = interval_seconds
125
+ elif schedule:
126
+ parts = schedule.split(":")
127
+ cal = {"Hour": int(parts[0]), "Minute": int(parts[1])}
128
+ if len(parts) > 2:
129
+ cal["Weekday"] = int(parts[2])
130
+ plist["StartCalendarInterval"] = cal
131
+
132
+ with open(plist_path, "wb") as f:
133
+ plistlib.dump(plist, f)
134
+
135
+ subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(plist_path)], capture_output=True)
136
+
137
+ return f"Cron '{cron_id}' installed at {plist_path} and loaded.{' Schedule: ' + schedule if schedule else f' Interval: {interval_seconds}s'}"
138
+
139
+
140
+ def _add_systemd_timer(cron_id, script_path, wrapper_path, schedule, interval_seconds,
141
+ description, script_type, nexo_home):
142
+ """Create and enable a systemd user timer (Linux)."""
143
+ unit_dir = Path.home() / ".config" / "systemd" / "user"
144
+ unit_dir.mkdir(parents=True, exist_ok=True)
145
+
146
+ python_bin = "/usr/bin/python3"
147
+ for p in ["/usr/bin/python3", "/usr/local/bin/python3"]:
148
+ if Path(p).exists():
149
+ python_bin = p
150
+ break
151
+
152
+ if script_type == "shell":
153
+ exec_cmd = f"/bin/bash {wrapper_path} {cron_id} /bin/bash {script_path}"
154
+ else:
155
+ exec_cmd = f"/bin/bash {wrapper_path} {cron_id} {python_bin} {script_path}"
156
+
157
+ # Service unit
158
+ service_content = f"""[Unit]
159
+ Description=NEXO: {description or cron_id}
160
+
161
+ [Service]
162
+ Type=oneshot
163
+ ExecStart={exec_cmd}
164
+ Environment=NEXO_HOME={nexo_home}
165
+ Environment=HOME={Path.home()}
166
+ """
167
+ service_path = unit_dir / f"nexo-{cron_id}.service"
168
+ service_path.write_text(service_content)
169
+
170
+ # Timer unit
171
+ if interval_seconds:
172
+ timer_spec = f"OnUnitActiveSec={interval_seconds}s\nOnBootSec=60s"
173
+ elif schedule:
174
+ parts = schedule.split(":")
175
+ hour, minute = int(parts[0]), int(parts[1])
176
+ if len(parts) > 2:
177
+ days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
178
+ day = days[int(parts[2])]
179
+ timer_spec = f"OnCalendar={day} *-*-* {hour:02d}:{minute:02d}:00"
180
+ else:
181
+ timer_spec = f"OnCalendar=*-*-* {hour:02d}:{minute:02d}:00"
182
+ else:
183
+ return "ERROR: no schedule or interval"
184
+
185
+ timer_content = f"""[Unit]
186
+ Description=NEXO timer: {description or cron_id}
187
+
188
+ [Timer]
189
+ {timer_spec}
190
+ Persistent=true
191
+
192
+ [Install]
193
+ WantedBy=timers.target
194
+ """
195
+ timer_path = unit_dir / f"nexo-{cron_id}.timer"
196
+ timer_path.write_text(timer_content)
197
+
198
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
199
+ subprocess.run(["systemctl", "--user", "enable", "--now", f"nexo-{cron_id}.timer"], capture_output=True)
200
+
201
+ return f"Cron '{cron_id}' installed as systemd timer and enabled. Service: {service_path}, Timer: {timer_path}"
202
+
203
+
204
+ TOOLS = [
205
+ (handle_schedule_status, "nexo_schedule_status",
206
+ "Show cron execution status: what ran overnight, what failed, durations. "
207
+ "Use at startup to give the user a quick health overview of autonomous processes."),
208
+
209
+ (handle_schedule_add, "nexo_schedule_add",
210
+ "Add a new personal cron job. Creates LaunchAgent (macOS) or systemd timer (Linux) "
211
+ "automatically, wrapped with execution tracking."),
212
+ ]
@@ -0,0 +1,264 @@
1
+ """Skills plugin — reusable procedures extracted from complex tasks.
2
+
3
+ Skills are procedural knowledge (step-by-step how-tos) vs learnings which are
4
+ declarative (don't do X). Created automatically by Deep Sleep or manually.
5
+
6
+ Pipeline: trace → draft → published, fully autonomous.
7
+ Trust score with decay controls quality — no human approval gates.
8
+ """
9
+
10
+ import json
11
+ from db import (
12
+ create_skill, get_skill, list_skills, search_skills,
13
+ update_skill, delete_skill,
14
+ record_skill_usage, match_skills, merge_skills, get_skill_stats,
15
+ )
16
+
17
+
18
+ def handle_skill_create(
19
+ id: str,
20
+ name: str,
21
+ description: str = '',
22
+ level: str = 'draft',
23
+ tags: str = '[]',
24
+ trigger_patterns: str = '[]',
25
+ source_sessions: str = '[]',
26
+ linked_learnings: str = '[]',
27
+ file_path: str = '',
28
+ ) -> str:
29
+ """Create a new skill (reusable procedure).
30
+
31
+ Skills are procedural knowledge — step-by-step instructions for complex tasks.
32
+ Created by Deep Sleep (auto-extraction) or manually during sessions.
33
+
34
+ Pipeline levels: trace → draft → published → archived.
35
+ Promotion is automatic: 2+ successful uses in distinct contexts → published.
36
+
37
+ Args:
38
+ id: Unique ID starting with 'SK-' (e.g., SK-DEPLOY-CHROME-EXT).
39
+ name: Human-readable name (e.g., 'Deploy Chrome Extension').
40
+ description: What this skill does (1-2 sentences).
41
+ level: Starting level — trace, draft (default), published, archived.
42
+ tags: JSON array of tags for discovery (e.g., '["chrome", "extension", "deploy"]').
43
+ trigger_patterns: JSON array of phrases that should trigger this skill
44
+ (e.g., '["deploy extension", "publish chrome"]').
45
+ source_sessions: JSON array of diary IDs where this skill was observed.
46
+ linked_learnings: JSON array of learning IDs related to this skill.
47
+ file_path: Path to the .md file with full procedure (if stored as file).
48
+ """
49
+ if not id.startswith('SK-'):
50
+ return "ERROR: Skill ID must start with 'SK-' (e.g., SK-DEPLOY-CHROME-EXT)"
51
+
52
+ existing = get_skill(id)
53
+ if existing:
54
+ return f"ERROR: Skill {id} already exists. Use nexo_skill_update to modify."
55
+
56
+ result = create_skill(
57
+ skill_id=id, name=name, description=description, level=level,
58
+ tags=tags, trigger_patterns=trigger_patterns,
59
+ source_sessions=source_sessions, linked_learnings=linked_learnings,
60
+ file_path=file_path,
61
+ )
62
+ if "error" in result:
63
+ return f"ERROR: {result['error']}"
64
+
65
+ return (
66
+ f"Skill {id} created ({level}, trust={result.get('trust_score', 50)}).\n"
67
+ f" Name: {name}\n"
68
+ f" Tags: {tags}\n"
69
+ f" Triggers: {trigger_patterns}"
70
+ )
71
+
72
+
73
+ def handle_skill_match(task: str, level: str = '') -> str:
74
+ """Find skills matching a task description. Call BEFORE starting multi-step tasks.
75
+
76
+ Searches by: FTS5 relevance, trigger pattern matching, tag keyword overlap.
77
+ Returns top-3 matches sorted by trust score.
78
+
79
+ Args:
80
+ task: Description of what you're about to do (e.g., 'deploy chrome extension to CWS').
81
+ level: Filter by level (optional). Default: draft + published.
82
+ """
83
+ matches = match_skills(task, level=level)
84
+ if not matches:
85
+ return f"No skills found for: '{task}'"
86
+
87
+ lines = [f"SKILLS MATCHED ({len(matches)}) for '{task}':"]
88
+ for m in matches:
89
+ match_method = m.pop('_match', 'unknown')
90
+ fp = f" → {m['file_path']}" if m.get('file_path') else ""
91
+ lines.append(
92
+ f" [{m['id']}] {m['name']} ({m['level']}, trust={m['trust_score']}, "
93
+ f"used={m['use_count']}x) via {match_method}{fp}\n"
94
+ f" {m['description'][:120]}"
95
+ )
96
+ try:
97
+ triggers = json.loads(m.get('trigger_patterns', '[]'))
98
+ if triggers:
99
+ lines.append(f" Triggers: {', '.join(triggers[:5])}")
100
+ except (json.JSONDecodeError, TypeError):
101
+ pass
102
+ return "\n".join(lines)
103
+
104
+
105
+ def handle_skill_get(id: str) -> str:
106
+ """Get a skill's full details including usage history.
107
+
108
+ Args:
109
+ id: Skill ID (e.g., SK-DEPLOY-CHROME-EXT).
110
+ """
111
+ skill = get_skill(id)
112
+ if not skill:
113
+ return f"ERROR: Skill {id} not found."
114
+
115
+ from db import get_db
116
+ conn = get_db()
117
+ recent_uses = conn.execute(
118
+ "SELECT * FROM skill_usage WHERE skill_id = ? ORDER BY created_at DESC LIMIT 5",
119
+ (id,),
120
+ ).fetchall()
121
+
122
+ lines = [
123
+ f"SKILL: {skill['id']}",
124
+ f" Name: {skill['name']}",
125
+ f" Description: {skill['description']}",
126
+ f" Level: {skill['level']}",
127
+ f" Trust: {skill['trust_score']}",
128
+ f" File: {skill['file_path'] or '(none)'}",
129
+ f" Tags: {skill['tags']}",
130
+ f" Triggers: {skill['trigger_patterns']}",
131
+ f" Source sessions: {skill['source_sessions']}",
132
+ f" Linked learnings: {skill['linked_learnings']}",
133
+ f" Stats: {skill['use_count']} uses, {skill['success_count']} success, {skill['fail_count']} fail",
134
+ f" Created: {skill['created_at']}",
135
+ f" Last used: {skill['last_used_at'] or 'never'}",
136
+ ]
137
+
138
+ if recent_uses:
139
+ lines.append("\n RECENT USAGE:")
140
+ for u in recent_uses:
141
+ u = dict(u)
142
+ status = "✓" if u['success'] else "✗"
143
+ lines.append(f" {status} {u['created_at']} — {u['context'][:60] or '(no context)'}")
144
+ if u.get('notes'):
145
+ lines.append(f" Notes: {u['notes'][:80]}")
146
+
147
+ return "\n".join(lines)
148
+
149
+
150
+ def handle_skill_result(id: str, success: bool = True, context: str = '', notes: str = '') -> str:
151
+ """Record the result of using a skill. Auto-promotes/degrades based on trust rules.
152
+
153
+ Call this AFTER following a skill's procedure to record whether it worked.
154
+ - Success: trust +5. After 2+ successes in distinct contexts: draft → published.
155
+ - Failure: trust -10. If trust < 20: → archived.
156
+
157
+ Args:
158
+ id: Skill ID.
159
+ success: Whether the skill's procedure worked correctly.
160
+ context: What task you were doing (used for distinct-context promotion).
161
+ notes: Additional notes (especially useful for failures — what went wrong).
162
+ """
163
+ result = record_skill_usage(skill_id=id, success=success, context=context, notes=notes)
164
+ if "error" in result:
165
+ return f"ERROR: {result['error']}"
166
+
167
+ promotion = result.pop('_promotion', None)
168
+ status = "SUCCESS" if success else "FAILURE"
169
+ msg = f"Skill {id} usage recorded: {status} (trust={result['trust_score']})"
170
+ if promotion:
171
+ msg += f"\n ⚡ PROMOTION: {promotion}"
172
+ return msg
173
+
174
+
175
+ def handle_skill_list(level: str = '', tag: str = '') -> str:
176
+ """List all skills, optionally filtered by level or tag.
177
+
178
+ Args:
179
+ level: Filter by level — trace, draft, published, archived.
180
+ tag: Filter by tag (e.g., 'chrome', 'deploy', 'shopify').
181
+ """
182
+ skills = list_skills(level=level, tag=tag)
183
+ if not skills:
184
+ filters = []
185
+ if level: filters.append(f"level={level}")
186
+ if tag: filters.append(f"tag={tag}")
187
+ return f"No skills found{' (' + ', '.join(filters) + ')' if filters else ''}."
188
+
189
+ lines = [f"SKILLS ({len(skills)}):"]
190
+ for s in skills:
191
+ fp = f" → {s['file_path']}" if s.get('file_path') else ""
192
+ used = f", last={s['last_used_at'][:10]}" if s.get('last_used_at') else ""
193
+ lines.append(
194
+ f" [{s['id']}] {s['name']} ({s['level']}, trust={s['trust_score']}, "
195
+ f"used={s['use_count']}x{used}){fp}"
196
+ )
197
+ return "\n".join(lines)
198
+
199
+
200
+ def handle_skill_merge(id1: str, id2: str, keep_id: str = '') -> str:
201
+ """Merge two similar skills into one. Combines tags, triggers, usage history.
202
+
203
+ The survivor keeps the higher trust score and all combined metadata.
204
+ The donor is deleted.
205
+
206
+ Args:
207
+ id1: First skill ID.
208
+ id2: Second skill ID.
209
+ keep_id: Which one to keep (default: higher trust score).
210
+ """
211
+ result = merge_skills(id1, id2, keep_id=keep_id)
212
+ if "error" in result:
213
+ return f"ERROR: {result['error']}"
214
+
215
+ merged_from = result.pop('_merged_from', '?')
216
+ return (
217
+ f"Skills merged. Kept {result['id']}, deleted {merged_from}.\n"
218
+ f" Trust: {result['trust_score']}, Uses: {result['use_count']}, "
219
+ f"Tags: {result['tags']}"
220
+ )
221
+
222
+
223
+ def handle_skill_stats() -> str:
224
+ """Show aggregate skill statistics: total count, by level, avg trust, usage rates."""
225
+ stats = get_skill_stats()
226
+ levels = stats.get('by_level', {})
227
+ lines = [
228
+ "SKILL STATS:",
229
+ f" Total: {stats['total']}",
230
+ f" By level: {', '.join(f'{k}={v}' for k, v in sorted(levels.items()))}",
231
+ f" Avg trust: {stats['avg_trust']}",
232
+ f" Total uses: {stats['total_uses']} (success rate: {stats['success_rate']}%)",
233
+ f" Uses last 7d: {stats['uses_last_7d']}",
234
+ ]
235
+ return "\n".join(lines)
236
+
237
+
238
+ # Plugin registration — TOOLS array consumed by plugin_loader.py
239
+ TOOLS = [
240
+ (handle_skill_create, "nexo_skill_create",
241
+ "Create a new skill (reusable procedure). Skills are step-by-step instructions for complex tasks. "
242
+ "Auto-promoted from draft→published after 2+ successful uses. ID must start with 'SK-'."),
243
+
244
+ (handle_skill_match, "nexo_skill_match",
245
+ "Find skills matching a task description. Call BEFORE starting multi-step tasks "
246
+ "to check if a reusable procedure exists. Returns top-3 matches by trust score."),
247
+
248
+ (handle_skill_get, "nexo_skill_get",
249
+ "Get a skill's full details including procedure, tags, triggers, and usage history."),
250
+
251
+ (handle_skill_result, "nexo_skill_result",
252
+ "Record the result of using a skill (success/failure). Auto-promotes draft→published "
253
+ "after 2+ successes, auto-archives if trust drops below 20."),
254
+
255
+ (handle_skill_list, "nexo_skill_list",
256
+ "List all skills, optionally filtered by level (trace/draft/published/archived) or tag."),
257
+
258
+ (handle_skill_merge, "nexo_skill_merge",
259
+ "Merge two similar skills into one. Combines tags, triggers, and usage history. "
260
+ "Survivor keeps the higher trust score."),
261
+
262
+ (handle_skill_stats, "nexo_skill_stats",
263
+ "Show aggregate skill statistics: count by level, average trust, usage rates."),
264
+ ]