nexo-brain 6.0.3 → 6.0.5

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.3",
3
+ "version": "6.0.5",
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,7 @@
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.3` is the current packaged-runtime line: double-fix release. (1) `resonance_tiers.json` is now published to `~/.nexo/brain/resonance_tiers.json` the public contract path defined by v6.0.0 and consumed by NEXO Desktop 0.12.0. Pre-v6.0.3 the installer wrote it to `~/.nexo/resonance_tiers.json`, so Desktop failed to start Claude with *"NEXO Brain contract missing"* on every fresh install and every update from 6.0.0 / 6.0.1 / 6.0.2 unless the user copied the file by hand. An idempotent migration in `auto_update.py` promotes legacy runtimes on `nexo update`. (2) `nexo_guard_check` now persists the caller's SID on every `guard_checks` row (env `NEXO_SID` env `CLAUDE_SESSION_ID` via `sessions.external_session_id` most-recently-updated `sessions` row). Pre-v6.0.3 it hardcoded `session_id=""`, so `hook_guardrails._session_has_guard_check` never matched and strict-protocol sessions tripped *"no guard_check seen for this session"* after every successful call.
21
+ Version `6.0.5` is the current packaged-runtime line: the strict pre-tool guardrail no longer blocks `Edit`/`Write` with *"unknown target"* when Claude Code's `PreToolUse` payload omits `session_id`. `process_pre_tool_event` now falls back to `$NEXO_HOME/coordination/.claude-session-id` (written by the SessionStart hook) before giving up, so a user who already called `nexo_startup` + `nexo_task_open` + `nexo_guard_check` + `nexo_track` stops seeing the block storm that several Claude Code versions triggered in 6.0.2–6.0.4. Fail-closed semantics are preserved: if neither the payload nor the coordination file yields a session id the guardrail still blocks with `missing_startup`. Release also lands a new `.github/workflows/tests.yml` that runs the full `pytest` suite on every PR the hook-guardrail regressions that shipped unnoticed in 6.0.2+ would have been caught by CI had this job existed then.
22
22
 
23
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).
24
24
 
package/bin/nexo-brain.js CHANGED
@@ -93,10 +93,50 @@ const RESONANCE_TIER_NAMES = ["maximo", "alto", "medio", "bajo"];
93
93
  const DEFAULT_RESONANCE_TIER = _RESONANCE_TIERS.default_tier || "alto";
94
94
 
95
95
  function isEphemeralInstall(nexoHome) {
96
- const homeDir = require("os").homedir();
96
+ const os = require("os");
97
+ const homeDir = os.homedir();
97
98
  const allowEphemeral = process.env.NEXO_ALLOW_EPHEMERAL_INSTALL === "1";
98
99
  if (allowEphemeral) return false;
99
- return nexoHome.startsWith("/tmp/") || homeDir.startsWith("/tmp/");
100
+
101
+ const normalize = (candidate) => {
102
+ if (!candidate) return "";
103
+ let resolved = String(candidate);
104
+ try {
105
+ resolved = fs.realpathSync.native(resolved);
106
+ } catch {
107
+ try {
108
+ resolved = fs.realpathSync(resolved);
109
+ } catch {
110
+ resolved = path.resolve(resolved);
111
+ }
112
+ }
113
+ return resolved.replace(/\\/g, "/").replace(/\/+$/, "");
114
+ };
115
+
116
+ const tempRoots = new Set();
117
+ for (const root of [os.tmpdir(), "/tmp", "/var/folders", "/private/var/folders"]) {
118
+ const normalized = normalize(root);
119
+ if (!normalized) continue;
120
+ tempRoots.add(normalized);
121
+ if (normalized === "/tmp") {
122
+ tempRoots.add("/private/tmp");
123
+ } else if (normalized === "/private/tmp") {
124
+ tempRoots.add("/tmp");
125
+ } else if (normalized.startsWith("/var/")) {
126
+ tempRoots.add(`/private${normalized}`);
127
+ } else if (normalized.startsWith("/private/var/")) {
128
+ tempRoots.add(normalized.replace(/^\/private/, ""));
129
+ }
130
+ }
131
+
132
+ const isWithin = (candidate, root) => (
133
+ candidate === root || candidate.startsWith(`${root}/`)
134
+ );
135
+
136
+ return [nexoHome, homeDir]
137
+ .map(normalize)
138
+ .filter(Boolean)
139
+ .some((candidate) => Array.from(tempRoots).some((root) => isWithin(candidate, root)));
100
140
  }
101
141
 
102
142
  const rl = readline.createInterface({
@@ -3325,7 +3365,10 @@ ${doScan ? `- Stack: ${Object.keys(profileData.code.languages || {}).slice(0, 5)
3325
3365
  schedule = await maybeConfigurePublicContribution(schedule, useDefaults);
3326
3366
  schedule = await maybeConfigureFullDiskAccess(schedule, useDefaults, python);
3327
3367
  const enabledOptionals = { dashboard: doDashboard, automation: schedule.automation_enabled !== false };
3328
- if (isEphemeralInstall(NEXO_HOME)) {
3368
+ const smokeTestMode = process.env.NEXO_TESTING_SMOKE === "1";
3369
+ if (smokeTestMode) {
3370
+ log("Smoke test mode detected — skipping LaunchAgents installation.");
3371
+ } else if (isEphemeralInstall(NEXO_HOME)) {
3329
3372
  log("Ephemeral HOME/NEXO_HOME detected — skipping LaunchAgents installation.");
3330
3373
  } else {
3331
3374
  installAllProcesses(platform, python, NEXO_HOME, schedule, LAUNCH_AGENTS, enabledOptionals);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "6.0.3",
3
+ "version": "6.0.5",
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
 
@@ -18,7 +18,18 @@ import time
18
18
  from pathlib import Path
19
19
 
20
20
  from runtime_home import export_resolved_nexo_home, managed_nexo_home
21
- from tree_hygiene import is_duplicate_artifact_name
21
+
22
+ try:
23
+ from tree_hygiene import is_duplicate_artifact_name
24
+ except ModuleNotFoundError as exc:
25
+ if getattr(exc, "name", "") != "tree_hygiene":
26
+ raise
27
+
28
+ # Older installed runtimes may update into code that references
29
+ # tree_hygiene.py before that module has been copied over. Fall back
30
+ # to "no duplicate" so the update can complete and deliver the module.
31
+ def is_duplicate_artifact_name(_path) -> bool:
32
+ return False
22
33
 
23
34
  NEXO_HOME = export_resolved_nexo_home()
24
35
  DATA_DIR = NEXO_HOME / "data"
@@ -542,9 +542,31 @@ CORE_HOOK_SPECS = [
542
542
  },
543
543
  ]
544
544
 
545
+ # Claude Code can retain legacy managed hooks from older/plugin-style installs
546
+ # or from transient test runtimes. Treat their handler basenames as managed so
547
+ # the next sync prunes them instead of leaving broken duplicates behind.
545
548
  LEGACY_CORE_HOOK_IDENTITIES_BY_EVENT = {
549
+ "SessionStart": {
550
+ "session_start.py",
551
+ },
552
+ "Stop": {
553
+ "stop.py",
554
+ },
555
+ "UserPromptSubmit": {
556
+ "auto_capture.py",
557
+ },
546
558
  "PostToolUse": {
547
559
  "heartbeat-guard.sh",
560
+ "post_tool_use.py",
561
+ },
562
+ "PreCompact": {
563
+ "pre_compact.py",
564
+ },
565
+ "Notification": {
566
+ "notification.py",
567
+ },
568
+ "SubagentStop": {
569
+ "subagent_stop.py",
548
570
  },
549
571
  }
550
572
 
@@ -599,7 +621,7 @@ def _hook_identity(command: str) -> str:
599
621
  text = str(command or "")
600
622
  if ".session-start-ts" in text:
601
623
  return "session-start-ts"
602
- match = re.search(r"([A-Za-z0-9._-]+\.sh)\b", text)
624
+ match = re.search(r"([A-Za-z0-9._-]+\.(?:sh|py))\b", text)
603
625
  if match:
604
626
  return match.group(1)
605
627
  return text.strip()
@@ -429,6 +429,38 @@ def _collect_automation_live_repo_blocks(
429
429
  return blocks
430
430
 
431
431
 
432
+ def _read_claude_session_id_from_coordination() -> str:
433
+ """Fallback claude_session_id when Claude Code's PreToolUse payload omits it.
434
+
435
+ SessionStart hook writes the active Claude Code session UUID to
436
+ ``<NEXO_HOME>/coordination/.claude-session-id``. When the PreToolUse
437
+ payload omits ``session_id`` (observed across several Claude Code
438
+ versions), the pre-tool guardrail would lose correlation with the open
439
+ NEXO session and block every write with "unknown target" (learning
440
+ #411). Reading the coordination file restores the correlation without
441
+ relaxing fail-closed semantics: if the file is missing or empty the
442
+ caller still blocks.
443
+ """
444
+ candidates = []
445
+ nexo_home = os.environ.get("NEXO_HOME", "").strip()
446
+ if nexo_home:
447
+ candidates.append(Path(nexo_home).expanduser() / "coordination" / ".claude-session-id")
448
+ candidates.append(Path.home() / ".nexo" / "coordination" / ".claude-session-id")
449
+ seen: set[str] = set()
450
+ for path in candidates:
451
+ key = str(path)
452
+ if key in seen:
453
+ continue
454
+ seen.add(key)
455
+ try:
456
+ value = path.read_text().strip()
457
+ except (FileNotFoundError, OSError):
458
+ continue
459
+ if value:
460
+ return value
461
+ return ""
462
+
463
+
432
464
  def process_pre_tool_event(payload: dict) -> dict:
433
465
  tool_name = str(payload.get("tool_name", "")).strip()
434
466
  op = _operation_kind(tool_name)
@@ -439,7 +471,10 @@ def process_pre_tool_event(payload: dict) -> dict:
439
471
  files = _extract_touched_files(tool_input)
440
472
  strictness = get_protocol_strictness()
441
473
  conn = get_db()
442
- sid = _resolve_nexo_sid(conn, str(payload.get("session_id", "")))
474
+ claude_sid = str(payload.get("session_id", "") or "").strip()
475
+ if not claude_sid:
476
+ claude_sid = _read_claude_session_id_from_coordination()
477
+ sid = _resolve_nexo_sid(conn, claude_sid)
443
478
  automation_blocks = _collect_automation_live_repo_blocks(
444
479
  conn,
445
480
  sid=sid,
@@ -10,7 +10,17 @@ import time
10
10
 
11
11
  from db import get_db
12
12
  from fastmcp.tools import Tool
13
- from tree_hygiene import is_duplicate_artifact_name
13
+
14
+ try:
15
+ from tree_hygiene import is_duplicate_artifact_name
16
+ except ModuleNotFoundError as exc:
17
+ if getattr(exc, "name", "") != "tree_hygiene":
18
+ raise
19
+
20
+ # Keep older runtimes bootable long enough to receive tree_hygiene.py
21
+ # during update; duplicate filtering will resume once the module lands.
22
+ def is_duplicate_artifact_name(_path) -> bool:
23
+ return False
14
24
 
15
25
  SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
16
26
  PLUGINS_DIR = os.path.join(SERVER_DIR, "plugins")
@@ -11,7 +11,18 @@ import time
11
11
  from pathlib import Path
12
12
 
13
13
  from runtime_home import export_resolved_nexo_home
14
- from tree_hygiene import is_duplicate_artifact_name
14
+
15
+ try:
16
+ from tree_hygiene import is_duplicate_artifact_name
17
+ except ModuleNotFoundError as exc:
18
+ if getattr(exc, "name", "") != "tree_hygiene":
19
+ raise
20
+
21
+ # Older packaged runtimes may import update.py before tree_hygiene.py
22
+ # has been copied in. Allow the update to finish so the missing module
23
+ # can be delivered by the same upgrade.
24
+ def is_duplicate_artifact_name(_path) -> bool:
25
+ return False
15
26
 
16
27
  # db_guard landed in v5.5.5. When plugins/update.py is imported from a runtime
17
28
  # that still ships the v5.5.4 tree (e.g. mid-upgrade), the import will fail —
@@ -173,6 +173,10 @@ USER_FACING_CALLERS: dict[str, str] = {
173
173
  "nexo_chat": USE_USER_DEFAULT_SENTINEL,
174
174
  "desktop_new_session": USE_USER_DEFAULT_SENTINEL,
175
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,
176
180
  }
177
181
 
178
182
  # System-owned callers. Grouped thematically for readability.
@@ -304,6 +304,10 @@ def _is_ignored(path: Path) -> bool:
304
304
  """Check if file should be ignored entirely."""
305
305
  if path.name in _IGNORED_FILES:
306
306
  return True
307
+ if re.search(r"\.bak(?:-[\w.-]+)?$", path.name, re.IGNORECASE):
308
+ return True
309
+ if path.name.endswith("~"):
310
+ return True
307
311
  if path.name.startswith("."):
308
312
  return True
309
313
  try: