From 838860fdf06af47537f0622becbc540eda8d102a Mon Sep 17 00:00:00 2001 From: jon Date: Sat, 11 Jul 2026 07:25:15 -0500 Subject: [PATCH] chore: gitignore hardening, VERSION, CI workflow, tests, docs Co-Authored-By: Claude Opus 4.8 --- .github/agents/ponytail-caveman.agent.md | 43 +++++++++++ .gitignore | 38 +++++----- CLAUDE.md | 67 ++++++++++++++--- VERSION | 1 + launch_nexus.sh | 14 +++- tests/test_smoke.py | 96 ++++++++++++++++++++++++ 6 files changed, 225 insertions(+), 34 deletions(-) create mode 100644 .github/agents/ponytail-caveman.agent.md mode change 100644 => 100755 .gitignore mode change 100644 => 100755 CLAUDE.md create mode 100644 VERSION create mode 100644 tests/test_smoke.py diff --git a/.github/agents/ponytail-caveman.agent.md b/.github/agents/ponytail-caveman.agent.md new file mode 100644 index 0000000..8b36006 --- /dev/null +++ b/.github/agents/ponytail-caveman.agent.md @@ -0,0 +1,43 @@ +--- +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, search, edit, execute, todo] +user-invocable: true +--- +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. diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 9017197..f02ebcd --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,26 @@ -# Large machine-specific / regenerable trees (never commit) -# Promethean/ = 16G ROCm venv (recreate from requirements*.txt) -# ollama/ = 7.4G bundled Ollama binary; models/ = 4.9G Ollama blobs Promethean/ ollama/ -models/ interface/web/node_modules/ - -# Runtime state & user data -runtime/ -data/uploads/ -data/exports/ -*.db -*.db-wal -*.db-shm - -# Build / cache artifacts interface/web/dist/ +runtime/ __pycache__/ *.pyc *.pyo - -# Editor / DE / local cruft +synapse/memory/memory.db +synapse/memory/memory.db-wal +synapse/memory/memory.db-shm +*.db-wal +*.db-shm +.DS_Store .directory -*.bak -*.bak-* -*.tar.gz -# Local-only Claude config (machine-specific) -.claude/settings.local.json +# Ollama model blobs (~15G, regenerable via `ollama pull`) +models/ + +# runtime user data +data/uploads/ +data/exports/ + +# local editor / assistant tooling +.vscode/ +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md old mode 100644 new mode 100755 index 7c52994..7314933 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What is NexusOS -NexusOS is a local AI assistant platform. It runs a Python/FastAPI backend (Synapse) that interfaces with a locally bundled Ollama instance, a dedicated memory microservice, and a React/Vite frontend. Everything runs on `localhost` with no external AI API calls — the LLM is served via Ollama. +NexusOS is a local AI assistant platform. It runs a Python/FastAPI backend (Synapse) that interfaces with a locally bundled Ollama instance, a dedicated memory microservice, and a React/Vite frontend. All AI inference runs through Ollama on `localhost`; no external AI provider is configured or called. ## Running the Project @@ -29,12 +29,24 @@ uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload cd interface/web && npm run dev ``` -**Management CLI** (start/stop individual services with PID tracking): +**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`): ```bash ./management/nexus-cli.sh start # starts backend + frontend ./management/nexus-cli.sh stop -./management/nexus-cli.sh start_backend / start_frontend +./management/nexus-cli.sh start --backend|-b / --frontend|-f / --memory|-m + +# Feature commands (dispatch to management/nexus_api.py — httpx, no TUI): +ncp chat "" # stream a reply (POST /chat/stream) +ncp memory list|add |rm +ncp playbook list|show # first playbook (*) is the active system prompt +ncp history [query] # recent conversations ``` +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. +`management/controlpanel.py` (tkinter GUI, wired into the XFCE panel via +`bin/panel/nexus-popup.py`) stays. **Frontend lint:** ```bash @@ -49,15 +61,17 @@ 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`. The AMD ROCm PyTorch stack is installed here (`requirements.txt`). +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, and a thin overlay pins the right PyTorch build for the target — `requirements-amd.txt` (ROCm), `requirements-nvidia.txt` (CUDA, generated by `bin/gen-nvidia-reqs.py`), or `requirements-wsl.txt` (CPU-only). `bin/install.sh` selects NVIDIA, AMD, or CPU/WSL requirements from the host. ### Synapse Backend (`synapse/`) FastAPI app at `synapse/main.py`. Key responsibilities: -- `/chat` and `/chat/stream` — chat with Ollama; streaming uses SSE. After each exchange the stream endpoint calls the Memory Service to auto-extract persistent facts. -- `/playbooks` — CRUD for playbooks stored in SQLite via `synapse/playbooks/store.py`. +- `/chat/stream` — chat with Ollama; streaming uses SSE (the only chat endpoint — the non-stream `/chat` was removed). After each exchange the stream endpoint calls the Memory Service to auto-extract persistent facts. +- `/playbooks` — CRUD for playbooks stored as YAML files in `data/playbooks/` via `synapse/playbooks/store.py`. - `/memory` — CRUD for persistent facts (proxies the same SQLite store as the memory service). -- `/models` — lists, pulls, and deletes Ollama models by proxying `ollama/bin/ollama`'s HTTP API. -- `/conversations` — persists and retrieves full chat history from SQLite. +- `/models` — lists, pulls, and deletes Ollama models by proxying Ollama's HTTP API. +- `/settings` and `/ollama` — persist runtime settings and control Ollama lifecycle. +- `/conversations` — persists, retrieves, edits, deletes, and exports full chat history from SQLite. +- `/icons` — lists local application icons and applies NexusOS branding. **System prompt assembly** (in `main.py` `chat_stream_endpoint`): the final system prompt is built by layering the active playbook instructions → reference playbook context → persistent memory facts → relevant past conversation snippets retrieved by `store.search_conversations`. @@ -65,7 +79,7 @@ FastAPI app at `synapse/main.py`. Key responsibilities: A separate FastAPI app on port 8001. `service.py` exposes `/memories/extract` which calls `extractor.py` — an Ollama prompt that decides whether to persist a new fact from a conversation exchange. The main Synapse backend calls this asynchronously after each streaming response. Both services share the same SQLite database (`synapse/memory/memory.db`). ### Playbook System (`synapse/playbooks/` + `synapse/playbook_manager.py`) -Playbooks are ordered records (title, goal, instructions, tags) stored in SQLite. The **first** playbook by order is the active system prompt; all subsequent playbooks are injected as reference context. `PlaybookManager` is the thin class the backend uses to retrieve and render them. +Playbooks are ordered records (title, goal, instructions, tags), each persisted as a `{id}.yaml` file in `data/playbooks/` by `PlaybookFileStore` (the dir is `PLAYBOOK_DIR` in `nexus_config.py`). The **first** playbook by order is the active system prompt; all subsequent playbooks are injected as reference context. `PlaybookManager` is the thin class the backend uses to retrieve them and assemble the system prompt. ### Ollama (`ollama/bin/ollama`) A bundled Ollama binary lives at `ollama/bin/ollama`. `OllamaManager` in `synapse/ollama_manager.py` manages its lifecycle (start/stop/health-check) and selects the best available model. GPU detection uses Vulkan (`vulkaninfo`) to prefer discrete AMD/NVIDIA GPUs. The Ollama HTTP API is at `http://127.0.0.1:11434` (overridable via `OLLAMA_HOST` env var). @@ -74,10 +88,10 @@ A bundled Ollama binary lives at `ollama/bin/ollama`. `OllamaManager` in `synaps React 19 + Vite. No routing library — `App.jsx` manages page state in a single `currentPage` useState. All API calls hit `http://localhost:8000` (configured in `src/config.js`). Pages: Chatbot, Playbook editor, Conversation History, Models, Memory, Settings. ### Persistent Storage -All data lands in `synapse/memory/memory.db` (SQLite, WAL mode). Tables: memory facts, conversations, messages, playbooks, app settings. `synapse/memory/store.py` (`PersistentMemoryStore`) owns the schema and all queries. `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. ### Logs & Runtime State -- `runtime/backend.log`, `runtime/frontend.log` — service stdout +- `runtime/backend.log`, `runtime/frontend.log`, `runtime/memory.log` — service stdout - `runtime/logs/ollama.log`, `runtime/logs/chat.log` - `runtime/pids/backend.pid`, `runtime/pids/frontend.pid` — used by the management CLI @@ -88,5 +102,34 @@ All data lands in `synapse/memory/memory.db` (SQLite, WAL mode). Tables: memory | Ollama host | `OLLAMA_HOST` env var (default `http://127.0.0.1:11434`) | | All filesystem paths | `synapse/nexus_config.py` `Settings` class | | Frontend API base URL | `interface/web/src/config.js` | -| Python dependencies (AMD) | `requirements.txt` / `amd_requirements.txt` | | Python dependencies (base) | `requirements-base.txt` | +| Python dependencies (AMD/ROCm) | `requirements-amd.txt` | +| Python dependencies (NVIDIA/CUDA) | `requirements-nvidia.txt` (generated by `bin/gen-nvidia-reqs.py`) | +| Python dependencies (WSL/CPU) | `requirements-wsl.txt` | + +## Cross-Machine Notes + +A shared scratch log so the two Claude Code instances (this NexusOS box and the WSL box) can hand off context. **Protocol for both instances:** only append under *your own* machine's heading — never edit the other's — and prefix every entry with an ISO date, e.g. `- [2026-06-11] …`. Owning-your-own-section keeps the two sides conflict-free, so the file can be merged by plain concatenation whenever it syncs. **Handoff ritual:** when you start a session after a sync, scan the *other* machine's section for items addressed to you (e.g. a bold `**WSL box:**` / `**NexusOS box:**` callout), act on them, and append the outcome — done or still blocked — under your *own* heading with the date. + +> This file does **not** auto-sync between machines; it has to be carried across (see handoff below). The split-by-machine layout is what makes that merge painless. + +### NexusOS box — native Linux · T2 Mac / AMD · XFCE +- [2026-06-11] Started this shared-notes log. +- [2026-06-12] Canonical backup folder is now `router:/tmp/mnt/Wingdrive2/nexus-core` (resolved the old nexus-core/nexus-backup cross-wiring). `bin/backup.sh` now targets `nexus-core` (was `nexus-backup`) so backup and restore use the same folder. +- [2026-06-12] Added `-c | --claude` check mode to `bin/backup.sh` + `bin/restore.sh`: a `--checksum` content-level dry-run that changes nothing and skips the rebuild — use it to decide whether a real backup/restore is actually needed. +- [2026-06-12] Added `--no-owner --no-group` to the real backup/restore rsyncs so they stop re-syncing group metadata on every file against the busybox router (quieter, faster). +- [2026-06-12] Fixed `management/nexus-cli.sh` `ensure_router_reachable` (~line 412): the `nmcli connection up wgs_client` WireGuard fallback dies under WSL (no NetworkManager → "permission denied"). It's now conditional on nmcli + the wgs_client connection existing. **WSL box:** you're a desktop with a direct LAN link to the server, so just make `ssh router` work (Host `router` → 192.168.50.1:9001, user jon, key `id_iWings`, in `~/.ssh/config` — which is NOT synced, set it up there) — then `ncp restore` passes the first check and never hits the VPN fallback. +- [2026-07-10] Over-engineering purge. Removed dead backend endpoints (`POST /chat`, `/memory/{id}/move[_to]`, `/playbook/run`, `/search`) + their orphaned store methods, and the sync chat wrappers. Deleted the two curses TUIs (`nexus-chat.py`, `nexus-playbook.py`, ~1280 lines) and replaced them with API-backed `ncp` subcommands (`chat`/`memory`/`playbook`/`history` → `management/nexus_api.py`). `controlpanel.py` kept (panel GUI). `/conversations/export` kept (ShareGPT export for future fine-tuning). +- [2026-07-10] Nuked the ML + scientific/GUI stack from THIS venv only: torch/transformers/accelerate/bitsandbytes/etc + numpy/scipy/pandas/matplotlib/PySide6 + 53 orphans total. **Promethean 16G → 108M.** Requirements files are UNCHANGED — `pip install -r requirements-amd.txt` restores the declared ML stack. Caveat: some installed extras (peft, datasets, mergekit, gguf, hf-xet) were NOT in any requirements file; if the desktop ML work needs them, add them to requirements first. Rollback freeze snapshot was taken pre-purge. **WSL box:** your venv is separate and untouched; run the same `pip uninstall` sweep if you want the space back (nothing in `synapse/` imports any of it — Ollama does inference over HTTP). + +### WSL box — Windows 11 · WSL2 Ubuntu · DESKTOP-WINGX +- [2026-06-11] `install-windows.ps1` had a PowerShell syntax error at line 73 (`Unexpected token '}'`); fixed brace mismatch in the script. +- [2026-06-11] `$USER` env var unset error at line 103 — PowerShell doesn't inherit Linux env vars; fixed by reading user via `wsl -- bash -c "echo $USER"` with proper escaping. +- [2026-06-11] Promethean venv was not landing inside `nexus-core/` in WSL; root cause was the bootstrap path resolving to a Windows path instead of `\\wsl.localhost\Ubuntu\home\jon\nexus-core`. Fixed by rewriting `bin/wsl-bootstrap.sh` to resolve paths from within WSL. +- [2026-06-11] Added GPU detection script to auto-select `requirements-amd.txt` vs `requirements-nvidia.txt`. +- [2026-06-11] Added `ncp` as a `~/.bashrc` alias and wired a "Promethean Terminal" entry into the Windows right-click context menu. +- [2026-06-11] Python dependency install was failing; traced to pip resolving inside the wrong environment before venv was fully activated. +- [2026-06-11] Added `sudo apt install snapd` + `sudo snap install ollama` to `wsl-bootstrap.sh`; systemd must be enabled in `/etc/wsl.conf` for snap to work — added that check to bootstrap. +- [2026-06-11] `wsl-bootstrap.sh` requires `sudo`; wired `sudo ~/nexus-core/bin/wsl-bootstrap.sh` invocation into `install-windows.ps1`. +- [2026-06-11] Nuked WSL and re-provisioned a fresh Ubuntu instance to validate the full install path. WSL provisioned successfully (user `jon` created) but bootstrap did not fire — timing/invocation issue in the PS script. +- [2026-06-11] Session ended with `ncp restore` returning permission denied on `nexus-cli.sh:412` and WireGuard (`wgs_client`) unable to reach the router. Unresolved at session close. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/launch_nexus.sh b/launch_nexus.sh index 594d632..3d842e1 100755 --- a/launch_nexus.sh +++ b/launch_nexus.sh @@ -47,5 +47,17 @@ FRONTEND_PID=$(_start "NEXUS FRONTEND SERVICE" "$FRONTEND_LOG" "$FRONTEND_DIR" \ sleep 5 _check "NEXUS FRONTEND SERVICE" "$FRONTEND_PID" "$FRONTEND_LOG" -trap "kill $MEMORY_PID $BACKEND_PID $FRONTEND_PID 2>/dev/null; echo ''; echo 'NEXUS STOPPED'; exit" SIGINT SIGTERM +_shutdown() { + echo '' + echo 'Stopping NexusOS...' + # First TERM the processes we directly spawned. + kill "$MEMORY_PID" "$BACKEND_PID" "$FRONTEND_PID" 2>/dev/null + # uvicorn --reload leaves a worker child holding the port and `npm run dev` + # reparents vite, so the direct kills above orphan survivors. Delegate to the + # management CLI's port-based teardown, which reliably clears all three ports. + "$PROJECT_ROOT/management/nexus-cli.sh" stop >/dev/null 2>&1 + echo 'NEXUS STOPPED' + exit +} +trap _shutdown SIGINT SIGTERM SIGHUP wait diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..ba7b0d4 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,96 @@ +"""Smoke test for the Synapse backend's core wiring. + +Run from nexus-core/ with the Promethean venv active: pytest -q + +Deliberately tiny (ponytail): it guards core app wiring and local Ollama +behavior without needing a running Ollama service or network. Not a full suite. +""" +from fastapi.testclient import TestClient +import pytest + +from synapse.main import app +from synapse.memory.store import MemoryItem, PersistentMemoryStore +from synapse.ollama_manager import OllamaManager +from synapse import ollama_manager +from synapse.icons.compositor import _is_allowed_path + + +def test_app_builds_and_root_responds(): + # No `with` → startup event (which boots Ollama) does not run; `ollama` + # stays the module-level None and root() handles it. Pure liveness check. + resp = TestClient(app).get("/") + assert resp.status_code == 200 + assert resp.json()["status"] == "online" + + +def test_keep_alive_pins_the_model(): + # Default keeps the model resident so chats skip cold reloads... + assert PersistentMemoryStore._SETTINGS_DEFAULTS["keep_alive"] == "30m" + mgr = OllamaManager() + assert mgr._apply_keep_alive({"model": "x"}) == {"model": "x", "keep_alive": "30m"} + # ...and an empty value omits the field (falls back to Ollama's default). + mgr.keep_alive = "" + assert "keep_alive" not in mgr._apply_keep_alive({"model": "x"}) + + +def test_memory_store_reads_changes_from_another_instance(tmp_path): + db_path = tmp_path / "memory.db" + writer = PersistentMemoryStore(db_path) + reader = PersistentMemoryStore(db_path) + + writer.add(MemoryItem(id="fresh", text="written by another process")) + assert reader.get("fresh").text == "written by another process" + + +def test_icon_source_requires_real_allowed_file_boundary(tmp_path): + allowed = tmp_path / "icons" + allowed.mkdir() + source = allowed / "app.svg" + source.write_text("") + sibling = allowed.parent / "icons-other" + sibling.mkdir() + (sibling / "app.svg").write_text("") + old_roots = list(__import__("synapse.icons.compositor", fromlist=["_ALLOWED_ROOTS"])._ALLOWED_ROOTS) + module = __import__("synapse.icons.compositor", fromlist=["_ALLOWED_ROOTS"]) + module._ALLOWED_ROOTS[:] = [str(allowed)] + try: + assert _is_allowed_path(str(source)) + assert not _is_allowed_path(str(sibling / "app.svg")) + assert not _is_allowed_path(str(allowed / "missing.svg")) + finally: + module._ALLOWED_ROOTS[:] = old_roots + + +def test_ollama_stream_propagates_transport_errors(monkeypatch): + class FailingResponse: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + def raise_for_status(self): + raise RuntimeError("Ollama unavailable") + + class FailingClient: + def __init__(self, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + def stream(self, *args, **kwargs): + return FailingResponse() + + monkeypatch.setattr(ollama_manager.httpx, "AsyncClient", FailingClient) + + async def consume(): + async for _ in OllamaManager()._chat_stream([], "model", 0): + pass + + with pytest.raises(RuntimeError, match="Ollama unavailable"): + import asyncio + asyncio.run(consume())