nexo-brain 7.16.0 → 7.17.0

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.16.0",
3
+ "version": "7.17.0",
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.16.0` is the current packaged-runtime line. 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.
21
+ Version `7.17.0` is the current packaged-runtime line. 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. This closes the family of bugs where heuristic path matches in the prompt aborted email-monitor sessions, followup-runner cycles, Deep Sleep synth, and postmortem-consolidation. Also rolls in the directory-path hardening planned for 7.16.4.
22
+
23
+ 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.
24
+
25
+ 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.
22
26
 
23
27
  Previously in `7.15.2`: patch release over v7.15.1 - Brain treats normal Codex startup context reads of calibration and project atlas files as healthy bootstrap activity instead of conditioned-file drift.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.16.0",
3
+ "version": "7.17.0",
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",
@@ -405,15 +405,53 @@ def _runner_mutating_tools_allowed(allowed_tools: str) -> bool:
405
405
  return bool(parts & _MUTATING_TOOL_NAMES)
406
406
 
407
407
 
408
+ _RUNNER_GUARD_EXEC_INVOCATION_PATTERN = re.compile(
409
+ r"\b(?:python3?|python|node|nodejs|bash|sh|zsh|ruby|deno|perl|pwsh|powershell|"
410
+ r"npx|pnpm|yarn|uv|pipx|env)\b\s+"
411
+ r"(/[^\s'\"`<>]+|[A-Za-z]:\\[^\s'\"`<>]+)"
412
+ )
413
+
414
+
408
415
  def _extract_runner_guard_paths(prompt: str, cwd: Path) -> list[str]:
416
+ """Return paths that the prompt seems to instruct the agent to *edit*.
417
+
418
+ Paths that appear immediately after a known interpreter
419
+ (``python3 /path/to/tool.py ...``) are stripped out: those are
420
+ subprocess executions, not edits, and the runner guard must not treat
421
+ them as protected-file writes. Without this exclusion the email-monitor
422
+ prompt — which explicitly instructs the agent to invoke
423
+ ``~/.nexo/core/scripts/nexo-send-reply.py`` to deliver the reply —
424
+ triggers the ``runtime-core`` blocking rule on every email and the
425
+ session aborts with exit 2 before any reply is generated.
426
+ """
409
427
  found: set[str] = set()
410
428
  text = str(prompt or "")
429
+
430
+ exec_only_paths: set[str] = set()
431
+ for match in _RUNNER_GUARD_EXEC_INVOCATION_PATTERN.finditer(text):
432
+ candidate = match.group(1).rstrip(".,);:]")
433
+ if candidate:
434
+ exec_only_paths.add(candidate)
435
+ try:
436
+ exec_only_paths.add(str(Path(candidate).expanduser().resolve(strict=False)))
437
+ except Exception:
438
+ pass
439
+
411
440
  for match in re.findall(r"(?<![A-Za-z0-9_])(?:/[^\s'\"`<>]+|[A-Za-z]:\\[^\s'\"`<>]+)", text):
412
441
  cleaned = match.rstrip(".,);:]")
413
- if cleaned:
414
- found.add(cleaned)
442
+ # Drop trailing slashes — `/tmp/` and friends are directories, not
443
+ # edit targets. Without this the followup-runner prompt's mention
444
+ # of `/tmp/` reached `handle_guard_check`, which tried to `open()`
445
+ # the directory and crashed the whole pre-emptive guard.
446
+ cleaned = cleaned.rstrip("/")
447
+ if not cleaned or cleaned in exec_only_paths:
448
+ continue
449
+ found.add(cleaned)
415
450
  for match in re.findall(r"(?<![A-Za-z0-9_])(?:src|scripts|tests|docs|lib|renderer|app)/[A-Za-z0-9_./-]+\.[A-Za-z0-9]+", text):
416
- found.add(str((cwd / match.rstrip(".,);:]")).resolve()))
451
+ resolved = str((cwd / match.rstrip(".,);:]")).resolve())
452
+ if resolved in exec_only_paths:
453
+ continue
454
+ found.add(resolved)
417
455
  try:
418
456
  resolved_cwd = cwd.resolve()
419
457
  except Exception:
@@ -428,34 +466,64 @@ def _extract_runner_guard_paths(prompt: str, cwd: Path) -> list[str]:
428
466
 
429
467
 
430
468
  def _run_headless_runner_guard(*, caller: str, cwd: Path, prompt: str, allowed_tools: str) -> dict:
