nexo-brain 7.34.0 → 7.36.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.34.0",
3
+ "version": "7.36.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,7 @@
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.34.0` is the current packaged-runtime line. Minor release - Cognitive OS Ola 2: a working-memory `resolution_cache` fast-path avoids re-resolving what was just resolved (never-stale, fail-closed: content-snapshot + global watermark + 15-min TTP, repo-map for code), a later action that reveals a prior self-error auto-captures a learning + prevention, the associative graph (Personalized PageRank) connects the dots multi-hop over the KG at answer time (anti-hub, fail-open, per-process cache), Deep Sleep gains a nightly phase that safely merges duplicate learnings (reversible, zero hard-delete, fail-closed backup, daily cap), and a reproducible memory-recall eval bank (recall@k/MRR) lands with a baseline. Builds on v7.33.0 (semantic recall + graph-at-answer + reliability).
21
+ Version `7.36.0` is the current packaged-runtime line. Minor release - local index disk reclaim: the local file/code index (`local-context.db`) no longer grows without bound. It now uses `auto_vacuum=INCREMENTAL` plus a one-time guarded `VACUUM` to convert existing databases, stores embeddings as compact float32 BLOBs instead of JSON text (~4-6x smaller, back-compatible dual-write/dual-read with a resumable backfill and kill switches), reclaims disk on purge/clear, and the daily self-audit now actively compacts at its size cap (`NEXO_LOCAL_INDEX_MAX_BYTES`) instead of only warning. An established index reclaims ~10-20GB immediately and grows several-fold slower; the backup subsystem was audited and is already bounded. Builds on v7.35.0 (selective forget + recurring-incident diagnostic templates).
22
22
 
23
23
  Previously in `7.31.9`: patch release over v7.31.8 - UI release closeout now has to prove the original reported symptom was reopened with observable evidence before claiming the release is ready.
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.34.0",
3
+ "version": "7.36.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",
@@ -570,6 +570,21 @@ def build_pre_action_context(
570
570
  reminders = _find_related_items("reminders", clean_query, hours=hours, limit=4) if clean_query else []
571
571
  followups = _find_related_items("followups", clean_query, hours=hours, limit=4) if clean_query else []
572
572
 
573
+ # Ola 4 SCHEMA-ABSTRACTION: if the current action CLEARLY matches a distilled
574
+ # recurring-incident archetype (e.g. "cron exit 0 but the tool failed
575
+ # silently"), prime the complete diagnosis instead of re-diagnosing from
576
+ # scratch. Best-effort, non-authoritative, precision-first: a template is
577
+ # surfaced only on a clear archetype match, never in general, and it NEVER
578
+ # blocks. Any failure here must not break pre-action context.
579
+ diagnostic_templates: list[dict] = []
580
+ if clean_query:
581
+ try:
582
+ import schema_abstraction as sa
583
+
584
+ diagnostic_templates = sa.match_templates_for_action(query=clean_query, limit=1)
585
+ except Exception:
586
+ diagnostic_templates = []
587
+
573
588
  return {
574
589
  "query": clean_query,
575
590
  "context_key": clean_key,
@@ -578,7 +593,8 @@ def build_pre_action_context(
578
593
  "events": events,
579
594
  "reminders": reminders,
580
595
  "followups": followups,
581
- "has_matches": bool(contexts or events or reminders or followups),
596
+ "diagnostic_templates": diagnostic_templates,
597
+ "has_matches": bool(contexts or events or reminders or followups or diagnostic_templates),
582
598
  }
583
599
 
584
600
 
@@ -592,6 +608,19 @@ def format_pre_action_context_bundle(bundle: dict, *, compact: bool = False) ->
592
608
  header += f" — query: {bundle['query'][:120]}"
593
609
  lines.append(header)
594
610
 
611
+ # Ola 4: primed diagnosis from a matched recurring-incident archetype goes
612
+ # FIRST — the whole point is to lead with the right diagnosis.
613
+ templates = bundle.get("diagnostic_templates") or []
614
+ if templates:
615
+ try:
616
+ import schema_abstraction as sa
617
+
618
+ rendered = sa.format_templates_for_injection(templates)
619
+ if rendered:
620
+ lines.append(rendered)
621
+ except Exception:
622
+ pass
623
+
595
624
  contexts = bundle.get("contexts") or []
596
625
  if contexts:
597
626
  lines.append("Contexts:")
package/src/db/_schema.py CHANGED
@@ -2007,6 +2007,7 @@ def _m63_local_context_layer(conn):
2007
2007
  model_revision TEXT NOT NULL DEFAULT '',
2008
2008
  dimension INTEGER NOT NULL,
2009
2009
  vector_json TEXT NOT NULL,
2010
+ vector_blob BLOB,
2010
2011
  created_at REAL NOT NULL
2011
2012
  );
2012
2013
 
@@ -2714,6 +2715,68 @@ def _m75_failure_prevention_ledger(conn):
2714
2715
  _migrate_add_index(conn, "idx_antibody_actions_verification", "antibody_actions", "verification_status, review_due_at")
2715
2716
 
2716
2717
 
2718
+ def _m88_schema_abstraction_templates(conn):
2719
+ """Ola 4 — diagnostic templates distilled from recurring incident archetypes.
2720
+
2721
+ NOTE (append-only migration discipline): this used to be called inline from
2722
+ ``_m75_failure_prevention_ledger`` (as ``_m75b_...``). That meant any install
2723
+ already at schema v75 would NEVER create ``diagnostic_templates`` through
2724
+ ``run_migrations()`` (v75 was already marked applied), so the table only
2725
+ appeared via the lazy ``_ensure_tables`` fallback. Promoting it to its own
2726
+ appended migration version makes a normal upgrade from v75 create the table
2727
+ through the standard migration path. Idempotent (``IF NOT EXISTS``), so it is
2728
+ a no-op on installs where ``_ensure_tables``/the old inline call already
2729
+ created it.
2730
+
2731
+ A diagnostic template is the destillation of a GENUINELY recurring class of
2732
+ incident (>= MIN_CLUSTER_SIZE distinct failure cases of the same archetype,
2733
+ by symptom similarity) into a reusable, complete-diagnosis-first checklist
2734
+ that primes the right diagnosis instantly when the archetype reappears,
2735
+ instead of re-diagnosing from scratch (Francisco's canonical case: "cron
2736
+ exit 0 but the tool failed in SILENCE").
2737
+
2738
+ Non-authoritative guidance: templates never block; they only inject a primed
2739
+ diagnosis into pre_action_context on a clear archetype match. Idempotent:
2740
+ deduped by ``template_uid`` (stable hash of archetype key). A template is
2741
+ minted only at high confidence; ambiguity yields nothing (a low-confidence
2742
+ candidate, never an active template).
2743
+ """
2744
+ conn.execute(
2745
+ """
2746
+ CREATE TABLE IF NOT EXISTS diagnostic_templates (
2747
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2748
+ template_uid TEXT NOT NULL UNIQUE,
2749
+ policy_version TEXT NOT NULL DEFAULT 'schema_abstraction.v1',
2750
+ archetype TEXT NOT NULL,
2751
+ archetype_key TEXT NOT NULL,
2752
+ failure_type TEXT NOT NULL DEFAULT 'other',
2753
+ area TEXT NOT NULL DEFAULT '',
2754
+ symptom_pattern TEXT NOT NULL DEFAULT '',
2755
+ diagnosis_steps_json TEXT NOT NULL DEFAULT '[]',
2756
+ prevention TEXT NOT NULL DEFAULT '',
2757
+ match_tokens_json TEXT NOT NULL DEFAULT '[]',
2758
+ member_uids_json TEXT NOT NULL DEFAULT '[]',
2759
+ incident_count INTEGER NOT NULL DEFAULT 0,
2760
+ confidence REAL NOT NULL DEFAULT 0.0,
2761
+ status TEXT NOT NULL DEFAULT 'active',
2762
+ privacy_level TEXT NOT NULL DEFAULT 'normal',
2763
+ created_at REAL NOT NULL,
2764
+ updated_at REAL NOT NULL,
2765
+ retired_at REAL,
2766
+ retired_reason TEXT NOT NULL DEFAULT '',
2767
+ metadata_json TEXT NOT NULL DEFAULT '{}',
2768
+ CHECK(status IN ('active','candidate','retired','superseded')),
2769
+ CHECK(privacy_level IN ('public','normal','private','sensitive','secret')),
2770
+ CHECK(incident_count >= 0),
2771
+ CHECK(confidence >= 0.0 AND confidence <= 1.0)
2772
+ )
2773
+ """
2774
+ )
2775
+ _migrate_add_index(conn, "idx_diagnostic_templates_archetype", "diagnostic_templates", "archetype_key")
2776
+ _migrate_add_index(conn, "idx_diagnostic_templates_status", "diagnostic_templates", "status, area")
2777
+ _migrate_add_index(conn, "idx_diagnostic_templates_type", "diagnostic_templates", "failure_type, status")
2778
+
2779
+
2717
2780
  def _m76_semantic_layers(conn):
2718
2781
  """SemanticLayers cache for compact, source-backed continuity.
2719
2782
 
@@ -3402,6 +3465,7 @@ MIGRATIONS = [
3402
3465
  (85, "eval_runs", _m85_eval_runs),
3403
3466
  (86, "resolution_cache", _m86_resolution_cache),
3404
3467
  (87, "resolution_cache_content_snapshot", _m87_resolution_cache_content_snapshot),
3468
+ (88, "schema_abstraction_templates", _m88_schema_abstraction_templates),
3405
3469
  ]
3406
3470
 
3407
3471
 
@@ -146,6 +146,14 @@ def _prune_db_backups(deep_sleep_dir: Path, report: dict, *, keep: int, apply: b
146
146
  _record_delete(report, backup, reason=f"old-db-backup:{family}", apply=apply)
147
147
  for sidecar in _sidecars(backup):
148
148
  _record_delete(report, sidecar, reason=f"old-db-backup-sidecar:{family}", apply=apply)
149
+ # Orphan sweep: -wal/-shm sidecars whose base .db no longer exists (left by
150
+ # interrupted/legacy deep-sleep processes). The online-backup path produces
151
+ # sidecar-free snapshots, so any sidecar with a missing base is a true
152
+ # orphan. Scoped strictly to this deep-sleep backup dir; never the live DBs.
153
+ for sidecar in list(deep_sleep_dir.glob("*-backup-*.db-wal")) + list(deep_sleep_dir.glob("*-backup-*.db-shm")):
154
+ base = Path(str(sidecar)[: -len("-wal")]) if str(sidecar).endswith("-wal") else Path(str(sidecar)[: -len("-shm")])
155
+ if not base.exists():
156
+ _record_delete(report, sidecar, reason="orphan-db-sidecar", apply=apply)
149
157
 
150
158
 
151
159
  def _prune_contexts(deep_sleep_dir: Path, report: dict, *, keep: int, apply: bool) -> None:
@@ -8,6 +8,7 @@ sent even when the send path did not originate from an inbound email row.
8
8
  from __future__ import annotations
9
9
 
10
10
  import json
11
+ import os
11
12
  import sqlite3
12
13
  from datetime import datetime, timedelta
13
14
  from pathlib import Path
@@ -43,6 +44,12 @@ RECENT_SENT_EMAILS_TITLE = "EMAILS ENVIADOS ULTIMAS 24H POR LA OPERATIVA"
43
44
 
44
45
 
45
46
  def sent_email_db_path() -> Path:
47
+ # NEXO_EMAIL_DB lets tests (and the selective-forget live-DB enumerator)
48
+ # isolate / discover the email store deterministically without rewiring
49
+ # NEXO_HOME. Falls back to the canonical runtime location.
50
+ override = os.environ.get("NEXO_EMAIL_DB", "").strip()
51
+ if override:
52
+ return Path(override).expanduser()
46
53
  return paths.nexo_email_dir() / "nexo-email.db"
47
54
 
48
55
 
@@ -465,6 +465,7 @@ class HeadlessEnforcer:
465
465
  self.user_message_count = 0
466
466
  self.tool_timestamps: dict[str, float] = {}
467
467
  self.msg_since_tool: dict[str, int] = {}
468
+ self._tool_user_message_index: dict[str, int] = {}
468
469
  self.injection_queue: list[dict] = []
469
470
  self._started_at = time.time()
470
471
  self._injections_done = 0
@@ -551,6 +552,8 @@ class HeadlessEnforcer:
551
552
  # seen, periodic/conditional reminders stay suppressed so cron
552
553
  # runners can reach TURN_END instead of reopening the task loop.
553
554
  self._session_stopped: bool = False
555
+ self._first_visible_startup_gate_fired: bool = False
556
+ self._first_visible_text_allowed: bool = False
554
557
  try:
555
558
  self._post_close_cooldown_seconds = max(
556
559
  0,
@@ -1036,6 +1039,52 @@ class HeadlessEnforcer:
1036
1039
  except Exception:
1037
1040
  pass
1038
1041
 
1042
+ def should_block_first_visible_text(self) -> bool:
1043
+ """Fail closed before the first visible answer when startup context is missing."""
1044
+ if self._first_visible_text_allowed:
1045
+ return False
1046
+ if self.user_message_count <= 0:
1047
+ self._first_visible_text_allowed = True
1048
+ return False
1049
+
1050
+ current_turn = int(self.user_message_count or 0)
1051
+ has_startup = "nexo_startup" in self.tools_called
1052
+ continuity_tools = {
1053
+ "nexo_smart_startup",
1054
+ "nexo_session_diary_read",
1055
+ "nexo_reminders",
1056
+ "nexo_checkpoint_read",
1057
+ }
1058
+ has_continuity = bool(self.tools_called.intersection(continuity_tools))
1059
+ heartbeat_turn = max(
1060
+ self._tool_user_message_index.get("nexo_heartbeat", -1),
1061
+ self._tool_user_message_index.get("nexo_task_open", -1),
1062
+ )
1063
+ has_turn_heartbeat = heartbeat_turn >= current_turn
1064
+
1065
+ missing = []
1066
+ if not has_startup:
1067
+ missing.append("nexo_startup")
1068
+ if not has_continuity:
1069
+ missing.append("continuidad minima")
1070
+ if not has_turn_heartbeat:
1071
+ missing.append("nexo_heartbeat")
1072
+ if not missing:
1073
+ self._first_visible_text_allowed = True
1074
+ return False
1075
+ if self._first_visible_startup_gate_fired:
1076
+ return True
1077
+
1078
+ prompt = (
1079
+ "Before any visible answer, register the session, load minimal continuity, "
1080
+ "and associate the current user message with a heartbeat. Missing: "
1081
+ f"{', '.join(missing)}. Execute the required NEXO tool calls now. "
1082
+ "Do not produce visible text for this reminder."
1083
+ )
1084
+ self._enqueue(prompt, "first-visible-startup-heartbeat-gate", rule_id="R38_first_visible_startup_gate")
1085
+ self._first_visible_startup_gate_fired = True
1086
+ return True
1087
+
1039
1088
  def _check_capability_denial_requires_reality(self, text: str):
1040
1089
  """Block unsupported capability denials until a live source was checked."""
1041
1090
  if not text or not _CAPABILITY_DENIAL_RE.search(text):
@@ -2537,6 +2586,7 @@ class HeadlessEnforcer:
2537
2586
  self.tools_called.add(name)
2538
2587
  self.tool_timestamps[name] = time.time()
2539
2588
  self.msg_since_tool[name] = 0
2589
+ self._tool_user_message_index[name] = int(self.user_message_count or 0)
2540
2590
 
2541
2591
  # v7.6 conditional counter advance. Tools watched by a
2542
2592
  # conditional rule tick a counter on every non-matching call.
@@ -3346,6 +3396,14 @@ def run_with_enforcement(
3346
3396
  msg = event.get("message", {})
3347
3397
  for block in msg.get("content", []):
3348
3398
  if block.get("type") == "text":
3399
+ try:
3400
+ if enforcer.should_block_first_visible_text():
3401
+ item = enforcer.flush()
3402
+ if item:
3403
+ _inject(item["prompt"])
3404
+ return False
3405
+ except Exception as _startup_gate_exc: # noqa: BLE001
3406
+ _logger.warning("first visible startup gate failed: %s", _startup_gate_exc)
3349
3407
  collected_text.append(block["text"])
3350
3408
  # R16 — probe each assistant text block as it arrives
3351
3409
  # so a declared-done line is caught on the same turn
@@ -374,6 +374,118 @@ def _write_json(path: Path, payload: dict) -> None:
374
374
  tmp.replace(path)
375
375
 
376
376
 
377
+ def _pending_trace_path(sid: str) -> Path:
378
+ safe_sid = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (sid or "unknown"))
379
+ return _production_closeout_dir() / f"post-change-trace-{safe_sid}.json"
380
+
381
+
382
+ def _split_files(value: object) -> set[str]:
383
+ if value is None:
384
+ return set()
385
+ if isinstance(value, (list, tuple, set)):
386
+ raw = "\n".join(str(item) for item in value)
387
+ else:
388
+ raw = str(value)
389
+ parts = re.split(r"[\n,;]+", raw)
390
+ return {part.strip() for part in parts if part and part.strip()}
391
+
392
+
393
+ def _record_post_change_trace(payload: dict, sid: str) -> None:
394
+ if not sid:
395
+ sid = "unknown"
396
+ path = _pending_trace_path(sid)
397
+ trace = _read_json(path) or {
398
+ "sid": sid,
399
+ "touched_files": [],
400
+ "guard_files": [],
401
+ "change_log_files": [],
402
+ "production_mutation": False,
403
+ "created_at": time.time(),
404
+ }
405
+ tool_name = _tool_name(payload)
406
+ tool_input = _tool_input(payload)
407
+ cmd = _extract_command(payload)
408
+
409
+ touched = set(trace.get("touched_files") or [])
410
+ guards = set(trace.get("guard_files") or [])
411
+ logged = set(trace.get("change_log_files") or [])
412
+
413
+ if _is_shared_mutation_payload(payload):
414
+ touched.update(_split_files(tool_input.get("file_path")))
415
+ touched.update(_split_files(tool_input.get("path")))
416
+ touched.update(_split_files(tool_input.get("files")))
417
+ touched.update(_split_files(tool_input.get("paths")))
418
+ if cmd:
419
+ trace["last_mutation_command"] = cmd[:500]
420
+ if _is_production_mutation_command(cmd):
421
+ trace["production_mutation"] = True
422
+
423
+ if tool_name in {"nexo_guard_check", "mcp__nexo__nexo_guard_check"}:
424
+ guards.update(_split_files(tool_input.get("files")))
425
+
426
+ if _is_change_log_tool(tool_name):
427
+ logged.update(_split_files(tool_input.get("files")))
428
+ logged.update(_split_files(tool_input.get("files_changed")))
429
+ if not logged and touched:
430
+ logged.update(touched)
431
+
432
+ if _is_task_close_tool(tool_name):
433
+ touched.update(_split_files(tool_input.get("files_changed")))
434
+
435
+ trace["touched_files"] = sorted(touched)
436
+ trace["guard_files"] = sorted(guards)
437
+ trace["change_log_files"] = sorted(logged)
438
+ trace["updated_at"] = time.time()
439
+
440
+ if touched or guards or logged or trace.get("production_mutation"):
441
+ _write_json(path, trace)
442
+
443
+
444
+ def _missing_trace_items(payload: dict, sid: str) -> list[str]:
445
+ if not _is_task_close_tool(_tool_name(payload)):
446
+ return []
447
+ trace = _read_json(_pending_trace_path(sid or "unknown"))
448
+ if not trace:
449
+ return []
450
+ tool_input = _tool_input(payload)
451
+ touched = set(trace.get("touched_files") or [])
452
+ if not touched and not trace.get("production_mutation"):
453
+ return []
454
+ guards = set(trace.get("guard_files") or [])
455
+ logged = set(trace.get("change_log_files") or [])
456
+ closing_files = _split_files(tool_input.get("files_changed"))
457
+
458
+ missing = []
459
+ if touched and not guards:
460
+ missing.append("guardias ejecutados")
461
+ if trace.get("production_mutation") and not logged and not _task_close_payload_has_change_trace(payload):
462
+ missing.append("registro de cambios")
463
+ if touched and closing_files and not touched.issubset(closing_files):
464
+ missing.append("files_changed completo")
465
+ if touched and not closing_files:
466
+ missing.append("files_changed")
467
+ return missing
468
+
469
+
470
+ def check_post_change_trace_closeout(payload: dict, sid: str) -> str | None:
471
+ if not sid:
472
+ sid = "unknown"
473
+ _record_post_change_trace(payload, sid)
474
+ missing = _missing_trace_items(payload, sid)
475
+ if not missing:
476
+ if _is_task_close_tool(_tool_name(payload)):
477
+ _pending_trace_path(sid).unlink(missing_ok=True)
478
+ return None
479
+ trace = _read_json(_pending_trace_path(sid))
480
+ files = ", ".join((trace.get("touched_files") or [])[:6]) or "cambio detectado"
481
+ message = (
482
+ "Cierre bloqueado: antes de marcar completado hay que cuadrar archivos tocados, "
483
+ f"guardias y registro de cambios. Falta: {', '.join(missing)}. "
484
+ f"Archivos detectados: {files}."
485
+ )
486
+ return append_operator_language_contract(message)
487
+
488
+
377
489
  def check_production_change_log_closeout(payload: dict, sid: str) -> str | None:
378
490
  if not sid:
379
491
  sid = "unknown"
@@ -551,6 +663,7 @@ def main() -> int:
551
663
  sid = _resolve_sid_from_payload(payload)
552
664
  reminder = check_inbox_and_emit_reminder(sid)
553
665
  change_log_message = check_production_change_log_closeout(payload, sid)
666
+ post_change_trace_message = check_post_change_trace_closeout(payload, sid)
554
667
  shared_scope_message = check_shared_scope_closeout(payload)
555
668
  g1_message: str | None = None
556
669
  try:
@@ -562,6 +675,7 @@ def main() -> int:
562
675
  protocol_message,
563
676
  reminder,
564
677
  change_log_message,
678
+ post_change_trace_message,
565
679
  shared_scope_message,
566
680
  g1_message,
567
681
  )