nexo-brain 7.29.0 → 7.30.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.
@@ -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
- if "hour" in s:
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
- if "weekday" in s:
517
- raw_weekday = int(s["weekday"])
518
- weekday = "0" if raw_weekday == 7 else str(raw_weekday)
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
- if "weekday" in s:
920
- # Manifest weekday uses launchd convention: 0=Sunday … 6=Saturday (7=Sunday alias)
949
+ weekdays = _calendar_weekdays(s)
950
+ if weekdays:
921
951
  days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
922
- timer_spec = f"OnCalendar={days[s['weekday']]} *-*-* {h:02d}:{m:02d}:00"
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:
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "claude_code": {
4
- "model": "claude-opus-4-7[1m]",
4
+ "model": "claude-opus-4-8",
5
5
  "reasoning_effort": "max",
6
- "display_name": "Opus 4.7 with 1M context",
7
- "recommendation_version": 2,
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",
@@ -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-7[1m]",
23
+ "model": "claude-opus-4-8",
24
24
  "reasoning_effort": "max",
25
- "display_name": "Opus 4.7 with 1M context",
26
- "recommendation_version": 2,
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
- _OPUS_46_PREFIX = "claude-opus-4-6"
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 4.6 → 4.7 auto-migration for claude_code users on a NEXO default."""
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 4.6 → 4.7 auto-migration for claude_code ---
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(_OPUS_46_PREFIX):
134
+ if any(cc_model.startswith(prefix) for prefix in _CLAUDE_DEFAULT_PREFIXES):
135
135
  default = client_default("claude_code")
136
- suffix = cc_model[len(_OPUS_46_PREFIX):]
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,
@@ -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.