nexo-brain 2.3.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +77 -8
  2. package/bin/nexo-brain.js +230 -22
  3. package/bin/nexo.js +55 -0
  4. package/community/skills/.gitkeep +1 -0
  5. package/package.json +5 -2
  6. package/src/auto_update.py +158 -8
  7. package/src/cli.py +605 -0
  8. package/src/cognitive/_ingest.py +1 -1
  9. package/src/cognitive/_memory.py +4 -4
  10. package/src/crons/manifest.json +8 -0
  11. package/src/dashboard/app.py +709 -37
  12. package/src/dashboard/templates/adaptive.html +112 -218
  13. package/src/dashboard/templates/artifacts.html +133 -0
  14. package/src/dashboard/templates/backups.html +136 -0
  15. package/src/dashboard/templates/base.html +413 -0
  16. package/src/dashboard/templates/calendar.html +523 -652
  17. package/src/dashboard/templates/chat.html +356 -0
  18. package/src/dashboard/templates/claims.html +259 -0
  19. package/src/dashboard/templates/cortex.html +262 -0
  20. package/src/dashboard/templates/credentials.html +128 -0
  21. package/src/dashboard/templates/crons.html +370 -0
  22. package/src/dashboard/templates/dashboard.html +384 -572
  23. package/src/dashboard/templates/dreams.html +252 -0
  24. package/src/dashboard/templates/email.html +160 -0
  25. package/src/dashboard/templates/evolution.html +189 -0
  26. package/src/dashboard/templates/feed.html +249 -0
  27. package/src/dashboard/templates/followup_health.html +170 -0
  28. package/src/dashboard/templates/graph.html +191 -269
  29. package/src/dashboard/templates/guard.html +259 -0
  30. package/src/dashboard/templates/inbox.html +220 -336
  31. package/src/dashboard/templates/memory.html +317 -197
  32. package/src/dashboard/templates/operations.html +498 -652
  33. package/src/dashboard/templates/plugins.html +185 -0
  34. package/src/dashboard/templates/rules.html +246 -0
  35. package/src/dashboard/templates/sentiment.html +247 -0
  36. package/src/dashboard/templates/sessions.html +215 -171
  37. package/src/dashboard/templates/skills.html +329 -0
  38. package/src/dashboard/templates/somatic.html +68 -172
  39. package/src/dashboard/templates/triggers.html +133 -0
  40. package/src/dashboard/templates/trust.html +360 -0
  41. package/src/db/__init__.py +5 -0
  42. package/src/db/_schema.py +25 -1
  43. package/src/db/_sessions.py +22 -0
  44. package/src/db/_skills.py +983 -252
  45. package/src/doctor/__init__.py +1 -0
  46. package/src/doctor/formatters.py +52 -0
  47. package/src/doctor/models.py +44 -0
  48. package/src/doctor/orchestrator.py +42 -0
  49. package/src/doctor/providers/__init__.py +1 -0
  50. package/src/doctor/providers/boot.py +206 -0
  51. package/src/doctor/providers/deep.py +292 -0
  52. package/src/doctor/providers/runtime.py +686 -0
  53. package/src/hooks/capture-tool-logs.sh +18 -4
  54. package/src/hooks/post-compact.sh +5 -1
  55. package/src/hooks/pre-compact.sh +1 -1
  56. package/src/plugin_loader.py +14 -0
  57. package/src/plugins/doctor.py +36 -0
  58. package/src/plugins/evolution.py +2 -1
  59. package/src/plugins/skills.py +135 -175
  60. package/src/requirements.txt +1 -0
  61. package/src/script_registry.py +322 -0
  62. package/src/scripts/deep-sleep/apply_findings.py +63 -33
  63. package/src/scripts/deep-sleep/collect.py +38 -9
  64. package/src/scripts/deep-sleep/extract-prompt.md +14 -0
  65. package/src/scripts/deep-sleep/synthesize-prompt.md +36 -0
  66. package/src/scripts/deep-sleep/synthesize.py +37 -1
  67. package/src/scripts/nexo-dashboard.sh +29 -0
  68. package/src/scripts/nexo-day-orchestrator.sh +139 -0
  69. package/src/scripts/nexo-evolution-run.py +2 -1
  70. package/src/scripts/nexo-learning-housekeep.py +1 -1
  71. package/src/scripts/nexo-watchdog.sh +1 -1
  72. package/src/server.py +9 -5
  73. package/src/skills/run-runtime-doctor/guide.md +12 -0
  74. package/src/skills/run-runtime-doctor/script.py +21 -0
  75. package/src/skills/run-runtime-doctor/skill.json +25 -0
  76. package/src/skills_runtime.py +347 -0
  77. package/src/tools_menu.py +3 -2
  78. package/src/tools_sessions.py +126 -0
  79. package/src/user_context.py +46 -0
  80. package/templates/nexo_helper.py +45 -0
  81. package/templates/script-template.py +44 -0
  82. package/templates/skill-script-template.py +39 -0
  83. package/templates/skill-template.md +33 -0
@@ -0,0 +1,347 @@
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 subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from db import (
18
+ approve_skill,
19
+ collect_skill_improvement_candidates,
20
+ collect_scriptable_skill_candidates,
21
+ get_featured_skills,
22
+ get_skill,
23
+ get_skill_execution_spec,
24
+ init_db,
25
+ materialize_personal_skill_definition,
26
+ record_skill_usage,
27
+ render_command_template,
28
+ sync_skill_directories,
29
+ update_skill,
30
+ )
31
+ from script_registry import doctor_script
32
+
33
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
34
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
35
+
36
+
37
+ def _parse_params(params) -> dict:
38
+ if isinstance(params, dict):
39
+ return params
40
+ if isinstance(params, str):
41
+ text = params.strip()
42
+ if not text:
43
+ return {}
44
+ return json.loads(text)
45
+ return {}
46
+
47
+
48
+ def _ensure_ready():
49
+ init_db()
50
+
51
+
52
+ def _resolve_mode(requested: str, skill: dict) -> str:
53
+ mode = (requested or "auto").strip().lower()
54
+ if mode in {"guide", "execute", "hybrid"}:
55
+ return mode
56
+ effective = str(skill.get("mode", "") or "").strip().lower()
57
+ if effective in {"guide", "execute", "hybrid"}:
58
+ return effective
59
+ if skill.get("file_path") and skill.get("content"):
60
+ return "hybrid"
61
+ if skill.get("file_path"):
62
+ return "execute"
63
+ return "guide"
64
+
65
+
66
+ def _summarize_skill(skill: dict) -> str:
67
+ steps = []
68
+ gotchas = []
69
+ try:
70
+ steps = json.loads(skill.get("steps", "[]"))
71
+ except json.JSONDecodeError:
72
+ pass
73
+ try:
74
+ gotchas = json.loads(skill.get("gotchas", "[]"))
75
+ except json.JSONDecodeError:
76
+ pass
77
+
78
+ lines = [
79
+ f"[{skill['id']}] {skill['name']}",
80
+ skill.get("description", "") or "(no description)",
81
+ ]
82
+ if steps:
83
+ lines.append("Steps:")
84
+ for index, step in enumerate(steps[:6], 1):
85
+ lines.append(f"{index}. {step}")
86
+ elif skill.get("content"):
87
+ lines.append(skill["content"][:800])
88
+ if gotchas:
89
+ lines.append("Gotchas:")
90
+ for gotcha in gotchas[:4]:
91
+ lines.append(f"- {gotcha}")
92
+ return "\n".join(lines).strip()
93
+
94
+
95
+ def _resolve_cli_command() -> list[str]:
96
+ installed = NEXO_HOME / "bin" / "nexo"
97
+ if installed.is_file():
98
+ return [str(installed)]
99
+ return [sys.executable, str(NEXO_CODE / "cli.py")]
100
+
101
+
102
+ def _run_skill_script(skill: dict, argv: list[str], timeout: int = 300) -> dict:
103
+ if not argv:
104
+ return {"returncode": 1, "stdout": "", "stderr": "No command to execute"}
105
+
106
+ env = {
107
+ **os.environ,
108
+ "NEXO_HOME": str(NEXO_HOME),
109
+ "NEXO_CODE": str(NEXO_CODE),
110
+ "NEXO_SKILL_ID": skill["id"],
111
+ "NEXO_SKILL_NAME": skill["name"],
112
+ }
113
+
114
+ cli_cmd = _resolve_cli_command()
115
+ cmd = [*cli_cmd, "scripts", "run", argv[0], *argv[1:]]
116
+ try:
117
+ result = subprocess.run(
118
+ cmd,
119
+ capture_output=True,
120
+ text=True,
121
+ timeout=timeout,
122
+ env=env,
123
+ )
124
+ return {
125
+ "returncode": result.returncode,
126
+ "stdout": result.stdout,
127
+ "stderr": result.stderr,
128
+ "command": cmd,
129
+ }
130
+ except subprocess.TimeoutExpired:
131
+ return {
132
+ "returncode": 124,
133
+ "stdout": "",
134
+ "stderr": f"Skill execution timed out after {timeout}s",
135
+ "command": cmd,
136
+ }
137
+
138
+
139
+ def get_featured_skill_summaries(limit: int = 5) -> list[dict]:
140
+ _ensure_ready()
141
+ sync_skill_directories()
142
+ featured = []
143
+ for skill in get_featured_skills(limit=limit):
144
+ triggers = []
145
+ try:
146
+ triggers = json.loads(skill.get("trigger_patterns", "[]"))
147
+ except json.JSONDecodeError:
148
+ pass
149
+ featured.append(
150
+ {
151
+ "id": skill["id"],
152
+ "name": skill["name"],
153
+ "mode": skill.get("mode", "guide"),
154
+ "execution_level": skill.get("execution_level", "none"),
155
+ "source_kind": skill.get("source_kind", "personal"),
156
+ "trust_score": skill.get("trust_score", 0),
157
+ "trigger_patterns": triggers[:3],
158
+ }
159
+ )
160
+ return featured
161
+
162
+
163
+ def apply_skill(skill_id: str, params=None, mode: str = "auto", dry_run: bool = False, context: str = "") -> dict:
164
+ _ensure_ready()
165
+ sync_skill_directories()
166
+ skill = get_skill(skill_id)
167
+ if not skill:
168
+ return {"ok": False, "error": f"Skill {skill_id} not found"}
169
+
170
+ effective_mode = _resolve_mode(mode, skill)
171
+ response = {
172
+ "ok": True,
173
+ "skill_id": skill["id"],
174
+ "skill_name": skill["name"],
175
+ "requested_mode": mode,
176
+ "resolved_mode": effective_mode,
177
+ "approval_state": {
178
+ "approval_required": bool(skill.get("approval_required", 0)),
179
+ "approved_at": skill.get("approved_at", ""),
180
+ "execution_level": skill.get("execution_level", "none"),
181
+ },
182
+ }
183
+
184
+ if effective_mode in {"guide", "hybrid"}:
185
+ response["guide_summary"] = _summarize_skill(skill)
186
+
187
+ if effective_mode in {"execute", "hybrid"}:
188
+ exec_spec = get_skill_execution_spec(skill_id)
189
+ if "error" in exec_spec:
190
+ response["ok"] = False
191
+ response["error"] = exec_spec["error"]
192
+ return response
193
+
194
+ if not skill.get("file_path"):
195
+ response["ok"] = False
196
+ response["error"] = f"Skill {skill_id} has no executable script"
197
+ return response
198
+
199
+ if exec_spec["execution_level"] in {"read-only", "local", "remote"} and not skill.get("approved_at"):
200
+ skill = approve_skill(skill_id, execution_level=exec_spec["execution_level"], approved_by="system:auto")
201
+ response["approval_state"] = {
202
+ "approval_required": bool(skill.get("approval_required", 0)),
203
+ "approved_at": skill.get("approved_at", ""),
204
+ "execution_level": skill.get("execution_level", exec_spec["execution_level"]),
205
+ }
206
+
207
+ doctor = doctor_script(skill["file_path"])
208
+ response["script_doctor"] = doctor
209
+ if doctor["status"] == "fail":
210
+ response["ok"] = False
211
+ response["error"] = "Skill script failed validation"
212
+ return response
213
+
214
+ rendered = render_command_template(skill, _parse_params(params))
215
+ if not rendered.get("ok"):
216
+ response["ok"] = False
217
+ response["error"] = "Invalid skill parameters"
218
+ response["param_errors"] = rendered.get("errors", [])
219
+ return response
220
+
221
+ argv = rendered["argv"] or [skill["file_path"]]
222
+ response["resolved_params"] = rendered["params"]
223
+ response["script_command"] = argv
224
+ if dry_run:
225
+ response["dry_run"] = True
226
+ return response
227
+
228
+ execution = _run_skill_script(skill, argv)
229
+ response["execution_result"] = execution
230
+ success = execution["returncode"] == 0
231
+ record = record_skill_usage(
232
+ skill_id=skill_id,
233
+ success=success,
234
+ context=context or skill["name"],
235
+ notes=(execution["stderr"] or execution["stdout"])[:500],
236
+ )
237
+ response["usage_recorded"] = {
238
+ "success": success,
239
+ "trust_score": record.get("trust_score"),
240
+ "level": record.get("level"),
241
+ "promotion": record.get("_promotion"),
242
+ }
243
+ if not success:
244
+ response["ok"] = False
245
+ response["error"] = f"Skill execution failed with exit {execution['returncode']}"
246
+
247
+ return response
248
+
249
+
250
+ def sync_skills() -> dict:
251
+ _ensure_ready()
252
+ return sync_skill_directories()
253
+
254
+
255
+ def approve_skill_execution(skill_id: str, execution_level: str = "", approved_by: str = "") -> dict:
256
+ _ensure_ready()
257
+ return approve_skill(skill_id, execution_level=execution_level, approved_by=approved_by)
258
+
259
+
260
+ def list_evolution_candidates() -> dict:
261
+ _ensure_ready()
262
+ sync_skill_directories()
263
+ return {
264
+ "scriptable": collect_scriptable_skill_candidates(),
265
+ "improvements": collect_skill_improvement_candidates(),
266
+ }
267
+
268
+
269
+ def auto_promote_skill_evolution(approved_by: str = "system:auto") -> dict:
270
+ """Convert mature guide skills into executable drafts without manual approval."""
271
+ _ensure_ready()
272
+ sync_skill_directories()
273
+ promoted = []
274
+ skipped = []
275
+ for candidate in collect_scriptable_skill_candidates():
276
+ skill = get_skill(candidate["id"])
277
+ if not skill or skill.get("file_path"):
278
+ continue
279
+
280
+ steps = candidate.get("steps") or []
281
+ gotchas = candidate.get("gotchas") or []
282
+ description = candidate.get("description", "") or "Automated skill generated from repeated successful usage."
283
+ lines = [
284
+ "#!/usr/bin/env python3",
285
+ '"""Auto-generated executable skill draft."""',
286
+ "import json",
287
+ "import sys",
288
+ "",
289
+ "def main() -> int:",
290
+ " payload = {",
291
+ f" 'skill_id': {json.dumps(candidate['id'])},",
292
+ f" 'skill_name': {json.dumps(candidate['name'])},",
293
+ f" 'description': {json.dumps(description)},",
294
+ f" 'steps': {json.dumps(steps, ensure_ascii=False)},",
295
+ f" 'gotchas': {json.dumps(gotchas, ensure_ascii=False)},",
296
+ " 'argv': sys.argv[1:],",
297
+ " }",
298
+ " print(json.dumps(payload, ensure_ascii=False))",
299
+ " return 0",
300
+ "",
301
+ 'if __name__ == "__main__":',
302
+ " raise SystemExit(main())",
303
+ "",
304
+ ]
305
+ update = update_skill(
306
+ candidate["id"],
307
+ mode=candidate.get("suggested_mode", "hybrid"),
308
+ execution_level=candidate.get("suggested_execution_level", "read-only"),
309
+ approval_required=0,
310
+ approved_by=approved_by,
311
+ )
312
+ if "error" in update:
313
+ skipped.append({"id": candidate["id"], "reason": update["error"]})
314
+ continue
315
+
316
+ materialized = materialize_personal_skill_definition(
317
+ {
318
+ "id": candidate["id"],
319
+ "name": candidate["name"],
320
+ "description": description,
321
+ "level": skill.get("level", "published"),
322
+ "mode": candidate.get("suggested_mode", "hybrid"),
323
+ "execution_level": candidate.get("suggested_execution_level", "read-only"),
324
+ "approved_by": approved_by,
325
+ "tags": json.loads(skill.get("tags", "[]")) if skill.get("tags") else [],
326
+ "trigger_patterns": candidate.get("trigger_patterns", []),
327
+ "source_sessions": candidate.get("source_sessions", []),
328
+ "steps": steps,
329
+ "gotchas": gotchas,
330
+ "content": skill.get("content", ""),
331
+ "command_template": {"argv": ["{{file_path}}"]},
332
+ "executable_entry": "script.py",
333
+ "script_body": "\n".join(lines),
334
+ }
335
+ )
336
+ if "error" in materialized:
337
+ skipped.append({"id": candidate["id"], "reason": materialized["error"]})
338
+ continue
339
+
340
+ promoted.append(
341
+ {
342
+ "id": candidate["id"],
343
+ "mode": candidate.get("suggested_mode", "hybrid"),
344
+ "execution_level": candidate.get("suggested_execution_level", "read-only"),
345
+ }
346
+ )
347
+ return {"promoted": promoted, "skipped": skipped}
package/src/tools_menu.py CHANGED
@@ -1,8 +1,9 @@
1
- """Menu generator — NEXO operations center."""
1
+ """Menu generator — operations center."""
2
2
 
