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 +83 -12
- package/package.json +1 -1
- package/src/desktop_bridge.py +56 -1
- package/src/events_bus.py +18 -13
- package/src/runtime_versioning.py +11 -0
- package/src/scripts/nexo-email-monitor.py +26 -2
- package/src/scripts/nexo-immune.py +16 -0
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:
|
|
1880
|
-
// Path: resources/brain-bundle/claude-code
|
|
1881
|
-
//
|
|
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
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
package/src/desktop_bridge.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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
|