nexo-brain 5.3.22 → 5.3.24

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": "5.3.22",
3
+ "version": "5.3.24",
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/bin/nexo-brain.js CHANGED
@@ -33,10 +33,32 @@ const LAUNCH_AGENTS = path.join(
33
33
  );
34
34
  const MACOS_FDA_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
35
35
  const PUBLIC_CONTRIBUTION_UPSTREAM = "wazionapps/nexo";
36
- const DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]";
37
- const DEFAULT_CLAUDE_CODE_REASONING_EFFORT = "";
38
- const DEFAULT_CODEX_MODEL = "gpt-5.4";
39
- const DEFAULT_CODEX_REASONING_EFFORT = "xhigh";
36
+ // Model defaults loaded from src/model_defaults.json — single source of truth
37
+ // shared with the Python runtime (src/model_defaults.py). Edit the JSON to
38
+ // change the recommended default for new installs and to offer an upgrade
39
+ // prompt to existing users via `nexo update`.
40
+ const MODEL_DEFAULTS_PATH = path.join(__dirname, "..", "src", "model_defaults.json");
41
+ function _loadModelDefaults() {
42
+ const fallback = {
43
+ claude_code: { model: "claude-opus-4-6[1m]", reasoning_effort: "", display_name: "Opus 4.6 with 1M context" },
44
+ codex: { model: "gpt-5.4", reasoning_effort: "xhigh", display_name: "GPT-5.4 with max reasoning" },
45
+ };
46
+ try {
47
+ const raw = JSON.parse(fs.readFileSync(MODEL_DEFAULTS_PATH, "utf8"));
48
+ if (raw && typeof raw === "object") {
49
+ return {
50
+ claude_code: { ...fallback.claude_code, ...(raw.claude_code || {}) },
51
+ codex: { ...fallback.codex, ...(raw.codex || {}) },
52
+ };
53
+ }
54
+ } catch (_) {}
55
+ return fallback;
56
+ }
57
+ const _MODEL_DEFAULTS = _loadModelDefaults();
58
+ const DEFAULT_CLAUDE_CODE_MODEL = _MODEL_DEFAULTS.claude_code.model;
59
+ const DEFAULT_CLAUDE_CODE_REASONING_EFFORT = _MODEL_DEFAULTS.claude_code.reasoning_effort || "";
60
+ const DEFAULT_CODEX_MODEL = _MODEL_DEFAULTS.codex.model;
61
+ const DEFAULT_CODEX_REASONING_EFFORT = _MODEL_DEFAULTS.codex.reasoning_effort || "";
40
62
 
41
63
  function isEphemeralInstall(nexoHome) {
42
64
  const homeDir = require("os").homedir();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.3.22",
3
+ "version": "5.3.24",
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",
@@ -2068,9 +2068,19 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
2068
2068
  try:
2069
2069
  from client_sync import sync_all_clients
2070
2070
  from client_preferences import normalize_client_preferences
2071
+ from model_defaults import heal_runtime_profiles
2071
2072
 
2072
2073
  schedule_path = dest / "config" / "schedule.json"
2073
2074
  schedule_payload = json.loads(schedule_path.read_text()) if schedule_path.exists() else {}
2075
+ # Heal Claude-family models written into Codex runtime profile by
2076
+ # earlier buggy versions (DEFAULT_CODEX_MODEL was aliased to Claude).
2077
+ existing_profiles = schedule_payload.get("client_runtime_profiles") or {}
2078
+ healed_profiles, heal_messages = heal_runtime_profiles(existing_profiles)
2079
+ if heal_messages:
2080
+ schedule_payload["client_runtime_profiles"] = healed_profiles
2081
+ for msg in heal_messages:
2082
+ _emit_progress(progress_fn, msg)
2083
+ actions.append("model-heal")
2074
2084
  normalized_preferences = normalize_client_preferences(schedule_payload)
2075
2085
  if normalized_preferences != {
2076
2086
  key: schedule_payload.get(key)
package/src/cli.py CHANGED
@@ -915,11 +915,109 @@ def _update(args):
915
915
  print(f" Contributor mode: {public_contribution['format_public_contribution_label'](contrib_choice)}")
916
916
  if contrib_choice.get("message"):
917
917
  print(f" Contributor mode: {contrib_choice.get('message')}")
918
+ # One-time model-recommendation upgrade prompt (interactive only).
919
+ try:
920
+ _prompt_model_recommendations(interactive=interactive)
921
+ except Exception as exc:
922
+ print(f" Model recommendation check skipped: {exc}", file=sys.stderr)
918
923
  else:
919
924
  print(f"UPDATE FAILED: {result.get('error', 'sync failed')}", file=sys.stderr)
920
925
  return 0 if result.get("ok") else 1
921
926
 
922
927
 
928
+ def _prompt_model_recommendations(*, interactive: bool) -> None:
929
+ """If model_defaults.json has bumped recommendation_version beyond what
930
+ the user has acknowledged, offer a one-time upgrade prompt. In
931
+ non-interactive (cron/headless) contexts, only log a hint. Honours
932
+ customized models by skipping silently (see was_nexo_default)."""
933
+ from client_preferences import (
934
+ load_client_preferences,
935
+ save_client_preferences,
936
+ normalize_client_key,
937
+ )
938
+ from model_defaults import detect_outdated_recommendations, client_default
939
+
940
+ preferences = load_client_preferences()
941
+ result = detect_outdated_recommendations(preferences)
942
+ pending = result.get("pending") or []
943
+ auto_ack = result.get("auto_ack") or {}
944
+
945
+ # Apply silent acknowledgements first (user already on current model, or
946
+ # has customized their model — either way no prompt needed). This avoids
947
+ # repeated stderr hints in cron/headless updates.
948
+ if auto_ack:
949
+ try:
950
+ existing_ack = dict(preferences.get("acknowledged_model_recommendations") or {})
951
+ existing_ack.update({k: int(v) for k, v in auto_ack.items()})
952
+ save_client_preferences(acknowledged_model_recommendations=existing_ack)
953
+ except Exception:
954
+ pass
955
+
956
+ if not pending:
957
+ return
958
+
959
+ is_tty = bool(interactive and sys.stdin.isatty() and sys.stdout.isatty())
960
+ if not is_tty:
961
+ for entry in pending:
962
+ print(
963
+ f" ⭐ Model recommendation available for "
964
+ f"{entry['client']}: {entry['display_name']}. "
965
+ f"Run `nexo update` interactively to review and apply.",
966
+ file=sys.stderr,
967
+ )
968
+ return
969
+
970
+ updated_profiles = dict(preferences.get("client_runtime_profiles") or {})
971
+ updated_ack = dict(preferences.get("acknowledged_model_recommendations") or {})
972
+ updated_ack.update({k: int(v) for k, v in auto_ack.items()})
973
+ changed = bool(auto_ack)
974
+ for entry in pending:
975
+ client = entry["client"]
976
+ effort_str = f" / {entry['current_effort']}" if entry["current_effort"] else ""
977
+ prev_effort = f" / {entry['user_effort']}" if entry["user_effort"] else ""
978
+ print()
979
+ print(f"[NEXO] ⭐ Nueva recomendación de modelo para {client}:")
980
+ print(
981
+ f" {entry['current_model']}{effort_str} "
982
+ f"(antes: {entry['user_model']}{prev_effort})"
983
+ )
984
+ print(f" {entry['display_name']} — recomendado por NEXO.")
985
+ answer = input(" ¿Migrar tu configuración? [y/N/later]: ").strip().lower()
986
+ client_key = normalize_client_key(client) or client
987
+ if answer in {"y", "yes", "s", "si", "sí"}:
988
+ updated_profiles[client_key] = {
989
+ "model": entry["current_model"],
990
+ "reasoning_effort": entry["current_effort"],
991
+ }
992
+ updated_ack[client_key] = entry["current_version"]
993
+ print(f" ✅ Migrado a {entry['current_model']}.")
994
+ changed = True
995
+ elif answer in {"later", "l", "luego"}:
996
+ # Do NOT acknowledge — will prompt again next interactive update.
997
+ print(" ↻ Te lo preguntaré en el próximo update.")
998
+ else:
999
+ # "N" / empty / anything else → record ack so we don't re-ask.
1000
+ updated_ack[client_key] = entry["current_version"]
1001
+ print(f" Mantenido {entry['user_model']}. No te preguntaré de nuevo para esta versión.")
1002
+ changed = True
1003
+
1004
+ if changed:
1005
+ save_client_preferences(
1006
+ client_runtime_profiles=updated_profiles,
1007
+ acknowledged_model_recommendations=updated_ack,
1008
+ )
1009
+ # Re-sync clients so config.toml / settings.json reflect the new model.
1010
+ try:
1011
+ from client_sync import sync_all_clients
1012
+ sync_all_clients(
1013
+ nexo_home=NEXO_HOME,
1014
+ runtime_root=NEXO_CODE,
1015
+ preferences=load_client_preferences(),
1016
+ )
1017
+ except Exception:
1018
+ pass
1019
+
1020
+
923
1021
  def _clients_sync(args):
924
1022
  from client_sync import format_sync_summary, sync_all_clients
925
1023
 
@@ -46,13 +46,18 @@ INSTALL_PREFERENCE_KEYS = {
46
46
  "skip",
47
47
  "manual",
48
48
  }
49
- DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
50
- DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
51
- # Codex/fast defaults: fall back to the user's configured model, not a hardcoded
52
- # third-party model. The user picks their model once at install time; every
53
- # profile and backend should honour that choice.
54
- DEFAULT_CODEX_MODEL = DEFAULT_CLAUDE_CODE_MODEL
55
- DEFAULT_CODEX_REASONING_EFFORT = ""
49
+ # Model defaults are loaded from src/model_defaults.json (single source of
50
+ # truth shared with bin/nexo-brain.js). Do not hardcode model values here —
51
+ # edit the JSON instead.
52
+ from model_defaults import client_default as _model_client_default
53
+
54
+ _CLAUDE_DEFAULTS = _model_client_default("claude_code")
55
+ _CODEX_DEFAULTS = _model_client_default("codex")
56
+
57
+ DEFAULT_CLAUDE_CODE_MODEL = _CLAUDE_DEFAULTS["model"]
58
+ DEFAULT_CLAUDE_CODE_REASONING_EFFORT = _CLAUDE_DEFAULTS["reasoning_effort"]
59
+ DEFAULT_CODEX_MODEL = _CODEX_DEFAULTS["model"]
60
+ DEFAULT_CODEX_REASONING_EFFORT = _CODEX_DEFAULTS["reasoning_effort"]
56
61
  DEFAULT_FAST_MODEL = DEFAULT_CLAUDE_CODE_MODEL
57
62
  DEFAULT_FAST_REASONING_EFFORT = ""
58
63
 
@@ -100,6 +105,14 @@ def default_client_preferences() -> dict:
100
105
  CLIENT_CODEX: "ask",
101
106
  CLIENT_CLAUDE_DESKTOP: "manual",
102
107
  },
108
+ # Tracks which model-recommendation version the user has already
109
+ # acknowledged per client. If an update ships a newer
110
+ # recommendation_version in src/model_defaults.json, the update flow
111
+ # offers a one-time upgrade prompt (see auto_update.py).
112
+ "acknowledged_model_recommendations": {
113
+ CLIENT_CLAUDE_CODE: 0,
114
+ CLIENT_CODEX: 0,
115
+ },
103
116
  }
