nexo-brain 7.16.3 → 7.17.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/agent_runner.py +63 -12
- package/src/plugins/guard.py +13 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.17.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,11 @@
|
|
|
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.
|
|
21
|
+
Version `7.17.1` is the current packaged-runtime line. Patch release over v7.17.0 - the headless Claude CLI 2.1+ direct-JSON response shape is now handled: when the wrapper `{"result": ...}` is absent and the agent's answer is returned directly, `_extract_claude_telemetry` surfaces the full payload to the caller instead of an empty string. Fixes the daily morning-agent failure with "Morning agent returned invalid JSON output".
|
|
22
|
+
|
|
23
|
+
Previously in `7.17.0`: minor release over v7.16.3 - the headless runner pre-emptive guard becomes advisory: it surfaces learnings/schemas to the agent and logs to `guard_checks`, but never returns `blocked=True`. The PreToolUse hook is the authoritative gate at write time.
|
|
24
|
+
|
|
25
|
+
Previously in `7.16.3`: patch release over v7.16.2 - the headless runner guard opts out of the runtime-core blocking rule because actual writes on those paths are already blocked at the PreToolUse layer.
|
|
22
26
|
|
|
23
27
|
Previously in `7.16.0`: minor release over v7.15.2 - Brain adds Memory Observations v2: evidence-backed event capture, derived observations, update-safe backfill, MCP retrieval, dashboard visibility, and safer refusal when memory lacks evidence.
|
|
24
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.17.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",
|
package/src/agent_runner.py
CHANGED
|
@@ -94,15 +94,32 @@ def _extract_claude_telemetry(raw_stdout: str, *, requested_output_format: str)
|
|
|
94
94
|
"warnings": ["backend did not return parseable JSON telemetry"],
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
# Two shapes can arrive in raw_stdout:
|
|
98
|
+
# (a) Classic Claude CLI wrapper:
|
|
99
|
+
# {"result": "<agent text or stringified JSON>", "usage": {...}, "total_cost_usd": N}
|
|
100
|
+
# (b) Direct agent JSON (Claude CLI 2.1+ with bare_mode + output_format=json
|
|
101
|
+
# + a prompt that requests raw JSON only). The wrapper is dropped and
|
|
102
|
+
# the entire payload IS the agent's answer, e.g. {"subject":..., "body":...}.
|
|
103
|
+
# Pre-7.17.1 only handled (a): payload.get("result", "") returned "" in case (b),
|
|
104
|
+
# which left result.stdout empty for the caller. morning-agent then raised
|
|
105
|
+
# "Morning agent returned invalid JSON output" on every cron tick even though
|
|
106
|
+
# the agent had answered correctly and the answer was already persisted in
|
|
107
|
+
# automation_runs.metadata.raw. The branch below normalises both shapes.
|
|
108
|
+
if "result" in payload:
|
|
109
|
+
result_payload = payload["result"]
|
|
110
|
+
telemetry_payload = payload
|
|
111
|
+
else:
|
|
112
|
+
result_payload = payload
|
|
113
|
+
telemetry_payload = {}
|
|
114
|
+
|
|
98
115
|
if requested_output_format and requested_output_format.lower() == "json" and not isinstance(result_payload, str):
|
|
99
116
|
final_stdout = json.dumps(result_payload, ensure_ascii=False)
|
|
100
117
|
else:
|
|
101
118
|
final_stdout = result_payload if isinstance(result_payload, str) else json.dumps(result_payload, ensure_ascii=False)
|
|
102
119
|
|
|
103
|
-
usage =
|
|
104
|
-
model_usage =
|
|
105
|
-
explicit_cost =
|
|
120
|
+
usage = telemetry_payload.get("usage") or {}
|
|
121
|
+
model_usage = telemetry_payload.get("modelUsage") or {}
|
|
122
|
+
explicit_cost = telemetry_payload.get("total_cost_usd")
|
|
106
123
|
if explicit_cost is None and isinstance(model_usage, dict):
|
|
107
124
|
explicit_cost = sum(
|
|
108
125
|
float((item or {}).get("costUSD") or 0.0)
|
|
@@ -439,6 +456,11 @@ def _extract_runner_guard_paths(prompt: str, cwd: Path) -> list[str]:
|
|
|
439
456
|
|
|
440
457
|
for match in re.findall(r"(?<![A-Za-z0-9_])(?:/[^\s'\"`<>]+|[A-Za-z]:\\[^\s'\"`<>]+)", text):
|
|
441
458
|
cleaned = match.rstrip(".,);:]")
|
|
459
|
+
# Drop trailing slashes — `/tmp/` and friends are directories, not
|
|
460
|
+
# edit targets. Without this the followup-runner prompt's mention
|
|
461
|
+
# of `/tmp/` reached `handle_guard_check`, which tried to `open()`
|
|
462
|
+
# the directory and crashed the whole pre-emptive guard.
|
|
463
|
+
cleaned = cleaned.rstrip("/")
|
|
442
464
|
if not cleaned or cleaned in exec_only_paths:
|
|
443
465
|
continue
|
|
444
466
|
found.add(cleaned)
|
|
@@ -461,17 +483,47 @@ def _extract_runner_guard_paths(prompt: str, cwd: Path) -> list[str]:
|
|
|
461
483
|
|
|
462
484
|
|
|
463
485
|
def _run_headless_runner_guard(*, caller: str, cwd: Path, prompt: str, allowed_tools: str) -> dict:
|
|
486
|
+
"""Pre-emptive runner guard — observational, never blocking.
|
|
487
|
+
|
|
488
|
+
The pre-emptive guard scans the *prompt text* for paths the agent might
|
|
489
|
+
edit. That is heuristic: it cannot tell whether a path will actually be
|
|
490
|
+
written, read, executed, or just mentioned in passing. Treating the
|
|
491
|
+
learnings/blocking-rules surfaced by that heuristic as hard blockers
|
|
492
|
+
has caused a year's worth of operational pain — every time a learning's
|
|
493
|
+
``applies_to`` happens to match a path the prompt mentions for any
|
|
494
|
+
reason, every cron, every email-monitor session, every Deep Sleep
|
|
495
|
+
synth aborts with exit 2.
|
|
496
|
+
|
|
497
|
+
The authoritative gate is the PreToolUse hook
|
|
498
|
+
(``hook_guardrails._collect_runtime_core_write_blocks`` and the
|
|
499
|
+
learning-aware blocks alongside it). That layer fires only when the
|
|
500
|
+
agent *actually attempts* a Write/Edit, with severity ``error``, and
|
|
501
|
+
prevents the protected mutation. The pre-emptive guard's job is to
|
|
502
|
+
surface relevant learnings/schemas to the agent up front so it can
|
|
503
|
+
reason about them, plus log to ``guard_checks`` for observability —
|
|
504
|
+
not to abort the run on a regex match.
|
|
505
|
+
|
|
506
|
+
Therefore this function always returns ``blocked=False``. Errors from
|
|
507
|
+
``handle_guard_check`` are also non-blocking; we let the run start so
|
|
508
|
+
the PreToolUse layer can do its job, and we log the unavailability for
|
|
509
|
+
diagnostics.
|
|
510
|
+
"""
|
|
464
511
|
if not _runner_mutating_tools_allowed(allowed_tools):
|
|
465
512
|
return {"blocked": False, "skipped": "read_only_tools"}
|
|
466
513
|
guard_paths = _extract_runner_guard_paths(prompt, cwd)
|
|
467
514
|
if not guard_paths:
|
|
468
515
|
return {"blocked": False, "skipped": "no_explicit_paths"}
|
|
516
|
+
summary = ""
|
|
469
517
|
try:
|
|
470
518
|
runtime_root = str(NEXO_HOME)
|
|
471
519
|
if runtime_root and runtime_root not in sys.path:
|
|
472
520
|
sys.path.insert(0, runtime_root)
|
|
473
521
|
from plugins.guard import handle_guard_check # type: ignore
|
|
474
522
|
|
|
523
|
+
# We still pass ``enforce_runtime_core_block="false"`` because that
|
|
524
|
+
# opt-out predates the advisory-only switch and other callers may
|
|
525
|
+
# still rely on it. With the advisory contract in place this flag
|
|
526
|
+
# is effectively redundant, but kept for back-compat.
|
|
475
527
|
output = handle_guard_check(
|
|
476
528
|
files=",".join(guard_paths),
|
|
477
529
|
area=f"runner:{caller or 'headless'}",
|
|
@@ -479,17 +531,16 @@ def _run_headless_runner_guard(*, caller: str, cwd: Path, prompt: str, allowed_t
|
|
|
479
531
|
include_schemas="true",
|
|
480
532
|
enforce_runtime_core_block="false",
|
|
481
533
|
)
|
|
534
|
+
summary = str(output or "")
|
|
482
535
|
except Exception as exc:
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
"paths": guard_paths,
|
|
487
|
-
}
|
|
488
|
-
blocked = "BLOCKING RULES" in str(output or "")
|
|
536
|
+
# Guard unavailable is observational, not blocking. The PreToolUse
|
|
537
|
+
# hook will catch any actual protected write at execute time.
|
|
538
|
+
summary = f"Runner guard unavailable: {exc}"
|
|
489
539
|
return {
|
|
490
|
-
"blocked":
|
|
491
|
-
"summary":
|
|
540
|
+
"blocked": False,
|
|
541
|
+
"summary": summary,
|
|
492
542
|
"paths": guard_paths,
|
|
543
|
+
"advisory": True,
|
|
493
544
|
}
|
|
494
545
|
|
|
495
546
|
|
package/src/plugins/guard.py
CHANGED
|
@@ -494,12 +494,24 @@ def handle_guard_check(
|
|
|
494
494
|
all_tables = set()
|
|
495
495
|
for filepath in file_list:
|
|
496
496
|
try:
|
|
497
|
+
# Skip directories silently — the path extractor that calls
|
|
498
|
+
# us is permissive and can hand back paths that end in `/`
|
|
499
|
+
# (e.g. `/tmp/`) or that resolve to real directories. Opening
|
|
500
|
+
# one of those raises IsADirectoryError, which is an OSError
|
|
501
|
+
# subclass; the prior except only caught FileNotFoundError /
|
|
502
|
+
# PermissionError, so the exception propagated and the whole
|
|
503
|
+
# runner pre-emptive guard returned `blocked=True` with
|
|
504
|
+
# "Runner guard unavailable: [Errno 21] Is a directory".
|
|
505
|
+
# That is exactly what was killing the followup-runner every
|
|
506
|
+
# hour after 7.16.0.
|
|
507
|
+
if Path(filepath).is_dir():
|
|
508
|
+
continue
|
|
497
509
|
with open(filepath, 'r', errors='ignore') as f:
|
|
498
510
|
content = f.read()
|
|
499
511
|
sql_keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE TABLE']
|
|
500
512
|
if any(kw in content.upper() for kw in sql_keywords):
|
|
501
513
|
all_tables.update(_extract_table_names(content))
|
|
502
|
-
except (FileNotFoundError, PermissionError):
|
|
514
|
+
except (FileNotFoundError, PermissionError, IsADirectoryError, OSError):
|
|
503
515
|
continue
|
|
504
516
|
|
|
505
517
|
cache = _load_schema_cache()
|