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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.11.5",
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.5` is the current packaged-runtime line. 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`).
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.5",
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",
@@ -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="error",
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="error",
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
- # Save raw output for debugging
240
- debug_file = _deep_sleep_dir() / f"debug-extract-{session_id[:20]}.txt"
241
- debug_file.parent.mkdir(parents=True, exist_ok=True)
242
- debug_file.write_text(result.stdout[:5000])
243
- print(f" Failed to parse JSON. Raw output saved to {debug_file}", file=sys.stderr)
244
- return None, "json_parse"
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