nexo-brain 7.25.0 → 7.25.1

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.1",
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(("/", "~", ".", "$"))
@@ -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]