nexo-brain 6.1.1 → 6.4.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": "6.1.1",
3
+ "version": "6.4.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,13 @@
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 `6.1.1` is the current packaged-runtime line: small fix to `nexo --help` so the `Latest: vX` line reliably appears when NEXO Desktop invokes the CLI via subprocess unblocks the Desktop Brain auto-update banner that previously couldn't parse the version delta. No behaviour change for interactive terminal users; the 6-hour registry cache still rate-limits network calls. Bundles all v6.1.0 Protocol Enforcer Fase 2 + multi-claude-sid hotfix content. Suite: 291 pass + 2 skip documented.
21
+ Version `6.4.0` is the current packaged-runtime line Plan Consolidado fase F1: multi-tenant email accounts (`email_accounts` table, `nexo email setup` interactive wizard, `nexo email add --password-stdin --json` for the Desktop bridge, idempotent migrator from legacy `~/.nexo/nexo-email/config.json`). The matching NEXO Desktop release (v0.19.0) ships the Email + Automations Settings panels so non-technical operators never have to touch a config file. See [CHANGELOG](CHANGELOG.md) for the full diff.
22
+
23
+ Previously in `6.3.1`: privacy hotfix over v6.3.0. The nightly auditor caught that `src/presets/entities_universal.json` in v6.3.0 shipped operator-specific `vhost_mapping` entries (private IPs, hostnames, tenant names). v6.3.1 pulls those out into `src/presets/entities_local.sample.json` (template) + `.gitignore`'d `~/.nexo/brain/presets/entities_local.json` (operator copy), and the installer drops the sample at `nexo init`. No behaviour change on the Guardian side.
24
+
25
+ Previously in `6.3.0` — Plan Consolidado wave 2, coordinated with NEXO Desktop v0.18.0. Closes the remaining Guardian roadmap items that do not require an invasive structure migration: extended `cognitive_sentiment` shape (is_correction/valence/intent), extended `entities` schema, 21 labelled rule fixtures with R13 spike gates, Fase F telemetry loops + Deep Sleep phase, pinned local zero-shot classifier skeleton (mDeBERTa), hook respects `NEXO_MIGRATING=1`, `origin` column on `personal_scripts`, and the T4 LLM gate wrapping R15/R23e/R23f/R23h (byte-parity Py ↔ JS). Two pre-release auditors flagged a CRITICAL in the first JS wire (method-name + async mismatch) and a HIGH (classifier bool conflated "no" with "unparseable"); both corrected with regression tests before merge.
26
+
27
+ Previously in `6.1.1`: small fix to `nexo --help` so the `Latest: vX` line reliably appears when NEXO Desktop invokes the CLI via subprocess — unblocks the Desktop Brain auto-update banner that previously couldn't parse the version delta. No behaviour change for interactive terminal users; the 6-hour registry cache still rate-limits network calls. Bundles all v6.1.0 Protocol Enforcer Fase 2 + multi-claude-sid hotfix content.
22
28
 
23
29
  Previously in `6.0.2`: adds the reserved caller prefix `personal/*` so scripts living in `~/.nexo/scripts/` can invoke the automation backend with their own caller id without editing `src/resonance_map.py`. New kwarg `tier` (`"maximo"` / `"alto"` / `"medio"` / `"bajo"`) on `run_automation_prompt`, `run_automation_interactive`, `nexo_helper.run_automation_text`, `nexo_helper.run_automation_json`, and `nexo-agent-run.py --tier`. Precedence for `personal/*` callers: explicit `tier=` → explicit `reasoning_effort=` → `calibration.preferences.default_resonance` → `DEFAULT_RESONANCE` (`alto`). Registered callers keep their behaviour unchanged. New guide: [`docs/personal-scripts-guide.md`](docs/personal-scripts-guide.md).