3
3
  from datetime import datetime, timedelta
4
4
  import json
5
5
  import subprocess
6
+ from user_context import get_context as _get_ctx
6
7
  import sys
7
8
  from pathlib import Path
8
9
  from tools_sessions import handle_status
@@ -109,7 +110,7 @@ def handle_menu() -> str:
109
110
 
110
111
  lines = []
111
112
  lines.append("╔" + "═" * W + "╗")
112
- lines.append("║" + "NEXO — OPERATIONS CENTER".center(W) + "║")
113
+ lines.append("║" + f"{_get_ctx().assistant_name} — OPERATIONS CENTER".center(W) + "║")
113
114
  lines.append("║" + date_str.center(W) + "║")
114
115
  lines.append("╠" + "═" * W + "╣")
115
116
 
@@ -101,9 +101,74 @@ def handle_startup(task: str = "Startup", claude_session_id: str = "") -> str:
101
101
  age = _format_age(m["created_epoch"])
102
102
  lines.append(f" [{m['from_sid']}] ({age}): {m['text']}")
103
103
 
104
+ # Check LaunchAgent health (macOS only)
105
+ la_warnings = _check_launchagents()
106
+ if la_warnings:
107
+ lines.append("")
108
+ lines.append("⚠ LAUNCHAGENT MISMATCH (plist on disk ≠ loaded in memory):")
109
+ for w in la_warnings:
110
+ lines.append(f" {w}")
111
+ lines.append(" Fix: launchctl unload + load the affected plists, or restart.")
112
+
104
113
  return "\n".join(lines)
105
114
 
106
115
 
116
+ def _check_launchagents() -> list[str]:
117
+ """Compare on-disk plists with what launchctl has loaded. macOS only."""
118
+ import platform
119
+ if platform.system() != "Darwin":
120
+ return []
121
+
122
+ import os, subprocess, plistlib, glob
123
+
124
+ plist_dir = os.path.expanduser("~/Library/LaunchAgents")
125
+ warnings = []
126
+
127
+ for plist_path in glob.glob(os.path.join(plist_dir, "com.nexo.*.plist")):
128
+ label = os.path.basename(plist_path).replace(".plist", "")
129
+ try:
130
+ with open(plist_path, "rb") as f:
131
+ disk = plistlib.load(f)
132
+ disk_args = disk.get("ProgramArguments", [])
133
+
134
+ result = subprocess.run(
135
+ ["launchctl", "list", label],
136
+ capture_output=True, text=True, timeout=5
137
+ )
138
+ if result.returncode != 0:
139
+ warnings.append(f"{label}: not loaded (plist exists on disk)")
140
+ continue
141
+
142
+ # Parse loaded ProgramArguments from launchctl output
143
+ loaded_args = []
144
+ in_args = False
145
+ for line in result.stdout.splitlines():
146
+ if '"ProgramArguments"' in line:
147
+ in_args = True
148
+ continue
149
+ if in_args:
150
+ line = line.strip().rstrip(";")
151
+ if line == ");":
152
+ break
153
+ if line.startswith('"') and line.endswith('"'):
154
+ loaded_args.append(line.strip('"'))
155
+
156
+ if loaded_args and disk_args and loaded_args != disk_args:
157
+ # Check if loaded path points to /tmp or nonexistent path
158
+ stale = any("/tmp/" in a or not os.path.exists(a) for a in loaded_args if "/" in a)
159
+ if stale:
160
+ # Auto-repair: reload the plist
161
+ subprocess.run(["launchctl", "unload", plist_path], capture_output=True, timeout=5)
162
+ subprocess.run(["launchctl", "load", plist_path], capture_output=True, timeout=5)
163
+ warnings.append(f"{label}: AUTO-REPAIRED (was pointing to stale/tmp path, reloaded from disk)")
164
+ else:
165
+ warnings.append(f"{label}: loaded args differ from disk plist")
166
+ except Exception:
167
+ continue
168
+
169
+ return warnings
170
+
171
+
107
172
  def handle_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