469
+ """Pre-emptive runner guard — observational, never blocking.
470
+
471
+ The pre-emptive guard scans the *prompt text* for paths the agent might
472
+ edit. That is heuristic: it cannot tell whether a path will actually be
473
+ written, read, executed, or just mentioned in passing. Treating the
474
+ learnings/blocking-rules surfaced by that heuristic as hard blockers
475
+ has caused a year's worth of operational pain — every time a learning's
476
+ ``applies_to`` happens to match a path the prompt mentions for any
477
+ reason, every cron, every email-monitor session, every Deep Sleep
478
+ synth aborts with exit 2.
479
+
480
+ The authoritative gate is the PreToolUse hook
481
+ (``hook_guardrails._collect_runtime_core_write_blocks`` and the
482
+ learning-aware blocks alongside it). That layer fires only when the
483
+ agent *actually attempts* a Write/Edit, with severity ``error``, and
484
+ prevents the protected mutation. The pre-emptive guard's job is to
485
+ surface relevant learnings/schemas to the agent up front so it can
486
+ reason about them, plus log to ``guard_checks`` for observability —
487
+ not to abort the run on a regex match.
488
+
489
+ Therefore this function always returns ``blocked=False``. Errors from
490
+ ``handle_guard_check`` are also non-blocking; we let the run start so
491
+ the PreToolUse layer can do its job, and we log the unavailability for
492
+ diagnostics.
493
+ """
431
494
  if not _runner_mutating_tools_allowed(allowed_tools):
432
495
  return {"blocked": False, "skipped": "read_only_tools"}
433
496
  guard_paths = _extract_runner_guard_paths(prompt, cwd)
434
497
  if not guard_paths:
435
498
  return {"blocked": False, "skipped": "no_explicit_paths"}
499
+ summary = ""
436
500
  try:
437
501
  runtime_root = str(NEXO_HOME)
438
502
  if runtime_root and runtime_root not in sys.path:
439
503
  sys.path.insert(0, runtime_root)
440
504
  from plugins.guard import handle_guard_check # type: ignore
441
505
 
506
+ # We still pass ``enforce_runtime_core_block="false"`` because that
507
+ # opt-out predates the advisory-only switch and other callers may
508
+ # still rely on it. With the advisory contract in place this flag
509
+ # is effectively redundant, but kept for back-compat.
442
510
  output = handle_guard_check(
443
511
  files=",".join(guard_paths),
444
512
  area=f"runner:{caller or 'headless'}",
445
513
  project_hint=f"headless runner caller={caller or 'unknown'} cwd={cwd}",
446
514
  include_schemas="true",
515
+ enforce_runtime_core_block="false",
447
516
  )
517
+ summary = str(output or "")
448
518
  except Exception as exc:
449
- return {
450
- "blocked": True,
451
- "summary": f"Runner guard unavailable: {exc}",
452
- "paths": guard_paths,
453
- }
454
- blocked = "BLOCKING RULES" in str(output or "")
519
+ # Guard unavailable is observational, not blocking. The PreToolUse
520
+ # hook will catch any actual protected write at execute time.
521
+ summary = f"Runner guard unavailable: {exc}"
455
522
  return {
456
- "blocked": blocked,
457
- "summary": str(output or ""),
523
+ "blocked": False,
524
+ "summary": summary,
458
525
  "paths": guard_paths,
526
+ "advisory": True,
459
527
  }
460
528
 
461
529
 
@@ -309,6 +309,7 @@ def handle_guard_check(
309
309
  area: str = "",
310
310
  project_hint: str = "",
311
311
  include_schemas: str = "true",
312
+ enforce_runtime_core_block: str = "true",
312
313
  ) -> str:
313
314
  """Check learnings relevant to files/area before editing. Call BEFORE any code change.
314
315
 
@@ -316,11 +317,21 @@ def handle_guard_check(
316
317
  files: Comma-separated file paths about to be edited
317
318
  area: System area (webapp, shopify, infrastructure, nexo-ops, etc.)
318
319
  include_schemas: Include DB table schemas if files touch database code (true/false)
320
+ enforce_runtime_core_block: When ``true`` (default) any path under
321
+ ``~/.nexo/core/`` adds a ``runtime-core`` blocking rule. When
322
+ ``false`` the caller is opting out because another guard layer
323
+ already protects those writes (the PreToolUse hook in
324
+ ``hook_guardrails._collect_runtime_core_write_blocks`` blocks any
325
+ actual Write/Edit on core paths with severity ``error``).
326
+ ``_run_headless_runner_guard`` passes ``false`` so that paths
327
+ mentioned in a prompt — e.g. invocations of helper scripts —
328
+ no longer abort the session pre-emptively.
319
329
  """
320
330
  conn = get_db()
321
331
  include_schemas_bool = include_schemas.lower() in ("true", "1", "yes")
