#!/usr/bin/env python3 """Nexus Chat — interactive TUI for the Synapse backend.""" import curses import httpx import json import os import signal import subprocess import sys import textwrap import threading import time import uuid NEXUS_ROOT = os.path.expanduser("~/nexus-core") API_BASE = "http://localhost:8000" MEMORY_BASE = "http://localhost:8001" PID_FILE = os.path.join(NEXUS_ROOT, "runtime/pids/backend.pid") LOG_FILE = os.path.join(NEXUS_ROOT, "runtime/backend.log") VENV_PY = os.path.join(NEXUS_ROOT, "Promethean/bin/python3") BACKEND_CMD = [ "uvicorn", "synapse.main:sio_app", "--host", "0.0.0.0", "--port", "8000", ] # ─── backend helpers ────────────────────────────────────────────────────────── def backend_running() -> bool: try: return httpx.get(f"{API_BASE}/", timeout=2.0).status_code == 200 except Exception: return False def start_backend() -> int | None: """Start the backend and return its PID, or None on failure.""" os.makedirs(os.path.dirname(PID_FILE), exist_ok=True) python = VENV_PY if os.path.exists(VENV_PY) else sys.executable cmd = [python, "-m"] + BACKEND_CMD with open(LOG_FILE, "a") as log: proc = subprocess.Popen( cmd, cwd=NEXUS_ROOT, stdout=log, stderr=log, start_new_session=True ) with open(PID_FILE, "w") as f: f.write(str(proc.pid)) for _ in range(30): time.sleep(1) if backend_running(): return proc.pid return None def stop_backend(pid: int): """Gracefully stop the backend process we started.""" try: os.kill(pid, signal.SIGTERM) except ProcessLookupError: return for _ in range(10): time.sleep(0.5) try: os.kill(pid, 0) except ProcessLookupError: break else: try: os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass try: os.remove(PID_FILE) except FileNotFoundError: pass def fetch_models() -> tuple[list[str], str]: """Return (model_list, selected_model).""" try: r = httpx.get(f"{API_BASE}/models", timeout=2.0) if r.status_code == 200: data = r.json() models = data.get("models", []) selected = data.get("selected") or (models[0] if models else "mistral") return models, selected except Exception: pass return ["mistral"], "mistral" # ─── TUI ────────────────────────────────────────────────────────────────────── SHORTCUTS = [ ("^X", "Exit"), ("^R", "Regen"), ("^N", "New"), ("^L", "Clear"), ("^T", "Model"), ("PgUp", "Up"), ("PgDn", "Down"), ("/remember", "Save fact"), ] # color pair indices CP_HEADER = 1 # header bar CP_SHORTBAR = 2 # shortcut bar bg CP_USER = 3 # "You" label CP_NEXUS = 4 # "Nexus" label CP_DIVIDER = 5 # divider line CP_STREAMING = 6 # streaming indicator CP_STATS = 7 # token stats line class NexusChat: def __init__(self, stdscr): self.scr = stdscr self.conv_id = str(uuid.uuid4()) self.history = [] # [{"role": ..., "content": ..., "stats": {...}|None}] self.input_buf = "" self.scroll = 0 # lines scrolled up from bottom self.streaming = False self.stream_buf = "" self._last_meta: dict | None = None self._memory_saved: dict | None = None # set when Mistral saves a fact self.models, self.model = fetch_models() self.model_idx = self.models.index(self.model) if self.model in self.models else 0 self._lock = threading.Lock() curses.start_color() curses.use_default_colors() curses.init_pair(CP_HEADER, curses.COLOR_BLACK, curses.COLOR_CYAN) curses.init_pair(CP_SHORTBAR, curses.COLOR_BLACK, curses.COLOR_WHITE) curses.init_pair(CP_USER, curses.COLOR_CYAN, -1) curses.init_pair(CP_NEXUS, curses.COLOR_MAGENTA, -1) curses.init_pair(CP_DIVIDER, curses.COLOR_CYAN, -1) curses.init_pair(CP_STREAMING,curses.COLOR_YELLOW, -1) curses.init_pair(CP_STATS, curses.COLOR_WHITE, -1) curses.curs_set(1) self.scr.keypad(True) self.scr.timeout(50) # ── geometry ────────────────────────────────────────────────────────────── def dims(self): return self.scr.getmaxyx() # (h, w) def msg_rows(self, h): # row 0 = header, row h-3 = divider, row h-2 = input, row h-1 = shortbar return range(1, h - 3) # ── drawing ─────────────────────────────────────────────────────────────── def _put(self, row, col, text, attr=0): h, w = self.dims() if row < 0 or row >= h: return try: self.scr.addstr(row, col, text[:max(0, w - col)], attr) except curses.error: pass def draw_header(self): h, w = self.dims() left = " NEXUS CHAT " right = f" model: {self.model} " if self.streaming: mid = " streaming… " mid_attr = curses.color_pair(CP_STREAMING) | curses.A_BOLD else: mid = "" mid_attr = 0 gap = max(0, w - len(left) - len(right) - len(mid)) bar = left + " " * (gap // 2) + mid + " " * (gap - gap // 2) + right self.scr.attron(curses.color_pair(CP_HEADER) | curses.A_BOLD) self._put(0, 0, bar.ljust(w)[:w]) self.scr.attroff(curses.color_pair(CP_HEADER) | curses.A_BOLD) def draw_shortbar(self): h, w = self.dims() # Fill bg self.scr.attron(curses.color_pair(CP_SHORTBAR)) self._put(h - 1, 0, " " * (w - 1)) self.scr.attroff(curses.color_pair(CP_SHORTBAR)) # Draw key/label pairs x = 1 for key, label in SHORTCUTS: if x + len(key) + len(label) + 2 >= w: break self.scr.attron(curses.A_REVERSE) self._put(h - 1, x, key) self.scr.attroff(curses.A_REVERSE) x += len(key) self.scr.attron(curses.color_pair(CP_SHORTBAR)) self._put(h - 1, x, f" {label} ") self.scr.attroff(curses.color_pair(CP_SHORTBAR)) x += len(label) + 3 def draw_divider(self): h, w = self.dims() self.scr.attron(curses.color_pair(CP_DIVIDER)) self._put(h - 3, 0, "─" * (w - 1)) self.scr.attroff(curses.color_pair(CP_DIVIDER)) def draw_input(self): h, w = self.dims() prompt = " › " # › prompt avail = w - len(prompt) - 1 display = self.input_buf[-avail:] if len(self.input_buf) > avail else self.input_buf try: self.scr.move(h - 2, 0) self.scr.clrtoeol() except curses.error: pass self._put(h - 2, 0, prompt) self._put(h - 2, len(prompt), display) def _build_lines(self, w): """Render history (+ live stream) into a flat list of (text, attr) tuples.""" lines = [] with self._lock: history = list(self.history) sbuf = self.stream_buf in_stream = self.streaming for msg in history: role = msg["role"] content = msg["content"] if role == "user": label = " You " attr = curses.color_pair(CP_USER) | curses.A_BOLD else: label = " Nexus " attr = curses.color_pair(CP_NEXUS) | curses.A_BOLD lines.append((label, attr)) for wl in textwrap.wrap(content, w - 4) or [""]: lines.append((f" {wl}", 0)) if role == "assistant": stats = msg.get("stats") if stats: tok_s = f" {stats['tokens']} tok · {stats['elapsed_s']}s · {stats['tokens_per_s']} t/s" else: est = max(1, len(content) // 4) tok_s = f" ~{est} tok (est)" lines.append((tok_s, curses.color_pair(CP_STATS))) mem = msg.get("memory") if mem: mem_s = f" + memory saved [{mem['section']}]: {mem['text'][:60]}" lines.append((mem_s, curses.color_pair(CP_STREAMING) | curses.A_BOLD)) lines.append(("", 0)) if in_stream: lines.append((" Nexus ", curses.color_pair(CP_NEXUS) | curses.A_BOLD)) for wl in textwrap.wrap(sbuf, w - 4) or ["…"]: lines.append((f" {wl}", 0)) return lines def draw_messages(self): h, w = self.dims() rows = self.msg_rows(h) height = len(rows) if height <= 0: return lines = self._build_lines(w) total = len(lines) if self.scroll == 0: start = max(0, total - height) else: start = max(0, total - height - self.scroll) visible = lines[start:start + height] for i, row in enumerate(rows): try: self.scr.move(row, 0) self.scr.clrtoeol() except curses.error: pass if i < len(visible): text, attr = visible[i] if attr: self.scr.attron(attr) self._put(row, 0, text) if attr: self.scr.attroff(attr) def draw(self): h, w = self.dims() self.draw_header() self.draw_messages() self.draw_divider() self.draw_input() self.draw_shortbar() # cursor to end of input prompt_len = 4 # " › " avail = w - prompt_len - 1 cx = prompt_len + min(len(self.input_buf), avail) try: self.scr.move(h - 2, cx) except curses.error: pass self.scr.refresh() # ── actions ─────────────────────────────────────────────────────────────── def send(self, message: str): with self._lock: self.history.append({"role": "user", "content": message}) prior = [m for m in self.history[:-1]] self.streaming = True self.stream_buf = "" self.scroll = 0 def _stream(): capture_meta = False capture_memory = False try: payload = { "message": message, "model": self.model, "conversation_id": self.conv_id, "history": prior, } with httpx.Client(timeout=180.0) as client: with client.stream("POST", f"{API_BASE}/chat/stream", json=payload) as resp: for line in resp.iter_lines(): if line == "event: meta": capture_meta = True continue if line == "event: memory": capture_memory = True continue if capture_meta: capture_meta = False if line.startswith("data: "): try: with self._lock: self._last_meta = json.loads(line[6:]) except Exception: pass continue if capture_memory: capture_memory = False if line.startswith("data: "): try: with self._lock: self._memory_saved = json.loads(line[6:]) except Exception: pass continue if line.startswith("data: "): with self._lock: self.stream_buf += line[6:] except Exception as e: with self._lock: self.stream_buf += f"\n[Error: {e}]" finally: with self._lock: if self.stream_buf: self.history.append({ "role": "assistant", "content": self.stream_buf, "stats": self._last_meta, "memory": self._memory_saved, }) self.streaming = False self.stream_buf = "" self._last_meta = None self._memory_saved = None threading.Thread(target=_stream, daemon=True).start() def regenerate(self): if self.streaming: return with self._lock: if not self.history: return if self.history[-1]["role"] == "assistant": self.history.pop() last_user = None for i in range(len(self.history) - 1, -1, -1): if self.history[i]["role"] == "user": last_user = self.history.pop(i)["content"] break if last_user: self.send(last_user) def cycle_model(self): if self.streaming or not self.models: return self.model_idx = (self.model_idx + 1) % len(self.models) self.model = self.models[self.model_idx] def new_conversation(self): if self.streaming: return with self._lock: self.history = [] self.conv_id = str(uuid.uuid4()) self.stream_buf = "" self.scroll = 0 def _cmd_remember(self, text: str): """Write a fact directly to the memory service, bypassing the LLM curator. Syntax: /remember /remember [Section] """ import re as _re section = "General" m = _re.match(r'^\[([^\]]+)\]\s*(.+)', text) if m: section, text = m.group(1).strip(), m.group(2).strip() if not text: return try: r = httpx.post( f"{MEMORY_BASE}/memories", json={"section": section, "text": text}, timeout=5.0, ) if r.status_code == 200: data = r.json() saved = {"section": data["section"], "text": data["text"]} with self._lock: self.history.append({ "role": "assistant", "content": f"Saved to memory [{saved['section']}]: {saved['text']}", "stats": None, "memory": saved, }) else: with self._lock: self.history.append({ "role": "assistant", "content": f"Memory service returned {r.status_code}", "stats": None, "memory": None, }) except Exception as e: with self._lock: self.history.append({ "role": "assistant", "content": f"Memory service unreachable: {e}", "stats": None, "memory": None, }) # ── main loop ───────────────────────────────────────────────────────────── def run(self): while True: self.draw() try: key = self.scr.getch() except KeyboardInterrupt: break if key == -1: continue if key == 24: # ^X exit break elif key == 18 and not self.streaming: # ^R regen self.regenerate() elif key == 14: # ^N new conversation self.new_conversation() elif key == 12: # ^L clear (alias new) self.new_conversation() elif key == 20 and not self.streaming: # ^T cycle model self.cycle_model() elif key in (10, 13): # Enter send msg = self.input_buf.strip() if msg and not self.streaming: self.input_buf = "" if msg.lower().startswith("/remember "): self._cmd_remember(msg[10:].strip()) else: self.send(msg) elif key in (127, curses.KEY_BACKSPACE, 8): self.input_buf = self.input_buf[:-1] elif key == curses.KEY_UP: self.scroll += 1 elif key == curses.KEY_DOWN: self.scroll = max(0, self.scroll - 1) elif key == curses.KEY_PPAGE: # PgUp h, _ = self.dims() self.scroll += max(1, h - 6) elif key == curses.KEY_NPAGE: # PgDn h, _ = self.dims() self.scroll = max(0, self.scroll - max(1, h - 6)) elif key == curses.KEY_HOME: self.scroll = 9999 # jump to top elif key == curses.KEY_END: self.scroll = 0 # jump to bottom elif 32 <= key <= 126: self.input_buf += chr(key) # ─── entry point ────────────────────────────────────────────────────────────── def main(): print("Checking Nexus backend…", flush=True) started_pid = None if not backend_running(): print("Synapse is not running — starting backend…", flush=True) started_pid = start_backend() if started_pid is None: print(f"ERROR: backend failed to start. Logs: {LOG_FILE}") sys.exit(1) print("Backend ready.", flush=True) else: print("Backend is running.", flush=True) time.sleep(0.2) try: curses.wrapper(lambda s: NexusChat(s).run()) finally: if started_pid is not None: print("\nShutting down Synapse backend…", flush=True) stop_backend(started_pid) print("Done.", flush=True) if __name__ == "__main__": main()