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.
|
|
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.
|
|
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.
|
|
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",
|
package/src/hook_guardrails.py
CHANGED
|
@@ -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(("/", "~", ".", "$"))
|
package/src/plugins/update.py
CHANGED
|
@@ -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
|
-
["
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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(
|
|
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
|
|
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]
|