nexo-brain 7.29.0 → 7.30.1
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 +5 -1
- package/package.json +1 -1
- package/src/auto_update.py +72 -0
- package/src/automation_controls.py +180 -10
- package/src/automation_preferences.py +69 -21
- package/src/cli.py +37 -0
- package/src/cron_recovery.py +58 -3
- package/src/crons/sync.py +47 -14
- package/src/model_defaults.json +4 -4
- package/src/model_defaults.py +9 -10
- package/src/plugins/desktop_preferences.py +63 -0
- package/src/plugins/personal_scripts.py +2 -0
- package/src/plugins/update.py +4 -0
- package/src/preference_catalog.py +438 -0
- package/src/resonance_tiers.json +4 -4
- package/src/script_registry.py +6 -0
- package/src/scripts/nexo-morning-agent.py +263 -3
- package/src/scripts/nexo-send-reply.py +29 -1
- package/src/server.py +1 -0
- package/templates/core-prompts/morning-agent.md +7 -0
- package/tool-enforcement-map.json +40 -0
package/src/cron_recovery.py
CHANGED
|
@@ -124,7 +124,7 @@ def resolve_declared_schedule(cron: dict) -> dict:
|
|
|
124
124
|
resolved = dict(schedule)
|
|
125
125
|
strategy = str(cron.get("schedule_strategy") or resolved.pop("strategy", "")).strip().lower()
|
|
126
126
|
if strategy != "machine_weekly_spread":
|
|
127
|
-
return resolved
|
|
127
|
+
return _normalize_declared_weekdays(resolved)
|
|
128
128
|
|
|
129
129
|
if not {"hour", "minute", "weekday"} <= resolved.keys():
|
|
130
130
|
return resolved
|
|
@@ -146,6 +146,41 @@ def resolve_declared_schedule(cron: dict) -> dict:
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
|
|
149
|
+
def _normalize_weekdays(value) -> list[int] | None:
|
|
150
|
+
if value is None:
|
|
151
|
+
return None
|
|
152
|
+
if isinstance(value, str):
|
|
153
|
+
parts = [part.strip() for part in value.replace("+", ",").split(",")]
|
|
154
|
+
elif isinstance(value, (list, tuple, set)):
|
|
155
|
+
parts = list(value)
|
|
156
|
+
else:
|
|
157
|
+
return None
|
|
158
|
+
selected: set[int] = set()
|
|
159
|
+
for part in parts:
|
|
160
|
+
try:
|
|
161
|
+
selected.add(int(part) % 7)
|
|
162
|
+
except Exception:
|
|
163
|
+
continue
|
|
164
|
+
if len(selected) >= 7:
|
|
165
|
+
return []
|
|
166
|
+
order = (1, 2, 3, 4, 5, 6, 0)
|
|
167
|
+
return [day for day in order if day in selected]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _normalize_declared_weekdays(schedule: dict) -> dict:
|
|
171
|
+
result = dict(schedule or {})
|
|
172
|
+
weekdays = _normalize_weekdays(result.get("weekdays"))
|
|
173
|
+
if weekdays is None and "weekday" in result:
|
|
174
|
+
weekdays = _normalize_weekdays([result.get("weekday")])
|
|
175
|
+
if weekdays:
|
|
176
|
+
result.pop("weekday", None)
|
|
177
|
+
result["weekdays"] = weekdays
|
|
178
|
+
elif weekdays == []:
|
|
179
|
+
result.pop("weekday", None)
|
|
180
|
+
result.pop("weekdays", None)
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
|
|
149
184
|
def load_enabled_crons() -> list[dict]:
|
|
150
185
|
try:
|
|
151
186
|
from automation_controls import apply_core_automation_overrides
|
|
@@ -276,7 +311,7 @@ def default_max_catchup_age(cron: dict) -> int:
|
|
|
276
311
|
interval = int(cron["interval_seconds"])
|
|
277
312
|
return max(interval * 4, interval + 900)
|
|
278
313
|
schedule = cron.get("schedule") or {}
|
|
279
|
-
if "weekday" in schedule:
|
|
314
|
+
if "weekday" in schedule or "weekdays" in schedule:
|
|
280
315
|
return 14 * 86400
|
|
281
316
|
if "hour" in schedule and "minute" in schedule:
|
|
282
317
|
return 48 * 3600
|
|
@@ -478,14 +513,34 @@ def legacy_state_runs(*, state_file: Path = STATE_FILE) -> dict[str, datetime]:
|
|
|
478
513
|
return parsed
|
|
479
514
|
|
|
480
515
|
|
|
481
|
-
def last_scheduled_time(calendar: dict, now: datetime | None = None) -> datetime:
|
|
516
|
+
def last_scheduled_time(calendar: dict | list, now: datetime | None = None) -> datetime:
|
|
482
517
|
now = now or datetime.now().astimezone(_local_timezone())
|
|
483
518
|
if now.tzinfo is None:
|
|
484
519
|
now = now.replace(tzinfo=_local_timezone())
|
|
485
520
|
|
|
521
|
+
if isinstance(calendar, list):
|
|
522
|
+
candidates = [
|
|
523
|
+
last_scheduled_time(item, now)
|
|
524
|
+
for item in calendar
|
|
525
|
+
if isinstance(item, dict)
|
|
526
|
+
]
|
|
527
|
+
if candidates:
|
|
528
|
+
return max(candidates)
|
|
529
|
+
return now
|
|
530
|
+
|
|
486
531
|
hour = int(calendar.get("hour", calendar.get("Hour", 0)))
|
|
487
532
|
minute = int(calendar.get("minute", calendar.get("Minute", 0)))
|
|
488
533
|
weekday = calendar.get("weekday", calendar.get("Weekday"))
|
|
534
|
+
weekdays = calendar.get("weekdays") or calendar.get("Weekdays")
|
|
535
|
+
normalized_weekdays = _normalize_weekdays(weekdays)
|
|
536
|
+
if normalized_weekdays:
|
|
537
|
+
base_calendar = dict(calendar)
|
|
538
|
+
base_calendar.pop("weekdays", None)
|
|
539
|
+
base_calendar.pop("Weekdays", None)
|
|
540
|
+
return max(
|
|
541
|
+
last_scheduled_time({**base_calendar, "weekday": day}, now)
|
|
542
|
+
for day in normalized_weekdays
|
|
543
|
+
)
|
|
489
544
|
|
|
490
545
|
today_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
491
546
|
if weekday is not None:
|
package/src/crons/sync.py
CHANGED
|
@@ -419,6 +419,43 @@ def _copy_into_runtime(src: Path) -> Path:
|
|
|
419
419
|
return dest
|
|
420
420
|
|
|
421
421
|
|
|
422
|
+
def _calendar_weekdays(schedule: dict) -> list[int]:
|
|
423
|
+
raw = schedule.get("weekdays") or schedule.get("Weekdays")
|
|
424
|
+
if raw is None and "weekday" in schedule:
|
|
425
|
+
raw = [schedule.get("weekday")]
|
|
426
|
+
if raw is None and "Weekday" in schedule:
|
|
427
|
+
raw = [schedule.get("Weekday")]
|
|
428
|
+
if isinstance(raw, str):
|
|
429
|
+
parts = [part.strip() for part in raw.replace("+", ",").split(",")]
|
|
430
|
+
elif isinstance(raw, (list, tuple, set)):
|
|
431
|
+
parts = list(raw)
|
|
432
|
+
else:
|
|
433
|
+
return []
|
|
434
|
+
selected: set[int] = set()
|
|
435
|
+
for part in parts:
|
|
436
|
+
try:
|
|
437
|
+
selected.add(int(part) % 7)
|
|
438
|
+
except Exception:
|
|
439
|
+
continue
|
|
440
|
+
if len(selected) >= 7:
|
|
441
|
+
return []
|
|
442
|
+
return [day for day in (1, 2, 3, 4, 5, 6, 0) if day in selected]
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _launchd_calendar_intervals(schedule: dict) -> dict | list[dict]:
|
|
446
|
+
base = {}
|
|
447
|
+
if "hour" in schedule:
|
|
448
|
+
base["Hour"] = schedule["hour"]
|
|
449
|
+
if "minute" in schedule:
|
|
450
|
+
base["Minute"] = schedule["minute"]
|
|
451
|
+
weekdays = _calendar_weekdays(schedule)
|
|
452
|
+
if weekdays:
|
|
453
|
+
if len(weekdays) == 1:
|
|
454
|
+
return {**base, "Weekday": weekdays[0]}
|
|
455
|
+
return [{**base, "Weekday": day} for day in weekdays]
|
|
456
|
+
return base
|
|
457
|
+
|
|
458
|
+
|
|
422
459
|
def build_plist(cron: dict) -> dict:
|
|
423
460
|
"""Build a macOS LaunchAgent plist dict from a manifest entry."""
|
|
424
461
|
cron_id = cron["id"]
|
|
@@ -480,15 +517,8 @@ def build_plist(cron: dict) -> dict:
|
|
|
480
517
|
if "interval_seconds" in cron and not cron.get("keep_alive"):
|
|
481
518
|
plist["StartInterval"] = cron["interval_seconds"]
|
|
482
519
|
elif "schedule" in cron and not cron.get("keep_alive"):
|
|
483
|
-
cal = {}
|
|
484
520
|
s = resolve_declared_schedule(cron)
|
|
485
|
-
|
|
486
|
-
cal["Hour"] = s["hour"]
|
|
487
|
-
if "minute" in s:
|
|
488
|
-
cal["Minute"] = s["minute"]
|
|
489
|
-
if "weekday" in s:
|
|
490
|
-
cal["Weekday"] = s["weekday"]
|
|
491
|
-
plist["StartCalendarInterval"] = cal
|
|
521
|
+
plist["StartCalendarInterval"] = _launchd_calendar_intervals(s)
|
|
492
522
|
|
|
493
523
|
return plist
|
|
494
524
|
|
|
@@ -513,9 +543,9 @@ def _cron_schedule(cron: dict) -> str | None:
|
|
|
513
543
|
s = resolve_declared_schedule(cron)
|
|
514
544
|
hour, minute = int(s.get("hour", 0)), int(s.get("minute", 0))
|
|
515
545
|
weekday = "*"
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
weekday = "0" if
|
|
546
|
+
weekdays = _calendar_weekdays(s)
|
|
547
|
+
if weekdays:
|
|
548
|
+
weekday = ",".join("0" if int(day) == 7 else str(int(day) % 7) for day in weekdays)
|
|
519
549
|
return f"{minute} {hour} * * {weekday}"
|
|
520
550
|
return None
|
|
521
551
|
|
|
@@ -916,10 +946,13 @@ StandardError=append:{stderr_log}
|
|
|
916
946
|
elif "schedule" in cron:
|
|
917
947
|
s = resolve_declared_schedule(cron)
|
|
918
948
|
h, m = s.get("hour", 0), s.get("minute", 0)
|
|
919
|
-
|
|
920
|
-
|
|
949
|
+
weekdays = _calendar_weekdays(s)
|
|
950
|
+
if weekdays:
|
|
921
951
|
days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
922
|
-
timer_spec =
|
|
952
|
+
timer_spec = "\n".join(
|
|
953
|
+
f"OnCalendar={days[int(day) % 7]} *-*-* {h:02d}:{m:02d}:00"
|
|
954
|
+
for day in weekdays
|
|
955
|
+
)
|
|
923
956
|
else:
|
|
924
957
|
timer_spec = f"OnCalendar=*-*-* {h:02d}:{m:02d}:00"
|
|
925
958
|
else:
|
package/src/model_defaults.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
3
|
"claude_code": {
|
|
4
|
-
"model": "claude-opus-4-
|
|
4
|
+
"model": "claude-opus-4-8",
|
|
5
5
|
"reasoning_effort": "max",
|
|
6
|
-
"display_name": "Opus 4.
|
|
7
|
-
"recommendation_version":
|
|
8
|
-
"previous_defaults": ["claude-opus-4-6[1m]"]
|
|
6
|
+
"display_name": "Opus 4.8 with max reasoning",
|
|
7
|
+
"recommendation_version": 3,
|
|
8
|
+
"previous_defaults": ["claude-opus-4-7[1m]", "claude-opus-4-7", "claude-opus-4-6[1m]"]
|
|
9
9
|
},
|
|
10
10
|
"codex": {
|
|
11
11
|
"model": "gpt-5.5",
|
package/src/model_defaults.py
CHANGED
|
@@ -20,11 +20,11 @@ from typing import Any
|
|
|
20
20
|
_FALLBACK: dict[str, Any] = {
|
|
21
21
|
"schema_version": 1,
|
|
22
22
|
"claude_code": {
|
|
23
|
-
"model": "claude-opus-4-
|
|
23
|
+
"model": "claude-opus-4-8",
|
|
24
24
|
"reasoning_effort": "max",
|
|
25
|
-
"display_name": "Opus 4.
|
|
26
|
-
"recommendation_version":
|
|
27
|
-
"previous_defaults": ["claude-opus-4-6[1m]"],
|
|
25
|
+
"display_name": "Opus 4.8 with max reasoning",
|
|
26
|
+
"recommendation_version": 3,
|
|
27
|
+
"previous_defaults": ["claude-opus-4-7[1m]", "claude-opus-4-7", "claude-opus-4-6[1m]"],
|
|
28
28
|
},
|
|
29
29
|
"codex": {
|
|
30
30
|
"model": "gpt-5.5",
|
|
@@ -99,14 +99,14 @@ def looks_like_claude_model(model: str) -> bool:
|
|
|
99
99
|
return str(model or "").strip().lower().startswith(_CLAUDE_MODEL_PREFIXES)
|
|
100
100
|
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
_CLAUDE_DEFAULT_PREFIXES = ("claude-opus-4-6", "claude-opus-4-7")
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
def heal_runtime_profiles(profiles: dict) -> tuple[dict, list[str]]:
|
|
106
106
|
"""Detect and repair invalid models in client_runtime_profiles. Returns
|
|
107
107
|
(healed_profiles_dict, list_of_heal_messages). Handles two cases:
|
|
108
108
|
1. Claude-family model in the codex profile (historical bug).
|
|
109
|
-
2. Opus
|
|
109
|
+
2. Opus default auto-migration for claude_code users on a NEXO default."""
|
|
110
110
|
if not isinstance(profiles, dict):
|
|
111
111
|
return profiles, []
|
|
112
112
|
healed = dict(profiles)
|
|
@@ -127,14 +127,13 @@ def heal_runtime_profiles(profiles: dict) -> tuple[dict, list[str]]:
|
|
|
127
127
|
f"(Claude models are invalid for Codex)."
|
|
128
128
|
)
|
|
129
129
|
|
|
130
|
-
# --- Opus
|
|
130
|
+
# --- Opus default auto-migration for claude_code ---
|
|
131
131
|
cc_profile = healed.get("claude_code") if isinstance(healed.get("claude_code"), dict) else None
|
|
132
132
|
if cc_profile is not None:
|
|
133
133
|
cc_model = str(cc_profile.get("model") or "").strip()
|
|
134
|
-
if cc_model.startswith(
|
|
134
|
+
if any(cc_model.startswith(prefix) for prefix in _CLAUDE_DEFAULT_PREFIXES):
|
|
135
135
|
default = client_default("claude_code")
|
|
136
|
-
|
|
137
|
-
new_model = f"claude-opus-4-7{suffix}"
|
|
136
|
+
new_model = default["model"]
|
|
138
137
|
old_effort = str(cc_profile.get("reasoning_effort") or "").strip()
|
|
139
138
|
new_effort = default["reasoning_effort"]
|
|
140
139
|
healed["claude_code"] = dict(cc_profile)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Agent-facing catalog and mutation tools for NEXO Desktop preferences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def handle_desktop_preferences_catalog(query: str = "", include_values: bool = True, locale: str = "es") -> str:
|
|
9
|
+
from preference_catalog import build_preference_catalog
|
|
10
|
+
|
|
11
|
+
return json.dumps(
|
|
12
|
+
build_preference_catalog(
|
|
13
|
+
include_values=bool(include_values),
|
|
14
|
+
query=str(query or "").strip() or None,
|
|
15
|
+
locale=str(locale or "es"),
|
|
16
|
+
),
|
|
17
|
+
ensure_ascii=False,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def handle_desktop_preference_get(id: str) -> str:
|
|
22
|
+
from preference_catalog import explain_preference
|
|
23
|
+
|
|
24
|
+
return json.dumps(explain_preference(id), ensure_ascii=False)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def handle_desktop_preference_explain(id: str) -> str:
|
|
28
|
+
from preference_catalog import explain_preference
|
|
29
|
+
|
|
30
|
+
return json.dumps(explain_preference(id), ensure_ascii=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def handle_desktop_preference_set(id: str, value: str, dry_run: bool = False) -> str:
|
|
34
|
+
from preference_catalog import set_preference
|
|
35
|
+
|
|
36
|
+
return json.dumps(
|
|
37
|
+
set_preference(id, value, dry_run=bool(dry_run)),
|
|
38
|
+
ensure_ascii=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
TOOLS = [
|
|
43
|
+
(
|
|
44
|
+
handle_desktop_preferences_catalog,
|
|
45
|
+
"nexo_desktop_preferences_catalog",
|
|
46
|
+
"List the settings and automation preferences NEXO can explain or change for the operator.",
|
|
47
|
+
),
|
|
48
|
+
(
|
|
49
|
+
handle_desktop_preference_get,
|
|
50
|
+
"nexo_desktop_preference_get",
|
|
51
|
+
"Read one preference by id or alias, including its current value when available.",
|
|
52
|
+
),
|
|
53
|
+
(
|
|
54
|
+
handle_desktop_preference_explain,
|
|
55
|
+
"nexo_desktop_preference_explain",
|
|
56
|
+
"Explain what one Desktop/Brain preference means and where it is stored.",
|
|
57
|
+
),
|
|
58
|
+
(
|
|
59
|
+
handle_desktop_preference_set,
|
|
60
|
+
"nexo_desktop_preference_set",
|
|
61
|
+
"Change one supported preference by id or alias. Use dry_run=true to preview.",
|
|
62
|
+
),
|
|
63
|
+
]
|
|
@@ -253,6 +253,7 @@ def handle_automation_schedule(
|
|
|
253
253
|
name: str,
|
|
254
254
|
every_seconds: int = 0,
|
|
255
255
|
daily_at: str = "",
|
|
256
|
+
weekdays: str = "",
|
|
256
257
|
clear: bool = False,
|
|
257
258
|
) -> str:
|
|
258
259
|
init_db()
|
|
@@ -262,6 +263,7 @@ def handle_automation_schedule(
|
|
|
262
263
|
name,
|
|
263
264
|
interval_seconds=interval_seconds,
|
|
264
265
|
daily_at=str(daily_at or "").strip() or None,
|
|
266
|
+
weekdays=str(weekdays or "").strip() or None,
|
|
265
267
|
clear=bool(clear),
|
|
266
268
|
),
|
|
267
269
|
ensure_ascii=False,
|
package/src/plugins/update.py
CHANGED
|
@@ -1829,9 +1829,13 @@ def handle_update(
|
|
|
1829
1829
|
from client_sync import sync_all_clients
|
|
1830
1830
|
from client_preferences import normalize_client_preferences
|
|
1831
1831
|
from model_defaults import heal_runtime_profiles
|
|
1832
|
+
from auto_update import _refresh_resonance_tiers_model_defaults
|
|
1832
1833
|
|
|
1833
1834
|
schedule_path = paths.config_dir() / "schedule.json"
|
|
1834
1835
|
schedule_payload = json.loads(schedule_path.read_text()) if schedule_path.exists() else {}
|
|
1836
|
+
for action in _refresh_resonance_tiers_model_defaults(NEXO_HOME):
|
|
1837
|
+
_emit_progress(progress_fn, action)
|
|
1838
|
+
steps_done.append("resonance-default-refresh")
|
|
1835
1839
|
# Heal Claude-family models written into Codex profile by earlier
|
|
1836
1840
|
# buggy versions. Must run BEFORE normalize so healed values
|
|
1837
1841
|
# propagate into the saved preferences.
|