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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.29.0",
3
+ "version": "7.30.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.29.0` is the current packaged-runtime line. Minor release over v7.28.0 - the morning briefing now has structured content preferences, Desktop-facing briefing presentation state, and per-account email signature support.
21
+ Version `7.30.0` is the current packaged-runtime line. Minor release over v7.29.0 - morning briefing preferences now support weather/news, selectable weekdays, safer SMTP certificates, Opus 4.8 defaults, and an agent-facing preference catalog so NEXO can explain or change supported settings by chat.
22
+
23
+ Previously in `7.29.0`: minor release over v7.28.0 - the morning briefing now has structured content preferences, Desktop-facing briefing presentation state, and per-account email signature support.
22
24
 
23
25
  Previously in `7.28.0`: minor release over v7.27.6 - the Brain memory architecture now links action authorship, reasons, operational state, entity profiles, failure prevention, semantic layers, and release-gated runtime memory benchmarks.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.29.0",
3
+ "version": "7.30.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -2001,6 +2001,75 @@ def _relocate_resonance_tiers_contract(dest: Path = NEXO_HOME) -> list[str]:
2001
2001
  return actions
2002
2002
 
2003
2003
 
2004
+ def _refresh_resonance_tiers_model_defaults(dest: Path = NEXO_HOME) -> list[str]:
2005
+ """Upgrade persisted resonance contracts that still use old NEXO defaults.
2006
+
2007
+ Existing users keep a public ``resonance_tiers.json`` contract in their
2008
+ personal brain. Source defaults alone do not update that file, so known
2009
+ Opus defaults must be migrated in place while custom models are left alone.
2010
+ """
2011
+ actions: list[str] = []
2012
+ source_path = Path(__file__).resolve().parent / "resonance_tiers.json"
2013
+ try:
2014
+ source_payload = json.loads(source_path.read_text())
2015
+ except Exception as exc:
2016
+ return [f"resonance-default-refresh-warning:source:{exc.__class__.__name__}"]
2017
+
2018
+ target_paths = [
2019
+ dest / "personal" / "brain" / "resonance_tiers.json",
2020
+ dest / "brain" / "resonance_tiers.json",
2021
+ ]
2022
+ old_prefixes = ("claude-opus-4-6", "claude-opus-4-7")
2023
+
2024
+ for target_path in target_paths:
2025
+ try:
2026
+ target_path.parent.mkdir(parents=True, exist_ok=True)
2027
+ if target_path.is_file():
2028
+ payload = json.loads(target_path.read_text())
2029
+ else:
2030
+ if target_path != target_paths[0]:
2031
+ continue
2032
+ payload = json.loads(json.dumps(source_payload))
2033
+ except Exception as exc:
2034
+ actions.append(f"resonance-default-refresh-warning:read:{target_path.name}:{exc.__class__.__name__}")
2035
+ continue
2036
+
2037
+ changed = False
2038
+ tiers = payload.get("tiers") if isinstance(payload, dict) else {}
2039
+ source_tiers = source_payload.get("tiers") if isinstance(source_payload, dict) else {}
2040
+ if not isinstance(tiers, dict) or not isinstance(source_tiers, dict):
2041
+ continue
2042
+
2043
+ for tier_name, tier_payload in tiers.items():
2044
+ if not isinstance(tier_payload, dict):
2045
+ continue
2046
+ claude = tier_payload.get("claude_code")
2047
+ source_claude = (source_tiers.get(tier_name) or {}).get("claude_code") if isinstance(source_tiers.get(tier_name), dict) else {}
2048
+ if not isinstance(claude, dict) or not isinstance(source_claude, dict):
2049
+ continue
2050
+ model = str(claude.get("model") or "").strip()
2051
+ tier_changed = False
2052
+ if model and model.startswith(old_prefixes):
2053
+ claude["model"] = str(source_claude.get("model") or "claude-opus-4-8")
2054
+ tier_changed = True
2055
+ if tier_changed and not str(claude.get("effort") or "").strip() and source_claude.get("effort"):
2056
+ claude["effort"] = str(source_claude.get("effort"))
2057
+ changed = changed or tier_changed
2058
+
2059
+ if "default_tier" not in payload and source_payload.get("default_tier"):
2060
+ payload["default_tier"] = source_payload.get("default_tier")
2061
+ changed = True
2062
+
2063
+ if changed or not target_path.is_file():
2064
+ try:
2065
+ target_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
2066
+ actions.append(f"resonance-default-refresh:{target_path.name}")
2067
+ except Exception as exc:
2068
+ actions.append(f"resonance-default-refresh-warning:write:{target_path.name}:{exc.__class__.__name__}")
2069
+
2070
+ return actions
2071
+
2072
+
2004
2073
  def _bootstrap_profile_from_calibration_meta(dest: Path = NEXO_HOME) -> list[str]:
