nexo-brain 7.12.4 → 7.12.7

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.
package/bin/nexo-brain.js CHANGED
@@ -1876,15 +1876,55 @@ function installClaudeCodeCli(platform) {
1876
1876
  const managedPrefix = managedClaudePrefix();
1877
1877
  const desktopManaged = isDesktopManagedInstall();
1878
1878
 
1879
- // OFFLINE-FIRST: detect bundled claude-code .tgz and install from it.
1880
- // Path: resources/brain-bundle/claude-code/claude-code-X.Y.Z.tgz (relative
1881
- // to bin/, so __dirname/../claude-code/). Falls back to PyPI/npm if absent.
1879
+ // OFFLINE-FIRST v0.32.4: install claude-code wrapper + ALL its native packs
1880
+ // from bundled tarballs. Path: resources/brain-bundle/claude-code/*.tgz.
1881
+ //
1882
+ // Bug que arregla este fix (encontrado 2026-05-02):
1883
+ // claude-code 2.1.x ships como wrapper + 4 native packs por arquitectura
1884
+ // (@anthropic-ai/claude-code-linux-x64, -darwin-arm64, -darwin-x64,
1885
+ // -linux-arm64). Antes solo bundeaba el wrapper (.tgz 13 KB) y pasaba SOLO
1886
+ // ese al npm install. npm intentaba resolver los `optionalDependencies` del
1887
+ // registry online → fallo offline → wrapper instala SIN binario claude →
1888
+ // `command -v claude` exit 127 → bootstrap "claude-runtime-missing-soft".
1889
+ // Ahora bundeamos los 5 .tgz (wrapper + 4 native) y se los pasamos TODOS
1890
+ // a npm en un solo install. npm los ve como dependencias pre-resueltas
1891
+ // y skip el registry lookup. claude binary ejecutable resultante.
1882
1892
  const bundledClaudeDir = path.join(__dirname, "..", "claude-code");
1883
1893
  if (fs.existsSync(bundledClaudeDir)) {
1884
- const tgzFiles = fs.readdirSync(bundledClaudeDir).filter((f) => f.endsWith(".tgz"));
1885
- if (tgzFiles.length > 0) {
1886
- const tgzPath = path.join(bundledClaudeDir, tgzFiles[0]);
1887
- log(" Installing claude-code from bundled tarball (offline)...");
1894
+ const allTgz = fs.readdirSync(bundledClaudeDir).filter((f) => f.endsWith(".tgz"));
1895
+ // Ordenar: el wrapper primero (sin sufijo de plataforma), después los packs.
1896
+ const wrapper = allTgz.find((f) => /^anthropic-ai-claude-code-\d/.test(f));
1897
+ // v7.12.6 Filter native packs to ONLY the current platform/arch.
1898
+ // Bug: passing all 4 native packs to `npm install` triggers EBADPLATFORM
1899
+ // when npm refuses to install e.g. `claude-code-darwin-arm64` on linux/x64
1900
+ // (`{"os":"darwin","cpu":"arm64"}` vs current `{"os":"linux","cpu":"x64"}`).
1901
+ // The whole install aborts → `command -v claude` fails → bootstrap stalls
1902
+ // at "Preparando NEXO..." and never reaches Brain config. First seen
1903
+ // 2026-05-03 on Inma's Win11 PC running the Linux x64 WSL distro.
1904
+ const platformSlug = `${process.platform}-${process.arch}`;
1905
+ const nativePacks = allTgz.filter((f) => {
1906
+ if (f === wrapper) return false;
1907
+ // Native pack filenames look like `anthropic-ai-claude-code-<os>-<cpu>-<version>.tgz`.
1908
+ // Match the `-<os>-<cpu>-` segment against the runtime platform slug.
1909
+ return f.includes(`-${platformSlug}-`);
1910
+ });
1911
+ if (wrapper && nativePacks.length > 0) {
1912
+ const tgzPaths = [path.join(bundledClaudeDir, wrapper), ...nativePacks.map((p) => path.join(bundledClaudeDir, p))];
1913
+ log(" Installing claude-code from bundled tarballs (offline, " + (1 + nativePacks.length) + " packs)...");
1914
+ spawnSync(
1915
+ "npm",
1916
+ ["install", "-g", "--prefix", managedPrefix, "--offline", "--no-audit", "--no-fund", ...tgzPaths],
1917
+ { stdio: "inherit", env: installEnv },
1918
+ );
1919
+ claudeInstalled = detectInstalledClients().claude_code.path || "";
1920
+ if (claudeInstalled) {
1921
+ persistClaudeCliPath(claudeInstalled);
1922
+ return { installed: true, path: claudeInstalled };
1923
+ }
1924
+ } else if (wrapper) {
1925
+ // Fallback: solo wrapper (legacy bundle 0.32.3 y anteriores).
1926
+ const tgzPath = path.join(bundledClaudeDir, wrapper);
1927
+ log(" Installing claude-code from bundled wrapper only (legacy bundle, may need network for native pack)...");
1888
1928
  spawnSync(
1889
1929
  "npm",
1890
1930
  ["install", "-g", "--prefix", managedPrefix, tgzPath],
@@ -2993,8 +3033,29 @@ async function runSetup() {
2993
3033
  let python = run("which python3");
2994
3034
  if (!python) {
2995
3035
  if (platform === "darwin") {
2996
- // macOS: use Homebrew
3036
+ // v0.32.5 Mac vanilla NO trae python3. La auto-instalación de
3037
+ // Homebrew vía `curl install.sh` requiere TTY interactivo + sudo +
3038
+ // user accept license. Cuando este script se invoca desde Electron
3039
+ // sandbox, NO hay TTY → curl pipe cuelga sin progreso → bootstrap
3040
+ // se queda silencioso. Mejor: detectar la ausencia de Python y
3041
+ // surface un error CLARO al user con instrucciones manuales que
3042
+ // funcionan siempre. Si hay TTY (corriendo desde terminal), seguimos
3043
+ // con el path automático.
3044
+ const isTty = !!(process.stdin && process.stdin.isTTY);
2997
3045
  let hasBrew = run("which brew");
3046
+ if (!hasBrew && !isTty) {
3047
+ log("ERROR: Python 3.12 is not installed and the auto-installer requires an interactive terminal.");
3048
+ log("");
3049
+ log("Please install Python 3.12 manually:");
3050
+ log(" 1. Open Terminal.app");
3051
+ log(" 2. Run: xcode-select --install");
3052
+ log(" 3. Run: /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"");
3053
+ log(" 4. Run: brew install python@3.12");
3054
+ log(" 5. Reopen NEXO Desktop");
3055
+ log("");
3056
+ log("More help: https://nexo-desktop.com/help/python-install");
3057
+ process.exit(1);
3058
+ }
2998
3059
  if (!hasBrew) {
2999
3060
  log("Homebrew not found. Installing...");
3000
3061
  spawnSync("/bin/bash", ["-c", '$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'], {
@@ -3003,9 +3064,14 @@ async function runSetup() {
3003
3064
  hasBrew = run("which brew") || run("eval $(/opt/homebrew/bin/brew shellenv) && which brew");
3004
3065
  }
3005
3066
  if (hasBrew) {
3006
- log("Python 3 not found. Installing via Homebrew...");
3007
- spawnSync("brew", ["install", "python3"], { stdio: "inherit" });
3008
- python = run("which python3");
3067
+ // v0.32.5 — explicit @3.12 pin: el Brain `requirements.txt` y los
3068
+ // wheels manylinux están compilados contra cp312. `brew install
3069
+ // python3` instala el `python3` formula que actualmente apunta a
3070
+ // 3.13 → wheels rechazados → numpy/cffi/cryptography/onnxruntime
3071
+ // fallan al import. Pinning a `python@3.12` evita el drift.
3072
+ log("Python 3.12 not found. Installing via Homebrew...");
3073
+ spawnSync("brew", ["install", "python@3.12"], { stdio: "inherit" });
3074
+ python = run("which python3.12") || run("which python3");
3009
3075
  }
3010
3076
  } else if (platform === "linux") {
3011
3077
  // Linux: try apt or yum
@@ -3477,7 +3543,12 @@ async function runSetup() {
3477
3543
  // present, pip uses --no-index --find-links to install without internet.
3478
3544
  // Falls back to PyPI if bundle not found.
3479
3545
  const bundledWheelsDir = path.join(__dirname, "..", "python-wheels");
3480
- const useBundle = fs.existsSync(bundledWheelsDir);
3546
+ // v0.32.5 el bundle empaca wheels manylinux (cp312 x86_64) porque
3547
+ // en Win Brain corre dentro de WSL Ubuntu noble. En Mac, Brain corre
3548
+ // nativo macOS y NO acepta esos wheels (ABI distinto). Si gateamos
3549
+ // useBundle a !linux, pip cae al PyPI online — bien. macOS y Win
3550
+ // (host nativo) deben tener red la primera vez.
3551
+ const useBundle = process.platform === "linux" && fs.existsSync(bundledWheelsDir);
3481
3552
  const pipArgs = useBundle
3482
3553
  ? ["-m", "pip", "install", "--no-index", "--find-links", bundledWheelsDir, "--progress-bar", "off", "-r", requirementsFile]
3483
3554
  : ["-m", "pip", "install", "-v", "--progress-bar", "off", "--default-timeout=60", "-r", requirementsFile];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.12.4",
3
+ "version": "7.12.7",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
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",
@@ -311,16 +311,40 @@ def cmd_identity(args) -> int:
311
311
  # ---------------------------------------------------------------- onboard
312
312
 
313
313
  def _onboard_steps() -> list[dict]:
314
+ # v7.12.7 — expanded the wizard to capture the most basic user
315
+ # context so the agent can be useful from day one (decided with
316
+ # Francisco 2026-05-03). No email here, no sensitive data; just
317
+ # how to address the user, where they live/work, what they do,
318
+ # and a couple of free-form interests. All non-mandatory fields
319
+ # stay optional so a user can power through with just name +
320
+ # language + assistant_name.
314
321
  return [
315
322
  {
316
323
  "id": "name",
317
- "prompt": {"es": "¿Cómo te llamas?", "en": "What's your name?"},
324
+ "prompt": {"es": "¿Cómo te llamamos?", "en": "What should we call you?"},
325
+ "hint": {
326
+ "es": "Tu nombre corto, el que usarás en el día a día.",
327
+ "en": "Your short name, the one we'll use day to day.",
328
+ },
318
329
  "type": "text",
319
330
  "writes": "user.name",
320
331
  "file": "calibration.json",
321
332
  "optional": False,
322
333
  "validate": r"^\S.{0,60}$",
323
334
  },
335
+ {
336
+ "id": "full_name",
337
+ "prompt": {"es": "¿Cuál es tu nombre completo?", "en": "What's your full name?"},
338
+ "hint": {
339
+ "es": "Por si necesito redactar emails, documentos o presentarte formalmente.",
340
+ "en": "In case I need to draft emails, documents, or introduce you formally.",
341
+ },
342
+ "type": "text",
343
+ "writes": "user.full_name",
344
+ "file": "calibration.json",
345
+ "optional": True,
346
+ "validate": r"^.{0,120}$",
347
+ },
324
348
  {
325
349
  "id": "language",
326
350
  "prompt": {"es": "¿En qué idioma quieres operar?", "en": "Which language should we use?"},
@@ -349,13 +373,44 @@ def _onboard_steps() -> list[dict]:
349
373
  },
350
374
  "reserved_values": list(RESERVED_ASSISTANT_NAME_VALUES),
351
375
  },
376
+ {
377
+ "id": "city",
378
+ "prompt": {"es": "¿Dónde vives o trabajas habitualmente?", "en": "Where do you live or work most days?"},
379
+ "hint": {
380
+ "es": "Una ciudad o zona; me ayuda con horarios, clima, ofertas locales y mil cosas más.",
381
+ "en": "A city or area; helps me with schedules, weather, local options, and a thousand small things.",
382
+ },
383
+ "type": "text",
384
+ "writes": "user.city",
385
+ "file": "calibration.json",
386
+ "optional": True,
387
+ "validate": r"^.{0,120}$",
388
+ },
352
389
  {
353
390
  "id": "role",
354
391
  "prompt": {"es": "¿A qué te dedicas?", "en": "What do you do?"},
392
+ "hint": {
393
+ "es": "Una frase corta vale: «médica de familia», «autónomo de hostelería», «estudiante de derecho».",
394
+ "en": "One short sentence works: \"family doctor\", \"freelance restaurateur\", \"law student\".",
395
+ },
355
396
  "type": "text",
356
397
  "writes": "meta.role",
357
398
  "file": "calibration.json",
358
399
  "optional": True,
400
+ "validate": r"^.{0,200}$",
401
+ },
402
+ {
403
+ "id": "interests",
404
+ "prompt": {"es": "¿En qué temas quieres que te ayude más?", "en": "Which topics should I help you with most?"},
405
+ "hint": {
406
+ "es": "Trabajo, finanzas, salud, familia, idiomas, estudios… separa con comas si son varios.",
407
+ "en": "Work, finances, health, family, languages, studies… separate with commas if more than one.",
408
+ },
409
+ "type": "text",
410
+ "writes": "meta.interests",
411
+ "file": "calibration.json",
412
+ "optional": True,
413
+ "validate": r"^.{0,300}$",
359
414
  },
360
415
  {
361
416
  "id": "technical_level",
package/src/events_bus.py CHANGED
@@ -101,22 +101,27 @@ def emit(
101
101
  path.parent.mkdir(parents=True, exist_ok=True)
102
102
  _rotate_if_needed(path)
103
103
 
104
- event = {
105
- "id": _next_id(path),
106
- "ts": time.time(),
107
- "type": event_type,
108
- "priority": priority,
109
- "text": text,
110
- "reason": reason,
111
- "source": source,
112
- "extra": extra or {},
113
- }
114
-
115
- line = json.dumps(event, ensure_ascii=False) + "\n"
116
- # fcntl flock for cross-process safety on macOS/Linux
104
+ # v0.32.5 — antes `_next_id(path)` se calculaba ANTES de adquirir el
105
+ # flock → dos emitters concurrentes leían el mismo tail, computaban
106
+ # el mismo id, y escribían dos eventos con el mismo id. El renderer
107
+ # dedup descartaba el segundo silently → eventos perdidos. Ahora
108
+ # `_next_id` se llama DENTRO del lock, garantizando monotonía.
109
+ line = None
110
+ event = None
117
111
  with path.open("a", encoding="utf-8") as fh:
118
112
  try:
119
113
  fcntl.flock(fh, fcntl.LOCK_EX)
114
+ event = {
115
+ "id": _next_id(path),
116
+ "ts": time.time(),
117
+ "type": event_type,
118
+ "priority": priority,
119
+ "text": text,
120
+ "reason": reason,
121
+ "source": source,
122
+ "extra": extra or {},
123
+ }
124
+ line = json.dumps(event, ensure_ascii=False) + "\n"
120
125
  fh.write(line)
121
126
  fh.flush()
122
127
  finally:
@@ -57,6 +57,17 @@ RESTART_ALLOWLIST = {
57
57
  "nexo_continuity_snapshot_read",
58
58
  "nexo_continuity_resume_bundle",
59
59
  "nexo_continuity_audit",
60
+ # v0.32.5 — añadidas las read-only tools que el protocolo CORE llama
61
+ # justo después de `nexo_startup` (memory recall, reminders, followups,
62
+ # context, doctor). Antes quedaban bloqueadas por mcp_restart_required
63
+ # tras `nexo update` con sesión activa → Nero parecía amnésico hasta
64
+ # que el cliente se cerraba/reabría.
65
+ "nexo_smart_startup",
66
+ "nexo_session_diary_read",
67
+ "nexo_reminders",
68
+ "nexo_followups",
69
+ "nexo_recent_context",
70
+ "nexo_doctor",
60
71
  }
61
72
 
62
73
 
@@ -382,8 +382,19 @@ def load_config():
382
382
  return cfg
383
383
  except Exception:
384
384
  pass
385
- with open(CONFIG_PATH) as f:
386
- payload = json.load(f)
385
+ # v0.32.5 graceful return None when no email setup yet. Antes esto
386
+ # lanzaba FileNotFoundError y el cron de cada minuto generaba 1440
387
+ # filas de error/día por cliente sin email configurado. 50 clientes
388
+ # pagados sin email setup → 72k filas de error/día → watchdog L2
389
+ # alertas falsas → ruido en logs y posible token burn. Ahora el
390
+ # cron retorna sin error cuando no hay config.
391
+ try:
392
+ with open(CONFIG_PATH) as f:
393
+ payload = json.load(f)
394
+ except FileNotFoundError:
395
+ return None
396
+ except (OSError, json.JSONDecodeError):
397
+ return None
387
398
  if isinstance(payload, dict):
388
399
  payload.pop("automation_task_profile", None)
389
400
  return payload
@@ -1694,6 +1705,13 @@ def _run_worker_job(job_path):
1694
1705
  return 1
1695
1706
 
1696
1707
  config = load_config()
1708
+ if config is None:
1709
+ # v0.32.5 — worker invoked but email setup gone (config deleted
1710
+ # between scheduling and execution). Drop the job silently
1711
+ # rather than spamming exception logs.
1712
+ log.warning(f"Worker job {job_file.name}: no email config, dropping.")
1713
+ job_file.unlink(missing_ok=True)
1714
+ return 0
1697
1715
 
1698
1716
  log.info(
1699
1717
  f"Worker job started: {job_file.name} "
@@ -2190,6 +2208,12 @@ def main():
2190
2208
  return
2191
2209
 
2192
2210
  config = load_config()
2211
+ # v0.32.5 — exit cleanly cuando no hay email setup.
2212
+ # Antes el FileNotFoundError propagaba a 1440 errores/día por
2213
+ # cliente Win sin email. Ahora salimos en silencio.
2214
+ if config is None:
2215
+ log.info("No email config — skipping monitor check.")
2216
+ return
2193
2217
  base_interval_seconds = max(60, _safe_int(config.get("check_interval_seconds"), 300))
2194
2218
  backoff_state = load_empty_inbox_backoff_state()
2195
2219
  debt_block = scan_debt()
@@ -323,6 +323,22 @@ def check_tokens():
323
323
 
324
324
  def check_launch_agents():
325
325
  """Check that expected LaunchAgents are loaded. Auto-repair if not."""
326
+ # v0.32.5 — antes esto siempre invocaba `launchctl list` (Mac-only).
327
+ # Cuando el cron immune corría dentro de WSL Ubuntu (clientes Win),
328
+ # `launchctl` no existe → rc != 0 → loaded_labels vacío → cada agente
329
+ # marcado missing → cada immune tick disparaba watchdog L2 con LLM
330
+ # repair loop → quemaba tokens. Ahora detectamos plataforma y skip
331
+ # graceful en Linux/Win-WSL: no hay LaunchAgents en esas plataformas;
332
+ # los crons se materializan con cron / systemd y los chequea otra
333
+ # función separada.
334
+ import platform as _platform
335
+ if _platform.system() != "Darwin":
336
+ return [{
337
+ "name": "launchd",
338
+ "status": "OK",
339
+ "detail": "skipped: launchd is macOS-only",
340
+ "repaired": False,
341
+ }]
326
342
  results = []
327
343
 
328
344
  # Get list of loaded agents