nexo-brain 5.0.3 → 5.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": "5.0.3",
3
+ "version": "5.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
@@ -87,6 +87,12 @@ Versions `3.1.7` through `3.2.0` close the recent-memory gap:
87
87
  - when even that misses, NEXO now exposes raw transcript fallback tools for Claude Code and Codex session stores
88
88
  - NEXO can now inspect itself through a live system catalog derived from canonical sources instead of relying only on stale docs or operator memory
89
89
 
90
+ Version `5.0.4` tightens the local runtime bridge and trims false-positive doctor noise:
91
+
92
+ - vendorable `nexo_helper.py` now resolves `NEXO_HOME` and the `nexo` CLI path robustly, so personal scripts and subprocess flows stop depending on a lucky PATH
93
+ - doctor no longer degrades because of advisory-only self-audit warnings or a single missing usage-telemetry row
94
+ - managed Claude Code and Codex bootstraps now force an immediate first answer after simple email/diary/reminder/followup reads instead of feeling hung while chaining extra lookups
95
+
90
96
  Version `5.0.3` closes the next post-5.0 runtime gap:
91
97
 
92
98
  - `nexo chat` now boots Claude Code and Codex with an explicit NEXO startup prompt instead of opening cold or leaking the target path as a fake prompt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.0.3",
3
+ "version": "5.0.4",
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",
@@ -65,9 +65,6 @@ def check_self_audit_summary() -> DoctorCheck:
65
65
  if error_count > 0:
66
66
  status = "critical"
67
67
  severity = "error"
68
- elif warn_count > 0:
69
- status = "degraded"
70
- severity = "warn"
71
68
  else:
72
69
  status = "healthy"
73
70
  severity = "info"
@@ -2491,7 +2491,7 @@ def check_protocol_compliance() -> DoctorCheck:
2491
2491
  if str(row["opened_at"] or "") >= first_cortex_eval_at
2492
2492
  ]
2493
2493
  decision_ok = [row for row in decision_eligible if row["task_id"] in covered_tasks]
2494
- decision_metric_ready = len(decision_eligible) >= 3
2494
+ decision_metric_ready = bool(first_cortex_eval_at) and len(decision_eligible) >= 3
2495
2495
 
2496
2496
  score_parts = []
2497
2497
  if verify_required:
@@ -2922,6 +2922,7 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
2922
2922
  pricing_gaps = int((row["pricing_gaps"] if row else 0) or 0)
2923
2923
  usage_denominator = successful_runs or total_runs
2924
2924
  cost_denominator = successful_runs or total_runs
2925
+ missing_usage_runs = max(0, usage_denominator - usage_runs) if usage_denominator else 0
2925
2926
  usage_coverage = round((usage_runs / usage_denominator) * 100, 1) if usage_denominator else 100.0
2926
2927
  cost_coverage = round((cost_runs / cost_denominator) * 100, 1) if cost_denominator else 100.0
2927
2928
  evidence = [
@@ -2933,6 +2934,8 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
2933
2934
  f"cost_coverage={cost_coverage}%",
2934
2935
  f"pricing_gaps={pricing_gaps}",
2935
2936
  ]
2937
+ if missing_usage_runs:
2938
+ evidence.append(f"missing_usage_runs={missing_usage_runs}")
2936
2939
  backends = str((row["backends"] if row else "") or "").strip()
2937
2940
  if backends:
2938
2941
  evidence.append(f"backends={backends}")
@@ -2940,7 +2943,7 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
2940
2943
  status = "healthy"
2941
2944
  severity = "info"
2942
2945
  repair_plan: list[str] = []
2943
- if usage_coverage < 100.0:
2946
+ if usage_coverage < 100.0 and missing_usage_runs > 1:
2944
2947
  status = "degraded"
2945
2948
  severity = "warn"
2946
2949
  repair_plan.append("Restore backend usage parsing so automation runs always emit token telemetry")
@@ -32,6 +32,8 @@ NEXO updates may rewrite `CORE`, but they must preserve `USER` verbatim.
32
32
  - After the first relevant tool or artifact result, reply with the answer immediately instead of silently chaining more investigation.
33
33
  - Only continue into deeper investigation before the first visible answer if the user explicitly asked for a deep investigation or the situation is urgent/high-risk.
34
34
  - For single-artifact asks (email, message, diary item, reminder, prior fact), retrieve the artifact, summarize it, then decide whether further action is needed.
35
+ - For single-artifact asks, the default cap before the first visible answer is one lookup plus one detail read. Do not keep chaining tools before answering unless the user explicitly asked for more depth.
36
+ - After `nexo_email_read`, `nexo_session_diary_read`, `nexo_reminders`, `nexo_followups`, or equivalent single-artifact retrieval, answer immediately. Do not launch another search, another read, or background analysis before the first user-visible answer unless the user explicitly asked for it.
35
37
 
