nexo-brain 6.4.0 → 7.0.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.
@@ -591,42 +591,103 @@ def _script_entry(path: Path, meta: dict, *, is_core: bool, classification: str,
591
591
 
592
592
 
593
593
  def classify_scripts_dir() -> dict:
594
- """Classify every file in NEXO_HOME/scripts into personal/core/ignored/non-script buckets."""
594
+ """Classify every file in scripts dirs into personal/core/ignored/non-script buckets.
595
+
596
+ Plan F0.6 — scans every dir returned by paths.all_scripts_dirs():
597
+ core/scripts, personal/scripts, core-dev/scripts. Falls back to the
598
+ legacy ~/.nexo/scripts/ if the new layout has no entries (transition
599
+ safety; remove the fallback once F0.6 has been validated for a week).
600
+ """
595
601
  _apply_legacy_personal_script_backfills()
596
- scripts_dir = get_scripts_dir()
597
- if not scripts_dir.is_dir():
598
- return {"scripts_dir": str(scripts_dir), "entries": [], "summary": {}}
602
+ import paths
603
+ candidate_dirs = list(paths.all_scripts_dirs())
604
+ legacy = get_scripts_dir()
605
+ if legacy.is_dir() and legacy not in candidate_dirs:
606
+ candidate_dirs.append(legacy)
599
607
 
600
608
  core_names = load_core_script_names()
601
609
  entries: list[dict] = []
602
- for f in sorted(scripts_dir.iterdir()):
603
- if not f.is_file():
610
+ seen_keys: set[tuple[str, str]] = set()
611
+ for sdir in candidate_dirs:
612
+ if not sdir.is_dir():
604
613
  continue
605
-
606
- meta = parse_inline_metadata(f)
607
- if _is_ignored(f):
608
- entries.append(_script_entry(f, meta, is_core=False, classification="ignored", reason="internal or hidden artifact"))
609
- continue
610
-
611
- if not _is_script_candidate(f, meta):
612
- entries.append(_script_entry(f, meta, is_core=False, classification="non-script", reason="not an executable/script candidate"))
613
- continue
614
-
615
- is_core = f.name in core_names
616
- classification = "core" if is_core else "personal"
617
- entries.append(_script_entry(f, meta, is_core=is_core, classification=classification))
614
+ # Decide default classification per directory: a script dropped
615
+ # in core/scripts is core unless its name says otherwise; same
616
+ # for personal and core-dev.
617
+ if "core-dev" in sdir.parts:
618
+ dir_classification = "core-dev"
619
+ elif "personal" in sdir.parts:
620
+ dir_classification = "personal"
621
+ elif "core" in sdir.parts:
622
+ dir_classification = "core"
623
+ else:
624
+ dir_classification = None # legacy — fall back to name-based
625
+ for f in sorted(sdir.iterdir()):
626
+ if not f.is_file():
627
+ continue
628
+ # Dedup by (name, dir-classification) so a personal script with
629
+ # the same filename as a core script doesn't disappear (D2 audit fix).
630
+ dedup_key = (f.name, dir_classification or "legacy")
631
+ if dedup_key in seen_keys:
632
+ continue
633
+ seen_keys.add(dedup_key)
634
+ meta = parse_inline_metadata(f)
635
+ if _is_ignored(f):
636
+ entries.append(_script_entry(f, meta, is_core=False, classification="ignored", reason="internal or hidden artifact"))
637
+ continue
638
+ if not _is_script_candidate(f, meta):
639
+ entries.append(_script_entry(f, meta, is_core=False, classification="non-script", reason="not an executable/script candidate"))
640
+ continue
641
+ if dir_classification:
642
+ cls = dir_classification
643
+ is_core = cls in ("core", "core-dev")
644
+ else:
645
+ is_core = f.name in core_names
646
+ cls = "core" if is_core else "personal"
647
+ entries.append(_script_entry(f, meta, is_core=is_core, classification=cls))
618
648
 
619
649
  summary: dict[str, int] = {}
620
650
  for entry in entries:
621
651
  summary[entry["classification"]] = summary.get(entry["classification"], 0) + 1
622
- return {"scripts_dir": str(scripts_dir), "entries": entries, "summary": summary}
652
+ return {"scripts_dir": str(candidate_dirs[0]) if candidate_dirs else "", "entries": entries, "summary": summary}
623
653
 
624
654
 
625
655
  def list_scripts(include_core: bool = False) -> list[dict]:
626
656
  """List scripts in NEXO_HOME/scripts/.
627
657
 
628
658
  By default only personal scripts. With include_core=True, also shows core/cron scripts.
659
+
660
+ Plan F0.2.4 fix — every entry now carries an `enabled` field
661
+ hydrated from the `personal_scripts` table. Core entries default
662
+ to True (they ship enabled and are not toggleable from this entry
663
+ point); personal entries default to True when no row exists yet
664
+ (sync hasn't run) so the Desktop toggle has a stable starting
665
+ state. The flag is what powers the Settings -> Automatizaciones
666
+ toggle's round-trip.
629
667
  """
668
+ # Build a path -> enabled map once so we don't open a transaction
669
+ # per entry. Personal_scripts rows that don't match anything in
670
+ # classify_scripts_dir() are simply ignored.
671
+ enabled_map: dict[str, bool] = {}
672
+ if include_core:
673
+ # Only the Desktop panel (include_core=True) needs the toggle
674
+ # round-trip. Gating the DB read this way avoids triggering
675
+ # init_db() in default callers (eg `nexo scripts list`), which
676
+ # would surface pre-existing test fixture pollution.
677
+ try:
678
+ from db import init_db
679
+ from db._personal_scripts import list_personal_scripts
680
+ init_db()
681
+ for row in list_personal_scripts(include_disabled=True):
682
+ p = row.get("path")
683
+ if p:
684
+ enabled_map[str(p)] = bool(row.get("enabled", True))
685
+ except Exception:
686
+ # Missing table (older runtime), locked DB, anything else:
687
+ # fall through to enabled=True (the cron wrapper gate is
688
+ # the source of truth at run time).
689
+ enabled_map = {}
690
+
630
691
  results = []
631
692
  for entry in classify_scripts_dir()["entries"]:
632
693
  if entry["classification"] not in {"personal", "core"}:
@@ -636,6 +697,7 @@ def list_scripts(include_core: bool = False) -> list[dict]:
636
697
  hidden = _truthy(entry.get("metadata", {}).get("hidden"))
637
698
  if hidden and not include_core:
638
699
  continue
700
+ entry["enabled"] = enabled_map.get(entry["path"], True)
639
701
  results.append(entry)
640
702
  return results
641
703
 
@@ -1462,6 +1524,78 @@ def remove_personal_script(name_or_path: str, *, keep_file: bool = False) -> dic
1462
1524
  }
