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.
@@ -0,0 +1,283 @@
1
+ """Shared cron recovery contract for catchup, launchagent sync, and diagnostics."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import plistlib
7
+ import sqlite3
8
+ import contextlib
9
+ from datetime import datetime, timedelta, timezone
10
+ from pathlib import Path
11
+
12
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
13
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
14
+ LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
15
+ OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
16
+ DB_PATH = NEXO_HOME / "data" / "nexo.db"
17
+ STATE_FILE = NEXO_HOME / "operations" / ".catchup-state.json"
18
+
19
+
20
+ def _local_timezone():
21
+ return datetime.now().astimezone().tzinfo or timezone.utc
22
+
23
+
24
+ def _load_json(path: Path, default):
25
+ try:
26
+ if path.is_file():
27
+ return json.loads(path.read_text())
28
+ except Exception:
29
+ pass
30
+ return default
31
+
32
+
33
+ def load_enabled_crons() -> list[dict]:
34
+ manifest_candidates = [
35
+ NEXO_HOME / "crons" / "manifest.json",
36
+ NEXO_CODE / "crons" / "manifest.json",
37
+ ]
38
+ optionals = _load_json(OPTIONALS_FILE, {})
39
+ if not isinstance(optionals, dict):
40
+ optionals = {}
41
+
42
+ for manifest_path in manifest_candidates:
43
+ if not manifest_path.is_file():
44
+ continue
45
+ try:
46
+ data = json.loads(manifest_path.read_text())
47
+ except Exception:
48
+ continue
49
+
50
+ enabled = []
51
+ for cron in data.get("crons", []):
52
+ optional_key = cron.get("optional")
53
+ if optional_key and not optionals.get(optional_key, False):
54
+ continue
55
+ enabled.append(dict(cron))
56
+ return enabled
57
+ return []
58
+
59
+
60
+ def default_recovery_policy(cron: dict) -> str:
61
+ if cron.get("keep_alive") or cron.get("interval_seconds"):
62
+ return "restart"
63
+ if cron.get("schedule"):
64
+ return "catchup"
65
+ return "none"
66
+
67
+
68
+ def default_max_catchup_age(cron: dict) -> int:
69
+ if cron.get("interval_seconds"):
70
+ interval = int(cron["interval_seconds"])
71
+ return max(interval * 4, interval + 900)
72
+ schedule = cron.get("schedule") or {}
73
+ if "weekday" in schedule:
74
+ return 14 * 86400
75
+ if "hour" in schedule and "minute" in schedule:
76
+ return 48 * 3600
77
+ return 0
78
+
79
+
80
+ def recovery_contract(cron: dict) -> dict:
81
+ policy = cron.get("recovery_policy") or default_recovery_policy(cron)
82
+ return {
83
+ "recovery_policy": policy,
84
+ "idempotent": bool(cron.get("idempotent", policy in {"catchup", "restart"})),
85
+ "max_catchup_age": int(cron.get("max_catchup_age", default_max_catchup_age(cron)) or 0),
86
+ "run_on_boot": bool(cron.get("run_on_boot", cron.get("run_at_load") or bool(cron.get("interval_seconds")))),
87
+ "run_on_wake": bool(cron.get("run_on_wake", policy == "catchup" or bool(cron.get("interval_seconds")))),
88
+ }
89
+
90
+
91
+ def should_run_at_load(cron: dict) -> bool:
92
+ if cron.get("keep_alive"):
93
+ return True
94
+ if cron.get("run_at_load"):
95
+ return True
96
+ return bool(cron.get("run_on_boot") and cron.get("interval_seconds"))
97
+
98
+
99
+ def launchagent_schedule(cron_id: str) -> dict:
100
+ plist_path = LAUNCH_AGENTS_DIR / f"com.nexo.{cron_id}.plist"
101
+ if not plist_path.is_file():
102
+ return {}
103
+ try:
104
+ with plist_path.open("rb") as fh:
105
+ plist_data = plistlib.load(fh)
106
+ except Exception:
107
+ return {}
108
+
109
+ result = {
110
+ "source": "launchagent",
111
+ "run_at_load": bool(plist_data.get("RunAtLoad")),
112
+ }
113
+ if "StartInterval" in plist_data:
114
+ result["schedule_type"] = "interval"
115
+ result["interval_seconds"] = int(plist_data["StartInterval"])
116
+ return result
117
+ if "StartCalendarInterval" in plist_data:
118
+ result["schedule_type"] = "calendar"
119
+ result["calendar"] = plist_data["StartCalendarInterval"]
120
+ return result
121
+ return result
122
+
123
+
124
+ def effective_schedule(cron: dict) -> dict:
125
+ actual = launchagent_schedule(cron["id"])
126
+ if actual.get("schedule_type"):
127
+ return actual
128
+
129
+ if cron.get("interval_seconds"):
130
+ return {
131
+ "source": "manifest",
132
+ "schedule_type": "interval",
133
+ "interval_seconds": int(cron["interval_seconds"]),
134
+ "run_at_load": should_run_at_load(cron),
135
+ }
136
+ if cron.get("schedule"):
137
+ return {
138
+ "source": "manifest",
139
+ "schedule_type": "calendar",
140
+ "calendar": cron["schedule"],
141
+ "run_at_load": should_run_at_load(cron),
142
+ }
143
+ return {
144
+ "source": "manifest",
145
+ "schedule_type": "manual",
146
+ "run_at_load": should_run_at_load(cron),
147
+ }
148
+
149
+
150
+ def _parse_timestamp(value: str, *, assume_utc: bool) -> datetime | None:
151
+ if not value:
152
+ return None
153
+ try:
154
+ parsed = datetime.fromisoformat(value)
155
+ except ValueError:
156
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
157
+ try:
158
+ parsed = datetime.strptime(value, fmt)
159
+ break
160
+ except ValueError:
161
+ continue
162
+ else:
163
+ return None
164
+ if parsed.tzinfo is None:
165
+ parsed = parsed.replace(tzinfo=timezone.utc if assume_utc else _local_timezone())
166
+ return parsed
167
+
168
+
169
+ def latest_successful_runs(cron_ids: list[str], *, db_path: Path = DB_PATH) -> dict[str, datetime]:
170
+ if not cron_ids or not db_path.is_file():
171
+ return {}
172
+ conn = None
173
+ try:
174
+ conn = sqlite3.connect(str(db_path), timeout=2)
175
+ conn.row_factory = sqlite3.Row
176
+ placeholders = ",".join("?" for _ in cron_ids)
177
+ rows = conn.execute(
178
+ f"""
179
+ SELECT c1.cron_id, c1.started_at
180
+ FROM cron_runs c1
181
+ JOIN (
182
+ SELECT cron_id, MAX(id) AS max_id
183
+ FROM cron_runs
184
+ WHERE cron_id IN ({placeholders}) AND exit_code = 0
185
+ GROUP BY cron_id
186
+ ) latest ON latest.max_id = c1.id
187
+ """,
188
+ tuple(cron_ids),
189
+ ).fetchall()
190
+ except Exception:
191
+ return {}
192
+ finally:
193
+ with contextlib.suppress(Exception):
194
+ conn.close()
195
+
196
+ result: dict[str, datetime] = {}
197
+ for row in rows:
198
+ parsed = _parse_timestamp(row["started_at"], assume_utc=True)
199
+ if parsed is not None:
200
+ result[row["cron_id"]] = parsed
201
+ return result
202
+
203
+
204
+ def legacy_state_runs(*, state_file: Path = STATE_FILE) -> dict[str, datetime]:
205
+ state = _load_json(state_file, {})
206
+ if not isinstance(state, dict):
207
+ return {}
208
+ parsed: dict[str, datetime] = {}
209
+ for cron_id, value in state.items():
210
+ timestamp = _parse_timestamp(str(value), assume_utc=False)
211
+ if timestamp is not None:
212
+ parsed[str(cron_id)] = timestamp
213
+ return parsed
214
+
215
+
216
+ def last_scheduled_time(calendar: dict, now: datetime | None = None) -> datetime:
217
+ now = now or datetime.now().astimezone(_local_timezone())
218
+ if now.tzinfo is None:
219
+ now = now.replace(tzinfo=_local_timezone())
220
+
221
+ hour = int(calendar.get("hour", calendar.get("Hour", 0)))
222
+ minute = int(calendar.get("minute", calendar.get("Minute", 0)))
223
+ weekday = calendar.get("weekday", calendar.get("Weekday"))
224
+
225
+ today_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
226
+ if weekday is not None:
227
+ py_weekday = (int(weekday) - 1) % 7
228
+ days_since = (now.weekday() - py_weekday) % 7
229
+ target = now - timedelta(days=days_since)
230
+ target = target.replace(hour=hour, minute=minute, second=0, microsecond=0)
231
+ if target > now:
232
+ target -= timedelta(weeks=1)
233
+ return target
234
+ if today_at <= now:
235
+ return today_at
236
+ return today_at - timedelta(days=1)
237
+
238
+
239
+ def catchup_candidates(now: datetime | None = None) -> list[dict]:
240
+ now = now or datetime.now().astimezone(_local_timezone())
241
+ if now.tzinfo is None:
242
+ now = now.replace(tzinfo=_local_timezone())
243
+
244
+ crons = load_enabled_crons()
245
+ contracts = {cron["id"]: recovery_contract(cron) for cron in crons if cron.get("id")}
246
+ successes = latest_successful_runs(list(contracts), db_path=DB_PATH)
247
+ legacy = legacy_state_runs(state_file=STATE_FILE)
248
+ candidates: list[dict] = []
249
+
250
+ for cron in crons:
251
+ cron_id = cron.get("id")
252
+ if not cron_id or cron_id == "catchup":
253
+ continue
254
+ contract = contracts[cron_id]
255
+ schedule = effective_schedule(cron)
256
+ if contract["recovery_policy"] != "catchup":
257
+ continue
258
+ if schedule.get("schedule_type") != "calendar":
259
+ continue
260
+ if not contract["idempotent"]:
261
+ continue
262
+
263
+ due_at = last_scheduled_time(schedule["calendar"], now)
264
+ last_success = successes.get(cron_id) or legacy.get(cron_id)
265
+ age_seconds = max(int((now - due_at).total_seconds()), 0)
266
+ missed = last_success is None or last_success < due_at
267
+ within_window = contract["max_catchup_age"] <= 0 or age_seconds <= contract["max_catchup_age"]
268
+
269
+ candidates.append({
270
+ "cron_id": cron_id,
271
+ "script": cron.get("script", ""),
272
+ "type": cron.get("type", "python"),
273
+ "contract": contract,
274
+ "schedule": schedule,
275
+ "last_due_at": due_at,
276
+ "last_success_at": last_success,
277
+ "age_seconds": age_seconds,
278
+ "missed": missed,
279
+ "within_window": within_window,
280
+ })
281
+
282
+ candidates.sort(key=lambda item: item["last_due_at"])
283
+ return candidates
@@ -8,35 +8,60 @@
8
8
  "type": "shell",
9
9
  "schedule": {"hour": 4, "minute": 30},
10
10
  "description": "Overnight session analysis — 4 phases: collect, extract, synthesize, apply",
11
- "core": true
11
+ "core": true,
12
+ "recovery_policy": "catchup",
13
+ "idempotent": true,
14
+ "max_catchup_age": 172800,
15
+ "run_on_boot": true,
16
+ "run_on_wake": true
12
17
  },
13
18
  {
14
19
  "id": "sleep",
15
20
  "script": "scripts/nexo-sleep.py",
16
21
  "schedule": {"hour": 4, "minute": 0},
17
22
  "description": "Nightly memory consolidation and dream cycle",
18
- "core": true
23
+ "core": true,
24
+ "recovery_policy": "catchup",
25
+ "idempotent": true,
26
+ "max_catchup_age": 172800,
27
+ "run_on_boot": true,
28
+ "run_on_wake": true
19
29
  },
20
30
  {
21
31
  "id": "cognitive-decay",
22
32
  "script": "scripts/nexo-cognitive-decay.py",
23
33
  "schedule": {"hour": 3, "minute": 0},
24
34
  "description": "Memory decay — reduce strength of unaccessed memories",
25
- "core": true
35
+ "core": true,
36
+ "recovery_policy": "catchup",
37
+ "idempotent": true,
38
+ "max_catchup_age": 172800,
39
+ "run_on_boot": true,
40
+ "run_on_wake": true
26
41
  },
27
42
  {
28
43
  "id": "learning-housekeep",
29
44
  "script": "scripts/nexo-learning-housekeep.py",
30
45
  "schedule": {"hour": 3, "minute": 15},
31
46
  "description": "Archive stale learnings, deduplicate, validate",
32
- "core": true
47
+ "core": true,
48
+ "recovery_policy": "catchup",
49
+ "idempotent": true,
50
+ "max_catchup_age": 172800,
51
+ "run_on_boot": true,
52
+ "run_on_wake": true
33
53
  },
34
54
  {
35
55
  "id": "immune",
36
56
  "script": "scripts/nexo-immune.py",
37
57
  "interval_seconds": 1800,
38
58
  "description": "Health monitor — checks MCP, DB, services, auto-repairs",
39
- "core": true
59
+ "core": true,
60
+ "recovery_policy": "restart",
61
+ "idempotent": true,
62
+ "max_catchup_age": 7200,
63
+ "run_on_boot": true,
64
+ "run_on_wake": true
40
65
  },
41
66
  {
42
67
  "id": "watchdog",
@@ -44,64 +69,97 @@
44
69
  "type": "shell",
45
70
  "interval_seconds": 1800,
46
71
  "description": "System health checks — snapshots, logs, alerts",
47
- "core": true
72
+ "core": true,
73
+ "recovery_policy": "restart",
74
+ "idempotent": true,
75
+ "max_catchup_age": 7200,
76
+ "run_on_boot": true,
77
+ "run_on_wake": true
48
78
  },
49
79
  {
50
80
  "id": "self-audit",
51
81
  "script": "scripts/nexo-daily-self-audit.py",
52
82
  "schedule": {"hour": 7, "minute": 0},
53
83
  "description": "Daily self-audit — validates learnings, protocols, drift",
54
- "core": true
84
+ "core": true,
85
+ "recovery_policy": "catchup",
86
+ "idempotent": true,
87
+ "max_catchup_age": 172800,
88
+ "run_on_boot": true,
89
+ "run_on_wake": true
55
90
  },
56
91
  {
57
92
  "id": "postmortem",
58
93
  "script": "scripts/nexo-postmortem-consolidator.py",
59
94
  "schedule": {"hour": 23, "minute": 30},
60
95
  "description": "Consolidate session post-mortems into patterns",
61
- "core": true
96
+ "core": true,
97
+ "recovery_policy": "catchup",
98
+ "idempotent": true,
99
+ "max_catchup_age": 172800,
100
+ "run_on_boot": true,
101
+ "run_on_wake": true
62
102
  },
63
103
  {
64
104
  "id": "evolution",
65
105
  "script": "scripts/nexo-evolution-run.py",
66
106
  "schedule": {"hour": 5, "minute": 0, "weekday": 0},
67
107
  "description": "Weekly self-improvement cycle — propose and evaluate changes",
68
- "core": true
108
+ "core": true,
109
+ "recovery_policy": "catchup",
110
+ "idempotent": true,
111
+ "max_catchup_age": 1209600,
112
+ "run_on_boot": true,
113
+ "run_on_wake": true
69
114
  },
70
115
  {
71
116
  "id": "followup-hygiene",
72
117
  "script": "scripts/nexo-followup-hygiene.py",
73
118
  "schedule": {"hour": 5, "minute": 0, "weekday": 0},
74
119
  "description": "Clean stale followups, archive completed, validate dates",
75
- "core": true
120
+ "core": true,
121
+ "recovery_policy": "catchup",
122
+ "idempotent": true,
123
+ "max_catchup_age": 1209600,
124
+ "run_on_boot": true,
125
+ "run_on_wake": true
76
126
  },
77
127
  {
78
128
  "id": "synthesis",
79
129
  "script": "scripts/nexo-synthesis.py",
80
130
  "schedule": {"hour": 6, "minute": 0},
81
131
  "description": "Daily synthesis — cross-reference learnings, decisions, changes",
82
- "core": true
132
+ "core": true,
133
+ "recovery_policy": "catchup",
134
+ "idempotent": true,
135
+ "max_catchup_age": 172800,
136
+ "run_on_boot": true,
137
+ "run_on_wake": true
83
138
  },
84
139
  {
85
140
  "id": "auto-close-sessions",
86
141
  "script": "auto_close_sessions.py",
87
142
  "interval_seconds": 300,
88
143
  "description": "Close stale sessions that lost their parent process",
89
- "core": true
144
+ "core": true,
145
+ "recovery_policy": "restart",
146
+ "idempotent": true,
147
+ "max_catchup_age": 1800,
148
+ "run_on_boot": true,
149
+ "run_on_wake": true
90
150
  },
91
- {
151
+ {
92
152
  "id": "catchup",
93
153
  "script": "scripts/nexo-catchup.py",
154
+ "interval_seconds": 900,
94
155
  "run_at_load": true,
95
156
  "description": "Morning catchup briefing for the user",
96
- "core": true
97
- },
98
- {
99
- "id": "day-orchestrator",
100
- "script": "scripts/nexo-day-orchestrator.sh",
101
- "type": "shell",
102
- "description": "Autonomous NEXO cycle — checks followups, emails, infra every 15 min (8:00-23:00)",
103
157
  "core": true,
104
- "optional": "orchestrator"
158
+ "recovery_policy": "none",
159
+ "idempotent": true,
160
+ "max_catchup_age": 0,
161
+ "run_on_boot": true,
162
+ "run_on_wake": true
105
163
  }
106
164
  ]
107
165
  }