nexo-brain 2.5.1 → 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 +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 +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 +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
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
NEXO Catch-Up — Runs at boot/wake to recover any missed scheduled tasks.
|
|
4
|
-
|
|
5
|
-
Tasks are loaded dynamically from crons/manifest.json (single source of truth).
|
|
6
|
-
Only scheduled crons (with hour/minute) are recovered — interval-based crons
|
|
7
|
-
(immune, watchdog, auto-close) restart automatically via launchd/systemd.
|
|
2
|
+
"""NEXO Catch-Up — recover missed core cron windows after boot/wake.
|
|
8
3
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Uses cron/launchd weekday convention (0=Sunday) converted to Python (0=Monday).
|
|
4
|
+
Recovery is driven by the explicit manifest contract plus cron_runs.
|
|
5
|
+
Legacy .catchup-state.json is now only a fallback for pre-wrapper history.
|
|
12
6
|
"""
|
|
13
7
|
|
|
8
|
+
import fcntl
|
|
14
9
|
import json
|
|
15
10
|
import os
|
|
16
11
|
import subprocess
|
|
17
12
|
import sys
|
|
18
|
-
from datetime import datetime
|
|
13
|
+
from datetime import datetime
|
|
19
14
|
from pathlib import Path
|
|
20
15
|
|
|
16
|
+
_SCRIPT_DIR = Path(__file__).resolve().parent
|
|
17
|
+
_DEFAULT_RUNTIME_ROOT = _SCRIPT_DIR.parent
|
|
18
|
+
_runtime_root = Path(os.environ.get("NEXO_CODE", str(_DEFAULT_RUNTIME_ROOT)))
|
|
19
|
+
if str(_runtime_root) not in sys.path:
|
|
20
|
+
sys.path.insert(0, str(_runtime_root))
|
|
21
|
+
|
|
22
|
+
from cron_recovery import catchup_candidates
|
|
23
|
+
|
|
21
24
|
HOME = Path.home()
|
|
22
25
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(HOME / ".nexo")))
|
|
23
26
|
|
|
@@ -48,8 +51,10 @@ LOG_DIR = NEXO_HOME / "logs"
|
|
|
48
51
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
49
52
|
LOG_FILE = LOG_DIR / "catchup.log"
|
|
50
53
|
STATE_FILE = NEXO_HOME / "operations" / ".catchup-state.json"
|
|
54
|
+
LOCK_FILE = NEXO_HOME / "operations" / ".catchup.lock"
|
|
51
55
|
|
|
52
56
|
SCRIPTS = NEXO_HOME / "scripts"
|
|
57
|
+
WRAPPER = SCRIPTS / "nexo-cron-wrapper.sh"
|
|
53
58
|
|
|
54
59
|
# Resolve Python: prefer NEXO's venv, then the same Python running this script
|
|
55
60
|
def _resolve_python() -> str:
|
|
@@ -68,52 +73,6 @@ def _resolve_python() -> str:
|
|
|
68
73
|
return sys.executable
|
|
69
74
|
|
|
70
75
|
NEXO_PYTHON = _resolve_python()
|
|
71
|
-
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
|
|
72
|
-
# Look for manifest in NEXO_HOME first (packaged install), then NEXO_CODE (dev/repo)
|
|
73
|
-
_manifest_home = NEXO_HOME / "crons" / "manifest.json"
|
|
74
|
-
_manifest_code = NEXO_CODE / "crons" / "manifest.json"
|
|
75
|
-
MANIFEST = _manifest_home if _manifest_home.exists() else _manifest_code
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _load_tasks_from_manifest() -> list[tuple]:
|
|
79
|
-
"""Read scheduled tasks from manifest.json — single source of truth.
|
|
80
|
-
|
|
81
|
-
Only includes crons with a schedule (hour/minute). Excludes interval-based
|
|
82
|
-
crons (immune, watchdog, auto-close) and run_at_load (catchup itself).
|
|
83
|
-
Returns: list of (name, hour, minute, python_or_bash, script, weekday)
|
|
84
|
-
"""
|
|
85
|
-
if not MANIFEST.exists():
|
|
86
|
-
log(f"WARNING: manifest not found at {MANIFEST}, using empty task list")
|
|
87
|
-
return []
|
|
88
|
-
|
|
89
|
-
with open(MANIFEST) as f:
|
|
90
|
-
data = json.load(f)
|
|
91
|
-
|
|
92
|
-
tasks = []
|
|
93
|
-
for cron in data.get("crons", []):
|
|
94
|
-
schedule = cron.get("schedule")
|
|
95
|
-
if not schedule or "hour" not in schedule:
|
|
96
|
-
continue # Skip interval-based and run_at_load crons
|
|
97
|
-
if cron["id"] == "catchup":
|
|
98
|
-
continue # Don't catch up ourselves
|
|
99
|
-
|
|
100
|
-
script = cron["script"]
|
|
101
|
-
script_type = cron.get("type", "python")
|
|
102
|
-
interpreter = NEXO_PYTHON if script_type == "python" else "/bin/bash"
|
|
103
|
-
weekday = schedule.get("weekday")
|
|
104
|
-
|
|
105
|
-
tasks.append((
|
|
106
|
-
cron["id"],
|
|
107
|
-
schedule["hour"],
|
|
108
|
-
schedule["minute"],
|
|
109
|
-
interpreter,
|
|
110
|
-
Path(script).name,
|
|
111
|
-
weekday,
|
|
112
|
-
))
|
|
113
|
-
|
|
114
|
-
# Sort by hour, minute for correct execution order
|
|
115
|
-
tasks.sort(key=lambda t: (t[1], t[2]))
|
|
116
|
-
return tasks
|
|
117
76
|
|
|
118
77
|
|
|
119
78
|
def log(msg: str):
|
|
@@ -138,59 +97,48 @@ def save_state(state: dict):
|
|
|
138
97
|
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
139
98
|
|
|
140
99
|
|
|
141
|
-
def
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
# Python datetime.weekday() uses: 0=Monday, 6=Sunday
|
|
150
|
-
# Convert: manifest 0 (Sun) -> python 6, manifest 1 (Mon) -> python 0, etc.
|
|
151
|
-
py_weekday = (weekday - 1) % 7
|
|
152
|
-
days_since = (now.weekday() - py_weekday) % 7
|
|
153
|
-
target = now - timedelta(days=days_since)
|
|
154
|
-
target = target.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
155
|
-
if target > now:
|
|
156
|
-
target -= timedelta(weeks=1)
|
|
157
|
-
return target
|
|
158
|
-
|
|
159
|
-
# Daily task
|
|
160
|
-
if today_at <= now:
|
|
161
|
-
return today_at
|
|
162
|
-
else:
|
|
163
|
-
return today_at - timedelta(days=1)
|
|
164
|
-
|
|
100
|
+
def _resolve_runtime_command(script_type: str) -> str:
|
|
101
|
+
if script_type == "shell":
|
|
102
|
+
return "/bin/bash"
|
|
103
|
+
if script_type == "node":
|
|
104
|
+
return "node"
|
|
105
|
+
if script_type == "php":
|
|
106
|
+
return "php"
|
|
107
|
+
return NEXO_PYTHON
|
|
165
108
|
|
|
166
|
-
def should_run(task_name: str, hour: int, minute: int, state: dict, weekday: int = None) -> bool:
|
|
167
|
-
"""Check if task needs catch-up: last run was before last scheduled time."""
|
|
168
|
-
last_run_str = state.get(task_name)
|
|
169
|
-
last_scheduled = last_scheduled_time(hour, minute, weekday)
|
|
170
|
-
|
|
171
|
-
if not last_run_str:
|
|
172
|
-
# Never ran — should run
|
|
173
|
-
return True
|
|
174
109
|
|
|
110
|
+
def _acquire_lock():
|
|
111
|
+
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
handle = LOCK_FILE.open("w")
|
|
175
113
|
try:
|
|
176
|
-
|
|
177
|
-
except
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
114
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
115
|
+
except BlockingIOError:
|
|
116
|
+
handle.close()
|
|
117
|
+
return None
|
|
118
|
+
handle.write(str(os.getpid()))
|
|
119
|
+
handle.flush()
|
|
120
|
+
return handle
|
|
181
121
|
|
|
182
122
|
|
|
183
|
-
def run_task(
|
|
123
|
+
def run_task(candidate: dict, state: dict) -> bool:
|
|
184
124
|
"""Execute a task and update state."""
|
|
185
|
-
|
|
125
|
+
name = candidate["cron_id"]
|
|
126
|
+
script_name = Path(candidate["script"]).name
|
|
127
|
+
script_path = str(SCRIPTS / script_name)
|
|
186
128
|
if not Path(script_path).exists():
|
|
187
129
|
log(f" SKIP {name}: script not found ({script_path})")
|
|
188
130
|
return False
|
|
189
131
|
|
|
190
|
-
|
|
132
|
+
runtime_cmd = _resolve_runtime_command(candidate.get("type", "python"))
|
|
133
|
+
if WRAPPER.exists():
|
|
134
|
+
command = ["/bin/bash", str(WRAPPER), name, runtime_cmd, script_path]
|
|
135
|
+
else:
|
|
136
|
+
command = [runtime_cmd, script_path]
|
|
137
|
+
|
|
138
|
+
log(f" RUNNING {name}: {script_name}")
|
|
191
139
|
try:
|
|
192
140
|
result = subprocess.run(
|
|
193
|
-
|
|
141
|
+
command,
|
|
194
142
|
capture_output=True, text=True, timeout=21600,
|
|
195
143
|
env={**os.environ, "HOME": str(HOME), "NEXO_CATCHUP": "1"}
|
|
196
144
|
)
|
|
@@ -205,7 +153,7 @@ def run_task(name: str, python: str, script: str, state: dict) -> bool:
|
|
|
205
153
|
log(f" stderr: {result.stderr[:300]}")
|
|
206
154
|
return False
|
|
207
155
|
except subprocess.TimeoutExpired:
|
|
208
|
-
log(f" TIMEOUT {name} (
|
|
156
|
+
log(f" TIMEOUT {name} (21600s)")
|
|
209
157
|
return False
|
|
210
158
|
except Exception as e:
|
|
211
159
|
log(f" ERROR {name}: {e}")
|
|
@@ -214,28 +162,45 @@ def run_task(name: str, python: str, script: str, state: dict) -> bool:
|
|
|
214
162
|
|
|
215
163
|
def main():
|
|
216
164
|
log("=== NEXO Catch-Up starting (boot/wake) ===")
|
|
217
|
-
|
|
165
|
+
lock_handle = _acquire_lock()
|
|
166
|
+
if lock_handle is None:
|
|
167
|
+
log("Catch-Up already running; skipping overlapping invocation.")
|
|
168
|
+
return
|
|
218
169
|
|
|
219
|
-
|
|
220
|
-
tasks =
|
|
170
|
+
state = load_state()
|
|
171
|
+
tasks = catchup_candidates()
|
|
221
172
|
|
|
222
173
|
ran = 0
|
|
223
174
|
skipped = 0
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
175
|
+
skipped_out_of_window = 0
|
|
176
|
+
try:
|
|
177
|
+
for candidate in tasks:
|
|
178
|
+
name = candidate["cron_id"]
|
|
179
|
+
if not candidate.get("missed"):
|
|
180
|
+
skipped += 1
|
|
181
|
+
continue
|
|
182
|
+
if not candidate.get("within_window"):
|
|
183
|
+
skipped_out_of_window += 1
|
|
184
|
+
log(
|
|
185
|
+
f" SKIP {name}: missed window is {candidate['age_seconds']}s old "
|
|
186
|
+
f"(max_catchup_age={candidate['contract']['max_catchup_age']}s)"
|
|
187
|
+
)
|
|
188
|
+
continue
|
|
189
|
+
due_at = candidate["last_due_at"].astimezone().strftime("%Y-%m-%d %H:%M")
|
|
190
|
+
log(f" {name} — missed scheduled run due at {due_at}, catching up...")
|
|
191
|
+
if run_task(candidate, state):
|
|
228
192
|
ran += 1
|
|
229
|
-
|
|
230
|
-
|
|
193
|
+
finally:
|
|
194
|
+
lock_handle.close()
|
|
231
195
|
|
|
232
|
-
if ran == 0:
|
|
196
|
+
if ran == 0 and skipped_out_of_window == 0:
|
|
233
197
|
log("All tasks up to date, nothing to catch up.")
|
|
234
198
|
elif ran >= 3:
|
|
235
199
|
# Many tasks caught up — ask CLI to assess system state
|
|
236
200
|
_cli_post_catchup_assessment(ran, skipped, state)
|
|
237
201
|
else:
|
|
238
|
-
|
|
202
|
+
suffix = f", {skipped_out_of_window} outside recovery window" if skipped_out_of_window else ""
|
|
203
|
+
log(f"Caught up {ran} tasks, {skipped} already current{suffix}.")
|
|
239
204
|
|
|
240
205
|
log("=== Catch-Up complete ===")
|
|
241
206
|
|
|
@@ -34,17 +34,35 @@ SANDBOX_DIR = CLAUDE_DIR / "sandbox" / "workspace"
|
|
|
34
34
|
MAX_CONSECUTIVE_FAILURES = 3
|
|
35
35
|
MAX_SNAPSHOTS = 8
|
|
36
36
|
|
|
37
|
-
# ── Immutable files —
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
37
|
+
# ── Immutable files — split by risk tier ────────────────────────────────
|
|
38
|
+
# These remain locked even in managed mode because they can break bootstrap,
|
|
39
|
+
# persistence, or the evolution engine itself.
|
|
40
|
+
GLOBAL_IMMUTABLE_FILES = {
|
|
41
|
+
"db.py",
|
|
42
|
+
"server.py",
|
|
43
|
+
"plugin_loader.py",
|
|
44
|
+
"nexo-watchdog.sh",
|
|
45
|
+
"cortex-wrapper.py",
|
|
46
|
+
"CLAUDE.md",
|
|
47
|
+
"personality.md",
|
|
48
|
+
"user-profile.md",
|
|
49
|
+
"evolution_cycle.py",
|
|
50
|
+
"storage_router.py",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Managed mode may autoevolve behavior/tooling modules, but auto/review keep
|
|
54
|
+
# these guarded to stay conservative for public installs.
|
|
55
|
+
STANDARD_MODE_IMMUTABLE_FILES = {
|
|
56
|
+
"cognitive.py",
|
|
57
|
+
"knowledge_graph.py",
|
|
58
|
+
"tools_sessions.py",
|
|
59
|
+
"tools_coordination.py",
|
|
60
|
+
"tools_reminders.py",
|
|
61
|
+
"tools_reminders_crud.py",
|
|
62
|
+
"tools_learnings.py",
|
|
63
|
+
"tools_credentials.py",
|
|
64
|
+
"tools_task_history.py",
|
|
65
|
+
"tools_menu.py",
|
|
48
66
|
}
|
|
49
67
|
|
|
50
68
|
|
|
@@ -93,6 +111,13 @@ def _normalize_mode(mode: str) -> str:
|
|
|
93
111
|
}
|
|
94
112
|
return aliases.get(value, value if value in {"auto", "review", "managed"} else "auto")
|
|
95
113
|
|
|
114
|
+
|
|
115
|
+
def _immutable_files_for_mode(mode: str) -> set[str]:
|
|
116
|
+
normalized = _normalize_mode(mode)
|
|
117
|
+
if normalized == "managed":
|
|
118
|
+
return set(GLOBAL_IMMUTABLE_FILES)
|
|
119
|
+
return set(GLOBAL_IMMUTABLE_FILES) | set(STANDARD_MODE_IMMUTABLE_FILES)
|
|
120
|
+
|
|
96
121
|
# ── Claude CLI path ──────────────────────────────────────────────────────
|
|
97
122
|
def _resolve_claude_cli() -> Path:
|
|
98
123
|
"""Find claude CLI: saved path > PATH > common locations."""
|
|
@@ -199,7 +224,7 @@ def is_safe_path(filepath: str, mode: str = "auto") -> bool:
|
|
|
199
224
|
filename = Path(expanded).name
|
|
200
225
|
mode = _normalize_mode(mode)
|
|
201
226
|
|
|
202
|
-
if filename in
|
|
227
|
+
if filename in _immutable_files_for_mode(mode):
|
|
203
228
|
return False
|
|
204
229
|
|
|
205
230
|
prefixes = _managed_safe_prefixes() if mode in {"managed", "review"} else _public_safe_prefixes()
|