forked from enderofwings/NexusOS
Initial commit: NexusOS - local AI assistant platform
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Blueman Applet
|
||||
Comment=Bluetooth agent — icon suppressed; the Bluetooth genmon applet replaces it
|
||||
Icon=blueman
|
||||
TryExec=blueman-applet
|
||||
Exec=blueman-applet
|
||||
Hidden=true
|
||||
X-XFCE-Autostart-Override=true
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Blueman Applet
|
||||
Comment=Bluetooth agent — icon suppressed; the Bluetooth genmon applet replaces it
|
||||
Icon=blueman
|
||||
TryExec=blueman-applet
|
||||
Exec=blueman-applet
|
||||
Hidden=true
|
||||
X-XFCE-Autostart-Override=true
|
||||
@@ -0,0 +1,6 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Nexus Network Popup
|
||||
Exec=/home/jon/.local/bin/network-popup.py
|
||||
Hidden=false
|
||||
X-XFCE-Autostart-Override=true
|
||||
@@ -0,0 +1,6 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Nexus Service Popup
|
||||
Exec=/home/jon/.local/bin/nexus-popup.py
|
||||
Hidden=false
|
||||
X-XFCE-Autostart-Override=true
|
||||
@@ -0,0 +1,4 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=nm-tray
|
||||
Hidden=true
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=nm-tray
|
||||
Comment=NetworkManager frontend (tray icon)
|
||||
Icon=network-transmit
|
||||
TryExec=nm-tray
|
||||
Exec=nm-tray
|
||||
Hidden=true
|
||||
X-XFCE-Autostart-Override=true
|
||||
@@ -0,0 +1,818 @@
|
||||
# /home/jon/nexus-core/management/controlpanel.py
|
||||
|
||||
import time
|
||||
import tkinter as tk
|
||||
import subprocess
|
||||
import threading
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import webbrowser
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Single source of truth: the VERSION file at the repo root
|
||||
try:
|
||||
VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip() or "0.0.0"
|
||||
except Exception:
|
||||
VERSION = "0.0.0"
|
||||
|
||||
# Force 0.0.0.0 to ensure the Windows Host bridge works for Project Nexus
|
||||
FRONTEND_DIR = PROJECT_ROOT / "interface" / "web"
|
||||
def _find_npm():
|
||||
"""Prefer nvm's newest node. When the panel is launched from the desktop
|
||||
(not a shell), nvm isn't on PATH, so shutil.which('npm') returns the SYSTEM
|
||||
node — often too old for the installed Vite (e.g. Node 18 -> "CustomEvent is
|
||||
not defined"). nvm exists precisely to shadow the system node, and ncp
|
||||
already prefers it, so resolve it here too. _spawn_process prepends the
|
||||
returned npm's bin dir to PATH, so its node wins for the child process."""
|
||||
import shutil
|
||||
nvm_dir = Path.home() / ".nvm" / "versions" / "node"
|
||||
if nvm_dir.exists():
|
||||
def _ver(p):
|
||||
try:
|
||||
return tuple(int(x) for x in p.name.lstrip("v").split("."))
|
||||
except ValueError:
|
||||
return (0,)
|
||||
for v in sorted((d for d in nvm_dir.iterdir() if d.is_dir()), key=_ver, reverse=True):
|
||||
candidate = v / "bin" / "npm"
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
return shutil.which("npm") or "npm"
|
||||
|
||||
FRONTEND_CMD = [_find_npm(), "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
FRONTEND_URL = "http://127.0.0.1:5173"
|
||||
|
||||
BACKEND_DIR = PROJECT_ROOT
|
||||
BACKEND_CMD = [
|
||||
str(PROJECT_ROOT / "Promethean" / "bin" / "python"),
|
||||
"-m", "uvicorn",
|
||||
"synapse.main:sio_app",
|
||||
"--host", "0.0.0.0",
|
||||
"--port", "8000",
|
||||
"--reload",
|
||||
"--reload-dir", str(PROJECT_ROOT / "synapse"),
|
||||
"--log-level", "info"
|
||||
]
|
||||
|
||||
MEMORY_DIR = PROJECT_ROOT
|
||||
MEMORY_CMD = [
|
||||
str(PROJECT_ROOT / "Promethean" / "bin" / "python"),
|
||||
"-m", "uvicorn",
|
||||
"synapse.memory.service:app",
|
||||
"--host", "0.0.0.0",
|
||||
"--port", "8001",
|
||||
"--reload",
|
||||
"--reload-dir", str(PROJECT_ROOT / "synapse"),
|
||||
"--log-level", "info"
|
||||
]
|
||||
|
||||
FRONTEND_PID = PROJECT_ROOT / "runtime" / "pids" / "frontend.pid"
|
||||
BACKEND_PID = PROJECT_ROOT / "runtime" / "pids" / "backend.pid"
|
||||
MEMORY_PID = PROJECT_ROOT / "runtime" / "pids" / "memory.pid"
|
||||
CHAT_LOG = PROJECT_ROOT / "runtime" / "logs" / "chat.log"
|
||||
|
||||
class NexusControlPanel:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
|
||||
if os.name != 'nt' and not os.environ.get('DISPLAY'):
|
||||
print("ERROR: No DISPLAY variable. Check WSLg.")
|
||||
sys.exit(1)
|
||||
|
||||
self.root.title("Nexus Control Panel")
|
||||
# Example icon path; replace with your actual icon file path
|
||||
try:
|
||||
self.root.iconphoto(True, tk.PhotoImage(file=str(PROJECT_ROOT / "assets" / "n-small.png")))
|
||||
except Exception as e:
|
||||
print(f"Icon load failed: {e}")
|
||||
|
||||
if not sys.platform.startswith("linux"):
|
||||
# Keep the custom borderless titlebar on Windows/WSL,
|
||||
# but allow normal stacking on Linux Mint.
|
||||
self.root.overrideredirect(True)
|
||||
self.root.configure(background="#1e1e1e")
|
||||
|
||||
window_width = 1800
|
||||
window_height = 720
|
||||
|
||||
try:
|
||||
self.root.update_idletasks()
|
||||
sw = self.root.winfo_screenwidth()
|
||||
sh = self.root.winfo_screenheight()
|
||||
self.root.geometry(f'{window_width}x{window_height}+{int(sw/2 - window_width/2)}+{int(sh/2 - window_height/2)}')
|
||||
except:
|
||||
self.root.geometry(f'{window_width}x{window_height}+100+100')
|
||||
|
||||
self.frontend_process = None
|
||||
self.backend_process = None
|
||||
self.memory_process = None
|
||||
self._frontend_starting = False
|
||||
self._backend_starting = False
|
||||
self._memory_starting = False
|
||||
self._closing = False
|
||||
|
||||
self._setup_custom_titlebar()
|
||||
self._setup_ui()
|
||||
self._init_log_tags()
|
||||
self._load_existing_logs()
|
||||
|
||||
# Set up window close handler for graceful shutdown
|
||||
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
|
||||
|
||||
backend_pid = PROJECT_ROOT / "runtime/pids/backend.pid"
|
||||
frontend_pid = PROJECT_ROOT / "runtime/pids/frontend.pid"
|
||||
|
||||
if self._is_running(backend_pid):
|
||||
threading.Thread(target=self._monitor_backend, daemon=True).start()
|
||||
|
||||
if self._is_running(frontend_pid):
|
||||
threading.Thread(target=self._monitor_frontend, daemon=True).start()
|
||||
|
||||
if self._is_running(MEMORY_PID):
|
||||
threading.Thread(target=self._monitor_memory, daemon=True).start()
|
||||
|
||||
threading.Thread(target=self._monitor_synapses, daemon=True).start()
|
||||
|
||||
self.update_master_ui()
|
||||
self._poll_status()
|
||||
threading.Thread(target=self._detect_gpu, daemon=True).start()
|
||||
|
||||
|
||||
def _load_existing_logs(self):
|
||||
backend_pid = PROJECT_ROOT / "runtime/pids/backend.pid"
|
||||
frontend_pid = PROJECT_ROOT / "runtime/pids/frontend.pid"
|
||||
|
||||
# BACKEND
|
||||
if self._is_running(backend_pid):
|
||||
try:
|
||||
with open(PROJECT_ROOT / "runtime/backend.log", "r") as f:
|
||||
for line in f.readlines()[-200:]:
|
||||
self.log("BACKEND", line.rstrip())
|
||||
except:
|
||||
pass
|
||||
|
||||
# FRONTEND
|
||||
if self._is_running(frontend_pid):
|
||||
try:
|
||||
with open(PROJECT_ROOT / "runtime/frontend.log", "r") as f:
|
||||
for line in f.readlines()[-200:]:
|
||||
self.log("FRONTEND", line.rstrip())
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def _init_log_tags(self):
|
||||
for console in [self.sys_console, self.front_console, self.back_console, self.mind_console, self.mem_console]:
|
||||
console.tag_config("SYSTEM", foreground="#ff00ea")
|
||||
console.tag_config("FRONTEND", foreground="#61dbfb")
|
||||
console.tag_config("BACKEND", foreground="#ffd43b")
|
||||
console.tag_config("MINDTRACE", foreground="#cc88ff")
|
||||
console.tag_config("MEMORY", foreground="#78e08f")
|
||||
console.tag_config("ERROR", foreground="#ff0000")
|
||||
console.tag_config("GPU_NVIDIA", foreground="#76b900")
|
||||
console.tag_config("GPU_AMD", foreground="#ed1c24")
|
||||
console.tag_config("GPU_VULKAN", foreground="#7b68ee")
|
||||
|
||||
def _setup_custom_titlebar(self):
|
||||
self.title_bar = tk.Frame(self.root, bg="#2d2d2d", bd=0)
|
||||
self.title_bar.pack(fill=tk.X, side=tk.TOP)
|
||||
tk.Label(self.title_bar, text="NexusOS // AI CONTROL PANEL", bg="#2d2d2d", fg="#00ff00", font=("Consolas", 10, "bold"), padx=8, pady=3).pack(side=tk.LEFT)
|
||||
tk.Label(self.title_bar, text=f"v{VERSION}", bg="#2d2d2d", fg="#666666", font=("Consolas", 9), padx=8, pady=3).pack(side=tk.RIGHT)
|
||||
self.title_bar.bind("<Button-1>", self.start_move)
|
||||
self.title_bar.bind("<B1-Motion>", self.do_move)
|
||||
|
||||
def start_move(self, event):
|
||||
self.x, self.y = event.x, event.y
|
||||
|
||||
def do_move(self, event):
|
||||
self.root.geometry(f"+{self.root.winfo_x() + (event.x - self.x)}+{self.root.winfo_y() + (event.y - self.y)}")
|
||||
|
||||
def _clear_all_logs(self):
|
||||
for console in [self.sys_console, self.front_console, self.back_console, self.mem_console, self.mind_console]:
|
||||
console.delete("1.0", tk.END)
|
||||
|
||||
def _copy_selection(self, widget):
|
||||
"""Copy selection or full console contents to the system clipboard."""
|
||||
try:
|
||||
content = widget.get(tk.SEL_FIRST, tk.SEL_LAST) if widget.tag_ranges(tk.SEL) else widget.get("1.0", tk.END)
|
||||
content = content.strip()
|
||||
if content:
|
||||
self.root.clipboard_clear()
|
||||
self.root.clipboard_append(content)
|
||||
self.root.update()
|
||||
self.log("SYSTEM", "Copied log contents to clipboard.")
|
||||
except Exception as e:
|
||||
self.log("ERROR", f"Clipboard Error: {e}")
|
||||
return "break"
|
||||
|
||||
def _create_terminal(self, parent, label_text, height=10, text_color="#cccccc"):
|
||||
frame = tk.Frame(parent, bg="#1e1e1e", bd=0)
|
||||
header = tk.Frame(frame, bg="#1e1e1e", bd=0)
|
||||
header.pack(fill=tk.X, pady=(0, 1))
|
||||
tk.Label(header, text=label_text, bg="#1e1e1e", fg=text_color, font=("Consolas", 8, "bold")).pack(side=tk.LEFT)
|
||||
console = tk.Text(frame, bg="#000000", fg=text_color, font=("Consolas", 8), height=height, borderwidth=3, relief="flat", exportselection=True)
|
||||
tk.Button(header, text="CLEAR LOG", bg="#2d2d2d", fg="#ff0000", font=("Consolas", 7, "bold"), relief="flat", pady=2, command=lambda c=console: c.delete("1.0", tk.END)).pack(side=tk.RIGHT, padx=0)
|
||||
tk.Button(header, text="COPY LOG", bg="#2d2d2d", fg=text_color, font=("Consolas", 7, "bold"), relief="flat", pady=2, command=lambda c=console: self._copy_selection(c)).pack(side=tk.RIGHT, padx=4)
|
||||
console.pack(fill=tk.BOTH, expand=True)
|
||||
return frame, console
|
||||
|
||||
def _setup_ui(self):
|
||||
controls = tk.Frame(self.root, bg="#1e1e1e")
|
||||
controls.pack(fill=tk.X, padx=10, pady=8)
|
||||
|
||||
# Style shared across all STOP buttons
|
||||
STOP_STYLE = {"bg": "#331111", "fg": "#ff4444", "disabledforeground": "#552222", "relief": "flat", "font": ("Consolas", 9, "bold"), "pady": 3}
|
||||
START_STYLE = {"bg": "#2d2d2d", "relief": "flat", "font": ("Consolas", 9, "bold"), "pady": 3}
|
||||
|
||||
# Clear All Logs
|
||||
tk.Button(controls, text="CLEAR ALL LOGS", bg="#2d2d2d", fg="#ff0000", relief="flat",
|
||||
font=("Consolas", 9, "bold"), pady=3,
|
||||
command=self._clear_all_logs).pack(side=tk.RIGHT, padx=(5, 0))
|
||||
|
||||
# Master Controls
|
||||
self.btn_master_start = tk.Button(controls, text="SYSTEM START", fg="#00ff00", **START_STYLE, command=self.start_all)
|
||||
self.btn_master_start.pack(side=tk.LEFT, padx=4)
|
||||
self.btn_master_stop = tk.Button(controls, text="SYSTEM STOP", **STOP_STYLE, command=self.stop_all)
|
||||
self.btn_master_stop.pack(side=tk.LEFT, padx=4)
|
||||
self.btn_master_kill = tk.Button(controls, text="SYSTEM KILL", bg="#4a0000", fg="#ff2222",
|
||||
disabledforeground="#552222", relief="flat",
|
||||
font=("Consolas", 9, "bold"), pady=3, command=self.kill_all)
|
||||
self.btn_master_kill.pack(side=tk.LEFT, padx=4)
|
||||
|
||||
tk.Label(controls, text=" | ", bg="#1e1e1e", fg="#444444").pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# Interface Controls
|
||||
self.btn_front_start = tk.Button(controls, text="INTERFACE", fg="#61dbfb", **START_STYLE, command=self.start_frontend)
|
||||
self.btn_front_start.pack(side=tk.LEFT, padx=2)
|
||||
self.btn_front_stop = tk.Button(controls, text="OFF", **STOP_STYLE, command=self.stop_frontend)
|
||||
self.btn_front_stop.pack(side=tk.LEFT, padx=2)
|
||||
self.btn_front_view = tk.Button(controls, text="VIEW", fg="#61dbfb", **START_STYLE, command=self.open_frontend_view)
|
||||
self.btn_front_view.pack(side=tk.LEFT, padx=2)
|
||||
|
||||
# Synapse Controls
|
||||
self.btn_back_start = tk.Button(controls, text="SYNAPSE", fg="#ffd43b", **START_STYLE, command=self.start_backend)
|
||||
self.btn_back_start.pack(side=tk.LEFT, padx=(8, 2))
|
||||
self.btn_back_stop = tk.Button(controls, text="OFF", **STOP_STYLE, command=self.stop_backend)
|
||||
self.btn_back_stop.pack(side=tk.LEFT, padx=2)
|
||||
|
||||
# Memory Service Controls
|
||||
self.btn_mem_start = tk.Button(controls, text="MEMORY", fg="#78e08f", **START_STYLE, command=self.start_memory)
|
||||
self.btn_mem_start.pack(side=tk.LEFT, padx=(8, 2))
|
||||
self.btn_mem_stop = tk.Button(controls, text="OFF", **STOP_STYLE, command=self.stop_memory)
|
||||
self.btn_mem_stop.pack(side=tk.LEFT, padx=2)
|
||||
|
||||
# Terminal Displays
|
||||
sys_f, self.sys_console = self._create_terminal(self.root, " >> SYSTEM STATUS", height=5, text_color="#ff00ea")
|
||||
sys_f.pack(fill=tk.X, padx=10, pady=(0, 8))
|
||||
|
||||
bottom_f = tk.Frame(self.root, bg="#1e1e1e")
|
||||
bottom_f.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 8))
|
||||
for col in range(4):
|
||||
bottom_f.columnconfigure(col, weight=1, uniform="term")
|
||||
bottom_f.rowconfigure(0, weight=1)
|
||||
|
||||
f_f, self.front_console = self._create_terminal(bottom_f, " >> INTERFACE (NPM)", text_color="#61dbfb")
|
||||
f_f.grid(row=0, column=0, sticky="nsew", padx=(0, 4))
|
||||
b_f, self.back_console = self._create_terminal(bottom_f, " >> SYNAPSE (UVICORN)", text_color="#ffd43b")
|
||||
b_f.grid(row=0, column=1, sticky="nsew", padx=4)
|
||||
mm_f, self.mem_console = self._create_terminal(bottom_f, " >> MEMORY SERVICE", text_color="#78e08f")
|
||||
mm_f.grid(row=0, column=2, sticky="nsew", padx=4)
|
||||
m_f, self.mind_console = self._create_terminal(bottom_f, " >> MINDTRACE", text_color="#cc88ff")
|
||||
m_f.grid(row=0, column=3, sticky="nsew", padx=(4, 0))
|
||||
|
||||
def _safe_after(self, delay, func):
|
||||
"""Schedule a Tk callback without ever raising.
|
||||
|
||||
tkinter is not thread-safe: the worker/monitor threads call in here,
|
||||
and during shutdown (root being destroyed) an .after() from a thread
|
||||
raises RuntimeError/TclError. Swallowing it keeps a thread's `finally`
|
||||
(which unlinks its PID file) from being aborted mid-way — the cause of
|
||||
the crash-on-close and the stale runtime/pids/*.pid files it left."""
|
||||
if self._closing:
|
||||
return
|
||||
try:
|
||||
self.root.after(delay, func)
|
||||
except (RuntimeError, tk.TclError):
|
||||
pass
|
||||
|
||||
def log(self, tag, message):
|
||||
def _log():
|
||||
con = self.sys_console
|
||||
if tag == "FRONTEND": con = self.front_console
|
||||
elif tag == "BACKEND": con = self.back_console
|
||||
elif tag == "MEMORY": con = self.mem_console
|
||||
elif tag == "MINDTRACE": con = self.mind_console
|
||||
con.insert(tk.END, f"[{tag}] {message}\n", tag)
|
||||
con.see(tk.END)
|
||||
self._safe_after(0, _log)
|
||||
|
||||
def _detect_gpu(self):
|
||||
"""Log a cosmetic colored GPU banner. Best-effort — label only."""
|
||||
def _run(cmd):
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||
return r.stdout if r.returncode == 0 else None
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
out = _run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"])
|
||||
if out and out.strip():
|
||||
self.log("GPU_NVIDIA", f"GPU: NVIDIA — {out.strip().splitlines()[0]} (CUDA)")
|
||||
return
|
||||
|
||||
out = _run(["rocm-smi", "--showproductname"])
|
||||
if out is not None:
|
||||
name = next((l.strip() for l in out.splitlines() if l.strip() and not l.startswith("=")), "")
|
||||
self.log("GPU_AMD", f"GPU: AMD — {name} (ROCm)" if name else "GPU: AMD (ROCm)")
|
||||
return
|
||||
|
||||
out = _run(["vulkaninfo", "--summary"])
|
||||
if out is not None:
|
||||
name = next((l.split("=", 1)[1].strip() for l in out.splitlines()
|
||||
if l.strip().startswith("deviceName")), "GPU")
|
||||
self.log("GPU_VULKAN", f"GPU: {name} (Vulkan)")
|
||||
return
|
||||
|
||||
out = _run(["lspci"]) # hardware present but no usable drivers
|
||||
for line in (out or "").splitlines():
|
||||
ll = line.lower()
|
||||
if any(x in ll for x in ("vga", "3d controller", "display")):
|
||||
if "nvidia" in ll:
|
||||
self.log("GPU_NVIDIA", "GPU: NVIDIA detected (no drivers)")
|
||||
return
|
||||
if "amd" in ll or "radeon" in ll or "advanced micro" in ll:
|
||||
self.log("GPU_AMD", "GPU: AMD detected (no drivers)")
|
||||
return
|
||||
|
||||
self.log("SYSTEM", "GPU: not detected")
|
||||
|
||||
def _poll_status(self):
|
||||
self.update_master_ui()
|
||||
self._safe_after(2000, self._poll_status)
|
||||
|
||||
def update_master_ui(self):
|
||||
if self._closing: # window may be destroyed; stop_* still calls this
|
||||
return
|
||||
f, b, m = self._frontend_is_active(), self._backend_is_active(), self._memory_is_active()
|
||||
self.btn_master_start.config(state=tk.DISABLED if (f and b and m) else tk.NORMAL)
|
||||
self.btn_master_stop.config(state=tk.NORMAL if (f or b or m) else tk.DISABLED)
|
||||
self.btn_front_start.config(state=tk.DISABLED if f else tk.NORMAL, bg="#0b3d0b" if f else "#2d2d2d")
|
||||
self.btn_back_start.config(state=tk.DISABLED if b else tk.NORMAL, bg="#3d3d0b" if b else "#2d2d2d")
|
||||
self.btn_mem_start.config(state=tk.DISABLED if m else tk.NORMAL, bg="#0b2d14" if m else "#2d2d2d")
|
||||
self.btn_front_stop.config(state=tk.NORMAL if f else tk.DISABLED)
|
||||
self.btn_front_view.config(state=tk.NORMAL if f else tk.DISABLED)
|
||||
self.btn_back_stop.config(state=tk.NORMAL if b else tk.DISABLED)
|
||||
self.btn_mem_stop.config(state=tk.NORMAL if m else tk.DISABLED)
|
||||
|
||||
def open_frontend_view(self):
|
||||
webbrowser.open_new_tab(FRONTEND_URL)
|
||||
|
||||
def _spawn_process(self, cmd, cwd):
|
||||
env = os.environ.copy()
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
# If the command uses an absolute path (e.g. nvm npm), prepend its
|
||||
# bin directory to PATH so child processes (node, vite shebang) resolve
|
||||
# from the same installation instead of falling back to /usr/bin/node.
|
||||
if cmd and os.path.isabs(cmd[0]):
|
||||
bin_dir = str(Path(cmd[0]).parent)
|
||||
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
|
||||
kwargs = {
|
||||
"cwd": str(cwd),
|
||||
"stdout": subprocess.PIPE,
|
||||
"stderr": subprocess.STDOUT,
|
||||
"text": True,
|
||||
"bufsize": 1,
|
||||
"env": env,
|
||||
}
|
||||
if os.name == "nt":
|
||||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
kwargs["preexec_fn"] = os.setsid
|
||||
return subprocess.Popen(cmd, **kwargs)
|
||||
|
||||
def _terminate_process(self, process):
|
||||
try:
|
||||
if os.name == "nt":
|
||||
try:
|
||||
process.send_signal(signal.CTRL_BREAK_EVENT)
|
||||
except Exception:
|
||||
pass
|
||||
process.terminate()
|
||||
else:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _write_pid(self, pid_file, pid):
|
||||
try:
|
||||
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(pid_file, "w") as f:
|
||||
f.write(str(pid))
|
||||
except Exception as e:
|
||||
self.log("ERROR", f"PID write failed ({pid_file.name}): {e}")
|
||||
|
||||
def _worker(self, name, cmd, cwd):
|
||||
try:
|
||||
p = self._spawn_process(cmd, cwd)
|
||||
if name == "FRONTEND":
|
||||
self._frontend_starting = False
|
||||
self.frontend_process = p
|
||||
self._write_pid(FRONTEND_PID, p.pid)
|
||||
elif name == "MEMORY":
|
||||
self._memory_starting = False
|
||||
self.memory_process = p
|
||||
self._write_pid(MEMORY_PID, p.pid)
|
||||
else:
|
||||
self._backend_starting = False
|
||||
self.backend_process = p
|
||||
self._write_pid(BACKEND_PID, p.pid)
|
||||
self._safe_after(0, self.update_master_ui)
|
||||
|
||||
if not p.stdout:
|
||||
raise RuntimeError("Process has no stdout stream")
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = p.stdout.readline()
|
||||
except ValueError:
|
||||
break
|
||||
if not line:
|
||||
break
|
||||
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Silence routine HTTP access logs
|
||||
if any(x in line for x in ["GET /", "POST /", "OPTIONS /"]):
|
||||
continue
|
||||
|
||||
# Normalize prefixes to consistent casing
|
||||
line = (line
|
||||
.replace("[Ollama]", "[OLLAMA]")
|
||||
.replace("[Synapse]", "[SYNAPSE]")
|
||||
)
|
||||
|
||||
self.log(name, line)
|
||||
|
||||
p.wait()
|
||||
except Exception as e:
|
||||
self.log("ERROR", f"{name} fault: {e}")
|
||||
finally:
|
||||
self.log("SYSTEM", f"{name} connection closed.")
|
||||
if name == "FRONTEND":
|
||||
self._frontend_starting = False
|
||||
self.frontend_process = None
|
||||
FRONTEND_PID.unlink(missing_ok=True)
|
||||
elif name == "MEMORY":
|
||||
self._memory_starting = False
|
||||
self.memory_process = None
|
||||
MEMORY_PID.unlink(missing_ok=True)
|
||||
else:
|
||||
self._backend_starting = False
|
||||
self.backend_process = None
|
||||
BACKEND_PID.unlink(missing_ok=True)
|
||||
self._safe_after(0, self.update_master_ui)
|
||||
|
||||
def start_frontend(self):
|
||||
if self._frontend_is_active():
|
||||
return
|
||||
self._frontend_starting = True
|
||||
self.update_master_ui()
|
||||
self.log("SYSTEM", "Starting INTERFACE...")
|
||||
threading.Thread(target=self._worker, args=("FRONTEND", FRONTEND_CMD, FRONTEND_DIR), daemon=True).start()
|
||||
|
||||
def stop_frontend(self):
|
||||
if self.frontend_process:
|
||||
self._terminate_process(self.frontend_process)
|
||||
self.frontend_process = None
|
||||
elif self._is_running(FRONTEND_PID):
|
||||
self._terminate_pid_file(FRONTEND_PID)
|
||||
self.update_master_ui()
|
||||
|
||||
def start_backend(self):
|
||||
if self._backend_is_active():
|
||||
return
|
||||
self._backend_starting = True
|
||||
self.update_master_ui()
|
||||
self.log("SYSTEM", "Starting SYNAPSE...")
|
||||
threading.Thread(target=self._worker, args=("BACKEND", BACKEND_CMD, BACKEND_DIR), daemon=True).start()
|
||||
# Wait for backend to start, then start Ollama
|
||||
threading.Thread(target=self._start_ollama_after_backend, daemon=True).start()
|
||||
|
||||
def _start_ollama_after_backend(self):
|
||||
"""Wait for backend to be ready, then start Ollama.
|
||||
If the backend process dies before becoming reachable, stop Ollama."""
|
||||
max_retries = 30
|
||||
for attempt in range(max_retries):
|
||||
# Once the _worker has had a chance to set backend_process, check
|
||||
# whether it has already exited (import error, port conflict, etc.)
|
||||
if attempt >= 2 and self.backend_process is not None:
|
||||
if self.backend_process.poll() is not None:
|
||||
self.log("SYSTEM", "Backend exited before becoming reachable — stopping OLLAMA.")
|
||||
self._stop_ollama_direct()
|
||||
return
|
||||
|
||||
try:
|
||||
response = httpx.get("http://localhost:8000/", timeout=2)
|
||||
if response.status_code == 200:
|
||||
time.sleep(1)
|
||||
self.log("SYSTEM", "Starting OLLAMA...")
|
||||
try:
|
||||
httpx.post("http://localhost:8000/ollama/start", timeout=2)
|
||||
self.log("SYSTEM", "OLLAMA startup command sent.")
|
||||
except Exception as e:
|
||||
self.log("SYSTEM", f"OLLAMA startup error: {e}")
|
||||
return
|
||||
except (httpx.RequestError, ConnectionError):
|
||||
time.sleep(1)
|
||||
|
||||
self.log("SYSTEM", "Backend failed to become reachable — stopping OLLAMA.")
|
||||
self._stop_ollama_direct()
|
||||
|
||||
def _stop_ollama_direct(self):
|
||||
"""Kill Ollama without the backend API (used when backend is not running)."""
|
||||
try:
|
||||
r = httpx.get("http://localhost:11434/api/tags", timeout=2.0)
|
||||
ollama_running = r.status_code == 200
|
||||
except Exception:
|
||||
ollama_running = False
|
||||
|
||||
if not ollama_running:
|
||||
self.log("SYSTEM", "OLLAMA is not running — nothing to stop.")
|
||||
return
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pkill", "-f", "ollama serve"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
self.log("SYSTEM", "OLLAMA stopped.")
|
||||
else:
|
||||
self.log("SYSTEM", "OLLAMA process not found via pkill.")
|
||||
except Exception as e:
|
||||
self.log("SYSTEM", f"Could not stop OLLAMA: {e}")
|
||||
|
||||
def stop_backend(self):
|
||||
self.log("SYSTEM", "Stopping OLLAMA...")
|
||||
try:
|
||||
httpx.post("http://localhost:8000/ollama/stop", timeout=5)
|
||||
except Exception:
|
||||
self._stop_ollama_direct()
|
||||
|
||||
if self.backend_process:
|
||||
self._terminate_process(self.backend_process)
|
||||
self.backend_process = None
|
||||
elif self._is_running(BACKEND_PID):
|
||||
self._terminate_pid_file(BACKEND_PID)
|
||||
self.update_master_ui()
|
||||
|
||||
def start_memory(self):
|
||||
if self._memory_is_active():
|
||||
return
|
||||
self._memory_starting = True
|
||||
self.update_master_ui()
|
||||
self.log("SYSTEM", "Starting MEMORY SERVICE...")
|
||||
threading.Thread(target=self._worker, args=("MEMORY", MEMORY_CMD, MEMORY_DIR), daemon=True).start()
|
||||
|
||||
def stop_memory(self):
|
||||
if self.memory_process:
|
||||
self._terminate_process(self.memory_process)
|
||||
self.memory_process = None
|
||||
elif self._is_running(MEMORY_PID):
|
||||
self._terminate_pid_file(MEMORY_PID)
|
||||
self.update_master_ui()
|
||||
|
||||
def _memory_is_active(self):
|
||||
return self._memory_starting or self.memory_process is not None or self._is_running(MEMORY_PID)
|
||||
|
||||
def _monitor_memory(self):
|
||||
log_path = PROJECT_ROOT / "runtime/memory.log"
|
||||
try:
|
||||
with open(log_path, "r") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
while self._is_running(MEMORY_PID):
|
||||
line = f.readline()
|
||||
if line:
|
||||
self.log("MEMORY", line.rstrip())
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
except Exception as e:
|
||||
self.log("ERROR", f"MEMORY monitor fault: {e}")
|
||||
|
||||
def start_all(self):
|
||||
self.start_memory()
|
||||
self.start_frontend()
|
||||
self.start_backend()
|
||||
|
||||
def kill_all(self):
|
||||
self.log("SYSTEM", "FORCE KILL — terminating all Nexus processes by port...")
|
||||
ports = {8000: "SYNAPSE", 8001: "MEMORY", 5173: "INTERFACE"}
|
||||
for port, name in ports.items():
|
||||
try:
|
||||
r = subprocess.run(["fuser", "-k", f"{port}/tcp"], capture_output=True, timeout=5)
|
||||
self.log("SYSTEM", f"{name} (:{port}) {'killed' if r.returncode == 0 else 'not running'}")
|
||||
except Exception as e:
|
||||
self.log("ERROR", f" Kill :{port} failed: {e}")
|
||||
for pat in ["uvicorn synapse", "npm run dev"]:
|
||||
try:
|
||||
subprocess.run(["pkill", "-9", "-f", pat], capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
for pid_file in [FRONTEND_PID, BACKEND_PID, MEMORY_PID]:
|
||||
pid_file.unlink(missing_ok=True)
|
||||
self.frontend_process = None
|
||||
self.backend_process = None
|
||||
self.memory_process = None
|
||||
self._frontend_starting = False
|
||||
self._backend_starting = False
|
||||
self._memory_starting = False
|
||||
self._safe_after(0, self.update_master_ui)
|
||||
self.log("SYSTEM", "Force kill complete.")
|
||||
|
||||
def stop_all(self):
|
||||
self.stop_frontend()
|
||||
self.stop_backend()
|
||||
self.stop_memory()
|
||||
|
||||
def on_close(self):
|
||||
if self._closing:
|
||||
return
|
||||
self._closing = True
|
||||
# Destroy the window FIRST so closing is instant. The teardown used to
|
||||
# run with the window still up, and stop_backend's blocking calls (a 5s
|
||||
# httpx.post to a backend stop_frontend had just killed, plus httpx +
|
||||
# pkill) froze it into "not responding" — the crash you saw with
|
||||
# services running. Destroy, then tear down fast and non-blocking.
|
||||
try:
|
||||
self.root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._force_teardown()
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(0)
|
||||
|
||||
def _force_teardown(self):
|
||||
"""Fast, non-blocking shutdown for window-close. No HTTP calls: the
|
||||
window is already gone, and the graceful /ollama/stop endpoint (used by
|
||||
the STOP button) blocks for seconds against a backend we're killing."""
|
||||
# Ollama runs in its own session (start_new_session=True), so the group
|
||||
# kills below won't reach it — signal it directly, by binary path so we
|
||||
# don't match unrelated processes. Popen (no wait) keeps this instant.
|
||||
try:
|
||||
subprocess.Popen(["pkill", "-TERM", "-f", "ollama/bin/ollama"])
|
||||
except Exception:
|
||||
pass
|
||||
# Services the panel spawned itself (each in its own setsid group):
|
||||
for proc in (self.frontend_process, self.backend_process, self.memory_process):
|
||||
if proc:
|
||||
self._terminate_process(proc)
|
||||
# Services attached via pid file (ncp-started, one shared group);
|
||||
# _terminate_pid_file verifies ownership and unlinks. Guarantee the
|
||||
# unlink even for a pid that's already dead so no stale files remain.
|
||||
for pf in (FRONTEND_PID, BACKEND_PID, MEMORY_PID):
|
||||
if self._is_running(pf):
|
||||
self._terminate_pid_file(pf)
|
||||
else:
|
||||
pf.unlink(missing_ok=True)
|
||||
|
||||
def _frontend_is_active(self):
|
||||
return self._frontend_starting or self.frontend_process is not None or self._is_running(FRONTEND_PID)
|
||||
|
||||
def _backend_is_active(self):
|
||||
return self._backend_starting or self.backend_process is not None or self._is_running(BACKEND_PID)
|
||||
|
||||
def _pid_is_ours(self, pid):
|
||||
"""True only if the live PID is actually one of our services. PID files
|
||||
outlive the process; the OS recycles the number onto something unrelated
|
||||
(a desktop app, a shell — or this panel), and killpg on a recycled PID's
|
||||
group takes down the wrong processes. Mirrors nexus-cli.sh's guard."""
|
||||
try:
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||
cmd = f.read().replace(b"\0", b" ").decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return False
|
||||
return any(m in cmd for m in ("uvicorn synapse", "npm run dev", "vite"))
|
||||
|
||||
def _terminate_pid_file(self, pid_file):
|
||||
try:
|
||||
with open(pid_file, "r") as f:
|
||||
pid = int(f.read().strip())
|
||||
except Exception:
|
||||
pid_file.unlink(missing_ok=True)
|
||||
return
|
||||
try:
|
||||
if os.name == "nt":
|
||||
try:
|
||||
os.kill(pid, signal.CTRL_BREAK_EVENT)
|
||||
except Exception:
|
||||
pass
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
elif self._pid_is_ours(pid):
|
||||
try:
|
||||
pgid = os.getpgid(pid)
|
||||
# Never killpg our OWN group — if a service was launched into
|
||||
# the panel's process group, nuking the group SIGKILLs the
|
||||
# panel mid-close. Kill just the service pid in that case.
|
||||
if pgid == os.getpgrp():
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
else:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except (ProcessLookupError, OSError):
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
# else: PID recycled onto a non-service — don't kill the wrong thing
|
||||
except Exception:
|
||||
pass
|
||||
pid_file.unlink(missing_ok=True)
|
||||
|
||||
def _is_running(self, pid_file):
|
||||
try:
|
||||
with open(pid_file, "r") as f:
|
||||
pid = int(f.read().strip())
|
||||
os.kill(pid, 0) # Check if process exists
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def _monitor_backend(self):
|
||||
log_path = PROJECT_ROOT / "runtime/backend.log"
|
||||
try:
|
||||
with open(log_path, "r") as f:
|
||||
f.seek(0, os.SEEK_END) # Start at end of file
|
||||
while self._is_running(PROJECT_ROOT / "runtime/pids/backend.pid"):
|
||||
line = f.readline()
|
||||
if line:
|
||||
self.log("BACKEND", line.rstrip())
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
except Exception as e:
|
||||
self.log("ERROR", f"BACKEND fault: {e}")
|
||||
|
||||
def _monitor_frontend(self):
|
||||
log_path = PROJECT_ROOT / "runtime/frontend.log"
|
||||
try:
|
||||
with open(log_path, "r") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
while self._is_running(PROJECT_ROOT / "runtime/pids/frontend.pid"):
|
||||
line = f.readline()
|
||||
if line:
|
||||
self.log("FRONTEND", line.rstrip())
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
except Exception as e:
|
||||
self.log("ERROR", f"FRONTEND fault: {e}")
|
||||
|
||||
def _monitor_synapses(self):
|
||||
while True:
|
||||
try:
|
||||
if not CHAT_LOG.exists():
|
||||
time.sleep(2)
|
||||
continue
|
||||
with open(CHAT_LOG, "r", encoding="utf-8") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
while True:
|
||||
line = f.readline()
|
||||
if line:
|
||||
stripped = line.rstrip()
|
||||
if stripped:
|
||||
self.log("MINDTRACE", stripped)
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
except Exception as e:
|
||||
self.log("ERROR", f"MINDTRACE monitor fault: {e}")
|
||||
time.sleep(2)
|
||||
|
||||
PANEL_LOG = PROJECT_ROOT / "runtime" / "logs" / "panel.log"
|
||||
|
||||
def _log_crash(where, exc_type, exc_value, exc_tb):
|
||||
"""Persist tracebacks — the .desktop launcher runs Terminal=false, so
|
||||
stderr is discarded and crashes are otherwise invisible. Covers the three
|
||||
surfaces tk hides: main thread, worker threads, and Tk callbacks."""
|
||||
import traceback, datetime
|
||||
try:
|
||||
PANEL_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(PANEL_LOG, "a") as f:
|
||||
f.write(f"\n[{datetime.datetime.now().isoformat()}] {where}\n")
|
||||
traceback.print_exception(exc_type, exc_value, exc_tb, file=f)
|
||||
except Exception:
|
||||
pass
|
||||
traceback.print_exception(exc_type, exc_value, exc_tb)
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.excepthook = lambda et, ev, tb: _log_crash("main-thread uncaught", et, ev, tb)
|
||||
threading.excepthook = lambda a: _log_crash("thread uncaught", a.exc_type, a.exc_value, a.exc_traceback)
|
||||
root = tk.Tk()
|
||||
root.report_callback_exception = lambda et, ev, tb: _log_crash("tk callback", et, ev, tb)
|
||||
app = NexusControlPanel(root)
|
||||
root.mainloop()
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launch NexusOS as a standalone Edge app window. Single-process mode: the
|
||||
# backend on :8000 serves the built web UI itself (no separate Vite), so this
|
||||
# only starts the memory service + backend. The AI (Ollama) does NOT auto-start
|
||||
# — turn it on from the UI's Start AI button. Closing the app window shuts the
|
||||
# services back down (matches the launcher's "closing the window shuts it down").
|
||||
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)"
|
||||
EDGE_PROFILE="$HOME/.config/nexus-edge"
|
||||
APP_URL="http://localhost:8000"
|
||||
|
||||
# If the app is already open, just raise another window in the existing
|
||||
# instance and leave the services alone — this launch doesn't own them.
|
||||
if pgrep -f "user-data-dir=$EDGE_PROFILE" >/dev/null 2>&1; then
|
||||
exec microsoft-edge-stable --user-data-dir="$EDGE_PROFILE" --app="$APP_URL"
|
||||
fi
|
||||
|
||||
# Own the services (and stop them on exit) only if WE start them. If the stack
|
||||
# is already up — started elsewhere (ncp start, the control panel) — this launch
|
||||
# is just a viewer and must not tear down someone else's services on close.
|
||||
# Backend on :8000 is the sentinel for "stack already running".
|
||||
if curl -s --max-time 1 "$APP_URL/" >/dev/null 2>&1; then
|
||||
OWN_SERVICES=0
|
||||
else
|
||||
OWN_SERVICES=1
|
||||
# Single-process app: memory + backend only. Backend serves the built UI at
|
||||
# :8000; no frontend/Vite process, no AI auto-start.
|
||||
"$NEXUS_ROOT/management/nexus-cli.sh" start -m
|
||||
"$NEXUS_ROOT/management/nexus-cli.sh" start -b
|
||||
fi
|
||||
|
||||
# Shut the services back down whenever this script ends — whether Edge exits
|
||||
# normally (window closed) or the script is itself terminated (SIGTERM/SIGHUP
|
||||
# from the session/WM). Only armed when we started the stack.
|
||||
[ "$OWN_SERVICES" = 1 ] && trap '"$NEXUS_ROOT/management/nexus-cli.sh" stop' EXIT
|
||||
|
||||
# Run the app in its OWN Edge profile (--user-data-dir). Without this, the
|
||||
# --class=NexusOS window becomes the Chromium "singleton owner" of the default
|
||||
# profile, so every later browser window inherits WM_CLASS=NexusOS. A separate
|
||||
# profile keeps only the app window NexusOS-class; regular browsing stays its own.
|
||||
#
|
||||
# Runs in the FOREGROUND, blocking until the app window is closed.
|
||||
# --disable-background-mode is essential: without it Edge keeps a background
|
||||
# process alive after the last window closes, so this call never returns and the
|
||||
# services are never stopped.
|
||||
microsoft-edge-stable \
|
||||
--user-data-dir="$EDGE_PROFILE" \
|
||||
--no-first-run \
|
||||
--no-default-browser-check \
|
||||
--disable-background-mode \
|
||||
--class=NexusOS \
|
||||
--app="$APP_URL"
|
||||
@@ -0,0 +1,675 @@
|
||||
#!/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.
|
||||
#
|
||||
# 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" "$@"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# -----------------------------
|
||||
# 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
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Nexus Control Panel
|
||||
Comment=Launch the Nexus Control Panel for Project Nexus
|
||||
Exec=/home/jon/nexus-core/Promethean/bin/python /home/jon/nexus-core/management/controlpanel.py
|
||||
Icon=/home/jon/nexus-core/assets/n-small.png
|
||||
Terminal=false
|
||||
Categories=Development;Utility;
|
||||
StartupWMClass=Tk
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backend for `ncp` subcommands (chat/memory/playbook/history).
|
||||
|
||||
Not a second CLI — `ncp` is the only entrypoint. This just hits the same REST
|
||||
API the web UI uses, so the terminal matches the general features. httpx and
|
||||
argparse only (both already present); no curses, no framework.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
BASE = __import__("os").environ.get("NEXUS_API", "http://localhost:8000")
|
||||
|
||||
|
||||
def _client():
|
||||
return httpx.Client(base_url=BASE, timeout=None)
|
||||
|
||||
|
||||
def _die_if_down(exc: Exception):
|
||||
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
|
||||
sys.exit(f"Backend not reachable at {BASE}. Start it: ncp start -b")
|
||||
raise exc
|
||||
|
||||
|
||||
def iter_chunks(lines):
|
||||
"""Yield ('kind', payload) from raw SSE lines. kind is 'chunk' for reply
|
||||
text, else the event name ('meta'/'done'/'error'/'title'/...). Pure so the
|
||||
stream parsing is unit-testable without a live server (see test_nexus_api.py)."""
|
||||
event = "message"
|
||||
for line in lines:
|
||||
if line == "": # blank line ends an event block
|
||||
event = "message"
|
||||
continue
|
||||
if line.startswith("event:"):
|
||||
event = line[6:].strip()
|
||||
elif line.startswith("data:"):
|
||||
data = line[5:].strip()
|
||||
if event in ("message", ""):
|
||||
yield "chunk", json.loads(data) # server json-encodes each token
|
||||
else:
|
||||
yield event, data
|
||||
|
||||
|
||||
def cmd_chat(args):
|
||||
body = {"message": " ".join(args.message)}
|
||||
if args.model:
|
||||
body["model"] = args.model
|
||||
try:
|
||||
with _client() as c, c.stream("POST", "/chat/stream", json=body) as r:
|
||||
r.raise_for_status()
|
||||
for kind, payload in iter_chunks(r.iter_lines()):
|
||||
if kind == "chunk":
|
||||
sys.stdout.write(payload)
|
||||
sys.stdout.flush()
|
||||
elif kind == "escalating":
|
||||
sys.stderr.write("\n[escalating to Claude…]\n")
|
||||
elif kind == "error":
|
||||
sys.exit("\n" + json.loads(payload).get("detail", "chat failed"))
|
||||
elif kind == "done":
|
||||
break
|
||||
print()
|
||||
except Exception as e:
|
||||
_die_if_down(e)
|
||||
|
||||
|
||||
def cmd_memory(args):
|
||||
try:
|
||||
with _client() as c:
|
||||
if args.action == "list":
|
||||
items = c.get("/memory").json()["items"]
|
||||
if not items:
|
||||
print("No memory facts.")
|
||||
return
|
||||
section = None
|
||||
for m in items:
|
||||
if m["section"] != section:
|
||||
section = m["section"]
|
||||
print(f"\n## {section}")
|
||||
print(f" {m['id'][:8]} {m['text']}")
|
||||
elif args.action == "add":
|
||||
m = c.post("/memory", json={"text": " ".join(args.rest),
|
||||
"section": args.section}).json()
|
||||
print(f"added {m['id'][:8]} to {m['section']}")
|
||||
elif args.action == "rm":
|
||||
c.delete(f"/memory/{args.rest[0]}").raise_for_status()
|
||||
print("deleted")
|
||||
except Exception as e:
|
||||
_die_if_down(e)
|
||||
|
||||
|
||||
def cmd_playbook(args):
|
||||
try:
|
||||
with _client() as c:
|
||||
if args.action == "list":
|
||||
pbs = c.get("/playbooks").json()["playbooks"]
|
||||
for i, p in enumerate(pbs):
|
||||
mark = "* " if i == 0 else " " # first = active system prompt
|
||||
tags = f" [{', '.join(p['tags'])}]" if p["tags"] else ""
|
||||
print(f"{mark}{p['id'][:8]} {p['title']}{tags}")
|
||||
elif args.action == "show":
|
||||
p = c.get(f"/playbooks/{args.rest[0]}").json()
|
||||
print(f"# {p['title']}\n\nGoal: {p['goal']}\n\n{p['instructions']}")
|
||||
except Exception as e:
|
||||
_die_if_down(e)
|
||||
|
||||
|
||||
def cmd_history(args):
|
||||
try:
|
||||
with _client() as c:
|
||||
params = {"q": args.query} if args.query else {}
|
||||
convs = c.get("/conversations", params=params).json()["conversations"]
|
||||
for cv in convs[:args.limit]:
|
||||
title = cv.get("title") or cv.get("preview") or "(untitled)"
|
||||
print(f" {cv['id'][:8]} {title}")
|
||||
except Exception as e:
|
||||
_die_if_down(e)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(prog="ncp")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
c = sub.add_parser("chat"); c.add_argument("message", nargs="+")
|
||||
c.add_argument("--model"); c.set_defaults(fn=cmd_chat)
|
||||
|
||||
m = sub.add_parser("memory"); m.add_argument("action", choices=["list", "add", "rm"])
|
||||
m.add_argument("rest", nargs="*"); m.add_argument("--section", default="General")
|
||||
m.set_defaults(fn=cmd_memory)
|
||||
|
||||
pb = sub.add_parser("playbook"); pb.add_argument("action", choices=["list", "show"])
|
||||
pb.add_argument("rest", nargs="*"); pb.set_defaults(fn=cmd_playbook)
|
||||
|
||||
h = sub.add_parser("history"); h.add_argument("query", nargs="?")
|
||||
h.add_argument("--limit", type=int, default=20); h.set_defaults(fn=cmd_history)
|
||||
|
||||
args = p.parse_args()
|
||||
args.fn(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
[Monitor]
|
||||
Command=/home/jon/.local/bin/network-applet.sh
|
||||
UpdatePeriod=5000
|
||||
UseLabel=0
|
||||
Font=
|
||||
Text=(genmon)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[Monitor]
|
||||
Command=/home/jon/.local/bin/nexus-applet.sh
|
||||
UpdatePeriod=5000
|
||||
UseLabel=0
|
||||
Font=
|
||||
Text=(genmon)
|
||||
@@ -0,0 +1,6 @@
|
||||
[Monitor]
|
||||
Command=/home/jon/.local/bin/bluetooth-applet.sh
|
||||
UpdatePeriod=5000
|
||||
UseLabel=0
|
||||
Font=
|
||||
Text=(genmon)
|
||||
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=NexusOS-KDE
|
||||
Comment=NexusOS on KDE Plasma (X11)
|
||||
Exec=startplasma-x11
|
||||
Icon=
|
||||
Type=Application
|
||||
DesktopNames=KDE
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Guard against the crash-on-close regression.
|
||||
|
||||
The panel's worker/monitor threads schedule Tk callbacks via _safe_after. During
|
||||
shutdown that .after() raises (thread + dying interpreter); if it escaped, the
|
||||
worker's `finally` aborted before unlinking its PID file -> stale runtime/pids/*.
|
||||
These asserts pin the two invariants that prevent that. Run: python test_controlpanel_close.py
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from controlpanel import NexusControlPanel
|
||||
|
||||
|
||||
def _fake(closing, after_raises):
|
||||
def after(_delay, _fn):
|
||||
if after_raises:
|
||||
raise RuntimeError("main thread is not in main loop")
|
||||
return SimpleNamespace(_closing=closing, root=SimpleNamespace(after=after))
|
||||
|
||||
|
||||
# 1. A raising .after() (teardown condition) must NOT propagate.
|
||||
NexusControlPanel._safe_after(_fake(closing=False, after_raises=True), 0, lambda: None)
|
||||
|
||||
# 2. Once closing, we must not touch Tk at all — schedule is skipped.
|
||||
scheduled = []
|
||||
obj = SimpleNamespace(_closing=True, root=SimpleNamespace(after=lambda d, f: scheduled.append(f)))
|
||||
NexusControlPanel._safe_after(obj, 0, lambda: None)
|
||||
assert scheduled == [], "closing panel must not schedule Tk callbacks"
|
||||
|
||||
print("ok")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Pin the SSE parser in nexus_api. Run: python management/test_nexus_api.py"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from nexus_api import iter_chunks
|
||||
|
||||
# A realistic /chat/stream frame: two token chunks, a meta block, then done.
|
||||
lines = [
|
||||
'data: "Hello"', "",
|
||||
'data: " world"', "",
|
||||
"event: meta", 'data: {"model":"mistral"}', "",
|
||||
"event: done", "data: {}", "",
|
||||
]
|
||||
out = list(iter_chunks(lines))
|
||||
assert out == [
|
||||
("chunk", "Hello"),
|
||||
("chunk", " world"),
|
||||
("meta", '{"model":"mistral"}'),
|
||||
("done", "{}"),
|
||||
], out
|
||||
# error frame surfaces as its own kind, not a chunk
|
||||
assert list(iter_chunks(["event: error", 'data: {"detail":"boom"}'])) == [
|
||||
("error", '{"detail":"boom"}')
|
||||
]
|
||||
print("ok")
|
||||
Reference in New Issue
Block a user