2005
2074
  """Create ``brain/profile.json`` from ``calibration.json`` fields when the
2006
2075
  profile file does not exist yet.
@@ -5045,6 +5114,9 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
5045
5114
  reloc_actions = _relocate_resonance_tiers_contract(dest)
5046
5115
  for action in reloc_actions:
5047
5116
  actions.append(action)
5117
+ refresh_actions = _refresh_resonance_tiers_model_defaults(dest)
5118
+ for action in refresh_actions:
5119
+ actions.append(action)
5048
5120
  except Exception as exc:
5049
5121
  actions.append(f"resonance-contract-relocate-warning:{exc.__class__.__name__}")
5050
5122
 
@@ -44,6 +44,58 @@ _CORE_AUTOMATION_SCHEDULES: dict[str, dict[str, Any]] = {
44
44
  }
45
45
 
46
46
  EXTRA_INSTRUCTIONS_METADATA_KEY = "operator_extra_instructions"
47
+ _WEEKDAY_ORDER = (1, 2, 3, 4, 5, 6, 0)
48
+ _WEEKDAY_LABELS = {
49
+ 0: "Sun",
50
+ 1: "Mon",
51
+ 2: "Tue",
52
+ 3: "Wed",
53
+ 4: "Thu",
54
+ 5: "Fri",
55
+ 6: "Sat",
56
+ }
57
+ _WEEKDAY_ALIASES = {
58
+ "0": 0,
59
+ "7": 0,
60
+ "sun": 0,
61
+ "sunday": 0,
62
+ "domingo": 0,
63
+ "dom": 0,
64
+ "1": 1,
65
+ "mon": 1,
66
+ "monday": 1,
67
+ "lunes": 1,
68
+ "lun": 1,
69
+ "2": 2,
70
+ "tue": 2,
71
+ "tuesday": 2,
72
+ "martes": 2,
73
+ "mar": 2,
74
+ "3": 3,
75
+ "wed": 3,
76
+ "wednesday": 3,
77
+ "miercoles": 3,
78
+ "miércoles": 3,
79
+ "mie": 3,
80
+ "mié": 3,
81
+ "4": 4,
82
+ "thu": 4,
83
+ "thursday": 4,
84
+ "jueves": 4,
85
+ "jue": 4,
86
+ "5": 5,
87
+ "fri": 5,
88
+ "friday": 5,
89
+ "viernes": 5,
90
+ "vie": 5,
91
+ "6": 6,
92
+ "sat": 6,
93
+ "saturday": 6,
94
+ "sabado": 6,
95
+ "sábado": 6,
96
+ "sab": 6,
97
+ "sáb": 6,
98
+ }
47
99
 
48
100
 
49
101
  _EMAIL_REQUIRED_SCRIPTS: dict[str, dict[str, Any]] = {
@@ -192,6 +244,98 @@ def get_core_manifest_cron(name: str) -> dict[str, Any]:
192
244
  return {}
193
245
 
194
246
 
247
+ def _normalize_weekdays(value: Any) -> list[int] | None:
248
+ if value is None:
249
+ return None
250
+ if isinstance(value, str):
251
+ raw = value.strip().lower()
252
+ if not raw or raw in {"all", "daily", "everyday", "cada dia", "cada día"}:
253
+ return []
254
+ parts = [part.strip() for part in raw.replace(";", ",").replace("|", ",").replace("+", ",").split(",")]
255
+ elif isinstance(value, (list, tuple, set)):
256
+ parts = list(value)
257
+ else:
258
+ return None
259
+
260
+ selected: set[int] = set()
261
+ invalid = False
262
+ for part in parts:
263
+ key = str(part).strip().lower()
264
+ if not key:
265
+ continue
266
+ if "-" in key:
267
+ left, right = [piece.strip() for piece in key.split("-", 1)]
268
+ start = _WEEKDAY_ALIASES.get(left)
269
+ end = _WEEKDAY_ALIASES.get(right)
270
+ if start is not None and end is not None:
271
+ ordered = list(_WEEKDAY_ORDER)
272
+ start_index = ordered.index(start)
273
+ end_index = ordered.index(end)
274
+ if start_index <= end_index:
275
+ selected.update(ordered[start_index:end_index + 1])
276
+ else:
277
+ selected.update(ordered[start_index:] + ordered[:end_index + 1])
278
+ continue
279
+ invalid = True
280
+ continue
281
+ if key in _WEEKDAY_ALIASES:
282
+ selected.add(_WEEKDAY_ALIASES[key])
283
+ continue
284
+ try:
285
+ day_number = int(key)
286
+ if 0 <= day_number <= 7:
287
+ selected.add(day_number % 7)
288
+ continue
289
+ except Exception:
290
+ pass
291
+ invalid = True
292
+ if invalid:
293
+ return None
294
+ if len(selected) >= 7:
295
+ return []
296
+ return [day for day in _WEEKDAY_ORDER if day in selected]
297
+
298
+
299
+ def _schedule_weekdays(schedule: dict[str, Any]) -> list[int]:
300
+ if not isinstance(schedule, dict):
301
+ return []
302
+ normalized = _normalize_weekdays(schedule.get("weekdays"))
303
+ if normalized is not None:
304
+ return normalized
305
+ if "weekday" in schedule:
306
+ normalized = _normalize_weekdays([schedule.get("weekday")])
307
+ return normalized or []
308
+ return []
309
+
310
+
311
+ def _apply_weekdays_to_schedule(schedule: dict[str, Any], weekdays: list[int] | None) -> dict[str, Any]:
312
+ next_schedule = dict(schedule)
313
+ next_schedule.pop("weekday", None)
314
+ if weekdays:
315
+ next_schedule["weekdays"] = weekdays
316
+ else:
317
+ next_schedule.pop("weekdays", None)
318
+ return next_schedule
319
+
320
+
321
+ def _same_calendar_schedule(left: dict[str, Any], right: dict[str, Any]) -> bool:
322
+ def normalized(payload: dict[str, Any]) -> dict[str, Any]:
323
+ out = dict(payload or {})
324
+ weekdays = _schedule_weekdays(out)
325
+ out.pop("weekday", None)
326
+ out.pop("weekdays", None)
327
+ if weekdays:
328
+ out["weekdays"] = weekdays
329
+ return out
330
+
331
+ return normalized(left) == normalized(right)
332
+
333
+
334
+ def _normalize_calendar_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
335
+ normalized = dict(schedule or {})
336
+ return _apply_weekdays_to_schedule(normalized, _schedule_weekdays(normalized))
337
+
338
+
195
339
  def _format_interval_label(interval_seconds: int) -> str:
196
340
  interval = max(1, int(interval_seconds or 0))
197
341
  if interval % 3600 == 0:
@@ -210,12 +354,9 @@ def _format_calendar_label(schedule: dict[str, Any]) -> str:
210
354
  except Exception:
211
355
  return ""
212
356
  label = f"{hour:02d}:{minute:02d}"
213
- if "weekday" in schedule:
214
- try:
215
- weekday = int(schedule.get("weekday", 0))
216
- except Exception:
217
- weekday = 0
218
- label += f" weekday={weekday}"
357
+ weekdays = _schedule_weekdays(schedule)
358
+ if weekdays:
359
+ label += " " + ",".join(_WEEKDAY_LABELS.get(day, str(day)) for day in weekdays)
219
360
  else:
220
361
  label += " daily"
221
362
  return label
@@ -232,13 +373,15 @@ def _decorate_schedule_state(*, base_cron: dict[str, Any], effective_cron: dict[
232
373
  }
233
374
  schedule = effective_cron.get("schedule")
234
375
  if isinstance(schedule, dict) and schedule:
376
+ normalized_schedule = _normalize_calendar_schedule(schedule)
377
+ normalized_default = _normalize_calendar_schedule(dict(base_cron.get("schedule") or {}))
235
378
  return {
236
379
  "schedule_type": "calendar",
237
380
  "interval_seconds": 0,
238
381
  "default_interval_seconds": 0,
239
- "schedule": dict(schedule),
240
- "default_schedule": dict(base_cron.get("schedule") or {}),
241
- "effective_schedule_label": _format_calendar_label(schedule),
382
+ "schedule": normalized_schedule,
383
+ "default_schedule": normalized_default,
384
+ "effective_schedule_label": _format_calendar_label(normalized_schedule),
242
385
  "schedule_source": source,
243
386
  }
244
387
  return {
@@ -354,6 +497,7 @@ def set_core_automation_schedule(
354
497
  *,
355
498
  interval_seconds: int | None = None,
356
499
  daily_at: str | None = None,
500
+ weekdays: Any = None,
357
501
  clear: bool = False,
358
502
  ) -> dict[str, Any]:
359
503
  clean_name = _normalize_name(name)
@@ -382,10 +526,15 @@ def set_core_automation_schedule(
382
526
  parsed_daily_at = _parse_daily_at(daily_at or "")
383
527
  if not parsed_daily_at:
384
528
  return {"ok": False, "error": "daily_at must use HH:MM (24h) format"}
529
+ normalized_weekdays = _normalize_weekdays(weekdays)
530
+ if weekdays is not None and normalized_weekdays is None:
531
+ return {"ok": False, "error": "weekdays must be a comma-separated list like Mon-Fri or Tue,Sat"}
385
532
  default_schedule = dict(base_cron.get("schedule") or {})
386
533
  next_schedule = dict(default_schedule)
387
534
  next_schedule.update(parsed_daily_at)
388
- next_override = {} if next_schedule == default_schedule else {"schedule": next_schedule}
535
+ if normalized_weekdays is not None:
536
+ next_schedule = _apply_weekdays_to_schedule(next_schedule, normalized_weekdays)
537
+ next_override = {} if _same_calendar_schedule(next_schedule, default_schedule) else {"schedule": next_schedule}
389
538
  else:
390
539
  try:
391
540
  normalized_interval = int(interval_seconds or 0)
@@ -420,6 +569,8 @@ def set_core_automation_schedule(
420
569
  "schedule_type": str(state.get("schedule_type") or ""),
421
570
  "schedule_source": str(state.get("schedule_source") or ""),
422
571
  "effective_schedule_label": str(state.get("effective_schedule_label") or ""),
572
+ "schedule": state.get("schedule"),
573
+ "default_schedule": state.get("default_schedule"),
423
574
  "interval_seconds": int(state.get("interval_seconds", 0) or 0),
424
575
  "default_interval_seconds": int(state.get("default_interval_seconds", 0) or 0),
425
576
  "minimum_interval_seconds": int(state.get("minimum_interval_seconds", 0) or 0),
@@ -687,6 +838,8 @@ def get_script_runtime_contract(name: str) -> dict[str, Any]:
687
838
  "schedule_type": str(schedule_state.get("schedule_type") or ""),
688
839
  "schedule_source": str(schedule_state.get("schedule_source") or ""),
689
840
  "effective_schedule_label": str(schedule_state.get("effective_schedule_label") or ""),
841
+ "schedule": schedule_state.get("schedule"),
842
+ "default_schedule": schedule_state.get("default_schedule"),
690
843
  "interval_seconds": int(schedule_state.get("interval_seconds", 0) or 0),
691
844
  "default_interval_seconds": int(schedule_state.get("default_interval_seconds", 0) or 0),
692
845
  "minimum_interval_seconds": int(schedule_state.get("minimum_interval_seconds", 0) or 0),
@@ -737,6 +890,7 @@ def get_operator_profile() -> dict[str, Any]:
737
890
  language = "en"
738
891
  operator_email = ""
739
892
  operator_accounts: list[dict] = []
893
+ profile_payload: dict[str, Any] = {}
740
894
 
741
895
  try:
742
896
  from calibration_runtime import load_runtime_calibration
@@ -763,6 +917,13 @@ def get_operator_profile() -> dict[str, Any]:
763
917
  or str(payload.get("lang") or "").strip()
764
918
  or language
765
919
  )
920
+ profile_path = brain_dir() / "profile.json"
921
+ if profile_path.exists():
922
+ loaded_profile = json.loads(profile_path.read_text())
923
+ if isinstance(loaded_profile, dict):
924
+ profile_payload = loaded_profile
925
+ operator_name = str(loaded_profile.get("name") or operator_name).strip() or operator_name
926
+ language = str(loaded_profile.get("language") or language).strip() or language
766
927
  except Exception:
767
928
  pass
768
929
 
@@ -797,6 +958,15 @@ def get_operator_profile() -> dict[str, Any]:
797
958
  "operator_email": operator_email,
798
959
  "operator_aliases": aliases,
799
960
  "operator_accounts": operator_accounts,
961
+ "current_residence": profile_payload.get("current_residence"),
962
+ "timezone": profile_payload.get("timezone"),
963
+ "location": profile_payload.get("location"),
964
+ "coordinates": profile_payload.get("coordinates"),
965
+ "latitude": profile_payload.get("latitude"),
966
+ "longitude": profile_payload.get("longitude"),
967
+ "role": profile_payload.get("role"),
968
+ "technical_level": profile_payload.get("technical_level"),
969
+ "news_rss_url": profile_payload.get("news_rss_url"),
800
970
  }
801
971
 
802
972
 
@@ -22,29 +22,75 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
22
22
  "id": "content",
23
23
  "label": "Content",
24
24
  "items": [
25
- {"id": "priorities", "type": "boolean", "label": "Priorities", "default": True},
26
- {"id": "agenda", "type": "boolean", "label": "Agenda", "default": True},
27
- {"id": "reminders", "type": "boolean", "label": "Reminders and tasks", "default": True},
28
- {"id": "followups", "type": "boolean", "label": "Follow-ups", "default": True},
29
- {"id": "decisions", "type": "boolean", "label": "Recent decisions", "default": True},
30
- {"id": "email_activity", "type": "boolean", "label": "Recent sent email", "default": True},
31
- {"id": "blockers", "type": "boolean", "label": "Blockers and risks", "default": True},
32
- {"id": "internal_refs", "type": "boolean", "label": "Internal references", "default": False},
25
+ {
26
+ "id": "priorities",
27
+ "type": "boolean",
28
+ "label": "Priorities",
29
+ "default": True,
30
+ "help": "The most important things NEXO thinks you should look at first.",
31
+ },
32
+ {
33
+ "id": "agenda",
34
+ "type": "boolean",
35
+ "label": "Agenda",
36
+ "default": True,
37
+ "help": "Calendar-like items, dated work and events that affect today.",
38
+ },
39
+ {
40
+ "id": "reminders",
41
+ "type": "boolean",
42
+ "label": "Reminders and tasks",
43
+ "default": True,
44
+ "help": "Pending reminders and tasks saved in NEXO.",
45
+ },
46
+ {
47
+ "id": "followups",
48
+ "type": "boolean",
49
+ "label": "Follow-ups",
50
+ "default": True,
51
+ "help": "Open work that NEXO is tracking until it is resolved or clearly blocked.",
52
+ },
53
+ {
54
+ "id": "decisions",
55
+ "type": "boolean",
56
+ "label": "Recent decisions",
57
+ "default": True,
58
+ "help": "Important decisions recorded recently so they are not forgotten.",
59
+ },
60
+ {
61
+ "id": "email_activity",
62
+ "type": "boolean",
63
+ "label": "Recent sent email",
64
+ "default": True,
65
+ "help": "Emails NEXO sent recently, useful to know what moved while you were away.",
66
+ },
67
+ {
68
+ "id": "blockers",
69
+ "type": "boolean",
70
+ "label": "Blockers and risks",
71
+ "default": True,
72
+ "help": "Things that may stop progress, need your decision or could become a problem.",
73
+ },
74
+ {
75
+ "id": "internal_refs",
76
+ "type": "boolean",
77
+ "label": "Internal references",
78
+ "default": False,
79
+ "help": "Technical file names, IDs or internal references. Keep this off for a cleaner human summary.",
80
+ },
33
81
  {
34
82
  "id": "news",
35
83
  "type": "boolean",
36
84
  "label": "News",
37
85
  "default": False,
38
- "disabled": True,
39
- "disabled_reason": "No verified news source is connected yet.",
86
+ "help": "A short set of public headlines from the configured news feed. If the feed is unreachable, NEXO simply says it was unavailable.",
40
87
  },
41
88
  {
42
89
  "id": "weather",
43
90
  "type": "boolean",
44
91
  "label": "Weather",
45
- "default": False,
46
- "disabled": True,
47
- "disabled_reason": "No verified weather source is connected yet.",
92
+ "default": True,
93
+ "help": "Today's weather from the location saved in Desktop or your residence in the profile.",
48
94
  },
49
95
  ],
50
96
  },
@@ -58,6 +104,7 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
58
104
  "label": "Length",
59
105
  "default": "normal",
60
106
  "options": ["short", "normal", "detailed"],
107
+ "help": "How much detail the briefing should include.",
61
108
  },
62
109
  {
63
110
  "id": "tone",
@@ -65,6 +112,7 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
65
112
  "label": "Tone",
66
113
  "default": "direct",
67
114
  "options": ["direct", "warm", "executive", "personal"],
115
+ "help": "How NEXO should write the summary.",
68
116
  },
69
117
  {
70
118
  "id": "format",
@@ -72,6 +120,7 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
72
120
  "label": "Format",
73
121
  "default": "sections",
74
122
  "options": ["sections", "bullets", "narrative"],
123
+ "help": "How the briefing is organized visually.",
75
124
  },
76
125
  ],
77
126
  },
@@ -85,13 +134,7 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
85
134
  "label": "Quiet days",
86
135
  "default": "summary_if_anything_important",
87
136
  "options": ["always_send", "summary_if_anything_important", "skip_if_empty"],
88
- },
89
- {
90
- "id": "audience",
91
- "type": "choice",
92
- "label": "User type",
93
- "default": "operator",
94
- "options": ["operator", "founder", "student", "sales", "technical", "personal"],
137
+ "help": "What NEXO should do on days with little or no important activity.",
95
138
  },
96
139
  ],
97
140
  },
@@ -279,6 +322,7 @@ def search_automation_preference_schema(name: str, query: str) -> list[dict[str,
279
322
  str(item.get("id") or ""),
280
323
  str(item.get("label") or ""),
281
324
  str(item.get("disabled_reason") or ""),
325
+ str(item.get("help") or ""),
282
326
  str(group.get("label") or ""),
283
327
  ])
284
328
  if clean_query in _fold_text(text):
package/src/cli.py CHANGED
@@ -1037,11 +1037,19 @@ def _automations_set_schedule(args):
1037
1037
  interval_seconds = int(args.every_seconds)
1038
1038
  elif getattr(args, "daily_at", None):
1039
1039
  daily_at = str(args.daily_at).strip()
1040
+ if getattr(args, "weekdays", None) and not daily_at:
1041
+ result = {"ok": False, "error": "--weekdays can only be used with --daily-at"}
1042
+ if args.json:
1043
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1044
+ return 1
1045
+ print(result["error"], file=sys.stderr)
1046
+ return 1
1040
1047
 
1041
1048
  result = set_automation_schedule(
1042
1049
  args.name,
1043
1050
  interval_seconds=interval_seconds,
1044
1051
  daily_at=daily_at,
1052
+ weekdays=getattr(args, "weekdays", None),
1045
1053
  clear=bool(getattr(args, "reset", False)),
1046
1054
  )
1047
1055
  if args.json:
@@ -2612,6 +2620,28 @@ def _preferences(args):
2612
2620
  _load_user_default_resonance,
2613
2621
  )
2614
2622
 
2623
+ action = str(getattr(args, "preferences_action", "") or "").strip().lower()
2624
+ if action in {"catalog", "get", "set", "explain"}:
2625
+ from preference_catalog import build_preference_catalog, explain_preference, set_preference
2626
+
2627
+ if action == "catalog":
2628
+ payload = build_preference_catalog(
2629
+ include_values=bool(getattr(args, "include_values", False)),
2630
+ query=str(getattr(args, "query", "") or "").strip() or None,
2631
+ )
2632
+ elif action in {"get", "explain"}:
2633
+ pref_id = str(getattr(args, "preferences_id", "") or "").strip()
2634
+ payload = explain_preference(pref_id)
2635
+ else:
2636
+ pref_id = str(getattr(args, "preferences_id", "") or "").strip()
2637
+ value = getattr(args, "preferences_value", None)
2638
+ payload = set_preference(pref_id, value, dry_run=bool(getattr(args, "dry_run", False)))
2639
+ if getattr(args, "json", False):
2640
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
2641
+ else:
2642
+ print(json.dumps(payload, ensure_ascii=False))
2643
+ return 0 if payload.get("ok", True) else 1
2644
+
2615
2645
  prefs = load_client_preferences()
2616
2646
  if not isinstance(prefs, dict):
2617
2647
  prefs = {}
@@ -4150,6 +4180,7 @@ def main():
4150
4180
  automations_schedule_group.add_argument("--every-seconds", type=int, help="Run the automation every N seconds")
4151
4181
  automations_schedule_group.add_argument("--daily-at", type=str, help="Run the automation every day at HH:MM (24h)")
4152
4182
  automations_schedule_group.add_argument("--reset", action="store_true", help="Restore the shipped default cadence")
4183
+ automations_schedule_p.add_argument("--weekdays", default=None, help="Optional calendar days, e.g. Mon-Fri or Tue,Sat")
4153
4184
  automations_schedule_p.add_argument("--json", action="store_true", help="JSON output")
4154
4185
 
4155
4186
  automations_schema_p = automations_sub.add_parser(
@@ -4281,6 +4312,12 @@ def main():
4281
4312
  "preferences",
4282
4313
  help="Read or change NEXO user preferences (resonance, default client, ...)",
4283
4314
  )
4315
+ preferences_parser.add_argument("preferences_action", nargs="?", choices=["catalog", "get", "set", "explain"], help="Catalog operation")
4316
+ preferences_parser.add_argument("preferences_id", nargs="?", help="Preference id or alias")
4317
+ preferences_parser.add_argument("preferences_value", nargs="?", help="Value for `preferences set`")
4318
+ preferences_parser.add_argument("--query", default="", help="Filter catalog entries")
4319
+ preferences_parser.add_argument("--include-values", action="store_true", help="Include current values in catalog output")
4320
+ preferences_parser.add_argument("--dry-run", action="store_true", help="Preview a preference change")
4284
4321
  preferences_parser.add_argument(
4285
4322
  "--resonance",
4286
4323
  choices=["maximo", "alto", "medio", "bajo"],