36
38
  <!-- nexo:start:profile -->
37
39
  ## User Profile
@@ -29,6 +29,8 @@ NEXO updates may rewrite `CORE`, but they must preserve `USER` verbatim.
29
29
  - After the first relevant tool or artifact result, answer immediately instead of silently chaining more investigation.
30
30
  - Only keep investigating before the first visible answer if the user explicitly requested deep investigation or the situation is urgent/high-risk.
31
31
  - For single-artifact asks (email, message, diary item, reminder, prior fact), retrieve it, summarize it, then decide if more work is needed.
32
+ - For single-artifact asks, the default cap before the first visible answer is one lookup plus one detail read. Do not keep chaining tools before answering unless the user explicitly asked for more depth.
33
+ - After `nexo_email_read`, `nexo_session_diary_read`, `nexo_reminders`, `nexo_followups`, or equivalent single-artifact retrieval, answer immediately. Do not launch another search, another read, or background analysis before the first visible answer unless the user explicitly asked for it.
32
34
 
33
35
  ## Codex Runtime Notes
34
36
  - Codex does not provide Claude Code hooks, so protocol discipline must be explicit.
@@ -10,12 +10,34 @@ from __future__ import annotations
10
10
 
11
11
  import json
12
12
  import os
13
+ import shutil
13
14
  import subprocess
14
15
  import sys
15
16
  from pathlib import Path
16
17
 
17
18
 
18
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
19
+ def _detect_nexo_home() -> Path:
20
+ env_home = os.environ.get("NEXO_HOME", "").strip()
21
+ if env_home:
22
+ return Path(env_home).expanduser()
23
+
24
+ helper_path = Path(__file__).resolve()
25
+ inferred_home = helper_path.parent.parent
26
+ if (
27
+ helper_path.parent.name == "templates"
28
+ and (inferred_home / "scripts").is_dir()
29
+ and (inferred_home / "config").is_dir()
30
+ ):
31
+ return inferred_home
32
+
33
+ claude_home = Path.home() / "claude"
34
+ if claude_home.is_dir():
35
+ return claude_home
36
+
37
+ return Path.home() / ".nexo"
38
+
39
+
40
+ NEXO_HOME = _detect_nexo_home()
19
41
  DEFAULT_ALLOWED_TOOLS = "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"
20
42
  DEFAULT_NEXO_TIMEOUT_SECONDS = max(15, int(os.environ.get("NEXO_HELPER_TIMEOUT", "90")))
21
43
 
@@ -49,16 +71,44 @@ def _load_bootstrap_prompt() -> str:
49
71
  return ""
50
72
 
51
73
 
74
+ def _resolve_nexo_cli() -> str:
75
+ candidates = []
76
+
77
+ env_cli = os.environ.get("NEXO_BIN", "").strip()
78
+ if env_cli:
79
+ candidates.append(Path(env_cli).expanduser())
80
+
81
+ candidates.extend(
82
+ [
83
+ NEXO_HOME / "bin" / "nexo",
84
+ Path.home() / ".local" / "bin" / "nexo",
85
+ Path.home() / "bin" / "nexo",
86
+ ]
87
+ )
88
+
89
+ for candidate in candidates:
90
+ try:
91
+ if candidate.is_file() and os.access(candidate, os.X_OK):
92
+ return str(candidate)
93
+ except OSError:
94
+ continue
95
+
96
+ return shutil.which("nexo") or "nexo"
97
+
98
+
52
99
  def run_nexo(args: list[str]) -> str:
53
100
  """Run a nexo CLI command and return stdout.
54
101
 
55
102
  Raises RuntimeError on non-zero exit.
56
103
  """
104
+ env = os.environ.copy()
105
+ env.setdefault("NEXO_HOME", str(NEXO_HOME))
57
106
  result = subprocess.run(
58
- ["nexo", *args],
107
+ [_resolve_nexo_cli(), *args],
59
108
  capture_output=True,
60
109
  text=True,
61
110
  timeout=DEFAULT_NEXO_TIMEOUT_SECONDS,
111
+ env=env,
62
112
  )
63
113
  if result.returncode != 0:
64
114
  raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"nexo exited {result.returncode}")
@@ -83,12 +133,72 @@ def call_tool_json(name: str, payload: dict | None = None) -> dict:
83
133
  return json.loads(out)
84
134
 
85
135
 