104
117
 
105
118
 
@@ -384,9 +397,30 @@ def normalize_client_preferences(
384
397
  schedule.get("automation_task_profiles")
385
398
  ),
386
399
  "client_install_preferences": install_preferences,
400
+ "acknowledged_model_recommendations": normalize_acknowledged_model_recommendations(
401
+ schedule.get("acknowledged_model_recommendations")
402
+ ),
387
403
  }
388
404
 
389
405
 
406
+ def normalize_acknowledged_model_recommendations(value) -> dict[str, int]:
407
+ """Normalize the per-client acknowledged recommendation_version map.
408
+ Missing clients default to 0 (never acknowledged)."""
409
+ defaults = {CLIENT_CLAUDE_CODE: 0, CLIENT_CODEX: 0}
410
+ if not isinstance(value, dict):
411
+ return defaults
412
+ result = dict(defaults)
413
+ for raw_key, raw_val in value.items():
414
+ client_key = normalize_client_key(raw_key)
415
+ if client_key not in TERMINAL_CLIENT_KEYS:
416
+ continue
417
+ try:
418
+ result[client_key] = max(0, int(raw_val))
419
+ except (TypeError, ValueError):
420
+ continue
421
+ return result
422
+
423
+
390
424
  def apply_client_preferences(
391
425
  schedule: dict | None = None,
392
426
  *,
@@ -398,6 +432,7 @@ def apply_client_preferences(
398
432
  client_runtime_profiles: dict | None = None,
399
433
  automation_task_profiles: dict | None = None,
400
434
  client_install_preferences: dict | None = None,
435
+ acknowledged_model_recommendations: dict | None = None,
401
436
  ) -> dict:
402
437
  merged = dict(schedule or {})
403
438
  current = normalize_client_preferences(schedule)
@@ -434,6 +469,13 @@ def apply_client_preferences(
434
469
  if client_install_preferences is not None
435
470
  else current["client_install_preferences"]
436
471
  )
472
+ merged["acknowledged_model_recommendations"] = (
473
+ normalize_acknowledged_model_recommendations(
474
+ acknowledged_model_recommendations
475
+ if acknowledged_model_recommendations is not None
476
+ else current.get("acknowledged_model_recommendations")
477
+ )
478
+ )
437
479
  return merged
438
480
 
439
481
 
@@ -451,6 +493,7 @@ def save_client_preferences(
451
493
  client_runtime_profiles: dict | None = None,
452
494
  automation_task_profiles: dict | None = None,
453
495
  client_install_preferences: dict | None = None,
496
+ acknowledged_model_recommendations: dict | None = None,
454
497
  ) -> Path:
455
498
  schedule = apply_client_preferences(
456
499
  load_schedule_config(),
@@ -462,6 +505,7 @@ def save_client_preferences(
462
505
  client_runtime_profiles=client_runtime_profiles,
463
506
  automation_task_profiles=automation_task_profiles,
464
507
  client_install_preferences=client_install_preferences,
508
+ acknowledged_model_recommendations=acknowledged_model_recommendations,
465
509
  )
466
510
  return save_schedule_config(schedule)
467
511
 
@@ -263,8 +263,25 @@ def _sync_codex_managed_config(
263
263
  runtime_profile = dict(runtime_profile or {})
264
264
  server_config = dict(server_config or {})
265
265
 
266
- if runtime_profile.get("model"):
267
- payload["model"] = runtime_profile["model"]
266
+ def _looks_like_claude_model(value: str) -> bool:
267
+ return value.strip().lower().startswith(
268
+ ("claude", "opus", "sonnet", "haiku")
269
+ )
270
+
271
+ # Heal any pre-existing Claude model written by earlier NEXO versions.
272
+ existing_model = str(payload.get("model") or "").strip()
273
+ if existing_model and _looks_like_claude_model(existing_model):
274
+ payload["model"] = "gpt-5.4"
275
+
276
+ # Only write a model from the runtime profile into Codex config if it
277
+ # looks like a Codex/OpenAI model. Claude models are invalid for Codex
278
+ # and cause "model not supported" errors on first run.
279
+ profile_model = (runtime_profile.get("model") or "").strip()
280
+ if profile_model and not _looks_like_claude_model(profile_model):
281
+ payload["model"] = profile_model
282
+ elif profile_model:
283
+ # Fall back to a known-good Codex default to self-heal.
284
+ payload["model"] = "gpt-5.4"
268
285
  if "reasoning_effort" in runtime_profile:
269
286
  payload["model_reasoning_effort"] = runtime_profile.get("reasoning_effort") or ""
270
287
 
@@ -281,7 +298,8 @@ def _sync_codex_managed_config(
281
298
  codex_table["mcp_managed"] = True
282
299
  codex_table["bootstrap_bytes"] = len(bootstrap_prompt.encode("utf-8")) if bootstrap_prompt else 0
283
300
  if runtime_profile.get("model"):
284
- codex_table["managed_model"] = runtime_profile["model"]
301
+ # Record the healed/actual model in use, not the raw (possibly Claude) profile.
302
+ codex_table["managed_model"] = payload.get("model") or runtime_profile["model"]
285
303
  codex_table["managed_reasoning_effort"] = runtime_profile.get("reasoning_effort", "") or ""
286
304
  if server_config:
287
305
  mcp_servers = payload.setdefault("mcp_servers", {})
@@ -0,0 +1,17 @@
1
+ {
2
+ "schema_version": 1,
3
+ "claude_code": {
4
+ "model": "claude-opus-4-6[1m]",
5
+ "reasoning_effort": "",
6
+ "display_name": "Opus 4.6 with 1M context",
7
+ "recommendation_version": 1,
8
+ "previous_defaults": []
9
+ },
10
+ "codex": {
11
+ "model": "gpt-5.4",
12
+ "reasoning_effort": "xhigh",
13
+ "display_name": "GPT-5.4 with max reasoning",
14
+ "recommendation_version": 1,
15
+ "previous_defaults": []
16
+ }
17
+ }
@@ -0,0 +1,194 @@
1
+ """Single source of truth for default model recommendations.
2
+
3
+ Values are loaded from `src/model_defaults.json` at runtime. The same JSON is
4
+ read by `bin/nexo-brain.js` during install/onboarding so that Python and JS
5
+ stay in sync automatically. Do not hardcode model defaults elsewhere — import
6
+ from this module (or read the JSON) so editing one file updates both runtimes.
7
+
8
+ When a new model is recommended, bump the client's `recommendation_version`
9
+ and append the previous default to `previous_defaults`. Existing users whose
10
+ model is in `[model] + previous_defaults` will be offered a one-time upgrade
11
+ prompt on their next interactive `nexo update`.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ _FALLBACK: dict[str, Any] = {
21
+ "schema_version": 1,
22
+ "claude_code": {
23
+ "model": "claude-opus-4-6[1m]",
24
+ "reasoning_effort": "",
25
+ "display_name": "Opus 4.6 with 1M context",
26
+ "recommendation_version": 1,
27
+ "previous_defaults": [],
28
+ },
29
+ "codex": {
30
+ "model": "gpt-5.4",
31
+ "reasoning_effort": "xhigh",
32
+ "display_name": "GPT-5.4 with max reasoning",
33
+ "recommendation_version": 1,
34
+ "previous_defaults": [],
35
+ },
36
+ }
37
+
38
+
39
+ def _json_path() -> Path:
40
+ return Path(__file__).resolve().parent / "model_defaults.json"
41
+
42
+
43
+ def _load_raw() -> dict[str, Any]:
44
+ try:
45
+ text = _json_path().read_text()
46
+ data = json.loads(text)
47
+ if isinstance(data, dict):
48
+ return data
49
+ except Exception:
50
+ pass
51
+ return _FALLBACK
52
+
53
+
54
+ def load_defaults() -> dict[str, Any]:
55
+ """Return the raw defaults mapping, preferring the JSON and falling back
56
+ to hardcoded values if the file is missing or malformed."""
57
+ return _load_raw()
58
+
59
+
60
+ def client_default(client: str) -> dict[str, Any]:
61
+ """Return normalized default config for a single client."""
62
+ raw = _load_raw()
63
+ fallback = _FALLBACK.get(client) or {}
64
+ entry = raw.get(client) if isinstance(raw.get(client), dict) else fallback
65
+ return {
66
+ "model": str(entry.get("model") or fallback.get("model") or ""),
67
+ "reasoning_effort": str(
68
+ entry.get("reasoning_effort")
69
+ if entry.get("reasoning_effort") is not None
70
+ else fallback.get("reasoning_effort") or ""
71
+ ),
72
+ "display_name": str(entry.get("display_name") or fallback.get("display_name") or ""),
73
+ "recommendation_version": int(entry.get("recommendation_version") or 0),
74
+ "previous_defaults": [
75
+ str(item) for item in (entry.get("previous_defaults") or []) if item
76
+ ],
77
+ }
78
+
79
+
80
+ def was_nexo_default(client: str, model: str) -> bool:
81
+ """True if `model` is (or ever was) a NEXO recommended default for
82
+ `client`. Used to distinguish a user who was riding our defaults (and
83
+ should be offered upgrades) from one who customized (respect their choice)."""
84
+ if not model:
85
+ return False
86
+ cfg = client_default(client)
87
+ if model == cfg["model"]:
88
+ return True
89
+ return model in cfg["previous_defaults"]
90
+
91
+
92
+ _CLAUDE_MODEL_PREFIXES = ("claude", "opus", "sonnet", "haiku")
93
+
94
+
95
+ def looks_like_claude_model(model: str) -> bool:
96
+ """Heuristic to detect a Claude-family model written where a Codex model
97
+ is expected. Used by the heal path for users hit by the historical
98
+ DEFAULT_CODEX_MODEL = DEFAULT_CLAUDE_CODE_MODEL alias bug."""
99
+ return str(model or "").strip().lower().startswith(_CLAUDE_MODEL_PREFIXES)
100
+
101
+
102
+ def heal_runtime_profiles(profiles: dict) -> tuple[dict, list[str]]:
103
+ """Detect and repair invalid models in client_runtime_profiles. Returns
104
+ (healed_profiles_dict, list_of_heal_messages). A Claude-family model in
105
+ the codex profile is the main historical corruption; we reset such a
106
+ profile to the current Codex default."""
107
+ if not isinstance(profiles, dict):
108
+ return profiles, []
109
+ healed = dict(profiles)
110
+ messages: list[str] = []
111
+ codex_profile = healed.get("codex") if isinstance(healed.get("codex"), dict) else None
112
+ if codex_profile is not None:
113
+ current = str(codex_profile.get("model") or "").strip()
114
+ if current and looks_like_claude_model(current):
115
+ default = client_default("codex")
116
+ healed["codex"] = {
117
+ "model": default["model"],
118
+ "reasoning_effort": default["reasoning_effort"],
119
+ }
120
+ messages.append(
121
+ f"Healed Codex profile: model '{current}' → '{default['model']}' "
122
+ f"(Claude models are invalid for Codex)."
123
+ )
124
+ return healed, messages
125
+
126
+
127
+ def detect_outdated_recommendations(
128
+ preferences: dict,
129
+ ) -> dict:
130
+ """Classify clients into "needs interactive prompt" vs "silent ack".
131
+
132
+ Returns a dict with two keys:
133
+ - ``pending``: list of entries that require an interactive prompt
134
+ because the user is still on an older NEXO-recommended model and a
135
+ newer recommendation is available.
136
+ - ``auto_ack``: mapping of ``{client: recommendation_version}`` that
137
+ should be acknowledged silently without prompting (because either
138
+ the user already matches the current recommended model, or they
139
+ have customized their model and we must respect that silently).
140
+
141
+ Saving the ``auto_ack`` entries prevents repeated stderr spam in
142
+ non-interactive (cron/headless) update runs.
143
+
144
+ ``pending`` entry shape:
145
+ {
146
+ "client": "codex",
147
+ "current_model": "gpt-5.9",
148
+ "current_effort": "high",
149
+ "current_version": 2,
150
+ "user_model": "gpt-5.4",
151
+ "user_effort": "xhigh",
152
+ "display_name": "GPT-5.9 with high reasoning",
153
+ }
154
+ """
155
+ pending: list[dict] = []
156
+ auto_ack: dict[str, int] = {}
157
+ profiles = preferences.get("client_runtime_profiles") or {}
158
+ acknowledged = preferences.get("acknowledged_model_recommendations") or {}
159
+ for client in ("claude_code", "codex"):
160
+ profile = profiles.get(client) if isinstance(profiles.get(client), dict) else None
161
+ if not profile:
162
+ continue
163
+ user_model = str(profile.get("model") or "").strip()
164
+ user_effort = str(profile.get("reasoning_effort") or "").strip()
165
+ default = client_default(client)
166
+ current_v = int(default.get("recommendation_version") or 0)
167
+ ack_v = int(acknowledged.get(client) or 0)
168
+ if current_v <= ack_v:
169
+ continue
170
+ # User customized their model entirely (not a previously recommended
171
+ # NEXO default) — respect their choice and record ack silently so we
172
+ # don't log a hint every `nexo update`.
173
+ if not was_nexo_default(client, user_model):
174
+ auto_ack[client] = current_v
175
+ continue
176
+ # User is already on the current recommended model. Even if their
177
+ # reasoning_effort differs (e.g. they picked "max" at onboarding and
178
+ # the JSON stores ""), nothing to migrate — their effort is a
179
+ # personal choice layered on top of the recommended model.
180
+ if user_model == default["model"]:
181
+ auto_ack[client] = current_v
182
+ continue
183
+ # User is on a prior NEXO default and the current recommendation is
184
+ # a different model → offer interactive upgrade.
185
+ pending.append({
186
+ "client": client,
187
+ "current_model": default["model"],
188
+ "current_effort": default["reasoning_effort"],
189
+ "current_version": current_v,
190
+ "user_model": user_model,
191
+ "user_effort": user_effort,
192
+ "display_name": default.get("display_name") or default["model"],
193
+ })
194
+ return {"pending": pending, "auto_ack": auto_ack}
@@ -442,9 +442,17 @@ def _restore_code_tree(backup_dir: str) -> str | None:
442
442
 
443
443
  def _normalize_preferences_for_client_sync() -> dict:
444
444
  from client_preferences import normalize_client_preferences
445
+ from model_defaults import heal_runtime_profiles
445
446
 
446
447
  schedule_path = NEXO_HOME / "config" / "schedule.json"
447
448
  schedule_payload = json.loads(schedule_path.read_text()) if schedule_path.exists() else {}
449
+ # Heal invalid models (e.g. Claude-family written into codex profile by
450
+ # earlier buggy versions). Must run BEFORE normalize so the healed values
451
+ # propagate into preferences and downstream client config files.
452
+ existing_profiles = schedule_payload.get("client_runtime_profiles") or {}
453
+ healed_profiles, _heal_messages = heal_runtime_profiles(existing_profiles)
454
+ if _heal_messages:
455
+ schedule_payload["client_runtime_profiles"] = healed_profiles
448
456
  normalized_preferences = normalize_client_preferences(schedule_payload)
449
457
  if normalized_preferences != {
450
458
  key: schedule_payload.get(key)
@@ -902,9 +910,20 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
902
910
  _emit_progress(progress_fn, "Refreshing shared client configs...")
903
911
  from client_sync import sync_all_clients
904
912
  from client_preferences import normalize_client_preferences
913
+ from model_defaults import heal_runtime_profiles
905
914
 
906
915
  schedule_path = NEXO_HOME / "config" / "schedule.json"
907
916
  schedule_payload = json.loads(schedule_path.read_text()) if schedule_path.exists() else {}
917
+ # Heal Claude-family models written into Codex profile by earlier
918
+ # buggy versions. Must run BEFORE normalize so healed values
919
+ # propagate into the saved preferences.
920
+ existing_profiles = schedule_payload.get("client_runtime_profiles") or {}
921
+ healed_profiles, heal_messages = heal_runtime_profiles(existing_profiles)
922
+ if heal_messages:
923
+ schedule_payload["client_runtime_profiles"] = healed_profiles
924
+ for msg in heal_messages:
925
+ _emit_progress(progress_fn, msg)
926
+ steps_done.append("model-heal")
908
927
  normalized_preferences = normalize_client_preferences(schedule_payload)
909
928
  if normalized_preferences != {
910
929
  key: schedule_payload.get(key)
@@ -65,11 +65,16 @@ MACOS_FDA_PROBE_PATHS = (
65
65
  Path.home() / "Library" / "Safari",
66
66
  Path.home() / "Library" / "Application Support" / "AddressBook",
67
67
  )
68
- DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
69
- DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
70
- # Codex defaults mirror the user's primary model — no hardcoded third-party models.
71
- DEFAULT_CODEX_MODEL = DEFAULT_CLAUDE_CODE_MODEL
72
- DEFAULT_CODEX_REASONING_EFFORT = ""
68
+ # Model defaults loaded from src/model_defaults.json (single source of truth).
69
+ from model_defaults import client_default as _model_client_default
70
+
71
+ _CLAUDE_DEFAULTS = _model_client_default("claude_code")
72
+ _CODEX_DEFAULTS = _model_client_default("codex")
73
+
74
+ DEFAULT_CLAUDE_CODE_MODEL = _CLAUDE_DEFAULTS["model"]
75
+ DEFAULT_CLAUDE_CODE_REASONING_EFFORT = _CLAUDE_DEFAULTS["reasoning_effort"]
76
+ DEFAULT_CODEX_MODEL = _CODEX_DEFAULTS["model"]
77
+ DEFAULT_CODEX_REASONING_EFFORT = _CODEX_DEFAULTS["reasoning_effort"]
73
78
 
74
79
 
75
80
  def resolve_launchagent_path() -> str: