Sync from upstream: ncp is Python now, runs on Windows too
management/ncp.py replaces the bash CLI's logic; nexus-cli.sh and the new ncp.ps1 are thin wrappers, so Linux keeps its entry point and Windows gains one. psutil handles process and port work on both platforms. install-windows.ps1 registers ncp in the PowerShell profile. The panel VPN switch now resolves its WireGuard connection through NetworkManager instead of a hardcoded name, and the .ps1 ASCII guard globs rather than naming files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
+55
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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 <model>")
|
||||
print("Browse more at: https://ollama.com/library")
|
||||
elif action == "install":
|
||||
if not name:
|
||||
print("Usage: ncp models install <model>")
|
||||
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 <list|available|install <model>>")
|
||||
|
||||
|
||||
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 <command> [options]
|
||||
|
||||
Commands:
|
||||
panel Launch the Nexus Control Panel (tkinter)
|
||||
web Open the web interface (starts the stack if needed)
|
||||
|
||||
chat <message> Send a message, stream the reply
|
||||
memory list List memory facts
|
||||
add <text> Add a fact (--section <name>)
|
||||
rm <id> Delete a fact by id
|
||||
playbook list List playbooks (* = active)
|
||||
show <id> 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 <name> 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:]))
|
||||
+12
-671
@@ -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 <name> <pid_file> <log_file> <work_dir> <cmd> [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 <label> <pid_file> <log_file>
|
||||
# label should be the full service name, e.g. "NEXUS BACKEND SERVICE"
|
||||
local label="$1" pid_file="$2" log_file="$3"
|
||||
local pid
|
||||
pid=$(cat "$pid_file" 2>/dev/null)
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
echo "$label STARTED"
|
||||
return 0
|
||||
else
|
||||
echo "$label FAILED TO START"
|
||||
[ -s "$log_file" ] && tail -8 "$log_file" | sed 's/^/ /'
|
||||
rm -f "$pid_file"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
_wait_for_port() {
|
||||
# _wait_for_port <name> <port> <pid_file> <log_file>
|
||||
# Polls until the service responds on the given port, then prints ready.
|
||||
# On timeout, falls back to _check for the error log.
|
||||
local name="$1" port="$2" pid_file="$3" log_file="$4"
|
||||
local timeout=30 i=0
|
||||
if curl -s --max-time 1 "http://localhost:$port/" > /dev/null 2>&1; then
|
||||
echo " $name already running (:$port)"
|
||||
return 0
|
||||
fi
|
||||
while ! curl -s --max-time 1 "http://localhost:$port/" > /dev/null 2>&1; do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -ge "$timeout" ]; then
|
||||
echo " $name timed out after ${timeout}s"
|
||||
_check "$name" "$pid_file" "$log_file"
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "$name STARTED"
|
||||
}
|
||||
|
||||
_pid_is_ours() {
|
||||
# _pid_is_ours <pid> <pattern> [pattern2]
|
||||
# True only if the live PID's command line matches one of the service
|
||||
# patterns. PID files outlive reboots; the OS recycles the number onto an
|
||||
# unrelated process (often a desktop-session process), so a bare `kill -0`
|
||||
# liveness check is not enough — TERMing a recycled PID can log the user out.
|
||||
local pid="$1" p1="$2" p2="$3" cmd
|
||||
[ -r "/proc/$pid/cmdline" ] || return 1
|
||||
cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null) || return 1
|
||||
[ -n "$p1" ] && [[ "$cmd" == *"$p1"* ]] && return 0
|
||||
[ -n "$p2" ] && [[ "$cmd" == *"$p2"* ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
_stop_service() {
|
||||
# _stop_service <label> <pid_file> <port> <pattern> [extra_pattern]
|
||||
#
|
||||
# PID-based stopping is unreliable here: the frontend PID file tracks npm,
|
||||
# but the server is a `node vite` GRANDCHILD (npm -> sh -c vite -> node)
|
||||
# that reparents and survives a parent kill; uvicorn --reload leaves a
|
||||
# worker child holding the port. So we stop in three escalating passes and
|
||||
# treat the PORT as the source of truth for whether the service is down.
|
||||
local label="$1" pid_file="$2" port="$3" pat="$4" pat2="$5"
|
||||
|
||||
# 1. Graceful: SIGTERM the tracked PID and its direct children.
|
||||
if [ -f "$pid_file" ]; then
|
||||
local pid; pid=$(cat "$pid_file" 2>/dev/null)
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null \
|
||||
&& _pid_is_ours "$pid" "$pat" "$pat2"; then
|
||||
pkill -TERM -P "$pid" 2>/dev/null || true
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
fi
|
||||
|
||||
# 2. Sweep stragglers by command line — catches reparented grandchildren,
|
||||
# --reload workers, and instances started outside this script.
|
||||
[ -n "$pat" ] && pkill -TERM -f "$pat" 2>/dev/null
|
||||
[ -n "$pat2" ] && pkill -TERM -f "$pat2" 2>/dev/null
|
||||
|
||||
# 3. Backstop: whatever still holds the port IS the service — kill it by
|
||||
# port. This is what makes stop reliable no matter how the process tree
|
||||
# was shaped or whether the PID file was accurate.
|
||||
if [ -n "$port" ]; then
|
||||
if ! _wait_for_port_close "$port"; then
|
||||
fuser -k "${port}/tcp" 2>/dev/null
|
||||
[ -n "$pat" ] && pkill -KILL -f "$pat" 2>/dev/null
|
||||
[ -n "$pat2" ] && pkill -KILL -f "$pat2" 2>/dev/null
|
||||
_wait_for_port_close "$port" || true
|
||||
fi
|
||||
if curl -s --max-time 1 "http://localhost:$port/" >/dev/null 2>&1; then
|
||||
echo "$label STILL RUNNING (:$port) — try 'ncp kill'"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
echo "$label STOPPED"
|
||||
return 0
|
||||
}
|
||||
|
||||
_wait_for_port_close() {
|
||||
# _wait_for_port_close <port>
|
||||
# Waits until the port stops responding. Silent — caller prints the result.
|
||||
local port="$1" timeout=15 i=0
|
||||
while curl -s --max-time 1 "http://localhost:$port/" > /dev/null 2>&1; do
|
||||
i=$((i + 1))
|
||||
[ "$i" -ge "$timeout" ] && return 1
|
||||
sleep 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
_status() {
|
||||
# _status <name> <pid_file> [port]
|
||||
local name="$1" pid_file="$2" port="${3:-}"
|
||||
if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
|
||||
echo " $name: RUNNING (PID $(cat "$pid_file"))"
|
||||
elif [ -n "$port" ] && curl -s --max-time 1 "http://localhost:$port/" > /dev/null 2>&1; then
|
||||
echo " $name: RUNNING (port :$port, PID stale — consider ncp kill)"
|
||||
else
|
||||
echo " $name: STOPPED"
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# KILL COMMAND
|
||||
# -----------------------------
|
||||
|
||||
kill_services() {
|
||||
echo "Force-killing all Nexus processes..."
|
||||
local ports=(8000 8001 5173 11434)
|
||||
local names=("SYNAPSE" "MEMORY" "INTERFACE" "OLLAMA")
|
||||
for i in "${!ports[@]}"; do
|
||||
port="${ports[$i]}"
|
||||
name="${names[$i]}"
|
||||
if fuser -k "${port}/tcp" 2>/dev/null; then
|
||||
echo " KILLED: ${name} (:${port})"
|
||||
else
|
||||
echo " NOT RUNNING: ${name} (:${port})"
|
||||
fi
|
||||
done
|
||||
pkill -9 -f "uvicorn synapse" 2>/dev/null || true
|
||||
pkill -9 -f "npm run dev" 2>/dev/null || true
|
||||
pkill -9 -f "vite --host" 2>/dev/null || true
|
||||
pkill -9 -f "ollama serve" 2>/dev/null || true
|
||||
rm -f "$PID_DIR"/*.pid
|
||||
echo "Done."
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# START COMMANDS
|
||||
# -----------------------------
|
||||
|
||||
start_ollama() {
|
||||
# Ollama runs as its own `ollama serve` process, but the backend's
|
||||
# OllamaManager owns model/GPU selection — so drive it through the backend
|
||||
# endpoint (the same path the control panel uses) rather than launching the
|
||||
# binary directly. Requires the backend to be up.
|
||||
echo "Starting OLLAMA..."
|
||||
if curl -s --max-time 30 -X POST http://localhost:8000/ollama/start > /dev/null 2>&1; then
|
||||
echo "NEXUS OLLAMA STARTED"
|
||||
else
|
||||
echo " OLLAMA start request failed (backend not reachable on :8000)"
|
||||
fi
|
||||
}
|
||||
|
||||
start_memory() {
|
||||
_launch "NEXUS MEMORY SERVICE" "$MEMORY_PID" "$MEMORY_LOG" "$NEXUS_ROOT" \
|
||||
"$PYTHON" -m uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload
|
||||
_wait_for_port "NEXUS MEMORY SERVICE" 8001 "$MEMORY_PID" "$MEMORY_LOG"
|
||||
}
|
||||
|
||||
start_backend() {
|
||||
_launch "NEXUS BACKEND SERVICE" "$BACKEND_PID" "$BACKEND_LOG" "$NEXUS_ROOT" \
|
||||
"$PYTHON" -m uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload
|
||||
_wait_for_port "NEXUS BACKEND SERVICE" 8000 "$BACKEND_PID" "$BACKEND_LOG"
|
||||
# AI is manual now — start it with `start --ai` or the web UI button.
|
||||
}
|
||||
|
||||
start_frontend() {
|
||||
_launch "NEXUS FRONTEND SERVICE" "$FRONTEND_PID" "$FRONTEND_LOG" "$FRONTEND_DIR" \
|
||||
npm run dev -- --host 0.0.0.0
|
||||
_wait_for_port "NEXUS FRONTEND SERVICE" 5173 "$FRONTEND_PID" "$FRONTEND_LOG"
|
||||
}
|
||||
|
||||
start_all() {
|
||||
# Memory + backend launch in parallel, wait for both before starting frontend
|
||||
_launch "NEXUS MEMORY SERVICE" "$MEMORY_PID" "$MEMORY_LOG" "$NEXUS_ROOT" \
|
||||
"$PYTHON" -m uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload
|
||||
_launch "NEXUS BACKEND SERVICE" "$BACKEND_PID" "$BACKEND_LOG" "$NEXUS_ROOT" \
|
||||
"$PYTHON" -m uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload
|
||||
_wait_for_port "NEXUS MEMORY SERVICE" 8001 "$MEMORY_PID" "$MEMORY_LOG"
|
||||
_wait_for_port "NEXUS BACKEND SERVICE" 8000 "$BACKEND_PID" "$BACKEND_LOG"
|
||||
# AI is manual now — start it with `start --ai` or the web UI button.
|
||||
_launch "NEXUS FRONTEND SERVICE" "$FRONTEND_PID" "$FRONTEND_LOG" "$FRONTEND_DIR" \
|
||||
npm run dev -- --host 0.0.0.0
|
||||
_wait_for_port "NEXUS FRONTEND SERVICE" 5173 "$FRONTEND_PID" "$FRONTEND_LOG"
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# STOP COMMANDS
|
||||
# -----------------------------
|
||||
|
||||
stop_ollama() {
|
||||
# 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.
|
||||
if curl -s --max-time 5 -X POST http://localhost:8000/ollama/stop > /dev/null 2>&1; then
|
||||
echo "NEXUS OLLAMA STOPPED"
|
||||
elif pgrep -f "ollama serve" > /dev/null 2>&1; then
|
||||
pkill -TERM -f "ollama serve" 2>/dev/null
|
||||
echo "NEXUS OLLAMA STOPPED (direct)"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_memory() {
|
||||
_stop_service "NEXUS MEMORY SERVICE" "$MEMORY_PID" 8001 "uvicorn synapse.memory"
|
||||
}
|
||||
|
||||
stop_backend() {
|
||||
stop_ollama
|
||||
_stop_service "NEXUS BACKEND SERVICE" "$BACKEND_PID" 8000 "uvicorn synapse.main"
|
||||
}
|
||||
|
||||
stop_frontend() {
|
||||
_stop_service "NEXUS FRONTEND SERVICE" "$FRONTEND_PID" 5173 "vite --host" "npm run dev"
|
||||
}
|
||||
|
||||
stop_all() {
|
||||
stop_memory
|
||||
stop_backend
|
||||
stop_frontend
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# STATUS COMMAND
|
||||
# -----------------------------
|
||||
|
||||
status_services() {
|
||||
echo "Nexus Service Status:"
|
||||
echo
|
||||
echo "Backend:"
|
||||
_status "Synapse " "$BACKEND_PID" 8000
|
||||
_status "Memory service" "$MEMORY_PID" 8001
|
||||
echo
|
||||
echo "Frontend:"
|
||||
_status "Vite " "$FRONTEND_PID" 5173
|
||||
echo
|
||||
echo "Model server:"
|
||||
if curl -s --max-time 1 "http://localhost:11434/api/tags" > /dev/null 2>&1; then
|
||||
echo " Ollama : RUNNING (:11434)"
|
||||
else
|
||||
echo " Ollama : STOPPED"
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# LOGS COMMAND
|
||||
# -----------------------------
|
||||
|
||||
show_logs() {
|
||||
case "$1" in
|
||||
--frontend|-f)
|
||||
echo "=== FRONTEND LOGS ==="
|
||||
tail -n 50 "$FRONTEND_LOG"
|
||||
;;
|
||||
--backend|-b)
|
||||
echo "=== BACKEND LOGS ==="
|
||||
tail -n 50 "$BACKEND_LOG"
|
||||
;;
|
||||
--memory|-m)
|
||||
echo "=== MEMORY SERVICE LOGS ==="
|
||||
tail -n 50 "$MEMORY_LOG"
|
||||
;;
|
||||
""|all)
|
||||
echo "=== MEMORY SERVICE LOGS ==="
|
||||
tail -n 30 "$MEMORY_LOG"
|
||||
echo
|
||||
echo "=== BACKEND LOGS ==="
|
||||
tail -n 30 "$BACKEND_LOG"
|
||||
echo
|
||||
echo "=== FRONTEND LOGS ==="
|
||||
tail -n 30 "$FRONTEND_LOG"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown logs target: '$1'"
|
||||
echo "Usage: ncp logs [frontend|backend|memory|all]"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# DOCTOR COMMAND
|
||||
# -----------------------------
|
||||
|
||||
doctor() {
|
||||
echo "Running Nexus Diagnostics..."
|
||||
echo
|
||||
|
||||
echo "Checking directories..."
|
||||
[ -d "$NEXUS_ROOT" ] && echo " ✔ Nexus root found" || echo " ✘ Missing Nexus root"
|
||||
[ -d "$FRONTEND_DIR" ] && echo " ✔ Frontend directory found" || echo " ✘ Missing frontend directory"
|
||||
|
||||
echo
|
||||
echo "Checking Python venv..."
|
||||
[ -x "$PYTHON" ] && echo " ✔ Promethean venv found ($($PYTHON --version 2>&1))" || echo " ✘ Promethean venv missing at $PYTHON"
|
||||
|
||||
echo
|
||||
echo "Checking Node & npm..."
|
||||
command -v node >/dev/null && echo " ✔ Node installed ($(node -v))" || echo " ✘ Node missing"
|
||||
command -v npm >/dev/null && echo " ✔ npm installed ($(npm -v))" || echo " ✘ npm missing"
|
||||
[ -d "$FRONTEND_DIR/node_modules" ] && echo " ✔ Frontend node_modules installed" || echo " ✘ Frontend node_modules missing (run: ncp update)"
|
||||
|
||||
echo
|
||||
echo "Checking Uvicorn..."
|
||||
"$PYTHON" -m uvicorn --version >/dev/null 2>&1 && echo " ✔ Uvicorn installed" || echo " ✘ Uvicorn missing"
|
||||
|
||||
echo
|
||||
echo "Checking backend service..."
|
||||
( cd "$NEXUS_ROOT" && "$PYTHON" -c "from synapse.main import sio_app" ) >/dev/null 2>&1 \
|
||||
&& echo " ✔ Backend module importable" \
|
||||
|| echo " ✘ Backend module failed to import"
|
||||
|
||||
echo
|
||||
echo "Checking memory service..."
|
||||
[ -d "$NEXUS_ROOT/synapse/memory" ] && echo " ✔ Memory module directory found" || echo " ✘ Missing memory module directory"
|
||||
( cd "$NEXUS_ROOT" && "$PYTHON" -c "from synapse.memory.service import app" ) >/dev/null 2>&1 \
|
||||
&& echo " ✔ Memory service module importable" \
|
||||
|| echo " ✘ Memory service module failed to import"
|
||||
[ -w "$NEXUS_ROOT/synapse/memory" ] && echo " ✔ Memory database directory writable" || echo " ✘ Memory database directory not writable"
|
||||
|
||||
echo
|
||||
echo "Checking Ollama..."
|
||||
[ -x "$OLLAMA_BIN" ] && echo " ✔ Ollama binary found ($OLLAMA_BIN)" || echo " ✘ Ollama binary missing at $OLLAMA_BIN"
|
||||
[ -d "$OLLAMA_MODELS_DIR" ] && echo " ✔ Ollama models directory found ($OLLAMA_MODELS_DIR)" || echo " ✘ Ollama models directory missing at $OLLAMA_MODELS_DIR"
|
||||
|
||||
echo
|
||||
status_services
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# HELP COMMAND
|
||||
# -----------------------------
|
||||
|
||||
show_help() {
|
||||
echo "Nexus Command Tree"
|
||||
echo
|
||||
echo "Usage: ncp <command> [options]"
|
||||
echo
|
||||
echo "Commands:"
|
||||
echo " panel Launch the Nexus Control Panel"
|
||||
echo
|
||||
echo " chat <message> Send a message, stream the reply"
|
||||
echo " memory list List memory facts"
|
||||
echo " add <text> Add a fact (--section <name>)"
|
||||
echo " rm <id> Delete a fact by id"
|
||||
echo " playbook list List playbooks (* = active)"
|
||||
echo " show <id> Print a playbook's goal + instructions"
|
||||
echo " history [query] Recent conversations (optional keyword)"
|
||||
echo
|
||||
echo " start Start ALL Nexus services (memory + backend + frontend)"
|
||||
echo " --memory, -m Start only the memory service"
|
||||
echo " --frontend,-f Start only the frontend"
|
||||
echo " --backend, -b Start only the backend"
|
||||
echo " --ai, -a Start the AI (Ollama) — manual; not started by default"
|
||||
echo
|
||||
echo " stop Stop ALL Nexus services"
|
||||
echo " --memory, -m Stop only the memory service"
|
||||
echo " --frontend,-f Stop only the frontend"
|
||||
echo " --backend, -b Stop only the backend"
|
||||
echo
|
||||
echo " kill Force-kill all Nexus processes by port (nuclear option)"
|
||||
echo " refresh Restart all services"
|
||||
echo
|
||||
echo " status Show service status"
|
||||
echo " logs Show logs for all services"
|
||||
echo " --memory, -m Memory service logs"
|
||||
echo " --frontend,-f Frontend logs"
|
||||
echo " --backend, -b Backend logs"
|
||||
echo
|
||||
echo " doctor Run Nexus diagnostics"
|
||||
echo " update Update Nexus dependencies"
|
||||
echo " clean Remove runtime files and caches"
|
||||
echo
|
||||
echo " models"
|
||||
echo " list List installed models"
|
||||
echo " available Show models available to install"
|
||||
echo " install <name> Pull a model into Nexus"
|
||||
echo
|
||||
echo " backup Backup Nexus to Gitea (git commit + push)"
|
||||
echo " backup -f, full Backup + snapshot live desktop wiring/notes"
|
||||
echo " backup -c Dry run: what a backup would commit/push"
|
||||
echo " restore Restore Nexus from Gitea (git pull + rebuild)"
|
||||
echo " restore -c Dry run: what a restore would apply"
|
||||
echo
|
||||
echo " help, -h Show this help message"
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# UPDATE COMMAND
|
||||
# -----------------------------
|
||||
|
||||
update_nexus() {
|
||||
echo "Updating Project Nexus..."
|
||||
echo
|
||||
|
||||
cd "$NEXUS_ROOT" || exit 1
|
||||
|
||||
echo "Skipping git pull — update only refreshes dependencies."
|
||||
|
||||
echo
|
||||
echo "Updating backend Python dependencies..."
|
||||
if [ -f "requirements-amd.txt" ]; then
|
||||
"$PYTHON" -m pip install -r requirements-amd.txt
|
||||
else
|
||||
echo "No requirements-amd.txt found."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Updating frontend dependencies..."
|
||||
if [ -d "$FRONTEND_DIR" ]; then
|
||||
cd "$FRONTEND_DIR"
|
||||
npm install
|
||||
else
|
||||
echo "Frontend directory missing — skipping npm install."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Running post-update diagnostics..."
|
||||
doctor
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# BACKUP / RESTORE COMMANDS
|
||||
# -----------------------------
|
||||
|
||||
# Backup/restore go through git → Gitea now (bin/sync.py), not rsync-to-router,
|
||||
# so no SSH/WireGuard reachability gate is needed — git handles its own
|
||||
# connectivity and errors over HTTPS. sync.py is the same entry point the Windows
|
||||
# box uses; it calls bin/restore-linux.sh and bin/backup-linux.sh for the XFCE
|
||||
# desktop half, which only runs here.
|
||||
# ncp - Linux entry point. The CLI itself is management/ncp.py, which runs
|
||||
# unchanged on Windows too (see management/ncp.ps1); this stays a shell script
|
||||
# because ~/.bashrc, launch_nexus.sh, bin/restore-linux.sh, controlpanel.py,
|
||||
# bin/panel/nexus-popup.py and management/nexus-app.sh all invoke this path.
|
||||
#
|
||||
# sync.py is stdlib-only, so system python3 works when the venv isn't built yet.
|
||||
sync_py() {
|
||||
local py="$PYTHON"
|
||||
[ -x "$py" ] || py=python3
|
||||
"$py" "$NEXUS_ROOT/bin/sync.py" "$@"
|
||||
}
|
||||
# Resolved from this file rather than $HOME/nexus-core, so a clone in a scratch
|
||||
# directory drives itself instead of reaching into the real install.
|
||||
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)"
|
||||
|
||||
restore_nexus() {
|
||||
echo "This pulls the latest backup from Gitea and rebuilds the environment"
|
||||
echo "(venv, npm, theme, panel). Local commits must be pushed or stashed first."
|
||||
printf "Continue? [y/N] "
|
||||
read -r confirm
|
||||
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
||||
echo "Restore cancelled."
|
||||
return
|
||||
fi
|
||||
sync_py restore
|
||||
}
|
||||
# ncp.py needs psutil (venv), but its backup/restore path delegates to the
|
||||
# stdlib-only bin/sync.py and must work before the venv is built.
|
||||
PY="$NEXUS_ROOT/Promethean/bin/python3"
|
||||
[ -x "$PY" ] || PY=python3
|
||||
|
||||
# -----------------------------
|
||||
# MODELS COMMANDS
|
||||
# -----------------------------
|
||||
|
||||
OLLAMA_BIN="$NEXUS_ROOT/ollama/bin/ollama"
|
||||
OLLAMA_MODELS_DIR="$NEXUS_ROOT/models"
|
||||
|
||||
models_list() {
|
||||
if ! curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; then
|
||||
echo "Ollama is not running. Start the backend first with: ncp start -b"
|
||||
return 1
|
||||
fi
|
||||
echo "Installed models:"
|
||||
echo
|
||||
curl -s http://localhost:11434/api/tags | "$PYTHON" -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
models = data.get('models', [])
|
||||
if not models:
|
||||
print(' No models installed.')
|
||||
else:
|
||||
for m in models:
|
||||
size_mb = m['size'] // 1024 // 1024
|
||||
size = f'{size_mb / 1024:.1f} GB' if size_mb >= 1024 else f'{size_mb} MB'
|
||||
print(f' {m[\"name\"]:<35} {size}')
|
||||
"
|
||||
}
|
||||
|
||||
models_available() {
|
||||
echo "Available models (via Ollama library):"
|
||||
echo
|
||||
printf " %-30s %-10s %s\n" "MODEL" "SIZE" "DESCRIPTION"
|
||||
printf " %-30s %-10s %s\n" "-----" "----" "-----------"
|
||||
printf " %-30s %-10s %s\n" "gemma3:1b" "~815 MB" "Google Gemma 3 — fast, lightweight"
|
||||
printf " %-30s %-10s %s\n" "gemma3:4b" "~3.3 GB" "Google Gemma 3 — balanced"
|
||||
printf " %-30s %-10s %s\n" "gemma3:12b" "~8.1 GB" "Google Gemma 3 — capable"
|
||||
printf " %-30s %-10s %s\n" "llama3.2:1b" "~1.3 GB" "Meta Llama 3.2 — fast, lightweight"
|
||||
printf " %-30s %-10s %s\n" "llama3.2:3b" "~2.0 GB" "Meta Llama 3.2 — compact, capable"
|
||||
printf " %-30s %-10s %s\n" "llama3.1:8b" "~4.7 GB" "Meta Llama 3.1 — strong general use"
|
||||
printf " %-30s %-10s %s\n" "mistral:latest" "~4.1 GB" "Mistral 7B — solid all-rounder"
|
||||
printf " %-30s %-10s %s\n" "mistral-nemo" "~7.1 GB" "Mistral Nemo 12B — strong reasoning"
|
||||
printf " %-30s %-10s %s\n" "qwen2.5:3b" "~2.0 GB" "Alibaba Qwen 2.5 — great at code"
|
||||
printf " %-30s %-10s %s\n" "qwen2.5:7b" "~4.7 GB" "Alibaba Qwen 2.5 — strong coder"
|
||||
printf " %-30s %-10s %s\n" "phi4-mini" "~2.5 GB" "Microsoft Phi-4 Mini — efficient"
|
||||
printf " %-30s %-10s %s\n" "phi4:14b" "~8.9 GB" "Microsoft Phi-4 — strong reasoning"
|
||||
printf " %-30s %-10s %s\n" "deepseek-r1:7b" "~4.7 GB" "DeepSeek R1 — reasoning model"
|
||||
printf " %-30s %-10s %s\n" "deepseek-r1:14b" "~9.0 GB" "DeepSeek R1 — strong reasoning"
|
||||
printf " %-30s %-10s %s\n" "codellama:7b" "~3.8 GB" "Meta Code Llama — code focused"
|
||||
printf " %-30s %-10s %s\n" "nomic-embed-text" "~274 MB" "Text embeddings model"
|
||||
echo
|
||||
echo "Install any model with: ncp models install <model>"
|
||||
echo "Browse more at: https://ollama.com/library"
|
||||
}
|
||||
|
||||
models_install() {
|
||||
local model="$1"
|
||||
if [ -z "$model" ]; then
|
||||
echo "Usage: ncp models install <model>"
|
||||
echo "Run 'ncp models available' to see options."
|
||||
return 1
|
||||
fi
|
||||
if [ ! -x "$OLLAMA_BIN" ]; then
|
||||
echo "Ollama binary not found at $OLLAMA_BIN"
|
||||
return 1
|
||||
fi
|
||||
echo "Pulling '$model' into $OLLAMA_MODELS_DIR ..."
|
||||
echo
|
||||
OLLAMA_MODELS="$OLLAMA_MODELS_DIR" "$OLLAMA_BIN" pull "$model"
|
||||
echo
|
||||
echo "Done. Run 'ncp models list' to verify."
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# CLEAN COMMAND
|
||||
# -----------------------------
|
||||
|
||||
clean_nexus() {
|
||||
echo "Cleaning Nexus runtime files..."
|
||||
echo
|
||||
|
||||
echo "Removing PID files..."
|
||||
rm -f "$PID_DIR"/*.pid
|
||||
|
||||
echo "Removing logs..."
|
||||
rm -f "$LOG_DIR"/*.log
|
||||
|
||||
echo "Removing Python cache..."
|
||||
find "$NEXUS_ROOT" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null
|
||||
|
||||
echo "Removing Node/Vite cache..."
|
||||
find "$FRONTEND_DIR" -type d -name ".vite" -exec rm -rf {} + 2>/dev/null
|
||||
|
||||
echo
|
||||
echo "Cleanup complete."
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# COMMAND TREE ROUTER
|
||||
# -----------------------------
|
||||
|
||||
subcommand="$1"
|
||||
shift
|
||||
|
||||
case "$subcommand" in
|
||||
|
||||
panel) python3 "$NEXUS_ROOT/management/controlpanel.py" ;;
|
||||
|
||||
chat|memory|playbook|history)
|
||||
"$PYTHON" "$NEXUS_ROOT/management/nexus_api.py" "$subcommand" "$@" ;;
|
||||
|
||||
start)
|
||||
case "$1" in
|
||||
--memory|-m) start_memory ;;
|
||||
--frontend|-f) start_frontend ;;
|
||||
--backend|-b) start_backend ;;
|
||||
--ai|-a) start_ollama ;;
|
||||
""|all) start_all ;;
|
||||
*) show_help ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
stop)
|
||||
case "$1" in
|
||||
--memory|-m) stop_memory ;;
|
||||
--frontend|-f) stop_frontend ;;
|
||||
--backend|-b) stop_backend ;;
|
||||
""|all) stop_all ;;
|
||||
*) show_help ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
refresh) stop_all && start_all ;;
|
||||
kill) kill_services ;;
|
||||
status) status_services ;;
|
||||
logs) show_logs "$1" ;;
|
||||
doctor) doctor ;;
|
||||
update) update_nexus ;;
|
||||
clean) clean_nexus ;;
|
||||
help|-h|"") show_help ;;
|
||||
|
||||
nvidia-reqs) "$PYTHON" "$NEXUS_ROOT/bin/gen-nvidia-reqs.py" ;;
|
||||
|
||||
backup)
|
||||
case "$1" in
|
||||
-f|full) sync_py backup --full ;;
|
||||
-c|--claude|check) sync_py backup --check ;;
|
||||
"") sync_py backup ;;
|
||||
*) echo "Usage: ncp backup [-f|full|-c]" ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
restore)
|
||||
case "$1" in
|
||||
-f|full) restore_nexus ;;
|
||||
# Dry run: no confirmation prompt, it changes nothing.
|
||||
-c|--claude|check) sync_py restore --check ;;
|
||||
"") restore_nexus ;;
|
||||
*) echo "Usage: ncp restore [-f|full|-c]" ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
models)
|
||||
case "$1" in
|
||||
list) models_list ;;
|
||||
available|search) models_available ;;
|
||||
install) models_install "$2" ;;
|
||||
*) echo "Usage: ncp models <list|available|install <model>>" ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown Nexus command: '$subcommand'"
|
||||
echo "Use 'ncp help' for available commands."
|
||||
;;
|
||||
esac
|
||||
exec "$PY" "$NEXUS_ROOT/management/ncp.py" "$@"
|
||||
|
||||
+9
-5
@@ -54,13 +54,17 @@ def test_default_models_have_one_source_of_truth():
|
||||
assert hardcoded not in ps1, f"install-windows.ps1 hardcodes {hardcoded!r}"
|
||||
|
||||
|
||||
def test_install_windows_stays_ascii():
|
||||
def test_powershell_files_stay_ascii():
|
||||
# PowerShell 5.1 decodes BOM-less files as ANSI: one stray Unicode dash
|
||||
# eats a quote and the whole script dies at parse time.
|
||||
for name in ("install-windows.ps1", "launch_nexus.ps1"):
|
||||
raw = (REPO_ROOT / name).read_bytes()
|
||||
# eats a quote and the whole script dies at parse time. Globbed rather than
|
||||
# listed by name so a newly added .ps1 is covered without editing this test.
|
||||
skip = {"Promethean", "node_modules", ".git", "dist"}
|
||||
ps1s = [p for p in REPO_ROOT.rglob("*.ps1") if not skip & set(p.parts)]
|
||||
assert ps1s, "no .ps1 files found - did the Windows path move?"
|
||||
for path in ps1s:
|
||||
raw = path.read_bytes()
|
||||
bad = [(i, b) for i, b in enumerate(raw) if b > 0x7F]
|
||||
assert not bad, f"{name} has non-ASCII bytes at {bad[:3]}"
|
||||
assert not bad, f"{path.relative_to(REPO_ROOT)} has non-ASCII bytes at {bad[:3]}"
|
||||
|
||||
|
||||
def test_first_playbook_is_the_system_prompt(tmp_path, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user