nexo-brain 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -4
- package/bin/nexo-brain.js +238 -12
- package/bin/nexo.js +55 -0
- package/community/skills/.gitkeep +1 -0
- package/package.json +11 -3
- package/src/auto_update.py +193 -9
- package/src/cli.py +719 -0
- package/src/cognitive/_ingest.py +1 -1
- package/src/cognitive/_memory.py +4 -4
- package/src/crons/manifest.json +8 -0
- package/src/dashboard/app.py +700 -35
- package/src/dashboard/templates/adaptive.html +112 -218
- package/src/dashboard/templates/artifacts.html +133 -0
- package/src/dashboard/templates/backups.html +136 -0
- package/src/dashboard/templates/base.html +413 -0
- package/src/dashboard/templates/calendar.html +523 -654
- package/src/dashboard/templates/chat.html +356 -0
- package/src/dashboard/templates/claims.html +259 -0
- package/src/dashboard/templates/cortex.html +262 -0
- package/src/dashboard/templates/credentials.html +128 -0
- package/src/dashboard/templates/crons.html +370 -0
- package/src/dashboard/templates/dashboard.html +383 -578
- package/src/dashboard/templates/dreams.html +252 -0
- package/src/dashboard/templates/email.html +160 -0
- package/src/dashboard/templates/evolution.html +189 -0
- package/src/dashboard/templates/feed.html +249 -0
- package/src/dashboard/templates/followup_health.html +170 -0
- package/src/dashboard/templates/graph.html +191 -269
- package/src/dashboard/templates/guard.html +259 -0
- package/src/dashboard/templates/inbox.html +220 -346
- package/src/dashboard/templates/memory.html +317 -197
- package/src/dashboard/templates/operations.html +521 -698
- package/src/dashboard/templates/plugins.html +185 -0
- package/src/dashboard/templates/rules.html +246 -0
- package/src/dashboard/templates/sentiment.html +247 -0
- package/src/dashboard/templates/sessions.html +215 -182
- package/src/dashboard/templates/skills.html +329 -0
- package/src/dashboard/templates/somatic.html +68 -172
- package/src/dashboard/templates/triggers.html +133 -0
- package/src/dashboard/templates/trust.html +360 -0
- package/src/db/__init__.py +5 -0
- package/src/db/_schema.py +16 -1
- package/src/db/_sessions.py +22 -0
- package/src/db/_skills.py +980 -274
- package/src/doctor/__init__.py +1 -0
- package/src/doctor/formatters.py +52 -0
- package/src/doctor/models.py +44 -0
- package/src/doctor/orchestrator.py +42 -0
- package/src/doctor/providers/__init__.py +1 -0
- package/src/doctor/providers/boot.py +206 -0
- package/src/doctor/providers/deep.py +292 -0
- package/src/doctor/providers/runtime.py +686 -0
- package/src/evolution_cycle.py +86 -6
- package/src/hooks/post-compact.sh +5 -1
- package/src/hooks/pre-compact.sh +1 -1
- package/src/plugins/doctor.py +36 -0
- package/src/plugins/evolution.py +11 -3
- package/src/plugins/skills.py +135 -175
- package/src/requirements.txt +1 -0
- package/src/script_registry.py +322 -0
- package/src/scripts/deep-sleep/apply_findings.py +63 -48
- package/src/scripts/deep-sleep/extract-prompt.md +14 -0
- package/src/scripts/deep-sleep/synthesize-prompt.md +36 -0
- package/src/scripts/deep-sleep/synthesize.py +37 -1
- package/src/scripts/nexo-dashboard.sh +29 -0
- package/src/scripts/nexo-day-orchestrator.sh +139 -0
- package/src/scripts/nexo-evolution-run.py +141 -54
- package/src/scripts/nexo-learning-housekeep.py +1 -1
- package/src/scripts/nexo-watchdog.sh +1 -1
- package/src/server.py +9 -5
- package/src/skills/run-runtime-doctor/guide.md +12 -0
- package/src/skills/run-runtime-doctor/script.py +21 -0
- package/src/skills/run-runtime-doctor/skill.json +25 -0
- package/src/skills_runtime.py +347 -0
- package/src/tools_menu.py +3 -2
- package/src/tools_sessions.py +126 -0
- package/src/user_context.py +46 -0
- package/templates/nexo_helper.py +45 -0
- package/templates/script-template.py +44 -0
- package/templates/skill-script-template.py +39 -0
- package/templates/skill-template.md +33 -0
|
@@ -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 —
|
|
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("║" + "
|
|
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
|
|
package/src/tools_sessions.py
CHANGED
|
@@ -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())
|