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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +22 -12
- package/bin/nexo-brain.js +483 -56
- package/package.json +4 -1
- package/src/agent_runner.py +322 -0
- package/src/auto_update.py +12 -3
- package/src/cli.py +22 -10
- package/src/client_preferences.py +394 -0
- package/src/client_sync.py +78 -0
- package/src/cron_recovery.py +8 -1
- package/src/crons/manifest.json +6 -0
- package/src/crons/sync.py +14 -1
- package/src/doctor/providers/runtime.py +109 -1
- package/src/plugins/schedule.py +69 -12
- package/src/plugins/update.py +5 -1
- package/src/runtime_power.py +23 -0
- package/src/script_registry.py +62 -1
- package/src/scripts/check-context.py +102 -100
- package/src/scripts/deep-sleep/extract.py +29 -54
- package/src/scripts/deep-sleep/synthesize.py +14 -38
- package/src/scripts/nexo-agent-run.py +73 -0
- package/src/scripts/nexo-catchup.py +15 -19
- package/src/scripts/nexo-daily-self-audit.py +17 -14
- package/src/scripts/nexo-evolution-run.py +25 -55
- package/src/scripts/nexo-immune.py +17 -15
- package/src/scripts/nexo-learning-validator.py +90 -58
- package/src/scripts/nexo-postmortem-consolidator.py +15 -14
- package/src/scripts/nexo-sleep.py +20 -14
- package/src/scripts/nexo-synthesis.py +19 -12
- package/src/scripts/nexo-update.sh +28 -2
- package/src/scripts/nexo-watchdog.sh +34 -10
- package/templates/nexo_helper.py +45 -0
- package/templates/plugin-template.py +4 -0
- package/templates/script-template.py +13 -2
- package/templates/skill-script-template.py +8 -0
|
@@ -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
|
+
}
|
package/src/client_sync.py
CHANGED
|
@@ -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))
|
package/src/cron_recovery.py
CHANGED
|
@@ -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
|
|
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
|
package/src/crons/manifest.json
CHANGED
|
@@ -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
|
|
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
|