322
332
  file_list = [f.strip() for f in files.split(",") if f.strip()] if files else []
323
333
  project_hint_active = bool(_project_hint_tokens(project_hint))
334
+ enforce_runtime_core_bool = str(enforce_runtime_core_block or "").lower() in ("true", "1", "yes")
324
335
 
325
336
  result = {
326
337
  "learnings": [],
@@ -335,18 +346,19 @@ def handle_guard_check(
335
346
  conditioned_blocking_seen = set()
336
347
  conditioned_by_file = _load_conditioned_learnings(conn, file_list) if file_list else {}
337
348
 
338
- runtime_core_hits = [filepath for filepath in file_list if _is_runtime_core_path(filepath)]
339
- for filepath in runtime_core_hits:
340
- result["blocking_rules"].append({
341
- "id": "runtime-core",
342
- "rule": (
343
- "Installed runtime core files are protected. Edit the source repo, validate there, "
344
- "then ship the change through release/update instead of mutating ~/.nexo/core directly."
345
- ),
346
- "repetitions": 0,
347
- "reason": "runtime_core_protected",
348
- "file": filepath,
349
- })
349
+ if enforce_runtime_core_bool:
350
+ runtime_core_hits = [filepath for filepath in file_list if _is_runtime_core_path(filepath)]
351
+ for filepath in runtime_core_hits:
352
+ result["blocking_rules"].append({
353
+ "id": "runtime-core",
354
+ "rule": (
355
+ "Installed runtime core files are protected. Edit the source repo, validate there, "
356
+ "then ship the change through release/update instead of mutating ~/.nexo/core directly."
357
+ ),
358
+ "repetitions": 0,
359
+ "reason": "runtime_core_protected",
360
+ "file": filepath,
361
+ })
350
362
 
351
363
  # 1. File-conditioned learnings — explicit applies_to guardrails for target files
352
364
  hit_ids = []
@@ -482,12 +494,24 @@ def handle_guard_check(
482
494
  all_tables = set()
483
495
  for filepath in file_list:
484
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
485
509
  with open(filepath, 'r', errors='ignore') as f:
486
510
  content = f.read()
487
511
  sql_keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE TABLE']
488
512
  if any(kw in content.upper() for kw in sql_keywords):
489
513
  all_tables.update(_extract_table_names(content))
490
- except (FileNotFoundError, PermissionError):
514
+ except (FileNotFoundError, PermissionError, IsADirectoryError, OSError):
491
515
  continue
492
516
 
493
517
  cache = _load_schema_cache()
@@ -30,7 +30,7 @@ import sqlite3
30
30
  import signal
31
31
  import time
32
32
  import uuid
33
- from datetime import datetime, timedelta
33
+ from datetime import datetime, timedelta, timezone
34
34
  from email.utils import parseaddr
35
35
  from pathlib import Path
36
36
  from logging.handlers import RotatingFileHandler
@@ -1269,6 +1269,8 @@ def _ensure_emails_table(conn):
1269
1269
  conn.execute("ALTER TABLE emails ADD COLUMN attempts INTEGER DEFAULT 0")
1270
1270
  if "error" not in cols:
1271
1271
  conn.execute("ALTER TABLE emails ADD COLUMN error TEXT")
1272
+ if "escalation_notified_at" not in cols:
1273
+ conn.execute("ALTER TABLE emails ADD COLUMN escalation_notified_at TEXT")
1272
1274
 
1273
1275
 
1274
1276
  def _email_table_columns(conn):
@@ -2356,9 +2358,61 @@ def _mark_needs_interactive(email_ids):
2356
2358
  log.warning(f"Failed to mark needs_interactive: {e}")
2357
2359
 
2358
2360
 
2361
+ def _filter_already_notified(message_ids):
2362
+ """Return the subset of message_ids that have NOT been escalated yet
2363
+ (i.e. emails.escalation_notified_at IS NULL). Idempotent: if the column
2364
+ is missing for any reason, no row is filtered out."""
2365
+ if not message_ids:
2366
+ return []
2367
+ try:
2368
+ conn = sqlite3.connect(str(EMAIL_DB_PATH))
2369
+ try:
2370
+ _ensure_emails_table(conn)
2371
+ placeholders = ",".join("?" for _ in message_ids)
2372
+ rows = conn.execute(
2373
+ f"""
2374
+ SELECT message_id FROM emails
2375
+ WHERE message_id IN ({placeholders})
2376
+ AND escalation_notified_at IS NULL
2377
+ """,
2378
+ tuple(message_ids),
2379
+ ).fetchall()
2380
+ return [r[0] for r in rows]
2381
+ finally:
2382
+ conn.close()
2383
+ except Exception as e:
2384
+ log.warning(f"escalation_notified_at filter failed, falling through: {e}")
2385
+ return list(message_ids)
2386
+
2387
+
2388
+ def _mark_escalation_notified(message_ids):
2389
+ """Stamp emails.escalation_notified_at = now() so we never re-notify
2390
+ the operator about the same exhausted email."""
2391
+ if not message_ids:
2392
+ return
2393
+ try:
2394
+ conn = sqlite3.connect(str(EMAIL_DB_PATH))
2395
+ try:
2396
+ _ensure_emails_table(conn)
2397
+ now_iso = datetime.now(timezone.utc).isoformat()
2398
+ for mid in message_ids:
2399
+ conn.execute(
2400
+ "UPDATE emails SET escalation_notified_at = ? WHERE message_id = ?",
2401
+ (now_iso, mid),
2402
+ )
2403
+ conn.commit()
2404
+ finally:
2405
+ conn.close()
2406
+ except Exception as e:
2407
+ log.warning(f"Failed to stamp escalation_notified_at: {e}")
2408
+
2409
+
2359
2410
  def _escalate_exhausted_emails(config, batch):
2360
2411
  """After all retries exhausted, directly escalate emails with attempts >= MAX
2361
- by marking them needs_interactive and sending email to operator via nexo-send-reply.py."""
2412
+ by marking them needs_interactive and sending email to operator via nexo-send-reply.py.
2413
+
2414
+ Deduplicated by emails.escalation_notified_at: if an email is already in
2415
+ needs_interactive state from a previous run, we do not re-notify the operator."""
2362
2416
  exhausted = [e for e in batch if (e.get("attempts", 0) + 1) >= MAX_EMAIL_ATTEMPTS]
2363
2417
  if not exhausted:
2364
2418
  return
@@ -2366,6 +2420,19 @@ def _escalate_exhausted_emails(config, batch):
2366
2420
  _mark_needs_interactive(ids)
2367
2421
  log.info(f"Marked {len(ids)} email(s) as needs_interactive after exhausting retries")
2368
2422
 
2423
+ pending_notify_ids = set(_filter_already_notified(ids))
2424
+ if not pending_notify_ids:
2425
+ log.info(
2426
+ f"All {len(ids)} exhausted email(s) already escalated to operator earlier — skipping duplicate notification."
2427
+ )
2428
+ return
2429
+ skipped = len(ids) - len(pending_notify_ids)
2430
+ if skipped:
2431
+ log.info(
2432
+ f"Escalation dedup: {skipped} email(s) already notified, will only escalate {len(pending_notify_ids)} new one(s)."
2433
+ )
2434
+ exhausted = [e for e in exhausted if e["message_id"] in pending_notify_ids]
2435
+
2369
2436
  operator_name, assistant_name, operator_language = _get_operator_info()
2370
2437
  operator_email = config.get("operator_email", "")
2371
2438
  if not operator_email:
@@ -2387,8 +2454,9 @@ def _escalate_exhausted_emails(config, batch):
2387
2454
  body_file.write_text(body, encoding="utf-8")
2388
2455
 
2389
2456
  send_script = get_send_reply_script_path(local_script_dir=_script_dir)
2457
+ send_ok = False
2390
2458
  try:
2391
- subprocess.run(
2459
+ result = subprocess.run(
2392
2460
  [
2393
2461
  sys.executable, str(send_script),
2394
2462
  "--to", f"{operator_name} <{operator_email}>",
@@ -2398,12 +2466,22 @@ def _escalate_exhausted_emails(config, batch):
2398
2466
  timeout=30,
2399
2467
  capture_output=True,
2400
2468
  )
2401
- log.info(f"Escalation email sent to {operator_email}")
2469
+ send_ok = (result.returncode == 0)
2470
+ if send_ok:
2471
+ log.info(f"Escalation email sent to {operator_email}")
2472
+ else:
2473
+ log.warning(
2474
+ f"Escalation send returned exit={result.returncode}; "
2475
+ f"stderr={(result.stderr or b'').decode('utf-8', 'replace')[:200]}"
2476
+ )
2402
2477
  except Exception as e:
2403
2478
  log.warning(f"Failed to send escalation email: {e}")
2404
2479
  finally:
2405
2480
  body_file.unlink(missing_ok=True)
2406
2481
 
2482
+ if send_ok:
2483
+ _mark_escalation_notified(list(pending_notify_ids))
2484
+
2407
2485
 
2408
2486
  def main():
2409
2487
  log.info("=== Monitor check ===")