nexo-brain 7.25.2 → 7.25.4

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": "7.25.1",
3
+ "version": "7.25.4",
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,9 @@
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 `7.25.1` is the current packaged-runtime line. Patch release over v7.25.0 - shell guardrails skip non-path curl/wget arguments and daily protocol-debt audits keep ERROR classes visible by severity and type.
21
+ Version `7.25.4` is the current packaged-runtime line. Patch release over v7.25.3 - Local Memory starts from safe user-content and email roots, adds configurable included/excluded file types, and cleans legacy whole-disk index state with backup or archive-rebuild safety.
22
+
23
+ Previously in `7.25.3`: patch release over v7.25.2 - Desktop-managed Brain installs require the same Python ABI as the bundled wheels and repair incompatible managed virtual environments before reuse.
22
24
 
23
25
  Previously in `7.25.0`: minor release over v7.24.0 - Memory Fabric links transcript lookup, historical backup diary recovery, unified search and knowledge graph evidence so memories are not available only inside expiring snapshots.
24
26
 
package/bin/nexo-brain.js CHANGED
@@ -57,6 +57,8 @@ const DEFAULT_ASSISTANT_NAME = "Nova";
57
57
  const RESERVED_ASSISTANT_NAME_KEYS = new Set(["nexo", "nexobrain", "nexodesktop"]);
58
58
  const MIN_INSTALLER_PYTHON_MAJOR = 3;
59
59
  const MIN_INSTALLER_PYTHON_MINOR = 10;
60
+ const DESKTOP_BUNDLED_WHEEL_PYTHON_MAJOR = 3;
61
+ const DESKTOP_BUNDLED_WHEEL_PYTHON_MINOR = 12;
60
62
 
61
63
  function normalizeAssistantNameCandidate(value) {
62
64
  return String(value || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "");
@@ -278,11 +280,25 @@ function pythonVersionMeetsMinimum(versionText) {
278
280
  || (major === MIN_INSTALLER_PYTHON_MAJOR && minor >= MIN_INSTALLER_PYTHON_MINOR);
279
281
  }
280
282
 
283
+ function pythonVersionMatchesDesktopBundle(versionText) {
284
+ const match = String(versionText || "").trim().match(/^(\d+)\.(\d+)(?:\.|$)/);
285
+ if (!match) return false;
286
+ return Number(match[1]) === DESKTOP_BUNDLED_WHEEL_PYTHON_MAJOR
287
+ && Number(match[2]) === DESKTOP_BUNDLED_WHEEL_PYTHON_MINOR;
288
+ }
289
+
290
+ function pythonVersionUsableForInstaller(versionText) {
291
+ if (!pythonVersionMeetsMinimum(versionText)) return false;
292
+ if (isDesktopManagedInstall()) return pythonVersionMatchesDesktopBundle(versionText);
293
+ return true;
294
+ }
295
+
281
296
  function resolveInstallerPython() {
282
297
  const candidates = [
283
298
  process.env.NEXO_BOOTSTRAP_PYTHON,
284
299
  process.env.NEXO_RUNTIME_PYTHON,
285
300
  process.env.NEXO_PYTHON,
301
+ run("which python3.12"),
286
302
  run("which python3"),
287
303
  run("which python"),
288
304
  ].filter(Boolean);
@@ -292,7 +308,7 @@ function resolveInstallerPython() {
292
308
  if (!clean || seen.has(clean)) continue;
293
309
  seen.add(clean);
294
310
  const version = pythonVersion(clean);
295
- if (version && pythonVersionMeetsMinimum(version)) return clean;
311
+ if (version && pythonVersionUsableForInstaller(version)) return clean;
296
312
  }
297
313
  return "";
298
314
  }
@@ -366,9 +382,11 @@ function uniqueBackupPath(targetPath, suffix) {
366
382
  function ensureManagedVenvCompatible(venvPath, venvPython) {
367
383
  if (!fs.existsSync(venvPython)) return;
368
384
  const version = pythonVersion(venvPython);
369
- if (version && pythonVersionMeetsMinimum(version)) return;
385
+ if (version && pythonVersionUsableForInstaller(version)) return;
370
386
 
371
- const reason = version ? `Python ${version}` : "an unreadable Python executable";
387
+ const reason = version
388
+ ? `Python ${version}${isDesktopManagedInstall() ? " (Desktop bundle requires Python 3.12)" : ""}`
389
+ : "an unreadable Python executable";
372
390
  const backupPath = uniqueBackupPath(venvPath, "unsupported-python");
373
391
  log(` Existing Python virtual environment uses ${reason}; moving it aside to recreate.`);
374
392
  try {
@@ -3300,14 +3318,14 @@ async function runSetup() {
3300
3318
  }
3301
3319
  if (!python) {
3302
3320
  log("Python 3 not found and couldn't install automatically.");
3303
- log(platform === "darwin" ? "Install it: brew install python3" : "Install it: sudo apt install python3");
3321
+ log(platform === "darwin" ? "Install it: brew install python@3.12" : "Install it: sudo apt install python3");
3304
3322
  process.exit(1);
3305
3323
  }
3306
3324
  }
3307
3325
  const pyVersion = pythonVersion(python);
3308
- if (!pyVersion || !pythonVersionMeetsMinimum(pyVersion)) {
3326
+ if (!pyVersion || !pythonVersionUsableForInstaller(pyVersion)) {
3309
3327
  log(pyVersion
3310
- ? `Python at ${python} is ${pyVersion}; NEXO Brain requires Python >=${MIN_INSTALLER_PYTHON_MAJOR}.${MIN_INSTALLER_PYTHON_MINOR}.`
3328
+ ? `Python at ${python} is ${pyVersion}; NEXO Brain requires ${isDesktopManagedInstall() ? "Python 3.12 for Desktop bundled wheels" : `Python >=${MIN_INSTALLER_PYTHON_MAJOR}.${MIN_INSTALLER_PYTHON_MINOR}`}.`
3311
3329
  : `Python at ${python || "(not found)"} is not executable.`);
3312
3330
  process.exit(1);
3313
3331
  }
@@ -3618,7 +3636,7 @@ async function runSetup() {
3618
3636
  }
3619
3637
  if (fs.existsSync(venvPython)) {
3620
3638
  const venvVersion = pythonVersion(venvPython);
3621
- if (!venvVersion || !pythonVersionMeetsMinimum(venvVersion)) {
3639
+ if (!venvVersion || !pythonVersionUsableForInstaller(venvVersion)) {
3622
3640
  log(`Python virtual environment is unsupported after creation (${venvVersion || "unknown version"}).`);
3623
3641
  process.exit(1);
3624
3642
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.25.2",
3
+ "version": "7.25.4",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — 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",
@@ -21,7 +21,7 @@ import time
21
21
  from pathlib import Path
22
22
 
23
23
  try:
24
- from product_mode import enforce_desktop_product_contract
24
+ from product_mode import desktop_product_requested, enforce_desktop_product_contract
25
25
  except ModuleNotFoundError as exc:
26
26
  if getattr(exc, "name", "") != "product_mode":
27
27
  raise
@@ -30,7 +30,7 @@ except ModuleNotFoundError as exc:
30
30
  core_path = str(_core_runtime)
31
31
  if core_path not in sys.path:
32
32
  sys.path.insert(0, core_path)
33
- from product_mode import enforce_desktop_product_contract
33
+ from product_mode import desktop_product_requested, enforce_desktop_product_contract
34
34
  from runtime_home import export_resolved_nexo_home, managed_nexo_home
35
35
 
36
36
  try:
@@ -939,14 +939,94 @@ def _venv_pip_path(runtime_root: Path = NEXO_HOME) -> Path:
939
939
  return runtime_root / ".venv" / "bin" / "pip"
940
940
 
941
941
 
942
+ def _python_version_tuple(python_bin: Path | str) -> tuple[int, int, int] | None:
943
+ try:
944
+ result = subprocess.run(
945
+ [str(python_bin), "-c", "import sys; print('.'.join(map(str, sys.version_info[:3])))"],
946
+ capture_output=True,
947
+ text=True,
948
+ timeout=15,
949
+ )
950
+ except Exception:
951
+ return None
952
+ if result.returncode != 0:
953
+ return None
954
+ match = re.search(r"(\d+)\.(\d+)\.(\d+)", result.stdout or result.stderr or "")
955
+ if not match:
956
+ return None
957
+ return int(match.group(1)), int(match.group(2)), int(match.group(3))
958
+
959
+
960
+ def _managed_venv_python_supported(python_bin: Path | str) -> bool:
961
+ version = _python_version_tuple(python_bin)
962
+ if not version:
963
+ return False
964
+ if desktop_product_requested():
965
+ return version[:2] == (3, 12)
966
+ return version >= (3, 10, 0)
967
+
968
+
969
+ def _resolve_managed_venv_base_python() -> str:
970
+ candidates = [
971
+ os.environ.get("NEXO_BOOTSTRAP_PYTHON", ""),
972
+ os.environ.get("NEXO_RUNTIME_PYTHON", ""),
973
+ os.environ.get("NEXO_PYTHON", ""),
974
+ ]
975
+ if desktop_product_requested():
976
+ candidates.extend([
977
+ "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12",
978
+ "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3",
979
+ "/opt/homebrew/bin/python3.12",
980
+ "/usr/local/bin/python3.12",
981
+ shutil.which("python3.12") or "",
982
+ ])
983
+ candidates.append(sys.executable)
984
+ candidates.append(shutil.which("python3") or "")
985
+ for candidate in candidates:
986
+ clean = str(candidate or "").strip()
987
+ if clean and Path(clean).exists() and _managed_venv_python_supported(clean):
988
+ return clean
989
+ return sys.executable
990
+
991
+
992
+ def _archive_incompatible_runtime_venv(runtime_root: Path = NEXO_HOME, reason: str = "incompatible-python") -> Path | None:
993
+ venv_dir = runtime_root / ".venv"
994
+ if not venv_dir.exists():
995
+ return None
996
+ stamp = time.strftime("%Y%m%d-%H%M%S")
997
+ backup_root = runtime_root / "runtime" / "backups"
998
+ try:
999
+ backup_root.mkdir(parents=True, exist_ok=True)
1000
+ target = backup_root / f"venv-{reason}-{stamp}"
1001
+ counter = 2
1002
+ while target.exists():
1003
+ target = backup_root / f"venv-{reason}-{stamp}-{counter}"
1004
+ counter += 1
1005
+ shutil.move(str(venv_dir), str(target))
1006
+ _log(f"Archived incompatible managed venv at {target}")
1007
+ return target
1008
+ except Exception as exc:
1009
+ _log(f"venv archive failed: {exc}")
1010
+ return None
1011
+
1012
+
942
1013
  def _ensure_runtime_venv(runtime_root: Path = NEXO_HOME) -> Path | None:
943
1014
  venv_python = _venv_python_path(runtime_root)
944
1015
  if venv_python.exists():
945
- return venv_python
1016
+ if _managed_venv_python_supported(venv_python):
1017
+ return venv_python
1018
+ version = _python_version_tuple(venv_python)
1019
+ reason = f"python-{'.'.join(map(str, version[:2]))}" if version else "unreadable-python"
1020
+ if _archive_incompatible_runtime_venv(runtime_root, reason=reason) is None:
1021
+ return None
1022
+ base_python = _resolve_managed_venv_base_python()
1023
+ if not _managed_venv_python_supported(base_python):
1024
+ _log(f"no supported base Python found for managed venv: {base_python}")
1025
+ return None
946
1026
  try:
947
1027
  runtime_root.mkdir(parents=True, exist_ok=True)
948
1028
  result = subprocess.run(
949
- [sys.executable, "-m", "venv", str(runtime_root / ".venv")],
1029
+ [base_python, "-m", "venv", str(runtime_root / ".venv")],
950
1030
  capture_output=True,
951
1031
  text=True,
952
1032
  timeout=120,
@@ -965,7 +1045,7 @@ def _reinstall_pip_deps() -> bool:
965
1045
  req_file = SRC_DIR / "requirements.txt"
966
1046
  if not req_file.exists():
967
1047
  return True
968
- _ensure_runtime_venv(NEXO_HOME)
1048
+ venv_python = _ensure_runtime_venv(NEXO_HOME)
969
1049
  venv_pip = _venv_pip_path(NEXO_HOME)
970
1050
  if not venv_pip.exists() and sys.platform != "win32":
971
1051
  alt_pip = NEXO_HOME / ".venv" / "bin" / "pip3"
@@ -977,11 +1057,14 @@ def _reinstall_pip_deps() -> bool:
977
1057
  [str(venv_pip), "install", "--quiet", "-r", str(req_file)],
978
1058
  capture_output=True, text=True, timeout=120,
979
1059
  )
980
- else:
1060
+ elif not desktop_product_requested():
981
1061
  result = subprocess.run(
982
1062
  [sys.executable, "-m", "pip", "install", "--quiet", "-r", str(req_file), "--break-system-packages"],
983
1063
  capture_output=True, text=True, timeout=120,
984
1064
  )
1065
+ else:
1066
+ _log(f"managed venv unavailable for Desktop dependency repair: {venv_python}")
1067
+ return False
985
1068
  if result.returncode != 0:
986
1069
  _log(f"pip install failed (exit {result.returncode}): {result.stderr or result.stdout}")
987
1070
  return False
@@ -4665,7 +4748,7 @@ def _reinstall_runtime_pip_deps(runtime_root: Path = NEXO_HOME) -> bool:
4665
4748
  req_file = runtime_root / "requirements.txt"
4666
4749
  if not req_file.exists():
4667
4750
  return True
4668
- _ensure_runtime_venv(runtime_root)
4751
+ venv_python = _ensure_runtime_venv(runtime_root)
4669
4752
  venv_pip = _venv_pip_path(runtime_root)
4670
4753
  if not venv_pip.exists() and sys.platform != "win32":
4671
4754
  alt_pip = runtime_root / ".venv" / "bin" / "pip3"
@@ -4679,13 +4762,16 @@ def _reinstall_runtime_pip_deps(runtime_root: Path = NEXO_HOME) -> bool:
4679
4762
  text=True,
4680
4763
  timeout=120,
4681
4764
  )
4682
- else:
4765
+ elif not desktop_product_requested():
4683
4766
  result = subprocess.run(
4684
4767
  [sys.executable, "-m", "pip", "install", "--quiet", "-r", str(req_file), "--break-system-packages"],
4685
4768
  capture_output=True,
4686
4769
  text=True,
4687
4770
  timeout=120,
4688
4771
  )
4772
+ else:
4773
+ _log(f"managed venv unavailable for Desktop runtime dependency repair: {venv_python}")
4774
+ return False
4689
4775
  return result.returncode == 0
4690
4776
  except Exception:
4691
4777
  return False
package/src/cli.py CHANGED
@@ -1853,6 +1853,42 @@ def _local_context_exclusions(args) -> int:
1853
1853
  return _local_context_emit({"ok": False, "error": f"unsupported exclusions command: {command}"}, args)
1854
1854
 
1855
1855
 
1856
+ def _local_context_filetypes(args) -> int:
1857
+ import local_context
1858
+ command = str(getattr(args, "local_context_filetypes_command", "") or "")
1859
+ if command == "list":
1860
+ return _local_context_emit(local_context.list_file_type_rules(readonly=False), args)
1861
+ if command == "include":
1862
+ return _local_context_emit(
1863
+ local_context.set_file_type_rule(
1864
+ getattr(args, "extension", ""),
1865
+ action=getattr(args, "action", "extract") or "extract",
1866
+ reason=getattr(args, "reason", "user") or "user",
1867
+ ),
1868
+ args,
1869
+ )
1870
+ if command == "exclude":
1871
+ return _local_context_emit(
1872
+ local_context.set_file_type_rule(
1873
+ getattr(args, "extension", ""),
1874
+ action="ignore",
1875
+ reason=getattr(args, "reason", "user") or "user",
1876
+ ),
1877
+ args,
1878
+ )
1879
+ if command == "remove":
1880
+ return _local_context_emit(local_context.remove_file_type_rule(getattr(args, "extension", "")), args)
1881
+ if command == "reset":
1882
+ return _local_context_emit(local_context.reset_file_type_rules(), args)
1883
+ return _local_context_emit({"ok": False, "error": f"unsupported filetypes command: {command}"}, args)
1884
+
1885
+
1886
+ def _local_context_migrate_roots_v2(args) -> int:
1887
+ import local_context
1888
+ dry_run = not bool(getattr(args, "apply", False))
1889
+ return _local_context_emit(local_context.migrate_roots_seed_v2(dry_run=dry_run), args)
1890
+
1891
+
1856
1892
  def _local_context_query(args) -> int:
1857
1893
  import local_context
1858
1894
  return _local_context_emit(
@@ -3759,6 +3795,29 @@ def main():
3759
3795
  local_context_exclusions_remove_p.add_argument("path", help="Folder path")
3760
3796
  local_context_exclusions_remove_p.add_argument("--json", action="store_true", help="JSON output")
3761
3797
 
3798
+ local_context_filetypes_p = local_context_sub.add_parser("filetypes", help="Manage included and excluded file extensions")
3799
+ local_context_filetypes_sub = local_context_filetypes_p.add_subparsers(dest="local_context_filetypes_command")
3800
+ local_context_filetypes_list_p = local_context_filetypes_sub.add_parser("list", help="List file type rules")
3801
+ local_context_filetypes_list_p.add_argument("--json", action="store_true", help="JSON output")
3802
+ local_context_filetypes_include_p = local_context_filetypes_sub.add_parser("include", help="Include a file extension")
3803
+ local_context_filetypes_include_p.add_argument("extension", help="Extension, for example .pdf or asd")
3804
+ local_context_filetypes_include_p.add_argument("--action", choices=["extract", "metadata"], default="extract", help="How to index this extension")
3805
+ local_context_filetypes_include_p.add_argument("--reason", default="user", help="Reason label")
3806
+ local_context_filetypes_include_p.add_argument("--json", action="store_true", help="JSON output")
3807
+ local_context_filetypes_exclude_p = local_context_filetypes_sub.add_parser("exclude", help="Exclude a file extension")
3808
+ local_context_filetypes_exclude_p.add_argument("extension", help="Extension, for example .jpg or tmp")
3809
+ local_context_filetypes_exclude_p.add_argument("--reason", default="user", help="Reason label")
3810
+ local_context_filetypes_exclude_p.add_argument("--json", action="store_true", help="JSON output")
3811
+ local_context_filetypes_remove_p = local_context_filetypes_sub.add_parser("remove", help="Remove a user extension override")
3812
+ local_context_filetypes_remove_p.add_argument("extension", help="Extension to reset")
3813
+ local_context_filetypes_remove_p.add_argument("--json", action="store_true", help="JSON output")
3814
+ local_context_filetypes_reset_p = local_context_filetypes_sub.add_parser("reset", help="Remove all user extension overrides")
3815
+ local_context_filetypes_reset_p.add_argument("--json", action="store_true", help="JSON output")
3816
+
3817
+ local_context_migrate_roots_v2_p = local_context_sub.add_parser("migrate-roots-v2", help="Plan or apply Local Memory roots v2 cleanup")
3818
+ local_context_migrate_roots_v2_p.add_argument("--apply", action="store_true", help="Apply cleanup. Omit for dry-run.")
3819
+ local_context_migrate_roots_v2_p.add_argument("--json", action="store_true", help="JSON output")
3820
+
3762
3821
  local_context_query_p = local_context_sub.add_parser("query", help="Query local memory evidence")
3763
3822
  local_context_query_p.add_argument("query", help="Question or search phrase")
3764
3823
  local_context_query_p.add_argument("--intent", default="answer", help="Intent label included with the query result")
@@ -4407,6 +4466,13 @@ def main():
4407
4466
  local_context_exclusions_p.print_help()
4408
4467
  return 0
4409
4468
  return _local_context_exclusions(args)
4469
+ if args.local_context_command == "filetypes":
4470
+ if not args.local_context_filetypes_command:
4471
+ local_context_filetypes_p.print_help()
4472
+ return 0
4473
+ return _local_context_filetypes(args)
4474
+ if args.local_context_command == "migrate-roots-v2":
4475
+ return _local_context_migrate_roots_v2(args)
4410
4476
  if args.local_context_command == "query":
4411
4477
  return _local_context_query(args)
4412
4478
  if args.local_context_command == "diagnostics":
package/src/db/_schema.py CHANGED
@@ -1775,6 +1775,9 @@ def _m63_local_context_layer(conn):
1775
1775
  display_path TEXT NOT NULL,
1776
1776
  mode TEXT NOT NULL DEFAULT 'normal',
1777
1777
  depth INTEGER NOT NULL DEFAULT 2,
1778
+ source TEXT NOT NULL DEFAULT 'user',
1779
+ remote INTEGER NOT NULL DEFAULT 0,
1780
+ seed_version INTEGER NOT NULL DEFAULT 1,
1778
1781
  status TEXT NOT NULL DEFAULT 'active',
1779
1782
  last_scan_at REAL,
1780
1783
  created_at REAL NOT NULL,
@@ -1785,10 +1788,24 @@ def _m63_local_context_layer(conn):
1785
1788
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1786
1789
  path TEXT NOT NULL UNIQUE,
1787
1790
  display_path TEXT NOT NULL,
1791
+ source TEXT NOT NULL DEFAULT 'user',
1792
+ kind TEXT NOT NULL DEFAULT 'folder',
1788
1793
  reason TEXT NOT NULL DEFAULT 'user',
1789
1794
  created_at REAL NOT NULL
1790
1795
  );
1791
1796
 
1797
+ CREATE TABLE IF NOT EXISTS local_index_file_type_rules (
1798
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1799
+ extension TEXT NOT NULL,
1800
+ action TEXT NOT NULL DEFAULT 'ignore',
1801
+ source TEXT NOT NULL DEFAULT 'user',
1802
+ priority INTEGER NOT NULL DEFAULT 0,
1803
+ reason TEXT NOT NULL DEFAULT '',
1804
+ created_at REAL NOT NULL,
1805
+ updated_at REAL NOT NULL,
1806
+ UNIQUE(extension, source)
1807
+ );
1808
+
1792
1809
  CREATE TABLE IF NOT EXISTS local_index_jobs (
1793
1810
  job_id TEXT PRIMARY KEY,
1794
1811
  asset_id TEXT NOT NULL,
@@ -1940,6 +1957,12 @@ def _m63_local_context_layer(conn):
1940
1957
 
1941
1958
  CREATE INDEX IF NOT EXISTS idx_local_index_roots_status
1942
1959
  ON local_index_roots(status);
1960
+ CREATE INDEX IF NOT EXISTS idx_local_index_roots_source
1961
+ ON local_index_roots(source, status);
1962
+ CREATE INDEX IF NOT EXISTS idx_local_index_exclusions_source
1963
+ ON local_index_exclusions(source);
1964
+ CREATE INDEX IF NOT EXISTS idx_local_index_file_type_rules_ext
1965
+ ON local_index_file_type_rules(extension, source);
1943
1966
  CREATE INDEX IF NOT EXISTS idx_local_index_jobs_status_priority
1944
1967
  ON local_index_jobs(status, priority, created_at);
1945
1968
  CREATE INDEX IF NOT EXISTS idx_local_index_jobs_asset
@@ -549,6 +549,111 @@ def check_python_runtime() -> DoctorCheck:
549
549
  )
550
550
 
551
551
 
552
+ def _desktop_product_requested() -> bool:
553
+ try:
554
+ from product_mode import desktop_product_requested
555
+ return bool(desktop_product_requested())
556
+ except Exception:
557
+ return str(os.environ.get("NEXO_DESKTOP_MANAGED", "")).strip() == "1"
558
+
559
+
560
+ def _managed_venv_python_path() -> Path:
561
+ if sys.platform == "win32":
562
+ return NEXO_HOME / ".venv" / "Scripts" / "python.exe"
563
+ return NEXO_HOME / ".venv" / "bin" / "python3"
564
+
565
+
566
+ def _probe_python_version(python_bin: Path | str) -> tuple[int, int, int] | None:
567
+ try:
568
+ result = subprocess.run(
569
+ [str(python_bin), "-c", "import sys; print('.'.join(map(str, sys.version_info[:3])))"],
570
+ capture_output=True,
571
+ text=True,
572
+ timeout=15,
573
+ )
574
+ except Exception:
575
+ return None
576
+ if result.returncode != 0:
577
+ return None
578
+ import re
579
+ match = re.search(r"(\d+)\.(\d+)\.(\d+)", result.stdout or result.stderr or "")
580
+ if not match:
581
+ return None
582
+ return int(match.group(1)), int(match.group(2)), int(match.group(3))
583
+
584
+
585
+ def _managed_venv_version_supported(version: tuple[int, int, int] | None) -> bool:
586
+ if not version:
587
+ return False
588
+ if _desktop_product_requested():
589
+ return version[:2] == (3, 12)
590
+ return version >= (3, 10, 0)
591
+
592
+
593
+ def _repair_managed_venv_python() -> bool:
594
+ try:
595
+ import auto_update
596
+ return bool(auto_update._ensure_runtime_venv(NEXO_HOME)) and bool(auto_update._reinstall_pip_deps())
597
+ except Exception:
598
+ return False
599
+
600
+
601
+ def check_managed_venv_python(fix: bool = False) -> DoctorCheck:
602
+ """Check the managed NEXO venv is compatible with bundled runtime deps."""
603
+ venv_python = _managed_venv_python_path()
604
+ if not venv_python.exists():
605
+ if not _desktop_product_requested():
606
+ return DoctorCheck(
607
+ id="boot.managed_venv_python",
608
+ tier="boot",
609
+ status="healthy",
610
+ severity="info",
611
+ summary="Managed Python venv not present yet",
612
+ evidence=[str(venv_python)],
613
+ )
614
+ return DoctorCheck(
615
+ id="boot.managed_venv_python",
616
+ tier="boot",
617
+ status="degraded",
618
+ severity="warn",
619
+ summary="Managed Python venv missing",
620
+ evidence=[str(venv_python)],
621
+ repair_plan=["Run nexo doctor --tier boot --fix or nexo update to recreate the managed venv"],
622
+ )
623
+
624
+ version = _probe_python_version(venv_python)
625
+ if _managed_venv_version_supported(version):
626
+ return DoctorCheck(
627
+ id="boot.managed_venv_python",
628
+ tier="boot",
629
+ status="healthy",
630
+ severity="info",
631
+ summary=f"Managed venv Python {'.'.join(map(str, version))}",
632
+ evidence=[str(venv_python)],
633
+ )
634
+
635
+ summary = "Managed venv Python is incompatible"
636
+ if version:
637
+ summary = f"Managed venv Python {'.'.join(map(str, version))} is incompatible"
638
+ if _desktop_product_requested():
639
+ summary += " — Desktop bundled wheels require Python 3.12"
640
+ if fix and _repair_managed_venv_python():
641
+ post = check_managed_venv_python(fix=False)
642
+ if post.status == "healthy":
643
+ post.fixed = True
644
+ post.summary += " (fixed)"
645
+ return post
646
+ return DoctorCheck(
647
+ id="boot.managed_venv_python",
648
+ tier="boot",
649
+ status="degraded",
650
+ severity="warn",
651
+ summary=summary,
652
+ evidence=[str(venv_python)],
653
+ repair_plan=["Run nexo doctor --tier boot --fix or nexo update to archive and recreate the managed venv"],
654
+ )
655
+
656
+
552
657
  CRITICAL_CONFIG_FILES = (
553
658
  ("schedule.json", ("config", "schedule.json")),
554
659
  ("optionals.json", ("config", "optionals.json")),
@@ -803,6 +908,7 @@ def run_boot_checks(fix: bool = False) -> list[DoctorCheck]:
803
908
  safe_check(check_disk_space),
804
909
  safe_check(check_wrapper_scripts),
805
910
  safe_check(check_python_runtime),
911
+ safe_check(check_managed_venv_python, fix=fix),
806
912
  safe_check(check_config_parse),
807
913
  safe_check(check_core_dev_packaged_install),
808
914
  safe_check(check_dashboard_desktop_contract),
@@ -17,17 +17,22 @@ from .api import (
17
17
  get_asset,
18
18
  get_neighbors,
19
19
  list_exclusions,
20
+ list_file_type_rules,
20
21
  list_roots,
21
22
  local_index_hygiene,
23
+ migrate_roots_seed_v2,
22
24
  model_status,
23
25
  pause,
24
26
  performance_config,
25
27
  purge_asset,
26
28
  reconcile_live_changes,
27
29
  remove_exclusion,
30
+ remove_file_type_rule,
28
31
  remove_root,
32
+ reset_file_type_rules,
29
33
  resume,
30
34
  run_once,
35
+ set_file_type_rule,
31
36
  set_performance_profile,
32
37
  status,
33
38
  warmup_models,
@@ -45,17 +50,22 @@ __all__ = [
45
50
  "get_asset",
46
51
  "get_neighbors",
47
52
  "list_exclusions",
53
+ "list_file_type_rules",
48
54
  "list_roots",
49
55
  "local_index_hygiene",
56
+ "migrate_roots_seed_v2",
50
57
  "model_status",
51
58
  "pause",
52
59
  "performance_config",
53
60
  "purge_asset",
54
61
  "reconcile_live_changes",
55
62
  "remove_exclusion",
63
+ "remove_file_type_rule",
56
64
  "remove_root",
65
+ "reset_file_type_rules",
57
66
  "resume",
58
67
  "run_once",
68
+ "set_file_type_rule",
59
69
  "set_performance_profile",
60
70
  "status",
61
71
  "warmup_models",