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.
@@ -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 _cron_expectations() -> dict[str, dict]:
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
- expectations = {}
101
+ enabled = []
81
102
  for cron in data.get("crons", []):
82
103
  cron_id = cron.get("id")
83
- if not cron_id or cron.get("run_at_load"):
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
- expectations[cron_id] = {"threshold": threshold, "label": label}
102
- return expectations
103
- return {}
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
- def _run_at_load_cron_ids() -> set[str]:
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 _launchagent_schedule_expectations() -> dict[str, dict]:
115
- manifest_candidates = [
116
- NEXO_HOME / "crons" / "manifest.json",
117
- NEXO_CODE / "crons" / "manifest.json",
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
- try:
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
- expected = {
134
- "StartInterval": None,
135
- "StartCalendarInterval": None,
136
- "RunAtLoad": None,
137
- }
138
- if cron.get("run_at_load"):
139
- expected["RunAtLoad"] = True
140
- elif "interval_seconds" in cron:
141
- expected["StartInterval"] = int(cron["interval_seconds"])
142
- elif "schedule" in cron:
143
- schedule = cron.get("schedule") or {}
144
- cal = {}
145
- if "hour" in schedule:
146
- cal["Hour"] = schedule["hour"]
147
- if "minute" in schedule:
148
- cal["Minute"] = schedule["minute"]
149
- if "weekday" in schedule:
150
- cal["Weekday"] = schedule["weekday"]
151
- expected["StartCalendarInterval"] = cal
152
- expectations[cron_id] = expected
153
- return expectations
154
- return {}
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().keys():
160
- ids.add(cron_id)
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(rows)} tracked crons ran recently",
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
- if actual_schedule != expected_schedule:
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={expected_schedule})"
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=f"LaunchAgent drift detected in {len(problems)} job(s)",
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="Launchd is serving stale or drifted NEXO jobs. Compare loaded job paths with plist paths on disk.",
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
  ]
@@ -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): db.py, server.py, plugin_loader.py, cognitive.py, CLAUDE.md
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
+ ]