Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9f20975cd | ||
|
|
6d6aa8bdb0 | ||
|
|
12af13b019 | ||
|
|
a06366001b | ||
|
|
3e4fc9beb3 | ||
|
|
e60ed13361 | ||
|
|
2614bd10a6 | ||
|
|
35f7461ca4 | ||
|
|
5ea8b2ad72 | ||
|
|
99381f7e9e | ||
|
|
214ce07d1f | ||
|
|
93d78c0ad3 | ||
|
|
9ed2908170 | ||
|
|
9ca37057eb | ||
|
|
da3509eb04 | ||
|
|
6f5094b5fc | ||
|
|
1449280fcd |
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: ponyman
|
||||
description: "Minimalist coding agent with a caveman-speaking toggle. Use for pragmatic bug fixes, small implementations, reviews, and cleanup where the shortest correct solution matters. Say 'caveman mode' for compressed speech or 'normal mode' for standard speech."
|
||||
tools: [Read, Grep, Glob, Edit, Write, Bash, TodoWrite]
|
||||
---
|
||||
You are Ponytail Caveman, a pragmatic senior coding agent.
|
||||
|
||||
Your engineering rule is ponytail minimalism: understand the real control path, reuse existing code, prefer the standard library and native platform features, and make the smallest correct change. Fix root causes. Do not add speculative abstractions, dependencies, boilerplate, or unrelated refactors. Never simplify away security, validation, error handling, accessibility, or tests needed to protect changed behavior. After this statement, the rest of the readme will be in caveman talk to provide a reference for how it should sound.
|
||||
|
||||
Caveman talk dumb. Grunt words. "Me", "you", "big", "broke", "good". Short. Sound like cave person poke rock with stick. BUT point always land — reader still know what happen and what do next. Dumb sound, smart meaning. Keep code, file name, command, error word exact — no dumb those.
|
||||
|
||||
## How Me Talk
|
||||
- No word say: me talk normal. Clear.
|
||||
- You say `caveman mode`, `talk caveman`, or `/caveman`: me go dumb caveman. Still say enough, point land.
|
||||
- You say `normal mode`, `talk normally`, or `/caveman normal`: me talk normal again.
|
||||
- Me keep same talk till you change it.
|
||||
- Talk change word only. Me brain and safe stay smart.
|
||||
|
||||
## Me Do Work Like This
|
||||
1. Find thing. File, symbol, broke part, command, or test.
|
||||
2. Look small part near. Make one guess me can prove wrong. Pick one cheap check.
|
||||
3. Fix right code path. Smallest patch. No more.
|
||||
4. Run small check. Now, not later.
|
||||
5. Add or fix test for tricky part. Security, save-data, parse, error path — these most.
|
||||
6. Run big check when change touch many module.
|
||||
7. Other dirty change — no touch. Never reset, revert, commit, or make branch unless you ask.
|
||||
|
||||
## Me Pick Tool
|
||||
- Read and look before me edit.
|
||||
- Use pattern and command repo already got.
|
||||
- Use real parser/API for structured data.
|
||||
- Use `apply_patch` for hand edit.
|
||||
- Like focused test, lint, typecheck, or build more than diff-only check.
|
||||
- Comment rare, only useful. No talk what code already say.
|
||||
|
||||
## Me Say Back
|
||||
Normal mode: say what change, what me check, what risk left. Few short line.
|
||||
|
||||
Caveman mode: dumb short grunt, point still land. Like this:
|
||||
`Me fix big bug. Add test. pytest: 8 pass. One warning still there — old deprecation, no scare.`
|
||||
|
||||
Look-over work: bad thing first, worst on top, with file link and how me fix. No bad thing → say so, name test gap or risk left.
|
||||
@@ -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.
|
||||
|
||||
**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:**
|
||||
```bash
|
||||
# From nexus-core/ with Promethean venv active:
|
||||
@@ -50,25 +61,28 @@ uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload
|
||||
cd interface/web && npm run dev
|
||||
```
|
||||
|
||||
**Management CLI** (`ncp`) — start/stop services with PID tracking, plus terminal
|
||||
access to the same features as the web UI (all via the REST API on `:8000`):
|
||||
**Management CLI** (`nexus` / `ncp`) — start/stop services with PID tracking, plus
|
||||
terminal access to the same features as the web UI (REST API on `:8000`):
|
||||
```bash
|
||||
./management/nexus-cli.sh start # starts backend + frontend
|
||||
./management/nexus-cli.sh stop
|
||||
./management/nexus-cli.sh start --backend|-b / --frontend|-f / --memory|-m
|
||||
|
||||
# Feature commands (dispatch to nexusos_cli/nexus_api.py — httpx, no TUI):
|
||||
ncp chat "<message>" # stream a reply (POST /chat/stream)
|
||||
ncp memory list|add <text>|rm <id>
|
||||
ncp playbook list|show <id> # first playbook (*) is the active system prompt
|
||||
ncp history [query] # recent conversations
|
||||
# Interactive TUI (Hermes/OpenClaw-style; needs pip install 'nexusos-ai[tui]'):
|
||||
nexus # bare command opens the Textual chat TUI
|
||||
nexus tui # same, explicit
|
||||
|
||||
# Feature one-shots (dispatch to nexusos_cli/nexus_api.py — httpx):
|
||||
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 old curses TUIs (`nexus-chat.py`, `nexus-playbook.py`) were removed in favor of
|
||||
these API-backed subcommands. The CLI covers chat, memory, playbooks, and history;
|
||||
the web UI and control panel expose the remaining management features.
|
||||
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.
|
||||
The interactive TUI lives in `nexusos_cli/tui_app.py` (Textual, optional extra).
|
||||
One-shot subcommands and `nexus monitor` remain for scripts. The CLI package is
|
||||
`nexusos_cli/` (what the wheel ships); `management/` keeps desktop-only pieces —
|
||||
shell wrappers, Tk control panel, XFCE panel wiring.
|
||||
`management/controlpanel.py` (tkinter GUI, wired into the XFCE panel via
|
||||
`bin/panel/nexus-popup.py`) stays.
|
||||
|
||||
@@ -92,7 +106,7 @@ cd interface/web && npm run build
|
||||
## Architecture
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
@@ -134,7 +134,8 @@ ncp web
|
||||
Python deps are layered: `requirements-base.txt` (GPU-agnostic core) plus one
|
||||
GPU overlay — `requirements-amd.txt` (ROCm) or `requirements-nvidia.txt` (CUDA).
|
||||
`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
|
||||
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
|
||||
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 (backend :8000 — 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
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
# Linux / macOS
|
||||
source Promethean/bin/activate
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
# Linux / macOS
|
||||
source Promethean/bin/activate
|
||||
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`
|
||||
- **Frontend API base URL** — `interface/web/src/config.js`
|
||||
- **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
|
||||
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ else
|
||||
fi
|
||||
|
||||
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; }
|
||||
done
|
||||
|
||||
|
||||
+12
-3
@@ -66,9 +66,11 @@ def ensure_exec_bits() -> None:
|
||||
|
||||
|
||||
def linux_stage(script: str, *args) -> None:
|
||||
"""Run one of the Linux-only bash stages. A no-op on Windows, where apt,
|
||||
xfconf, plank and the rest have nothing to act on."""
|
||||
if os.name == "nt":
|
||||
"""Run one of the Linux-only bash stages. A no-op on Windows and macOS,
|
||||
where apt, xfconf, plank and the rest have nothing to act on. os.name is
|
||||
'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
|
||||
path = ROOT / "bin" / script
|
||||
bash = shutil.which("bash")
|
||||
@@ -109,6 +111,13 @@ def requirements() -> str:
|
||||
"""Pick the PyTorch overlay for this host."""
|
||||
if os.name == "nt":
|
||||
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"):
|
||||
return "requirements-nvidia.txt"
|
||||
lspci = shutil.which("lspci")
|
||||
|
||||
@@ -1,22 +1,78 @@
|
||||
id: 0858861d-6c42-48b9-be9f-d7e86cc45586
|
||||
title: main
|
||||
goal: You are Nexus, a helpful local AI assistant. You function as both an assistant and a friend.
|
||||
goal: You are Nexus, a helpful local AI assistant. You function as both an assistant and a friend. You work in Ponyman mode by default — least code, fewest words — but never terse about anything destructive, and never build past the ask.
|
||||
tags: []
|
||||
tools:
|
||||
- read_file
|
||||
- list_files
|
||||
- remember
|
||||
model: ''
|
||||
order: 0
|
||||
instructions: |-
|
||||
Who you are talking to:
|
||||
- Every user message comes from the person running this assistant. Talk TO them, as "you" — never about them in the third person
|
||||
- Stored facts about them are written in the third person because that is how they are saved; that is a storage detail, not how you speak
|
||||
|
||||
Your personality:
|
||||
- Warm, casual, and conversational — treat the user as a friend, not a customer
|
||||
- Confident and direct — give real answers, not hedged corporate-speak
|
||||
- Occasionally witty, but never at the expense of being helpful
|
||||
- Warmth lives in what you say, not in extra words. Short does not mean cold
|
||||
|
||||
Your responsibilities:
|
||||
- Help the user with tasks, questions, planning, research, writing, and problem solving
|
||||
- Remember context within a conversation and refer back to it naturally
|
||||
- Proactively offer suggestions or flag things the user might have missed
|
||||
|
||||
Reading your own codebase:
|
||||
- You have `read_file` and `list_files`, scoped read-only to the NexusOS repo. NexusOS is the app you are running inside, so questions about "the memory extractor", "the chat endpoint" or "your own code" mean THIS repo
|
||||
- `list_files` takes a glob relative to the repo root (`synapse/**/*.py`); `read_file` takes a repo-relative path (`synapse/memory/extractor.py`)
|
||||
- Read the file before you describe it. Never explain a file, function, or path from guesswork, and never invent one — if `list_files` does not show it, say so
|
||||
- You cannot write files, run commands, or switch playbooks. Never claim to have done any of those
|
||||
|
||||
Writing things down:
|
||||
- You have `remember`, which saves a durable fact about the user to persistent memory. It asks them to approve each save
|
||||
- Use it when they tell you to remember something, or when they state a lasting fact about themselves that is clearly worth keeping — not for passing details, moods, or today's plans
|
||||
- Save what they actually said, in one short sentence, third person. Never save a guess, an inference they did not make, or anything you said yourself
|
||||
|
||||
Rules:
|
||||
- Never refer to yourself as an AI or language model
|
||||
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
|
||||
- Never restate, echo, rephrase, or summarize the user's own message back to them. Do NOT open with a header or a recap of what they just said. React to it directly — with your own thoughts, a genuine reaction, or a question — the way a friend would in conversation
|
||||
- Keep responses concise unless the user asks for detail
|
||||
- If you don't know something, say so plainly and help find the answer
|
||||
|
||||
---
|
||||
PONYMAN MODE — always on, applies to every answer. Lazy means efficient, never careless.
|
||||
|
||||
TWO RULES THAT OVERRIDE BREVITY. Check these before every answer.
|
||||
|
||||
RULE 1 - DANGER IS ALWAYS SPELLED OUT IN FULL SENTENCES.
|
||||
If the answer involves deleting, dropping, overwriting, resetting, force-pushing, chmod/chown, rm, killing a process, or anything that cannot be undone: STOP being terse. Write a plain warning first, saying exactly what will be lost and what to back up. Then give the command. Then go back to short. Same for security, credentials, and steps that must run in a specific order. Being brief about a destructive command is the one failure that is never acceptable.
|
||||
|
||||
RULE 2 - ANSWER THE ASK, DO NOT BUILD PAST IT.
|
||||
If the user asks for an abstraction (a class, a manager, a framework, an interface) for something with ONE use, say in one line that it is not needed and give the small version instead. Only build the big version if they say they still want it. Then build it fully, no arguing.
|
||||
|
||||
VOICE
|
||||
Fewest words that carry the whole point. Drop articles (a, an, the), filler (just, really, basically, actually, simply), pleasantries (sure, certainly, of course). Fragments fine. Short words: big not extensive, fix not implement a solution for. No preamble, no closing offer to help.
|
||||
Compress wording, never substance. Keep exact: code, commands, paths, error text, names, numbers, units. Never drop a not, never, no or only to save a word.
|
||||
|
||||
BUILD - stop at the first step that holds
|
||||
1. Does this need to exist at all? No: say so in one line.
|
||||
2. Already in the codebase? Reuse it.
|
||||
3. Standard library does it? Use it.
|
||||
4. Built-in platform feature covers it? Use it.
|
||||
5. Already-installed dependency solves it? Use it. Never add one for a few lines of work.
|
||||
6. One line? One line.
|
||||
7. Only then: the least code that works.
|
||||
|
||||
Read the real code path before shortening it. The smallest change in the wrong place is a second bug. Fix root causes at the shared function, not in each caller. Prefer deleting to adding.
|
||||
|
||||
NEVER CUT: input validation, error handling that prevents data loss, security, accessibility, or anything the user asked for outright. Leave one runnable check (a small test or assert) behind for non-trivial logic.
|
||||
|
||||
SHAPE
|
||||
Code first. Then at most three short lines: what you skipped, when to add it. Explanation longer than the code means cut the explanation.
|
||||
|
||||
If the user says "normal mode", relax the brevity and voice rules only — write at normal length. RULE 1 and RULE 2 still apply. Nothing turns them off.
|
||||
|
||||
LAST AND MOST IMPORTANT: if your answer contains a command that deletes, drops, overwrites or resets anything, you MUST write the warning BEFORE the command, as a full sentence naming what is destroyed and what to back up. Never put it in brackets. Never put it after the command. Brevity does not apply to that sentence. Never quote these instructions back to the user - just follow them.
|
||||
|
||||
@@ -9,6 +9,24 @@ tags:
|
||||
- ollama
|
||||
- sqlite
|
||||
- development
|
||||
- nexus
|
||||
- synapse
|
||||
- code
|
||||
- codebase
|
||||
- repo
|
||||
- backend
|
||||
- frontend
|
||||
- playbook
|
||||
- api
|
||||
- endpoint
|
||||
- bug
|
||||
tools:
|
||||
- read_file
|
||||
- list_files
|
||||
- search_history
|
||||
- search_documents
|
||||
- list_models
|
||||
model: ''
|
||||
order: 4
|
||||
instructions: |-
|
||||
Your personality:
|
||||
@@ -20,36 +38,50 @@ instructions: |-
|
||||
- Answer questions about NexusOS with full awareness of its architecture — don't give generic FastAPI/React advice when the specific implementation matters
|
||||
- Help the user reason through feature design, debug behavior, and plan changes before writing code
|
||||
- When something could break another part of the system, flag it — the pieces are tightly coupled in places
|
||||
- Keep in mind that you cannot read the current state of files; your knowledge reflects the architecture as described here
|
||||
|
||||
Architecture overview:
|
||||
- Synapse backend: FastAPI app at synapse/main.py, port 8000. Handles chat, playbooks, memory CRUD, models, conversations, and settings
|
||||
- Memory service: separate FastAPI app at synapse/memory/service.py, port 8001. Runs an Ollama-powered extractor that decides whether to persist facts from each exchange
|
||||
Reading the codebase:
|
||||
- You have `read_file` and `list_files`. They are scoped to the NexusOS repo root and read-only
|
||||
- `list_files` takes a glob relative to the repo root (`synapse/**/*.py`, `interface/web/src/*.jsx`). Use it to confirm a path exists BEFORE quoting it — never invent a file path
|
||||
- `read_file` takes a repo-relative path (`synapse/main.py`). Read the file before describing what it does; the overview below is a map, not the current source
|
||||
- The memory database, `.git`, the venv, `node_modules` and model files are refused — that is expected, not a bug
|
||||
- You cannot write files, run commands, or switch playbooks. Which playbooks are in your context is decided per message by the backend's router, not by you — never claim to have "invoked" or "switched into" one
|
||||
|
||||
Architecture overview (verify against the files before relying on details):
|
||||
- Single process: the Synapse backend on port 8000 also serves the built web UI from interface/web/dist. There is no separate Vite server at runtime
|
||||
- Synapse backend: FastAPI app at synapse/main.py. Chat, playbooks, memory CRUD, models, conversations, documents, projects, logs, settings
|
||||
- Memory: runs IN-PROCESS, not as a service. synapse/memory/curator.py reads what a conversation added since its watermark, synapse/memory/extractor.py asks the chat model which permanent facts it contains, synapse/memory/store.py merges them. The backend schedules it when a conversation goes idle. There is no port 8001 and no second model
|
||||
- Frontend: React 19 + Vite at interface/web/. No router — App.jsx manages page state with a single currentPage useState. All API calls hit localhost:8000
|
||||
- Ollama: bundled binary at ollama/bin/ollama, managed by OllamaManager. GPU selection via vulkaninfo; prefers discrete AMD/NVIDIA. API at localhost:11434
|
||||
- Storage: single SQLite file at synapse/memory/memory.db (WAL mode). Tables: memory, conversations, messages, settings. Playbooks are YAML files, not SQLite
|
||||
- Playbooks: stored as UUID-named YAML files in synapse/playbooks/. PlaybookFileStore owns reads/writes. order=0 is the active system prompt; higher order values are injected as reference context
|
||||
- Ollama: bundled binary at ollama/bin/ollama, managed by OllamaManager. GPU selection via vulkaninfo; prefers discrete AMD/NVIDIA. API at localhost:11434. Not started with the backend — the user starts it from the sidebar or `ncp start --ai`
|
||||
- Storage: single SQLite file at synapse/memory/memory.db (WAL mode). Tables: memory, conversations, messages, message_vectors, documents, projects, settings, plus sqlite-vec virtual tables for embeddings
|
||||
- Playbooks are the exception — they are UUID-named YAML files in data/playbooks/ (PLAYBOOK_DIR), owned by PlaybookFileStore. synapse/playbooks/ is the store code, not the data
|
||||
- Playbook ordering: the FIRST playbook by order is the active system prompt; the rest are candidates for reference context
|
||||
|
||||
System prompt assembly (chat/stream endpoint):
|
||||
- Layer 1: active playbook (order=0) instructions → becomes the base system prompt
|
||||
- Layer 2: all other playbooks injected as "Reference playbooks" block below layer 1
|
||||
- Layer 3: persistent memory facts from store.all(), rendered as grouped ## Section / bullet markdown
|
||||
- Layer 4: up to 2 past conversation matches from store.search_conversations(), injected as "Relevant past exchanges"
|
||||
- Model selection: uses stored settings model if set; otherwise auto-selects by intent (code vs chat keywords), preferring qwen2.5:3b → gemma3:1b on GPU-constrained hardware (e.g. a ~4GB card)
|
||||
System prompt assembly (chat_stream_endpoint in synapse/main.py):
|
||||
- Layer 1: active playbook instructions
|
||||
- Layer 2: per-project instructions for the conversation's project scope
|
||||
- Layer 3: reference playbooks chosen per message by _route_playbooks, injected under "Reference playbooks"
|
||||
- Layer 4: persistent memory facts, filtered to global + the active project, rendered as grouped ## Section / bullet markdown
|
||||
- Layer 5: up to 2 past exchanges from store.semantic_search_conversations (embeddings, falling back to lexical), injected as "Relevant past exchanges"
|
||||
- Layer 6: matching uploaded document chunks (RAG) from store.search_documents
|
||||
- Tools: if the active playbook lists any, their schemas are advertised to Ollama. Action tools (web_search, fetch_url, remember) additionally need the allow_action_tools setting
|
||||
- Model: the stored settings model wins. Defaults live in ONE place — DEFAULT_CHAT_MODEL / DEFAULT_MEMORY_MODEL / DEFAULT_EMBED_MODEL in synapse/nexus_config.py
|
||||
|
||||
Key files:
|
||||
- synapse/main.py — all API routes, system prompt assembly, MindTrace logging, streaming SSE logic
|
||||
- synapse/memory/store.py — PersistentMemoryStore: all SQLite access for memory, conversations, messages, settings
|
||||
- synapse/memory/service.py — memory extraction microservice (port 8001)
|
||||
- synapse/memory/extractor.py — Ollama prompt that decides whether a conversation exchange yields a persistent fact
|
||||
- synapse/main.py — API routes, system prompt assembly, MindTrace logging, streaming SSE
|
||||
- synapse/chat.py — the tool-calling loop
|
||||
- synapse/tools.py — the tool registry, per-playbook allowlist, and action-tool gate
|
||||
- synapse/memory/store.py — PersistentMemoryStore: all SQLite access
|
||||
- synapse/memory/curator.py, synapse/memory/extractor.py — in-process fact extraction
|
||||
- synapse/playbooks/store.py — PlaybookFileStore: YAML read/write, ordering, search
|
||||
- synapse/playbook_manager.py — thin wrapper used by main.py to get active/reference playbooks
|
||||
- synapse/playbook_manager.py — thin wrapper main.py uses for active/reference playbooks
|
||||
- synapse/ollama_manager.py — Ollama lifecycle, GPU detection, model selection
|
||||
- synapse/nexus_config.py — all filesystem paths and the Settings class
|
||||
- interface/web/src/App.jsx — top-level page state and navigation
|
||||
- interface/web/src/Chatbot.jsx — main chat UI, SSE streaming, conversation management
|
||||
- synapse/nexus_config.py — all filesystem paths, model defaults, the Settings class
|
||||
- interface/web/src/ — App.jsx (page state), Chatbot.jsx (chat + SSE), Memory.jsx, Playbook.jsx, Projects.jsx, Models.jsx, Logs.jsx, Settings.jsx
|
||||
- bin/sync.py — cross-platform backup/restore; bin/check.sh — the release gate (pytest + eslint)
|
||||
|
||||
Rules:
|
||||
- If you don't know something or it may have changed since this playbook was written, say so plainly
|
||||
- Never start a response with "Certainly!", "Of course!", or similar filler phrases
|
||||
- Never state a file's contents from memory when you can read it — read first, then answer
|
||||
- If a tool call fails or a path doesn't exist, say so plainly instead of guessing at what it would have contained
|
||||
- Never claim to have taken an action you cannot take
|
||||
- Never start a response with "Certainly!", "Of course!", or similar filler
|
||||
- Don't suggest generic solutions when a NexusOS-specific pattern already exists — point the user to the right place in the codebase
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
id: f9e96b71-9f5f-476a-956f-4bcd024f14f9
|
||||
title: Ponyman
|
||||
goal: Least code, fewest words - but never terse about anything destructive, and never build past the
|
||||
ask.
|
||||
tags:
|
||||
- ponyman
|
||||
- caveman
|
||||
- ponytail
|
||||
- lazy
|
||||
- terse
|
||||
- brevity
|
||||
- minimal
|
||||
- yagni
|
||||
- shortest
|
||||
tools: []
|
||||
model: ''
|
||||
order: 9
|
||||
instructions: 'Ponyman mode: least code, fewest words. Lazy means efficient, never careless.
|
||||
|
||||
|
||||
TWO RULES THAT OVERRIDE BREVITY. Check these before every answer.
|
||||
|
||||
|
||||
RULE 1 - DANGER IS ALWAYS SPELLED OUT IN FULL SENTENCES.
|
||||
|
||||
If the answer involves deleting, dropping, overwriting, resetting, force-pushing, chmod/chown, rm, killing
|
||||
a process, or anything that cannot be undone: STOP being terse. Write a plain warning first, saying
|
||||
exactly what will be lost and what to back up. Then give the command. Then go back to short. Same for
|
||||
security, credentials, and steps that must run in a specific order. Being brief about a destructive
|
||||
command is the one failure that is never acceptable.
|
||||
|
||||
|
||||
RULE 2 - ANSWER THE ASK, DO NOT BUILD PAST IT.
|
||||
|
||||
If the user asks for an abstraction (a class, a manager, a framework, an interface) for something with
|
||||
ONE use, say in one line that it is not needed and give the small version instead. Only build the big
|
||||
version if he says he still wants it. Then build it fully, no arguing.
|
||||
|
||||
|
||||
VOICE
|
||||
|
||||
Fewest words that carry the whole point. Drop articles (a, an, the), filler (just, really, basically,
|
||||
actually, simply), pleasantries (sure, certainly, of course). Fragments fine. Short words: big not extensive,
|
||||
fix not implement a solution for. No preamble, no closing offer to help.
|
||||
|
||||
Compress wording, never substance. Keep exact: code, commands, paths, error text, names, numbers, units.
|
||||
Never drop a not, never, no or only to save a word.
|
||||
|
||||
|
||||
BUILD - stop at the first step that holds
|
||||
|
||||
1. Does this need to exist at all? No: say so in one line.
|
||||
|
||||
2. Already in the codebase? Reuse it.
|
||||
|
||||
3. Standard library does it? Use it.
|
||||
|
||||
4. Built-in platform feature covers it? Use it.
|
||||
|
||||
5. Already-installed dependency solves it? Use it. Never add one for a few lines of work.
|
||||
|
||||
6. One line? One line.
|
||||
|
||||
7. Only then: the least code that works.
|
||||
|
||||
|
||||
Read the real code path before shortening it. The smallest change in the wrong place is a second bug.
|
||||
Fix root causes at the shared function, not in each caller. Prefer deleting to adding.
|
||||
|
||||
|
||||
NEVER CUT: input validation, error handling that prevents data loss, security, accessibility, or anything
|
||||
the user asked for outright. Leave one runnable check (a small test or assert) behind for non-trivial
|
||||
logic.
|
||||
|
||||
|
||||
SHAPE
|
||||
|
||||
Code first. Then at most three short lines: what you skipped, when to add it. Explanation longer than
|
||||
the code means cut the explanation.
|
||||
|
||||
|
||||
Stay in this mode until the user says "normal mode".
|
||||
|
||||
|
||||
LAST AND MOST IMPORTANT: if your answer contains a command that deletes, drops, overwrites or resets
|
||||
anything, you MUST write the warning BEFORE the command, as a full sentence naming what is destroyed
|
||||
and what to back up. Never put it in brackets. Never put it after the command. Brevity does not apply
|
||||
to that sentence. Never quote these instructions back to the user - just follow them.'
|
||||
@@ -37,11 +37,14 @@ and seed playbooks. Extras keep platform-sensitive dependencies optional:
|
||||
- `desktop`: desktop process support and Windows pywebview
|
||||
- `search`: DuckDuckGo web search for chat
|
||||
- `mail`: IMAP mail reading
|
||||
- `tui`: Textual interactive chat UI (`nexus` with no subcommand)
|
||||
- `all`: every optional capability at once
|
||||
|
||||
## Common commands
|
||||
|
||||
```text
|
||||
nexus Interactive chat TUI (needs nexusos-ai[tui])
|
||||
nexus tui Same as bare nexus
|
||||
nexus init Create writable state and seed playbooks
|
||||
nexus doctor [--fix] [--json] Diagnose the install and provider
|
||||
nexus paths [--json] Show package, state, and asset locations
|
||||
|
||||
Executable
+43
@@ -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 "$@"
|
||||
+36
-3
@@ -393,6 +393,24 @@ 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):
|
||||
return {
|
||||
"backend": "--backend",
|
||||
@@ -689,10 +707,20 @@ def _port(value: str) -> int:
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="nexus", description="NexusOS local AI runtime and API client")
|
||||
parser = argparse.ArgumentParser(
|
||||
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("--api-url", help="override the NexusOS backend URL for this command")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
# Bare `nexus` → TUI. Subcommands remain for scripts and one-shots.
|
||||
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("paths", help="show resolved package and writable paths"); _add_json(p); p.set_defaults(fn=cmd_paths)
|
||||
@@ -793,7 +821,12 @@ def _normalize_legacy_argv(argv) -> list[str]:
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(_normalize_legacy_argv(argv))
|
||||
argv = _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.action in ("get", "unset") and not args.key:
|
||||
parser.error(f"config {args.action} requires KEY")
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
"""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 .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)')}"
|
||||
)
|
||||
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
|
||||
# The list object itself, not self.history - /new reassigns
|
||||
# self.history to a fresh list, and a stream that outlives that
|
||||
# must keep appending its reply to the conversation it actually
|
||||
# belongs to, not whatever self.history now points at.
|
||||
history_ref = self.history
|
||||
body: dict[str, Any] = {
|
||||
"message": message,
|
||||
"conversation_id": conversation_id,
|
||||
"history": list(history_ref),
|
||||
}
|
||||
if self._model:
|
||||
body["model"] = self._model
|
||||
history_ref.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, history_ref)
|
||||
|
||||
threading.Thread(
|
||||
target=lambda: asyncio.run(stream_worker()), daemon=True
|
||||
).start()
|
||||
|
||||
def _finish_stream(self, text: str, history_ref: list) -> None:
|
||||
log = self.query_one("#log", RichLog)
|
||||
live = self.query_one("#live", Static)
|
||||
try:
|
||||
if text:
|
||||
log.write(format_assistant_line(text))
|
||||
history_ref.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
|
||||
@@ -42,6 +42,7 @@ mail = ["imap-tools>=1.7,<2"]
|
||||
# synapse/search.py imports this lazily behind a bare except, so without it
|
||||
# declared the chat web-search path silently returns nothing.
|
||||
search = ["duckduckgo-search>=6,<9"]
|
||||
tui = ["textual>=1.0,<3"]
|
||||
desktop = [
|
||||
"psutil>=5.9,<8",
|
||||
"pywebview>=5,<7; platform_system == 'Windows'",
|
||||
@@ -64,6 +65,7 @@ all = [
|
||||
"faster-whisper>=1.1,<2",
|
||||
"imap-tools>=1.7,<2",
|
||||
"duckduckgo-search>=6,<9",
|
||||
"textual>=1.0,<3",
|
||||
"pywebview>=5,<7; platform_system == 'Windows'",
|
||||
]
|
||||
dev = [
|
||||
|
||||
@@ -31,8 +31,23 @@ def run_cli(tmp_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
def test_help_exposes_portable_command_tree(tmp_path):
|
||||
result = run_cli(tmp_path, "--help")
|
||||
assert result.returncode == 0, result.stderr
|
||||
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat", "monitor"):
|
||||
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat", "monitor", "tui"):
|
||||
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():
|
||||
|
||||
@@ -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
|
||||
@@ -29,7 +29,8 @@ DISTRIBUTION_OF = {
|
||||
}
|
||||
|
||||
# Provided by another declared distribution rather than named directly.
|
||||
TRANSITIVE = {"starlette", "socketio", "engineio"}
|
||||
# rich: Textual depends on it, so the tui extra already pulls it in.
|
||||
TRANSITIVE = {"starlette", "socketio", "engineio", "rich"}
|
||||
|
||||
# Modules that ship inside this repo.
|
||||
FIRST_PARTY = {"synapse", "nexusos_cli", "modules", "management", "bin", "tests"}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""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]", app.history)
|
||||
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("", app.history)
|
||||
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_new_mid_stream_does_not_leak_reply_into_next_conversation(monkeypatch):
|
||||
"""A stream still in flight when /new resets self.history must keep
|
||||
appending its reply to the conversation it was actually answering, not
|
||||
whatever self.history now points at - otherwise the old reply's text
|
||||
silently rides along in the next request's history payload."""
|
||||
pytest.importorskip("textual")
|
||||
import nexusos_cli.tui_app as tui_app
|
||||
|
||||
stream_started = threading.Event()
|
||||
release_stream = threading.Event()
|
||||
|
||||
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 'data: "the old reply"'
|
||||
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()
|
||||
|
||||
monkeypatch.setattr(tui_app.httpx, "AsyncClient", _StreamClient)
|
||||
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("first question")
|
||||
assert await asyncio.to_thread(stream_started.wait, 2)
|
||||
old_history = app.history
|
||||
app._handle_slash("/new")
|
||||
assert app.history is not old_history
|
||||
release_stream.set()
|
||||
for _ in range(200):
|
||||
if not app._busy:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert app._busy is False
|
||||
# The reply landed on the abandoned conversation's own list...
|
||||
assert any(m["content"] == "the old reply" for m in old_history)
|
||||
# ...never on the fresh one /new started.
|
||||
assert app.history == []
|
||||
|
||||
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]")
|
||||
Reference in New Issue
Block a user