nexo-brain 7.33.0 → 7.35.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.33.0",
3
+ "version": "7.35.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.33.0` is the current packaged-runtime line. Minor release - Cognitive OS Ola 1 (phase 2): memory search now finds by MEANING (observation embeddings + FTS/vector fusion), the KG/causal graph is read at answer time (kg_neighbors pre-answer source), local files are recalled via FTS5, nightly learning consolidation no longer times out, correction capture is reliable (soft), and the followup runner uses an atomic lock. Builds on v7.32.0 (causal-graph populate + workflow reaper + the 7.31.14 critical fixes).
21
+ Version `7.35.0` is the current packaged-runtime line. Minor release - Cognitive OS Ola 4: selective forget lets you delete a leaked secret or a wrong memory and prove it is gone (zeroed across every live store, `secure_delete=ON`) or correct a fact reversibly, recurring failure archetypes are distilled into reusable diagnostic templates primed before a matching action (strong/weak marker tiers so benign success phrasing never triggers them, guidance-only), and closing a local-only followup-runner is no longer mis-flagged as an external real-world action. Builds on v7.34.0 (working memory + self-error learning + associative graph + deep-sleep rewrite + evals).
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.33.0",
3
+ "version": "7.35.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",
@@ -168,7 +168,7 @@ from db._entities import (
168
168
  # Episodic memory
169
169
  from db._episodic import (
170
170
  cleanup_old_changes, change_log_retention_days, change_log_retention_policy,
171
- log_change, search_changes, update_change_commit, auto_resolve_followups,
171
+ log_change, search_changes, get_change_watermark, update_change_commit, auto_resolve_followups,
172
172
  cleanup_old_decisions, log_decision, update_decision_outcome,
173
173
  get_memory_review_queue, find_decisions_by_context_ref, search_decisions,
174
174
  cleanup_old_diaries, write_session_diary,
@@ -197,6 +197,7 @@ from db._protocol import (
197
197
  VALID_TASK_TYPES,
198
198
  VALID_CLOSE_OUTCOMES,
199
199
  create_protocol_task, get_protocol_task, close_protocol_task,
200
+ list_recent_closed_tasks,
200
201
  set_protocol_task_guard_acknowledged,
201
202
  create_protocol_debt, resolve_protocol_debts, list_protocol_debts,
202
203
  record_session_correction_requirement, list_session_correction_requirements,
@@ -93,6 +93,38 @@ def search_changes(query: str = '', files: str = '', days: int = 30) -> list[dic
93
93
  return [dict(r) for r in rows]
94
94
 
95
95
 
96
+ def get_change_watermark(sid: str | None = None) -> int:
97
+ """Cheap monotonic integer that rises whenever a relevant mutation lands.
98
+
99
+ Used by the resolution cache (working memory) as the "nothing changed"
100
+ invalidation signal — Francisco's third rule. ``change_log`` is the ledger
101
+ where the PostToolUse hook records every code/config/state mutation, so
102
+ ``MAX(id)`` is a one-SELECT, monotonic, append-only proxy for "did anything
103
+ change since I cached this answer?". If the watermark advanced, the cache
104
+ is invalidated by conservatism (prefer recomputing over serving stale).
105
+
106
+ ``sid`` optionally narrows the watermark to a single session's mutations.
107
+ Returns 0 when the ledger is empty or unavailable (which a fresh cache
108
+ entry will also store, so an empty ledger never spuriously invalidates).
109
+ """
110
+ try:
111
+ conn = get_db()
112
+ if sid:
113
+ row = conn.execute(
114
+ "SELECT MAX(id) FROM change_log WHERE session_id = ?", (str(sid),)
115
+ ).fetchone()
116
+ else:
117
+ row = conn.execute("SELECT MAX(id) FROM change_log").fetchone()
118
+ except Exception:
119
+ return 0
120
+ if not row or row[0] is None:
121
+ return 0
122
+ try:
123
+ return int(row[0])
124
+ except (TypeError, ValueError):
125
+ return 0
126
+
127
+
96
128
  def auto_resolve_followups(change: dict) -> list[str]:
97
129
  """Cross-reference a change_log entry with open followups. Auto-completes matches.
98
130
 
@@ -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:")
@@ -436,6 +436,41 @@ def close_protocol_task(
436
436
  return get_protocol_task(task_id) or {}
437
437
 
438
438
 
439
+ def list_recent_closed_tasks(
440
+ *,
441
+ outcome: str = "done",
442
+ exclude_task_id: str = "",
443
+ limit: int = 200,
444
+ within_days: int = 0,
445
+ ) -> list[dict]:
446
+ """Return recently CLOSED protocol tasks for self-error detection.
447
+
448
+ Read-only. Ordered most-recent-first by ``closed_at``. The self-error
449
+ detector compares the just-closed task against these prior closures to
450
+ spot a later action that corrects something a previous task already
451
+ claimed as ``done``. Kept deliberately narrow (status filter + small
452
+ limit) so it never scans the whole history on every close.
453
+ """
454
+ conn = get_db()
455
+ clauses = ["status = ?", "closed_at IS NOT NULL"]
456
+ params: list[object] = [str(outcome).strip() or "done"]
457
+ if exclude_task_id:
458
+ clauses.append("task_id != ?")
459
+ params.append(exclude_task_id.strip())
460
+ if within_days and within_days > 0:
461
+ clauses.append("closed_at >= datetime('now', ?)")
462
+ params.append(f"-{int(within_days)} days")
463
+ where = " AND ".join(clauses)
464
+ rows = conn.execute(
465
+ f"""SELECT * FROM protocol_tasks
466
+ WHERE {where}
467
+ ORDER BY closed_at DESC
468
+ LIMIT ?""",
469
+ (*params, max(1, int(limit))),
470
+ ).fetchall()
471
+ return [dict(row) for row in rows]
472
+
473
+
439
474
  def create_protocol_debt(
440
475
  session_id: str,
441
476
  debt_type: str,
package/src/db/_schema.py CHANGED
@@ -2714,6 +2714,68 @@ def _m75_failure_prevention_ledger(conn):
2714
2714
  _migrate_add_index(conn, "idx_antibody_actions_verification", "antibody_actions", "verification_status, review_due_at")
2715
2715
 
2716
2716
 
2717
+ def _m88_schema_abstraction_templates(conn):
2718
+ """Ola 4 — diagnostic templates distilled from recurring incident archetypes.
2719
+
2720
+ NOTE (append-only migration discipline): this used to be called inline from
2721
+ ``_m75_failure_prevention_ledger`` (as ``_m75b_...``). That meant any install
2722
+ already at schema v75 would NEVER create ``diagnostic_templates`` through
2723
+ ``run_migrations()`` (v75 was already marked applied), so the table only
2724
+ appeared via the lazy ``_ensure_tables`` fallback. Promoting it to its own
2725
+ appended migration version makes a normal upgrade from v75 create the table
2726
+ through the standard migration path. Idempotent (``IF NOT EXISTS``), so it is
2727
+ a no-op on installs where ``_ensure_tables``/the old inline call already
2728
+ created it.
2729
+
2730
+ A diagnostic template is the destillation of a GENUINELY recurring class of
2731
+ incident (>= MIN_CLUSTER_SIZE distinct failure cases of the same archetype,
2732
+ by symptom similarity) into a reusable, complete-diagnosis-first checklist
2733
+ that primes the right diagnosis instantly when the archetype reappears,
2734
+ instead of re-diagnosing from scratch (Francisco's canonical case: "cron
2735
+ exit 0 but the tool failed in SILENCE").
2736
+
2737
+ Non-authoritative guidance: templates never block; they only inject a primed
2738
+ diagnosis into pre_action_context on a clear archetype match. Idempotent:
2739
+ deduped by ``template_uid`` (stable hash of archetype key). A template is
2740
+ minted only at high confidence; ambiguity yields nothing (a low-confidence
2741
+ candidate, never an active template).
2742
+ """
2743
+ conn.execute(
2744
+ """
2745
+ CREATE TABLE IF NOT EXISTS diagnostic_templates (
2746
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2747
+ template_uid TEXT NOT NULL UNIQUE,
2748
+ policy_version TEXT NOT NULL DEFAULT 'schema_abstraction.v1',
2749
+ archetype TEXT NOT NULL,
2750
+ archetype_key TEXT NOT NULL,
2751
+ failure_type TEXT NOT NULL DEFAULT 'other',
2752
+ area TEXT NOT NULL DEFAULT '',
2753
+ symptom_pattern TEXT NOT NULL DEFAULT '',
2754
+ diagnosis_steps_json TEXT NOT NULL DEFAULT '[]',
2755
+ prevention TEXT NOT NULL DEFAULT '',
2756
+ match_tokens_json TEXT NOT NULL DEFAULT '[]',
2757
+ member_uids_json TEXT NOT NULL DEFAULT '[]',
2758
+ incident_count INTEGER NOT NULL DEFAULT 0,
2759
+ confidence REAL NOT NULL DEFAULT 0.0,
2760
+ status TEXT NOT NULL DEFAULT 'active',
2761
+ privacy_level TEXT NOT NULL DEFAULT 'normal',
2762
+ created_at REAL NOT NULL,
2763
+ updated_at REAL NOT NULL,
2764
+ retired_at REAL,
2765
+ retired_reason TEXT NOT NULL DEFAULT '',
2766
+ metadata_json TEXT NOT NULL DEFAULT '{}',
2767
+ CHECK(status IN ('active','candidate','retired','superseded')),
2768
+ CHECK(privacy_level IN ('public','normal','private','sensitive','secret')),
2769
+ CHECK(incident_count >= 0),
2770
+ CHECK(confidence >= 0.0 AND confidence <= 1.0)
2771
+ )
2772
+ """
2773
+ )
2774
+ _migrate_add_index(conn, "idx_diagnostic_templates_archetype", "diagnostic_templates", "archetype_key")
2775
+ _migrate_add_index(conn, "idx_diagnostic_templates_status", "diagnostic_templates", "status, area")
2776
+ _migrate_add_index(conn, "idx_diagnostic_templates_type", "diagnostic_templates", "failure_type, status")
2777
+
2778
+
2717
2779
  def _m76_semantic_layers(conn):
2718
2780
  """SemanticLayers cache for compact, source-backed continuity.
2719
2781
 
@@ -3213,6 +3275,108 @@ def _m83_observation_embeddings(conn):
3213
3275
  conn.commit()
3214
3276
 
3215
3277
 
3278
+ def _m85_eval_runs(conn):
3279
+ """Time series for the memory eval bench (recall@k / MRR / semantic gain).
3280
+
3281
+ One row per (run, metric) so the table is queryable as a series: the
3282
+ before/after delta of an Ola 1 change is just two rows for the same metric
3283
+ with different ``ola1_enabled``. ``model_warm`` distinguishes numbers from
3284
+ the real embedding model (1) vs the deterministic offline fallback (0), so
3285
+ a CI run (fallback, pipeline check) is never confused with a Deep Sleep run
3286
+ (real model, semantic quality). Append-only and additive — re-running the
3287
+ harness inserts new rows, never mutates old ones.
3288
+
3289
+ Mirrors the existing _m28_automation_runs / _m34_cortex_evaluations shape.
3290
+ """
3291
+ conn.execute(
3292
+ """
3293
+ CREATE TABLE IF NOT EXISTS eval_runs (
3294
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3295
+ suite TEXT NOT NULL DEFAULT '',
3296
+ case_set_id TEXT DEFAULT '',
3297
+ case_set_version TEXT DEFAULT '',
3298
+ fixture_hash TEXT DEFAULT '',
3299
+ metric TEXT NOT NULL DEFAULT '',
3300
+ value REAL NOT NULL DEFAULT 0.0,
3301
+ ola1_enabled INTEGER NOT NULL DEFAULT 1,
3302
+ model_warm INTEGER NOT NULL DEFAULT 0,
3303
+ created_at TEXT DEFAULT (datetime('now'))
3304
+ )
3305
+ """
3306
+ )
3307
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_eval_runs_suite ON eval_runs(suite)")
3308
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_eval_runs_metric ON eval_runs(suite, metric)")
3309
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_eval_runs_created ON eval_runs(created_at)")
3310
+ conn.commit()
3311
+
3312
+
3313
+ def _m86_resolution_cache(conn):
3314
+ """Working-memory / resolution cache for the pre-answer router and repo maps.
3315
+
3316
+ Non-authoritative, like semantic_layers (_m76): the canonical facts still
3317
+ live in diary/workflows/tasks/evidence/memory/learnings/change_log and in
3318
+ the git repos themselves. This table only caches the FINAL organized
3319
+ result of a retrieval (a ``PreAnswerRoute.to_dict()`` for ``kind='route'``)
3320
+ or a lightweight repo snapshot (``kind='repo_map'``), keyed by a
3321
+ deterministic ``cache_key`` (route_cache_key | ``repo:{project_key}``).
3322
+
3323
+ The anti-stale contract (Francisco's rule of gold) lives in the read path
3324
+ (``resolution_cache.is_valid``): a HIT is only valid when ALL hold —
3325
+ (1) now() < expires_at, (2) status=='fresh',
3326
+ (3) source_fingerprint recomputed == stored, (4) change_watermark global ==
3327
+ stored. The columns below exist to support exactly that check. The
3328
+ ``instant`` tier (ttl=0) never writes here.
3329
+ """
3330
+ conn.execute(
3331
+ """
3332
+ CREATE TABLE IF NOT EXISTS resolution_cache (
3333
+ cache_key TEXT PRIMARY KEY,
3334
+ kind TEXT NOT NULL DEFAULT 'route',
3335
+ intent TEXT NOT NULL DEFAULT '',
3336
+ area TEXT NOT NULL DEFAULT '',
3337
+ sid TEXT NOT NULL DEFAULT '',
3338
+ result_json TEXT NOT NULL,
3339
+ source_fingerprint TEXT NOT NULL,
3340
+ source_refs_json TEXT NOT NULL DEFAULT '[]',
3341
+ change_watermark INTEGER NOT NULL DEFAULT 0,
3342
+ status TEXT NOT NULL DEFAULT 'fresh',
3343
+ policy_version TEXT NOT NULL DEFAULT '',
3344
+ resolved_at REAL NOT NULL,
3345
+ expires_at REAL NOT NULL DEFAULT 0,
3346
+ hit_count INTEGER NOT NULL DEFAULT 0,
3347
+ CHECK(kind IN ('route', 'repo_map')),
3348
+ CHECK(status IN ('fresh', 'stale', 'expired', 'invalid'))
3349
+ )
3350
+ """
3351
+ )
3352
+ _migrate_add_index(conn, "idx_resolution_cache_status_exp", "resolution_cache", "status, expires_at")
3353
+ _migrate_add_index(conn, "idx_resolution_cache_kind", "resolution_cache", "kind, sid")
3354
+ _migrate_add_index(conn, "idx_resolution_cache_fingerprint", "resolution_cache", "source_fingerprint")
3355
+ conn.commit()
3356
+
3357
+
3358
+ def _m87_resolution_cache_content_snapshot(conn):
3359
+ """Per-row content snapshot for the resolution cache's anti-stale check.
3360
+
3361
+ The fingerprint (``source_fingerprint``) is a single opaque digest over the
3362
+ versions of the consulted refs. It proved the AGGREGATE changed but could
3363
+ not by itself say WHICH ref moved, and — more importantly — it relied on
3364
+ ``semantic_layers.source_version_for`` keyed by CANONICAL prefixes
3365
+ (``followup:``), while the pre-answer router emits its own SOURCE-NAME refs
3366
+ (``followups:``). Those source-name refs resolved to an ``unsupported``
3367
+ namespace → empty version → an inert fingerprint, so a followup completed by
3368
+ a plain UPDATE (no change_log write → watermark unmoved) was served stale.
3369
+
3370
+ This column stores an explicit ``{ref: version}`` map captured from the REAL
3371
+ rows at write time (``resolution_cache.row_version_snapshot``). On read we
3372
+ re-read those same rows by id and compare — the snapshot is now the PRIMARY
3373
+ freshness guarantee; TTL and the global watermark remain cheap fast-fails.
3374
+ Idempotent, append-only ALTER (non-destructive).
3375
+ """
3376
+ _migrate_add_column(conn, "resolution_cache", "content_snapshot_json", "TEXT NOT NULL DEFAULT '{}'")
3377
+ conn.commit()
3378
+
3379
+
3216
3380
  MIGRATIONS = [
3217
3381
  (1, "learnings_columns", _m1_learnings_columns),
3218
3382
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -3297,6 +3461,10 @@ MIGRATIONS = [
3297
3461
  (81, "core_rules_product_metadata", _m81_core_rules_product_metadata),
3298
3462
  (82, "confidence_checks", _m82_confidence_checks),
3299
3463
  (83, "observation_embeddings", _m83_observation_embeddings),
3464
+ (85, "eval_runs", _m85_eval_runs),
3465
+ (86, "resolution_cache", _m86_resolution_cache),
3466
+ (87, "resolution_cache_content_snapshot", _m87_resolution_cache_content_snapshot),
3467
+ (88, "schema_abstraction_templates", _m88_schema_abstraction_templates),
3300
3468
  ]
3301
3469
 
3302
3470
 
@@ -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
 
@@ -352,6 +352,7 @@ def record_router_usage(
352
352
  elapsed_ms: int | None = None,
353
353
  deadline_ms: int | None = None,
354
354
  used_before_response: bool = True,
355
+ cache_hit: bool = False,
355
356
  db_path: str | os.PathLike[str] | None = None,
356
357
  ) -> dict[str, Any]:
357
358
  evidence_refs = router_payload.get("evidence_refs") or []
@@ -374,6 +375,7 @@ def record_router_usage(
374
375
  "escalated_from": router_payload.get("escalated_from") or budget_policy.get("escalated_from") or "",
375
376
  "escalated_to": router_payload.get("escalated_to") or budget_policy.get("escalated_to") or "",
376
377
  "route_cache_key": budget_policy.get("route_cache_key") or "",
378
+ "cache_hit": bool(cache_hit or router_payload.get("cache_hit")),
377
379
  "max_sources": budget_policy.get("max_sources") or 0,
378
380
  "max_source_timeout_ms": budget_policy.get("max_source_timeout_ms") or 0,
379
381
  "allowed_sources": budget_policy.get("allowed_sources") or [],