108
173
  """Update session, check inbox + questions. Lightweight — no embeddings, no RAG.
109
174
 
@@ -209,6 +274,18 @@ def handle_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
209
274
  parts.append("")
210
275
  parts.append(f"⚠ DIARY_OVERDUE: {_hb_count} heartbeats, {int(age_seconds/60)}min active, no diary. Write nexo_session_diary_write NOW.")
211
276
 
277
+ # Guard check reminder: if context_hint mentions code editing and no guard_check this session
278
+ if context_hint and _hint_suggests_code_edit(context_hint):
279
+ try:
280
+ guard_used = conn.execute(
281
+ "SELECT COUNT(*) FROM guard_log WHERE session_id = ?", (sid,)
282
+ ).fetchone()[0]
283
+ if guard_used == 0:
284
+ parts.append("")
285
+ parts.append("⚠ GUARD REMINDER: You appear to be editing code but haven't called `nexo_guard_check` this session. Do it NOW before any edits.")
286
+ except Exception:
287
+ pass # guard_log table may not exist in older installs
288
+
212
289
  return "\n".join(parts)
213
290
 
214
291
 
@@ -445,11 +522,60 @@ def handle_smart_startup_query() -> str:
445
522
  lines.append("")
446
523
  lines.append(tone)
447
524
 
525
+ # Toolbox reminder: skills + behavioral learnings count
526
+ toolbox = _toolbox_summary(conn)
527
+ if toolbox:
528
+ lines.append("")
529
+ lines.append(toolbox)
530
+
448
531
  return "\n".join(lines)
449
532
  except Exception as e:
450
533
  return f"Smart startup query error: {e}"
451
534
 
452
535
 
536
+ def _hint_suggests_code_edit(hint: str) -> bool:
537
+ """Check if a heartbeat context_hint suggests the agent is editing code."""
538
+ hint_lower = hint.lower()
539
+ edit_signals = ['edit', 'fix', 'patch', 'modify', 'implement', 'refactor', 'add function',
540
+ 'change code', 'update script', 'write code', '.py', '.js', '.ts', '.php',
541
+ 'commit', 'arregl', 'modific', 'implement', 'correg']
542
+ return any(signal in hint_lower for signal in edit_signals)
543
+
544
+
545
+ def _toolbox_summary(conn) -> str:
546
+ """Quick count of available skills and behavioral learnings for startup reminder."""
547
+ try:
548
+ skill_count = conn.execute(
549
+ "SELECT COUNT(*) FROM skills"
550
+ ).fetchone()[0]
551
+ learning_count = conn.execute(
552
+ "SELECT COUNT(*) FROM learnings WHERE status = 'active' AND priority IN ('critical', 'high')"
553
+ ).fetchone()[0]
554
+ parts = []
555
+ if skill_count > 0:
556
+ parts.append(f"{skill_count} skills available — use `nexo_skill_match(task)` before multi-step tasks")
557
+ try:
558
+ from skills_runtime import get_featured_skill_summaries
559
+
560
+ featured = get_featured_skill_summaries(limit=3)
561
+ if featured:
562
+ parts.append("Featured skills:")
563
+ for skill in featured:
564
+ triggers = ", ".join(skill.get("trigger_patterns", [])[:2]) or "no triggers"
565
+ parts.append(
566
+ f"- {skill['id']} — {skill['mode']}/{skill['execution_level']} — triggers: {triggers}"
567
+ )
568
+ except Exception:
569
+ pass
570
+ if learning_count > 0:
571
+ parts.append(f"{learning_count} high-priority learnings — use `nexo_guard_check` before editing code")
572
+ if parts:
573
+ return "TOOLBOX REMINDER:\n " + "\n ".join(parts)
574
+ except Exception:
575
+ pass
576
+ return ""
577
+
578
+
453
579
  def handle_stop(sid: str) -> str:
454
580
  """Cleanly close a session, removing it from active sessions immediately."""
455
581
  _stop_keepalive(sid)
@@ -0,0 +1,46 @@
1
+ """User context singleton — loads operator/user identity from calibration.json."""
2
+ from __future__ import annotations
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ _ctx = None
8
+
9
+ class UserContext:
10
+ """Cached user/operator identity loaded once from calibration.json."""
11
+
12
+ def __init__(self):
13
+ nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
14
+ cal_path = nexo_home / "brain" / "calibration.json"
15
+ ver_path = nexo_home / "version.json"
16
+
17
+ self.assistant_name = "NEXO"
18
+ self.user_name = ""
19
+ self.user_language = "en"
20
+
21
+ # calibration.json has operator_name + user info
22
+ if cal_path.exists():
23
+ try:
24
+ cal = json.loads(cal_path.read_text())
25
+ self.assistant_name = cal.get("operator_name", "") or \
26
+ cal.get("user", {}).get("assistant_name", "") or "NEXO"
27
+ self.user_name = cal.get("user", {}).get("name", "")
28
+ self.user_language = cal.get("user", {}).get("language", "en")
29
+ except Exception:
30
+ pass
31
+
32
+ # Fallback: version.json also has operator_name
33
+ if self.assistant_name == "NEXO" and ver_path.exists():
34
+ try:
35
+ ver = json.loads(ver_path.read_text())
36
+ self.assistant_name = ver.get("operator_name", "") or "NEXO"
37
+ except Exception:
38
+ pass
39
+
40
+
41
+ def get_context() -> UserContext:
42
+ """Get or create the singleton UserContext."""
43
+ global _ctx
44
+ if _ctx is None:
45
+ _ctx = UserContext()
46
+ return _ctx
@@ -0,0 +1,45 @@
1
+ """NEXO Helper — vendorable utility for personal scripts.
2
+
3
+ Provides stable access to NEXO MCP tools via the CLI.
4
+ Copy this file next to your script or keep it in NEXO_HOME/templates/.
5
+
6
+ This module does NOT import any NEXO internals (db, server, cognitive).
7
+ All communication goes through the stable `nexo scripts call` CLI.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import subprocess
13
+
14
+
15
+ def run_nexo(args: list[str]) -> str:
16
+ """Run a nexo CLI command and return stdout.
17
+
18
+ Raises RuntimeError on non-zero exit.
19
+ """
20
+ result = subprocess.run(
21
+ ["nexo", *args],
22
+ capture_output=True,
23
+ text=True,
24
+ )
25
+ if result.returncode != 0:
26
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"nexo exited {result.returncode}")
27
+ return result.stdout
28
+
29
+
30
+ def call_tool(name: str, payload: dict | None = None) -> str:
31
+ """Call a NEXO MCP tool by name. Returns raw text output."""
32
+ args = ["scripts", "call", name, "--input", json.dumps(payload or {})]
33
+ return run_nexo(args)
34
+
35
+
36
+ def call_tool_text(name: str, payload: dict | None = None) -> str:
37
+ """Call a NEXO MCP tool and return text output."""
38
+ return call_tool(name, payload)
39
+
40
+
41
+ def call_tool_json(name: str, payload: dict | None = None) -> dict:
42
+ """Call a NEXO MCP tool and return parsed JSON output."""
43
+ args = ["scripts", "call", name, "--input", json.dumps(payload or {}), "--json-output"]
44
+ out = run_nexo(args)
45
+ return json.loads(out)
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env python3
2
+ # nexo: name=example-script
3
+ # nexo: description=Example personal script using the stable NEXO CLI
4
+ # nexo: runtime=python
5
+ # nexo: timeout=60
6
+ # nexo: tools=nexo_learning_search,nexo_schedule_status
7
+
8
+ """Example personal script for NEXO.
9
+
10
+ This template demonstrates:
11
+ - Inline metadata for auto-discovery
12
+ - Safe CLI calls through nexo_helper
13
+ - Timeout handling (via metadata)
14
+ - argparse for user arguments
15
+ - No direct DB access
16
+ - Clean exit codes
17
+ """
18
+
19
+ import argparse
20
+ import sys
21
+
22
+ # nexo_helper.py is in NEXO_HOME/templates/ — copy it next to your script
23
+ # or add the templates dir to your path
24
+ try:
25
+ from nexo_helper import call_tool_text
26
+ except ImportError:
27
+ import os
28
+ sys.path.insert(0, os.path.join(os.environ.get("NEXO_HOME", "~/.nexo"), "templates"))
29
+ from nexo_helper import call_tool_text
30
+
31
+
32
+ def main():
33
+ parser = argparse.ArgumentParser(description="Example NEXO personal script")
34
+ parser.add_argument("--query", default="cron", help="Search query for learnings")
35
+ args = parser.parse_args()
36
+
37
+ print(f"Searching learnings for: {args.query}")
38
+ result = call_tool_text("nexo_learning_search", {"query": args.query})
39
+ print(result)
40
+ return 0
41
+
42
+
43
+ if __name__ == "__main__":
44
+ raise SystemExit(main())