1463
1525
 
1464
1526
 
1527
+ def set_personal_script_enabled(name_or_path: str, enabled: bool) -> dict:
1528
+ """Plan F0.2.2 — flip the `enabled` flag on a personal script.
1529
+
1530
+ Returns ``{ok: bool, name, enabled, changed: bool}``. Refuses to
1531
+ flip packaged core scripts (they ship enabled and the operator
1532
+ should `nexo scripts unschedule` if they want one to stop).
1533
+
1534
+ The cron wrapper (`nexo-cron-wrapper.sh`, F0.2.4) reads this flag
1535
+ on every tick and exits 0 with `summary='[disabled]'` when the
1536
+ script is disabled, so the LaunchAgent can stay loaded but the
1537
+ script itself is dormant.
1538
+ """
1539
+ from db import init_db, get_personal_script
1540
+ from db._core import get_db
1541
+
1542
+ init_db()
1543
+ sync_personal_scripts()
1544
+ script = get_personal_script(name_or_path) or resolve_script(name_or_path)
1545
+ if not script:
1546
+ return {"ok": False, "error": f"Script not found: {name_or_path}"}
1547
+ if script.get("core") and not _within_scripts_dir(Path(script.get("path", ""))):
1548
+ return {
1549
+ "ok": False,
1550
+ "error": "Refusing to toggle a packaged core script via this entry point — "
1551
+ "use `nexo scripts unschedule` to stop it instead.",
1552
+ }
1553
+ target = 1 if enabled else 0
1554
+ conn = get_db()
1555
+ cur = conn.execute(
1556
+ "UPDATE personal_scripts SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE path = ?",
1557
+ (target, script["path"]),
1558
+ )
1559
+ conn.commit()
1560
+ changed = bool(cur.rowcount)
1561
+ return {
1562
+ "ok": True,
1563
+ "name": script.get("name", name_or_path),
1564
+ "path": script.get("path", ""),
1565
+ "enabled": bool(target),
1566
+ "changed": changed,
1567
+ }
1568
+
1569
+
1570
+ def get_personal_script_status(name_or_path: str) -> dict:
1571
+ """Plan F0.2.2 — read-only view of one personal script for the
1572
+ Desktop panel and the `nexo scripts status` CLI verb."""
1573
+ from db import init_db, get_personal_script
1574
+ from db._core import get_db
1575
+
1576
+ init_db()
1577
+ sync_personal_scripts()
1578
+ script = get_personal_script(name_or_path) or resolve_script(name_or_path)
1579
+ if not script:
1580
+ return {"ok": False, "error": f"Script not found: {name_or_path}"}
1581
+ conn = get_db()
1582
+ last = conn.execute(
1583
+ "SELECT exit_code, started_at, ended_at, summary FROM cron_runs "
1584
+ "WHERE cron_id = ? ORDER BY id DESC LIMIT 1",
1585
+ (script.get("name") or "",),
1586
+ ).fetchone()
1587
+ last_run = dict(last) if last else None
1588
+ return {
1589
+ "ok": True,
1590
+ "name": script.get("name"),
1591
+ "path": script.get("path"),
1592
+ "enabled": bool(script.get("enabled", True)),
1593
+ "core": bool(script.get("core")),
1594
+ "classification": script.get("classification", "user"),
1595
+ "last_run": last_run,
1596
+ }
1597
+
1598
+
1465
1599
  def doctor_script(path_or_name: str) -> dict:
1466
1600
  """Validate a single script. Returns dict with pass/warn/fail items."""
1467
1601
  # Resolve
@@ -1,10 +1,10 @@
1
1
  #!/bin/bash
2
- # NEXO DB hourly backup — crontab: 0 * * * * $NEXO_HOME/scripts/nexo-backup.sh
2
+ # NEXO DB hourly backup — crontab: 0 * * * * $NEXO_HOME/core/scripts/nexo-backup.sh
3
3
  NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
4
4
  NEXO_DIR="$NEXO_HOME"
5
5
  BACKUP_DIR="$NEXO_HOME/backups"
6
6
  WEEKLY_DIR="$BACKUP_DIR/weekly"
7
- DB="$NEXO_HOME/data/nexo.db"
7
+ DB="$NEXO_HOME/runtime/data/nexo.db"
8
8
  RETENTION_HOURS=48
9
9
 
10
10
  mkdir -p "$BACKUP_DIR" "$WEEKLY_DIR"
@@ -22,8 +22,8 @@ CRON_ID="${1:?Usage: nexo-cron-wrapper.sh <cron_id> <command...>}"
22
22
  shift
23
23
 
24
24
  NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
25
- DB="$NEXO_HOME/data/nexo.db"
26
- SPOOL_DIR="$NEXO_HOME/operations/cron-spool"
25
+ DB="$NEXO_HOME/runtime/data/nexo.db"
26
+ SPOOL_DIR="$NEXO_HOME/runtime/operations/cron-spool"
27
27
 
