nexo-brain 7.28.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.28.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,11 @@
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.28.0` is the current packaged-runtime line. 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.
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.
24
+
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.
22
26
 
23
27
  Previously in `7.27.6`: patch release over v7.27.5 - operational memory continuity now persists promises as commitments, routes pre-answer questions through evidence-backed memory, and exposes observation-queue convergence in health checks.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.28.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),
@@ -672,14 +823,23 @@ def get_script_runtime_contract(name: str) -> dict[str, Any]:
672
823
  blocked_reason = str(recipient_status.get("reason") or "")
673
824
  blocked_reason_code = str(recipient_status.get("reason_code") or "")
674
825
 
826
+ try:
827
+ from automation_preferences import supports_automation_preferences
828
+ preferences_supported = supports_automation_preferences(clean_name)
829
+ except Exception:
830
+ preferences_supported = False
831
+
675
832
  return {
676
833
  "name": clean_name,
677
834
  "toggleable_core": is_toggleable_core_script(clean_name),
678
835
  "supports_extra_instructions": supports_operator_extra_instructions(clean_name),
836
+ "supports_automation_preferences": preferences_supported,
679
837
  "schedule_configurable": bool(schedule_state.get("schedule_configurable")),
680
838
  "schedule_type": str(schedule_state.get("schedule_type") or ""),
681
839
  "schedule_source": str(schedule_state.get("schedule_source") or ""),
682
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"),
683
843
  "interval_seconds": int(schedule_state.get("interval_seconds", 0) or 0),
684
844
  "default_interval_seconds": int(schedule_state.get("default_interval_seconds", 0) or 0),
685
845
  "minimum_interval_seconds": int(schedule_state.get("minimum_interval_seconds", 0) or 0),
@@ -730,6 +890,7 @@ def get_operator_profile() -> dict[str, Any]:
730
890
  language = "en"
731
891
  operator_email = ""
732
892
  operator_accounts: list[dict] = []
893
+ profile_payload: dict[str, Any] = {}
733
894
 
734
895
  try:
735
896
  from calibration_runtime import load_runtime_calibration
@@ -756,6 +917,13 @@ def get_operator_profile() -> dict[str, Any]:
756
917
  or str(payload.get("lang") or "").strip()
757
918
  or language
758
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
759
927
  except Exception:
760
928
  pass
761
929
 
@@ -790,6 +958,15 @@ def get_operator_profile() -> dict[str, Any]:
790
958
  "operator_email": operator_email,
791
959
  "operator_aliases": aliases,
792
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"),
793
970
  }
794
971
 
795
972