nexo-brain 2.5.1 → 2.6.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/.claude-plugin/plugin.json +33 -0
- package/.mcp.json +12 -0
- package/README.md +38 -26
- package/bin/nexo-brain.js +35 -32
- package/hooks/hooks.json +14 -0
- package/package.json +11 -4
- package/src/auto_update.py +44 -1
- package/src/cli.py +388 -23
- package/src/cron_recovery.py +283 -0
- package/src/crons/manifest.json +79 -21
- package/src/crons/sync.py +136 -31
- package/src/db/__init__.py +11 -0
- package/src/db/_personal_scripts.py +548 -0
- package/src/db/_schema.py +44 -1
- package/src/doctor/providers/runtime.py +272 -75
- package/src/evolution_cycle.py +4 -1
- package/src/nexo.db +0 -0
- package/src/plugins/personal_scripts.py +117 -0
- package/src/plugins/schedule.py +116 -27
- package/src/script_registry.py +877 -28
- package/src/scripts/nexo-catchup.py +74 -109
- package/src/scripts/nexo-evolution-run.py +37 -12
- package/src/scripts/nexo-watchdog.sh +242 -54
- package/src/tools_learnings.py +8 -0
- package/templates/launchagents/com.nexo.catchup.plist +7 -6
- package/templates/script-template.py +3 -0
- package/templates/script-template.sh +13 -0
- package/src/scripts/nexo-day-orchestrator.sh +0 -139
package/src/crons/sync.py
CHANGED
|
@@ -20,74 +20,159 @@ import json
|
|
|
20
20
|
import os
|
|
21
21
|
import platform
|
|
22
22
|
import plistlib
|
|
23
|
+
import shutil
|
|
23
24
|
import subprocess
|
|
24
25
|
import sys
|
|
25
26
|
from pathlib import Path
|
|
26
27
|
|
|
28
|
+
_CRONS_DIR = Path(__file__).resolve().parent
|
|
29
|
+
_DEFAULT_RUNTIME_ROOT = _CRONS_DIR.parent
|
|
30
|
+
_runtime_root = Path(os.environ.get("NEXO_CODE", str(_DEFAULT_RUNTIME_ROOT)))
|
|
31
|
+
if str(_runtime_root) not in sys.path:
|
|
32
|
+
sys.path.insert(0, str(_runtime_root))
|
|
33
|
+
|
|
34
|
+
from cron_recovery import should_run_at_load
|
|
35
|
+
|
|
27
36
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
28
|
-
|
|
37
|
+
SOURCE_ROOT = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
|
|
38
|
+
RUNTIME_ROOT = NEXO_HOME
|
|
29
39
|
MANIFEST = Path(__file__).resolve().parent / "manifest.json"
|
|
30
40
|
LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
|
|
31
41
|
LABEL_PREFIX = "com.nexo."
|
|
32
42
|
LOG_DIR = NEXO_HOME / "logs"
|
|
43
|
+
OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
|
|
44
|
+
RETIRED_CORE_FILES = (
|
|
45
|
+
Path("scripts") / "nexo-day-orchestrator.sh",
|
|
46
|
+
)
|
|
33
47
|
|
|
34
48
|
|
|
35
49
|
def log(msg: str):
|
|
36
50
|
print(f"[cron-sync] {msg}", flush=True)
|
|
37
51
|
|
|
38
52
|
|
|
53
|
+
def _sync_watchdog_hash_registry():
|
|
54
|
+
"""Keep the immutable-hash registry aligned with the runtime watchdog script."""
|
|
55
|
+
try:
|
|
56
|
+
watchdog_path = RUNTIME_ROOT / "scripts" / "nexo-watchdog.sh"
|
|
57
|
+
if not watchdog_path.exists():
|
|
58
|
+
return
|
|
59
|
+
registry_path = RUNTIME_ROOT / "scripts" / ".watchdog-hashes"
|
|
60
|
+
entries: dict[str, str] = {}
|
|
61
|
+
if registry_path.exists():
|
|
62
|
+
for line in registry_path.read_text().splitlines():
|
|
63
|
+
if "|" not in line:
|
|
64
|
+
continue
|
|
65
|
+
file_path, expected_hash = line.split("|", 1)
|
|
66
|
+
if file_path:
|
|
67
|
+
entries[file_path] = expected_hash
|
|
68
|
+
import hashlib
|
|
69
|
+
entries[str(watchdog_path)] = hashlib.sha256(watchdog_path.read_bytes()).hexdigest()
|
|
70
|
+
registry_path.write_text(
|
|
71
|
+
"\n".join(f"{file_path}|{digest}" for file_path, digest in sorted(entries.items())) + "\n"
|
|
72
|
+
)
|
|
73
|
+
except Exception as e:
|
|
74
|
+
log(f"WARNING: could not sync watchdog hash registry: {e}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _refresh_runtime_manifest():
|
|
78
|
+
"""Keep the installed crons manifest aligned with the source manifest."""
|
|
79
|
+
try:
|
|
80
|
+
runtime_manifest = RUNTIME_ROOT / "crons" / "manifest.json"
|
|
81
|
+
runtime_manifest.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
shutil.copy2(MANIFEST, runtime_manifest)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
log(f"WARNING: could not refresh runtime manifest: {e}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cleanup_retired_core_files():
|
|
88
|
+
"""Remove retired core runtime files that should no longer survive updates."""
|
|
89
|
+
for rel_path in RETIRED_CORE_FILES:
|
|
90
|
+
try:
|
|
91
|
+
target = RUNTIME_ROOT / rel_path
|
|
92
|
+
if target.exists():
|
|
93
|
+
if target.is_dir():
|
|
94
|
+
shutil.rmtree(target)
|
|
95
|
+
else:
|
|
96
|
+
target.unlink()
|
|
97
|
+
log(f" Removed retired core file: {rel_path}")
|
|
98
|
+
except Exception as e:
|
|
99
|
+
log(f"WARNING: could not remove retired core file {rel_path}: {e}")
|
|
100
|
+
|
|
101
|
+
|
|
39
102
|
def load_manifest() -> list[dict]:
|
|
40
103
|
with open(MANIFEST) as f:
|
|
41
104
|
data = json.load(f)
|
|
42
|
-
|
|
105
|
+
crons = data.get("crons", [])
|
|
106
|
+
|
|
107
|
+
enabled_optionals: dict[str, bool] = {}
|
|
108
|
+
if OPTIONALS_FILE.is_file():
|
|
109
|
+
try:
|
|
110
|
+
enabled_optionals = json.loads(OPTIONALS_FILE.read_text())
|
|
111
|
+
except Exception as e:
|
|
112
|
+
log(f"WARNING: could not read optionals.json: {e}")
|
|
113
|
+
|
|
114
|
+
filtered = []
|
|
115
|
+
for cron in crons:
|
|
116
|
+
optional_key = cron.get("optional")
|
|
117
|
+
if optional_key and not enabled_optionals.get(optional_key, False):
|
|
118
|
+
continue
|
|
119
|
+
filtered.append(cron)
|
|
120
|
+
return filtered
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _runtime_relative_path(src: Path) -> Path:
|
|
124
|
+
"""Return the path inside NEXO_HOME that mirrors the source tree."""
|
|
125
|
+
src = src.resolve()
|
|
126
|
+
try:
|
|
127
|
+
return src.relative_to(SOURCE_ROOT.resolve())
|
|
128
|
+
except Exception:
|
|
129
|
+
# Best effort fallback for unexpected inputs.
|
|
130
|
+
return Path("scripts") / src.name
|
|
43
131
|
|
|
44
132
|
|
|
45
|
-
def
|
|
46
|
-
"""Copy a script from
|
|
133
|
+
def _copy_into_runtime(src: Path) -> Path:
|
|
134
|
+
"""Copy a script or directory from the source tree into NEXO_HOME.
|
|
47
135
|
|
|
48
|
-
|
|
49
|
-
|
|
136
|
+
LaunchAgents should execute from NEXO_HOME, not directly from repo paths
|
|
137
|
+
under macOS-protected folders such as ~/Documents.
|
|
50
138
|
"""
|
|
51
|
-
|
|
52
|
-
|
|
139
|
+
dest = RUNTIME_ROOT / _runtime_relative_path(src)
|
|
140
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
53
141
|
|
|
54
142
|
if src.is_dir():
|
|
55
|
-
import shutil
|
|
56
|
-
dest = dest_dir / src.name
|
|
57
143
|
if dest.exists():
|
|
58
144
|
shutil.rmtree(dest)
|
|
59
145
|
shutil.copytree(src, dest)
|
|
60
146
|
return dest
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
shutil.copy2(src, dest)
|
|
147
|
+
|
|
148
|
+
shutil.copy2(src, dest)
|
|
149
|
+
if src.suffix in {".sh", ".py"} or os.access(src, os.X_OK):
|
|
65
150
|
dest.chmod(0o755)
|
|
66
|
-
|
|
151
|
+
return dest
|
|
67
152
|
|
|
68
153
|
|
|
69
154
|
def build_plist(cron: dict) -> dict:
|
|
70
155
|
"""Build a macOS LaunchAgent plist dict from a manifest entry."""
|
|
71
156
|
cron_id = cron["id"]
|
|
72
157
|
label = f"{LABEL_PREFIX}{cron_id}"
|
|
73
|
-
script_src =
|
|
158
|
+
script_src = SOURCE_ROOT / cron["script"]
|
|
74
159
|
script_type = cron.get("type", "python")
|
|
75
160
|
|
|
76
|
-
# Copy scripts
|
|
77
|
-
script_dest =
|
|
161
|
+
# Copy scripts into NEXO_HOME preserving the source tree layout.
|
|
162
|
+
script_dest = _copy_into_runtime(script_src)
|
|
78
163
|
script_path = str(script_dest)
|
|
79
164
|
|
|
80
165
|
# Also copy the wrapper and any subdirectories (e.g., deep-sleep/)
|
|
81
|
-
wrapper_src =
|
|
82
|
-
wrapper_dest =
|
|
166
|
+
wrapper_src = SOURCE_ROOT / "scripts" / "nexo-cron-wrapper.sh"
|
|
167
|
+
wrapper_dest = _copy_into_runtime(wrapper_src)
|
|
83
168
|
wrapper_path = str(wrapper_dest)
|
|
84
169
|
|
|
85
170
|
# Copy script subdirectories if they exist (e.g., deep-sleep/ for nexo-deep-sleep.sh)
|
|
86
171
|
script_name = script_src.stem # e.g., "nexo-deep-sleep"
|
|
87
172
|
subdir_name = script_name.replace("nexo-", "") # e.g., "deep-sleep"
|
|
88
|
-
subdir_src =
|
|
173
|
+
subdir_src = SOURCE_ROOT / "scripts" / subdir_name
|
|
89
174
|
if subdir_src.is_dir():
|
|
90
|
-
|
|
175
|
+
_copy_into_runtime(subdir_src)
|
|
91
176
|
|
|
92
177
|
if script_type == "shell":
|
|
93
178
|
program_args = ["/bin/bash", wrapper_path, cron_id, "/bin/bash", script_path]
|
|
@@ -118,17 +203,23 @@ def build_plist(cron: dict) -> dict:
|
|
|
118
203
|
+ "/Library/Frameworks/Python.framework/Versions/3.12/bin",
|
|
119
204
|
"HOME": str(Path.home()),
|
|
120
205
|
"NEXO_HOME": str(NEXO_HOME),
|
|
121
|
-
"NEXO_CODE": str(
|
|
206
|
+
"NEXO_CODE": str(RUNTIME_ROOT),
|
|
207
|
+
"NEXO_SOURCE_CODE": str(SOURCE_ROOT),
|
|
208
|
+
"NEXO_MANAGED_CORE_CRON": "1",
|
|
122
209
|
"PYTHONUNBUFFERED": "1",
|
|
123
210
|
},
|
|
124
211
|
}
|
|
125
212
|
|
|
126
213
|
# Schedule
|
|
127
|
-
if cron.get("
|
|
214
|
+
if cron.get("keep_alive"):
|
|
128
215
|
plist["RunAtLoad"] = True
|
|
129
|
-
|
|
216
|
+
plist["KeepAlive"] = True
|
|
217
|
+
else:
|
|
218
|
+
if should_run_at_load(cron):
|
|
219
|
+
plist["RunAtLoad"] = True
|
|
220
|
+
if "interval_seconds" in cron and not cron.get("keep_alive"):
|
|
130
221
|
plist["StartInterval"] = cron["interval_seconds"]
|
|
131
|
-
elif "schedule" in cron:
|
|
222
|
+
elif "schedule" in cron and not cron.get("keep_alive"):
|
|
132
223
|
cal = {}
|
|
133
224
|
s = cron["schedule"]
|
|
134
225
|
if "hour" in s:
|
|
@@ -170,6 +261,10 @@ def plist_needs_update(existing_path: Path, new_plist: dict) -> bool:
|
|
|
170
261
|
return True
|
|
171
262
|
if existing.get("RunAtLoad") != new_plist.get("RunAtLoad"):
|
|
172
263
|
return True
|
|
264
|
+
if existing.get("KeepAlive") != new_plist.get("KeepAlive"):
|
|
265
|
+
return True
|
|
266
|
+
if existing.get("EnvironmentVariables") != new_plist.get("EnvironmentVariables"):
|
|
267
|
+
return True
|
|
173
268
|
return False
|
|
174
269
|
|
|
175
270
|
|
|
@@ -244,8 +339,15 @@ def sync(dry_run: bool = False):
|
|
|
244
339
|
try:
|
|
245
340
|
with open(plist_path, "rb") as f:
|
|
246
341
|
existing = plistlib.load(f)
|
|
342
|
+
env = existing.get("EnvironmentVariables", {}) or {}
|
|
247
343
|
args = existing.get("ProgramArguments", [])
|
|
248
|
-
is_core =
|
|
344
|
+
is_core = env.get("NEXO_MANAGED_CORE_CRON") == "1"
|
|
345
|
+
if not is_core:
|
|
346
|
+
arg_blob = " ".join(str(a) for a in args)
|
|
347
|
+
is_core = (
|
|
348
|
+
"nexo-cron-wrapper.sh" in arg_blob
|
|
349
|
+
and (str(SOURCE_ROOT) in arg_blob or str(NEXO_HOME) in arg_blob)
|
|
350
|
+
)
|
|
249
351
|
except Exception:
|
|
250
352
|
is_core = False
|
|
251
353
|
|
|
@@ -255,6 +357,9 @@ def sync(dry_run: bool = False):
|
|
|
255
357
|
else:
|
|
256
358
|
log(f" SKIP (personal): {cron_id}")
|
|
257
359
|
|
|
360
|
+
_cleanup_retired_core_files()
|
|
361
|
+
_refresh_runtime_manifest()
|
|
362
|
+
_sync_watchdog_hash_registry()
|
|
258
363
|
log("Sync complete.")
|
|
259
364
|
|
|
260
365
|
|
|
@@ -265,7 +370,7 @@ def sync_linux(dry_run: bool = False):
|
|
|
265
370
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
266
371
|
|
|
267
372
|
manifest_crons = load_manifest()
|
|
268
|
-
wrapper_src =
|
|
373
|
+
wrapper_src = SOURCE_ROOT / "scripts" / "nexo-cron-wrapper.sh"
|
|
269
374
|
wrapper_dest = _copy_script_to_nexo_home(wrapper_src)
|
|
270
375
|
|
|
271
376
|
log(f"Manifest: {len(manifest_crons)} core crons")
|
|
@@ -278,13 +383,13 @@ def sync_linux(dry_run: bool = False):
|
|
|
278
383
|
|
|
279
384
|
for cron in manifest_crons:
|
|
280
385
|
cron_id = cron["id"]
|
|
281
|
-
script_src =
|
|
386
|
+
script_src = SOURCE_ROOT / cron["script"]
|
|
282
387
|
script_dest = _copy_script_to_nexo_home(script_src)
|
|
283
388
|
script_type = cron.get("type", "python")
|
|
284
389
|
|
|
285
390
|
# Copy subdirectories
|
|
286
391
|
subdir_name = script_src.stem.replace("nexo-", "")
|
|
287
|
-
subdir_src =
|
|
392
|
+
subdir_src = SOURCE_ROOT / "scripts" / subdir_name
|
|
288
393
|
if subdir_src.is_dir():
|
|
289
394
|
_copy_script_to_nexo_home(subdir_src)
|
|
290
395
|
|
|
@@ -306,7 +411,7 @@ Description=NEXO: {cron.get('description', cron_id)}
|
|
|
306
411
|
Type=oneshot
|
|
307
412
|
ExecStart={exec_cmd}
|
|
308
413
|
Environment=NEXO_HOME={NEXO_HOME}
|
|
309
|
-
Environment=NEXO_CODE={
|
|
414
|
+
Environment=NEXO_CODE={SOURCE_ROOT}
|
|
310
415
|
Environment=HOME={Path.home()}
|
|
311
416
|
StandardOutput=append:{stdout_log}
|
|
312
417
|
StandardError=append:{stderr_log}
|
package/src/db/__init__.py
CHANGED
|
@@ -93,6 +93,17 @@ from db._cron_runs import (
|
|
|
93
93
|
cron_run_start, cron_run_end, cron_runs_recent, cron_runs_summary,
|
|
94
94
|
)
|
|
95
95
|
|
|
96
|
+
# Personal scripts registry
|
|
97
|
+
from db._personal_scripts import (
|
|
98
|
+
upsert_personal_script, list_personal_scripts, get_personal_script,
|
|
99
|
+
delete_missing_personal_scripts, register_personal_script_schedule,
|
|
100
|
+
delete_missing_personal_schedules, list_personal_script_schedules,
|
|
101
|
+
get_personal_script_schedule, delete_personal_script_schedule,
|
|
102
|
+
delete_personal_script,
|
|
103
|
+
record_personal_script_run, sync_personal_scripts_registry,
|
|
104
|
+
get_personal_script_health_report,
|
|
105
|
+
)
|
|
106
|
+
|
|
96
107
|
# Skills
|
|
97
108
|
from db._skills import (
|
|
98
109
|
create_skill, get_skill, list_skills, search_skills,
|