136
+ def _extract_json_object(text: str) -> dict:
137
+ raw = str(text or "").strip()
138
+ if not raw:
139
+ raise RuntimeError("Automation backend returned empty output.")
140
+
141
+ if raw.startswith("```"):
142
+ lines = raw.splitlines()
143
+ if len(lines) >= 2:
144
+ end = len(lines)
145
+ for i in range(len(lines) - 1, 0, -1):
146
+ if lines[i].strip() == "```":
147
+ end = i
148
+ break
149
+ raw = "\n".join(lines[1:end]).strip()
150
+
151
+ try:
152
+ parsed = json.loads(raw)
153
+ if isinstance(parsed, dict):
154
+ return parsed
155
+ except Exception:
156
+ pass
157
+
158
+ start = raw.find("{")
159
+ if start < 0:
160
+ raise RuntimeError("Automation backend did not return a JSON object.")
161
+
162
+ depth = 0
163
+ in_string = False
164
+ escape = False
165
+ for idx in range(start, len(raw)):
166
+ ch = raw[idx]
167
+ if in_string:
168
+ if escape:
169
+ escape = False
170
+ elif ch == "\\":
171
+ escape = True
172
+ elif ch == '"':
173
+ in_string = False
174
+ continue
175
+
176
+ if ch == '"':
177
+ in_string = True
178
+ elif ch == "{":
179
+ depth += 1
180
+ elif ch == "}":
181
+ depth -= 1
182
+ if depth == 0:
183
+ candidate = raw[start : idx + 1]
184
+ try:
185
+ parsed = json.loads(candidate)
186
+ except Exception as exc:
187
+ raise RuntimeError(f"Automation backend returned invalid JSON object: {exc}") from exc
188
+ if isinstance(parsed, dict):
189
+ return parsed
190
+ break
191
+
192
+ raise RuntimeError("Automation backend did not return a parseable JSON object.")
193
+
194
+
86
195
  def run_automation_text(
87
196
  prompt: str,
88
197
  *,
89
198
  model: str = "",
90
199
  reasoning_effort: str = "",
91
200
  cwd: str = "",
201
+ timeout: int | None = None,
92
202
  allowed_tools: str = DEFAULT_ALLOWED_TOOLS,
93
203
  append_system_prompt: str = "",
94
204
  include_bootstrap: bool = True,
@@ -130,7 +240,58 @@ def run_automation_text(
130
240
  capture_output=True,
131
241
  text=True,
132
242
  env=env,
243
+ timeout=(int(timeout) if timeout else None),
133
244
  )
134
245
  if result.returncode != 0:
135
246
  raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"automation backend exited {result.returncode}")
136
247
  return result.stdout
248
+
249
+
250
+ def run_automation_json(
251
+ prompt: str,
252
+ *,
253
+ model: str = "",
254
+ reasoning_effort: str = "",
255
+ cwd: str = "",
256
+ timeout: int | None = None,
257
+ allowed_tools: str = DEFAULT_ALLOWED_TOOLS,
258
+ append_system_prompt: str = "",
259
+ include_bootstrap: bool = True,
260
+ ) -> dict:
261
+ """Run the configured backend and return a parsed JSON object."""
262
+ runner = NEXO_HOME / "scripts" / "nexo-agent-run.py"
263
+ if not runner.exists():
264
+ raise RuntimeError(f"Automation runner not found: {runner}")
265
+
266
+ cmd = [sys.executable, str(runner), "--prompt", prompt, "--output-format", "json"]
267
+ if model:
268
+ cmd.extend(["--model", model])
269
+ if reasoning_effort:
270
+ cmd.extend(["--reasoning-effort", reasoning_effort])
271
+ if cwd:
272
+ cmd.extend(["--cwd", cwd])
273
+ merged_system_prompt = []
274
+ if include_bootstrap:
275
+ bootstrap = _load_bootstrap_prompt()
276
+ if bootstrap:
277
+ merged_system_prompt.append(bootstrap)
278
+ if append_system_prompt:
279
+ merged_system_prompt.append(append_system_prompt)
280
+ if merged_system_prompt:
281
+ cmd.extend(["--append-system-prompt", "\n\n".join(merged_system_prompt)])
282
+ if allowed_tools:
283
+ cmd.extend(["--allowed-tools", allowed_tools])
284
+
285
+ env = os.environ.copy()
286
+ env.setdefault("NEXO_HOME", str(NEXO_HOME))
287
+ env.setdefault("NEXO_CODE", env.get("NEXO_CODE", str(NEXO_HOME)))
288
+ result = subprocess.run(
289
+ cmd,
290
+ capture_output=True,
291
+ text=True,
292
+ env=env,
293
+ timeout=(int(timeout) if timeout else None),
294
+ )
295
+ if result.returncode != 0:
296
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"automation backend exited {result.returncode}")
297
+ return _extract_json_object(result.stdout)