nexo-brain 6.0.4 → 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.4",
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.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.
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.4",
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",
@@ -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 —
@@ -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: