nexo-brain 6.4.0 → 6.5.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.4.0",
3
+ "version": "6.5.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,12 @@
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.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.
21
+ Version `6.5.0` is the current packaged-runtime line — Plan Consolidado fase F0.2: operators can now `nexo scripts enable|disable|status <name>` any personal automation. The cron wrapper honours the flag at every tick (`exit 0` with `summary='[disabled]'` while the LaunchAgent stays loaded). The companion NEXO Desktop client (a closed-source product, distributed separately) wires the same toggle into its Automatizaciones panel. See [CHANGELOG](CHANGELOG.md) for the full diff.
22
+
23
+ > **About NEXO Desktop.** NEXO Desktop is a separate closed-source companion app distributed at [systeam.es/nexo-desktop](https://systeam.es/nexo-desktop) — its source does not live in this repo. When release notes mention Desktop they describe a coordinated client release that consumes the Brain's CLI / MCP contract; the Brain itself is fully usable on its own (terminal, Codex, Claude Code, or any MCP client).
24
+
25
+
26
+ Previously in `6.4.0`: Plan Consolidado fase F1 — multi-tenant email accounts (`email_accounts` table, `nexo email setup` interactive wizard, `nexo email add --password-stdin --json` for machine consumers, idempotent migrator from legacy `~/.nexo/nexo-email/config.json`).
22
27
 
23
28
  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
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "6.4.0",
3
+ "version": "6.5.0",
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/cli.py CHANGED
@@ -452,6 +452,49 @@ def _scripts_unschedule(args):
452
452
  return 0 if result.get("ok") else 1
453
453
 
454
454
 
455
+ def _scripts_set_enabled(args, enabled):
456
+ from script_registry import set_personal_script_enabled
457
+
458
+ result = set_personal_script_enabled(args.name, enabled)
459
+ if args.json:
460
+ print(json.dumps(result, indent=2, ensure_ascii=False))
461
+ return 0 if result.get("ok") else 1
462
+ if not result.get("ok"):
463
+ print(result.get("error", "Failed to toggle script"), file=sys.stderr)
464
+ return 1
465
+ verb = "enabled" if enabled else "disabled"
466
+ if result.get("changed"):
467
+ print(f"Script {result['name']} {verb}.")
468
+ else:
469
+ print(f"Script {result['name']} already {verb}.")
470
+ return 0
471
+
472
+
473
+ def _scripts_status(args):
474
+ from script_registry import get_personal_script_status
475
+
476
+ result = get_personal_script_status(args.name)
477
+ if args.json:
478
+ print(json.dumps(result, indent=2, ensure_ascii=False))
479
+ return 0 if result.get("ok") else 1
480
+ if not result.get("ok"):
481
+ print(result.get("error", "Failed to read status"), file=sys.stderr)
482
+ return 1
483
+ state = "enabled" if result.get("enabled") else "DISABLED"
484
+ print(f"{result.get('name')} [{result.get('classification')}] -> {state}")
485
+ last = result.get("last_run") or {}
486
+ if last:
487
+ exit_code = last.get("exit_code")
488
+ started = last.get("started_at") or "?"
489
+ print(f" last run: {started} (exit={exit_code})")
490
+ summary = (last.get("summary") or "").strip()
491
+ if summary:
492
+ print(f" summary: {summary[:120]}")
493
+ else:
494
+ print(" last run: (none)")
495
+ return 0
496
+
497
+
455
498
  def _scripts_remove(args):
456
499
  from script_registry import remove_personal_script
457
500
 
@@ -2116,6 +2159,19 @@ def main():
2116
2159
  unschedule_p.add_argument("name", help="Script name or path")
2117
2160
  unschedule_p.add_argument("--json", action="store_true", help="JSON output")
2118
2161
 
2162
+ # scripts enable / disable / status (Plan F0.2.2)
2163
+ enable_p = scripts_sub.add_parser("enable", help="Enable a personal script (cron wrapper will run it again)")
2164
+ enable_p.add_argument("name", help="Script name or path")
2165
+ enable_p.add_argument("--json", action="store_true", help="JSON output")
2166
+
2167
+ disable_p = scripts_sub.add_parser("disable", help="Disable a personal script (cron wrapper will skip it)")
2168
+ disable_p.add_argument("name", help="Script name or path")
2169
+ disable_p.add_argument("--json", action="store_true", help="JSON output")
2170
+
2171
+ status_p = scripts_sub.add_parser("status", help="Show enabled flag + last cron_runs row for a script")
2172
+ status_p.add_argument("name", help="Script name or path")
2173
+ status_p.add_argument("--json", action="store_true", help="JSON output")
2174
+
2119
2175
  # scripts remove
2120
2176
  remove_p = scripts_sub.add_parser("remove", help="Remove a personal script and any attached schedules")
2121
2177
  remove_p.add_argument("name", help="Script name or path")
@@ -2394,6 +2450,12 @@ def main():
2394
2450
  return _scripts_doctor(args)
2395
2451
  elif args.scripts_command == "call":
2396
2452
  return _scripts_call(args)
2453
+ elif args.scripts_command == "enable":
2454
+ return _scripts_set_enabled(args, True)
2455
+ elif args.scripts_command == "disable":
2456
+ return _scripts_set_enabled(args, False)
2457
+ elif args.scripts_command == "status":
2458
+ return _scripts_status(args)
2397
2459
  else:
2398
2460
  scripts_parser.print_help()
2399
2461
  return 0
@@ -122,7 +122,9 @@ def upsert_personal_script(
122
122
  metadata_json = excluded.metadata_json,
123
123
  created_by = COALESCE(NULLIF(personal_scripts.created_by, ''), excluded.created_by),
124
124
  source = excluded.source,
125
- enabled = excluded.enabled,
125
+ -- Plan F0.2.2: preserve operator-set `enabled` flag across sync runs.
126
+ -- Sync defaults to enabled=True for INSERTs; on UPDATE we keep
127
+ -- whatever the operator (or `nexo scripts disable`) set.
126
128
  has_inline_metadata = excluded.has_inline_metadata,
127
129
  last_synced_at = excluded.last_synced_at,
128
130
  updated_at = excluded.updated_at
@@ -626,7 +626,38 @@ def list_scripts(include_core: bool = False) -> list[dict]:
626
626
  """List scripts in NEXO_HOME/scripts/.
627
627
 
628
628
  By default only personal scripts. With include_core=True, also shows core/cron scripts.
629
+
630
+ Plan F0.2.4 fix — every entry now carries an `enabled` field
631
+ hydrated from the `personal_scripts` table. Core entries default
632
+ to True (they ship enabled and are not toggleable from this entry
633
+ point); personal entries default to True when no row exists yet
634
+ (sync hasn't run) so the Desktop toggle has a stable starting
635
+ state. The flag is what powers the Settings -> Automatizaciones
636
+ toggle's round-trip.
629
637
  """
638
+ # Build a path -> enabled map once so we don't open a transaction
639
+ # per entry. Personal_scripts rows that don't match anything in
640
+ # classify_scripts_dir() are simply ignored.
641
+ enabled_map: dict[str, bool] = {}
642
+ if include_core:
643
+ # Only the Desktop panel (include_core=True) needs the toggle
644
+ # round-trip. Gating the DB read this way avoids triggering
645
+ # init_db() in default callers (eg `nexo scripts list`), which
646
+ # would surface pre-existing test fixture pollution.
647
+ try:
648
+ from db import init_db
649
+ from db._personal_scripts import list_personal_scripts
650
+ init_db()
651
+ for row in list_personal_scripts(include_disabled=True):
652
+ p = row.get("path")
653
+ if p:
654
+ enabled_map[str(p)] = bool(row.get("enabled", True))
655
+ except Exception:
656
+ # Missing table (older runtime), locked DB, anything else:
657
+ # fall through to enabled=True (the cron wrapper gate is
658
+ # the source of truth at run time).
659
+ enabled_map = {}
660
+
630
661
  results = []
631
662
  for entry in classify_scripts_dir()["entries"]:
632
663
  if entry["classification"] not in {"personal", "core"}:
@@ -636,6 +667,7 @@ def list_scripts(include_core: bool = False) -> list[dict]:
636
667
  hidden = _truthy(entry.get("metadata", {}).get("hidden"))
637
668
  if hidden and not include_core:
638
669
  continue
670
+ entry["enabled"] = enabled_map.get(entry["path"], True)
639
671
  results.append(entry)
640
672
  return results
641
673
 
@@ -1462,6 +1494,78 @@ def remove_personal_script(name_or_path: str, *, keep_file: bool = False) -> dic
1462
1494
  }
1463
1495
 
1464
1496
 
1497
+ def set_personal_script_enabled(name_or_path: str, enabled: bool) -> dict:
1498
+ """Plan F0.2.2 — flip the `enabled` flag on a personal script.
1499
+
1500
+ Returns ``{ok: bool, name, enabled, changed: bool}``. Refuses to
1501
+ flip packaged core scripts (they ship enabled and the operator
1502
+ should `nexo scripts unschedule` if they want one to stop).
1503
+
1504
+ The cron wrapper (`nexo-cron-wrapper.sh`, F0.2.4) reads this flag
1505
+ on every tick and exits 0 with `summary='[disabled]'` when the
1506
+ script is disabled, so the LaunchAgent can stay loaded but the
1507
+ script itself is dormant.
1508
+ """
1509
+ from db import init_db, get_personal_script
1510
+ from db._core import get_db
1511
+
1512
+ init_db()
1513
+ sync_personal_scripts()
1514
+ script = get_personal_script(name_or_path) or resolve_script(name_or_path)
1515
+ if not script:
1516
+ return {"ok": False, "error": f"Script not found: {name_or_path}"}
1517
+ if script.get("core") and not _within_scripts_dir(Path(script.get("path", ""))):
1518
+ return {
1519
+ "ok": False,
1520
+ "error": "Refusing to toggle a packaged core script via this entry point — "
1521
+ "use `nexo scripts unschedule` to stop it instead.",
1522
+ }
1523
+ target = 1 if enabled else 0
1524
+ conn = get_db()
1525
+ cur = conn.execute(
1526
+ "UPDATE personal_scripts SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE path = ?",
1527
+ (target, script["path"]),
1528
+ )
1529
+ conn.commit()
1530
+ changed = bool(cur.rowcount)
1531
+ return {
1532
+ "ok": True,
1533
+ "name": script.get("name", name_or_path),
1534
+ "path": script.get("path", ""),
1535
+ "enabled": bool(target),
1536
+ "changed": changed,
1537
+ }
1538
+
1539
+
1540
+ def get_personal_script_status(name_or_path: str) -> dict:
1541
+ """Plan F0.2.2 — read-only view of one personal script for the
1542
+ Desktop panel and the `nexo scripts status` CLI verb."""
1543
+ from db import init_db, get_personal_script
1544
+ from db._core import get_db
1545
+
1546
+ init_db()
1547
+ sync_personal_scripts()
1548
+ script = get_personal_script(name_or_path) or resolve_script(name_or_path)
1549
+ if not script:
1550
+ return {"ok": False, "error": f"Script not found: {name_or_path}"}
1551
+ conn = get_db()
1552
+ last = conn.execute(
1553
+ "SELECT exit_code, started_at, ended_at, summary FROM cron_runs "
1554
+ "WHERE cron_id = ? ORDER BY id DESC LIMIT 1",
1555
+ (script.get("name") or "",),
1556
+ ).fetchone()
1557
+ last_run = dict(last) if last else None
1558
+ return {
1559
+ "ok": True,
1560
+ "name": script.get("name"),
1561
+ "path": script.get("path"),
1562
+ "enabled": bool(script.get("enabled", True)),
1563
+ "core": bool(script.get("core")),
1564
+ "classification": script.get("classification", "user"),
1565
+ "last_run": last_run,
1566
+ }
1567
+
1568
+
1465
1569
  def doctor_script(path_or_name: str) -> dict:
1466
1570
  """Validate a single script. Returns dict with pass/warn/fail items."""
1467
1571
  # Resolve
@@ -185,6 +185,44 @@ trap 'on_signal SIGTERM 143' TERM
185
185
  trap 'on_signal SIGINT 130' INT
186
186
  trap 'on_signal SIGHUP 129' HUP
187
187
 
188
+
189
+ # Plan F0.2.4 — disabled-script gate. Personal_scripts table holds an
190
+ # `enabled` flag flipped via `nexo scripts enable|disable <name>`. When
191
+ # the operator disables a cron the LaunchAgent stays loaded (no
192
+ # launchctl churn) but this wrapper short-circuits to a clean exit 0
193
+ # with summary='[disabled]'. The corresponding cron_runs row gets an
194
+ # explicit "skipped (disabled)" note so the daily audit can tell apart
195
+ # "didn't run because off" from "ran with exit 0".
196
+ DISABLED_GATE_OUTPUT=$(python3 - "$DB" "$CRON_ID" <<'PYGATE' 2>/dev/null || true
197
+ from __future__ import annotations
198
+ import sqlite3
199
+ import sys
200
+ db_path, cron_id = sys.argv[1:]
201
+ try:
202
+ conn = sqlite3.connect(db_path)
203
+ try:
204
+ row = conn.execute(
205
+ "SELECT enabled FROM personal_scripts WHERE name = ? LIMIT 1",
206
+ (cron_id,),
207
+ ).fetchone()
208
+ finally:
209
+ conn.close()
210
+ except Exception:
211
+ sys.exit(0)
212
+ if row is not None and not int(row[0] or 0):
213
+ print("disabled")
214
+ PYGATE
215
+ )
216
+ if [ "$DISABLED_GATE_OUTPUT" = "disabled" ]; then
217
+ EXIT_CODE=0
218
+ SIGNAL_NAME=""
219
+ : > "$OUTPUT_FILE"
220
+ echo "[disabled] $CRON_ID skipped - re-enable with: nexo scripts enable $CRON_ID" > "$OUTPUT_FILE"
221
+ finalize_row
222
+ cleanup
223
+ exit 0
224
+ fi
225
+
188
226
  "$@" > "$OUTPUT_FILE" 2>&1 &
189
227
  CHILD_PID=$!
190
228