24
30
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "6.1.1",
3
+ "version": "6.4.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
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.",
5
+ "description": "NEXO Brain \u2014 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",
7
7
  "bin": {
8
8
  "nexo-brain": "./bin/nexo-brain.js",
@@ -1519,12 +1519,56 @@ def _run_db_migrations() -> bool:
1519
1519
  applied = run_migrations(conn)
1520
1520
  if applied > 0:
1521
1521
  _log(f"Applied {applied} DB migration(s)")
1522
+ # Plan Consolidado F1 — one-shot legacy email config migration.
1523
+ # After m46 adds the table, operators installed pre-v6.4.0 still
1524
+ # keep their data inside ~/.nexo/nexo-email/config.json. If the
1525
+ # table is empty and the JSON exists, port the primary account
1526
+ # over automatically so scripts pick up the new source on the
1527
+ # next cron without the operator running anything manually.
1528
+ _maybe_migrate_legacy_email_config()
1522
1529
  return True
1523
1530
  except Exception as e:
1524
1531
  _log(f"DB migration error: {e}")
1525
1532
  return False
1526
1533
 
1527
1534
 
1535
+ def _maybe_migrate_legacy_email_config() -> None:
1536
+ """F1 auto-migrator — idempotent. Runs the helper script the first
1537
+ time after v6.4.0 lands on an existing runtime."""
1538
+ try:
1539
+ from db._email_accounts import get_email_account
1540
+ except Exception:
1541
+ return # m46 not applied yet (older runtime), nothing to do
1542
+ try:
1543
+ legacy = NEXO_HOME / "nexo-email" / "config.json"
1544
+ if not legacy.exists():
1545
+ return
1546
+ if get_email_account("primary"):
1547
+ return # already migrated
1548
+ import subprocess as _sp
1549
+ script = Path(__file__).resolve().parent / "scripts" / "nexo-email-migrate-config.py"
1550
+ if not script.exists():
1551
+ return
1552
+ _log("F1: migrating legacy email config.json → email_accounts table")
1553
+ env = {**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parent)}
1554
+ r = _sp.run(
1555
+ [sys.executable, str(script)],
1556
+ env=env,
1557
+ capture_output=True,
1558
+ text=True,
1559
+ timeout=30,
1560
+ )
1561
+ if r.returncode == 0:
1562
+ line = (r.stdout.strip().splitlines() or ["ok"])[-1]
1563
+ _log(f"F1 email migration: {line}")
1564
+ else:
1565
+ _log(f"F1 email migration FAILED (rc={r.returncode}): {r.stderr.strip()[:200]}")
1566
+ except _sp.TimeoutExpired:
1567
+ _log("F1 email migration timed out after 30s")
1568
+ except Exception as exc:
1569
+ _log(f"F1 email migration skipped: {exc}")
1570
+
1571
+
1528
1572
  # ── npm version check (notify only) ─────────────────────────────────
1529
1573
 
1530
1574
  def _check_npm_version() -> str | None:
