nexo-brain 6.0.2 → 6.0.4

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": "6.0.2",
3
+ "version": "6.0.4",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `6.0.2` is the current packaged-runtime line: adds the reserved caller prefix `personal/*` so scripts living in `~/.nexo/scripts/` can invoke the automation backend with their own caller id without editing `src/resonance_map.py`. New kwarg `tier` (`"maximo"` / `"alto"` / `"medio"` / `"bajo"`) on `run_automation_prompt`, `run_automation_interactive`, `nexo_helper.run_automation_text`, `nexo_helper.run_automation_json`, and `nexo-agent-run.py --tier`. Precedence for `personal/*` callers: explicit `tier=` explicit `reasoning_effort=` `calibration.preferences.default_resonance` `DEFAULT_RESONANCE` (`alto`). Registered callers keep their behaviour unchanged. New guide: [`docs/personal-scripts-guide.md`](docs/personal-scripts-guide.md).
21
+ Version `6.0.4` is the current packaged-runtime line: `nexo chat` and the dashboard's "Open followup in Terminal" action now honour `preferences.default_resonance`. Pre-v6.0.4 both launchers picked `--model` / `--effort` straight from `config/schedule.json`'s `client_runtime_profiles`, so users who switched their Resonance in NEXO Desktop Preferences (Alto → writes `brain/calibration.json`) kept getting the stale tier cached in the legacy profile usually `max`. A new `_resolve_interactive_model_and_effort(caller, backend, ...)` helper consults `resonance_map.resolve_model_and_effort` first and falls back to `client_runtime_profiles` only when the resonance contract is missing. `nexo_followup_terminal` joins `nexo_chat` / `desktop_new_session` / `nexo_update_interactive` in `USER_FACING_CALLERS` so the dashboard launcher resolves against the user's preference.
22
+
23
+ Previously in `6.0.2`: adds the reserved caller prefix `personal/*` so scripts living in `~/.nexo/scripts/` can invoke the automation backend with their own caller id without editing `src/resonance_map.py`. New kwarg `tier` (`"maximo"` / `"alto"` / `"medio"` / `"bajo"`) on `run_automation_prompt`, `run_automation_interactive`, `nexo_helper.run_automation_text`, `nexo_helper.run_automation_json`, and `nexo-agent-run.py --tier`. Precedence for `personal/*` callers: explicit `tier=` → explicit `reasoning_effort=` → `calibration.preferences.default_resonance` → `DEFAULT_RESONANCE` (`alto`). Registered callers keep their behaviour unchanged. New guide: [`docs/personal-scripts-guide.md`](docs/personal-scripts-guide.md).
22
24
 
23
25
  Previously in `6.0.1`: hotfix on top of the 6.0.0 release. `protocol_settings.py` now treats the process as interactive when **either** stdin+stdout are TTYs **or** `NEXO_INTERACTIVE=1` is exported — closes the gap where NEXO Desktop 0.12.0 spawned `claude` through pipes and Brain fell back to `lenient` even with a human in the loop. The `PostToolUse` hook also gains an inbox autodetect stage: when the session has unread `nexo_send` messages and has gone 60s+ without a heartbeat, it emits a `systemMessage` asking the agent to run `nexo_heartbeat` and consume them. Rate-limited to one reminder per minute per SID (new `hook_inbox_reminders` table, migration m42). Added `sessions.last_heartbeat_ts`, stamped by every successful heartbeat. `NEXO_INTERACTIVE` is an internal Brain↔Electron contract — not user-facing, not a resurrection of the removed `NEXO_PROTOCOL_STRICTNESS`.
24
26
 
package/bin/nexo-brain.js CHANGED
@@ -235,7 +235,6 @@ function getCoreRuntimeFlatFiles(srcDir = path.join(__dirname, "..", "src")) {
235
235
  "runtime_power.py",
236
236
  "requirements.txt",
237
237
  "model_defaults.json",
238
- "resonance_tiers.json",
239
238
  ];
240
239
  const discoveredRootModules = fs.existsSync(srcDir)
241
240
  ? fs.readdirSync(srcDir)
@@ -257,6 +256,29 @@ function getCoreRuntimePackages() {
257
256
  return ["db", "cognitive", "doctor"];
258
257
  }
259
258
 
259
+ // Brain contracts — files the NEXO Brain publishes to consumers like
260
+ // NEXO Desktop under ~/.nexo/brain/. These are NOT code (they live outside
261
+ // getCoreRuntimeFlatFiles for that reason) but data contracts with a stable
262
+ // path that external clients read. Keep in sync with docs/contracts/.
263
+ function publishBrainContracts(srcDir = path.join(__dirname, "..", "src"), nexoHome = NEXO_HOME) {
264
+ const brainDir = path.join(nexoHome, "brain");
265
+ fs.mkdirSync(brainDir, { recursive: true });
266
+ const contracts = ["resonance_tiers.json"];
267
+ contracts.forEach((name) => {
268
+ const src = path.join(srcDir, name);
269
+ if (fs.existsSync(src)) {
270
+ fs.copyFileSync(src, path.join(brainDir, name));
271
+ }
272
+ });
273
+ // Clean up legacy location: before v6.0.3 the file was written to
274
+ // NEXO_HOME/resonance_tiers.json. Remove it so only the contract path
275
+ // remains authoritative.
276
+ const legacy = path.join(nexoHome, "resonance_tiers.json");
277
+ if (fs.existsSync(legacy)) {
278
+ try { fs.unlinkSync(legacy); } catch (_) { /* best-effort */ }
279
+ }
280
+ }
281
+
260
282
  function resolveLaunchAgentPath(home) {
261
283
  const parts = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin",
262
284
  path.join(home, ".local/bin"), path.join(home, ".nexo/bin")];
@@ -1654,6 +1676,8 @@ async function main() {
1654
1676
  copyDirRec(pkgSrc, path.join(NEXO_HOME, pkg));
1655
1677
  }
1656
1678
  });
1679
+ // Publish Brain contracts to ~/.nexo/brain/ (read by NEXO Desktop et al.)
1680
+ publishBrainContracts(srcDir, NEXO_HOME);
1657
1681
  log(" Core files updated.");
1658
1682
 
1659
1683
  // Reconcile Python dependencies after updating code (mirrors fresh-install logic)
@@ -2490,6 +2514,9 @@ async function main() {
2490
2514
  }
2491
2515
  });
2492
2516
 
2517
+ // Publish Brain contracts to ~/.nexo/brain/ (read by NEXO Desktop et al.)
2518
+ publishBrainContracts(srcDir, NEXO_HOME);
2519
+
2493
2520
  // Runtime CLI wrapper lives in NEXO_HOME/bin so it survives npx installs.
