diff --git a/bin/panel/network-popup.py b/bin/panel/network-popup.py index 256d661..13f87d9 100644 --- a/bin/panel/network-popup.py +++ b/bin/panel/network-popup.py @@ -59,6 +59,21 @@ def nmcli(*args): # load failed and each SSID row fell back to a "?" label. ICON_DIR = str(Path(__file__).resolve().parent.parent.parent / "assets" / "panel-icons") +def wg_connection(): + """(name, is_up) for the WireGuard tunnel, or (None, False) if there is + none. Asked of NetworkManager rather than hardcoded: this was a literal + 'wgs_client', so reissuing the tunnel under a new name left the switch + silently dead - it read Disconnected forever and only errored on click. + Prefers an active tunnel, else the first configured one.""" + wgs = [p for p in (line.split(":") for line + in nmcli("-f", "NAME,TYPE,STATE", "connection", "show").splitlines()) + if len(p) >= 3 and p[1] == "wireguard"] + for name, _, state in wgs: + if state == "activated": + return name, True + return (wgs[0][0], False) if wgs else (None, False) + + def signal_icon(sig): if sig >= 80: return f"{ICON_DIR}/network-wireless-signal-excellent.png" if sig >= 60: return f"{ICON_DIR}/network-wireless-signal-good.png" @@ -73,6 +88,7 @@ class NetworkPopup: self._click_x = None self.vpn_switch = None self.vpn_status_lbl = None + self.vpn_conn = None self._build_window() def _on_sig(s, f): @@ -201,12 +217,17 @@ class NetworkPopup: self._vpn_handler = self.vpn_switch.connect('state-set', self._on_vpn_toggle) vpn_row.pack_start(self.vpn_switch, False, False, 0) - # Set switch state immediately from sysfs — no nmcli round-trip needed - vpn_up = os.path.exists('/sys/class/net/wgs_client') + # show() rebuilds this content every time the popup opens, so the + # tunnel is re-resolved each time rather than cached at startup. + self.vpn_conn, vpn_up = wg_connection() self.vpn_switch.handler_block(self._vpn_handler) self.vpn_switch.set_active(vpn_up) + self.vpn_switch.set_sensitive(self.vpn_conn is not None) self.vpn_switch.handler_unblock(self._vpn_handler) - self.vpn_status_lbl.set_text("Connected" if vpn_up else "Disconnected") + self.vpn_status_lbl.set_text( + "Connected" if vpn_up + else "Disconnected" if self.vpn_conn + else "No tunnel configured") outer.pack_start(vpn_row, False, False, 0) outer.pack_start(Gtk.Separator(), False, False, 0) @@ -343,10 +364,15 @@ class NetworkPopup: return False # let GTK move the switch immediately; revert on failure def _do_vpn_toggle(self, enable): + if not self.vpn_conn: + subprocess.Popen(["notify-send", "-u", "critical", "WireGuard", + "No WireGuard connection configured"]) + GLib.idle_add(self._apply_vpn_state, False, "No tunnel configured") + return action = "up" if enable else "down" try: subprocess.check_output( - ["nmcli", "connection", action, "wgs_client"], + ["nmcli", "connection", action, self.vpn_conn], stderr=subprocess.STDOUT, text=True ) label = "Connected" if enable else "Disconnected" diff --git a/install-windows.ps1 b/install-windows.ps1 index 862fc0e..443bc30 100644 --- a/install-windows.ps1 +++ b/install-windows.ps1 @@ -12,7 +12,16 @@ Right-click install-windows.ps1 -> "Run with PowerShell" Requires Windows 10/11 with winget (App Installer). No WSL, no reboot. +.PARAMETER InvokerProfile + Internal. The PowerShell profile path of the user who launched the + installer, captured before self-elevation and passed to the elevated run. + If a standard account elevates with a DIFFERENT admin's credentials, the + elevated process sees that admin's $PROFILE - so ncp would be registered + for the wrong user. Not meant to be passed by hand. #> +param( + [string]$InvokerProfile = "" +) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" @@ -48,7 +57,11 @@ $IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIden [Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $IsAdmin) { Write-Host "Requesting administrator privileges..." -ForegroundColor Yellow - Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`"" -Verb RunAs + # Capture THIS user's profile path before elevating and hand it to the + # elevated run: if UAC prompts for another admin's credentials, that process + # would otherwise register ncp in the wrong user's profile. + $Invoker = $PROFILE.CurrentUserAllHosts + Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" -InvokerProfile `"$Invoker`"" -Verb RunAs exit } @@ -164,6 +177,44 @@ try { Write-Warn "Could not adjust Ollama autostart - you can still Start/Stop AI from the app." } +# -- ncp command --------------------------------------------------------------- +# The Linux side registers ncp in ~/.bashrc from restore-linux.sh; this is the +# same idea for PowerShell. Guarded by a marker so re-running the installer is a +# no-op instead of stacking duplicate functions. +Write-Step "Registering the ncp command" +try { + # CurrentUserAllHosts (profile.ps1), not $PROFILE: $PROFILE is host-specific, + # so ncp would exist in the console but not in the VS Code terminal or ISE. + # Prefer the invoking user's path when we were elevated - see -InvokerProfile. + $ProfilePath = if ($InvokerProfile) { $InvokerProfile } else { $PROFILE.CurrentUserAllHosts } + $ProfileDir = Split-Path -Parent $ProfilePath + if (-not (Test-Path $ProfileDir)) { + New-Item -ItemType Directory -Path $ProfileDir -Force | Out-Null + } + $Marker = "# NexusOS ncp" + $Existing = "" + if (Test-Path $ProfilePath) { + $Existing = Get-Content -Path $ProfilePath -Raw -ErrorAction SilentlyContinue + } + if ($Existing -and $Existing.Contains($Marker)) { + Write-OK "ncp already registered in $ProfilePath" + } else { + $NcpPs1 = Join-Path $RepoRoot "management\ncp.ps1" + # Without this, a Join-Path that fails writes `function ncp { & "" }` - + # a broken profile with no error. Fail into the catch below instead. + if (-not (Test-Path $NcpPs1)) { throw "ncp.ps1 not found at '$NcpPs1'" } + # ASCII encoding: the content is ASCII anyway, and -Encoding UTF8 on + # PowerShell 5.1 can plant a BOM in the middle of an existing profile. + Add-Content -Path $ProfilePath -Encoding ASCII -Value "" + Add-Content -Path $ProfilePath -Encoding ASCII -Value $Marker + Add-Content -Path $ProfilePath -Encoding ASCII -Value "function ncp { & `"$NcpPs1`" @args }" + Write-OK "ncp registered in $ProfilePath" + } +} catch { + Write-Warn "Could not register ncp - add this line to your PowerShell profile:" + Write-Warn " function ncp { & '$RepoRoot\management\ncp.ps1' @args }" +} + # -- Desktop shortcut ---------------------------------------------------------- Write-Step "Creating desktop shortcut" try { @@ -193,6 +244,9 @@ Write-Host " (or run powershell -File launch_nexus.ps1)" -ForegroundC Write-Host "" Write-Host " The app opens at http://localhost:8000" -ForegroundColor White Write-Host " The AI starts OFF - click 'Start AI' in the sidebar to turn it on." -ForegroundColor White +Write-Host "" +Write-Host " Terminal: open a NEW PowerShell window, then run ncp help" -ForegroundColor White +Write-Host " (the profile that defines ncp is only read at startup)" -ForegroundColor DarkGray Write-Host " ==========================================================" -ForegroundColor Green Write-Host "" Read-Host "Press Enter to close" diff --git a/management/ncp.ps1 b/management/ncp.ps1 new file mode 100644 index 0000000..1a67d7b --- /dev/null +++ b/management/ncp.ps1 @@ -0,0 +1,28 @@ +# ncp - Windows entry point. The CLI itself is management\ncp.py, the same file +# the Linux box runs; this only picks an interpreter and forwards the arguments. +# +# Register it once per machine by adding this to your PowerShell profile +# (notepad $PROFILE): +# +# function ncp { & "$HOME\nexus-core\management\ncp.ps1" @args } +# +# ASCII only, no exceptions: PowerShell 5.1 decodes BOM-less files as ANSI, so a +# single Unicode dash eats a quote and the script dies at parse time. A test in +# tests/test_smoke.py fails if any .ps1 in this repo gains a non-ASCII byte. + +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) + +# Prefer the venv interpreter (ncp.py needs psutil for service management), but +# fall back to system Python so backup/restore still work before the venv is +# built - those delegate to the stdlib-only bin\sync.py. +$Py = Join-Path $Root "Promethean\Scripts\python.exe" +if (-not (Test-Path $Py)) { + $Py = (Get-Command python -ErrorAction SilentlyContinue).Source +} +if (-not $Py) { + Write-Error "No Python found. Run install-windows.ps1 first." + exit 1 +} + +& $Py (Join-Path $Root "management\ncp.py") @args +exit $LASTEXITCODE diff --git a/management/ncp.py b/management/ncp.py new file mode 100644 index 0000000..662e340 --- /dev/null +++ b/management/ncp.py @@ -0,0 +1,711 @@ +#!/usr/bin/env python3 +"""ncp - the NexusOS management CLI, one implementation for Linux and Windows. + +This replaces the logic that lived in management/nexus-cli.sh. That script was +bash + pkill + fuser + /proc, so the Windows box had no `ncp` at all - the same +split that made bin/install.sh rot until it was rsyncing from a path retired +months earlier. Same fix as bin/sync.py: the portable half is Python, and the +per-platform pieces are small and explicit. + +nexus-cli.sh is now a two-line wrapper, so `ncp`, launch_nexus.sh, +bin/restore-linux.sh, controlpanel.py, nexus-popup.py and nexus-app.sh all keep +calling what they always called. + +psutil does the process work (cmdlines, pattern sweeps, port owners). It is +declared in requirements-base.txt AND requirements-wsl.txt, so both boxes have +it - but it is imported lazily so `ncp backup`/`restore` still run before the +venv exists, exactly as they did when they shelled out to sync.py. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PID_DIR = ROOT / "runtime" / "pids" +LOG_DIR = ROOT / "runtime" +FRONTEND_DIR = ROOT / "interface" / "web" +OLLAMA_BIN = ROOT / "ollama" / "bin" / ("ollama.exe" if os.name == "nt" else "ollama") +OLLAMA_MODELS_DIR = ROOT / "models" + +WINDOWS = os.name == "nt" +PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3") + +PID_DIR.mkdir(parents=True, exist_ok=True) +LOG_DIR.mkdir(parents=True, exist_ok=True) + +# The output carries em-dashes and check marks (matching what nexus-cli.sh +# printed). On Windows a redirected stdout defaults to cp1252, which raises +# UnicodeEncodeError on both - so pin UTF-8 rather than degrade the output. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + + +# -- small helpers ------------------------------------------------------------- + +def http_ok(url: str, timeout: float = 1.0) -> bool: + """True if the URL answers at all. Any HTTP status counts - a 404 still + proves something is listening, which is all the port checks care about.""" + try: + urllib.request.urlopen(url, timeout=timeout).read(1) + return True + except urllib.error.HTTPError: + return True + except Exception: + return False + + +def npm() -> str | None: + """npm, resolving through nvm. The bash version sourced ~/.nvm/nvm.sh; a + non-login shell has neither, so fall back to globbing the nvm install.""" + found = shutil.which("npm") # resolves npm.cmd on Windows + if found: + return found + versions = sorted((Path.home() / ".nvm" / "versions" / "node").glob("*/bin/npm")) + return str(versions[-1]) if versions else None + + +def _psutil(): + try: + import psutil + return psutil + except ImportError: + sys.exit("psutil is missing - run ./install.sh (or install-windows.ps1) to rebuild the venv.") + + +# -- services ------------------------------------------------------------------ + +class Service: + def __init__(self, key, label, port, cwd, patterns, argv=None): + self.key, self.label, self.port = key, label, port + self.cwd, self.patterns, self._argv = cwd, patterns, argv + self.pid_file = PID_DIR / f"{key}.pid" + self.log_file = LOG_DIR / f"{key}.log" + + @property + def url(self) -> str: + return f"http://localhost:{self.port}/" + + def argv(self): + return self._argv() if callable(self._argv) else self._argv + + +def _uvicorn(app: str, port: int): + return [str(PYTHON), "-m", "uvicorn", app, "--host", "0.0.0.0", + "--port", str(port), "--reload"] + + +SERVICES = { + "memory": Service("memory", "NEXUS MEMORY SERVICE", 8001, ROOT, + ["uvicorn synapse.memory"], + lambda: _uvicorn("synapse.memory.service:app", 8001)), + "backend": Service("backend", "NEXUS BACKEND SERVICE", 8000, ROOT, + ["uvicorn synapse.main"], + lambda: _uvicorn("synapse.main:sio_app", 8000)), + "frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR, + ["vite --host", "npm run dev"], + lambda: [npm(), "run", "dev", "--", "--host", "0.0.0.0"]), +} + + +def read_pid(svc: Service): + try: + return int(svc.pid_file.read_text().strip()) + except (OSError, ValueError): + return None + + +def alive(pid) -> bool: + ps = _psutil() + return pid is not None and ps.pid_exists(pid) + + +def pid_is_ours(pid, patterns) -> bool: + """True only if the live PID's command line matches one of the service + patterns. PID files outlive reboots and the OS recycles the number onto an + unrelated process - often a desktop-session one - so a bare liveness check + is not enough. TERMing a recycled PID can log the user out.""" + ps = _psutil() + try: + cmd = " ".join(ps.Process(pid).cmdline()) + except Exception: + return False + return any(p in cmd for p in patterns) + + +def launch(svc: Service) -> None: + pid = read_pid(svc) + if alive(pid) and pid_is_ours(pid, svc.patterns): + return + argv = svc.argv() + if argv[0] is None: + print(f" {svc.label}: npm not found - install Node, or run ./install.sh") + return + svc.log_file.write_text("") + with open(svc.log_file, "ab") as log: + # Detach so the service outlives this process, on both platforms. + kwargs = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP + | getattr(subprocess, "DETACHED_PROCESS", 0)} + if WINDOWS else {"start_new_session": True}) + proc = subprocess.Popen(argv, cwd=str(svc.cwd), stdout=log, + stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, + **kwargs) + svc.pid_file.write_text(str(proc.pid)) + + +def check(svc: Service) -> bool: + pid = read_pid(svc) + if alive(pid): + print(f"{svc.label} STARTED") + return True + print(f"{svc.label} FAILED TO START") + try: + tail = svc.log_file.read_text(errors="replace").splitlines()[-8:] + for line in tail: + print(f" {line}") + except OSError: + pass + svc.pid_file.unlink(missing_ok=True) + return False + + +def wait_for_port(svc: Service, timeout: int = 30) -> bool: + if http_ok(svc.url): + print(f" {svc.label} already running (:{svc.port})") + return True + for _ in range(timeout): + time.sleep(1) + if http_ok(svc.url): + print(f"{svc.label} STARTED") + return True + print(f" {svc.label} timed out after {timeout}s") + return check(svc) + + +def wait_for_port_close(port: int, timeout: int = 15) -> bool: + for _ in range(timeout): + if not http_ok(f"http://localhost:{port}/"): + return True + time.sleep(1) + return False + + +def kill_matching(patterns, force=False) -> None: + """The pkill -f sweep: catches reparented grandchildren (npm -> sh -> node + vite), uvicorn --reload workers, and instances started outside this CLI.""" + ps = _psutil() + me = os.getpid() + for proc in ps.process_iter(["pid", "cmdline"]): + if proc.info["pid"] == me: + continue + cmd = " ".join(proc.info["cmdline"] or []) + if any(p in cmd for p in patterns): + try: + proc.kill() if force else proc.terminate() + except Exception: + pass + + +def kill_port(port: int) -> bool: + """Whatever holds the port IS the service - this is the backstop that makes + stop reliable regardless of process-tree shape or PID-file accuracy.""" + ps = _psutil() + killed = False + try: + conns = ps.net_connections(kind="inet") + except Exception: + return False + for conn in conns: + if conn.laddr and conn.laddr.port == port and conn.status == "LISTEN" and conn.pid: + try: + ps.Process(conn.pid).kill() + killed = True + except Exception: + pass + return killed + + +def stop_service(svc: Service) -> bool: + """Three escalating passes, with the PORT as the source of truth for + whether the service is actually down.""" + pid = read_pid(svc) + if pid is not None: + if alive(pid) and pid_is_ours(pid, svc.patterns): + ps = _psutil() + try: + proc = ps.Process(pid) + for child in proc.children(recursive=True): + try: + child.terminate() + except Exception: + pass + proc.terminate() + except Exception: + pass + svc.pid_file.unlink(missing_ok=True) + + kill_matching(svc.patterns) + + if not wait_for_port_close(svc.port): + kill_port(svc.port) + kill_matching(svc.patterns, force=True) + wait_for_port_close(svc.port) + if http_ok(svc.url): + print(f"{svc.label} STILL RUNNING (:{svc.port}) — try 'ncp kill'") + return False + print(f"{svc.label} STOPPED") + return True + + +# -- ollama -------------------------------------------------------------------- + +def start_ollama() -> None: + """Driven through the backend endpoint (the path the control panel uses) + rather than launching the binary, because OllamaManager owns model and GPU + selection. Requires the backend to be up.""" + print("Starting OLLAMA...") + req = urllib.request.Request("http://localhost:8000/ollama/start", method="POST") + try: + urllib.request.urlopen(req, timeout=30).read(1) + print("NEXUS OLLAMA STARTED") + except Exception: + print(" OLLAMA start request failed (backend not reachable on :8000)") + + +def stop_ollama() -> None: + """Prefer the backend endpoint for a clean OllamaManager shutdown; if the + backend is already down, kill `ollama serve` directly so it never lingers + holding VRAM/RAM. Must run BEFORE the backend is torn down.""" + req = urllib.request.Request("http://localhost:8000/ollama/stop", method="POST") + try: + urllib.request.urlopen(req, timeout=5).read(1) + print("NEXUS OLLAMA STOPPED") + return + except Exception: + pass + kill_matching(["ollama serve"]) + print("NEXUS OLLAMA STOPPED (direct)") + + +# -- commands ------------------------------------------------------------------ + +def cmd_start(target) -> None: + if target in ("--memory", "-m"): + launch(SERVICES["memory"]); wait_for_port(SERVICES["memory"]) + elif target in ("--backend", "-b"): + launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"]) + elif target in ("--frontend", "-f"): + launch(SERVICES["frontend"]); wait_for_port(SERVICES["frontend"]) + elif target in ("--ai", "-a"): + start_ollama() + elif target in (None, "", "all"): + # Memory + backend in parallel, both ready before the frontend starts. + launch(SERVICES["memory"]) + launch(SERVICES["backend"]) + wait_for_port(SERVICES["memory"]) + wait_for_port(SERVICES["backend"]) + launch(SERVICES["frontend"]) + wait_for_port(SERVICES["frontend"]) + else: + show_help() + + +def cmd_stop(target) -> None: + if target in ("--memory", "-m"): + stop_service(SERVICES["memory"]) + elif target in ("--backend", "-b"): + stop_ollama(); stop_service(SERVICES["backend"]) + elif target in ("--frontend", "-f"): + stop_service(SERVICES["frontend"]) + elif target in (None, "", "all"): + stop_service(SERVICES["memory"]) + stop_ollama() + stop_service(SERVICES["backend"]) + stop_service(SERVICES["frontend"]) + else: + show_help() + + +def cmd_kill() -> None: + print("Force-killing all Nexus processes...") + for port, name in ((8000, "SYNAPSE"), (8001, "MEMORY"), + (5173, "INTERFACE"), (11434, "OLLAMA")): + if kill_port(port): + print(f" KILLED: {name} (:{port})") + else: + print(f" NOT RUNNING: {name} (:{port})") + kill_matching(["uvicorn synapse", "npm run dev", "vite --host", "ollama serve"], + force=True) + for pid_file in PID_DIR.glob("*.pid"): + pid_file.unlink(missing_ok=True) + print("Done.") + + +def cmd_status() -> None: + print("Nexus Service Status:\n") + + def one(name, svc): + pid = read_pid(svc) + if alive(pid): + print(f" {name}: RUNNING (PID {pid})") + elif http_ok(svc.url): + print(f" {name}: RUNNING (port :{svc.port}, PID stale — consider ncp kill)") + else: + print(f" {name}: STOPPED") + + print("Backend:") + one("Synapse ", SERVICES["backend"]) + one("Memory service", SERVICES["memory"]) + print("\nFrontend:") + one("Vite ", SERVICES["frontend"]) + print("\nModel server:") + running = http_ok("http://localhost:11434/api/tags") + print(f" Ollama : {'RUNNING (:11434)' if running else 'STOPPED'}") + + +def _tail(path: Path, n: int) -> None: + try: + for line in path.read_text(errors="replace").splitlines()[-n:]: + print(line) + except OSError: + print(f" (no log at {path})") + + +LOG_HEADINGS = {"memory": "MEMORY SERVICE", "backend": "BACKEND", "frontend": "FRONTEND"} + + +def cmd_logs(target) -> None: + named = {"--frontend": "frontend", "-f": "frontend", + "--backend": "backend", "-b": "backend", + "--memory": "memory", "-m": "memory"} + if target in named: + key = named[target] + print(f"=== {LOG_HEADINGS[key]} LOGS ===") + _tail(SERVICES[key].log_file, 50) + elif target in (None, "", "all"): + for i, key in enumerate(("memory", "backend", "frontend")): + if i: + print() + print(f"=== {LOG_HEADINGS[key]} LOGS ===") + _tail(SERVICES[key].log_file, 30) + else: + print(f"Unknown logs target: '{target}'") + print("Usage: ncp logs [frontend|backend|memory|all]") + + +def cmd_doctor() -> None: + def mark(ok, good, bad): + print(f" {'✔' if ok else '✘'} {good if ok else bad}") + + print("Running Nexus Diagnostics...\n") + print("Checking directories...") + mark(ROOT.is_dir(), "Nexus root found", "Missing Nexus root") + mark(FRONTEND_DIR.is_dir(), "Frontend directory found", "Missing frontend directory") + + print("\nChecking Python venv...") + if PYTHON.exists(): + ver = subprocess.run([str(PYTHON), "--version"], capture_output=True, + text=True).stdout.strip() + mark(True, f"Promethean venv found ({ver})", "") + else: + mark(False, "", f"Promethean venv missing at {PYTHON}") + + print("\nChecking Node & npm...") + + def version(exe): + if not exe: + return "" + try: + return subprocess.run([exe, "-v"], capture_output=True, text=True).stdout.strip() + except OSError: + return "" + + node, npm_path = shutil.which("node"), npm() + mark(bool(node), f"Node installed ({version(node)})", "Node missing") + mark(bool(npm_path), f"npm installed ({version(npm_path)})", "npm missing") + mark((FRONTEND_DIR / "node_modules").is_dir(), + "Frontend node_modules installed", + "Frontend node_modules missing (run: ncp update)") + + print("\nChecking Uvicorn...") + uvicorn_ok = subprocess.run([str(PYTHON), "-m", "uvicorn", "--version"], + capture_output=True).returncode == 0 + mark(uvicorn_ok, "Uvicorn installed", "Uvicorn missing") + + def importable(stmt): + return subprocess.run([str(PYTHON), "-c", stmt], cwd=str(ROOT), + capture_output=True).returncode == 0 + + print("\nChecking backend service...") + mark(importable("from synapse.main import sio_app"), + "Backend module importable", "Backend module failed to import") + + print("\nChecking memory service...") + mark((ROOT / "synapse" / "memory").is_dir(), + "Memory module directory found", "Missing memory module directory") + mark(importable("from synapse.memory.service import app"), + "Memory service module importable", "Memory service module failed to import") + mark(os.access(ROOT / "synapse" / "memory", os.W_OK), + "Memory database directory writable", "Memory database directory not writable") + + print("\nChecking Ollama...") + mark(OLLAMA_BIN.exists(), f"Ollama binary found ({OLLAMA_BIN})", + f"Ollama binary missing at {OLLAMA_BIN}") + mark(OLLAMA_MODELS_DIR.is_dir(), f"Ollama models directory found ({OLLAMA_MODELS_DIR})", + f"Ollama models directory missing at {OLLAMA_MODELS_DIR}") + print() + cmd_status() + + +def cmd_update() -> None: + print("Updating Project Nexus...\n") + print("Skipping git pull — update only refreshes dependencies.\n") + # Same overlay choice sync.py makes, so update and restore cannot install + # different PyTorch builds on the same host. + sys.path.insert(0, str(ROOT / "bin")) + import importlib.util + spec = importlib.util.spec_from_file_location("sync", ROOT / "bin" / "sync.py") + sync = importlib.util.module_from_spec(spec) + spec.loader.exec_module(sync) + req = sync.requirements() + print(f"Updating backend Python dependencies ({req})...") + subprocess.run([str(PYTHON), "-m", "pip", "install", "-r", req], cwd=str(ROOT)) + + print("\nUpdating frontend dependencies...") + npm_path = npm() + if npm_path and FRONTEND_DIR.is_dir(): + subprocess.run([npm_path, "install"], cwd=str(FRONTEND_DIR)) + else: + print("npm or frontend directory missing — skipping npm install.") + + print("\nRunning post-update diagnostics...") + cmd_doctor() + + +def cmd_clean() -> None: + print("Cleaning Nexus runtime files...\n") + print("Removing PID files...") + for f in PID_DIR.glob("*.pid"): + f.unlink(missing_ok=True) + print("Removing logs...") + for f in LOG_DIR.glob("*.log"): + f.unlink(missing_ok=True) + print("Removing Python cache...") + for d in ROOT.rglob("__pycache__"): + shutil.rmtree(d, ignore_errors=True) + print("Removing Node/Vite cache...") + for d in FRONTEND_DIR.rglob(".vite"): + shutil.rmtree(d, ignore_errors=True) + print("\nCleanup complete.") + + +MODEL_CATALOG = [ + ("gemma3:1b", "~815 MB", "Google Gemma 3 — fast, lightweight"), + ("gemma3:4b", "~3.3 GB", "Google Gemma 3 — balanced"), + ("gemma3:12b", "~8.1 GB", "Google Gemma 3 — capable"), + ("llama3.2:1b", "~1.3 GB", "Meta Llama 3.2 — fast, lightweight"), + ("llama3.2:3b", "~2.0 GB", "Meta Llama 3.2 — compact, capable"), + ("llama3.1:8b", "~4.7 GB", "Meta Llama 3.1 — strong general use"), + ("mistral:latest", "~4.1 GB", "Mistral 7B — solid all-rounder"), + ("mistral-nemo", "~7.1 GB", "Mistral Nemo 12B — strong reasoning"), + ("qwen2.5:3b", "~2.0 GB", "Alibaba Qwen 2.5 — great at code"), + ("qwen2.5:7b", "~4.7 GB", "Alibaba Qwen 2.5 — strong coder"), + ("phi4-mini", "~2.5 GB", "Microsoft Phi-4 Mini — efficient"), + ("phi4:14b", "~8.9 GB", "Microsoft Phi-4 — strong reasoning"), + ("deepseek-r1:7b", "~4.7 GB", "DeepSeek R1 — reasoning model"), + ("deepseek-r1:14b", "~9.0 GB", "DeepSeek R1 — strong reasoning"), + ("codellama:7b", "~3.8 GB", "Meta Code Llama — code focused"), + ("nomic-embed-text", "~274 MB", "Text embeddings model"), +] + + +def cmd_models(action, name) -> None: + if action == "list": + if not http_ok("http://localhost:11434/api/tags"): + print("Ollama is not running. Start the backend first with: ncp start -b") + return + raw = urllib.request.urlopen("http://localhost:11434/api/tags", timeout=5).read() + models = json.loads(raw).get("models", []) + print("Installed models:\n") + if not models: + print(" No models installed.") + return + for m in models: + mb = m["size"] // 1024 // 1024 + size = f"{mb / 1024:.1f} GB" if mb >= 1024 else f"{mb} MB" + print(f" {m['name']:<35} {size}") + elif action in ("available", "search"): + print("Available models (via Ollama library):\n") + print(f" {'MODEL':<30} {'SIZE':<10} DESCRIPTION") + print(f" {'-----':<30} {'----':<10} -----------") + for model, size, desc in MODEL_CATALOG: + print(f" {model:<30} {size:<10} {desc}") + print("\nInstall any model with: ncp models install ") + print("Browse more at: https://ollama.com/library") + elif action == "install": + if not name: + print("Usage: ncp models install ") + print("Run 'ncp models available' to see options.") + return + if not OLLAMA_BIN.exists(): + print(f"Ollama binary not found at {OLLAMA_BIN}") + return + print(f"Pulling '{name}' into {OLLAMA_MODELS_DIR} ...\n") + subprocess.run([str(OLLAMA_BIN), "pull", name], + env={**os.environ, "OLLAMA_MODELS": str(OLLAMA_MODELS_DIR)}) + print("\nDone. Run 'ncp models list' to verify.") + else: + print("Usage: ncp models >") + + +def sync_py(*args) -> int: + """sync.py is stdlib-only, so system python works before the venv exists.""" + py = str(PYTHON) if PYTHON.exists() else sys.executable + return subprocess.run([py, str(ROOT / "bin" / "sync.py"), *args]).returncode + + +def cmd_restore(flag) -> int: + if flag in ("-c", "--claude", "check"): + return sync_py("restore", "--check") # dry run: no prompt, changes nothing + print("This pulls the latest backup from Gitea and rebuilds the environment") + print("(venv, npm, theme, panel). Local commits must be pushed or stashed first.") + if input("Continue? [y/N] ").strip().lower() != "y": + print("Restore cancelled.") + return 0 + return sync_py("restore") + + +def cmd_web() -> int: + """Per-platform UI launcher. On Linux nexus-app.sh already raises an existing + window, acts as a viewer when the stack is up, and only stops services it + started. Windows uses the pywebview window launch_nexus.ps1 opens.""" + if WINDOWS: + if not http_ok(SERVICES["backend"].url): + launch(SERVICES["memory"]) + launch(SERVICES["backend"]) + wait_for_port(SERVICES["backend"]) + return subprocess.run([str(PYTHON), str(ROOT / "bin" / "nexus_window.py")]).returncode + return subprocess.run([str(ROOT / "management" / "nexus-app.sh")]).returncode + + +def show_help() -> None: + print("""Nexus Command Tree + +Usage: ncp [options] + +Commands: + panel Launch the Nexus Control Panel (tkinter) + web Open the web interface (starts the stack if needed) + + chat Send a message, stream the reply + memory list List memory facts + add Add a fact (--section ) + rm Delete a fact by id + playbook list List playbooks (* = active) + show Print a playbook's goal + instructions + history [query] Recent conversations (optional keyword) + + start Start ALL Nexus services (memory + backend + frontend) + --memory, -m Start only the memory service + --frontend,-f Start only the frontend + --backend, -b Start only the backend + --ai, -a Start the AI (Ollama) — manual; not started by default + + stop Stop ALL Nexus services + --memory, -m Stop only the memory service + --frontend,-f Stop only the frontend + --backend, -b Stop only the backend + + kill Force-kill all Nexus processes by port (nuclear option) + refresh Restart all services + + status Show service status + logs Show logs for all services + --memory, -m Memory service logs + --frontend,-f Frontend logs + --backend, -b Backend logs + + doctor Run Nexus diagnostics + update Update Nexus dependencies + clean Remove runtime files and caches + + models + list List installed models + available Show models available to install + install Pull a model into Nexus + + backup Backup Nexus to Gitea (git commit + push) + backup -f, full Backup + snapshot live desktop wiring/notes + backup -c Dry run: what a backup would commit/push + restore Restore Nexus from Gitea (git pull + rebuild) + restore -c Dry run: what a restore would apply + + help, -h Show this help message""") + + +def main(argv) -> int: + cmd = argv[0] if argv else "" + rest = argv[1:] + arg = rest[0] if rest else None + + if cmd in ("help", "-h", ""): + show_help() + elif cmd == "panel": + return subprocess.run([str(PYTHON), str(ROOT / "management" / "controlpanel.py")]).returncode + elif cmd == "web": + return cmd_web() + elif cmd in ("chat", "memory", "playbook", "history"): + return subprocess.run([str(PYTHON), str(ROOT / "management" / "nexus_api.py"), + cmd, *rest]).returncode + elif cmd == "start": + cmd_start(arg) + elif cmd == "stop": + cmd_stop(arg) + elif cmd == "refresh": + cmd_stop(None) + cmd_start(None) + elif cmd == "kill": + cmd_kill() + elif cmd == "status": + cmd_status() + elif cmd == "logs": + cmd_logs(arg) + elif cmd == "doctor": + cmd_doctor() + elif cmd == "update": + cmd_update() + elif cmd == "clean": + cmd_clean() + elif cmd == "nvidia-reqs": + return subprocess.run([str(PYTHON), str(ROOT / "bin" / "gen-nvidia-reqs.py")]).returncode + elif cmd == "backup": + if arg in ("-f", "full"): + return sync_py("backup", "--full") + if arg in ("-c", "--claude", "check"): + return sync_py("backup", "--check") + if arg in (None, ""): + return sync_py("backup") + print("Usage: ncp backup [-f|full|-c]") + elif cmd == "restore": + if arg in (None, "", "-f", "full", "-c", "--claude", "check"): + return cmd_restore(arg) + print("Usage: ncp restore [-c]") + elif cmd == "models": + cmd_models(arg, rest[1] if len(rest) > 1 else None) + else: + print(f"Unknown Nexus command: '{cmd}'") + print("Use 'ncp help' for available commands.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/management/nexus-cli.sh b/management/nexus-cli.sh index 16e7993..55395b5 100644 --- a/management/nexus-cli.sh +++ b/management/nexus-cli.sh @@ -1,675 +1,16 @@ #!/usr/bin/env bash - -# Load nvm so npm/node resolve to the managed version -export NVM_DIR="$HOME/.nvm" -# shellcheck source=/dev/null -[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - -NEXUS_ROOT="$HOME/nexus-core" -PID_DIR="$NEXUS_ROOT/runtime/pids" -LOG_DIR="$NEXUS_ROOT/runtime" -PYTHON="$NEXUS_ROOT/Promethean/bin/python3" -FRONTEND_DIR="$NEXUS_ROOT/interface/web" - -mkdir -p "$PID_DIR" "$LOG_DIR" - -BACKEND_PID="$PID_DIR/backend.pid" -MEMORY_PID="$PID_DIR/memory.pid" -FRONTEND_PID="$PID_DIR/frontend.pid" - -BACKEND_LOG="$LOG_DIR/backend.log" -MEMORY_LOG="$LOG_DIR/memory.log" -FRONTEND_LOG="$LOG_DIR/frontend.log" - -# ----------------------------- -# INTERNAL HELPERS -# ----------------------------- - -_launch() { - # _launch [args...] - local name="$1" pid_file="$2" log_file="$3" work_dir="$4" - shift 4 - - if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then - return 0 - fi - - : > "$log_file" - ( cd "$work_dir" && exec "$@" ) >> "$log_file" 2>&1 & - echo $! > "$pid_file" -} - -_check() { - # _check