@@ -0,0 +1,176 @@
1
+ """Plan Consolidado 0.21 — Local zero-shot multilingual classifier.
2
+
3
+ Skeleton + pinned HuggingFace coordinates. The heavy load
4
+ (`transformers`, ~500 MB model download) is lazy so the rest of the
5
+ runtime does not pay the cost on every import.
6
+
7
+ Contract:
8
+
9
+ clf = LocalZeroShotClassifier()
10
+ result = clf.classify(
11
+ "lo hemos dejado, ya estaría",
12
+ labels=("done_claim", "status_update", "question", "noise"),
13
+ )
14
+ result == {"label": "done_claim", "confidence": 0.87, "scores": {...}}
15
+
16
+ When transformers is not installed or the download fails (offline),
17
+ `classify` returns `None` and `classify_fail_closed` returns a
18
+ conservative fallback label so rules degrade gracefully (item 0.20).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import threading
25
+ from dataclasses import dataclass
26
+ from typing import Iterable
27
+
28
+ _logger = logging.getLogger(__name__)
29
+
30
+
31
+ # Keep in lockstep with docs/classifier-model-notes.md.
32
+ # Plan 0.21 wave-2 update: the original pin
33
+ # (MoritzLaurer/mDeBERTa-v3-base-mnli-xnli @ a1a5a76) refused to load
34
+ # under transformers 5.x with a missing `model_type` error. Switched
35
+ # to the multilingual-2mil7 sibling which is the same DeBERTa-v2
36
+ # architecture, multilingual, and loads cleanly. Revision pinned to
37
+ # the last HF upstream commit verified in smoke.
38
+ MODEL_ID = "MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7"
39
+ MODEL_REVISION = "b5113eb38ab63efdd7f280f8c144ea8b13f978ce"
40
+ DEFAULT_CONFIDENCE_FLOOR = 0.6
41
+
42
+
43
+ @dataclass
44
+ class ClassificationResult:
45
+ label: str
46
+ confidence: float
47
+ scores: dict[str, float]
48
+ latency_ms: float
49
+
50
+
51
+ class LocalZeroShotClassifier:
52
+ """Lazy wrapper around transformers' zero-shot-classification pipeline.
53
+
54
+ Thread-safe lazy load; failures degrade to `classify(...) = None` so
55
+ the Guardian can decide whether to invoke the LLM fallback
56
+ (`call_model_raw`) or a conservative regex path.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ model_id: str = MODEL_ID,
63
+ revision: str = MODEL_REVISION,
64
+ confidence_floor: float = DEFAULT_CONFIDENCE_FLOOR,
65
+ ) -> None:
66
+ self.model_id = model_id
67
+ self.revision = revision
68
+ self.confidence_floor = confidence_floor
69
+ self._pipe = None
70
+ self._load_failed = False
71
+ self._lock = threading.Lock()
72
+
73
+ # ------------------------------------------------------------------
74
+ # Lazy load
75
+ # ------------------------------------------------------------------
76
+ def _ensure_loaded(self) -> bool:
77
+ if self._pipe is not None:
78
+ return True
79
+ if self._load_failed:
80
+ return False
81
+ with self._lock:
82
+ if self._pipe is not None:
83
+ return True
84
+ if self._load_failed:
85
+ return False
86
+ try:
87
+ from transformers import pipeline # type: ignore
88
+ except Exception as exc: # pragma: no cover — no HF on CI
89
+ _logger.warning(
90
+ "classifier_local disabled: transformers unavailable (%s)",
91
+ exc,
92
+ )
93
+ self._load_failed = True
94
+ return False
95
+ try:
96
+ self._pipe = pipeline(
97
+ "zero-shot-classification",
98
+ model=self.model_id,
99
+ revision=self.revision,
100
+ device=-1, # CPU-only
101
+ )
102
+ return True
103
+ except Exception as exc: # pragma: no cover — network / disk
104
+ _logger.warning(
105
+ "classifier_local pipeline failed to initialise: %s", exc
106
+ )
107
+ self._load_failed = True
108
+ return False
109
+
110
+ # ------------------------------------------------------------------
111
+ # Public API
112
+ # ------------------------------------------------------------------
113
+ def is_available(self) -> bool:
114
+ return self._ensure_loaded()
115
+
116
+ def classify(
117
+ self,
118
+ text: str,
119
+ labels: Iterable[str],
120
+ *,
121
+ multi_label: bool = False,
122
+ ) -> ClassificationResult | None:
123
+ """Return best label + confidence or None if the local pipeline
124
+ is unavailable."""
125
+ if not text or not labels:
126
+ return None
127
+ if not self._ensure_loaded():
128
+ return None
129
+ import time
130
+ t0 = time.time()
131
+ try:
132
+ raw = self._pipe( # type: ignore[operator]
133
+ text,
134
+ candidate_labels=list(labels),
135
+ multi_label=multi_label,
136
+ )
137
+ except Exception as exc: # pragma: no cover
138
+ _logger.warning("classifier_local inference failed: %s", exc)
139
+ return None
140
+ latency_ms = (time.time() - t0) * 1000.0
141
+ scores = dict(zip(raw["labels"], raw["scores"]))
142
+ top_label = raw["labels"][0]
143
+ return ClassificationResult(
144
+ label=top_label,
145
+ confidence=float(raw["scores"][0]),
146
+ scores=scores,
147
+ latency_ms=latency_ms,
148
+ )
149
+
150
+ def classify_fail_closed(
151
+ self,
152
+ text: str,
153
+ labels: Iterable[str],
154
+ fallback_label: str,
155
+ ) -> ClassificationResult:
156
+ """Never returns None — falls back to `fallback_label` with
157
+ confidence 0 so the Guardian can still decide without crashing.
158
+ """
159
+ got = self.classify(text, labels)
160
+ if got is not None and got.confidence >= self.confidence_floor:
161
+ return got
162
+ return ClassificationResult(
163
+ label=fallback_label,
164
+ confidence=0.0,
165
+ scores={label: 0.0 for label in labels},
166
+ latency_ms=0.0,
167
+ )
168
+
169
+
170
+ __all__ = [
171
+ "LocalZeroShotClassifier",
172
+ "ClassificationResult",
173
+ "MODEL_ID",
174
+ "MODEL_REVISION",
175
+ "DEFAULT_CONFIDENCE_FLOOR",
176
+ ]
package/src/cli.py CHANGED
@@ -2046,6 +2046,13 @@ def main():
2046
2046
  parser.add_argument("-v", "--version", action="store_true", help="Show version")
2047
2047
  sub = parser.add_subparsers(dest="command")
2048
2048
 
2049
+ # -- email (Plan F1 — interactive wizard for email accounts) --
2050
+ try:
2051
+ from cli_email import register_email_parser
2052
+ register_email_parser(sub)
2053
+ except Exception as _exc_email: # pragma: no cover
2054
+ pass
2055
+
2049
2056
  # -- chat --
2050
2057
  chat_parser = sub.add_parser("chat", help="Launch a NEXO terminal client")
2051
2058
  chat_parser.add_argument("path", nargs="?", default=".", help="Working directory (default: current directory)")
@@ -2354,6 +2361,14 @@ def main():
2354
2361
  print(f"nexo v{_get_version()}")
2355
2362
  return 0
2356
2363
 
2364
+ if args.command == "email":
2365
+ # Plan F1 — setup / list / test / remove cuentas email.
2366
+ fn = getattr(args, "func", None)
2367
+ if fn is None:
2368
+ print("usage: nexo email {setup,list,test,remove}")
2369
+ return 1
2370
+ return int(fn(args) or 0)
2371
+
2357
2372
  if args.command == "scripts":
2358
2373
  if args.scripts_command == "list":
2359
2374
  return _scripts_list(args)