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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/nexo-brain.js +26 -4
- package/package.json +1 -1
- package/src/auto_update.py +10 -0
- package/src/cli.py +98 -0
- package/src/client_preferences.py +51 -7
- package/src/client_sync.py +21 -3
- package/src/model_defaults.json +17 -0
- package/src/model_defaults.py +194 -0
- package/src/plugins/update.py +19 -0
- package/src/runtime_power.py +10 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.3.
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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.
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
package/src/client_sync.py
CHANGED
|
@@ -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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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}
|
package/src/plugins/update.py
CHANGED
|
@@ -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)
|
package/src/runtime_power.py
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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:
|