synapse/memory/service.py was deleted on 2026-08-25 when memory curation moved in-process (curator.py) - there is no longer a second FastAPI app to run on :8001. This CLI was evidently built against a pre-curator baseline: `nexus serve` spawned `synapse.memory.service:app` (fails with ModuleNotFoundError, logged only to memory.log where nobody would see it), `nexus start memory`/`stop memory` had no handler at all (silently fell through to show_help()), and doctor/status/monitor all carried a "memory service" row that could never be anything but down. Removed rather than repaired, since there's nothing to repair: the service, its SERVICES entry, --memory-port/--no-memory, the -m/--memory target everywhere it was offered (start/stop/logs/LEGACY_TARGETS), and the memory_port/memory_url settings this PR had added. The `nexus memory list|add|rm` data commands (nexus_api.py, hitting the backend's own /memory REST endpoint) are untouched - unrelated, and still work.
457 lines
15 KiB
Python
457 lines
15 KiB
Python
"""ASCII dashboard for live NexusOS service / tool / resource stats.
|
|
|
|
No curses, no rich — pure box-drawing + optional ANSI color so it works in
|
|
Termux, plain SSH, and Windows Terminal alike. The collector is separate from
|
|
the renderer so tests can feed fixtures without a running stack.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from synapse.nexus_config import settings
|
|
|
|
from . import ncp as services
|
|
|
|
# Box drawing — ASCII fallbacks when the terminal can't do Unicode.
|
|
_BOX = {
|
|
"tl": "┌", "tr": "┐", "bl": "└", "br": "┘",
|
|
"h": "─", "v": "│", "l": "├", "r": "┤",
|
|
}
|
|
_BOX_ASCII = {
|
|
"tl": "+", "tr": "+", "bl": "+", "br": "+",
|
|
"h": "-", "v": "|", "l": "+", "r": "+",
|
|
}
|
|
|
|
_FILL = "█"
|
|
_EMPTY = "░"
|
|
_FILL_ASCII = "#"
|
|
_EMPTY_ASCII = "-"
|
|
|
|
|
|
def _use_unicode() -> bool:
|
|
enc = (getattr(__import__("sys").stdout, "encoding", None) or "").lower()
|
|
return "utf" in enc or enc in ("cp65001",)
|
|
|
|
|
|
def _http_ok(url: str, timeout: float = 1.0) -> bool:
|
|
try:
|
|
urllib.request.urlopen(url, timeout=timeout).read(1)
|
|
return True
|
|
except urllib.error.HTTPError:
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _provider_payload() -> dict:
|
|
url = settings.ollama_host.rstrip("/")
|
|
return {
|
|
"provider": settings.provider,
|
|
"url": url,
|
|
"managed_by_nexus": settings.manage_ollama,
|
|
"reachable": _http_ok(url + "/api/tags", timeout=2.0),
|
|
}
|
|
|
|
|
|
def _service_status() -> dict:
|
|
payload = {}
|
|
for key in ("backend", "frontend"):
|
|
svc = services.SERVICES[key]
|
|
pid = services.read_pid(svc)
|
|
payload[key] = {
|
|
"running": services.alive(pid) or _http_ok(svc.url),
|
|
"pid": pid if services.alive(pid) else None,
|
|
"url": svc.url,
|
|
}
|
|
payload["provider"] = _provider_payload()
|
|
return payload
|
|
|
|
|
|
def _get_json(url: str, timeout: float = 1.5) -> Any | None:
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode("utf-8", errors="replace"))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _bar(ratio: float, width: int = 20, unicode: bool = True) -> str:
|
|
ratio = max(0.0, min(1.0, float(ratio)))
|
|
filled = int(round(ratio * width))
|
|
fill = _FILL if unicode else _FILL_ASCII
|
|
empty = _EMPTY if unicode else _EMPTY_ASCII
|
|
return fill * filled + empty * (width - filled)
|
|
|
|
|
|
def _fmt_bytes(n: float | int | None) -> str:
|
|
if n is None:
|
|
return "—"
|
|
n = float(n)
|
|
for unit in ("B", "K", "M", "G", "T"):
|
|
if abs(n) < 1024 or unit == "T":
|
|
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
|
|
n /= 1024
|
|
return f"{n:.1f}T"
|
|
|
|
|
|
def _pid_stats(pids: list[int | None]) -> dict:
|
|
"""Aggregate CPU%/RSS for known service PIDs. Soft-depends on psutil."""
|
|
live = [int(p) for p in pids if p]
|
|
if not live:
|
|
return {"cpu_pct": None, "rss": None, "pids": []}
|
|
try:
|
|
import psutil # type: ignore
|
|
except ImportError:
|
|
return {"cpu_pct": None, "rss": None, "pids": live}
|
|
cpu = 0.0
|
|
rss = 0
|
|
seen: list[int] = []
|
|
for pid in live:
|
|
try:
|
|
proc = psutil.Process(pid)
|
|
cpu += proc.cpu_percent(interval=0.0)
|
|
rss += proc.memory_info().rss
|
|
seen.append(pid)
|
|
except (psutil.Error, ProcessLookupError, ValueError):
|
|
continue
|
|
return {"cpu_pct": cpu, "rss": rss, "pids": seen}
|
|
|
|
|
|
def _host_stats() -> dict:
|
|
try:
|
|
import psutil # type: ignore
|
|
except ImportError:
|
|
return {"cpu_pct": None, "mem_used": None, "mem_total": None, "mem_pct": None}
|
|
vm = psutil.virtual_memory()
|
|
return {
|
|
"cpu_pct": psutil.cpu_percent(interval=0.05),
|
|
"mem_used": vm.used,
|
|
"mem_total": vm.total,
|
|
"mem_pct": vm.percent,
|
|
}
|
|
|
|
|
|
def _api_counts(api_url: str) -> dict:
|
|
"""Pull cheap inventory counts from the backend when it is up."""
|
|
base = api_url.rstrip("/")
|
|
out = {
|
|
"online": False,
|
|
"version": None,
|
|
"ollama": None,
|
|
"memories": None,
|
|
"conversations": None,
|
|
"playbooks": None,
|
|
"models": None,
|
|
"action_tool_policy": None,
|
|
}
|
|
status = _get_json(base + "/status")
|
|
if not isinstance(status, dict):
|
|
return out
|
|
out["online"] = True
|
|
out["version"] = status.get("version")
|
|
out["ollama"] = status.get("ollama")
|
|
|
|
mem = _get_json(base + "/memory")
|
|
if isinstance(mem, list):
|
|
out["memories"] = len(mem)
|
|
elif isinstance(mem, dict) and isinstance(mem.get("memories"), list):
|
|
out["memories"] = len(mem["memories"])
|
|
|
|
conv = _get_json(base + "/conversations")
|
|
if isinstance(conv, list):
|
|
out["conversations"] = len(conv)
|
|
elif isinstance(conv, dict):
|
|
items = conv.get("conversations") or conv.get("items") or []
|
|
if isinstance(items, list):
|
|
out["conversations"] = len(items)
|
|
|
|
pbs = _get_json(base + "/playbooks")
|
|
if isinstance(pbs, list):
|
|
out["playbooks"] = len(pbs)
|
|
elif isinstance(pbs, dict) and isinstance(pbs.get("playbooks"), list):
|
|
out["playbooks"] = len(pbs["playbooks"])
|
|
|
|
models = _get_json(base + "/models")
|
|
if isinstance(models, list):
|
|
out["models"] = len(models)
|
|
elif isinstance(models, dict):
|
|
items = models.get("models") or models.get("items") or []
|
|
if isinstance(items, list):
|
|
out["models"] = len(items)
|
|
|
|
settings_payload = _get_json(base + "/settings")
|
|
if isinstance(settings_payload, dict):
|
|
out["action_tool_policy"] = settings_payload.get("action_tool_policy")
|
|
|
|
return out
|
|
|
|
|
|
def _toolchain_stats() -> list[dict]:
|
|
"""Which run_snippet languages have a host toolchain right now."""
|
|
try:
|
|
from synapse import code_run
|
|
except Exception:
|
|
return []
|
|
rows = []
|
|
for name, spec in code_run.RUN_LANGS.items():
|
|
tool = None
|
|
try:
|
|
tool = spec["tool"]()
|
|
except Exception:
|
|
tool = None
|
|
rows.append({
|
|
"lang": name,
|
|
"ready": bool(tool),
|
|
"tool": tool or None,
|
|
"summary": spec.get("summary") or name,
|
|
})
|
|
return rows
|
|
|
|
|
|
def _recent_tools(log_path: Path, limit: int = 8) -> list[str]:
|
|
"""Best-effort scrape of recent tool names from chat.log."""
|
|
if not log_path.is_file():
|
|
return []
|
|
try:
|
|
# Read the tail without pulling a multi-MB log into memory.
|
|
data = log_path.read_bytes()
|
|
if len(data) > 64_000:
|
|
data = data[-64_000:]
|
|
text = data.decode("utf-8", errors="replace")
|
|
except OSError:
|
|
return []
|
|
found: list[str] = []
|
|
for line in reversed(text.splitlines()):
|
|
# Chat tool loop yields "__status__<tool>"; logs may also name tools
|
|
# in JSON payloads. Keep the match narrow.
|
|
if "__status__" in line:
|
|
name = line.split("__status__", 1)[-1].strip().split()[0].strip(",\"'")
|
|
if name and name not in ("tools",) and name not in found:
|
|
found.append(name)
|
|
elif '"name":' in line and any(
|
|
t in line for t in ("render_preview", "run_snippet", "web_search",
|
|
"fetch_url", "remember", "get_time")
|
|
):
|
|
for t in ("run_snippet", "render_preview", "web_search", "fetch_url",
|
|
"remember", "get_time", "search_documents"):
|
|
if t in line and t not in found:
|
|
found.append(t)
|
|
if len(found) >= limit:
|
|
break
|
|
return found
|
|
|
|
|
|
def collect_snapshot() -> dict:
|
|
"""Gather one monitoring frame. Safe when services are down."""
|
|
services_payload = _service_status()
|
|
pids = [
|
|
services_payload.get("backend", {}).get("pid"),
|
|
services_payload.get("frontend", {}).get("pid"),
|
|
]
|
|
api = _api_counts(settings.api_url)
|
|
return {
|
|
"ts": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
|
"version": settings.version,
|
|
"services": services_payload,
|
|
"api": api,
|
|
"host": _host_stats(),
|
|
"procs": _pid_stats(pids),
|
|
"toolchains": _toolchain_stats(),
|
|
"recent_tools": _recent_tools(settings.logs_dir / "chat.log"),
|
|
"paths": {
|
|
"api_url": settings.api_url,
|
|
"runtime_dir": str(settings.runtime_dir),
|
|
},
|
|
}
|
|
|
|
|
|
def _pad(text: str, width: int) -> str:
|
|
# Visual width ≈ len for our ASCII/box content (no wide East-Asian chars).
|
|
if len(text) > width:
|
|
return text[: width - 1] + "…" if width > 1 else text[:width]
|
|
return text + " " * (width - len(text))
|
|
|
|
|
|
def _row(box: dict, inner: str, width: int) -> str:
|
|
return f"{box['v']} {_pad(inner, width - 4)} {box['v']}"
|
|
|
|
|
|
def _rule(box: dict, width: int, kind: str = "mid") -> str:
|
|
h = box["h"] * (width - 2)
|
|
if kind == "top":
|
|
return f"{box['tl']}{h}{box['tr']}"
|
|
if kind == "bot":
|
|
return f"{box['bl']}{h}{box['br']}"
|
|
return f"{box['l']}{h}{box['r']}"
|
|
|
|
|
|
def _svc_line(name: str, running: bool, detail: str, unicode: bool) -> str:
|
|
mark = (_FILL if unicode else _FILL_ASCII) * 3 if running else (_EMPTY if unicode else _EMPTY_ASCII) * 3
|
|
state = "UP " if running else "DOWN"
|
|
return f"{name:<10} {mark} {state} {detail}"
|
|
|
|
|
|
def render_frame(snapshot: dict, *, width: int | None = None, unicode: bool | None = None) -> str:
|
|
"""Turn a snapshot into a single multi-line ASCII panel."""
|
|
if unicode is None:
|
|
unicode = _use_unicode()
|
|
box = _BOX if unicode else _BOX_ASCII
|
|
cols = shutil.get_terminal_size((80, 24)).columns if width is None else width
|
|
width = max(56, min(100, cols))
|
|
|
|
lines: list[str] = []
|
|
lines.append(_rule(box, width, "top"))
|
|
title = f"NexusOS {snapshot.get('version', '')} monitor"
|
|
raw_ts = snapshot.get("ts") or ""
|
|
# Prefer local clock HH:MM:SS from an ISO stamp; fall back to wall clock.
|
|
stamp = ""
|
|
if "T" in raw_ts:
|
|
try:
|
|
stamp = raw_ts.split("T", 1)[1][:8]
|
|
except Exception:
|
|
stamp = ""
|
|
if not stamp:
|
|
stamp = datetime.now().strftime("%H:%M:%S")
|
|
gap = max(1, width - 4 - len(title) - len(stamp))
|
|
header = f"{title}{' ' * gap}{stamp}"
|
|
lines.append(_row(box, header, width))
|
|
lines.append(_rule(box, width, "mid"))
|
|
|
|
lines.append(_row(box, "SERVICES", width))
|
|
svcs = snapshot.get("services") or {}
|
|
for key, label in (("backend", "backend"), ("frontend", "frontend")):
|
|
info = svcs.get(key) or {}
|
|
running = bool(info.get("running"))
|
|
pid = info.get("pid")
|
|
url = info.get("url") or ""
|
|
detail = url
|
|
if pid:
|
|
detail = f"pid {pid} {url}"
|
|
lines.append(_row(box, _svc_line(label, running, detail, unicode), width))
|
|
provider = svcs.get("provider") or _provider_payload()
|
|
pref = f"{provider.get('provider', '?')} @ {provider.get('url', '')}"
|
|
lines.append(_row(box, _svc_line("provider", bool(provider.get("reachable")), pref, unicode), width))
|
|
|
|
lines.append(_rule(box, width, "mid"))
|
|
lines.append(_row(box, "RESOURCES", width))
|
|
host = snapshot.get("host") or {}
|
|
procs = snapshot.get("procs") or {}
|
|
cpu = host.get("cpu_pct")
|
|
if cpu is not None:
|
|
lines.append(_row(
|
|
box,
|
|
f"host CPU [{_bar(cpu / 100.0, 22, unicode)}] {cpu:5.1f}%",
|
|
width,
|
|
))
|
|
else:
|
|
lines.append(_row(box, "host CPU (install psutil for live bars)", width))
|
|
mem_pct = host.get("mem_pct")
|
|
if mem_pct is not None:
|
|
lines.append(_row(
|
|
box,
|
|
f"host MEM [{_bar(mem_pct / 100.0, 22, unicode)}] "
|
|
f"{_fmt_bytes(host.get('mem_used'))} / {_fmt_bytes(host.get('mem_total'))}",
|
|
width,
|
|
))
|
|
proc_cpu = procs.get("cpu_pct")
|
|
proc_rss = procs.get("rss")
|
|
if proc_cpu is not None or proc_rss is not None:
|
|
lines.append(_row(
|
|
box,
|
|
f"nexus cpu={proc_cpu if proc_cpu is not None else '—':>5} "
|
|
f"rss={_fmt_bytes(proc_rss)} pids={','.join(str(p) for p in (procs.get('pids') or [])) or '—'}",
|
|
width,
|
|
))
|
|
|
|
lines.append(_rule(box, width, "mid"))
|
|
lines.append(_row(box, "DATA / TOOLS", width))
|
|
api = snapshot.get("api") or {}
|
|
if api.get("online"):
|
|
policy = api.get("action_tool_policy") or "—"
|
|
lines.append(_row(
|
|
box,
|
|
f"api UP v{api.get('version') or '?'} ollama={api.get('ollama') or '—'} "
|
|
f"tools={policy}",
|
|
width,
|
|
))
|
|
lines.append(_row(
|
|
box,
|
|
f"memories={_n(api.get('memories'))} "
|
|
f"chats={_n(api.get('conversations'))} "
|
|
f"playbooks={_n(api.get('playbooks'))} "
|
|
f"models={_n(api.get('models'))}",
|
|
width,
|
|
))
|
|
else:
|
|
lines.append(_row(box, "api DOWN — start with: nexus start", width))
|
|
|
|
recent = snapshot.get("recent_tools") or []
|
|
lines.append(_row(
|
|
box,
|
|
"recent " + (", ".join(recent) if recent else "(none in chat.log)"),
|
|
width,
|
|
))
|
|
|
|
lines.append(_rule(box, width, "mid"))
|
|
lines.append(_row(box, "RUN TOOLCHAINS (run_snippet)", width))
|
|
chains = snapshot.get("toolchains") or []
|
|
if not chains:
|
|
lines.append(_row(box, "(code_run unavailable)", width))
|
|
else:
|
|
# Pack ready/missing into one or two compact lines.
|
|
ready = [c["lang"] for c in chains if c.get("ready")]
|
|
missing = [c["lang"] for c in chains if not c.get("ready")]
|
|
lines.append(_row(
|
|
box,
|
|
f"ready {', '.join(ready) if ready else '—'}",
|
|
width,
|
|
))
|
|
lines.append(_row(
|
|
box,
|
|
f"missing {', '.join(missing) if missing else '—'}",
|
|
width,
|
|
))
|
|
|
|
lines.append(_rule(box, width, "bot"))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _n(value) -> str:
|
|
return "—" if value is None else str(value)
|
|
|
|
|
|
def run_monitor(*, interval: float = 1.5, once: bool = False, json_output: bool = False) -> int:
|
|
"""Print one frame, or refresh in place until interrupted."""
|
|
clear = "\033[H\033[J"
|
|
first = True
|
|
while True:
|
|
snap = collect_snapshot()
|
|
if json_output:
|
|
print(json.dumps(snap, indent=2, sort_keys=True))
|
|
else:
|
|
frame = render_frame(snap)
|
|
if once or not first:
|
|
# Replacing the screen keeps the panel stable; first frame of a
|
|
# live session also clears so leftover shell output doesn't mix.
|
|
if not once:
|
|
print(clear + frame, end="", flush=True)
|
|
else:
|
|
print(frame)
|
|
else:
|
|
print(clear + frame, end="", flush=True)
|
|
first = False
|
|
if once:
|
|
return 0
|
|
try:
|
|
time.sleep(max(0.3, float(interval)))
|
|
except KeyboardInterrupt:
|
|
print()
|
|
return 130
|