nexo-brain 7.27.0 → 7.27.2

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.27.0",
3
+ "version": "7.27.2",
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.27.0` is the current packaged-runtime line. Minor release over v7.26.0 - Codex-side defaults move to verified `gpt-5.5` resonance tiers, managed config healing follows that model family, and Local Memory file-type actions tolerate transient SQLite locks.
21
+ Version `7.27.2` is the current packaged-runtime line. Patch release over v7.27.1 - legacy Codex preferences now migrate to OpenAI correctly, and the F0.6 config-folder transition keeps reading existing `config/schedule.json` before it is moved.
22
+
23
+ Previously in `7.27.1`: patch release over v7.27.0 - lifecycle stop calls skip external provider session UUIDs safely, and provider runtime selection keeps chat plus automation aligned to the same Anthropic/OpenAI account.
24
+
25
+ Previously in `7.27.0`: minor release over v7.26.0 - Codex-side defaults move to verified `gpt-5.5` resonance tiers, managed config healing follows that model family, and Local Memory file-type actions tolerate transient SQLite locks.
22
26
 
23
27
  Previously in `7.26.0`: minor release over v7.25.6 - provider runtime parity lets Desktop-managed Brain choose Anthropic or OpenAI, keep provider metadata on sessions/automation/crons, and provision the managed OpenAI runtime from bundled Desktop resources.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.27.0",
3
+ "version": "7.27.2",
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",
package/src/cli.py CHANGED
@@ -2519,6 +2519,7 @@ def _preferences(args):
2519
2519
  PROVIDER_KEYS,
2520
2520
  PROVIDER_NONE,
2521
2521
  load_client_preferences,
2522
+ normalize_provider_key,
2522
2523
  normalize_default_terminal_client,
2523
2524
  save_client_preferences,
2524
2525
  )
@@ -2554,6 +2555,18 @@ def _preferences(args):
2554
2555
  file=sys.stderr,
2555
2556
  )
2556
2557
  return 2
2558
+ normalized_chat_provider = normalize_provider_key(chat_provider)
2559
+ normalized_automation_provider = normalize_provider_key(automation_provider)
2560
+ if (
2561
+ normalized_chat_provider in PROVIDER_KEYS
2562
+ and normalized_automation_provider in PROVIDER_KEYS
2563
+ and normalized_chat_provider != normalized_automation_provider
2564
+ ):
2565
+ print(
2566
+ "[NEXO] Chat and background automation must use the same provider.",
2567
+ file=sys.stderr,
2568
+ )
2569
+ return 2
2557
2570
 
2558
2571
  if args.resonance:
2559
2572
  tier = args.resonance.lower()
@@ -2668,12 +2681,16 @@ def _provider(args):
2668
2681
  if target not in PROVIDER_KEYS:
2669
2682
  print(f"[NEXO] Unknown provider '{target}'. Valid values: {', '.join(PROVIDER_KEYS)}.", file=sys.stderr)
2670
2683
  return 2
2671
- chat_provider = None if getattr(args, "automation_only", False) else target
2672
- automation_provider = None if getattr(args, "chat_only", False) else target
2684
+ if getattr(args, "automation_only", False) or getattr(args, "chat_only", False):
2685
+ print(
2686
+ "[NEXO] Provider selection is unified: chat and background automation use the same provider.",
2687
+ file=sys.stderr,
2688
+ )
2689
+ return 2
2673
2690
  save_client_preferences(
2674
- selected_chat_provider=chat_provider,
2675
- automation_provider=automation_provider,
2676
- automation_user_override=True if automation_provider is not None else None,
2691
+ selected_chat_provider=target,
2692
+ automation_provider=target,
2693
+ automation_user_override=True,
2677
2694
  )
2678
2695
  prefs = load_client_preferences()
2679
2696
  provider_runtime = prefs.get("provider_runtime") if isinstance(prefs.get("provider_runtime"), dict) else {}
@@ -373,6 +373,21 @@ def _provider_from_runtime_payload(value, key: str, fallback_client: str) -> str
373
373
  return client_to_provider(fallback_client) or PROVIDER_ANTHROPIC
374
374
 
375
375
 
376
+ def _provider_runtime_is_unselected_default(value) -> bool:
377
+ if not isinstance(value, dict):
378
+ return False
379
+ selected = normalize_provider_key(value.get("selected_chat_provider"))
380
+ automation_provider = normalize_provider_key(value.get("automation_provider"))
381
+ automation_backend = normalize_backend_key(value.get("automation_backend"))
382
+ last_change = value.get("last_provider_change") if isinstance(value.get("last_provider_change"), dict) else {}
383
+ return (
384
+ selected == PROVIDER_ANTHROPIC
385
+ and automation_provider in {"", PROVIDER_ANTHROPIC}
386
+ and automation_backend in {"", CLIENT_CLAUDE_CODE}
387
+ and not last_change.get("changed_at")
388
+ )
389
+
390
+
376
391
  def normalize_provider_runtime(
377
392
  value,
378
393
  *,
@@ -388,19 +403,17 @@ def normalize_provider_runtime(
388
403
  default_terminal_client,
389
404
  )
390
405
  raw_automation_provider = normalize_provider_key(raw.get("automation_provider")) if isinstance(raw, dict) else ""
391
- automation_provider = (
392
- PROVIDER_NONE
393
- if raw_automation_provider == PROVIDER_NONE
394
- else _provider_from_runtime_payload(
395
- raw,
396
- "automation_provider",
397
- automation_backend if automation_enabled else BACKEND_NONE,
398
- )
399
- )
400
- if not automation_enabled or automation_backend == BACKEND_NONE:
406
+ raw_has_automation_backend = isinstance(raw, dict) and "automation_backend" in raw
407
+ raw_automation_backend = normalize_backend_key(raw.get("automation_backend")) if raw_has_automation_backend else ""
408
+ if (
409
+ not automation_enabled
410
+ or automation_backend == BACKEND_NONE
411
+ or raw_automation_provider == PROVIDER_NONE
412
+ or (raw_has_automation_backend and raw_automation_backend == BACKEND_NONE)
413
+ ):
401
414
  automation_provider = PROVIDER_NONE
402
- elif automation_provider not in PROVIDER_KEYS:
403
- automation_provider = client_to_provider(automation_backend) or PROVIDER_ANTHROPIC
415
+ else:
416
+ automation_provider = selected_provider
404
417
 
405
418
  providers = defaults["providers"]
406
419
  raw_providers = raw.get("providers") if isinstance(raw.get("providers"), dict) else {}
@@ -692,8 +705,19 @@ def normalize_client_preferences(
692
705
  runtime_profiles = normalize_client_runtime_profiles(
693
706
  schedule.get("client_runtime_profiles")
694
707
  )
708
+ raw_provider_runtime = schedule.get("provider_runtime")
709
+ legacy_terminal_provider = client_to_provider(default_terminal_client)
710
+ if (
711
+ legacy_terminal_provider in PROVIDER_KEYS
712
+ and legacy_terminal_provider != PROVIDER_ANTHROPIC
713
+ and _provider_runtime_is_unselected_default(raw_provider_runtime)
714
+ ):
715
+ raw_provider_runtime = dict(raw_provider_runtime or {})
716
+ raw_provider_runtime["selected_chat_provider"] = legacy_terminal_provider
717
+ raw_provider_runtime["automation_provider"] = legacy_terminal_provider
718
+ raw_provider_runtime["automation_backend"] = provider_to_client(legacy_terminal_provider)
695
719
  provider_runtime = normalize_provider_runtime(
696
- schedule.get("provider_runtime"),
720
+ raw_provider_runtime,
697
721
  default_terminal_client=default_terminal_client,
698
722
  automation_backend=automation_backend,
699
723
  automation_enabled=automation_enabled,
@@ -786,28 +810,53 @@ def apply_client_preferences(
786
810
  else current.get("provider_runtime")
787
811
  )
788
812
  raw_provider_runtime = dict(raw_provider_runtime or {})
789
- if selected_chat_provider is not None:
790
- raw_provider_runtime["selected_chat_provider"] = selected_chat_provider
791
- derived_chat_client = provider_to_client(selected_chat_provider)
792
- if derived_chat_client:
793
- merged["interactive_clients"][derived_chat_client] = True
794
- merged["default_terminal_client"] = derived_chat_client
813
+ explicit_selected_provider = normalize_provider_key(selected_chat_provider)
814
+ explicit_automation_provider = normalize_provider_key(automation_provider)
815
+ backend_provider = (
816
+ client_to_provider(merged["automation_backend"])
817
+ if merged["automation_backend"] != BACKEND_NONE
818
+ else ""
819
+ )
820
+ terminal_provider = (
821
+ client_to_provider(merged["default_terminal_client"])
822
+ if default_terminal_client is not None
823
+ else ""
824
+ )
825
+ single_provider = (
826
+ (explicit_selected_provider if explicit_selected_provider != PROVIDER_NONE else "")
827
+ or (explicit_automation_provider if explicit_automation_provider != PROVIDER_NONE else "")
828
+ or (backend_provider if automation_backend is not None else "")
829
+ or terminal_provider
830
+ )
831
+ if single_provider in PROVIDER_KEYS:
832
+ raw_provider_runtime["selected_chat_provider"] = single_provider
833
+ selected_client = provider_to_client(single_provider)
834
+ if selected_client:
835
+ merged["interactive_clients"][selected_client] = True
836
+ merged["default_terminal_client"] = selected_client
795
837
  elif default_terminal_client is not None:
796
838
  raw_provider_runtime["selected_chat_provider"] = client_to_provider(
797
839
  merged["default_terminal_client"]
798
840
  )
799
- if automation_provider is not None:
800
- raw_provider_runtime["automation_provider"] = automation_provider
801
- derived_backend = provider_to_client(automation_provider)
802
- if derived_backend:
803
- merged["interactive_clients"][derived_backend] = True
804
- merged["automation_backend"] = (
805
- derived_backend if derived_backend else BACKEND_NONE
806
- )
841
+
842
+ automation_off = (
843
+ merged["automation_enabled"] is False
844
+ or merged["automation_backend"] == BACKEND_NONE
845
+ or explicit_automation_provider == PROVIDER_NONE
846
+ )
847
+ if automation_off:
848
+ raw_provider_runtime["automation_provider"] = PROVIDER_NONE
849
+ raw_provider_runtime["automation_backend"] = BACKEND_NONE
850
+ merged["automation_backend"] = BACKEND_NONE
851
+ elif single_provider in PROVIDER_KEYS:
852
+ raw_provider_runtime["automation_provider"] = single_provider
853
+ merged["automation_backend"] = provider_to_client(single_provider)
854
+ raw_provider_runtime["automation_backend"] = merged["automation_backend"]
807
855
  elif automation_backend is not None or automation_enabled is not None:
808
856
  raw_provider_runtime["automation_provider"] = client_to_provider(
809
857
  merged["automation_backend"]
810
858
  ) if merged["automation_backend"] != BACKEND_NONE else PROVIDER_NONE
859
+ raw_provider_runtime["automation_backend"] = merged["automation_backend"]
811
860
  merged["provider_runtime"] = normalize_provider_runtime(
812
861
  raw_provider_runtime,
813
862
  default_terminal_client=merged["default_terminal_client"],
@@ -977,7 +1026,7 @@ def detect_installed_clients(user_home: str | os.PathLike[str] | None = None) ->
977
1026
 
978
1027
 
979
1028
  def resolve_terminal_client(requested: str | None = None, *, preferences: dict | None = None) -> str:
980
- normalized = preferences or load_client_preferences()
1029
+ normalized = normalize_client_preferences(preferences) if preferences is not None else load_client_preferences()
981
1030
  if requested is not None:
982
1031
  interactive_clients = normalized["interactive_clients"]
983
1032
  return normalize_default_terminal_client(requested, interactive_clients=interactive_clients)
@@ -988,7 +1037,7 @@ def resolve_terminal_client(requested: str | None = None, *, preferences: dict |
988
1037
 
989
1038
 
990
1039
  def resolve_selected_chat_provider(preferences: dict | None = None) -> str:
991
- normalized = preferences or load_client_preferences()
1040
+ normalized = normalize_client_preferences(preferences) if preferences is not None else load_client_preferences()
992
1041
  provider_runtime = normalize_provider_runtime(
993
1042
  normalized.get("provider_runtime"),
994
1043
  default_terminal_client=normalized.get("default_terminal_client", CLIENT_CLAUDE_CODE),
@@ -999,7 +1048,7 @@ def resolve_selected_chat_provider(preferences: dict | None = None) -> str:
999
1048
 
1000
1049
 
1001
1050
  def resolve_automation_provider(preferences: dict | None = None) -> str:
1002
- normalized = preferences or load_client_preferences()
1051
+ normalized = normalize_client_preferences(preferences) if preferences is not None else load_client_preferences()
1003
1052
  provider_runtime = normalize_provider_runtime(
1004
1053
  normalized.get("provider_runtime"),
1005
1054
  default_terminal_client=normalized.get("default_terminal_client", CLIENT_CLAUDE_CODE),
@@ -1016,7 +1065,7 @@ def resolve_user_model(preferences: dict | None = None) -> str:
1016
1065
  string. The value comes from the user's ``client_runtime_profiles`` for
1017
1066
  their ``default_terminal_client`` (usually ``claude_code``).
1018
1067
  """
1019
- normalized = preferences or load_client_preferences()
1068
+ normalized = normalize_client_preferences(preferences) if preferences is not None else load_client_preferences()
1020
1069
  client = normalize_default_terminal_client(
1021
1070
  normalized["default_terminal_client"],
1022
1071
  interactive_clients=normalized["interactive_clients"],
@@ -1026,11 +1075,8 @@ def resolve_user_model(preferences: dict | None = None) -> str:
1026
1075
 
1027
1076
 
1028
1077
  def resolve_automation_backend(preferences: dict | None = None) -> str:
1029
- normalized = preferences or load_client_preferences()
1030
- return normalize_automation_backend(
1031
- normalized["automation_backend"],
1032
- automation_enabled=normalized["automation_enabled"],
1033
- )
1078
+ normalized = normalize_client_preferences(preferences) if preferences is not None else load_client_preferences()
1079
+ return normalized["automation_backend"]
1034
1080
 
1035
1081
 
1036
1082
  def resolve_client_runtime_profile(
@@ -218,7 +218,15 @@ def handle_nexo_lifecycle_stop_nexo_session(
218
218
  from tools_sessions import handle_stop
219
219
 
220
220
  raw_sid = str(sid or "").strip()
221
- stop_sids = lifecycle_events.registered_stop_session_ids(raw_sid) or [raw_sid]
221
+ stop_sids = lifecycle_events.registered_stop_session_ids(raw_sid)
222
+ if not stop_sids:
223
+ return json.dumps({
224
+ "status": "ok",
225
+ "sid": raw_sid,
226
+ "stopped_session_ids": [],
227
+ "skipped": True,
228
+ "reason": "no-registered-nexo-session",
229
+ }, ensure_ascii=False)
222
230
  messages = [handle_stop(stop_sid) for stop_sid in stop_sids]
223
231
  return json.dumps({
224
232
  "status": "ok",
@@ -337,6 +337,23 @@ def _schedule_defaults() -> dict:
337
337
  "default_terminal_client": "claude_code",
338
338
  "automation_enabled": True,
339
339
  "automation_backend": "claude_code",
340
+ "provider_runtime": {
341
+ "schema_version": 1,
342
+ "selected_chat_provider": "anthropic",
343
+ "automation_provider": "anthropic",
344
+ "automation_backend": "claude_code",
345
+ "providers": {
346
+ "anthropic": {"client": "claude_code"},
347
+ "openai": {"client": "codex"},
348
+ },
349
+ "fallback_policy": {"chat": "ask", "automation": "fail_closed"},
350
+ "last_provider_change": {
351
+ "changed_at": None,
352
+ "from_provider": None,
353
+ "to_provider": None,
354
+ "source": None,
355
+ },
356
+ },
340
357
  "client_runtime_profiles": {
341
358
  "claude_code": {
342
359
  "model": DEFAULT_CLAUDE_CODE_MODEL,
@@ -361,16 +378,32 @@ def _schedule_defaults() -> dict:
361
378
  }
362
379
 
363
380
 
364
- def load_schedule_config() -> dict:
365
- if not SCHEDULE_FILE.is_file():
366
- return _schedule_defaults()
381
+ def _load_schedule_payload(path: Path) -> dict | None:
367
382
  try:
368
- data = json.loads(SCHEDULE_FILE.read_text())
383
+ data = json.loads(path.read_text())
369
384
  except Exception:
385
+ return None
386
+ return data if isinstance(data, dict) else None
387
+
388
+
389
+ def _legacy_schedule_file() -> Path:
390
+ return NEXO_HOME / "config" / "schedule.json"
391
+
392
+
393
+ def load_schedule_config() -> dict:
394
+ data: dict | None = None
395
+ if SCHEDULE_FILE.is_file():
396
+ data = _load_schedule_payload(SCHEDULE_FILE)
397
+ else:
398
+ legacy_file = _legacy_schedule_file()
399
+ if legacy_file != SCHEDULE_FILE and legacy_file.is_file():
400
+ data = _load_schedule_payload(legacy_file)
401
+ if data is None:
370
402
  return _schedule_defaults()
371
- if not isinstance(data, dict):
372
- return _schedule_defaults()
373
- merged = _schedule_defaults()
403
+ defaults = _schedule_defaults()
404
+ if "provider_runtime" not in data:
405
+ defaults.pop("provider_runtime", None)
406
+ merged = defaults
374
407
  merged.update(data)
375
408
  merged.setdefault("processes", {})
376
409
  return merged
@@ -63,11 +63,33 @@ for candidate in (
63
63
  except Exception:
64
64
  continue
65
65
 
66
- provider_runtime = schedule.get("provider_runtime") if isinstance(schedule.get("provider_runtime"), dict) else {}
67
- backend = str(provider_runtime.get("automation_backend") or schedule.get("automation_backend") or "claude_code").strip().lower()
68
- provider = str(provider_runtime.get("automation_provider") or "").strip().lower()
69
- if provider not in {"anthropic", "openai", "none"}:
70
- provider = {"claude_code": "anthropic", "codex": "openai", "none": "none"}.get(backend, "")
66
+ for import_root in (
67
+ nexo_home / "core",
68
+ nexo_home / "core" / "src",
69
+ nexo_home / "src",
70
+ ):
71
+ if import_root.exists():
72
+ sys.path.insert(0, str(import_root))
73
+ try:
74
+ from client_preferences import normalize_client_preferences # type: ignore
75
+ prefs = normalize_client_preferences(schedule)
76
+ provider_runtime = prefs.get("provider_runtime") if isinstance(prefs.get("provider_runtime"), dict) else {}
77
+ provider = str(provider_runtime.get("automation_provider") or "none").strip().lower()
78
+ backend = str(prefs.get("automation_backend") or provider_runtime.get("automation_backend") or "none").strip().lower()
79
+ except Exception:
80
+ provider_runtime = schedule.get("provider_runtime") if isinstance(schedule.get("provider_runtime"), dict) else {}
81
+ selected = str(provider_runtime.get("selected_chat_provider") or "").strip().lower()
82
+ backend_raw = str(provider_runtime.get("automation_backend") or schedule.get("automation_backend") or "claude_code").strip().lower()
83
+ provider_raw = str(provider_runtime.get("automation_provider") or "").strip().lower()
84
+ automation_enabled = schedule.get("automation_enabled", True) is not False
85
+ backend_map = {"claude_code": "anthropic", "codex": "openai", "none": "none"}
86
+ provider_map = {"anthropic": "anthropic", "openai": "openai", "none": "none"}
87
+ if (not automation_enabled) or backend_raw in {"none", "off", "disabled", "false", "0"} or provider_raw in {"none", "off", "disabled", "false", "0"}:
88
+ provider = "none"
89
+ backend = "none"
90
+ else:
91
+ provider = provider_map.get(selected) or provider_map.get(provider_raw) or backend_map.get(backend_raw, "anthropic")
92
+ backend = {"anthropic": "claude_code", "openai": "codex", "none": "none"}.get(provider, "claude_code")
71
93
  snapshot = {
72
94
  "selected_chat_provider": provider_runtime.get("selected_chat_provider") or "",
73
95
  "automation_provider": provider,
@@ -76,7 +76,24 @@ def _resolve_core_runner(script_name: str) -> Path:
76
76
 
77
77
  def _resolve_automation_backend() -> str:
78
78
  data = _load_schedule()
79
- return str(data.get("automation_backend", "claude_code") or "claude_code")
79
+ provider_runtime = data.get("provider_runtime") if isinstance(data.get("provider_runtime"), dict) else {}
80
+ selected = str(provider_runtime.get("selected_chat_provider") or "").strip().lower()
81
+ backend_raw = str(provider_runtime.get("automation_backend") or data.get("automation_backend") or "claude_code").strip().lower()
82
+ provider_raw = str(provider_runtime.get("automation_provider") or "").strip().lower()
83
+ automation_enabled = data.get("automation_enabled", True) is not False
84
+ if (
85
+ not automation_enabled
86
+ or backend_raw in {"none", "off", "disabled", "false", "0"}
87
+ or provider_raw in {"none", "off", "disabled", "false", "0"}
88
+ ):
89
+ return "none"
90
+ provider = (
91
+ {"anthropic": "anthropic", "openai": "openai"}.get(selected)
92
+ or {"anthropic": "anthropic", "openai": "openai"}.get(provider_raw)
93
+ or {"claude_code": "anthropic", "codex": "openai"}.get(backend_raw)
94
+ or "anthropic"
95
+ )
96
+ return {"anthropic": "claude_code", "openai": "codex"}.get(provider, "claude_code")
80
97
 
81
98
 
82
99
  def _load_bootstrap_prompt() -> str: