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
|
@@ -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
|
@@ -34,14 +34,83 @@ PROMPT_FILE = _resolve_evolution_file("evolution-prompt.md")
|
|
|
34
34
|
MAX_SNAPSHOTS = 8
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def _normalize_dimensions(raw: dict | None) -> dict:
|
|
38
|
+
normalized = {}
|
|
39
|
+
for key, value in (raw or {}).items():
|
|
40
|
+
canonical_key = "agi" if key == "agi_readiness" else key
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
normalized[canonical_key] = {
|
|
43
|
+
"current": int(value.get("current", 0) or 0),
|
|
44
|
+
"target": int(value.get("target", 0) or 0),
|
|
45
|
+
}
|
|
46
|
+
else:
|
|
47
|
+
normalized[canonical_key] = {
|
|
48
|
+
"current": 0,
|
|
49
|
+
"target": int(value or 0),
|
|
50
|
+
}
|
|
51
|
+
return normalized
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def normalize_objective(obj: dict | None) -> dict:
|
|
55
|
+
"""Upgrade legacy objective files to the canonical schema."""
|
|
56
|
+
source = dict(obj or {})
|
|
57
|
+
|
|
58
|
+
if "evolution_mode" in source:
|
|
59
|
+
mode = str(source.get("evolution_mode") or "auto").strip().lower()
|
|
60
|
+
else:
|
|
61
|
+
legacy_mode = str(source.get("review_mode") or "").strip().lower()
|
|
62
|
+
if legacy_mode in {"manual", "review"}:
|
|
63
|
+
mode = "review"
|
|
64
|
+
elif legacy_mode in {"managed", "hybrid", "owner", "core"}:
|
|
65
|
+
mode = "managed"
|
|
66
|
+
else:
|
|
67
|
+
mode = "auto"
|
|
68
|
+
|
|
69
|
+
if mode not in {"auto", "review", "managed"}:
|
|
70
|
+
mode = "auto"
|
|
71
|
+
|
|
72
|
+
dimensions = source.get("dimensions")
|
|
73
|
+
if not isinstance(dimensions, dict) or not dimensions:
|
|
74
|
+
dimensions = _normalize_dimensions(source.get("dimension_targets"))
|
|
75
|
+
else:
|
|
76
|
+
dimensions = _normalize_dimensions(dimensions)
|
|
77
|
+
|
|
78
|
+
defaults = {
|
|
79
|
+
"episodic_memory": {"current": 0, "target": 90},
|
|
80
|
+
"autonomy": {"current": 0, "target": 80},
|
|
81
|
+
"proactivity": {"current": 0, "target": 70},
|
|
82
|
+
"self_improvement": {"current": 0, "target": 60},
|
|
83
|
+
"agi": {"current": 0, "target": 20},
|
|
84
|
+
}
|
|
85
|
+
merged_dimensions = dict(defaults)
|
|
86
|
+
merged_dimensions.update(dimensions)
|
|
87
|
+
|
|
88
|
+
normalized = dict(source)
|
|
89
|
+
normalized["evolution_mode"] = mode
|
|
90
|
+
normalized["dimensions"] = merged_dimensions
|
|
91
|
+
normalized["total_evolutions"] = int(source.get("total_evolutions", source.get("cycles_completed", 0)) or 0)
|
|
92
|
+
normalized["last_evolution"] = source.get("last_evolution", source.get("last_cycle"))
|
|
93
|
+
normalized["total_proposals_made"] = int(source.get("total_proposals_made", 0) or 0)
|
|
94
|
+
normalized["total_auto_applied"] = int(source.get("total_auto_applied", 0) or 0)
|
|
95
|
+
normalized["consecutive_failures"] = int(source.get("consecutive_failures", 0) or 0)
|
|
96
|
+
normalized["history"] = source.get("history", []) if isinstance(source.get("history"), list) else []
|
|
97
|
+
normalized["evolution_enabled"] = bool(source.get("evolution_enabled", True))
|
|
98
|
+
normalized.pop("review_mode", None)
|
|
99
|
+
normalized.pop("dimension_targets", None)
|
|
100
|
+
normalized.pop("cycles_completed", None)
|
|
101
|
+
normalized.pop("last_cycle", None)
|
|
102
|
+
return normalized
|
|
103
|
+
|
|
104
|
+
|
|
37
105
|
def load_objective() -> dict:
|
|
38
106
|
if OBJECTIVE_FILE.exists():
|
|
39
|
-
return json.loads(OBJECTIVE_FILE.read_text())
|
|
40
|
-
return {}
|
|
107
|
+
return normalize_objective(json.loads(OBJECTIVE_FILE.read_text()))
|
|
108
|
+
return normalize_objective({})
|
|
41
109
|
|
|
42
110
|
|
|
43
111
|
def save_objective(obj: dict):
|
|
44
|
-
OBJECTIVE_FILE.
|
|
112
|
+
OBJECTIVE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
OBJECTIVE_FILE.write_text(json.dumps(normalize_objective(obj), indent=2, ensure_ascii=False))
|
|
45
114
|
|
|
46
115
|
|
|
47
116
|
def get_week_data(db_path: str) -> dict:
|
|
@@ -196,9 +265,21 @@ def build_evolution_prompt(week_data: dict, objective: dict) -> str:
|
|
|
196
265
|
"current_scores": {dim: m["score"] for dim, m in week_data.get("current_metrics", {}).items()},
|
|
197
266
|
}
|
|
198
267
|
|
|
199
|
-
mode = objective.get("evolution_mode", "auto")
|
|
268
|
+
mode = normalize_objective(objective).get("evolution_mode", "auto")
|
|
200
269
|
total = objective.get("total_evolutions", 0)
|
|
201
270
|
max_auto = max_auto_changes(total)
|
|
271
|
+
if mode == "review":
|
|
272
|
+
mode_desc = "review-only, nothing executes automatically"
|
|
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"
|
|
275
|
+
elif mode == "managed":
|
|
276
|
+
mode_desc = f"owner-managed, max {max_auto} auto-applied changes with rollback and followups"
|
|
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"
|
|
279
|
+
else:
|
|
280
|
+
mode_desc = f"public auto, max {max_auto} auto-applied changes in personal safe zones"
|
|
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"
|
|
202
283
|
|
|
203
284
|
prompt = f"""You are NEXO Evolution — the weekly self-improvement cycle.
|
|
204
285
|
|
|
@@ -212,7 +293,7 @@ WEEK SUMMARY:
|
|
|
212
293
|
- {stats['evolution_history']} past evolution proposals
|
|
213
294
|
- Current scores: {json.dumps(stats['current_scores'])}
|
|
214
295
|
|
|
215
|
-
MODE: {mode} ({
|
|
296
|
+
MODE: {mode} ({mode_desc})
|
|
216
297
|
CYCLE: #{total + 1}
|
|
217
298
|
|
|
218
299
|
INVESTIGATE using these tools:
|
|
@@ -232,9 +313,11 @@ LOOK FOR:
|
|
|
232
313
|
- Patterns in self-critique that suggest systemic issues
|
|
233
314
|
|
|
234
315
|
SAFETY:
|
|
235
|
-
- Safe zones for
|
|
236
|
-
- IMMUTABLE files (never touch
|
|
316
|
+
- Safe zones for this mode: {safe_zones}
|
|
317
|
+
- IMMUTABLE files (never touch in this mode): {immutable_files}
|
|
237
318
|
- Every change needs: what file, what to change, why, risk, how to verify
|
|
319
|
+
- AUTO changes must be deterministic. If the edit is ambiguous, risky, or needs human taste, mark it as "propose".
|
|
320
|
+
- In managed mode, failed AUTO changes will be rolled back automatically and turned into followups with evidence.
|
|
238
321
|
|
|
239
322
|
OUTPUT FORMAT (JSON):
|
|
240
323
|
{{
|
package/src/nexo.db
ADDED
|
File without changes
|
package/src/plugins/evolution.py
CHANGED
|
@@ -43,8 +43,15 @@ def handle_evolution_history(limit: int = 10) -> str:
|
|
|
43
43
|
|
|
44
44
|
lines = [f"EVOLUTION HISTORY ({len(history)} entries):"]
|
|
45
45
|
for h in history:
|
|
46
|
-
status_icon = {
|
|
47
|
-
|
|
46
|
+
status_icon = {
|
|
47
|
+
"applied": "✓",
|
|
48
|
+
"rolled_back": "↺",
|
|
49
|
+
"blocked": "⛔",
|
|
50
|
+
"proposed": "?",
|
|
51
|
+
"pending_review": "…",
|
|
52
|
+
"accepted": "✓✓",
|
|
53
|
+
"rejected": "✗✗",
|
|
54
|
+
}.get(h["status"], "·")
|
|
48
55
|
lines.append(f" {status_icon} #{h['id']} [{h['classification']}] {h['dimension']}")
|
|
49
56
|
lines.append(f" {h['proposal'][:100]}")
|
|
50
57
|
if h.get("test_result"):
|