nexo-brain 7.11.5 → 7.11.6
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 +3 -1
- package/package.json +1 -1
- package/src/hook_guardrails.py +40 -3
- package/src/scripts/deep-sleep/extract.py +62 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.11.
|
|
3
|
+
"version": "7.11.6",
|
|
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.11.
|
|
21
|
+
Version `7.11.6` is the current packaged-runtime line. Patch release — Guardian G4 now filters more false-positive slash fragments before they become debt, `strict_protocol_write_without_task` downgrades to `warn` when the session has a fresh heartbeat, and Deep Sleep extraction validates the real prompt contract instead of accepting any syntactically valid JSON. Validation so far: `50` targeted tests across hook guardrails and Deep Sleep extraction.
|
|
22
|
+
|
|
23
|
+
Previously in `7.11.5`: patch release — Desktop-managed installs now block the standalone dashboard at the same product-mode layer as evolution, so `installation_live`, cron sync, and watchdog no longer disagree about whether `com.nexo.dashboard` should exist. Validation: `125` targeted tests across product-mode, cron sync, and doctor, plus a full pre-release wrapper (`2321 passed, 2 skipped, 1 xfailed, 4 xpassed`).
|
|
22
24
|
|
|
23
25
|
Previously in `7.11.4`: patch release — packaged runtimes now receive root JSON contracts such as `local_model_manifest.json`, install/update paths sync core crons from `src/crons/manifest.json` instead of depending on a stale JS list, `runner-health-check` is wired into cron/doctor/dashboard instead of writing an unread file, and the watchdog retries failed crons immediately while treating `run_once_on_wake` as catchup-style recovery. Validation: `117` targeted tests across packaged update, cron sync/recovery, dashboard, local models, and runtime update contracts.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.11.
|
|
3
|
+
"version": "7.11.6",
|
|
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
|
@@ -8,11 +8,12 @@ import os
|
|
|
8
8
|
import re
|
|
9
9
|
import shlex
|
|
10
10
|
import sys
|
|
11
|
+
import time
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
import paths
|
|
13
14
|
|
|
14
15
|
from core_prompts import render_core_prompt
|
|
15
|
-
from db import create_protocol_debt, get_db
|
|
16
|
+
from db import create_protocol_debt, get_db, get_last_heartbeat_ts
|
|
16
17
|
from operator_language import append_operator_language_contract
|
|
17
18
|
from plugins.guard import _load_conditioned_learnings, _normalize_path_token
|
|
18
19
|
from protocol_settings import get_protocol_strictness
|
|
@@ -262,10 +263,13 @@ _PATH_ARTIFACT_RE = re.compile(
|
|
|
262
263
|
[\$\`] # unresolved shell substitution / backtick boundary
|
|
263
264
|
| [\*\?] # glob metacharacter
|
|
264
265
|
| [\[\]\{\}] # bracket/range/heredoc markers
|
|
266
|
+
| [\|\=\;] # regex fragments / shell assignment / command separators
|
|
265
267
|
| \s # embedded whitespace (most likely truncation)
|
|
266
268
|
""",
|
|
267
269
|
re.VERBOSE,
|
|
268
270
|
)
|
|
271
|
+
_DATE_LIKE_PATH_RE = re.compile(r"^/\d{1,4}/\d{1,4}(?:/\d{1,4})?$")
|
|
272
|
+
_STRICT_WRITE_HEARTBEAT_WINDOW_SECONDS = 300
|
|
269
273
|
|
|
270
274
|
# Single-segment ``/word`` candidates that match a small dictionary block-list
|
|
271
275
|
# of confirmed false positives observed in the live debt log.
|
|
@@ -303,6 +307,8 @@ def _looks_like_real_path(path: str) -> bool:
|
|
|
303
307
|
return False
|
|
304
308
|
if _PATH_ARTIFACT_RE.search(raw):
|
|
305
309
|
return False
|
|
310
|
+
if _DATE_LIKE_PATH_RE.fullmatch(raw):
|
|
311
|
+
return False
|
|
306
312
|
# Pure numeric segments (``/166``, ``/487``, ``/1000``) are almost
|
|
307
313
|
# always status codes or counters lifted out of a log line.
|
|
308
314
|
stripped = raw.lstrip("/")
|
|
@@ -321,9 +327,38 @@ def _looks_like_real_path(path: str) -> bool:
|
|
|
321
327
|
return False
|
|
322
328
|
except OSError:
|
|
323
329
|
return False
|
|
330
|
+
parts = [segment for segment in stripped.split("/") if segment]
|
|
331
|
+
if len(parts) > 1 and "." not in parts[-1]:
|
|
332
|
+
try:
|
|
333
|
+
if not Path(raw).exists():
|
|
334
|
+
return False
|
|
335
|
+
except OSError:
|
|
336
|
+
return False
|
|
324
337
|
return True
|
|
325
338
|
|
|
326
339
|
|
|
340
|
+
def _strict_write_without_task_severity(session_id: str) -> str:
|
|
341
|
+
"""Downgrade missing-task debt when the session is clearly alive.
|
|
342
|
+
|
|
343
|
+
A recent heartbeat shows the session is connected to a real ongoing
|
|
344
|
+
conversation even if the operator skipped `nexo_task_open`. We still
|
|
345
|
+
block strict writes, but store the debt as warn so dashboards separate
|
|
346
|
+
protocol drift from completely untracked edits.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
if not session_id:
|
|
350
|
+
return "error"
|
|
351
|
+
try:
|
|
352
|
+
last_hb = get_last_heartbeat_ts(session_id)
|
|
353
|
+
except Exception:
|
|
354
|
+
return "error"
|
|
355
|
+
if last_hb is None:
|
|
356
|
+
return "error"
|
|
357
|
+
if time.time() - float(last_hb) <= _STRICT_WRITE_HEARTBEAT_WINDOW_SECONDS:
|
|
358
|
+
return "warn"
|
|
359
|
+
return "error"
|
|
360
|
+
|
|
361
|
+
|
|
327
362
|
def _resolve_runtime_path(path: str) -> Path:
|
|
328
363
|
candidate = Path(str(path or "")).expanduser()
|
|
329
364
|
if not candidate.is_absolute():
|
|
@@ -1394,12 +1429,13 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
1394
1429
|
if not files:
|
|
1395
1430
|
task = _find_any_open_task(conn, sid)
|
|
1396
1431
|
if not task:
|
|
1432
|
+
severity = _strict_write_without_task_severity(sid)
|
|
1397
1433
|
debt = _ensure_protocol_debt(
|
|
1398
1434
|
conn,
|
|
1399
1435
|
session_id=sid,
|
|
1400
1436
|
task_id="",
|
|
1401
1437
|
debt_type="strict_protocol_write_without_task",
|
|
1402
|
-
severity=
|
|
1438
|
+
severity=severity,
|
|
1403
1439
|
evidence=f"{tool_name} attempted without a detectable file path and without an open protocol task.",
|
|
1404
1440
|
file_token="unknown-target",
|
|
1405
1441
|
)
|
|
@@ -1425,12 +1461,13 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
1425
1461
|
for filepath in files:
|
|
1426
1462
|
task = _find_open_task_for_file(conn, sid, filepath)
|
|
1427
1463
|
if not task:
|
|
1464
|
+
severity = _strict_write_without_task_severity(sid)
|
|
1428
1465
|
debt = _ensure_protocol_debt(
|
|
1429
1466
|
conn,
|
|
1430
1467
|
session_id=sid,
|
|
1431
1468
|
task_id="",
|
|
1432
1469
|
debt_type="strict_protocol_write_without_task",
|
|
1433
|
-
severity=
|
|
1470
|
+
severity=severity,
|
|
1434
1471
|
evidence=f"{tool_name} attempted on {filepath} without an open protocol task for that file.",
|
|
1435
1472
|
file_token=filepath,
|
|
1436
1473
|
)
|
|
@@ -68,6 +68,7 @@ TRANSIENT_ERROR_KINDS = {
|
|
|
68
68
|
"timeout",
|
|
69
69
|
"signal",
|
|
70
70
|
}
|
|
71
|
+
REQUIRED_PROTOCOL_SUMMARY_KEYS = ("guard_check", "heartbeat", "change_log")
|
|
71
72
|
|
|
72
73
|
|
|
73
74
|
def _classify_cli_result(result) -> tuple[str, str]:
|
|
@@ -133,6 +134,53 @@ def extract_json_from_response(text: str) -> dict | None:
|
|
|
133
134
|
return None
|
|
134
135
|
|
|
135
136
|
|
|
137
|
+
def _is_valid_extraction(
|
|
138
|
+
parsed: dict,
|
|
139
|
+
*,
|
|
140
|
+
expected_session_id: str | None = None,
|
|
141
|
+
) -> bool:
|
|
142
|
+
"""Validate the minimum Deep Sleep extraction contract.
|
|
143
|
+
|
|
144
|
+
The extractor prompt's real top-level shape is
|
|
145
|
+
``session_id/findings/protocol_summary`` plus optional richer sections.
|
|
146
|
+
We intentionally validate the live prompt contract rather than an older
|
|
147
|
+
proposal so a syntactically valid but structurally degraded JSON payload
|
|
148
|
+
does not silently count as success.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
if not isinstance(parsed, dict):
|
|
152
|
+
return False
|
|
153
|
+
session_id = parsed.get("session_id")
|
|
154
|
+
if not isinstance(session_id, str) or not session_id.strip():
|
|
155
|
+
return False
|
|
156
|
+
if expected_session_id and session_id != expected_session_id:
|
|
157
|
+
return False
|
|
158
|
+
findings = parsed.get("findings")
|
|
159
|
+
if not isinstance(findings, list):
|
|
160
|
+
return False
|
|
161
|
+
if any(not isinstance(item, dict) for item in findings):
|
|
162
|
+
return False
|
|
163
|
+
protocol_summary = parsed.get("protocol_summary")
|
|
164
|
+
if not isinstance(protocol_summary, dict):
|
|
165
|
+
return False
|
|
166
|
+
for key in REQUIRED_PROTOCOL_SUMMARY_KEYS:
|
|
167
|
+
if not isinstance(protocol_summary.get(key), dict):
|
|
168
|
+
return False
|
|
169
|
+
for key in ("emotional_timeline", "abandoned_projects", "skill_candidates"):
|
|
170
|
+
if key in parsed and not isinstance(parsed.get(key), list):
|
|
171
|
+
return False
|
|
172
|
+
if "productivity_score" in parsed and not isinstance(parsed.get("productivity_score"), dict):
|
|
173
|
+
return False
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _write_debug_extract(session_id: str, kind: str, raw_output: str) -> Path:
|
|
178
|
+
debug_file = _deep_sleep_dir() / f"debug-extract-{session_id[:20]}-{kind}.txt"
|
|
179
|
+
debug_file.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
debug_file.write_text((raw_output or "")[:5000])
|
|
181
|
+
return debug_file
|
|
182
|
+
|
|
183
|
+
|
|
136
184
|
def _safe_session_slug(session_id: str) -> str:
|
|
137
185
|
return (
|
|
138
186
|
session_id
|
|
@@ -215,6 +263,8 @@ def analyze_session(
|
|
|
215
263
|
if not line.strip().startswith("Post-mortem") and line.strip()
|
|
216
264
|
)
|
|
217
265
|
parsed = extract_json_from_response(output)
|
|
266
|
+
debug_output = output
|
|
267
|
+
parse_failure_kind = "json_parse"
|
|
218
268
|
|
|
219
269
|
# Fallback: if Claude returned text instead of JSON, ask a short conversion call
|
|
220
270
|
if not parsed and len(output.strip()) > 50:
|
|
@@ -231,17 +281,23 @@ def analyze_session(
|
|
|
231
281
|
append_system_prompt=json_system_prompt,
|
|
232
282
|
)
|
|
233
283
|
if convert_result.returncode == 0:
|
|
284
|
+
debug_output = convert_result.stdout
|
|
234
285
|
parsed = extract_json_from_response(convert_result.stdout)
|
|
235
286
|
if parsed:
|
|
236
287
|
print(f" Conversion succeeded")
|
|
237
288
|
|
|
289
|
+
if parsed and not _is_valid_extraction(parsed, expected_session_id=session_id):
|
|
290
|
+
parse_failure_kind = "json_schema"
|
|
291
|
+
debug_output = json.dumps(parsed, indent=2, ensure_ascii=False)
|
|
292
|
+
parsed = None
|
|
293
|
+
|
|
238
294
|
if not parsed:
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return None,
|
|
295
|
+
debug_file = _write_debug_extract(session_id, parse_failure_kind, debug_output)
|
|
296
|
+
print(
|
|
297
|
+
f" Failed to validate extraction ({parse_failure_kind}). Raw output saved to {debug_file}",
|
|
298
|
+
file=sys.stderr,
|
|
299
|
+
)
|
|
300
|
+
return None, parse_failure_kind
|
|
245
301
|
|
|
246
302
|
return parsed, None
|
|
247
303
|
|