nexo-brain 2.5.0 → 2.6.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.
- package/.claude-plugin/plugin.json +33 -0
- package/.mcp.json +12 -0
- package/README.md +48 -23
- package/bin/nexo-brain.js +65 -33
- package/hooks/hooks.json +14 -0
- package/package.json +15 -3
- package/src/auto_update.py +79 -2
- package/src/cli.py +490 -11
- package/src/cron_recovery.py +283 -0
- package/src/crons/manifest.json +79 -21
- package/src/crons/sync.py +132 -27
- 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 +90 -7
- package/src/nexo.db +0 -0
- package/src/plugins/evolution.py +9 -2
- 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 +178 -67
- 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
|
+
|
|
43
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
|
|
44
131
|
|
|
45
|
-
def _copy_script_to_nexo_home(src: Path) -> Path:
|
|
46
|
-
"""Copy a script from NEXO_CODE to NEXO_HOME/scripts/ for Sandbox compatibility.
|
|
47
132
|
|
|
48
|
-
|
|
49
|
-
|
|
133
|
+
def _copy_into_runtime(src: Path) -> Path:
|
|
134
|
+
"""Copy a script or directory from the source tree into NEXO_HOME.
|
|
135
|
+
|
|
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
|
|
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,
|