2494
2521
  const runtimeCli = [
2495
2522
  "#!/usr/bin/env bash",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "6.0.2",
3
+ "version": "6.0.4",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain \u2014 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",
@@ -457,16 +457,75 @@ def _interactive_target_cwd(target: str | os.PathLike[str]) -> str:
457
457
  return str(Path.cwd())
458
458
 
459
459
 
460
+ def _resolve_interactive_model_and_effort(
461
+ caller: str,
462
+ backend: str,
463
+ *,
464
+ preferences: dict | None = None,
465
+ tier: str | None = None,
466
+ ) -> tuple[str, str]:
467
+ """Return ``(model, effort)`` for an interactive launch.
468
+
469
+ v6.0.4 — interactive launchers (nexo chat, dashboard followup) were
470
+ picking the command flags straight from ``client_runtime_profiles``
471
+ (config/schedule.json), ignoring the user's ``default_resonance``
472
+ preference in calibration.json. That meant Preferences → "Alto"
473
+ never reached ``nexo chat`` even though the same preference was
474
+ applied correctly in headless runs (run_automation_prompt) and in
475
+ NEXO Desktop (lib/claude-runtime.js).
476
+
477
+ Resolution order (mirrors run_automation_prompt):
478
+ 1. resonance_map → (model, effort) for the registered caller,
479
+ honouring user_default and the explicit tier override.
480
+ 2. If the resonance_map is unavailable or returns blanks, fall
481
+ back to ``client_runtime_profiles`` so we stay backward
482
+ compatible with pre-6.0.0 installs missing resonance_tiers.json.
483
+ """
484
+ profile = resolve_client_runtime_profile(backend, preferences=preferences)
485
+ model = ""
486
+ effort = ""
487
+ try:
488
+ from resonance_map import resolve_model_and_effort
489
+
490
+ user_default = ""
491
+ if isinstance(preferences, dict):
492
+ user_default = str(preferences.get("default_resonance") or "").strip()
493
+ explicit_tier = (tier or "").strip() or None
494
+ mapped_model, mapped_effort = resolve_model_and_effort(
495
+ caller,
496
+ backend,
497
+ user_default=user_default or None,
498
+ explicit_tier=explicit_tier,
499
+ )
500
+ if mapped_model:
501
+ model = mapped_model
502
+ if mapped_effort:
503
+ effort = mapped_effort
504
+ except Exception:
505
+ # resonance_map missing or caller not registered — fall back to the
506
+ # legacy client_runtime_profiles path so nothing explodes.
507
+ pass
508
+ if not model:
509
+ model = profile.get("model", "")
510
+ if not effort:
511
+ effort = profile.get("reasoning_effort", "")
512
+ return model, effort
513
+
514
+
460
515
  def build_interactive_client_command(
461
516
  *,
462
517
  target: str | os.PathLike[str],
463
518
  client: str | None = None,
464
519
  preferences: dict | None = None,
520
+ caller: str = "nexo_chat",
521
+ tier: str | None = None,
465
522
  ) -> tuple[str, list[str]]:
466
523
  prefs = preferences or load_client_preferences()
467
524
  selected = resolve_terminal_client(client, preferences=prefs)
468
525
  target_path = str(Path(target).expanduser())
469
- profile = resolve_client_runtime_profile(selected, preferences=prefs)
526
+ resolved_model, resolved_effort = _resolve_interactive_model_and_effort(
527
+ caller, selected, preferences=prefs, tier=tier
528
+ )
470
529
  startup_prompt = _interactive_startup_prompt(selected)
471
530
 
472
531
  if selected == CLIENT_CLAUDE_CODE:
@@ -476,10 +535,10 @@ def build_interactive_client_command(
476
535
  "Claude Code launcher not found in PATH. Install `claude` first."
477
536
  )
478
537
  cmd = [claude_bin]
479
- if profile["model"]:
480
- cmd.extend(["--model", profile["model"]])
481
- if profile["reasoning_effort"]:
482
- cmd.extend(["--effort", profile["reasoning_effort"]])
538
+ if resolved_model:
539
+ cmd.extend(["--model", resolved_model])
540
+ if resolved_effort:
541
+ cmd.extend(["--effort", resolved_effort])
483
542
  cmd.append("--dangerously-skip-permissions")
484
543
  if startup_prompt:
485
544
  cmd.append(startup_prompt)
@@ -495,10 +554,10 @@ def build_interactive_client_command(
495
554
  bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
496
555
  if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
497
556
  cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
498
- if profile["model"]:
499
- cmd.extend(["-m", profile["model"]])
500
- if profile["reasoning_effort"]:
501
- cmd.extend(["-c", f'model_reasoning_effort="{profile["reasoning_effort"]}"'])
557
+ if resolved_model:
558
+ cmd.extend(["-m", resolved_model])
559
+ if resolved_effort:
560
+ cmd.extend(["-c", f'model_reasoning_effort="{resolved_effort}"'])
502
561
  cmd.extend(["-C", target_path])
503
562
  if startup_prompt:
504
563
  cmd.append(startup_prompt)
@@ -548,8 +607,14 @@ def run_automation_interactive(
548
607
  the interactive surface honours whatever the user selected.
549
608
  """
550
609
  prefs = preferences or load_client_preferences()
610
+ # v6.0.4 — caller+tier propagate into the builder so interactive launches
611
+ # honour default_resonance (previously ignored for nexo chat).
551
612
  resolved_client, cmd = build_interactive_client_command(
552
- target=target, client=client, preferences=prefs
613
+ target=target,
614
+ client=client,
615
+ preferences=prefs,
616
+ caller=caller,
617
+ tier=(tier or "").strip() or None,
553
618
  )
554
619
  launch_env = os.environ.copy()
555
620
  if env:
@@ -616,10 +681,14 @@ def build_followup_terminal_shell_command(
616
681
  client: str | None = None,
617
682
  preferences: dict | None = None,
618
683
  cwd: str | os.PathLike[str] | None = None,
684
+ caller: str = "nexo_followup_terminal",
685
+ tier: str | None = None,
619
686
  ) -> tuple[str, str]:
620
687
  prefs = preferences or load_client_preferences()
621
688
  selected = resolve_terminal_client(client, preferences=prefs)
622
- profile = resolve_client_runtime_profile(selected, preferences=prefs)
689
+ resolved_model, resolved_effort = _resolve_interactive_model_and_effort(
690
+ caller, selected, preferences=prefs, tier=tier
691
+ )
623
692
  prompt = f"NEXO: execute followup from file $(cat {followup_reference})"
624
693
 
625
694
  if selected == CLIENT_CLAUDE_CODE:
@@ -629,10 +698,10 @@ def build_followup_terminal_shell_command(
629
698
  "Claude Code launcher not found in PATH. Install `claude` first."
630
699
  )
631
700
  cmd = [claude_bin]
632
- if profile["model"]:
633
- cmd.extend(["--model", profile["model"]])
634
- if profile["reasoning_effort"]:
635
- cmd.extend(["--effort", profile["reasoning_effort"]])
701
+ if resolved_model:
702
+ cmd.extend(["--model", resolved_model])
703
+ if resolved_effort:
704
+ cmd.extend(["--effort", resolved_effort])
636
705
  cmd.extend(["--dangerously-skip-permissions", prompt])
637
706
  return selected, shlex.join(cmd)
638
707
 
@@ -647,10 +716,10 @@ def build_followup_terminal_shell_command(
647
716
  bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
648
717
  if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
649
718
  cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
650
- if profile["model"]:
651
- cmd.extend(["-m", profile["model"]])
652
- if profile["reasoning_effort"]:
653
- cmd.extend(["-c", f'model_reasoning_effort="{profile["reasoning_effort"]}"'])
719
+ if resolved_model:
720
+ cmd.extend(["-m", resolved_model])
721
+ if resolved_effort:
722
+ cmd.extend(["-c", f'model_reasoning_effort="{resolved_effort}"'])
654
723
  cmd.extend(["-C", target_cwd, prompt])
655
724
  return selected, shlex.join(cmd)
656
725
 
@@ -973,6 +973,57 @@ def _migrate_effort_to_resonance(dest: Path = NEXO_HOME) -> list[str]:
973
973
  return actions
974
974
 
975
975
 
976
+ def _relocate_resonance_tiers_contract(dest: Path = NEXO_HOME) -> list[str]:
977
+ """Ensure ``resonance_tiers.json`` lives at the public contract path
978
+ ``NEXO_HOME/brain/resonance_tiers.json`` and purge the legacy copy at
979
+ ``NEXO_HOME/resonance_tiers.json``.
980
+
981
+ Context: v6.0.0 defined the public contract (read by NEXO Desktop) as
982
+ ``~/.nexo/brain/resonance_tiers.json`` but the installer kept copying
983
+ the file to ``~/.nexo/resonance_tiers.json`` (legacy flat-file layout),
984
+ so Desktop failed with *"NEXO Brain contract missing"* until the user
985
+ moved the file by hand. v6.0.3 publishes straight to ``brain/`` and
986
+ this migration reconciles existing runtimes.
987
+
988
+ Idempotent: no-op once the contract file is in ``brain/`` and the
989
+ legacy file is gone. Never raises — migration must not block an update.
990
+ """
991
+ actions: list[str] = []
992
+ brain_dir = dest / "brain"
993
+ contract_path = brain_dir / "resonance_tiers.json"
994
+ legacy_path = dest / "resonance_tiers.json"
995
+
996
+ try:
997
+ brain_dir.mkdir(parents=True, exist_ok=True)
998
+ except Exception as exc:
999
+ actions.append(f"resonance-contract-relocate-warning:mkdir:{exc.__class__.__name__}")
1000
+ return actions
1001
+
1002
+ # If the contract already exists in brain/, just drop the legacy copy.
1003
+ if contract_path.is_file():
1004
+ if legacy_path.is_file():
1005
+ try:
1006
+ legacy_path.unlink()
1007
+ actions.append("resonance-contract-relocate:legacy-removed")
1008
+ except Exception as exc:
1009
+ actions.append(f"resonance-contract-relocate-warning:unlink:{exc.__class__.__name__}")
1010
+ return actions
1011
+
1012
+ # Contract missing from brain/ — promote the legacy file if present.
1013
+ if legacy_path.is_file():
1014
+ try:
1015
+ contract_path.write_bytes(legacy_path.read_bytes())
1016
+ legacy_path.unlink()
1017
+ actions.append("resonance-contract-relocate:moved-to-brain")
1018
+ except Exception as exc:
1019
+ actions.append(f"resonance-contract-relocate-warning:move:{exc.__class__.__name__}")
1020
+ # If neither exists, the caller (nexo-brain.js publishBrainContracts)
1021
+ # will write it from the package source on the next install pass; nothing
1022
+ # for this Python migration to do.
1023
+
1024
+ return actions
1025
+
1026
+
976
1027
  def _bootstrap_profile_from_calibration_meta(dest: Path = NEXO_HOME) -> list[str]:
977
1028
  """Create ``brain/profile.json`` from ``calibration.json`` fields when the
978
1029
  profile file does not exist yet.
@@ -2894,6 +2945,17 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
2894
2945
  except Exception as exc:
2895
2946
  actions.append(f"profile-bootstrap-warning:{exc.__class__.__name__}")
2896
2947
 
2948
+ # v6.0.3 — relocate resonance_tiers.json from NEXO_HOME root (pre-v6.0.3
2949
+ # layout) to NEXO_HOME/brain/ (public contract path consumed by
2950
+ # NEXO Desktop). Idempotent; safe no-op once the move is done.
2951
+ try:
2952
+ _emit_progress(progress_fn, "Relocating resonance_tiers contract to brain/...")
2953
+ reloc_actions = _relocate_resonance_tiers_contract(dest)
2954
+ for action in reloc_actions:
2955
+ actions.append(action)
2956
+ except Exception as exc:
2957
+ actions.append(f"resonance-contract-relocate-warning:{exc.__class__.__name__}")
2958
+
2897
2959
  # v6.0.0 purge — drop legacy fields that moved elsewhere in v6.
2898
2960
  # client_runtime_profiles.*.{model,reasoning_effort} → resonance_tiers.json.
2899
2961
  # preferences.protocol_strictness → TTY/no-TTY detection.
@@ -11,6 +11,55 @@ from pathlib import Path
11
11
  from db import get_db, find_similar_learnings, extract_keywords, search_learnings, search_changes
12
12
 
13
13
 
14
+ def _resolve_active_sid(conn) -> str:
15
+ """Resolve the SID of the caller invoking this guard check.
16
+
17
+ Order of precedence:
18
+ 1. ``NEXO_SID`` env var (headless crons + wrapped CLI set this).
19
+ 2. ``CLAUDE_SESSION_ID`` env var translated via
20
+ ``sessions.external_session_id`` / ``sessions.claude_session_id``.
21
+ 3. The most-recently-updated session in ``sessions`` (fallback for
22
+ MCP tool calls where the broker doesn't forward env vars but a
23
+ single active session exists).
24
+
25
+ Returns an empty string if nothing resolves. The caller MUST handle
26
+ the empty-string case explicitly — persisting ``session_id=""`` was
27
+ the v6.0.2 bug that left ``missing_file_guard`` blind to successful
28
+ guard checks.
29
+ """
30
+ env_sid = (os.environ.get("NEXO_SID") or "").strip()
31
+ if env_sid.startswith("nexo-"):
32
+ return env_sid
33
+
34
+ external_candidates = [env_sid] if env_sid else []
35
+ claude_sid = (os.environ.get("CLAUDE_SESSION_ID") or "").strip()
36
+ if claude_sid:
37
+ external_candidates.append(claude_sid)
38
+ for cand in external_candidates:
39
+ try:
40
+ row = conn.execute(
41
+ "SELECT sid FROM sessions "
42
+ "WHERE external_session_id = ? OR claude_session_id = ? "
43
+ "ORDER BY last_update_epoch DESC LIMIT 1",
44
+ (cand, cand),
45
+ ).fetchone()
46
+ except Exception:
47
+ row = None
48
+ if row and row["sid"]:
49
+ return str(row["sid"])
50
+
51
+ try:
52
+ row = conn.execute(
53
+ "SELECT sid FROM sessions "
54
+ "ORDER BY last_update_epoch DESC LIMIT 1"
55
+ ).fetchone()
56
+ except Exception:
57
+ row = None
58
+ if row and row["sid"]:
59
+ return str(row["sid"])
60
+ return ""
61
+
62
+
14
63
  def _split_applies_to(applies_to: str) -> list[str]:
15
64
  return [item.strip() for item in str(applies_to or "").split(",") if item.strip()]
16
65
 
@@ -370,11 +419,15 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
370
419
  (time.time(), lid)
371
420
  )
372
421
 
373
- # Log the guard check
422
+ # Log the guard check — v6.0.3 fix: resolve the caller's SID instead of
423
+ # inserting the empty string. Pre-v6.0.3 every row landed with
424
+ # session_id='' so missing_file_guard could not find the guard call
425
+ # from hook_guardrails._session_has_guard_check and blocked forever.
426
+ active_sid = _resolve_active_sid(conn)
374
427
  conn.execute(
375
428
  "INSERT INTO guard_checks (session_id, files, area, learnings_returned, blocking_rules_returned) "
376
429
  "VALUES (?, ?, ?, ?, ?)",
377
- ("", files, area, len(result["learnings"]) + len(result["universal_rules"]),
430
+ (active_sid, files, area, len(result["learnings"]) + len(result["universal_rules"]),
378
431
  len(result["blocking_rules"]))
379
432
  )
380
433
  conn.commit()
@@ -46,6 +46,7 @@ future scripts from silently inheriting the wrong tier.
46
46
  from __future__ import annotations
47
47
 
48
48
  import json
49
+ import os
49
50
  from pathlib import Path
50
51
  from typing import Tuple
51
52
 
@@ -65,7 +66,27 @@ from typing import Tuple
65
66
 
66
67
  TIERS = ("maximo", "alto", "medio", "bajo")
67
68
 
68
- _RESONANCE_JSON_PATH = Path(__file__).resolve().parent / "resonance_tiers.json"
69
+ # Resolution order for the contract file:
70
+ # 1) ~/.nexo/brain/resonance_tiers.json (v6.0.3+ public contract path, shared
71
+ # with NEXO Desktop and any external client)
72
+ # 2) Legacy NEXO_HOME/resonance_tiers.json (pre-v6.0.3 runtime layout)
73
+ # 3) Package source src/resonance_tiers.json (dev checkouts / tests)
74
+ def _resolve_resonance_path() -> Path:
75
+ nexo_home = os.environ.get("NEXO_HOME") or str(Path.home() / ".nexo")
76
+ candidates = [
77
+ Path(nexo_home) / "brain" / "resonance_tiers.json",
78
+ Path(nexo_home) / "resonance_tiers.json",
79
+ Path(__file__).resolve().parent / "resonance_tiers.json",
80
+ ]
81
+ for candidate in candidates:
82
+ if candidate.is_file():
83
+ return candidate
84
+ # No contract present: return the canonical path so the ValueError raised
85
+ # at load time points at where the contract should live.
86
+ return candidates[0]
87
+
88
+
89
+ _RESONANCE_JSON_PATH = _resolve_resonance_path()
69
90
 
70
91
 
71
92
  def _normalize_tier_entry(entry: dict) -> dict[str, tuple[str, str]]:
@@ -152,6 +173,10 @@ USER_FACING_CALLERS: dict[str, str] = {
152
173
  "nexo_chat": USE_USER_DEFAULT_SENTINEL,
153
174
  "desktop_new_session": USE_USER_DEFAULT_SENTINEL,
154
175
  "nexo_update_interactive": USE_USER_DEFAULT_SENTINEL,
176
+ # v6.0.4 — dashboard "Open followup in Terminal" spawns a fresh
177
+ # interactive Claude/Codex session. Treat it like nexo_chat so the
178
+ # user's default_resonance preference flows through.
179
+ "nexo_followup_terminal": USE_USER_DEFAULT_SENTINEL,
155
180
  }
156
181
 
157
182
  # System-owned callers. Grouped thematically for readability.