nexo-brain 2.6.11 → 2.6.13

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,394 @@
1
+ from __future__ import annotations
2
+
3
+ """Client and automation preference helpers stored in config/schedule.json."""
4
+
5
+ import os
6
+ import shutil
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from runtime_power import load_schedule_config, save_schedule_config
11
+
12
+
13
+ CLIENT_CLAUDE_CODE = "claude_code"
14
+ CLIENT_CODEX = "codex"
15
+ CLIENT_CLAUDE_DESKTOP = "claude_desktop"
16
+ BACKEND_NONE = "none"
17
+
18
+ INTERACTIVE_CLIENT_KEYS = (
19
+ CLIENT_CLAUDE_CODE,
20
+ CLIENT_CODEX,
21
+ CLIENT_CLAUDE_DESKTOP,
22
+ )
23
+ TERMINAL_CLIENT_KEYS = (
24
+ CLIENT_CLAUDE_CODE,
25
+ CLIENT_CODEX,
26
+ )
27
+ AUTOMATION_BACKEND_KEYS = (
28
+ BACKEND_NONE,
29
+ CLIENT_CLAUDE_CODE,
30
+ CLIENT_CODEX,
31
+ )
32
+ INSTALL_PREFERENCE_KEYS = {
33
+ "ask",
34
+ "auto",
35
+ "skip",
36
+ "manual",
37
+ }
38
+ DEFAULT_CLIENT_RUNTIME_PROFILES = {
39
+ CLIENT_CLAUDE_CODE: {
40
+ "model": "opus",
41
+ "reasoning_effort": "",
42
+ },
43
+ CLIENT_CODEX: {
44
+ "model": "gpt-5.4",
45
+ "reasoning_effort": "xhigh",
46
+ },
47
+ }
48
+
49
+
50
+ def _user_home() -> Path:
51
+ return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
52
+
53
+
54
+ def _coerce_bool(value, default: bool) -> bool:
55
+ if isinstance(value, bool):
56
+ return value
57
+ if value is None:
58
+ return default
59
+ candidate = str(value).strip().lower()
60
+ if candidate in {"1", "true", "yes", "y", "on", "enabled"}:
61
+ return True
62
+ if candidate in {"0", "false", "no", "n", "off", "disabled"}:
63
+ return False
64
+ return default
65
+
66
+
67
+ def default_client_preferences() -> dict:
68
+ return {
69
+ "interactive_clients": {
70
+ CLIENT_CLAUDE_CODE: True,
71
+ CLIENT_CODEX: False,
72
+ CLIENT_CLAUDE_DESKTOP: False,
73
+ },
74
+ "default_terminal_client": CLIENT_CLAUDE_CODE,
75
+ "automation_enabled": True,
76
+ "automation_backend": CLIENT_CLAUDE_CODE,
77
+ "client_runtime_profiles": default_client_runtime_profiles(),
78
+ "client_install_preferences": {
79
+ CLIENT_CLAUDE_CODE: "ask",
80
+ CLIENT_CODEX: "ask",
81
+ CLIENT_CLAUDE_DESKTOP: "manual",
82
+ },
83
+ }
84
+
85
+
86
+ def normalize_client_key(value: str | None) -> str:
87
+ candidate = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
88
+ aliases = {
89
+ "claude": CLIENT_CLAUDE_CODE,
90
+ "claude_code": CLIENT_CLAUDE_CODE,
91
+ "claudecode": CLIENT_CLAUDE_CODE,
92
+ "claudecli": CLIENT_CLAUDE_CODE,
93
+ "codex": CLIENT_CODEX,
94
+ "openai_codex": CLIENT_CODEX,
95
+ "claude_desktop": CLIENT_CLAUDE_DESKTOP,
96
+ "claudedesktop": CLIENT_CLAUDE_DESKTOP,
97
+ "desktop": CLIENT_CLAUDE_DESKTOP,
98
+ "claude_app": CLIENT_CLAUDE_DESKTOP,
99
+ }
100
+ return aliases.get(candidate, "")
101
+
102
+
103
+ def normalize_backend_key(value: str | None) -> str:
104
+ candidate = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
105
+ if candidate in {"", "none", "off", "disabled", "false", "0"}:
106
+ return BACKEND_NONE
107
+ client_key = normalize_client_key(candidate)
108
+ if client_key in TERMINAL_CLIENT_KEYS:
109
+ return client_key
110
+ return ""
111
+
112
+
113
+ def normalize_interactive_clients(value) -> dict[str, bool]:
114
+ if not isinstance(value, dict):
115
+ return dict(default_client_preferences()["interactive_clients"])
116
+
117
+ normalized = {
118
+ CLIENT_CLAUDE_CODE: False,
119
+ CLIENT_CODEX: False,
120
+ CLIENT_CLAUDE_DESKTOP: False,
121
+ }
122
+
123
+ for raw_key, raw_value in value.items():
124
+ key = normalize_client_key(raw_key)
125
+ if key:
126
+ normalized[key] = _coerce_bool(raw_value, False)
127
+ return normalized
128
+
129
+
130
+ def normalize_default_terminal_client(value, interactive_clients: dict[str, bool] | None = None) -> str:
131
+ interactive_clients = normalize_interactive_clients(interactive_clients or {})
132
+ candidate = normalize_client_key(value)
133
+ if candidate in TERMINAL_CLIENT_KEYS and interactive_clients.get(candidate, False):
134
+ return candidate
135
+ for terminal_client in TERMINAL_CLIENT_KEYS:
136
+ if interactive_clients.get(terminal_client, False):
137
+ return terminal_client
138
+ return CLIENT_CLAUDE_CODE
139
+
140
+
141
+ def normalize_automation_enabled(value) -> bool:
142
+ return _coerce_bool(value, True)
143
+
144
+
145
+ def normalize_automation_backend(value, *, automation_enabled: bool = True) -> str:
146
+ if not automation_enabled:
147
+ return BACKEND_NONE
148
+ candidate = normalize_backend_key(value)
149
+ if candidate in TERMINAL_CLIENT_KEYS:
150
+ return candidate
151
+ return CLIENT_CLAUDE_CODE
152
+
153
+
154
+ def normalize_client_install_preferences(value) -> dict[str, str]:
155
+ defaults = default_client_preferences()["client_install_preferences"]
156
+ normalized = dict(defaults)
157
+ if not isinstance(value, dict):
158
+ return normalized
159
+ for raw_key, raw_value in value.items():
160
+ key = normalize_client_key(raw_key)
161
+ pref = str(raw_value or "").strip().lower()
162
+ if key and pref in INSTALL_PREFERENCE_KEYS:
163
+ normalized[key] = pref
164
+ return normalized
165
+
166
+
167
+ def default_client_runtime_profiles() -> dict[str, dict[str, str]]:
168
+ return {
169
+ client_key: dict(profile)
170
+ for client_key, profile in DEFAULT_CLIENT_RUNTIME_PROFILES.items()
171
+ }
172
+
173
+
174
+ def _normalize_runtime_model(value, *, default: str) -> str:
175
+ candidate = str(value or "").strip()
176
+ return candidate or default
177
+
178
+
179
+ def _normalize_runtime_reasoning_effort(value, *, default: str) -> str:
180
+ candidate = str(value or "").strip().lower()
181
+ return candidate or default
182
+
183
+
184
+ def normalize_client_runtime_profiles(value) -> dict[str, dict[str, str]]:
185
+ defaults = default_client_runtime_profiles()
186
+ normalized = default_client_runtime_profiles()
187
+ if not isinstance(value, dict):
188
+ return normalized
189
+
190
+ for raw_client, raw_profile in value.items():
191
+ client_key = normalize_client_key(raw_client)
192
+ if client_key not in TERMINAL_CLIENT_KEYS:
193
+ continue
194
+ if isinstance(raw_profile, dict):
195
+ normalized[client_key] = {
196
+ "model": _normalize_runtime_model(
197
+ raw_profile.get("model"),
198
+ default=defaults[client_key]["model"],
199
+ ),
200
+ "reasoning_effort": _normalize_runtime_reasoning_effort(
201
+ raw_profile.get("reasoning_effort"),
202
+ default=defaults[client_key]["reasoning_effort"],
203
+ ),
204
+ }
205
+ continue
206
+ normalized[client_key] = {
207
+ "model": _normalize_runtime_model(raw_profile, default=defaults[client_key]["model"]),
208
+ "reasoning_effort": defaults[client_key]["reasoning_effort"],
209
+ }
210
+ return normalized
211
+
212
+
213
+ def normalize_client_preferences(schedule: dict | None = None) -> dict:
214
+ schedule = dict(schedule or {})
215
+ interactive_clients = normalize_interactive_clients(schedule.get("interactive_clients"))
216
+ automation_enabled = normalize_automation_enabled(schedule.get("automation_enabled"))
217
+ default_terminal_client = normalize_default_terminal_client(
218
+ schedule.get("default_terminal_client"),
219
+ interactive_clients=interactive_clients,
220
+ )
221
+ automation_backend = normalize_automation_backend(
222
+ schedule.get("automation_backend"),
223
+ automation_enabled=automation_enabled,
224
+ )
225
+ install_preferences = normalize_client_install_preferences(
226
+ schedule.get("client_install_preferences")
227
+ )
228
+ runtime_profiles = normalize_client_runtime_profiles(
229
+ schedule.get("client_runtime_profiles")
230
+ )
231
+ return {
232
+ "interactive_clients": interactive_clients,
233
+ "default_terminal_client": default_terminal_client,
234
+ "automation_enabled": automation_enabled,
235
+ "automation_backend": automation_backend,
236
+ "client_runtime_profiles": runtime_profiles,
237
+ "client_install_preferences": install_preferences,
238
+ }
239
+
240
+
241
+ def apply_client_preferences(
242
+ schedule: dict | None = None,
243
+ *,
244
+ interactive_clients: dict | None = None,
245
+ default_terminal_client: str | None = None,
246
+ automation_enabled=None,
247
+ automation_backend: str | None = None,
248
+ client_runtime_profiles: dict | None = None,
249
+ client_install_preferences: dict | None = None,
250
+ ) -> dict:
251
+ merged = dict(schedule or {})
252
+ current = normalize_client_preferences(schedule)
253
+ merged["interactive_clients"] = normalize_interactive_clients(
254
+ interactive_clients if interactive_clients is not None else current["interactive_clients"]
255
+ )
256
+ merged["automation_enabled"] = normalize_automation_enabled(
257
+ automation_enabled if automation_enabled is not None else current["automation_enabled"]
258
+ )
259
+ merged["default_terminal_client"] = normalize_default_terminal_client(
260
+ default_terminal_client if default_terminal_client is not None else current["default_terminal_client"],
261
+ interactive_clients=merged["interactive_clients"],
262
+ )
263
+ merged["automation_backend"] = normalize_automation_backend(
264
+ automation_backend if automation_backend is not None else current["automation_backend"],
265
+ automation_enabled=merged["automation_enabled"],
266
+ )
267
+ merged["client_runtime_profiles"] = normalize_client_runtime_profiles(
268
+ client_runtime_profiles
269
+ if client_runtime_profiles is not None
270
+ else current["client_runtime_profiles"]
271
+ )
272
+ merged["client_install_preferences"] = normalize_client_install_preferences(
273
+ client_install_preferences
274
+ if client_install_preferences is not None
275
+ else current["client_install_preferences"]
276
+ )
277
+ return merged
278
+
279
+
280
+ def load_client_preferences() -> dict:
281
+ return normalize_client_preferences(load_schedule_config())
282
+
283
+
284
+ def save_client_preferences(
285
+ *,
286
+ interactive_clients: dict | None = None,
287
+ default_terminal_client: str | None = None,
288
+ automation_enabled=None,
289
+ automation_backend: str | None = None,
290
+ client_runtime_profiles: dict | None = None,
291
+ client_install_preferences: dict | None = None,
292
+ ) -> Path:
293
+ schedule = apply_client_preferences(
294
+ load_schedule_config(),
295
+ interactive_clients=interactive_clients,
296
+ default_terminal_client=default_terminal_client,
297
+ automation_enabled=automation_enabled,
298
+ automation_backend=automation_backend,
299
+ client_runtime_profiles=client_runtime_profiles,
300
+ client_install_preferences=client_install_preferences,
301
+ )
302
+ return save_schedule_config(schedule)
303
+
304
+
305
+ def _claude_desktop_config_path(home: Path) -> Path:
306
+ if sys.platform == "darwin":
307
+ return home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
308
+ if os.name == "nt":
309
+ return home / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json"
310
+ return home / ".config" / "Claude" / "claude_desktop_config.json"
311
+
312
+
313
+ def detect_installed_clients(user_home: str | os.PathLike[str] | None = None) -> dict[str, dict]:
314
+ home = Path(user_home).expanduser() if user_home else _user_home()
315
+
316
+ claude_bin = os.environ.get("CLAUDE_BIN", "").strip() or shutil.which("claude") or ""
317
+ codex_bin = os.environ.get("CODEX_BIN", "").strip() or shutil.which("codex") or ""
318
+
319
+ if sys.platform == "darwin":
320
+ desktop_app = next(
321
+ (
322
+ str(candidate)
323
+ for candidate in (
324
+ home / "Applications" / "Claude.app",
325
+ Path("/Applications/Claude.app"),
326
+ )
327
+ if candidate.exists()
328
+ ),
329
+ "",
330
+ )
331
+ else:
332
+ desktop_app = ""
333
+ desktop_config = _claude_desktop_config_path(home)
334
+
335
+ return {
336
+ CLIENT_CLAUDE_CODE: {
337
+ "installed": bool(claude_bin),
338
+ "path": claude_bin,
339
+ "detected_by": "binary" if claude_bin else "missing",
340
+ },
341
+ CLIENT_CODEX: {
342
+ "installed": bool(codex_bin),
343
+ "path": codex_bin,
344
+ "detected_by": "binary" if codex_bin else "missing",
345
+ },
346
+ CLIENT_CLAUDE_DESKTOP: {
347
+ "installed": bool(desktop_app or desktop_config.exists()),
348
+ "path": desktop_app or str(desktop_config),
349
+ "detected_by": "app" if desktop_app else ("config" if desktop_config.exists() else "missing"),
350
+ },
351
+ }
352
+
353
+
354
+ def resolve_terminal_client(requested: str | None = None, *, preferences: dict | None = None) -> str:
355
+ normalized = preferences or load_client_preferences()
356
+ if requested is not None:
357
+ interactive_clients = normalized["interactive_clients"]
358
+ return normalize_default_terminal_client(requested, interactive_clients=interactive_clients)
359
+ return normalize_default_terminal_client(
360
+ normalized["default_terminal_client"],
361
+ interactive_clients=normalized["interactive_clients"],
362
+ )
363
+
364
+
365
+ def resolve_automation_backend(preferences: dict | None = None) -> str:
366
+ normalized = preferences or load_client_preferences()
367
+ return normalize_automation_backend(
368
+ normalized["automation_backend"],
369
+ automation_enabled=normalized["automation_enabled"],
370
+ )
371
+
372
+
373
+ def resolve_client_runtime_profile(
374
+ client: str | None,
375
+ *,
376
+ preferences: dict | None = None,
377
+ ) -> dict[str, str]:
378
+ normalized = preferences or load_client_preferences()
379
+ client_key = normalize_client_key(client)
380
+ defaults = default_client_runtime_profiles()
381
+ if client_key not in TERMINAL_CLIENT_KEYS:
382
+ client_key = CLIENT_CLAUDE_CODE
383
+ profiles = normalize_client_runtime_profiles(normalized.get("client_runtime_profiles"))
384
+ profile = profiles.get(client_key) or defaults[client_key]
385
+ return {
386
+ "model": _normalize_runtime_model(
387
+ profile.get("model"),
388
+ default=defaults[client_key]["model"],
389
+ ),
390
+ "reasoning_effort": _normalize_runtime_reasoning_effort(
391
+ profile.get("reasoning_effort"),
392
+ default=defaults[client_key]["reasoning_effort"],
393
+ ),
394
+ }
@@ -10,6 +10,46 @@ import subprocess
10
10
  import sys
