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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -1
- package/package.json +1 -1
- package/src/cli.py +62 -0
- package/src/db/_personal_scripts.py +3 -1
- package/src/script_registry.py +104 -0
- package/src/scripts/nexo-cron-wrapper.sh +38 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
package/src/script_registry.py
CHANGED
|
@@ -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
|
|