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
|
@@ -11,17 +11,26 @@ import sys
|
|
|
11
11
|
import time
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
+
from cron_recovery import should_run_at_load
|
|
14
15
|
from doctor.models import DoctorCheck
|
|
15
16
|
|
|
16
17
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
17
18
|
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[2])))
|
|
18
19
|
LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
|
|
20
|
+
PROTECTED_MACOS_ROOTS = (
|
|
21
|
+
Path.home() / "Documents",
|
|
22
|
+
Path.home() / "Desktop",
|
|
23
|
+
Path.home() / "Downloads",
|
|
24
|
+
Path.home() / "Library" / "Mobile Documents",
|
|
25
|
+
)
|
|
19
26
|
|
|
20
27
|
# Freshness thresholds in seconds
|
|
21
28
|
IMMUNE_FRESHNESS = 3600 # 1 hour (runs every 30 min)
|
|
22
29
|
WATCHDOG_FRESHNESS = 3600 # 1 hour (runs every 30 min)
|
|
23
30
|
DEFAULT_CRON_THRESHOLD = 7200 # Fallback when manifest data is unavailable
|
|
24
31
|
SPECIAL_LAUNCHAGENT_IDS = {"prevent-sleep", "tcc-approve"}
|
|
32
|
+
SPECIAL_ENV_NORMALIZE_IDS = SPECIAL_LAUNCHAGENT_IDS
|
|
33
|
+
OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
|
|
25
34
|
|
|
26
35
|
|
|
27
36
|
def _file_age_seconds(path: Path) -> float | None:
|
|
@@ -64,11 +73,23 @@ def _parse_timestamp(value: str) -> dt.datetime | None:
|
|
|
64
73
|
return None
|
|
65
74
|
|
|
66
75
|
|
|
67
|
-
def
|
|
76
|
+
def _enabled_optionals() -> dict[str, bool]:
|
|
77
|
+
try:
|
|
78
|
+
if OPTIONALS_FILE.is_file():
|
|
79
|
+
data = json.loads(OPTIONALS_FILE.read_text())
|
|
80
|
+
if isinstance(data, dict):
|
|
81
|
+
return {str(k): bool(v) for k, v in data.items()}
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
return {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _enabled_manifest_crons() -> list[dict]:
|
|
68
88
|
manifest_candidates = [
|
|
69
89
|
NEXO_HOME / "crons" / "manifest.json",
|
|
70
90
|
NEXO_CODE / "crons" / "manifest.json",
|
|
71
91
|
]
|
|
92
|
+
optionals = _enabled_optionals()
|
|
72
93
|
for manifest_path in manifest_candidates:
|
|
73
94
|
if not manifest_path.is_file():
|
|
74
95
|
continue
|
|
@@ -77,87 +98,104 @@ def _cron_expectations() -> dict[str, dict]:
|
|
|
77
98
|
except Exception:
|
|
78
99
|
continue
|
|
79
100
|
|
|
80
|
-
|
|
101
|
+
enabled = []
|
|
81
102
|
for cron in data.get("crons", []):
|
|
82
103
|
cron_id = cron.get("id")
|
|
83
|
-
if not cron_id
|
|
104
|
+
if not cron_id:
|
|
105
|
+
continue
|
|
106
|
+
optional_key = cron.get("optional")
|
|
107
|
+
if optional_key and not optionals.get(optional_key, False):
|
|
84
108
|
continue
|
|
109
|
+
enabled.append(cron)
|
|
110
|
+
return enabled
|
|
111
|
+
return []
|
|
85
112
|
|
|
86
|
-
interval_seconds = cron.get("interval_seconds")
|
|
87
|
-
schedule = cron.get("schedule") or {}
|
|
88
|
-
if interval_seconds:
|
|
89
|
-
threshold = max(int(interval_seconds) * 3, int(interval_seconds) + 600)
|
|
90
|
-
label = f"every {int(interval_seconds) // 60}m"
|
|
91
|
-
elif "weekday" in schedule:
|
|
92
|
-
threshold = 8 * 86400
|
|
93
|
-
label = "weekly"
|
|
94
|
-
elif "hour" in schedule and "minute" in schedule:
|
|
95
|
-
threshold = 36 * 3600
|
|
96
|
-
label = "daily"
|
|
97
|
-
else:
|
|
98
|
-
threshold = DEFAULT_CRON_THRESHOLD
|
|
99
|
-
label = "custom"
|
|
100
113
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
114
|
+
def _cron_expectations() -> dict[str, dict]:
|
|
115
|
+
expectations = {}
|
|
116
|
+
for cron in _enabled_manifest_crons():
|
|
117
|
+
cron_id = cron.get("id")
|
|
118
|
+
if not cron_id or cron.get("keep_alive"):
|
|
119
|
+
continue
|
|
120
|
+
if cron.get("run_at_load") and not cron.get("interval_seconds") and not cron.get("schedule"):
|
|
121
|
+
continue
|
|
104
122
|
|
|
123
|
+
interval_seconds = cron.get("interval_seconds")
|
|
124
|
+
schedule = cron.get("schedule") or {}
|
|
125
|
+
if interval_seconds:
|
|
126
|
+
threshold = max(int(interval_seconds) * 3, int(interval_seconds) + 600)
|
|
127
|
+
label = f"every {int(interval_seconds) // 60}m"
|
|
128
|
+
elif "weekday" in schedule:
|
|
129
|
+
threshold = 8 * 86400
|
|
130
|
+
label = "weekly"
|
|
131
|
+
elif "hour" in schedule and "minute" in schedule:
|
|
132
|
+
threshold = 36 * 3600
|
|
133
|
+
label = "daily"
|
|
134
|
+
else:
|
|
135
|
+
threshold = DEFAULT_CRON_THRESHOLD
|
|
136
|
+
label = "custom"
|
|
105
137
|
|
|
106
|
-
|
|
107
|
-
return
|
|
108
|
-
cron_id
|
|
109
|
-
for cron_id, expected in _launchagent_schedule_expectations().items()
|
|
110
|
-
if expected.get("RunAtLoad") is True
|
|
111
|
-
}
|
|
138
|
+
expectations[cron_id] = {"threshold": threshold, "label": label}
|
|
139
|
+
return expectations
|
|
112
140
|
|
|
113
141
|
|
|
114
|
-
def
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
]
|
|
119
|
-
for manifest_path in manifest_candidates:
|
|
120
|
-
if not manifest_path.is_file():
|
|
142
|
+
def _run_at_load_cron_ids() -> set[str]:
|
|
143
|
+
ids: set[str] = set()
|
|
144
|
+
for cron_id, expected in _launchagent_schedule_expectations().items():
|
|
145
|
+
if expected.get("RunAtLoad") is not True:
|
|
121
146
|
continue
|
|
122
|
-
|
|
123
|
-
data = _load_json(manifest_path)
|
|
124
|
-
except Exception:
|
|
147
|
+
if expected.get("StartInterval") or expected.get("StartCalendarInterval") or expected.get("KeepAlive"):
|
|
125
148
|
continue
|
|
149
|
+
ids.add(cron_id)
|
|
150
|
+
return ids
|
|
126
151
|
|
|
127
|
-
expectations = {}
|
|
128
|
-
for cron in data.get("crons", []):
|
|
129
|
-
cron_id = cron.get("id")
|
|
130
|
-
if not cron_id:
|
|
131
|
-
continue
|
|
132
152
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
def _launchagent_schedule_expectations() -> dict[str, dict]:
|
|
154
|
+
expectations = {}
|
|
155
|
+
for cron in _enabled_manifest_crons():
|
|
156
|
+
cron_id = cron.get("id")
|
|
157
|
+
if not cron_id:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
expected = {
|
|
161
|
+
"StartInterval": None,
|
|
162
|
+
"StartCalendarInterval": None,
|
|
163
|
+
"RunAtLoad": None,
|
|
164
|
+
"KeepAlive": None,
|
|
165
|
+
"schedule_configured": False,
|
|
166
|
+
}
|
|
167
|
+
if cron.get("keep_alive"):
|
|
168
|
+
expected["RunAtLoad"] = True
|
|
169
|
+
expected["KeepAlive"] = True
|
|
170
|
+
expected["schedule_configured"] = True
|
|
171
|
+
elif "interval_seconds" in cron:
|
|
172
|
+
expected["StartInterval"] = int(cron["interval_seconds"])
|
|
173
|
+
expected["RunAtLoad"] = True if should_run_at_load(cron) else None
|
|
174
|
+
expected["schedule_configured"] = True
|
|
175
|
+
elif "schedule" in cron:
|
|
176
|
+
schedule = cron.get("schedule") or {}
|
|
177
|
+
cal = {}
|
|
178
|
+
if "hour" in schedule:
|
|
179
|
+
cal["Hour"] = schedule["hour"]
|
|
180
|
+
if "minute" in schedule:
|
|
181
|
+
cal["Minute"] = schedule["minute"]
|
|
182
|
+
if "weekday" in schedule:
|
|
183
|
+
cal["Weekday"] = schedule["weekday"]
|
|
184
|
+
expected["StartCalendarInterval"] = cal
|
|
185
|
+
expected["RunAtLoad"] = True if should_run_at_load(cron) else None
|
|
186
|
+
expected["schedule_configured"] = True
|
|
187
|
+
elif should_run_at_load(cron):
|
|
188
|
+
expected["RunAtLoad"] = True
|
|
189
|
+
expected["schedule_configured"] = True
|
|
190
|
+
expectations[cron_id] = expected
|
|
191
|
+
return expectations
|
|
155
192
|
|
|
156
193
|
|
|
157
194
|
def _managed_launchagent_plists() -> list[tuple[str, Path]]:
|
|
158
195
|
ids = set(SPECIAL_LAUNCHAGENT_IDS)
|
|
159
|
-
for cron_id in _launchagent_schedule_expectations().
|
|
160
|
-
|
|
196
|
+
for cron_id, expected in _launchagent_schedule_expectations().items():
|
|
197
|
+
if expected.get("schedule_configured"):
|
|
198
|
+
ids.add(cron_id)
|
|
161
199
|
|
|
162
200
|
plists = []
|
|
163
201
|
for cron_id in sorted(ids):
|
|
@@ -175,6 +213,43 @@ def _extract_launchctl_value(output: str, prefix: str) -> str | None:
|
|
|
175
213
|
return None
|
|
176
214
|
|
|
177
215
|
|
|
216
|
+
def _is_protected_macos_path(value: str | os.PathLike[str] | None) -> bool:
|
|
217
|
+
if not value or platform.system() != "Darwin":
|
|
218
|
+
return False
|
|
219
|
+
try:
|
|
220
|
+
raw = str(value).replace("~", str(Path.home()), 1)
|
|
221
|
+
candidate = Path(raw).expanduser().resolve(strict=False)
|
|
222
|
+
except Exception:
|
|
223
|
+
return False
|
|
224
|
+
return any(candidate == root or root in candidate.parents for root in PROTECTED_MACOS_ROOTS)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _plist_runtime_paths(plist_data: dict) -> list[str]:
|
|
228
|
+
paths: list[str] = []
|
|
229
|
+
env = plist_data.get("EnvironmentVariables") or {}
|
|
230
|
+
for key in ("NEXO_HOME", "NEXO_CODE"):
|
|
231
|
+
value = env.get(key)
|
|
232
|
+
if value:
|
|
233
|
+
paths.append(str(value))
|
|
234
|
+
for arg in plist_data.get("ProgramArguments") or []:
|
|
235
|
+
arg_str = str(arg)
|
|
236
|
+
if arg_str.startswith("/") or arg_str.startswith("~"):
|
|
237
|
+
paths.append(arg_str)
|
|
238
|
+
return paths
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _recent_permission_denial(cron_id: str, max_age_seconds: int = 7 * 86400) -> bool:
|
|
242
|
+
stderr_path = NEXO_HOME / "logs" / f"{cron_id}-stderr.log"
|
|
243
|
+
age = _file_age_seconds(stderr_path)
|
|
244
|
+
if age is None or age > max_age_seconds:
|
|
245
|
+
return False
|
|
246
|
+
try:
|
|
247
|
+
tail = "\n".join(stderr_path.read_text(errors="ignore").splitlines()[-50:])
|
|
248
|
+
except Exception:
|
|
249
|
+
return False
|
|
250
|
+
return "Operation not permitted" in tail
|
|
251
|
+
|
|
252
|
+
|
|
178
253
|
def _repair_launchagents(items: list[tuple[str, Path]]) -> tuple[bool, list[str]]:
|
|
179
254
|
evidence = []
|
|
180
255
|
uid = str(os.getuid())
|
|
@@ -199,6 +274,33 @@ def _repair_launchagents(items: list[tuple[str, Path]]) -> tuple[bool, list[str]
|
|
|
199
274
|
return ok, evidence
|
|
200
275
|
|
|
201
276
|
|
|
277
|
+
def _repair_special_launchagent_plists(items: list[tuple[str, Path]]) -> tuple[bool, list[str]]:
|
|
278
|
+
evidence: list[str] = []
|
|
279
|
+
ok = True
|
|
280
|
+
for cron_id, plist_path in items:
|
|
281
|
+
if cron_id not in SPECIAL_ENV_NORMALIZE_IDS:
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
with plist_path.open("rb") as fh:
|
|
285
|
+
plist_data = plistlib.load(fh)
|
|
286
|
+
env = plist_data.setdefault("EnvironmentVariables", {})
|
|
287
|
+
changed = False
|
|
288
|
+
if env.get("NEXO_CODE") != str(NEXO_HOME):
|
|
289
|
+
env["NEXO_CODE"] = str(NEXO_HOME)
|
|
290
|
+
changed = True
|
|
291
|
+
if env.get("NEXO_HOME") != str(NEXO_HOME):
|
|
292
|
+
env["NEXO_HOME"] = str(NEXO_HOME)
|
|
293
|
+
changed = True
|
|
294
|
+
if changed:
|
|
295
|
+
with plist_path.open("wb") as fh:
|
|
296
|
+
plistlib.dump(plist_data, fh)
|
|
297
|
+
evidence.append(f"com.nexo.{cron_id}: normalized special LaunchAgent env")
|
|
298
|
+
except Exception as e:
|
|
299
|
+
ok = False
|
|
300
|
+
evidence.append(f"com.nexo.{cron_id}: {e}")
|
|
301
|
+
return ok, evidence
|
|
302
|
+
|
|
303
|
+
|
|
202
304
|
def _sync_launchagents_from_manifest() -> tuple[bool, list[str]]:
|
|
203
305
|
sync_path = NEXO_CODE / "crons" / "sync.py"
|
|
204
306
|
if not sync_path.is_file():
|
|
@@ -456,15 +558,20 @@ def check_cron_freshness() -> DoctorCheck:
|
|
|
456
558
|
stale = []
|
|
457
559
|
expectations = _cron_expectations()
|
|
458
560
|
ignored_crons = _run_at_load_cron_ids()
|
|
561
|
+
tracked_crons = set(expectations)
|
|
459
562
|
now = time.time()
|
|
460
563
|
for row in rows:
|
|
461
564
|
cron_id = row[0]
|
|
462
565
|
if cron_id in ignored_crons:
|
|
463
566
|
continue
|
|
567
|
+
if cron_id not in tracked_crons:
|
|
568
|
+
continue
|
|
464
569
|
parsed = _parse_timestamp(row[1]) if row[1] else None
|
|
465
570
|
if parsed is None:
|
|
466
571
|
stale.append(f"{cron_id}: unreadable timestamp {row[1]!r}")
|
|
467
572
|
continue
|
|
573
|
+
if parsed.tzinfo is None:
|
|
574
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
468
575
|
|
|
469
576
|
age = now - parsed.timestamp()
|
|
470
577
|
expected = expectations.get(cron_id, {"threshold": DEFAULT_CRON_THRESHOLD, "label": "runtime default"})
|
|
@@ -485,7 +592,7 @@ def check_cron_freshness() -> DoctorCheck:
|
|
|
485
592
|
tier="runtime",
|
|
486
593
|
status="healthy",
|
|
487
594
|
severity="info",
|
|
488
|
-
summary=f"All {len(
|
|
595
|
+
summary=f"All {len(tracked_crons)} tracked crons ran recently",
|
|
489
596
|
)
|
|
490
597
|
except Exception as e:
|
|
491
598
|
return DoctorCheck(
|
|
@@ -522,6 +629,8 @@ def check_launchagent_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
522
629
|
problems = []
|
|
523
630
|
problem_items: list[tuple[str, Path]] = []
|
|
524
631
|
tmp_drift = False
|
|
632
|
+
tcc_risk = False
|
|
633
|
+
tcc_failure = False
|
|
525
634
|
schedule_expectations = _launchagent_schedule_expectations()
|
|
526
635
|
for cron_id, plist_path in managed:
|
|
527
636
|
label = f"com.nexo.{cron_id}"
|
|
@@ -560,6 +669,18 @@ def check_launchagent_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
560
669
|
problems.append(f"{label}: plist unreadable ({e})")
|
|
561
670
|
continue
|
|
562
671
|
|
|
672
|
+
protected_refs = [path for path in _plist_runtime_paths(plist_data) if _is_protected_macos_path(path)]
|
|
673
|
+
if protected_refs:
|
|
674
|
+
tcc_risk = True
|
|
675
|
+
if _recent_permission_denial(cron_id):
|
|
676
|
+
tcc_failure = True
|
|
677
|
+
problems.append(
|
|
678
|
+
f"{label}: recent 'Operation not permitted' while using protected macOS path {protected_refs[0]}"
|
|
679
|
+
)
|
|
680
|
+
else:
|
|
681
|
+
problems.append(f"{label}: runtime points into protected macOS path {protected_refs[0]}")
|
|
682
|
+
had_problem = True
|
|
683
|
+
|
|
563
684
|
for env_key in ("NEXO_HOME", "NEXO_CODE"):
|
|
564
685
|
expected_value = env.get(env_key)
|
|
565
686
|
if not expected_value:
|
|
@@ -572,16 +693,23 @@ def check_launchagent_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
572
693
|
tmp_drift = True
|
|
573
694
|
|
|
574
695
|
expected_schedule = schedule_expectations.get(cron_id)
|
|
575
|
-
if expected_schedule is not None:
|
|
696
|
+
if expected_schedule is not None and expected_schedule.get("schedule_configured"):
|
|
576
697
|
actual_schedule = {
|
|
577
698
|
"StartInterval": plist_data.get("StartInterval"),
|
|
578
699
|
"StartCalendarInterval": plist_data.get("StartCalendarInterval"),
|
|
579
700
|
"RunAtLoad": plist_data.get("RunAtLoad"),
|
|
701
|
+
"KeepAlive": plist_data.get("KeepAlive"),
|
|
580
702
|
}
|
|
581
|
-
|
|
703
|
+
target_schedule = {
|
|
704
|
+
"StartInterval": expected_schedule.get("StartInterval"),
|
|
705
|
+
"StartCalendarInterval": expected_schedule.get("StartCalendarInterval"),
|
|
706
|
+
"RunAtLoad": expected_schedule.get("RunAtLoad"),
|
|
707
|
+
"KeepAlive": expected_schedule.get("KeepAlive"),
|
|
708
|
+
}
|
|
709
|
+
if actual_schedule != target_schedule:
|
|
582
710
|
problems.append(
|
|
583
711
|
f"{label}: schedule drift "
|
|
584
|
-
f"(actual={actual_schedule}, expected={
|
|
712
|
+
f"(actual={actual_schedule}, expected={target_schedule})"
|
|
585
713
|
)
|
|
586
714
|
had_problem = True
|
|
587
715
|
|
|
@@ -600,28 +728,37 @@ def check_launchagent_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
600
728
|
check = DoctorCheck(
|
|
601
729
|
id="runtime.launchagents",
|
|
602
730
|
tier="runtime",
|
|
603
|
-
status="critical" if tmp_drift else "degraded",
|
|
604
|
-
severity="error" if tmp_drift else "warn",
|
|
605
|
-
summary=
|
|
731
|
+
status="critical" if (tmp_drift or tcc_failure) else "degraded",
|
|
732
|
+
severity="error" if (tmp_drift or tcc_failure) else "warn",
|
|
733
|
+
summary=(
|
|
734
|
+
f"LaunchAgent drift detected in {len(problems)} job(s)"
|
|
735
|
+
if not tcc_risk
|
|
736
|
+
else f"LaunchAgent drift or TCC/runtime path risk detected in {len(problems)} job(s)"
|
|
737
|
+
),
|
|
606
738
|
evidence=problems[:10],
|
|
607
739
|
repair_plan=[
|
|
608
740
|
"Reload the affected LaunchAgents from ~/Library/LaunchAgents",
|
|
609
741
|
"Re-sync core cron plists from crons/manifest.json if the schedule drifted",
|
|
610
742
|
"If any job is loaded from /tmp, boot it out before bootstrapping the real plist",
|
|
743
|
+
"If any core job points into Documents/Desktop/Downloads, re-sync it so it runs from NEXO_HOME instead",
|
|
611
744
|
],
|
|
612
|
-
escalation_prompt=
|
|
745
|
+
escalation_prompt=(
|
|
746
|
+
"Launchd is serving stale or drifted NEXO jobs. Compare loaded job paths with plist paths on disk, "
|
|
747
|
+
"and treat recent 'Operation not permitted' against Documents/Desktop/Downloads as a TCC/runtime path issue."
|
|
748
|
+
),
|
|
613
749
|
)
|
|
614
750
|
|
|
615
751
|
if fix:
|
|
616
752
|
sync_ok, sync_evidence = _sync_launchagents_from_manifest()
|
|
753
|
+
special_ok, special_evidence = _repair_special_launchagent_plists(problem_items)
|
|
617
754
|
repaired, repair_evidence = _repair_launchagents(problem_items)
|
|
618
|
-
if sync_ok and repaired:
|
|
755
|
+
if sync_ok and special_ok and repaired:
|
|
619
756
|
post_check = check_launchagent_integrity(fix=False)
|
|
620
757
|
if post_check.status == "healthy":
|
|
621
758
|
post_check.fixed = True
|
|
622
759
|
post_check.summary += " (fixed)"
|
|
623
760
|
return post_check
|
|
624
|
-
check.evidence.extend((sync_evidence + repair_evidence)[:10])
|
|
761
|
+
check.evidence.extend((sync_evidence + special_evidence + repair_evidence)[:10])
|
|
625
762
|
return check
|
|
626
763
|
|
|
627
764
|
|
|
@@ -674,6 +811,65 @@ def check_skill_health(fix: bool = False) -> DoctorCheck:
|
|
|
674
811
|
)
|
|
675
812
|
|
|
676
813
|
|
|
814
|
+
def check_personal_script_registry(fix: bool = False) -> DoctorCheck:
|
|
815
|
+
"""Check the DB-backed personal script registry against filesystem/plists."""
|
|
816
|
+
try:
|
|
817
|
+
from db import init_db, get_personal_script_health_report
|
|
818
|
+
from script_registry import sync_personal_scripts
|
|
819
|
+
|
|
820
|
+
init_db()
|
|
821
|
+
sync_personal_scripts(prune_missing=True)
|
|
822
|
+
report = get_personal_script_health_report(fix=fix)
|
|
823
|
+
except Exception as e:
|
|
824
|
+
return DoctorCheck(
|
|
825
|
+
id="runtime.personal_scripts",
|
|
826
|
+
tier="runtime",
|
|
827
|
+
status="degraded",
|
|
828
|
+
severity="warn",
|
|
829
|
+
summary=f"Personal scripts registry check failed: {e}",
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
issues = report.get("issues", [])
|
|
833
|
+
if not issues:
|
|
834
|
+
audit = report.get("schedule_audit", {}).get("summary", {})
|
|
835
|
+
summary = (
|
|
836
|
+
f"Personal scripts registered "
|
|
837
|
+
f"({report.get('scripts', 0)} scripts, {report.get('schedules', 0)} schedules"
|
|
838
|
+
f", {audit.get('healthy', report.get('schedules', 0))} managed)"
|
|
839
|
+
)
|
|
840
|
+
if fix:
|
|
841
|
+
summary += " (fixed)"
|
|
842
|
+
return DoctorCheck(
|
|
843
|
+
id="runtime.personal_scripts",
|
|
844
|
+
tier="runtime",
|
|
845
|
+
status="healthy",
|
|
846
|
+
severity="info",
|
|
847
|
+
summary=summary,
|
|
848
|
+
fixed=fix,
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
errors = [issue for issue in issues if issue.get("severity") == "error"]
|
|
852
|
+
warnings = [issue for issue in issues if issue.get("severity") != "error"]
|
|
853
|
+
return DoctorCheck(
|
|
854
|
+
id="runtime.personal_scripts",
|
|
855
|
+
tier="runtime",
|
|
856
|
+
status="critical" if errors else "degraded",
|
|
857
|
+
severity="error" if errors else "warn",
|
|
858
|
+
summary=f"Personal scripts registry issues detected in {len(issues)} item(s)",
|
|
859
|
+
evidence=[issue["message"] for issue in issues[:10]],
|
|
860
|
+
repair_plan=[
|
|
861
|
+
"Run nexo scripts sync to reconcile filesystem scripts and personal LaunchAgents",
|
|
862
|
+
"Run nexo scripts reconcile so declared schedules are recreated through the official flow",
|
|
863
|
+
"Use nexo doctor --tier runtime --fix to apply the safe reconcile path for declared schedules",
|
|
864
|
+
"Keep personal scripts in NEXO_HOME/scripts so updates do not collide with core",
|
|
865
|
+
],
|
|
866
|
+
escalation_prompt=(
|
|
867
|
+
"Personal script metadata, files, and personal cron schedules are out of sync. "
|
|
868
|
+
"Reconcile NEXO_HOME/scripts with personal LaunchAgents without treating them as core crons."
|
|
869
|
+
),
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
|
|
677
873
|
def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
|
|
678
874
|
"""Run all runtime-tier checks. Read-only by default."""
|
|
679
875
|
return [
|
|
@@ -682,5 +878,6 @@ def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
|
|
|
682
878
|
check_stale_sessions(),
|
|
683
879
|
check_cron_freshness(),
|
|
684
880
|
check_launchagent_integrity(fix=fix),
|
|
881
|
+
check_personal_script_registry(fix=fix),
|
|
685
882
|
check_skill_health(fix=fix),
|
|
686
883
|
]
|
package/src/evolution_cycle.py
CHANGED
|
@@ -271,12 +271,15 @@ def build_evolution_prompt(week_data: dict, objective: dict) -> str:
|
|
|
271
271
|
if mode == "review":
|
|
272
272
|
mode_desc = "review-only, nothing executes automatically"
|
|
273
273
|
safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/, ~/.nexo/brain/"
|
|
274
|
+
immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, cognitive.py, knowledge_graph.py, tools_*.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md"
|
|
274
275
|
elif mode == "managed":
|
|
275
276
|
mode_desc = f"owner-managed, max {max_auto} auto-applied changes with rollback and followups"
|
|
276
277
|
safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/, ~/.nexo/brain/, NEXO_CODE/src, repo bin/docs/templates/tests"
|
|
278
|
+
immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md, personality.md, user-profile.md"
|
|
277
279
|
else:
|
|
278
280
|
mode_desc = f"public auto, max {max_auto} auto-applied changes in personal safe zones"
|
|
279
281
|
safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/"
|
|
282
|
+
immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, cognitive.py, knowledge_graph.py, tools_*.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md"
|
|
280
283
|
|
|
281
284
|
prompt = f"""You are NEXO Evolution — the weekly self-improvement cycle.
|
|
282
285
|
|
|
@@ -311,7 +314,7 @@ LOOK FOR:
|
|
|
311
314
|
|
|
312
315
|
SAFETY:
|
|
313
316
|
- Safe zones for this mode: {safe_zones}
|
|
314
|
-
- IMMUTABLE files (never touch
|
|
317
|
+
- IMMUTABLE files (never touch in this mode): {immutable_files}
|
|
315
318
|
- Every change needs: what file, what to change, why, risk, how to verify
|
|
316
319
|
- AUTO changes must be deterministic. If the edit is ambiguous, risky, or needs human taste, mark it as "propose".
|
|
317
320
|
- In managed mode, failed AUTO changes will be rolled back automatically and turned into followups with evidence.
|
package/src/nexo.db
ADDED
|
File without changes
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""NEXO Personal Scripts — registry-backed management for user scripts."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from db import init_db, list_personal_scripts, list_personal_script_schedules
|
|
6
|
+
from plugins.schedule import handle_schedule_add
|
|
7
|
+
from script_registry import (
|
|
8
|
+
classify_scripts_dir,
|
|
9
|
+
create_script,
|
|
10
|
+
ensure_personal_schedules,
|
|
11
|
+
reconcile_personal_scripts,
|
|
12
|
+
remove_personal_script,
|
|
13
|
+
sync_personal_scripts,
|
|
14
|
+
unschedule_personal_script,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def handle_personal_scripts_sync() -> str:
|
|
19
|
+
init_db()
|
|
20
|
+
result = sync_personal_scripts()
|
|
21
|
+
return json.dumps(result, ensure_ascii=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def handle_personal_scripts_classify() -> str:
|
|
25
|
+
return json.dumps(classify_scripts_dir(), ensure_ascii=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def handle_personal_scripts_list(include_schedules: bool = True) -> str:
|
|
29
|
+
init_db()
|
|
30
|
+
sync_personal_scripts()
|
|
31
|
+
scripts = list_personal_scripts()
|
|
32
|
+
if include_schedules:
|
|
33
|
+
return json.dumps({"scripts": scripts}, ensure_ascii=False)
|
|
34
|
+
|
|
35
|
+
simplified = []
|
|
36
|
+
for script in scripts:
|
|
37
|
+
simplified.append({
|
|
38
|
+
"id": script["id"],
|
|
39
|
+
"name": script["name"],
|
|
40
|
+
"description": script.get("description", ""),
|
|
41
|
+
"runtime": script.get("runtime", "unknown"),
|
|
42
|
+
"path": script["path"],
|
|
43
|
+
"has_schedule": script.get("has_schedule", False),
|
|
44
|
+
})
|
|
45
|
+
return json.dumps({"scripts": simplified}, ensure_ascii=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def handle_personal_script_create(
|
|
49
|
+
name: str,
|
|
50
|
+
description: str = "",
|
|
51
|
+
runtime: str = "python",
|
|
52
|
+
schedule: str = "",
|
|
53
|
+
interval_seconds: int = 0,
|
|
54
|
+
) -> str:
|
|
55
|
+
init_db()
|
|
56
|
+
created = create_script(name, description=description, runtime=runtime)
|
|
57
|
+
if schedule or interval_seconds:
|
|
58
|
+
cron_id = created["name"]
|
|
59
|
+
handle_schedule_add(
|
|
60
|
+
cron_id=cron_id,
|
|
61
|
+
script=created["path"],
|
|
62
|
+
schedule=schedule,
|
|
63
|
+
interval_seconds=interval_seconds,
|
|
64
|
+
description=description,
|
|
65
|
+
script_type=runtime,
|
|
66
|
+
)
|
|
67
|
+
sync_result = sync_personal_scripts()
|
|
68
|
+
created["sync"] = sync_result
|
|
69
|
+
return json.dumps(created, ensure_ascii=False)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def handle_personal_script_schedules() -> str:
|
|
73
|
+
init_db()
|
|
74
|
+
sync_personal_scripts()
|
|
75
|
+
return json.dumps({"schedules": list_personal_script_schedules()}, ensure_ascii=False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def handle_personal_scripts_reconcile(dry_run: bool = False) -> str:
|
|
79
|
+
init_db()
|
|
80
|
+
return json.dumps(reconcile_personal_scripts(dry_run=dry_run), ensure_ascii=False)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def handle_personal_scripts_ensure_schedules(dry_run: bool = False) -> str:
|
|
84
|
+
init_db()
|
|
85
|
+
return json.dumps(ensure_personal_schedules(dry_run=dry_run), ensure_ascii=False)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def handle_personal_script_unschedule(name: str) -> str:
|
|
89
|
+
init_db()
|
|
90
|
+
return json.dumps(unschedule_personal_script(name), ensure_ascii=False)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def handle_personal_script_remove(name: str, keep_file: bool = False) -> str:
|
|
94
|
+
init_db()
|
|
95
|
+
return json.dumps(remove_personal_script(name, keep_file=keep_file), ensure_ascii=False)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
TOOLS = [
|
|
99
|
+
(handle_personal_scripts_sync, "nexo_personal_scripts_sync",
|
|
100
|
+
"Sync personal scripts and personal cron schedules from filesystem and LaunchAgents into the registry."),
|
|
101
|
+
(handle_personal_scripts_classify, "nexo_personal_scripts_classify",
|
|
102
|
+
"Classify files in NEXO_HOME/scripts into personal, core, ignored, and non-script buckets."),
|
|
103
|
+
(handle_personal_scripts_list, "nexo_personal_scripts_list",
|
|
104
|
+
"List personal scripts known to NEXO, optionally including attached schedules."),
|
|
105
|
+
(handle_personal_script_create, "nexo_personal_script_create",
|
|
106
|
+
"Create a new personal script in NEXO_HOME/scripts, register it, and optionally attach a schedule."),
|
|
107
|
+
(handle_personal_script_schedules, "nexo_personal_script_schedules",
|
|
108
|
+
"List registered personal script schedules."),
|
|
109
|
+
(handle_personal_scripts_reconcile, "nexo_personal_scripts_reconcile",
|
|
110
|
+
"Classify, sync, and ensure declared personal schedules so NEXO_HOME/scripts and personal crons stay aligned."),
|
|
111
|
+
(handle_personal_scripts_ensure_schedules, "nexo_personal_scripts_ensure_schedules",
|
|
112
|
+
"Create or repair personal script schedules declared in inline metadata."),
|
|
113
|
+
(handle_personal_script_unschedule, "nexo_personal_script_unschedule",
|
|
114
|
+
"Remove all personal schedules attached to a script without touching core crons."),
|
|
115
|
+
(handle_personal_script_remove, "nexo_personal_script_remove",
|
|
116
|
+
"Remove a personal script from the registry and optionally delete its file after unscheduling it."),
|
|
117
|
+
]
|