28
28
  # Unlock macOS Keychain so headless Claude Code can read auth tokens.
29
29
  # Claude Code stores its API key in the login keychain which auto-locks.
@@ -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
 
@@ -5,14 +5,14 @@
5
5
  # Watermark approach: tracks the last processed timestamp so nothing is missed.
6
6
  # Sessions from late-night/early-morning work are included in the next run.
7
7
  #
8
- # Logs to $NEXO_HOME/logs/deep-sleep.log
8
+ # Logs to $NEXO_HOME/runtime/logs/deep-sleep.log
9
9
 
10
10
  set -euo pipefail
11
11
 
12
12
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
13
13
  NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
14
14
  LOG_DIR="$NEXO_HOME/logs"
15
- DEEP_SLEEP_DIR="$NEXO_HOME/operations/deep-sleep"
15
+ DEEP_SLEEP_DIR="$NEXO_HOME/runtime/operations/deep-sleep"
16
16
  WATERMARK_FILE="$DEEP_SLEEP_DIR/.watermark"
17
17
  RUN_ID=$(date +%Y-%m-%d)
18
18
 
@@ -27,7 +27,7 @@ echo "$NOW" > "$DEBOUNCE_FILE"
27
27
 
28
28
  # 4. Find NEXO SID mapped to this Claude session_id
29
29
  NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
30
- DB="$NEXO_HOME/data/nexo.db"
30
+ DB="$NEXO_HOME/runtime/data/nexo.db"
31
31
  [ -f "$DB" ] || exit 0
32
32
 
33
33
  NEXO_SID=$(sqlite3 "$DB" "SELECT sid FROM sessions WHERE (external_session_id = '${CLAUDE_SID}' OR claude_session_id = '${CLAUDE_SID}') AND last_update_epoch > (strftime('%s','now') - 900) ORDER BY last_update_epoch DESC LIMIT 1;" 2>/dev/null)
@@ -6,7 +6,7 @@ set -euo pipefail
6
6
  SNAP_DIR="${1:?Usage: nexo-snapshot-restore.sh <snapshot-dir>}"
7
7
  MANIFEST="$SNAP_DIR/manifest.json"
8
8
  NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
9
- RESTORE_LOG="$NEXO_HOME/logs/snapshot-restores.log"
9
+ RESTORE_LOG="$NEXO_HOME/runtime/logs/snapshot-restores.log"
10
10
 
11
11
  if [ ! -f "$MANIFEST" ]; then
12
12
  echo "ERROR: No manifest.json in $SNAP_DIR" >&2
@@ -23,8 +23,8 @@ fi
23
23
  NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
24
24
  TCC_DB="$HOME/Library/Application Support/com.apple.TCC/TCC.db"
25
25
  VERSIONS_DIR="$HOME/.local/share/claude/versions"
26
- MARKER_DIR="$NEXO_HOME/data/.tcc-approved"
27
- LOG="$NEXO_HOME/logs/tcc-auto-approve.log"
26
+ MARKER_DIR="$NEXO_HOME/runtime/data/.tcc-approved"
27
+ LOG="$NEXO_HOME/runtime/logs/tcc-auto-approve.log"
28
28
 
29
29
  mkdir -p "$MARKER_DIR" "$(dirname "$LOG")"
30
30
 
@@ -13,16 +13,16 @@ set -uo pipefail
13
13
  HOME_DIR="$HOME"
14
14
  NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
15
15
  NEXO_DIR="$NEXO_HOME"
