Author SHA1 Message Date
Athena 214ce07d1f test(macos): cover installer platform guards 2026-08-26 08:19:45 -05:00
Athena KaminskyandClaude Opus 5 93d78c0ad3 feat(macos): native install path via Homebrew
install-macos.sh mirrors install.sh's split: every portable step - git pull,
venv, pip with the right overlay, npm build - stays in bin/sync.py, shared
with Linux and Windows. The script only does what sync.py cannot do for
itself on a bare Mac, which is install the Homebrew packages needed before a
Python exists to run sync.py with.

Two stages had to learn about darwin. ensure_exec_bits() keyed off
`os.name == "nt"`, which is false on macOS, so it ran the Linux path; and
requirements() had no darwin branch. linux_stage() now no-ops there, which is
what makes skipping the Ollama fetch correct rather than an omission:
bin/fetch-ollama.sh only ships a Linux x86-64 binary, and _ollama_bin() in
synapse/ollama_manager.py already prefers the bundled copy and falls back to
whatever `ollama` is on PATH. On macOS that is the brewed one, with Metal
acceleration and no flags needed.

The XFCE desktop branding is Linux-only and was already gated off macOS the
same way, so there is nothing to install for it here.

install-macos.sh joins the shell-parse list in bin/check.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 08:18:47 -05:00
23 changed files with 167 additions and 3513 deletions
-1
View File
@@ -13,7 +13,6 @@ synapse/memory/memory.db
synapse/memory/memory.db-wal synapse/memory/memory.db-wal
synapse/memory/memory.db-shm synapse/memory/memory.db-shm
assets/gitnexus-logo.svg assets/gitnexus-logo.svg
/data/curry.db
*.db-wal *.db-wal
*.db-shm *.db-shm
.DS_Store .DS_Store
+25 -23
View File
@@ -37,6 +37,17 @@ desktop shortcut runs and still works directly.
``` ```
This activates the `Promethean` venv and starts the Synapse backend on port 8000 (which also serves the built UI). It additionally starts a **Vite dev server** for frontend hot-reload — a Linux-dev convenience, unlike the single-process Windows/production path where the backend serves `dist/` alone. It does **not** start Ollama. This activates the `Promethean` venv and starts the Synapse backend on port 8000 (which also serves the built UI). It additionally starts a **Vite dev server** for frontend hot-reload — a Linux-dev convenience, unlike the single-process Windows/production path where the backend serves `dist/` alone. It does **not** start Ollama.
**macOS (community-supported):**
```bash
./install-macos.sh # one-time: Homebrew packages + venv + web build, via bin/sync.py
./launch_nexus.sh # same script as Linux - it's plain bash, no Linux-only calls
```
No bundled Ollama binary (Linux x86-64 only) and no XFCE desktop branding — both
already no-op on macOS via `bin/sync.py`'s `linux_stage()`. Ollama is instead the
Homebrew-installed native binary, picked up automatically because
`OllamaManager` falls back to `ollama` on PATH when the bundled binary is
absent; that gets full Metal GPU acceleration with no extra config.
**Individual services via CLI:** **Individual services via CLI:**
```bash ```bash
# From nexus-core/ with Promethean venv active: # From nexus-core/ with Promethean venv active:
@@ -50,28 +61,25 @@ uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload
cd interface/web && npm run dev cd interface/web && npm run dev
``` ```
**Management CLI** (`nexus` / `ncp`) — start/stop services with PID tracking, plus **Management CLI** (`ncp`) — start/stop services with PID tracking, plus terminal
terminal access to the same features as the web UI (REST API on `:8000`): access to the same features as the web UI (all via the REST API on `:8000`):
```bash ```bash
./management/nexus-cli.sh start # starts backend + frontend ./management/nexus-cli.sh start # starts backend + frontend
./management/nexus-cli.sh stop ./management/nexus-cli.sh stop
./management/nexus-cli.sh start --backend|-b / --frontend|-f / --memory|-m ./management/nexus-cli.sh start --backend|-b / --frontend|-f / --memory|-m
# Interactive TUI (Hermes/OpenClaw-style; needs pip install 'nexusos-ai[tui]'): # Feature commands (dispatch to nexusos_cli/nexus_api.py — httpx, no TUI):
nexus # bare command opens the Textual chat TUI ncp chat "<message>" # stream a reply (POST /chat/stream)
nexus tui # same, explicit ncp memory list|add <text>|rm <id>
ncp playbook list|show <id> # first playbook (*) is the active system prompt
# Feature one-shots (dispatch to nexusos_cli/nexus_api.py — httpx): ncp history [query] # recent conversations
nexus chat send "<message>" # stream a reply (POST /chat/stream)
nexus memory list|add <text>|rm <id>
nexus playbook list|show <id> # first playbook (*) is the active system prompt
nexus history [query] # recent conversations
nexus monitor # ASCII status dashboard (no prompt)
``` ```
The interactive TUI lives in `nexusos_cli/tui_app.py` (Textual, optional extra). The old curses TUIs (`nexus-chat.py`, `nexus-playbook.py`) were removed in favor of
One-shot subcommands and `nexus monitor` remain for scripts. The CLI package is these API-backed subcommands. The CLI covers chat, memory, playbooks, and history;
`nexusos_cli/` (what the wheel ships); `management/` keeps desktop-only pieces — the web UI and control panel expose the remaining management features.
shell wrappers, Tk control panel, XFCE panel wiring. The CLI itself lives in `nexusos_cli/` (that is what the wheel ships and what
`nexus`/`ncp`/`nexusos` dispatch to); `management/` keeps the desktop-only
pieces — the shell wrappers, the Tk control panel, and the XFCE panel wiring.
`management/controlpanel.py` (tkinter GUI, wired into the XFCE panel via `management/controlpanel.py` (tkinter GUI, wired into the XFCE panel via
`bin/panel/nexus-popup.py`) stays. `bin/panel/nexus-popup.py`) stays.
@@ -95,7 +103,7 @@ cd interface/web && npm run build
## Architecture ## Architecture
### Python venv ### Python venv
All Python code runs inside `Promethean/` (a local venv). Always activate it before running backend commands: `source Promethean/bin/activate`. Dependencies are layered: `requirements-base.txt` holds the GPU-agnostic core (nothing in it needs a GPU or imports torch), and a thin overlay per platform sets the right PyTorch package index — `requirements-amd.txt` (ROCm), `requirements-nvidia.txt` (CUDA, generated by `bin/gen-nvidia-reqs.py`), or `requirements-windows.txt` (CPU-only, standalone). `bin/sync.py` (`requirements()`) selects NVIDIA, AMD, or CPU/Windows requirements from the host and installs that alone by default — fast, no multi-GB downloads. All Python code runs inside `Promethean/` (a local venv). Always activate it before running backend commands: `source Promethean/bin/activate`. Dependencies are layered: `requirements-base.txt` holds the GPU-agnostic core (nothing in it needs a GPU or imports torch), and a thin overlay per platform sets the right PyTorch package index — `requirements-amd.txt` (ROCm), `requirements-nvidia.txt` (CUDA, generated by `bin/gen-nvidia-reqs.py`), or `requirements-windows.txt` (CPU-only, standalone). macOS uses `requirements-base.txt` without an overlay because Ollama handles inference outside the venv. `bin/sync.py` (`requirements()`) selects the appropriate requirements for the host and installs that alone by default — fast, no multi-GB downloads.
`requirements-ml.txt` is a separate, **opt-in** overlay for local ML inference (transformers/accelerate/bitsandbytes + torch/torchaudio/torchvision) — nothing in `synapse/` imports any of it; Ollama does all inference over HTTP. Only pull it in for local model work outside Ollama: `pip install -r requirements-amd.txt -r requirements-ml.txt` (or `-nvidia`, or alone for CPU-only torch). Not installed by `bin/sync.py`/the installers. `requirements-ml.txt` is a separate, **opt-in** overlay for local ML inference (transformers/accelerate/bitsandbytes + torch/torchaudio/torchvision) — nothing in `synapse/` imports any of it; Ollama does all inference over HTTP. Only pull it in for local model work outside Ollama: `pip install -r requirements-amd.txt -r requirements-ml.txt` (or `-nvidia`, or alone for CPU-only torch). Not installed by `bin/sync.py`/the installers.
@@ -126,12 +134,6 @@ React 19 + Vite. No routing library — `App.jsx` manages page state in a single
### Persistent Storage ### Persistent Storage
Most data lands in `synapse/memory/memory.db` (SQLite, WAL mode). Tables: memory facts, conversations, messages, app settings. `synapse/memory/store.py` (`PersistentMemoryStore`) owns the schema and all queries. Playbooks are the exception — they live as YAML files in `data/playbooks/` (see Playbook System). `nexus_config.py` defines all paths; it also ensures all required directories exist on import. Most data lands in `synapse/memory/memory.db` (SQLite, WAL mode). Tables: memory facts, conversations, messages, app settings. `synapse/memory/store.py` (`PersistentMemoryStore`) owns the schema and all queries. Playbooks are the exception — they live as YAML files in `data/playbooks/` (see Playbook System). `nexus_config.py` defines all paths; it also ensures all required directories exist on import.
### Curry (`synapse/curry_core.py` + `synapse/curry_store.py`)
`curry_core.py` is vendored from [Athena-Pro/Curry](https://github.com/Athena-Pro/Curry), with two deliberate deviations from upstream documented in the file's own docstring (a sandbox-escape fix and a `check_same_thread=False` connection fix) — an immutable, versioned fact store (constants, functions, model registrations, inference provenance) backed by its own SQLite file (`CURRY_DB` in `nexus_config.py`, separate from `memory.db`). `curry_store.py` opens it into a module-level singleton (`curry_db`) at import time — the same pattern as `memory.store.store` / `playbooks.store.playbook_store` — so it's preloaded and callable from anywhere in the backend without extra setup. It ships inside the wheel (`bin/check.sh`'s packaging gate asserts this) and has no external dependencies of its own. Ten `curry_*` tools in `tools.py` expose it to chat (`curry_declare_constant`, `curry_call_function`, etc.); the five that write or execute are ACTION tools in `ALWAYS_ASK_ACTION_TOOLS`, same approval floor as `edit_source`. Re-sync `curry_core.py` from upstream by hand, not by script.
### Direct tool invocation (`synapse/slash_commands.py`)
A chat message that's nothing but `/tool_name(arg=val, ...)` (Python-call-shaped, arguments parsed via `ast.literal_eval` only — no names, no calls, no attribute access) dispatches straight through `tools.dispatch()`, skipping model selection, context assembly, and the ask-policy approval round-trip. A human typing it is the approval. Wired into `chat_stream_endpoint` as an early short-circuit; the TUI's `_handle_slash` falls through to the backend for anything shaped like a tool call that isn't one of its own local meta-commands (`/help`, `/model`, `/new`).
### Logs & Runtime State ### Logs & Runtime State
- `runtime/backend.log`, `runtime/frontend.log`, `runtime/memory.log` — service stdout - `runtime/backend.log`, `runtime/frontend.log`, `runtime/memory.log` — service stdout
- `runtime/logs/ollama.log`, `runtime/logs/chat.log` - `runtime/logs/ollama.log`, `runtime/logs/chat.log`
+35 -4
View File
@@ -134,7 +134,8 @@ ncp web
Python deps are layered: `requirements-base.txt` (GPU-agnostic core) plus one Python deps are layered: `requirements-base.txt` (GPU-agnostic core) plus one
GPU overlay — `requirements-amd.txt` (ROCm) or `requirements-nvidia.txt` (CUDA). GPU overlay — `requirements-amd.txt` (ROCm) or `requirements-nvidia.txt` (CUDA).
`requirements-windows.txt` is the standalone CPU-only runtime (no base overlay). `requirements-windows.txt` is the standalone CPU-only runtime (no base overlay).
`bin/sync.py` picks the right one for the host. macOS uses `requirements-base.txt` directly, no overlay — see the macOS section
below. `bin/sync.py` picks the right one for the host.
`./install.sh` is also the update path — re-run it any time to pull the latest `./install.sh` is also the update path — re-run it any time to pull the latest
and rebuild. `--check` dry-runs it; `--no-desktop` skips the XFCE panel/theme and rebuild. `--check` dry-runs it; `--no-desktop` skips the XFCE panel/theme
@@ -172,10 +173,38 @@ The app opens at `:8000`; click **Start AI** to launch Ollama. The installer
uses `requirements-windows.txt` (CPU-only, pure-Python — no ML stack, since Ollama uses `requirements-windows.txt` (CPU-only, pure-Python — no ML stack, since Ollama
does all inference over HTTP). does all inference over HTTP).
### macOS
Community-supported — no bundled Ollama binary or XFCE desktop branding (that
stage is Linux/XFCE-only and already skips itself here), but the
backend/frontend/Ollama stack itself runs natively, no VM or container needed.
```bash
# 1. Install Homebrew first if you don't have it: https://brew.sh
# 2. Build everything: Homebrew packages (Python, Node, git, Ollama), venv,
# web UI, memory DB.
./install-macos.sh
# 3. Launch (memory :8001, backend :8000 — backend also serves the built UI)
./launch_nexus.sh
```
Ollama here is the Homebrew-installed native binary, not the Linux-only bundled
one — `OllamaManager` already falls back to `ollama` on PATH when
`ollama/bin/ollama` doesn't exist, so **Start AI** in the sidebar (or `ollama
serve` in a terminal) uses it with full Metal GPU acceleration automatically,
no configuration needed.
`./install-macos.sh` is also the update path, same idea as Linux — re-run it
any time to pull the latest and rebuild; `--check` dry-runs it. It's a thin
wrapper over `bin/sync.py restore`, the same code Linux and `ncp restore`
(any platform) run.
### Individual services ### Individual services
```bash ```bash
# Linux # Linux / macOS
source Promethean/bin/activate source Promethean/bin/activate
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too) uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too)
@@ -199,7 +228,7 @@ Nexus's dependencies out of the system Python. To add a package, activate it
and `pip install` as usual: and `pip install` as usual:
```bash ```bash
# Linux # Linux / macOS
source Promethean/bin/activate source Promethean/bin/activate
pip install <package> pip install <package>
``` ```
@@ -274,7 +303,9 @@ are the exception (YAML files in `data/playbooks/`). All paths are defined in
- **Filesystem paths** — `synapse/nexus_config.py` - **Filesystem paths** — `synapse/nexus_config.py`
- **Frontend API base URL** — `interface/web/src/config.js` - **Frontend API base URL** — `interface/web/src/config.js`
- **Python deps** — `requirements-base.txt` + amd/nvidia GPU overlay; - **Python deps** — `requirements-base.txt` + amd/nvidia GPU overlay;
`requirements-windows.txt` = standalone CPU runtime `requirements-windows.txt` = standalone CPU runtime; macOS uses
`requirements-base.txt` with no overlay (Ollama, not this venv, does
inference — natively, with Metal)
## Issues and feature requests ## Issues and feature requests
+1 -5
View File
@@ -40,7 +40,7 @@ else
fi fi
echo "== shell parse ==" echo "== shell parse =="
for f in scripts/install-termux.sh launch_nexus.sh management/nexus-cli.sh; do for f in scripts/install-termux.sh install-macos.sh launch_nexus.sh management/nexus-cli.sh; do
[ -f "$f" ] && { bash -n "$f" || fail=1; } [ -f "$f" ] && { bash -n "$f" || fail=1; }
done done
@@ -63,10 +63,6 @@ if not any(n.startswith("synapse/_resources/web/") for n in names):
sys.exit("wheel is missing the compiled web UI (cd interface/web && npm run build)") sys.exit("wheel is missing the compiled web UI (cd interface/web && npm run build)")
if not any(n.startswith("synapse/_resources/playbooks/") for n in names): if not any(n.startswith("synapse/_resources/playbooks/") for n in names):
sys.exit("wheel is missing the seed playbooks") sys.exit("wheel is missing the seed playbooks")
if "synapse/curry_core.py" not in names or "synapse/curry_store.py" not in names:
sys.exit("wheel is missing vendored Curry (synapse/curry_core.py / curry_store.py)")
if "synapse/slash_commands.py" not in names:
sys.exit("wheel is missing synapse/slash_commands.py")
print(f"wheel OK: {len(names)} files") print(f"wheel OK: {len(names)} files")
PY PY
else else
+12 -3
View File
@@ -66,9 +66,11 @@ def ensure_exec_bits() -> None:
def linux_stage(script: str, *args) -> None: def linux_stage(script: str, *args) -> None:
"""Run one of the Linux-only bash stages. A no-op on Windows, where apt, """Run one of the Linux-only bash stages. A no-op on Windows and macOS,
xfconf, plank and the rest have nothing to act on.""" where apt, xfconf, plank and the rest have nothing to act on. os.name is
if os.name == "nt": 'posix' on both Linux and macOS, so the Windows-only os.name check alone
doesn't exclude macOS - needs the explicit darwin check too."""
if os.name == "nt" or sys.platform == "darwin":
return return
path = ROOT / "bin" / script path = ROOT / "bin" / script
bash = shutil.which("bash") bash = shutil.which("bash")
@@ -109,6 +111,13 @@ def requirements() -> str:
"""Pick the PyTorch overlay for this host.""" """Pick the PyTorch overlay for this host."""
if os.name == "nt": if os.name == "nt":
return "requirements-windows.txt" # CPU / pure-Python, right for native Windows return "requirements-windows.txt" # CPU / pure-Python, right for native Windows
if sys.platform == "darwin":
# No ROCm/CUDA overlay applies here, and none is needed: Ollama does
# all inference over HTTP (see requirements-ml.txt), and on macOS
# that's a natively-installed, Metal-accelerated Ollama binary
# (OllamaManager falls back to it on PATH - see synapse/ollama_manager.py),
# entirely outside this venv.
return "requirements-base.txt"
if shutil.which("nvidia-smi"): if shutil.which("nvidia-smi"):
return "requirements-nvidia.txt" return "requirements-nvidia.txt"
lspci = shutil.which("lspci") lspci = shutil.which("lspci")
-3
View File
@@ -37,14 +37,11 @@ and seed playbooks. Extras keep platform-sensitive dependencies optional:
- `desktop`: desktop process support and Windows pywebview - `desktop`: desktop process support and Windows pywebview
- `search`: DuckDuckGo web search for chat - `search`: DuckDuckGo web search for chat
- `mail`: IMAP mail reading - `mail`: IMAP mail reading
- `tui`: Textual interactive chat UI (`nexus` with no subcommand)
- `all`: every optional capability at once - `all`: every optional capability at once
## Common commands ## Common commands
```text ```text
nexus Interactive chat TUI (needs nexusos-ai[tui])
nexus tui Same as bare nexus
nexus init Create writable state and seed playbooks nexus init Create writable state and seed playbooks
nexus doctor [--fix] [--json] Diagnose the install and provider nexus doctor [--fix] [--json] Diagnose the install and provider
nexus paths [--json] Show package, state, and asset locations nexus paths [--json] Show package, state, and asset locations
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# NexusOS installer, macOS. One painless command:
#
# git clone <repo> nexus-core && cd nexus-core && ./install-macos.sh
#
# Mirrors install.sh's philosophy: every portable step - git pull, venv, pip
# with the right overlay, npm build - lives in bin/sync.py, shared with Linux
# and Windows. This script only does what sync.py can't do for itself on a
# bare Mac: install the Homebrew packages needed before Python even exists to
# run sync.py with. Re-run any time to update; --check dry-runs it.
#
# Ollama itself is *not* fetched here - bin/fetch-ollama.sh only ships a Linux
# x86-64 binary, and linux_stage() in bin/sync.py already no-ops on macOS, so
# that stage is skipped entirely. The Homebrew `ollama` installed below is
# picked up automatically instead: synapse/ollama_manager.py prefers the
# bundled Linux binary and falls back to whatever `ollama` it finds on PATH,
# which on macOS is this one - with full Metal GPU acceleration, no flags
# needed. The XFCE desktop branding (theme/panel/splash) is Linux-only and
# already gated off macOS the same way; nothing to install for it here.
set -euo pipefail
cd "$(dirname "$0")"
if ! command -v brew >/dev/null; then
echo "Homebrew is required (it installs Python/Node/git/Ollama)." >&2
echo "Install it, then re-run this script: https://brew.sh" >&2
exit 1
fi
echo "Installing/checking system packages (python@3.12, node, git, ollama)..."
brew install python@3.12 node git ollama
py="$(brew --prefix python@3.12)/bin/python3.12"
if [ ! -x "$py" ]; then
echo "python3.12 not found at $py after brew install - check 'brew doctor'." >&2
exit 1
fi
# Prefer the venv interpreter once it exists, same as install.sh; the brewed
# interpreter above is only the bootstrap case on a fresh clone. sync.py is
# stdlib-only either way.
[ -x "Promethean/bin/python" ] && py="Promethean/bin/python"
exec "$py" bin/sync.py restore "$@"
+3 -36
View File
@@ -398,24 +398,6 @@ def cmd_monitor(args) -> int:
) )
def cmd_tui(args) -> int:
"""Interactive Hermes/OpenClaw-style chat TUI (requires nexusos-ai[tui])."""
if not sys.stdin.isatty() or not sys.stdout.isatty():
print(
"The TUI needs a terminal. Use: nexus chat send \"\"\n"
"Or run `nexus` in an interactive shell.",
file=sys.stderr,
)
return 2
try:
from .tui_app import run_tui
except ImportError as exc:
print(str(exc), file=sys.stderr)
return 2
api = getattr(args, "api_url", None) or settings.api_url
return run_tui(api_url=api)
def _target_flag(target: str | None): def _target_flag(target: str | None):
return { return {
"memory": "--memory", "memory": "--memory",
@@ -740,20 +722,10 @@ def _port(value: str) -> int:
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(prog="nexus", description="NexusOS local AI runtime and API client")
prog="nexus",
description=(
"NexusOS local AI runtime and API client. "
"With no subcommand, opens the interactive TUI (needs nexusos-ai[tui])."
),
)
parser.add_argument("--version", action="version", version=f"NexusOS {settings.version}") parser.add_argument("--version", action="version", version=f"NexusOS {settings.version}")
parser.add_argument("--api-url", help="override the NexusOS backend URL for this command") parser.add_argument("--api-url", help="override the NexusOS backend URL for this command")
# Bare `nexus` → TUI. Subcommands remain for scripts and one-shots. sub = parser.add_subparsers(dest="command", required=True)
sub = parser.add_subparsers(dest="command", required=False)
p = sub.add_parser("tui", help="interactive chat TUI (default when no subcommand)")
p.set_defaults(fn=cmd_tui)
p = sub.add_parser("init", help="create user state and seed default playbooks"); _add_json(p); p.set_defaults(fn=cmd_init) p = sub.add_parser("init", help="create user state and seed default playbooks"); _add_json(p); p.set_defaults(fn=cmd_init)
p = sub.add_parser("paths", help="show resolved package and writable paths"); _add_json(p); p.set_defaults(fn=cmd_paths) p = sub.add_parser("paths", help="show resolved package and writable paths"); _add_json(p); p.set_defaults(fn=cmd_paths)
@@ -855,12 +827,7 @@ def _normalize_legacy_argv(argv) -> list[str]:
def main(argv=None) -> int: def main(argv=None) -> int:
parser = build_parser() parser = build_parser()
argv = _normalize_legacy_argv(argv) args = parser.parse_args(_normalize_legacy_argv(argv))
args = parser.parse_args(argv)
if not getattr(args, "command", None):
# Bare `nexus` / `ncp` / `nexusos` → interactive TUI.
args.command = "tui"
args.fn = cmd_tui
if args.command == "config": if args.command == "config":
if args.action in ("get", "unset") and not args.key: if args.action in ("get", "unset") and not args.key:
parser.error(f"config {args.action} requires KEY") parser.error(f"config {args.action} requires KEY")
-519
View File
@@ -1,519 +0,0 @@
"""Hermes/OpenClaw-style interactive TUI for NexusOS.
Optional: needs the ``tui`` extra (Textual). Launched by a bare ``nexus`` when
stdin/stdout are a TTY. Classic one-shots (``nexus chat send``, ``nexus monitor``,
``nexus status``, ) stay on the argparse tree.
"""
from __future__ import annotations
import asyncio
import json
import threading
import uuid
from typing import Any
import httpx
from synapse.nexus_config import settings
from synapse.slash_commands import parse_slash_command
from .monitor import collect_snapshot
# Between SSE chunks a silent backend must not pin the UI forever. Connect stays
# short; the overall stream may run minutes.
_STREAM_TIMEOUT = httpx.Timeout(None, connect=5.0, read=120.0, write=30.0, pool=5.0)
_APPROVAL_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
def _require_textual():
try:
from textual.app import App
from textual.binding import Binding
from textual.widgets import Footer, Header, Input, RichLog, Static
except ImportError as e: # pragma: no cover - optional extra
raise ImportError(
"The interactive TUI needs the 'tui' extra — "
"pip install 'nexusos-ai[tui]' (or: pip install textual)."
) from e
return App, Binding, Footer, Header, Input, RichLog, Static
def _escape(text: str) -> str:
"""Make model/user text safe for Rich markup widgets.
Rich's own escape is the only version that round-trips. Escaping every
backslash by hand looks equivalent but is not: Rich un-escapes ``\\[`` and
never collapses ``\\\\``, so doubling them puts the doubles on screen -
every Windows path and regex escape in a reply renders wrong.
Imported inside the function so the module still loads without the ``tui``
extra. Rich is not declared in pyproject: Textual depends on it, so it is
present whenever the TUI can run at all, and tests/test_packaging_deps.py
lists it in TRANSITIVE for that reason.
"""
from rich.markup import escape
return escape(text)
def format_user_line(message: str) -> str:
return f"[bold green]you>[/] {_escape(message)}"
def format_assistant_line(text: str) -> str:
return f"[bold blue]nexus>[/] {_escape(text)}"
def _deny_tool_request(
*,
api_url: str,
conversation_id: str,
payload: str,
client_factory=httpx.Client,
) -> list[str]:
"""Immediately deny a TUI action request and let the stream resume.
The web client presents an approval dialog, but the TUI does not yet have
that interaction. Denying with the stream's capability token preserves the
``ask`` safety boundary without leaving the backend waiting for five minutes.
"""
request = json.loads(payload)
token = request.get("token") or ""
actions = request.get("actions") or []
names = [
action.get("name", "")
for action in actions
if isinstance(action, dict) and action.get("name")
]
if not token or not names:
raise ValueError("invalid tool approval request")
body = {
"conversation_id": conversation_id,
"token": token,
"decisions": {name: False for name in names},
}
with client_factory(base_url=api_url, timeout=_APPROVAL_TIMEOUT) as client:
response = client.post("/chat/approve", json=body)
response.raise_for_status()
return names
def _status_line(snap: dict | None = None) -> str:
"""Format a snapshot. Pass ``snap`` — do not omit it on the UI thread."""
if snap is None:
snap = collect_snapshot()
svcs = snap.get("services") or {}
api = snap.get("api") or {}
host = snap.get("host") or {}
parts = [f"NexusOS {snap.get('version', '')}"]
for key in ("backend", "memory", "provider"):
info = svcs.get(key) or {}
if key == "provider":
up = bool(info.get("reachable"))
else:
up = bool(info.get("running"))
parts.append(f"{key}={'UP' if up else 'DOWN'}")
if api.get("online"):
parts.append(f"tools={api.get('action_tool_policy') or ''}")
cpu = host.get("cpu_pct")
if cpu is not None:
parts.append(f"cpu={cpu:.0f}%")
chains = snap.get("toolchains") or []
ready = [c["lang"] for c in chains if c.get("ready")]
if ready:
parts.append("run=" + ",".join(ready))
return " · ".join(parts)
def _compact_status(snap: dict | None = None) -> str:
"""One-line strip for the bar under the chat log."""
if snap is None:
snap = collect_snapshot()
host = snap.get("host") or {}
api = snap.get("api") or {}
recent = snap.get("recent_tools") or []
cpu = host.get("cpu_pct")
mem = host.get("mem_pct")
bits = []
if cpu is not None:
bits.append(f"cpu {cpu:.0f}%")
if mem is not None:
bits.append(f"mem {mem:.0f}%")
if api.get("online"):
bits.append(
f"memories={api.get('memories') if api.get('memories') is not None else ''} "
f"chats={api.get('conversations') if api.get('conversations') is not None else ''}"
)
else:
bits.append("api DOWN — nexus start")
if recent:
bits.append("recent " + ", ".join(recent[:4]))
return "".join(bits)
class NexusTUI:
"""Factory so Textual imports stay lazy until run()."""
@staticmethod
def build_app(*, api_url: str | None = None):
App, Binding, Footer, Header, Input, RichLog, Static = _require_textual()
base = (api_url or settings.api_url).rstrip("/")
class AppImpl(App):
CSS = """
Screen { layout: vertical; }
#status {
height: 1;
dock: top;
background: $boost;
color: $text;
padding: 0 1;
}
#strip {
height: 1;
background: $surface;
color: $text-muted;
padding: 0 1;
}
#log {
height: 1fr;
border: tall $accent;
padding: 0 1;
}
#live {
height: auto;
max-height: 8;
padding: 0 1;
color: $text;
}
#prompt { dock: bottom; }
"""
BINDINGS = [
Binding("ctrl+c", "interrupt", "Interrupt", priority=True),
Binding("ctrl+d", "quit", "Quit", priority=True),
]
def __init__(self):
super().__init__()
self.api_url = base
self.conversation_id: str | None = None
self.history: list[dict] = []
self._model: str | None = None
self._busy = False
self._stop_stream = threading.Event()
self._stream_cancel: (
tuple[asyncio.AbstractEventLoop, asyncio.Task] | None
) = None
self._status_lock = threading.Lock()
self._status_pending = False
def compose(self):
# Placeholders only — never collect_snapshot() on the UI thread.
yield Header(show_clock=True)
yield Static("NexusOS …", id="status")
yield RichLog(id="log", highlight=True, markup=True, wrap=True)
yield Static("", id="live")
yield Static("collecting status…", id="strip")
yield Input(
placeholder="Message Nexus… (/help for commands)",
id="prompt",
)
yield Footer()
def on_mount(self) -> None:
self.title = "NexusOS"
self.sub_title = self.api_url
log = self.query_one("#log", RichLog)
log.write("[bold]NexusOS[/] interactive TUI")
log.write(
"Type a message and Enter. "
"Slash: /help /status /new /model /quit"
)
log.write(f"API: {_escape(self.api_url)}")
log.write("")
self._schedule_status_refresh()
self.set_interval(2.0, self._schedule_status_refresh)
self.query_one("#prompt", Input).focus()
def _schedule_status_refresh(self) -> None:
"""Kick a worker; never call collect_snapshot on the event loop."""
with self._status_lock:
if self._status_pending:
return
self._status_pending = True
def worker():
try:
snap = collect_snapshot()
self._call_ui(self._apply_status, snap)
except Exception:
pass
finally:
with self._status_lock:
self._status_pending = False
threading.Thread(target=worker, daemon=True).start()
def _apply_status(self, snap: dict) -> None:
self.query_one("#status", Static).update(_status_line(snap))
self.query_one("#strip", Static).update(_compact_status(snap))
def _call_ui(self, callback, *args) -> None:
"""call_from_thread, but never after quit (avoids CancelledError
traceback garbling the restored shell)."""
if not self.is_running:
return
try:
self.call_from_thread(callback, *args)
except BaseException:
# CancelledError is BaseException; also ignore post-exit races.
pass
def _show_error(self, message: str) -> None:
"""Write a stream error to the persistent transcript."""
self.query_one("#log", RichLog).write(message)
def _cancel_stream(self) -> None:
"""Cancel the task that owns the socket read.
Closing a synchronous httpx client from the UI thread does not
reliably unblock its worker-thread read on macOS. Async task
cancellation is delivered to the pending read itself.
"""
self._stop_stream.set()
cancel = self._stream_cancel
if cancel is not None:
loop, task = cancel
loop.call_soon_threadsafe(task.cancel)
def action_quit(self) -> None:
self._cancel_stream()
self.exit()
def action_interrupt(self) -> None:
if self._busy:
self._cancel_stream()
self.query_one("#log", RichLog).write(
"[yellow]▸ interrupt requested[/]"
)
else:
self.exit()
def on_input_submitted(self, event: Input.Submitted) -> None:
text = (event.value or "").strip()
event.input.value = ""
if not text:
return
if text.startswith("/"):
self._handle_slash(text)
return
if self._busy:
self.query_one("#log", RichLog).write(
"[yellow]Still streaming — wait or Ctrl+C to interrupt[/]"
)
return
self._start_chat(text)
def _handle_slash(self, text: str) -> None:
log = self.query_one("#log", RichLog)
cmd, _, rest = text[1:].partition(" ")
cmd = cmd.lower().strip()
rest = rest.strip()
if cmd in ("q", "quit", "exit"):
self.exit()
elif cmd in ("h", "help"):
log.write(
"[bold]/help[/] this list\n"
"[bold]/status[/] refresh service strip\n"
"[bold]/new[/] fresh conversation\n"
"[bold]/model[/] \\[name] pin model for next turns\n"
"[bold]/quit[/] leave the TUI\n"
"One-shot: [dim]nexus chat send \"\"[/]"
)
elif cmd == "status":
self._schedule_status_refresh()
log.write("[dim]refreshing status…[/]")
elif cmd == "new":
self.conversation_id = None
self.history = []
log.write("[bold cyan]— new conversation —[/]")
elif cmd == "model":
if rest:
self._model = rest
log.write(f"[dim]model pinned:[/] {_escape(rest)}")
else:
log.write(
f"[dim]model:[/] {_escape(self._model or '(auto)')}"
)
elif parse_slash_command(text) is not None:
# Shaped like /tool_name(arg=val, ...) rather than one of
# the local meta-commands above — not handled here, sent
# to the backend as-is. chat_stream_endpoint recognizes
# and dispatches it directly (see synapse/slash_commands.py);
# a malformed one still goes through so the user sees the
# backend's own error, with full context, in one place.
if self._busy:
log.write(
"[yellow]Still streaming — wait or Ctrl+C to interrupt[/]"
)
else:
self._start_chat(text)
else:
log.write(
f"[red]unknown command[/] /{_escape(cmd)} — try /help"
)
def _start_chat(self, message: str) -> None:
log = self.query_one("#log", RichLog)
live = self.query_one("#live", Static)
log.write(format_user_line(message))
live.update("[bold blue]nexus>[/] [dim]…[/]")
self._busy = True
self._stop_stream.clear()
if not self.conversation_id:
self.conversation_id = str(uuid.uuid4())
conversation_id = self.conversation_id
body: dict[str, Any] = {
"message": message,
"conversation_id": conversation_id,
"history": list(self.history),
}
if self._model:
body["model"] = self._model
self.history.append({"role": "user", "content": message})
async def stream_worker():
reply_parts: list[str] = []
task = asyncio.current_task()
loop = asyncio.get_running_loop()
if task is None: # pragma: no cover - asyncio guarantees it
raise RuntimeError("stream worker has no task")
self._stream_cancel = (loop, task)
try:
if self._stop_stream.is_set():
raise asyncio.CancelledError
async with httpx.AsyncClient(
base_url=self.api_url, timeout=_STREAM_TIMEOUT
) as client:
async with client.stream(
"POST", "/chat/stream", json=body
) as resp:
if resp.status_code >= 400:
detail = (await resp.aread()).decode(
"utf-8", errors="replace"
)[:300]
self._call_ui(
self._show_error,
f"[red]error HTTP {resp.status_code}[/] "
f"{_escape(detail)}",
)
return
event = "message"
async for line in resp.aiter_lines():
if self._stop_stream.is_set():
raise asyncio.CancelledError
if line == "":
event = "message"
continue
if line.startswith("event:"):
event = line[6:].strip()
continue
if not line.startswith("data:"):
continue
payload = line[5:].strip()
kind = event
if kind in ("message", ""):
kind = "chunk"
payload = json.loads(payload)
if kind == "chunk":
reply_parts.append(payload)
preview = "".join(reply_parts)
if len(preview) > 4000:
preview = "" + preview[-4000:]
self._call_ui(
live.update,
format_assistant_line(preview),
)
elif kind == "tool_request":
try:
names = _deny_tool_request(
api_url=self.api_url,
conversation_id=conversation_id,
payload=payload,
)
shown = ", ".join(names)
self._call_ui(
log.write,
"[yellow]▸ denied action tool "
f"{_escape(shown)} — interactive "
"approval is not yet available in "
"the TUI[/]",
)
except Exception as exc:
self._call_ui(
self._show_error,
"[red]tool denial failed:[/] "
f"{_escape(str(exc))}",
)
return
elif kind == "error":
try:
detail = json.loads(payload).get(
"detail", payload
)
except Exception:
detail = payload
self._call_ui(
self._show_error,
f"[red]error:[/] "
f"{_escape(str(detail))}",
)
elif kind == "done":
break
except asyncio.CancelledError:
pass
except httpx.ConnectError:
self._call_ui(
self._show_error,
f"[red]Backend not reachable at "
f"{_escape(self.api_url)}. Start it: nexus start[/]",
)
except Exception as exc:
self._call_ui(
self._show_error,
f"[red]{_escape(type(exc).__name__)}:[/] "
f"{_escape(str(exc))}",
)
finally:
if self._stream_cancel == (loop, task):
self._stream_cancel = None
text = "".join(reply_parts).strip()
self._call_ui(self._finish_stream, text)
threading.Thread(
target=lambda: asyncio.run(stream_worker()), daemon=True
).start()
def _finish_stream(self, text: str) -> None:
log = self.query_one("#log", RichLog)
live = self.query_one("#live", Static)
try:
if text:
log.write(format_assistant_line(text))
self.history.append(
{"role": "assistant", "content": text}
)
finally:
# Always clear busy — a MarkupError must not wedge the TUI.
live.update("")
self._busy = False
self._schedule_status_refresh()
return AppImpl()
def run_tui(*, api_url: str | None = None) -> int:
"""Run the Textual app. Returns a process exit code."""
app = NexusTUI.build_app(api_url=api_url)
app.run()
return 0
-2
View File
@@ -42,7 +42,6 @@ mail = ["imap-tools>=1.7,<2"]
# synapse/search.py imports this lazily behind a bare except, so without it # synapse/search.py imports this lazily behind a bare except, so without it
# declared the chat web-search path silently returns nothing. # declared the chat web-search path silently returns nothing.
search = ["duckduckgo-search>=6,<9"] search = ["duckduckgo-search>=6,<9"]
tui = ["textual>=1.0,<3"]
desktop = [ desktop = [
"psutil>=5.9,<8", "psutil>=5.9,<8",
"pywebview>=5,<7; platform_system == 'Windows'", "pywebview>=5,<7; platform_system == 'Windows'",
@@ -65,7 +64,6 @@ all = [
"faster-whisper>=1.1,<2", "faster-whisper>=1.1,<2",
"imap-tools>=1.7,<2", "imap-tools>=1.7,<2",
"duckduckgo-search>=6,<9", "duckduckgo-search>=6,<9",
"textual>=1.0,<3",
"pywebview>=5,<7; platform_system == 'Windows'", "pywebview>=5,<7; platform_system == 'Windows'",
] ]
dev = [ dev = [
+2 -8
View File
@@ -167,16 +167,10 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
break break
messages.append(msg) messages.append(msg)
# Curry write/execute tools always require approval when model-issued, # If any action tool needs per-call approval, pause and wait for the user.
# even if the global policy allows lower-risk actions. A human-typed
# /tool(...) command is dispatched separately by main.py.
decisions = None decisions = None
action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))] action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))]
needs_approval = policy == "ask" or any( if policy == "ask" and action_calls:
c.get("function", {}).get("name", "") in _tools.ALWAYS_ASK_ACTION_TOOLS
for c in action_calls
)
if needs_approval and action_calls:
event = asyncio.Event() event = asyncio.Event()
# Single-use capability token, delivered only to the client that owns # Single-use capability token, delivered only to the client that owns
# this stream. /chat/approve requires it, so knowing the (guessable, # this stream. /chat/approve requires it, so knowing the (guessable,
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
"""NexusOS's own Curry instance: preloaded at import time, ready to be called.
Curry (curry_core.py, vendored alongside this file) is an immutable, versioned
fact store - constants, functions, model registrations, and inference
provenance, backed by SQLite. Nothing in NexusOS wires chat/model-authored
content into it yet; this module only makes it available - `from
synapse.curry_store import curry_db` and call `declare_constant`,
`get_constant_latest`, `declare_function`, `call_function`, etc. directly, the
same way `synapse.memory.store.store` and `synapse.playbooks.store.playbook_store`
are used elsewhere in this codebase.
Kept as a separate database file (CURRY_DB) from the memory/conversation store
on purpose: Curry's schema and lifecycle are independent of the memory store's.
"""
from __future__ import annotations
from .curry_core import Curry
from .nexus_config import CURRY_DB
curry_db = Curry(str(CURRY_DB))
__all__ = ["curry_db"]
+4 -61
View File
@@ -182,9 +182,7 @@ async def _generate_conversation_title(first_message: str, model: str) -> Option
from .memory.store import store, MemoryItem from .memory.store import store, MemoryItem
from .playbooks.store import playbook_store, PlaybookItem from .playbooks.store import playbook_store, PlaybookItem
from .curry_store import curry_db # noqa: F401 - import triggers Curry's own preload at startup
from .search import needs_web_search, web_search from .search import needs_web_search, web_search
from . import slash_commands as _slash_commands
MEMORY_SERVICE = settings.memory_url MEMORY_SERVICE = settings.memory_url
@@ -462,38 +460,6 @@ async def _resume_dropped_extractions() -> None:
# ------------------------- # -------------------------
# Chat (streaming) # Chat (streaming)
# ------------------------- # -------------------------
async def _slash_command_stream(
slash: "_slash_commands.SlashCommand | _slash_commands.SlashCommandError",
conversation_id: str,
) -> AsyncGenerator[str, None]:
"""Dispatch an explicit slash-command without a model or approval round-trip."""
if isinstance(slash, _slash_commands.SlashCommandError):
yield f"event: error\ndata: {_json.dumps({'detail': slash.text})}\n\n"
return
if slash.tool not in _tools.REGISTRY:
detail = f"unknown tool: {slash.tool}"
yield f"event: error\ndata: {_json.dumps({'detail': detail})}\n\n"
return
yield f"event: status\ndata: {_json.dumps({'tool': slash.tool})}\n\n"
raw_result = await _tools.dispatch(slash.tool, slash.args)
content = raw_result
try:
parsed = _json.loads(raw_result)
if isinstance(parsed, dict) and isinstance(parsed.get("fence"), str):
content = parsed["fence"]
else:
content = _json.dumps(parsed, indent=2, ensure_ascii=False)
except (TypeError, ValueError):
pass
store.add_message(conversation_id, "assistant", content)
yield f"data: {_json.dumps(content)}\n\n"
yield "event: done\ndata: {}\n\n"
@app.post("/chat/stream") @app.post("/chat/stream")
async def chat_stream_endpoint(payload: Dict[str, Any]): async def chat_stream_endpoint(payload: Dict[str, Any]):
# Bound concurrent chats so a flood can't fan out unlimited model inference. # Bound concurrent chats so a flood can't fan out unlimited model inference.
@@ -503,39 +469,13 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
_chat_slot_held = True _chat_slot_held = True
try: try:
message = payload.get("message", "") message = payload.get("message", "")
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
if not message:
raise HTTPException(status_code=400, detail="Missing 'message'")
# A whole-message /tool_name(arg=val, ...) command is an explicit human
# action. It skips model selection and approval but not the tool's own
# validation; slash_commands.py accepts literal keyword values only.
slash = _slash_commands.parse_slash_command(message)
if slash is not None:
project_id = store.conversation_project(conversation_id)
if project_id is None:
project_id = store.get_settings().get("active_project", "")
store.create_conversation(conversation_id, project_id or "")
store.add_message(conversation_id, "user", message)
slash_stream = _slash_command_stream(slash, conversation_id)
async def _slash_guarded() -> AsyncGenerator[str, None]:
try:
async for chunk in slash_stream:
yield chunk
finally:
_CHAT_INFLIGHT.release()
_chat_slot_held = False
return StreamingResponse(_slash_guarded(), media_type="text/event-stream")
app_settings = store.get_settings() app_settings = store.get_settings()
# Model precedence: explicit request > active playbook's pinned model > auto-select. # Model precedence: explicit request > active playbook's pinned model > auto-select.
_active_pb = playbook_manager.get_main_playbook() _active_pb = playbook_manager.get_main_playbook()
_pb_model = _active_pb.model if (_active_pb and _active_pb.model) else "" _pb_model = _active_pb.model if (_active_pb and _active_pb.model) else ""
model = payload.get("model") or _pb_model or await _auto_select_model(message) model = payload.get("model") or _pb_model or await _auto_select_model(message)
context = payload.get("context", {}) context = payload.get("context", {})
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
history = payload.get("history", []) history = payload.get("history", [])
temperature = payload.get("temperature", app_settings.get("temperature")) temperature = payload.get("temperature", app_settings.get("temperature"))
num_ctx = payload.get("num_ctx", app_settings.get("num_ctx", 0)) num_ctx = payload.get("num_ctx", app_settings.get("num_ctx", 0))
@@ -543,6 +483,9 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1)) gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model) num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
if not message:
raise HTTPException(status_code=400, detail="Missing 'message'")
# Resolve the project scope: an existing conversation keeps its bound project; # Resolve the project scope: an existing conversation keeps its bound project;
# a brand-new one inherits the current workspace (active_project setting). # a brand-new one inherits the current workspace (active_project setting).
# Everything project-scoped below (instructions, memory facts, RAG) uses it. # Everything project-scoped below (instructions, memory facts, RAG) uses it.
+1 -7
View File
@@ -160,11 +160,6 @@ SEED_PLAYBOOK_DIR = (
# --- DATABASE / STORAGE FILES (match your repo) --- # --- DATABASE / STORAGE FILES (match your repo) ---
MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db") MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db")
# Vendored Curry (synapse/curry_core.py) database: immutable versioned
# constants/functions/models + inference provenance. Separate file from
# MEMORY_DB on purpose - Curry's schema and lifecycle are independent of the
# memory/conversation store.
CURRY_DB = _configured_path("curry_db", "NEXUS_CURRY_DB", DATA_DIR / "curry.db")
# --- LOG FILES --- # --- LOG FILES ---
BACKEND_LOG = RUNTIME_DIR / "backend.log" BACKEND_LOG = RUNTIME_DIR / "backend.log"
@@ -185,7 +180,6 @@ _REQUIRED_DIRS = (
UPLOADS_DIR, UPLOADS_DIR,
EXPORTS_DIR, EXPORTS_DIR,
MEMORY_DB.parent, MEMORY_DB.parent,
CURRY_DB.parent,
) )
@@ -430,7 +424,7 @@ __all__ = ["Settings", "settings", "path", "VERSION",
"read_user_config", "write_user_config", "init_state", "INITIALIZED_FILES", "read_user_config", "write_user_config", "init_state", "INITIALIZED_FILES",
"DATA_DIR", "MODELS_DIR", "RUNTIME_DIR", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR", "MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
"EXPORTS_DIR", "MEMORY_DB", "CURRY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR", "EXPORTS_DIR", "MEMORY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
"ASSETS_DIR", "SEED_PLAYBOOK_DIR", "ASSETS_DIR", "SEED_PLAYBOOK_DIR",
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG", "BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
"ALLOWED_HOSTS", "ALLOWED_ORIGINS", "ALLOWED_HOSTS", "ALLOWED_ORIGINS",
-94
View File
@@ -1,94 +0,0 @@
"""Direct tool invocation from chat input: `/tool_name(arg=val, arg=val)`.
A human typing this IS the approval there's no one else to ask — so a
recognized slash-command skips the ask-policy round-trip entirely and
dispatches straight through `tools.dispatch()`, the same entry point a
model-issued tool call already goes through. It does not bypass anything a
tool validates internally (path boundaries, size caps, Curry's own sandbox
checks, etc.) only the human-approval step, which this message already is.
Argument values are parsed with `ast.literal_eval`, not `eval()`: strings,
numbers, booleans, None, and literal lists/dicts/tuples only. There is no way
to reference a name, call a function, or access an attribute in this syntax
a malformed or hostile-looking argument fails to parse rather than executing
anything, which is the "lint, not run" property that makes this different
from just typing Python.
The whole message must be nothing but the command this is a deliberate
command line, not a directive embedded in prose. Anything else (including a
message that merely starts with `/` but isn't shaped like this) falls through
to the normal chat/model path unchanged.
"""
from __future__ import annotations
import ast
import re
from dataclasses import dataclass
from typing import Any, Optional
# name(args) where name is a plain identifier — the same shape as a Python
# function call, so it reads the way the tool's own schema already documents
# it. re.DOTALL: argument values (e.g. a multi-line body= string) may
# legitimately contain newlines.
_COMMAND_RE = re.compile(r"^/([A-Za-z_][A-Za-z0-9_]*)\((.*)\)\s*$", re.DOTALL)
@dataclass
class SlashCommand:
tool: str
args: dict[str, Any]
@dataclass
class SlashCommandError:
text: str
def parse_slash_command(message: str) -> Optional[SlashCommand | SlashCommandError]:
"""Parse `/tool_name(arg=val, ...)`.
Returns None when `message` isn't shaped like a slash-command at all (the
caller should treat it as an ordinary chat message). Returns
SlashCommandError when it looks like one but is malformed that's worth
telling the user about rather than silently sending "/curry_call_fnction(...)"
to the model as if it were prose.
"""
stripped = (message or "").strip()
match = _COMMAND_RE.match(stripped)
if not match:
return None
tool_name, raw_args = match.group(1), match.group(2).strip()
if not raw_args:
return SlashCommand(tool=tool_name, args={})
# Parse "k1=v1, k2=v2" as keyword arguments to a call with no positional
# arguments and no function to actually call — ast.parse(mode='eval') on a
# synthetic call expression reuses Python's own keyword-argument grammar
# (quoting, nesting, trailing commas) instead of hand-rolling a parser for
# it, while call() as a bare name is never resolved or invoked.
try:
tree = ast.parse(f"call({raw_args})", mode="eval")
except SyntaxError as e:
return SlashCommandError(f"could not parse arguments for /{tool_name}(...): {e}")
call_node = tree.body
if not isinstance(call_node, ast.Call) or call_node.args:
return SlashCommandError(
f"/{tool_name}(...) arguments must be keyword form: arg=value, arg=value"
)
args: dict[str, Any] = {}
for kw in call_node.keywords:
if kw.arg is None: # **mapping unpacking — no source for that here
return SlashCommandError(f"/{tool_name}(...) does not support **-unpacking")
try:
args[kw.arg] = ast.literal_eval(kw.value)
except (ValueError, SyntaxError):
return SlashCommandError(
f"/{tool_name}(...): argument '{kw.arg}' must be a literal "
"(string, number, bool, None, list, dict, or tuple) — not an "
"expression, name, or call"
)
return SlashCommand(tool=tool_name, args=args)
+6 -344
View File
@@ -3,20 +3,16 @@
Ollama drives the calling: `/api/chat` with a `tools` param returns Ollama drives the calling: `/api/chat` with a `tools` param returns
`message.tool_calls`, and this module is just the registry + dispatch. `message.tool_calls`, and this module is just the registry + dispatch.
Most tools READ local state (memory, history, documents, models). Some act: Most tools READ local state (memory, history, documents, models). A few act:
`web_search`/`fetch_url` make outbound HTTP requests, `remember` writes a `web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a
memory fact, and `curry_*` reads or writes NexusOS's vendored Curry ledger. memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security
The per-playbook allowlist (`PlaybookItem.tools`) is the first gate. Curry boundary an action tool only fires when a playbook explicitly lists it.
write/execute tools additionally require per-call approval when model-issued.
A message consisting only of `/tool_name(arg=val, ...)` dispatches directly;
see `synapse/slash_commands.py` for that explicit-human-command boundary.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Awaitable, Callable from typing import Awaitable, Callable
from .curry_store import curry_db
from .memory.store import store, MemoryItem from .memory.store import store, MemoryItem
from .ollama_manager import get_ollama_manager from .ollama_manager import get_ollama_manager
@@ -219,124 +215,6 @@ async def _list_files(pattern: str = "", **_) -> str:
return json.dumps(sorted(hits)) return json.dumps(sorted(hits))
# Curry (synapse/curry_core.py, vendored) — immutable, versioned constants and
# functions. Expected caller errors keep the same structured JSON shape as the
# other tools instead of falling through dispatch()'s generic error envelope.
_CURRY_FENCE_LANG = "nexus-curry"
def _curry_fence(payload: dict) -> str:
body = json.dumps(payload, ensure_ascii=False, default=str).replace("`", "\\u0060")
return f"```{_CURRY_FENCE_LANG}\n{body}\n```"
async def _curry_call(fn, *args, **kwargs) -> dict:
# Curry holds one SQLite connection. Calls stay on the event-loop thread,
# where these local database operations are short and naturally serialized.
try:
result = fn(*args, **kwargs)
return {"ok": True, "result": result}
except (KeyError, ValueError, TypeError, RuntimeError) as exc:
return {"ok": False, "error": str(exc)}
async def _curry_declare_constant(
id: str = "", version: int = 0, value=None, type_signature: str = "",
description: str = "", **_,
) -> str:
"""ACTION tool: declare a new, immutable version of a named constant."""
out = await _curry_call(
curry_db.declare_constant, id, version, value, type_signature, description or None
)
if out["ok"]:
out = {"ok": True, "id": id, "version": version}
out["fence"] = _curry_fence({"kind": "declare_constant", **out})
return json.dumps(out)
async def _curry_get_constant(id: str = "", version: int = 0, **_) -> str:
return json.dumps(await _curry_call(curry_db.get_constant, id, version))
async def _curry_get_constant_latest(id: str = "", **_) -> str:
return json.dumps(await _curry_call(curry_db.get_constant_latest, id))
async def _curry_list_constants(active_only: bool = True, **_) -> str:
return json.dumps(await _curry_call(curry_db.list_constants, active_only))
async def _curry_retire_constant(
id: str = "", version: int = 0, reason: str = "", **_,
) -> str:
out = await _curry_call(
curry_db.retire_constant_with_reason,
id,
version,
reason or "retired via tool call",
)
return json.dumps(out)
async def _curry_declare_function(
name: str = "", version: int = 0, body: str = "",
constant_bindings: dict | None = None, function_bindings: dict | None = None,
is_pure: bool = False, expected_args: list | None = None,
description: str = "", arg_descriptions: dict | None = None, **_,
) -> str:
"""ACTION tool: declare one statically validated expression."""
out = await _curry_call(
curry_db.declare_function,
name,
version,
body,
constant_bindings or {},
function_bindings or {},
is_pure,
expected_args,
description or None,
arg_descriptions,
)
if out["ok"]:
out = {"ok": True, "name": name, "version": version}
out["fence"] = _curry_fence({"kind": "declare_function", **out})
return json.dumps(out)
async def _curry_get_function(name: str = "", version: int = 0, **_) -> str:
return json.dumps(await _curry_call(curry_db.get_function, name, version))
async def _curry_list_functions(active_only: bool = True, **_) -> str:
return json.dumps(await _curry_call(curry_db.list_functions, active_only))
async def _curry_call_function(
name: str = "", version: int = 0, args: dict | None = None, **_,
) -> str:
out = await _curry_call(curry_db.call_function, name, version, args or {})
if out["ok"]:
out["fence"] = _curry_fence({
"kind": "call_function",
"name": name,
"version": version,
**out,
})
return json.dumps(out)
async def _curry_retire_function(
name: str = "", version: int = 0, reason: str = "", **_,
) -> str:
out = await _curry_call(
curry_db.retire_function_with_reason,
name,
version,
reason or "retired via tool call",
)
return json.dumps(out)
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format. # name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = { REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
"search_memory": ( "search_memory": (
@@ -482,229 +360,13 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
}, },
_remember, _remember,
), ),
"curry_declare_constant": (
{
"type": "function",
"function": {
"name": "curry_declare_constant",
"description": (
"Declare a new immutable version of a Curry constant. "
"Requires per-call human approval when model-issued."
),
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Constant identifier."},
"version": {"type": "integer", "description": "A new, higher version."},
"value": {"description": "Value matching type_signature."},
"type_signature": {
"type": "string",
"description": (
"Float64 | Int32 | String | Blob | Json | Tokens | "
"Currency | Bool"
),
},
"description": {"type": "string"},
},
"required": ["id", "version", "value", "type_signature"],
},
},
},
_curry_declare_constant,
),
"curry_get_constant": (
{
"type": "function",
"function": {
"name": "curry_get_constant",
"description": "Retrieve a Curry constant by exact id and version.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string"},
"version": {"type": "integer"},
},
"required": ["id", "version"],
},
},
},
_curry_get_constant,
),
"curry_get_constant_latest": (
{
"type": "function",
"function": {
"name": "curry_get_constant_latest",
"description": "Retrieve the latest active version of a Curry constant.",
"parameters": {
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"],
},
},
},
_curry_get_constant_latest,
),
"curry_list_constants": (
{
"type": "function",
"function": {
"name": "curry_list_constants",
"description": "List Curry constants.",
"parameters": {
"type": "object",
"properties": {"active_only": {"type": "boolean"}},
},
},
},
_curry_list_constants,
),
"curry_retire_constant": (
{
"type": "function",
"function": {
"name": "curry_retire_constant",
"description": (
"Retire, but do not delete, a Curry constant version. "
"Requires per-call human approval when model-issued."
),
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string"},
"version": {"type": "integer"},
"reason": {"type": "string"},
},
"required": ["id", "version"],
},
},
},
_curry_retire_constant,
),
"curry_declare_function": (
{
"type": "function",
"function": {
"name": "curry_declare_function",
"description": (
"Declare a new immutable Curry function version. The body is one "
"statically validated Python expression. Requires per-call human "
"approval when model-issued."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"version": {"type": "integer"},
"body": {"type": "string"},
"constant_bindings": {"type": "object"},
"function_bindings": {"type": "object"},
"is_pure": {"type": "boolean"},
"expected_args": {
"type": "array",
"items": {"type": "string"},
},
"description": {"type": "string"},
"arg_descriptions": {"type": "object"},
},
"required": ["name", "version", "body"],
},
},
},
_curry_declare_function,
),
"curry_get_function": (
{
"type": "function",
"function": {
"name": "curry_get_function",
"description": "Retrieve a Curry function by exact name and version.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"version": {"type": "integer"},
},
"required": ["name", "version"],
},
},
},
_curry_get_function,
),
"curry_list_functions": (
{
"type": "function",
"function": {
"name": "curry_list_functions",
"description": "List Curry functions and their expected arguments.",
"parameters": {
"type": "object",
"properties": {"active_only": {"type": "boolean"}},
},
},
},
_curry_list_functions,
),
"curry_call_function": (
{
"type": "function",
"function": {
"name": "curry_call_function",
"description": (
"Execute an exact Curry function version with runtime arguments. "
"Requires per-call human approval when model-issued."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"version": {"type": "integer"},
"args": {"type": "object"},
},
"required": ["name", "version"],
},
},
},
_curry_call_function,
),
"curry_retire_function": (
{
"type": "function",
"function": {
"name": "curry_retire_function",
"description": (
"Retire, but do not delete, a Curry function version. "
"Requires per-call human approval when model-issued."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"version": {"type": "integer"},
"reason": {"type": "string"},
},
"required": ["name", "version"],
},
},
},
_curry_retire_function,
),
} }
# Tools that act (write local state or reach the network). These require an # Tools that act (write local state or reach the network). These require an
# explicit consent gate (settings.allow_action_tools) on top of the per-playbook # explicit consent gate (settings.allow_action_tools) on top of the per-playbook
# allowlist — a playbook granting one isn't enough on its own. Curry writes and # allowlist — a playbook granting one isn't enough on its own.
# execution additionally require per-call approval for model-issued calls. ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"})
CURRY_ALWAYS_ASK_TOOLS = frozenset({
"curry_declare_constant",
"curry_retire_constant",
"curry_declare_function",
"curry_retire_function",
"curry_call_function",
})
ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"}) | CURRY_ALWAYS_ASK_TOOLS
ALWAYS_ASK_ACTION_TOOLS = CURRY_ALWAYS_ASK_TOOLS
def is_action(name: str) -> bool: def is_action(name: str) -> bool:
+1 -16
View File
@@ -31,23 +31,8 @@ def run_cli(tmp_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
def test_help_exposes_portable_command_tree(tmp_path): def test_help_exposes_portable_command_tree(tmp_path):
result = run_cli(tmp_path, "--help") result = run_cli(tmp_path, "--help")
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat", "monitor", "tui"): for command in ("init", "config", "provider", "doctor", "serve", "models", "chat", "monitor"):
assert command in result.stdout assert command in result.stdout
assert "interactive TUI" in result.stdout or "TUI" in result.stdout
def test_bare_nexus_defaults_to_tui_command():
"""No subcommand → TUI entry (Hermes-style). Non-TTY exits 2 without launching."""
from unittest import mock
from nexusos_cli.cli import build_parser, cmd_tui
parser = build_parser()
args = parser.parse_args([])
assert args.command is None # filled in by main()
with mock.patch("sys.stdin.isatty", return_value=False), \
mock.patch("sys.stdout.isatty", return_value=False):
assert cmd_tui(args) == 2
def test_legacy_cli_spellings_remain_compatible(): def test_legacy_cli_spellings_remain_compatible():
-53
View File
@@ -1,53 +0,0 @@
"""synapse/curry_core.py (vendored) + synapse/curry_store.py (NexusOS's preload).
Two concerns: the vendor sync didn't silently drop the sandbox fix from
https://github.com/Athena-Pro/Curry/pull/4, and curry_store gives NexusOS a
live instance for the registered chat tools.
"""
import pytest
from synapse.curry_core import Curry, TypeSignature
from synapse import curry_store
def test_curry_store_is_preloaded_and_open():
# curry_store.curry_db is a module-level singleton constructed at import
# time (mirrors synapse.memory.store.store / synapse.playbooks.store.playbook_store)
# - by the time this test runs, it has already opened its database file.
assert isinstance(curry_store.curry_db, Curry)
assert curry_store.curry_db.conn.execute("SELECT 1").fetchone()[0] == 1
def test_curry_db_path_matches_nexus_config(tmp_path, monkeypatch):
from synapse import nexus_config
assert str(curry_store.curry_db.db_path) == str(nexus_config.CURRY_DB)
def test_vendored_sandbox_fix_rejects_format_dunder_escape(tmp_path):
# Regression test for the vendored fix: a body that hides dunder-attribute
# traversal inside a str.format() field spec must still be rejected at
# declare time, not just the literal '.__class__' form. If a future
# re-vendor from upstream drops the fix, this is what catches it.
db = Curry(str(tmp_path / "sandbox_check.db"))
db.declare_function("helper", 1, "1")
exploit = "'{0.__globals__}'.format(helper)"
with pytest.raises(ValueError, match="format"):
db.declare_function("evil", 1, exploit, function_bindings={"helper": 1})
# the original, always-caught dunder-attribute form stays blocked too
with pytest.raises(ValueError):
db.declare_function("evil2", 1, "x.__class__", expected_args=["x"])
db.close()
def test_vendored_curry_basic_versioning_roundtrip(tmp_path):
db = Curry(str(tmp_path / "roundtrip.db"))
db.declare_constant("rate", 1, 0.1, TypeSignature.FLOAT64.value)
db.declare_function(
"apply_rate", 1, "amount * (1 + rate)",
constant_bindings={"rate": 1}, expected_args=["amount"],
)
assert db.call_function("apply_rate", 1, {"amount": 100}) == 110.00000000000001
db.close()
+33
View File
@@ -0,0 +1,33 @@
"""Platform guards for the community-supported native macOS install path."""
from __future__ import annotations
import importlib.util
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("nexus_sync_macos_test", ROOT / "bin" / "sync.py")
assert SPEC and SPEC.loader
sync = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(sync)
def test_darwin_uses_gpu_agnostic_requirements(monkeypatch):
monkeypatch.setattr(sync.os, "name", "posix")
monkeypatch.setattr(sync.sys, "platform", "darwin")
assert sync.requirements() == "requirements-base.txt"
def test_linux_provisioning_stages_are_skipped_on_darwin(monkeypatch):
monkeypatch.setattr(sync.sys, "platform", "darwin")
def unexpected_run(*args, **kwargs):
raise AssertionError(f"Linux provisioning ran on macOS: {args!r}")
monkeypatch.setattr(sync.subprocess, "run", unexpected_run)
sync.linux_stage("restore-linux.sh", "packages")
def test_macos_installer_is_in_the_shell_parse_gate():
gate = (ROOT / "bin" / "check.sh").read_text(encoding="utf-8")
assert "install-macos.sh" in gate
+1 -2
View File
@@ -29,8 +29,7 @@ DISTRIBUTION_OF = {
} }
# Provided by another declared distribution rather than named directly. # Provided by another declared distribution rather than named directly.
# rich: Textual depends on it, so the tui extra already pulls it in. TRANSITIVE = {"starlette", "socketio", "engineio"}
TRANSITIVE = {"starlette", "socketio", "engineio", "rich"}
# Modules that ship inside this repo. # Modules that ship inside this repo.
FIRST_PARTY = {"synapse", "nexusos_cli", "modules", "management", "bin", "tests"} FIRST_PARTY = {"synapse", "nexusos_cli", "modules", "management", "bin", "tests"}
-204
View File
@@ -1,204 +0,0 @@
"""synapse/slash_commands.py (the /tool_name(arg=val) parser) and its wiring
into chat_stream_endpoint (direct dispatch, no model call, no approval
round-trip) plus the ten curry_* tools it can now reach.
"""
import json
import pytest
from fastapi.testclient import TestClient
from synapse.slash_commands import SlashCommand, SlashCommandError, parse_slash_command
from synapse.main import app
from synapse import tools
# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------
def test_parses_keyword_arguments_as_python_literals():
result = parse_slash_command('/curry_call_function(name="x", version=1, args={"a": 1})')
assert result == SlashCommand(
tool="curry_call_function",
args={"name": "x", "version": 1, "args": {"a": 1}},
)
def test_parses_no_arguments():
assert parse_slash_command("/curry_list_functions()") == SlashCommand(tool="curry_list_functions", args={})
def test_non_slash_message_returns_none():
assert parse_slash_command("just chatting, not a command") is None
def test_slash_without_parens_returns_none():
# The TUI's own local commands (/model foo, /new) use this shape — must
# never be mistaken for a tool call.
assert parse_slash_command("/model gpt") is None
def test_slash_embedded_in_prose_returns_none():
assert parse_slash_command('hey /curry_call_function(name="x", version=1) run this') is None
def test_name_or_call_as_argument_value_is_rejected():
# ast.literal_eval only accepts literals — a bare name or a call is a
# parse failure, not a value, so nothing here is ever evaluated.
result = parse_slash_command("/curry_call_function(x=some_name)")
assert isinstance(result, SlashCommandError)
result2 = parse_slash_command('/curry_call_function(x=__import__("os"))')
assert isinstance(result2, SlashCommandError)
def test_positional_arguments_are_rejected():
result = parse_slash_command("/curry_call_function(1, 2)")
assert isinstance(result, SlashCommandError)
def test_double_star_unpacking_is_rejected():
result = parse_slash_command('/curry_call_function(**{"a": 1})')
assert isinstance(result, SlashCommandError)
def test_malformed_syntax_is_rejected():
result = parse_slash_command("/curry_call_function(name=)")
assert isinstance(result, SlashCommandError)
# ---------------------------------------------------------------------------
# Curry tool registration
# ---------------------------------------------------------------------------
_CURRY_ACTION_TOOLS = {
"curry_declare_constant", "curry_retire_constant",
"curry_declare_function", "curry_retire_function", "curry_call_function",
}
_CURRY_READ_TOOLS = {
"curry_get_constant", "curry_get_constant_latest", "curry_list_constants",
"curry_get_function", "curry_list_functions",
}
def test_all_curry_tools_registered():
for name in _CURRY_ACTION_TOOLS | _CURRY_READ_TOOLS:
assert name in tools.REGISTRY
def test_curry_write_and_execute_tools_are_gated_actions():
for name in _CURRY_ACTION_TOOLS:
assert tools.is_action(name), name
assert name in tools.ALWAYS_ASK_ACTION_TOOLS, name
def test_curry_read_tools_are_not_actions():
for name in _CURRY_READ_TOOLS:
assert not tools.is_action(name), name
# ---------------------------------------------------------------------------
# End-to-end HTTP: direct dispatch, no model call, no approval round-trip
# ---------------------------------------------------------------------------
@pytest.fixture
def client():
return TestClient(app)
def _sse_events(body: str) -> list[tuple[str, str]]:
events = []
event_type = "message"
for block in body.split("\n\n"):
for line in block.splitlines():
if line.startswith("event: "):
event_type = line[len("event: "):].strip()
elif line.startswith("data: "):
events.append((event_type, line[len("data: "):]))
event_type = "message"
return events
def test_slash_command_dispatches_without_model_call(client, monkeypatch):
from synapse import chat as chatmod
async def _boom(*a, **k):
raise AssertionError("the model must not be called for a slash-command")
monkeypatch.setattr(chatmod, "stream_chat_response", _boom)
resp = client.post("/chat/stream", json={
"message": '/curry_list_functions()',
"conversation_id": "test-slash-http-1",
})
events = _sse_events(resp.text)
assert ("status", json.dumps({"tool": "curry_list_functions"})) in events
assert any(t == "done" for t, _ in events)
def test_slash_command_skips_approval_round_trip(client, monkeypatch):
async def _fake_dispatch(name, args):
return json.dumps({"ok": True, "result": "did it"})
monkeypatch.setattr(tools, "dispatch", _fake_dispatch)
resp = client.post("/chat/stream", json={
"message": '/curry_call_function(name="x", version=1, args={})',
"conversation_id": "test-slash-http-2",
})
events = _sse_events(resp.text)
assert not any(t == "tool_request" for t, _ in events)
assert any(t == "done" for t, _ in events)
def test_slash_command_uses_fence_from_result_when_present(client, monkeypatch):
async def _fake_dispatch(name, args):
return json.dumps({"ok": True, "fence": "```nexus-curry\n{\"kind\": \"x\"}\n```"})
monkeypatch.setattr(tools, "dispatch", _fake_dispatch)
resp = client.post("/chat/stream", json={
"message": '/curry_call_function(name="x", version=1, args={})',
"conversation_id": "test-slash-http-3",
})
events = _sse_events(resp.text)
content = [d for t, d in events if t == "message"]
assert content and "nexus-curry" in content[0]
def test_slash_command_unknown_tool_yields_error_not_a_chat_reply(client):
resp = client.post("/chat/stream", json={
"message": "/not_a_real_tool(a=1)",
"conversation_id": "test-slash-http-4",
})
events = _sse_events(resp.text)
assert any(t == "error" for t, _ in events)
assert not any(t == "status" for t, _ in events)
def test_slash_command_malformed_yields_error(client):
resp = client.post("/chat/stream", json={
"message": "/curry_call_function(x=some_name)",
"conversation_id": "test-slash-http-5",
})
events = _sse_events(resp.text)
assert any(t == "error" for t, _ in events)
def test_message_with_leading_slash_but_not_command_shaped_goes_to_chat(client, monkeypatch):
# e.g. "/model gpt" or plain prose starting with "/" - must still reach
# the normal model path, not be swallowed as a broken slash-command.
called = {}
async def _fake_stream(*a, **k):
called["hit"] = True
return
yield # pragma: no cover - make this an async generator
# main.py did `from .chat import stream_chat_response`, a separate name
# binding from chat.stream_chat_response - patch the one main.py actually
# calls.
from synapse import main as mainmod
monkeypatch.setattr(mainmod, "stream_chat_response", _fake_stream)
client.post("/chat/stream", json={
"message": "/model gpt",
"conversation_id": "test-slash-http-6",
})
assert called.get("hit") is True
-386
View File
@@ -1,386 +0,0 @@
"""TUI helpers and headless App.run_test coverage."""
from __future__ import annotations
import asyncio
import threading
import pytest
from rich.text import Text
from nexusos_cli.tui_app import (
_compact_status,
_deny_tool_request,
_escape,
_status_line,
format_assistant_line,
format_user_line,
)
class _ApprovalResponse:
def raise_for_status(self):
return None
class _ApprovalClient:
calls = []
def __init__(self, **kwargs):
self.kwargs = kwargs
def __enter__(self):
return self
def __exit__(self, *args):
return None
def post(self, path, *, json):
self.calls.append((path, json, self.kwargs))
return _ApprovalResponse()
def test_status_line_mentions_services():
snap = {
"version": "1.0.0",
"services": {
"backend": {"running": True},
"memory": {"running": False},
"provider": {"reachable": True},
},
"api": {"online": True, "action_tool_policy": "ask"},
"host": {"cpu_pct": 10.0},
"toolchains": [{"lang": "python", "ready": True}],
}
line = _status_line(snap)
assert "backend=UP" in line
assert "memory=DOWN" in line
assert "provider=UP" in line
assert "tools=ask" in line
assert "run=python" in line
def test_compact_status_handles_api_down():
snap = {
"host": {},
"api": {"online": False},
"recent_tools": [],
}
assert "api DOWN" in _compact_status(snap)
def test_escape_preserves_code_brackets_in_display():
raw = "idx = arr[i] and rng = [a-z]+"
plain = Text.from_markup(format_assistant_line(raw)).plain
assert "arr[i]" in plain
assert "[a-z]+" in plain
# Unescaped markup would drop the bracket contents.
assert plain != "nexus> idx = arr and rng = +"
def test_closing_tag_in_model_output_does_not_raise():
raw = "close with [/] please"
plain = Text.from_markup(format_assistant_line(raw)).plain
assert "[/]" in plain
def test_user_line_escapes_markup():
plain = Text.from_markup(format_user_line("use [bold] please")).plain
assert "[bold]" in plain
def test_finish_stream_markup_does_not_wedge_busy():
"""A stray '[/]' used to raise before _busy=False and lock the TUI forever."""
pytest.importorskip("textual")
from nexusos_cli.tui_app import NexusTUI
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
async def _run():
async with app.run_test():
app._busy = True
app._finish_stream("see [/] and arr[i]")
assert app._busy is False
assert app.history[-1]["content"] == "see [/] and arr[i]"
asyncio.run(_run())
def test_stream_error_remains_visible_after_finish():
pytest.importorskip("textual")
from nexusos_cli.tui_app import NexusTUI
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
async def _run():
async with app.run_test():
app._busy = True
app._show_error("[red]Backend not reachable[/]")
app._finish_stream("")
log = app.query_one("#log")
assert any("Backend not reachable" in line.text for line in log.lines)
assert app._busy is False
asyncio.run(_run())
@pytest.mark.parametrize("key", ["ctrl+c", "ctrl+d"])
def test_priority_exit_bindings_reach_app_while_prompt_is_focused(key):
pytest.importorskip("textual")
from nexusos_cli.tui_app import NexusTUI
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
async def _run():
async with app.run_test() as pilot:
assert app.is_running
await pilot.press(key)
await pilot.pause()
assert not app.is_running
asyncio.run(_run())
def test_tool_request_is_denied_with_stream_token():
_ApprovalClient.calls.clear()
names = _deny_tool_request(
api_url="http://localhost:8000",
conversation_id="conversation-1",
payload='{"token":"secret","actions":[{"name":"run_snippet"}]}',
client_factory=_ApprovalClient,
)
assert names == ["run_snippet"]
path, body, client_kwargs = _ApprovalClient.calls[-1]
assert path == "/chat/approve"
assert body == {
"conversation_id": "conversation-1",
"token": "secret",
"decisions": {"run_snippet": False},
}
assert client_kwargs["base_url"] == "http://localhost:8000"
def test_inflight_tool_denial_uses_original_conversation_id(monkeypatch):
pytest.importorskip("textual")
import nexusos_cli.tui_app as tui_app
stream_started = threading.Event()
release_stream = threading.Event()
denied_for = []
class _StreamResponse:
status_code = 200
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
async def aiter_lines(self):
stream_started.set()
await asyncio.to_thread(release_stream.wait, 2)
yield "event: tool_request"
yield 'data: {"token":"secret","actions":[{"name":"run_snippet"}]}'
yield ""
yield "event: done"
yield "data: {}"
class _StreamClient:
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
def stream(self, *args, **kwargs):
return _StreamResponse()
def _capture_denial(*, conversation_id, **kwargs):
denied_for.append(conversation_id)
return ["run_snippet"]
monkeypatch.setattr(tui_app.httpx, "AsyncClient", _StreamClient)
monkeypatch.setattr(tui_app, "_deny_tool_request", _capture_denial)
app = tui_app.NexusTUI.build_app(api_url="http://127.0.0.1:9")
async def _run():
async with app.run_test():
app._start_chat("run it")
assert await asyncio.to_thread(stream_started.wait, 2)
original_id = app.conversation_id
app._handle_slash("/new")
assert app.conversation_id is None
release_stream.set()
for _ in range(200):
if not app._busy:
break
await asyncio.sleep(0.01)
assert app._busy is False
assert denied_for == [original_id]
asyncio.run(_run())
def test_interrupt_cancels_silent_stream_and_accepts_next_message(monkeypatch):
pytest.importorskip("textual")
import nexusos_cli.tui_app as tui_app
first_stream_started = threading.Event()
class _StreamResponse:
status_code = 200
def __init__(self, call_number):
self.call_number = call_number
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
async def aiter_lines(self):
if self.call_number == 1:
first_stream_started.set()
await asyncio.Event().wait()
yield "data: \"READY\""
yield ""
yield "event: done"
yield "data: {}"
class _StreamClient:
calls = 0
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
def stream(self, *args, **kwargs):
type(self).calls += 1
return _StreamResponse(type(self).calls)
monkeypatch.setattr(tui_app.httpx, "AsyncClient", _StreamClient)
app = tui_app.NexusTUI.build_app(api_url="http://127.0.0.1:9")
async def _wait_until_idle():
for _ in range(100):
if not app._busy:
return
await asyncio.sleep(0.01)
pytest.fail("stream did not become idle within one second")
async def _run():
async with app.run_test() as pilot:
app._start_chat("first")
assert await asyncio.to_thread(first_stream_started.wait, 2)
await pilot.press("ctrl+c")
await _wait_until_idle()
log = app.query_one("#log")
assert any("interrupt requested" in line.text for line in log.lines)
assert not any("ReadTimeout" in line.text for line in log.lines)
app._start_chat("second")
await _wait_until_idle()
assert app.history[-1] == {
"role": "assistant",
"content": "READY",
}
asyncio.run(_run())
def test_escape_round_trip_helper():
assert "[" in _escape("x[y]") or "\\[" in _escape("x[y]")
def test_slash_tool_call_shape_forwards_to_start_chat(monkeypatch):
"""/tool_name(arg=val) isn't a local meta-command — it must reach the
backend (synapse/slash_commands.py + chat_stream_endpoint dispatch it),
not fall into the generic 'unknown command' branch."""
pytest.importorskip("textual")
from nexusos_cli.tui_app import NexusTUI
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
calls: list[str] = []
monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text))
async def _run():
async with app.run_test():
text = '/curry_call_function(name="double", version=1, args={"x": 21})'
app._handle_slash(text)
assert calls == [text]
log = app.query_one("#log")
assert not any("unknown command" in line.text for line in log.lines)
asyncio.run(_run())
def test_slash_malformed_tool_call_still_forwards_for_the_backend_error(monkeypatch):
"""Even a malformed /tool(...) is forwarded rather than swallowed locally
the backend's parser gives a clearer, more specific error than the
TUI's generic 'unknown command' would."""
pytest.importorskip("textual")
from nexusos_cli.tui_app import NexusTUI
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
calls: list[str] = []
monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text))
async def _run():
async with app.run_test():
text = "/curry_call_function(x=__import__('os'))"
app._handle_slash(text)
assert calls == [text]
asyncio.run(_run())
def test_slash_local_meta_commands_still_handled_locally(monkeypatch):
"""A known local command must still be handled in-TUI, never forwarded —
the new tool-call passthrough is strictly the fallback branch."""
pytest.importorskip("textual")
from nexusos_cli.tui_app import NexusTUI
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
calls: list[str] = []
monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text))
async def _run():
async with app.run_test():
app._handle_slash("/help")
assert calls == []
log = app.query_one("#log")
assert any("this list" in line.text for line in log.lines)
asyncio.run(_run())
def test_slash_unknown_bare_command_still_rejected(monkeypatch):
"""A genuinely unknown command (no parens, not a local command) keeps the
existing 'unknown command' behavior rather than silently forwarding
anything that starts with /."""
pytest.importorskip("textual")
from nexusos_cli.tui_app import NexusTUI
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
calls: list[str] = []
monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text))
async def _run():
async with app.run_test():
app._handle_slash("/frobnicate")
assert calls == []
log = app.query_one("#log")
assert any("unknown command" in line.text for line in log.lines)
asyncio.run(_run())