11
11
  from pathlib import Path
12
12
 
13
+ try:
14
+ from client_preferences import (
15
+ BACKEND_NONE,
16
+ INTERACTIVE_CLIENT_KEYS,
17
+ normalize_backend_key,
18
+ normalize_client_key,
19
+ normalize_client_preferences,
20
+ )
21
+ except Exception:
22
+ BACKEND_NONE = "none"
23
+ INTERACTIVE_CLIENT_KEYS = ("claude_code", "codex", "claude_desktop")
24
+
25
+ def normalize_client_key(value: str | None) -> str:
26
+ candidate = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
27
+ aliases = {
28
+ "claude": "claude_code",
29
+ "claude_code": "claude_code",
30
+ "codex": "codex",
31
+ "claude_desktop": "claude_desktop",
32
+ "desktop": "claude_desktop",
33
+ }
34
+ return aliases.get(candidate, "")
35
+
36
+ def normalize_backend_key(value: str | None) -> str:
37
+ candidate = normalize_client_key(value)
38
+ return candidate or (BACKEND_NONE if str(value or "").strip().lower() in {"none", "off", "disabled"} else "")
39
+
40
+ def normalize_client_preferences(schedule: dict | None = None) -> dict:
41
+ return {
42
+ "interactive_clients": {
43
+ "claude_code": True,
44
+ "codex": False,
45
+ "claude_desktop": False,
46
+ },
47
+ "default_terminal_client": "claude_code",
48
+ "automation_enabled": True,
49
+ "automation_backend": "claude_code",
50
+ }
51
+
52
+
13
53
 
14
54
  def _user_home() -> Path:
15
55
  return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
@@ -252,8 +292,37 @@ def sync_all_clients(
252
292
  python_path: str = "",
253
293
  operator_name: str = "",
254
294
  user_home: str | os.PathLike[str] | None = None,
295
+ enabled_clients: list[str] | tuple[str, ...] | set[str] | None = None,
296
+ preferences: dict | None = None,
255
297
  ) -> dict:
298
+ if enabled_clients is None:
299
+ if preferences is None:
300
+ enabled_set = set(INTERACTIVE_CLIENT_KEYS)
301
+ else:
302
+ active_preferences = normalize_client_preferences(preferences)
303
+ enabled_set = {
304
+ key
305
+ for key in INTERACTIVE_CLIENT_KEYS
306
+ if active_preferences.get("interactive_clients", {}).get(key, False)
307
+ }
308
+ backend_key = normalize_backend_key(active_preferences.get("automation_backend"))
309
+ if active_preferences.get("automation_enabled", True) and backend_key and backend_key != BACKEND_NONE:
310
+ enabled_set.add(backend_key)
311
+ if not enabled_set:
312
+ enabled_set.add("claude_code")
313
+ else:
314
+ enabled_set = {normalize_client_key(item) for item in enabled_clients if normalize_client_key(item)}
315
+ if not enabled_set:
316
+ enabled_set = {"claude_code"}
317
+
256
318
  def _safe(label: str, fn) -> dict:
319
+ if label not in enabled_set:
320
+ return {
321
+ "ok": True,
322
+ "client": label,
323
+ "skipped": True,
324
+ "reason": "disabled in client preferences",
325
+ }
257
326
  try:
258
327
  return fn(
259
328
  nexo_home=nexo_home,
@@ -278,6 +347,7 @@ def sync_all_clients(
278
347
  Path(nexo_home).expanduser() if nexo_home else _default_nexo_home(),
279
348
  runtime_root,
280
349
  )),
350
+ "enabled_clients": sorted(enabled_set),
281
351
  "clients": results,
282
352
  }
283
353
 
@@ -307,6 +377,13 @@ def main(argv: list[str] | None = None) -> int:
307
377
  parser.add_argument("--runtime-root", default="")
308
378
  parser.add_argument("--python", dest="python_path", default="")
309
379
  parser.add_argument("--operator-name", default="")
380
+ parser.add_argument(
381
+ "--enabled-client",
382
+ action="append",
383
+ dest="enabled_clients",
384
+ choices=["claude_code", "claude_desktop", "codex"],
385
+ help="Sync only the specified client(s). Repeat for multiple values.",
386
+ )
310
387
  parser.add_argument("--json", action="store_true")
311
388
  args = parser.parse_args(argv)
312
389
 
@@ -315,6 +392,7 @@ def main(argv: list[str] | None = None) -> int:
315
392
  runtime_root=args.runtime_root or None,
316
393
  python_path=args.python_path,
317
394
  operator_name=args.operator_name,
395
+ enabled_clients=args.enabled_clients,
318
396
  )
319
397
  if args.json:
320
398
  print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -13,6 +13,7 @@ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
13
13
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
14
14
  LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
15
15
  OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
16
+ SCHEDULE_FILE = NEXO_HOME / "config" / "schedule.json"
16
17
  DB_PATH = NEXO_HOME / "data" / "nexo.db"
17
18
  STATE_FILE = NEXO_HOME / "operations" / ".catchup-state.json"
18
19
 
@@ -38,6 +39,8 @@ def load_enabled_crons() -> list[dict]:
38
39
  optionals = _load_json(OPTIONALS_FILE, {})
39
40
  if not isinstance(optionals, dict):
40
41
  optionals = {}
42
+ schedule_data = _load_json(SCHEDULE_FILE, {})
43
+ automation_default = bool(schedule_data.get("automation_enabled", True)) if isinstance(schedule_data, dict) else True
41
44
 
42
45
  for manifest_path in manifest_candidates:
43
46
  if not manifest_path.is_file():
@@ -50,7 +53,11 @@ def load_enabled_crons() -> list[dict]:
50
53
  enabled = []
51
54
  for cron in data.get("crons", []):
52
55
  optional_key = cron.get("optional")
53
- if optional_key and not optionals.get(optional_key, False):
56
+ if optional_key == "automation":
57
+ optional_enabled = optionals.get(optional_key, automation_default)
58
+ else:
59
+ optional_enabled = optionals.get(optional_key, False)
60
+ if optional_key and not optional_enabled:
54
61
  continue
55
62
  enabled.append(dict(cron))
56
63
  return enabled
@@ -9,6 +9,7 @@
9
9
  "schedule": {"hour": 4, "minute": 30},
10
10
  "description": "Overnight session analysis — 4 phases: collect, extract, synthesize, apply",
11
11
  "core": true,
12
+ "optional": "automation",
12
13
  "recovery_policy": "catchup",
13
14
  "idempotent": true,
14
15
  "max_catchup_age": 172800,
@@ -21,6 +22,7 @@
21
22
  "schedule": {"hour": 4, "minute": 0},
22
23
  "description": "Nightly memory consolidation and dream cycle",
23
24
  "core": true,
25
+ "optional": "automation",
24
26
  "recovery_policy": "catchup",
25
27
  "idempotent": true,
26
28
  "max_catchup_age": 172800,
@@ -82,6 +84,7 @@
82
84
  "schedule": {"hour": 7, "minute": 0},
83
85
  "description": "Daily self-audit — validates learnings, protocols, drift",
84
86
  "core": true,
87
+ "optional": "automation",
85
88
  "recovery_policy": "catchup",
86
89
  "idempotent": true,
87
90
  "max_catchup_age": 172800,
@@ -94,6 +97,7 @@
94
97
  "schedule": {"hour": 23, "minute": 30},
95
98
  "description": "Consolidate session post-mortems into patterns",
96
99
  "core": true,
100
+ "optional": "automation",
97
101
  "recovery_policy": "catchup",
98
102
  "idempotent": true,
99
103
  "max_catchup_age": 172800,
@@ -106,6 +110,7 @@
106
110
  "schedule": {"hour": 5, "minute": 0, "weekday": 0},
107
111
  "description": "Weekly self-improvement cycle — propose and evaluate changes",
108
112
  "core": true,
113
+ "optional": "automation",
109
114
  "recovery_policy": "catchup",
110
115
  "idempotent": true,
111
116
  "max_catchup_age": 1209600,
@@ -130,6 +135,7 @@
130
135
  "schedule": {"hour": 6, "minute": 0},
131
136
  "description": "Daily synthesis — cross-reference learnings, decisions, changes",
132
137
  "core": true,
138
+ "optional": "automation",
133
139
  "recovery_policy": "catchup",
134
140
  "idempotent": true,
135
141
  "max_catchup_age": 172800,
package/src/crons/sync.py CHANGED
@@ -41,6 +41,7 @@ LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
41
41
  LABEL_PREFIX = "com.nexo."
42
42
  LOG_DIR = NEXO_HOME / "logs"
43
43
  OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
44
+ SCHEDULE_FILE = NEXO_HOME / "config" / "schedule.json"
44
45
  RETIRED_CORE_FILES = (
45
46
  Path("scripts") / "nexo-day-orchestrator.sh",
46
47
  )
@@ -111,10 +112,22 @@ def load_manifest() -> list[dict]:
111
112
  except Exception as e:
112
113
  log(f"WARNING: could not read optionals.json: {e}")
113
114
 
115
+ automation_default = True
116
+ if SCHEDULE_FILE.is_file():
117
+ try:
118
+ schedule_data = json.loads(SCHEDULE_FILE.read_text())
119
+ automation_default = bool(schedule_data.get("automation_enabled", True))
120
+ except Exception:
121
+ pass
122
+
114
123
  filtered = []
115
124
  for cron in crons:
116
125
  optional_key = cron.get("optional")
117
- if optional_key and not enabled_optionals.get(optional_key, False):
126
+ if optional_key == "automation":
127
+ enabled = enabled_optionals.get(optional_key, automation_default)
128
+ else:
129
+ enabled = enabled_optionals.get(optional_key, False)
130
+ if optional_key and not enabled:
118
131
  continue
119
132
  filtered.append(cron)
120
133
  return filtered