16
- CORTEX_DIR="$NEXO_HOME/brain"
17
- OPS_DIR="$NEXO_HOME/operations"
18
- LOG_DIR="$NEXO_HOME/logs"
19
- DB_PATH="$NEXO_HOME/data/nexo.db"
16
+ CORTEX_DIR="$NEXO_HOME/personal/brain"
17
+ OPS_DIR="$NEXO_HOME/runtime/operations"
18
+ LOG_DIR="$NEXO_HOME/runtime/logs"
19
+ DB_PATH="$NEXO_HOME/runtime/data/nexo.db"
20
20
  LOG="$LOG_DIR/watchdog.log"
21
21
  STATUS_JSON="$OPS_DIR/watchdog-status.json"
22
22
  REPORT_TXT="$OPS_DIR/watchdog-report.txt"
23
23
  ALERT_FILE="$OPS_DIR/.watchdog-alert"
24
- HASH_REGISTRY="$NEXO_HOME/scripts/.watchdog-hashes"
25
- FAIL_COUNT_FILE="$NEXO_HOME/scripts/.watchdog-fails"
24
+ HASH_REGISTRY="$NEXO_HOME/core/scripts/.watchdog-hashes"
25
+ FAIL_COUNT_FILE="$NEXO_HOME/core/scripts/.watchdog-fails"
26
26
  MAX_FAILS=3
27
27
 
28
28
  mkdir -p "$LOG_DIR" "$OPS_DIR"
@@ -47,8 +47,8 @@ log() { echo "[$TS] $1" >> "$LOG"; }
47
47
  # Add personal (non-manifest) monitors to PERSONAL_MONITORS below.
48
48
  NEXO_CODE="${NEXO_CODE:-$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)}"
49
49
  # Look for manifest in NEXO_HOME first (packaged install), then NEXO_CODE (dev/repo)
50
- if [ -f "$NEXO_HOME/crons/manifest.json" ]; then
51
- MANIFEST_FILE="$NEXO_HOME/crons/manifest.json"
50
+ if [ -f "$NEXO_HOME/runtime/crons/manifest.json" ]; then
51
+ MANIFEST_FILE="$NEXO_HOME/runtime/crons/manifest.json"
52
52
  else
53
53
  MANIFEST_FILE="$NEXO_CODE/crons/manifest.json"
54
54
  fi
@@ -166,7 +166,7 @@ done < <(_build_monitors_from_manifest)
166
166
  # Personal (non-manifest) monitors — add yours below.
167
167
  # These are NOT in manifest.json and won't be synced by cron-sync.
168
168
  PERSONAL_MONITORS=(
169
- # "My Service|com.nexo.my-service|$NEXO_HOME/logs/my-service.log||3600||Every 30 min|personal"
169
+ # "My Service|com.nexo.my-service|$NEXO_HOME/runtime/logs/my-service.log||3600||Every 30 min|personal"
170
170
  )
171
171
  MONITORS+=("${PERSONAL_MONITORS[@]+"${PERSONAL_MONITORS[@]}"}")
172
172
 
@@ -882,7 +882,7 @@ if [ -f "$HASH_REGISTRY" ]; then
882
882
  if [ "$ACTUAL" != "$expected_hash" ]; then
883
883
  TAMPERED=$((TAMPERED + 1))
884
884
  log "CRITICAL: Immutable file modified: $filepath"
885
- LATEST_SNAP=$(ls -td "$NEXO_HOME/snapshots/"*/ 2>/dev/null | head -1)
885
+ LATEST_SNAP=$(ls -td "$NEXO_HOME/runtime/snapshots/"*/ 2>/dev/null | head -1)
886
886
  if [ -n "$LATEST_SNAP" ] && [ -f "${LATEST_SNAP}files/${filepath#$HOME_DIR/}" ]; then
887
887
  cp "${LATEST_SNAP}files/${filepath#$HOME_DIR/}" "$filepath"
888
888
  log "RESTORED immutable file from snapshot"
@@ -1075,7 +1075,7 @@ fi
1075
1075
  # ============================================================================
1076
1076
  # Only triggers if: (a) there are FAILs after mechanical repair, (b) no NEXO
