nexo-brain 7.25.0 → 7.25.2

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": "7.25.0",
3
+ "version": "7.25.1",
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 `7.25.0` is the current packaged-runtime line. Minor release over v7.24.0 - Memory Fabric links transcript lookup, historical backup diary recovery, unified search and knowledge graph evidence so memories are not available only inside expiring snapshots.
21
+ Version `7.25.1` is the current packaged-runtime line. Patch release over v7.25.0 - shell guardrails skip non-path curl/wget arguments and daily protocol-debt audits keep ERROR classes visible by severity and type.
22
+
23
+ Previously in `7.25.0`: minor release over v7.24.0 - Memory Fabric links transcript lookup, historical backup diary recovery, unified search and knowledge graph evidence so memories are not available only inside expiring snapshots.
22
24
 
23
25
  Previously in `7.24.0`: minor release over v7.23.13 - Home Agents, cognitive quality controls, English operational copy, and non-blocking task-open context are integrated into main.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.25.0",
3
+ "version": "7.25.2",
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",
@@ -76,6 +76,26 @@ SHELL_WRITE_BASES = {
76
76
  "rsync",
77
77
  }
78
78
  SHELL_REDIRECT_TOKENS = {">", ">>", "1>", "1>>", "2>", "2>>"}
79
+ # Flags whose *next* token is a non-path argument (User-Agent strings,
80
+ # headers, query payloads, URLs, etc.). Without this whitelist the bash
81
+ # path extractor lifts fragments like ``Mozilla/5.0`` and ``AppleWebKit/
82
+ # 537.36`` as candidate paths, generating a ``g4_guard_check_required``
83
+ # debt for every curl/wget with a ``-A`` flag. Tracked by NF-AUDIT-
84
+ # 20260522-DRAIN-WHITELIST-EXPAND (W2).
85
+ SHELL_FLAGS_WITH_NON_PATH_ARG = {
86
+ "-A", "--user-agent",
87
+ "-H", "--header",
88
+ "-e", "--referer",
89
+ "-X", "--request",
90
+ "-d", "--data", "--data-raw", "--data-binary", "--data-urlencode",
91
+ "-F", "--form",
92
+ "-u", "--user",
93
+ "--url",
94
+ "--cookie", "-b",
95
+ "--connect-timeout", "--max-time",
96
+ "--retry", "--retry-delay", "--retry-max-time",
97
+ "--resolve",
98
+ }
79
99
  INLINE_INTERPRETER_BASES = {
80
100
  "python",
81
101
  "python3",
@@ -392,6 +412,14 @@ def _looks_like_real_path(path: str) -> bool:
392
412
  stripped = raw.lstrip("/")
393
413
  if stripped and re.fullmatch(r"\d+", stripped):
394
414
  return False
415
+ # Version-like fragments (``/5.0``, ``/537.36``, ``/140.0.0.0``) come
416
+ # from User-Agent / library version strings that the EMBEDDED_PATH_RE
417
+ # scanner lifts out of curl/wget commands. Token-level skip
418
+ # (SHELL_FLAGS_WITH_NON_PATH_ARG) does not help here because the regex
419
+ # runs against the raw command string. Filter them out at the path
420
+ # plausibility check instead.
421
+ if stripped and re.fullmatch(r"\d+(?:\.\d+)+", stripped):
422
+ return False
395
423
  if raw.lower() in _PATH_DICTIONARY_BLOCKLIST:
396
424
  return False
397
425
  # Reject single-segment ``/word`` candidates that do not exist on the
@@ -697,12 +725,25 @@ def _extract_bash_touched_files(tool_input) -> list[str]:
697
725
  seen.add(normalized)
698
726
  candidates.append(resolved)
699
727
 
728
+ skip_next = False
700
729
  for index, token in enumerate(tokens):
730
+ if skip_next:
731
+ skip_next = False
732
+ continue
701
733
  if token in SHELL_REDIRECT_TOKENS:
702
734
  if index + 1 < len(tokens):
703
735
  add(tokens[index + 1])
704
736
  continue
705
737
  if token.startswith("-"):
738
+ # ``--flag=value`` is self-contained; the value is glued so the
739
+ # extractor already ignores it. For the separated form
740
+ # (``-A "Mozilla/5.0 ..."`` / ``-H "X-Foo: bar"``) we have to
741
+ # skip the NEXT token, otherwise the path extractor lifts
742
+ # User-Agent / header / payload fragments as candidate paths
743
+ # and floods the audit with ``g4_guard_check_required`` noise.
744
+ flag_head = token.split("=", 1)[0]
745
+ if flag_head in SHELL_FLAGS_WITH_NON_PATH_ARG and "=" not in token:
746
+ skip_next = True
706
747
  continue
707
748
  if (
708
749
  token.startswith(("/", "~", ".", "$"))
@@ -280,18 +280,41 @@ def _npm_command_parts() -> tuple[list[str], dict[str, str]]:
280
280
  desktop_node = str(os.environ.get("NEXO_DESKTOP_NODE", "")).strip()
281
281
  bundled_npm_cli = str(os.environ.get("NEXO_DESKTOP_NPM_CLI", "")).strip()
282
282
  env = dict(os.environ)
283
- if desktop_node and bundled_npm_cli and Path(desktop_node).exists():
283
+ if desktop_node and bundled_npm_cli and Path(desktop_node).exists() and Path(bundled_npm_cli).exists():
284
284
  env["ELECTRON_RUN_AS_NODE"] = "1"
285
+ _apply_desktop_npm_prefix(env)
285
286
  return [desktop_node, bundled_npm_cli], env
286
287
  return ["npm"], env
287
288
 
288
289
 
290
+ def _desktop_npm_prefix() -> str:
291
+ return (
292
+ str(os.environ.get("NEXO_DESKTOP_NPM_PREFIX", "")).strip()
293
+ or str(os.environ.get("NEXO_CLAUDE_PREFIX", "")).strip()
294
+ or str(NEXO_HOME / "runtime" / "bootstrap" / "npm-global")
295
+ )
296
+
297
+
298
+ def _apply_desktop_npm_prefix(env: dict[str, str]) -> None:
299
+ if env.get("ELECTRON_RUN_AS_NODE") != "1":
300
+ return
301
+ npm_prefix = _desktop_npm_prefix()
302
+ if not npm_prefix:
303
+ return
304
+ env.setdefault("NPM_CONFIG_PREFIX", npm_prefix)
305
+ prefix_bin = str(Path(npm_prefix) / "bin")
306
+ current_path = str(env.get("PATH", ""))
307
+ entries = [entry for entry in current_path.split(os.pathsep) if entry]
308
+ env["PATH"] = os.pathsep.join([prefix_bin, *[entry for entry in entries if entry != prefix_bin]])
309
+
310
+
289
311
  def _run_npm(args: list[str], **kwargs):
290
312
  cmd, env = _npm_command_parts()
291
313
  extra_env = kwargs.pop("env", None)
292
314
  merged_env = dict(env)
293
315
  if extra_env:
294
316
  merged_env.update(extra_env)
317
+ _apply_desktop_npm_prefix(merged_env)
295
318
  return subprocess.run([*cmd, *args], env=merged_env, **kwargs)
296
319
 
297
320
 
@@ -1268,7 +1291,7 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
1268
1291
  try:
1269
1292
  _emit_progress(progress_fn, "Downloading and applying the latest npm package...")
1270
1293
  result = _run_npm(
1271
- ["update", "-g", "nexo-brain"],
1294
+ ["install", "-g", "nexo-brain@latest"],
1272
1295
  capture_output=True, text=True, timeout=120,
1273
1296
  env={**os.environ, "NEXO_HOME": str(NEXO_HOME)},
1274
1297
  )
@@ -192,7 +192,7 @@ def run(
192
192
  "SELECT id, session_id, task_id, debt_type, severity, evidence, created_at "
193
193
  "FROM protocol_debt WHERE resolved_at IS NULL"
194
194
  ).fetchall()
195
- by_type: dict[str, int] = {}
195
+ by_severity_type: dict[tuple[str, str], int] = {}
196
196
  for row in rows:
197
197
  task_open = _task_is_open(conn, str(row["task_id"] or ""))
198
198
  bucket = classify_debt(
@@ -216,13 +216,36 @@ def run(
216
216
  ),
217
217
  )
218
218
  elif bucket == "requires_user":
219
- by_type[str(row["debt_type"])] = by_type.get(str(row["debt_type"]), 0) + 1
220
- # Consolidate requires_user into a per-type summary so the morning
221
- # briefing stays short even when the backlog is long.
219
+ # Track by (severity, debt_type) so the morning briefing
220
+ # can split ERROR vs WARN buckets dynamically without
221
+ # this split, freshly-introduced ERROR debt classes stay
222
+ # invisible until someone hand-edits the whitelist.
223
+ severity = str(row["severity"] or "warn").strip().lower() or "warn"
224
+ debt_type = str(row["debt_type"] or "")
225
+ key = (severity, debt_type)
226
+ by_severity_type[key] = by_severity_type.get(key, 0) + 1
227
+ # Consolidate requires_user into a per-severity, per-type summary
228
+ # so the morning briefing stays short even when the backlog is
229
+ # long, while still surfacing ALL error classes (not a fixed top-4).
222
230
  report["requires_user_summary"] = [
223
- {"debt_type": debt_type, "count": count}
224
- for debt_type, count in sorted(by_type.items(), key=lambda x: -x[1])
231
+ {"severity": severity, "debt_type": debt_type, "count": count}
232
+ for (severity, debt_type), count in sorted(
233
+ by_severity_type.items(),
234
+ key=lambda item: (item[0][0] != "error", -item[1]),
235
+ )
225
236
  ]
237
+ # Aggregate by severity so consumers can report
238
+ # ``ERROR=N (a=x, b=y), WARN=M`` without re-bucketing.
239
+ report["requires_user_by_severity"] = {}
240
+ for entry in report["requires_user_summary"]:
241
+ sev = entry["severity"]
242
+ stat = report["requires_user_by_severity"].setdefault(
243
+ sev, {"total": 0, "by_type": []}
244
+ )
245
+ stat["total"] += int(entry["count"])
246
+ stat["by_type"].append(
247
+ {"debt_type": entry["debt_type"], "count": int(entry["count"])}
248
+ )
226
249
  if dry_run:
227
250
  conn.execute("ROLLBACK")
228
251
  else:
@@ -2102,6 +2102,7 @@ def _run_protocol_debt_drain_inline() -> dict:
2102
2102
  "error": report.get("error", ""),
2103
2103
  "drained_count": len(report.get("drained_ids") or []),
2104
2104
  "requires_user_summary": report.get("requires_user_summary") or [],
2105
+ "requires_user_by_severity": report.get("requires_user_by_severity") or {},
2105
2106
  "audit_path": report.get("audit_path", ""),
2106
2107
  }
2107
2108
 
@@ -2193,11 +2194,45 @@ def run_mechanical_autofixes():
2193
2194
  if debt_drain.get("ok"):
2194
2195
  drained_count = int(debt_drain.get("drained_count") or 0)
2195
2196
  requires_user_summary = debt_drain.get("requires_user_summary") or []
2197
+ requires_user_by_severity = debt_drain.get("requires_user_by_severity") or {}
2196
2198
  if drained_count or requires_user_summary:
2197
2199
  detail_bits: list[str] = []
2198
2200
  if drained_count:
2199
2201
  detail_bits.append(f"drained {drained_count} stale protocol debt item(s)")
2200
- if requires_user_summary:
2202
+ if requires_user_by_severity:
2203
+ # Split by severity so ERROR-class debt always appears
2204
+ # in the morning briefing, regardless of which debt
2205
+ # types happen to exist that day. Within a severity,
2206
+ # show every type (no top-N truncation) — silent drift
2207
+ # is what the previous "top-4" cap was hiding.
2208
+ severity_order = ["error", "warn", "info"]
2209
+ seen = set()
2210
+ severity_bits: list[str] = []
2211
+ for sev in severity_order + [
2212
+ s for s in requires_user_by_severity if s not in severity_order
2213
+ ]:
2214
+ if sev in seen or sev not in requires_user_by_severity:
2215
+ continue
2216
+ seen.add(sev)
2217
+ stat = requires_user_by_severity[sev]
2218
+ breakdown = ", ".join(
2219
+ f"{entry.get('debt_type')}={int(entry.get('count') or 0)}"
2220
+ for entry in stat.get("by_type") or []
2221
+ )
2222
+ label = sev.upper()
2223
+ if breakdown:
2224
+ severity_bits.append(
2225
+ f"{label}={int(stat.get('total') or 0)} ({breakdown})"
2226
+ )
2227
+ else:
2228
+ severity_bits.append(f"{label}={int(stat.get('total') or 0)}")
2229
+ if severity_bits:
2230
+ detail_bits.append(
2231
+ "still needs review: " + " | ".join(severity_bits)
2232
+ )
2233
+ elif requires_user_summary:
2234
+ # Defensive fallback: older drain reports without the
2235
+ # per-severity aggregation still render usefully.
2201
2236
  summary = ", ".join(
2202
2237
  f"{item.get('debt_type')} x{int(item.get('count') or 0)}"
2203
2238
  for item in requires_user_summary[:4]