1077
1077
  # repair is already running, (c) no interactive session is active (avoid conflict)
1078
- REPAIR_LOCK="$NEXO_HOME/scripts/.watchdog-nexo-repair.lock"
1078
+ REPAIR_LOCK="$NEXO_HOME/core/scripts/.watchdog-nexo-repair.lock"
1079
1079
  REPAIR_COOLDOWN=1800 # 30 min between NEXO repair attempts
1080
1080
 
1081
1081
  if [ "$TOTAL_FAIL" -gt 0 ]; then
@@ -1162,19 +1162,19 @@ STEPS:
1162
1162
  2. Check stderr/stdout logs for the actual error
1163
1163
  3. Fix the root cause (missing file, bad config, dependency issue, etc.)
1164
1164
  4. Reload the service and verify it is running (launchctl on macOS, systemctl on Linux)
1165
- 5. Log what you did to $NEXO_HOME/logs/watchdog-repair-result.log
1165
+ 5. Log what you did to $NEXO_HOME/runtime/logs/watchdog-repair-result.log
1166
1166
  ${PROPAGATE_BLOCK}
1167
1167
 
1168
1168
  CONSTRAINTS:
1169
1169
  - Do NOT modify CLAUDE.md, AGENTS.md, or any protected file
1170
1170
  - Do NOT start interactive conversations
1171
1171
  - Keep it under 5 minutes
1172
- - Log what you did to $NEXO_HOME/logs/watchdog-repair-result.log
1172
+ - Log what you did to $NEXO_HOME/runtime/logs/watchdog-repair-result.log
1173
1173
  NEXOPROMPT
1174
1174
 
1175
1175
  # Launch NEXO in background with the configured automation backend.
1176
1176
  # Keep the hardened Claude fallback for older runtimes or partial installs.
1177
- AGENT_RUNNER="$NEXO_HOME/scripts/nexo-agent-run.py"
1177
+ AGENT_RUNNER="$NEXO_HOME/core/scripts/nexo-agent-run.py"
1178
1178
  NEXO_PYTHON="$NEXO_HOME/.venv/bin/python3"
1179
1179
  if [ ! -x "$NEXO_PYTHON" ]; then
1180
1180
  NEXO_PYTHON=$(command -v python3 2>/dev/null || echo "python3")
@@ -6,6 +6,7 @@ import importlib.util
6
6
  import inspect
7
7
  import json
8
8
  import os
9
+ import paths
9
10
  import re
10
11
  import sys
11
12
  from pathlib import Path
@@ -17,8 +18,8 @@ from script_registry import list_scripts
17
18
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
18
19
  NEXO_CODE = Path(__file__).resolve().parent
19
20
  SERVER_PATH = NEXO_CODE / "server.py"
20
- MANIFEST_PATHS = [NEXO_CODE / "crons" / "manifest.json", NEXO_HOME / "crons" / "manifest.json"]
21
- ATLAS_PATH = NEXO_HOME / "brain" / "project-atlas.json"
21
+ MANIFEST_PATHS = [NEXO_CODE / "crons" / "manifest.json", paths.crons_dir() / "manifest.json"]
22
+ ATLAS_PATH = paths.brain_dir() / "project-atlas.json"
22
23
 
23
24
  SECTION_ORDER = (
24
25
  "core_tools",
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import os
6
+ import paths
6
7
  import time
7
8
  import secrets
8
9
  import threading
@@ -26,7 +27,7 @@ from db import (
26
27
 
27
28
  KEEPALIVE_INTERVAL = 600 # 10 min — well inside the 15-min TTL
28
29
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
29
- SESSION_PORTABILITY_DIR = NEXO_HOME / "operations" / "session-portability"
30
+ SESSION_PORTABILITY_DIR = paths.operations_dir() / "session-portability"
30
31
 
31
32
  _keepalive_threads: dict[str, threading.Event] = {} # sid → stop_event
32
33
 
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import paths
7
8
  import shutil
8
9
  import sqlite3
9
10
  import tarfile
@@ -17,7 +18,7 @@ from runtime_home import export_resolved_nexo_home
17
18
 
18
19
  NEXO_HOME = export_resolved_nexo_home()
19
20
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
20
- EXPORTS_DIR = NEXO_HOME / "exports"
21
+ EXPORTS_DIR = paths.exports_dir()
21
22
  STAGING_DIR = EXPORTS_DIR / ".staging"
22
23
  USER_DIRS = ("brain", "coordination", "nexo-email", "assets")
23
24
  USER_CONFIG_FILES = ("schedule.json",)
@@ -182,12 +183,12 @@ def export_user_bundle(output_path: str = "") -> dict:
182
183
  sections: dict[str, dict] = {}
183
184
 
184
185
  try:
185
- data_db = NEXO_HOME / "data" / "nexo.db"
186
+ data_db = paths.db_path()
186
187
  if data_db.is_file():
187
188
  _sqlite_backup(data_db, bundle_root / "data" / "nexo.db")
188
189
  sections["data_db"] = {"path": "data/nexo.db"}
189
190
 
190
- brain_dir = NEXO_HOME / "brain"
191
+ brain_dir = paths.brain_dir()
191
192
  if brain_dir.is_dir():
192
193
  copied = _copy_tree_filtered(brain_dir, bundle_root / "brain", exclude_names={"nexo.db"})
193
194
  brain_db = brain_dir / "nexo.db"
@@ -271,7 +272,7 @@ def import_user_bundle(bundle_path: str) -> dict:
271
272
  if not archive_path.is_file():
272
273
  return {"ok": False, "error": f"bundle not found: {archive_path}"}
273
274
 
274
- backups_dir = NEXO_HOME / "backups"
275
+ backups_dir = paths.backups_dir()
275
276
  backups_dir.mkdir(parents=True, exist_ok=True)
276
277
  safety_backup = backups_dir / f"pre-import-user-data-{_now_stamp()}.tar.gz"
277
278
  safety_result = export_user_bundle(str(safety_backup))
@@ -300,15 +301,15 @@ def import_user_bundle(bundle_path: str) -> dict:
300
301
 
301
302
  data_db = bundle_root / "data" / "nexo.db"
302
303
  if data_db.is_file():
303
- _sqlite_backup(data_db, NEXO_HOME / "data" / "nexo.db")
304
+ _sqlite_backup(data_db, paths.db_path())
304
305
  restored["data_db"] = {"path": "data/nexo.db"}
305
306
 
306
307
  brain_dir = bundle_root / "brain"
307
308
  if brain_dir.is_dir():
308
- copied = _copy_tree_filtered(brain_dir, NEXO_HOME / "brain", exclude_names={"nexo.db"})
309
+ copied = _copy_tree_filtered(brain_dir, paths.brain_dir(), exclude_names={"nexo.db"})
309
310
  brain_db = brain_dir / "nexo.db"
310
311
  if brain_db.is_file():
311
- _sqlite_backup(brain_db, NEXO_HOME / "brain" / "nexo.db")
312
+ _sqlite_backup(brain_db, paths.brain_dir() / "nexo.db")
312
313
  copied += 1
313
314
  restored["brain"] = {"path": "brain", "files": copied}
314
315
 
@@ -328,7 +329,7 @@ def import_user_bundle(bundle_path: str) -> dict:
328
329
 
329
330
  imported_scripts = 0
330
331
  scripts_dir = bundle_root / "personal-scripts"
331
- target_scripts_dir = NEXO_HOME / "scripts"
332
+ target_scripts_dir = paths.personal_scripts_dir()
332
333
  target_scripts_dir.mkdir(parents=True, exist_ok=True)
333
334
  if scripts_dir.is_dir():
334
335
  for script_path in sorted(scripts_dir.iterdir()):