Author SHA1 Message Date
Athena 5f67d19e80 fix(packaging): include backend modules in distributions 2026-08-26 08:17:25 -05:00
Athena KaminskyandClaude Opus 5 9104c724c4 feat(packaging): move the CLI into nexusos_cli and make the wheel self-sufficient
The CLI shipped from `management/`, which also holds desktop-only pieces (the
Tk control panel, the XFCE panel wiring, the shell wrappers). Packaging that
directory meant the wheel either dragged in tkinter or shipped a broken import.
Split it: `nexusos_cli/` is what the wheel ships and what `nexus`/`ncp`/
`nexusos` dispatch to, `management/` keeps the desktop half.

Alongside the move:

* hatch_build.py decides the interface/web/dist include at build time. dist/
  is gitignored, so a static force-include aborts `pip install -e .` on a
  fresh clone - before the reader reaches the `npm run build` step. Editable
  installs now skip a missing dist; wheels and sdists hard-error naming the
  command to run.
* synapse/proc_util.py gives frontend_manager and ncp process inspection and
  termination without psutil, which became an optional extra when the wheel
  landed. It routes around Windows having no signals, where os.kill(pid, 15)
  is an unblockable TerminateProcess rather than a polite request.
* nexusos_cli/monitor.py adds `ncp monitor`, an ASCII dashboard with no curses
  or rich dependency so it works in Termux, plain SSH and Windows Terminal.
  Collector and renderer are separate so tests feed fixtures, no stack needed.
* tests/test_packaging_deps.py fails the gate when synapse or nexusos_cli
  import a distribution pyproject does not declare, and when an optional
  dependency is imported at module scope instead of lazily.
* bin/check.sh now builds the wheel, twine-checks it, and asserts the compiled
  UI and seed playbooks are actually inside it. A wheel that builds but ships
  no dist/ serves a blank page, which only shows up after release.

tests/test_nexus_api.py moves to tests/ with the module it covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 08:11:39 -05:00
Athena d579502a5b feat: add portable NexusOS CLI and packaging 2026-08-26 08:11:39 -05:00
Athena 2ebe93b4f7 fix(ollama): normalize hosts, errors, and reasoning output
Separate bind and client addresses, include Ollama's response body in HTTP failures, and strip inline <think> blocks from complete and streamed replies.
2026-08-26 03:32:45 -05:00
Athena d56d579755 fix(memory): sweep legacy orphaned message vectors
Repair vector rows left behind by older databases at store startup. Keep the existing single-statement delete path from main and avoid reintroducing the redundant batched helper.
2026-08-26 03:32:34 -05:00
Athena c3a7b6eefd fix(sync): close SQLite handles before restore
Use contextlib.closing for dump, comparison, and restore connections so Windows can unlink the live database immediately after the comparison step.
2026-08-26 03:32:15 -05:00
janvanwan 42eaed647a feat: sync with upstream — v1.2.0, in-app updates, Projects, modules
Brings the public tree back in line with the development repo after several
weeks of drift caused by a stale publish include list.

New:
- In-app update path: GET /update/check compares the checkout against
  origin/main and POST /update/apply runs `ncp upgrade` detached (pull,
  rebuild, restart). The sidebar shows the version, checks on click, and
  offers an "update available" pill.
- Projects: a project workspace groups chats and RAG documents, with
  per-project instructions and document retrieval scoped to the active
  project. Replaces the standalone Documents page.
- modules/: auto-discovered feature plugins (mail, network) with their
  frontend counterparts and tests.
- Memory curation runs in-process (synapse/memory/curator.py) on the chat
  model when a conversation goes idle. The separate memory service on :8001
  is gone, along with the launcher lines that started it.

Also: the KDE theme, panel and Promethean terminal assets, the full test
suite, and VERSION 1.2.0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-25 09:13:55 -05:00
janvanwanandClaude Opus 5 fe5d18afa7 feat(ui): bulk-clear conversations, fold Start/Stop AI into the status dot
- Clear button beside Export deletes every conversation currently listed. It
  respects the search filter, so it doubles as "delete these matches"; the
  confirm text names which of the two is about to happen. Starts a fresh
  conversation id if the active one was among those deleted.
- The separate Start/Stop AI button is gone. The status dot was already
  colour-coding the same state and sitting right above it, so clicking it now
  toggles Ollama and the tooltip carries the phase text ("loading model…"),
  which is where the eye already is during the wait.
- Active nav/conversation items are marked with a blue left border and
  weighted text instead of a filled background, and the sidebar is wider so
  conversation titles stop truncating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:39:30 -05:00
janvanwanandClaude Opus 5 4bfa979ee8 fix(panel): suppress the nm-applet tray icon like the other replaced applets
network-manager-gnome autostarts its own systray icon from /etc/xdg/autostart,
which duplicates the network genmon (plugin-13) already in the panel. Override
it with a Hidden=true user-level .desktop, the same way blueman-applet and
nm-tray are already handled, and copy it into place from the panel installer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:39:30 -05:00
janvanwanandClaude Opus 5 f46e53de15 feat(branding): restore the boot splash, SDDM theme, and greeter background
Neither the Plymouth theme nor the login/lock theming was ever installed by a
restore, so a rebuilt box came up with stock distro branding.

bin/boot-branding.sh installs all three, and the desktop stage of
restore-linux.sh calls it. Each block self-skips when its greeter is absent or
already current, so it is cheap to re-run and works whether the box boots
lightdm or SDDM:

- Plymouth: a real copy under /usr/share/plymouth/themes, not a symlink into
  the user home. The stock initramfs hook copies the theme path verbatim, so a
  symlink into an unmounted home dangles at early boot and Plymouth drops to
  text mode -- which is what assets/boot/initramfs-hook-my-custom-logo existed
  to work around. With a real copy that hook is unnecessary.
- SDDM: the NexusOS-QML theme, selected via /etc/sddm.conf.d.
- slick-greeter: cannot lay out a custom login screen, so the logo and wordmark
  are baked into assets/themes/greeter/greeter-background.svg and its own
  centered login box lands underneath.

Main.qml now loads background.png instead of background.svg. QtSvg implements
SVG Tiny 1.2, which has no <pattern>, so the brushed-metal and machine-line
textures silently dropped out at runtime. librsvg renders them, so the SVG is
rasterized at 1920x1200 and committed alongside it; the regeneration command is
in the comment above the source: line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:31:11 -05:00
janvanwanandClaude Opus 5 5294ab7c78 feat(playbooks): Ponyman covers both the build method and the voice
Rewritten from a coding-rules-only playbook into the full mode: minimal
solutions AND compressed speech, triggered per conversation by tag.

Two rules lead the prompt because a small local model drops whatever is buried
in the middle. Rule 1: anything destructive gets a full-sentence warning BEFORE
the command, naming what is lost and what to back up - brevity never applies
there. Rule 2: an abstraction asked for with a single use gets a one-line
"not needed" and the small version instead. Rule 1 is repeated at the very end,
which is what made it hold in testing.

Tags carry the trigger words. A reference playbook is injected only when one of
its tag words appears in the message (_route_playbooks), so a playbook with no
matching tag never routes in at all.

Known limits, measured against llama3.1:8b: as a reference playbook only the
voice holds reliably. The build ladder and the warning-before-command ordering
need the playbook promoted to first position, because the chat endpoint appends
memory facts, conversation snippets and documents AFTER the reference block - a
reference playbook cannot own the end of the prompt, so a rule needing recency
weighting cannot get it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:45:47 -05:00
janvanwanandClaude Opus 5 8691a67803 fix(sync,memory,gpu): restorable memory dump, curator grounding, GPU + Models fixes
Ported from downstream development. Four independent defects.

1. The memory dump was unrestorable. iterdump() serializes sqlite_vec virtual
   tables as a raw INSERT INTO sqlite_master(...) followed by inserts into a
   table the replaying connection cannot see, so replaying memory.db.sql died
   on "no such table: vec_messages" and left ZERO tables behind. dump_db() now
   loads the vec0 extension and filters the derived vec tables out of the
   iterdump stream, matched on each statement's target table rather than as a
   substring - a chat message whose text mentions vec_messages is an
   INSERT INTO "messages" and has to survive.

   compare() reported an unreadable dump as "diverged", which read like a real
   verdict and made both guards refuse backup AND restore, locking the machine
   out of syncing in either direction. Unreadable is now its own verdict.

   _extra() compared updated_at against a "" default, but the column is REAL,
   so the comparison raises TypeError on the first conversation the other side
   lacks - exactly the case it counts. It tests membership first now. The
   direction test declared updated_at TEXT, which is why this survived: the
   test compared str to str while the field compared str to float.

2. The memory curator invented facts. It attributed the ASSISTANT's words to
   the user, wrote absence claims read off the existing-memory block, and added
   judgements ("favorite") the user never used. The prompt now scopes the USER
   line as the only source, and two deterministic guards drop absence claims
   and facts whose distinctive tokens appear nowhere in the user's message -
   prompt wording alone did not hold on a 7B curator.

3. _best_vulkan_device scored Mesa's llvmpipe above an integrated GPU, pinning
   Ollama to a software rasterizer advertising 31 GiB of "VRAM" - CPU inference
   with Vulkan overhead on top. Software rasterizers are dropped.

4. Models.jsx compared catalog names to installed names literally, but Ollama
   resolves a bare name to ":latest", so an untagged entry (nomic-embed-text)
   read as missing forever and the Required gate never opened. Chatbot.jsx
   fetched the model list once on mount although App keeps the page mounted
   behind display:none, so a newly pulled model never appeared in the picker
   until a full browser reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:33:43 -05:00
enderofwings b5541d1c48 Merge pull request 'Security: request-size caps, concurrency limits, model-pull allowlist' (#3) from Athena/NexusOS:harden/3-resource-limits into main 2026-08-07 15:52:07 +00:00
125 changed files with 8324 additions and 2057 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+74
View File
@@ -0,0 +1,74 @@
name: package
on:
push:
branches: [main, code-preview]
tags: ["v*"]
pull_request:
jobs:
wheel:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: interface/web/package-lock.json
- name: Build and test web UI
working-directory: interface/web
run: |
npm ci
npm run lint
npm test
npm run build
- uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
- name: Test Python runtime
run: |
python -m pip install -e ".[dev]"
python -m pytest -q tests management
for f in scripts/install-termux.sh bin/check.sh management/nexus-cli.sh; do bash -n "$f"; done
- name: Build wheel and sdist
run: |
python -m build
python -m twine check dist/*
- name: Verify clean wheel install
run: |
python -m venv "$RUNNER_TEMP/nexus-wheel"
"$RUNNER_TEMP/nexus-wheel/bin/python" -m pip install dist/*.whl
cd "$RUNNER_TEMP"
export NEXUS_HOME="$RUNNER_TEMP/nexus-home"
export NEXUS_CONFIG_DIR="$RUNNER_TEMP/nexus-config"
"$RUNNER_TEMP/nexus-wheel/bin/nexus" init --json
"$RUNNER_TEMP/nexus-wheel/bin/nexus" doctor --json
"$RUNNER_TEMP/nexus-wheel/bin/python" -c "from synapse.main import sio_app; assert sio_app"
# The wheel must carry the compiled UI, not just import cleanly.
"$RUNNER_TEMP/nexus-wheel/bin/python" - <<'PY'
from synapse.nexus_config import settings
index = settings.web_dist_dir / "index.html"
assert index.is_file(), f"wheel shipped no web UI at {index}"
PY
- uses: actions/upload-artifact@v4
with:
name: nexusos-python-dist
path: dist/*
- name: Publish tagged release to PyPI
if: startsWith(gitea.ref, 'refs/tags/v')
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: python -m twine upload --non-interactive dist/*
+3
View File
@@ -2,6 +2,9 @@ Promethean/
ollama/
interface/web/node_modules/
interface/web/dist/
/.build-check/
/dist/
/*.egg-info/
runtime/
__pycache__/
*.pyc
+31 -15
View File
@@ -10,20 +10,32 @@ NexusOS is a local AI assistant platform. It runs a Python/FastAPI backend (Syna
NexusOS runs **single-process**: the Synapse backend on port 8000 serves the
built web UI (`interface/web/dist`) itself, so there is no separate Vite server
at runtime. Ollama is started manually (sidebar **Start AI** / `nexus-cli.sh
start --ai`), not on backend startup.
at runtime. Ollama is not started on backend process startup itself, but `ncp
start`/`start -b` bring it up right after via the `/ollama/start` endpoint; the
sidebar **Start/Stop AI** button and `ncp start --ai` remain for toggling it
independently once the backend is already up.
**Windows (recommended):**
```powershell
powershell -ExecutionPolicy Bypass -File .\install-windows.ps1 # one-time native install
ncp web # memory :8001 + backend :8000 + UI
ncp web # backend :8000 + UI
```
`ncp` comes from `management\ncp.cmd`, which the installer puts on the machine
PATH — open a NEW shell after installing. `.\launch_nexus.ps1` is what the
desktop shortcut runs and still works directly.
**Linux install / update:**
```bash
./install.sh # wrapper over `python3 bin/sync.py restore`
./install.sh --check # dry run
./install.sh --no-desktop # skip the XFCE wiring (test clones, non-XFCE boxes)
```
**Linux full stack (dev):**
```bash
./launch_nexus.sh
```
This activates the `Promethean` venv and starts the memory service on port 8001 and the Synapse backend on port 8000 (which also serves the built UI). It additionally starts a **Vite dev server** for frontend hot-reload — a Linux-dev convenience, unlike the single-process Windows/production path where the backend serves `dist/` alone. It does **not** start Ollama.
This activates the `Promethean` venv and starts the Synapse backend on port 8000 (which also serves the built UI). It additionally starts a **Vite dev server** for frontend hot-reload — a Linux-dev convenience, unlike the single-process Windows/production path where the backend serves `dist/` alone. It does **not** start Ollama.
**Individual services via CLI:**
```bash
@@ -31,10 +43,7 @@ This activates the `Promethean` venv and starts the memory service on port 8001
source Promethean/bin/activate
# Backend (serves the built UI at :8000 too)
uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload
# Memory service
uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload
# Frontend DEV server (hot-reload) — only when editing the UI; production is the
# built dist/ served by the backend. Run `npm run build` to refresh dist/.
@@ -48,7 +57,7 @@ access to the same features as the web UI (all via the REST API on `:8000`):
./management/nexus-cli.sh stop
./management/nexus-cli.sh start --backend|-b / --frontend|-f / --memory|-m
# Feature commands (dispatch to management/nexus_api.py — httpx, no TUI):
# 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
@@ -57,12 +66,15 @@ 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.
The CLI itself lives in `nexusos_cli/` (that is what the wheel ships and what
`nexus`/`ncp`/`nexusos` dispatch to); `management/` keeps the desktop-only
pieces — the shell wrappers, the Tk control panel, and the XFCE panel wiring.
`management/controlpanel.py` (tkinter GUI, wired into the XFCE panel via
`bin/panel/nexus-popup.py`) stays.
**Checks (the release gate):**
```bash
./bin/check.sh # pytest (tests/ + management/) + eslint + .ps1 parse check
./bin/check.sh # pytest + eslint + frontend tests + .ps1/.sh parse + wheel build
```
There is no hosted CI — the remote is self-hosted Gitea with no act_runner — so
this script *is* the gate. Run it before tagging a release.
@@ -80,13 +92,15 @@ 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, 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-windows.txt` (CPU-only). `bin/sync.py` (`requirements()`) selects NVIDIA, AMD, or CPU/Windows requirements from the host.
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.
`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.
### Synapse Backend (`synapse/`)
FastAPI app at `synapse/main.py`. Key responsibilities:
- `/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.
- `/chat/stream` — chat with Ollama; streaming uses SSE (the only chat endpoint — the non-stream `/chat` was removed). When a conversation goes idle the stream endpoint hands it to the in-process curator, which extracts 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).
- `/memory` — CRUD for persistent facts, backed by the same SQLite store the curator writes to.
- `/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.
@@ -94,8 +108,8 @@ FastAPI app at `synapse/main.py`. Key responsibilities:
**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`.
### Memory Service (`synapse/memory/`)
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`).
### Memory (`synapse/memory/`)
Runs **in-process** — there is no separate service and no second model. `curator.py` reads the messages a conversation has added since its watermark, `extractor.py` asks the chat model which permanent facts they contain, and `store.py` merges the results into `memory.db`. The backend schedules it when a conversation goes idle (`_pending_extractions` in `main.py`), so a half-said fact is never persisted mid-thought. The old :8001 FastAPI app held a second, smaller model that could not share the GPU with the chat model; the chat model is already resident, so the extra hop bought nothing.
### Playbook System (`synapse/playbooks/` + `synapse/playbook_manager.py`)
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.
@@ -126,3 +140,5 @@ Most data lands in `synapse/memory/memory.db` (SQLite, WAL mode). Tables: memory
| Python dependencies (AMD/ROCm) | `requirements-amd.txt` |
| Python dependencies (NVIDIA/CUDA) | `requirements-nvidia.txt` (generated by `bin/gen-nvidia-reqs.py`) |
| Python dependencies (Windows/CPU) | `requirements-windows.txt` |
| Python dependencies (optional local ML/torch) | `requirements-ml.txt` (opt-in, not installed by default) |
+39 -13
View File
@@ -5,8 +5,10 @@
# NexusOS
**A local-first AI assistant platform.** Runs entirely on your machine — a
Python/FastAPI backend, a bundled Ollama instance for inference, a persistent
memory service, and a React frontend. No external AI provider is called.
Python/FastAPI backend, an Ollama-compatible endpoint for inference, a
persistent memory service, and a React frontend. Ollama is local by default;
Termux and container installs can explicitly point at a separately managed
endpoint.
</div>
@@ -15,10 +17,12 @@ memory service, and a React frontend. No external AI provider is called.
## What it is
NexusOS ("Nexus") is a self-hosted assistant you actually own. All inference
runs through a **locally bundled Ollama** on `localhost`; conversations, facts,
and settings live in local SQLite. It ships with desktop branding (XFCE theme,
runs through **Ollama** on `localhost` by default; conversations, facts, and
settings live in local SQLite. It ships with desktop branding (XFCE theme,
icons, boot splash) so it can be run as a full assistant environment on Linux,
not just a web app.
not just a web app. A remote Ollama-compatible URL is an explicit configuration
option for lightweight clients; NexusOS never starts or stops that remote
process.
Chat with vision and voice, persistent memory, tool-using playbooks, document
RAG scoped to Projects, gated action tools, and full model management — see
@@ -88,6 +92,29 @@ NexusOS runs **single-process**: the backend on `:8000` serves the built web UI
itself, so there's no separate frontend server at runtime. Ollama is started
manually from the app (**Start AI** in the sidebar), not at boot.
### Python package and portable CLI
The portable package installs `nexus`, `ncp`, and `nexusos` as equivalent
commands. From a checkout today:
```bash
cd interface/web && npm ci && npm run build && cd ../.. # compile the UI
python -m pip install -e ".[standard]"
nexus init
nexus doctor
nexus serve
```
After a package release, the install becomes `python -m pip install
"nexusos-ai[standard]"`. The wheel includes the compiled web UI and default
playbooks; it keeps writable state outside `site-packages`. See
[the CLI reference](docs/CLI.md) for commands, configuration, and dependency
profiles.
Termux uses the base package with a remote Ollama-compatible provider. Its
bootstrap and the current Android native-wheel gate are documented in
[the Termux guide](docs/TERMUX.md).
### Linux
Nexus was built using an Apple T2 computer running Linux Mint XFCE. The desktop
@@ -99,7 +126,7 @@ Linux distro, but I only support the Ubuntu/Linux Mint package base.
# system packages, Ollama binary and the XFCE desktop wiring.
./install.sh
# 2. Launch (memory :8001, backend :8000 — backend also serves the built UI)
# 2. Launch (backend :8000 — it also serves the built UI)
# The install symlinks ncp into /usr/local/bin (sudo); open a new shell first.
ncp web
```
@@ -151,8 +178,7 @@ does all inference over HTTP).
# Linux
source Promethean/bin/activate
uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload # backend (serves the UI too)
uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload # memory
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too)
# Frontend dev server (hot-reload) — only needed when editing the UI;
# production serves the built dist/ from the backend at :8000.
@@ -163,8 +189,7 @@ cd interface/web && npm run dev
# Windows — needs the RemoteSigned policy from the Quick start step above
Promethean\Scripts\Activate.ps1
uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload # backend (serves the UI too)
uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload # memory
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too)
```
### Promethean
@@ -216,7 +241,7 @@ ncp help # see complete help tree
|---|---|---|
| **Promethean** (venv) | `Promethean/` | The Python venv all backend code runs in — `source Promethean/bin/activate` (Linux) / `Promethean\Scripts\python.exe` (Windows). Keeps deps out of the system Python. |
| **Synapse** (backend) | `synapse/` | FastAPI app: `/chat/stream` (+ `/chat/approve` for gated tool calls), `/playbooks`, `/memory`, `/models`, `/documents`, `/projects`, `/conversations`, `/stt`, `/logs`, `/settings`, `/ollama`, `/frontend`, `/icons`. Assembles the system prompt: active playbook → reference playbooks → memory facts → relevant past snippets → matching documents → web search results. |
| **Memory service** | `synapse/memory/` | Separate FastAPI app (:8001). `/memories/extract` uses an Ollama prompt to decide what to persist. Shares the SQLite DB with the backend. |
| **Memory** | `synapse/memory/` | In-process, no second service or model: `curator.py` reads a conversation once it goes idle, `extractor.py` asks the chat model what is worth keeping, `store.py` merges it into the SQLite DB. |
| **Documents / RAG** | `synapse/memory/store.py` | PDF/DOCX/TXT/MD ingest, chunked and embedded, retrieved via a sqlite-vec index; scoped per **Project** workspace. |
| **Action tools** | `synapse/tools.py`, `synapse/search.py` | Read-only tools (search memory/history/documents, list models, get time) run automatically; `web_search`, `fetch_url`, and `remember` require per-call approval from the chat UI. |
| **Playbooks** | `synapse/playbooks/` + `data/playbooks/` | Ordered `{id}.yaml` records managed by `PlaybookManager`; each can pin a chat model and a tool list. |
@@ -233,10 +258,11 @@ are the exception (YAML files in `data/playbooks/`). All paths are defined in
## Layout
- `synapse/` — FastAPI backend + memory service + playbook/ollama managers
- `synapse/` — FastAPI backend + memory curator + playbook/ollama managers
- `modules/` — auto-discovered feature plugins
- `interface/web/` — React + Vite frontend
- `management/` — nexus-cli.sh, ncp API client, control panel, desktop theme
- `nexusos_cli/` — the portable CLI the wheel ships (`nexus`/`ncp`/`nexusos`)
- `management/` — nexus-cli.sh wrapper, control panel, desktop theme
- `bin/` — install, backup/restore, panel + provisioning scripts
- `assets/` — branding: icons, boot splash, XFCE/GTK theme
- `data/playbooks/` — active playbook YAML
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.2.0
@@ -7,7 +7,7 @@ X-KDE-ServiceTypes=org.kde.kwin.decoration
[KWin]
Type=Aurorae
X-KDE-PluginInfo-Author=Jon
X-KDE-PluginInfo-Author=NexusOS
X-KDE-PluginInfo-Email=
X-KDE-PluginInfo-Name=NexusOS
X-KDE-PluginInfo-Version=1.0
+167 -8
View File
@@ -12,8 +12,10 @@ KDE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$KDE/../../.." && pwd)"
NO_SDDM=0
FORCE_PANEL=0
for arg in "$@"; do
[[ "$arg" == "--no-sddm" ]] && NO_SDDM=1
[[ "$arg" == "--panel" ]] && FORCE_PANEL=1
done
echo "NexusOS KDE installer — repo: $REPO"
@@ -24,13 +26,11 @@ mkdir -p \
~/.local/share/plasma/desktoptheme \
~/.local/share/color-schemes \
~/.local/share/konsole \
~/.local/share/kscreenlocker/themes \
~/.config/Kvantum
# ── 2. Symlink theme directories (update automatically on git pull) ──────
ln -sfn "$KDE/aurorae/NexusOS" ~/.local/share/aurorae/themes/NexusOS
ln -sfn "$KDE/plasma/NexusOS" ~/.local/share/plasma/desktoptheme/NexusOS
ln -sfn "$KDE/kscreenlocker/NexusOS" ~/.local/share/kscreenlocker/themes/NexusOS
ln -sfn "$KDE/kvantum/NexusOS" ~/.config/Kvantum/NexusOS
echo " [ok] theme symlinks"
@@ -80,10 +80,25 @@ else
echo " [skip] plasma-apply-desktoptheme not found — apply desktop theme manually"
fi
# ── 8. kscreenlocker ─────────────────────────────────────────────────────
# ── 8. Lock screen ───────────────────────────────────────────────────────
# Plasma 5.27 draws the lock screen from a look-and-feel package's
# contents/lockscreen, and [Greeter]Theme names that PACKAGE. The old value here
# ("NexusOS") was not a look-and-feel id, so it silently fell back to Breeze.
# The standalone kscreenlocker theme that value referred to targeted an earlier
# kscreenlocker API that 5.27 no longer loads, and has been deleted.
#
# Breeze's lock UI is deliberately kept rather than replaced: a lock screen that
# fails to load is one you cannot get back through. Only the wallpaper is ours,
# which is what carries the brushed-metal look.
if command -v kwriteconfig5 &>/dev/null; then
kwriteconfig5 --file kscreenlockerrc --group Greeter --key Theme NexusOS
echo " [ok] kscreenlocker theme: NexusOS"
kwriteconfig5 --file kscreenlockerrc --group Greeter --key Theme org.kde.breeze.desktop
LOCK_BG="$KDE/sddm/NexusOS-QML/assets/background.png"
if [[ -f "$LOCK_BG" ]]; then
kwriteconfig5 --file kscreenlockerrc \
--group Greeter --group Wallpaper --group org.kde.image --group General \
--key Image "$LOCK_BG"
echo " [ok] lock screen wallpaper: NexusOS brushed metal"
fi
fi
# ── 9. GTK apps under Plasma ─────────────────────────────────────────────
@@ -106,7 +121,7 @@ if [[ $NO_SDDM -eq 0 ]]; then
sudo cp -r "$KDE/sddm/NexusOS-QML" /usr/share/sddm/themes/
sudo cp "$REPO/management/sessions/nexusos-kde.desktop" /usr/share/xsessions/
if command -v kwriteconfig5 &>/dev/null; then
sudo kwriteconfig5 --file /etc/sddm.conf --group Theme --key Current NexusOS-QML
sudo kwriteconfig5 --file /etc/sddm.conf.d/nexusos.conf --group Theme --key Current NexusOS-QML
else
# Fallback: write the INI directly
sudo bash -c 'printf "[Theme]\nCurrent=NexusOS-QML\n" > /etc/sddm.conf.d/nexusos.conf'
@@ -125,14 +140,158 @@ if [[ -f "$DESKTOP_SRC" ]]; then
echo " [ok] Promethean Terminal launcher updated"
fi
# ── 11b. Global Theme + wallpaper ────────────────────────────────────────
# Copied, NOT symlinked, unlike the theme dirs above: KPackage silently skips a
# symlinked package directory, so a symlinked look-and-feel never appears in
# `lookandfeeltool -l` or System Settings at all. Aurorae/desktoptheme/Kvantum
# read their directories directly and are fine as symlinks.
LNF_SRC="$KDE/look-and-feel/com.nexusos.desktop"
WALL_SRC="$KDE/wallpaper/NexusOS"
if [[ -d "$LNF_SRC" ]]; then
mkdir -p ~/.local/share/plasma/look-and-feel ~/.local/share/wallpapers
rm -rf ~/.local/share/plasma/look-and-feel/com.nexusos.desktop
cp -rL "$LNF_SRC" ~/.local/share/plasma/look-and-feel/
rm -rf ~/.local/share/wallpapers/NexusOS
cp -rL "$WALL_SRC" ~/.local/share/wallpapers/
echo " [ok] Global Theme + wallpaper packages installed"
if command -v lookandfeeltool &>/dev/null; then
# Verify registration before applying. KPackage reports nothing when it
# rejects a package, so without this check a botched install is silent and
# you only find out by opening System Settings.
if lookandfeeltool -l 2>/dev/null | grep -qx "com.nexusos.desktop"; then
lookandfeeltool -a com.nexusos.desktop 2>/dev/null \
&& echo " [ok] Global Theme applied (splash, colors, decoration)"
else
echo " [WARN] com.nexusos.desktop did not register with KPackage."
echo " Most likely it was symlinked instead of copied -- KPackage"
echo " skips symlinked package directories silently."
fi
fi
if command -v plasma-apply-wallpaperimage &>/dev/null; then
plasma-apply-wallpaperimage ~/.local/share/wallpapers/NexusOS &>/dev/null \
&& echo " [ok] wallpaper applied"
fi
fi
# ── 11c. Panel layout + dock ─────────────────────────────────────────────
# The XFCE panel rebuilt with native Plasma widgets, plus Plank for the dock
# (its config and NexusOS theme already exist under assets/themes/restore-snapshot).
#
# Applied ONCE, guarded by a marker file. This rewrites the panel from scratch,
# so re-running it on every restore would wipe any widget you added since. Pass
# --panel to force it, e.g. after a fresh install or to undo panel experiments.
PANEL_MARKER="$HOME/.config/nexusos-panel-applied"
PANEL_JS="$KDE/panel-layout.js"
if [[ -f "$PANEL_JS" ]] && { [[ ! -f "$PANEL_MARKER" ]] || [[ $FORCE_PANEL -eq 1 ]]; }; then
if qdbus org.kde.plasmashell /PlasmaShell evaluateScript \
"$(sed "s|__NEXUS_ROOT__|$REPO|g" "$PANEL_JS")" &>/dev/null; then
touch "$PANEL_MARKER"
echo " [ok] panel layout applied (top, 36px, Nexus launcher)"
else
echo " [skip] panel layout — plasmashell not running"
fi
elif [[ -f "$PANEL_MARKER" ]]; then
echo " [ok] panel layout already applied (--panel to redo)"
fi
# Plank autostart. Plank is a GTK X11 app and runs fine under Plasma; this is
# the same dock, config and theme the XFCE session used.
if command -v plank &>/dev/null; then
mkdir -p ~/.config/autostart
# Symlink, matching bin/panel/install.sh. cp fails outright when the target is
# already a symlink back to this same file ("are the same file"), which made
# the step look like it had silently done nothing.
ln -sfn "$REPO/management/autostart/plank.desktop" ~/.config/autostart/plank.desktop \
&& echo " [ok] Plank autostart installed"
pgrep -x plank &>/dev/null || { setsid plank </dev/null &>/dev/null & disown; }
fi
# ── 11d. Custom shortcut: region screenshot to clipboard ─────────────────
# Meta+Shift+S -> `xfce4-screenshooter -rc`, the equivalent of the XFCE binding.
# Spectacle is not installed by kde-plasma-desktop, and xfce4-screenshooter
# works fine under Plasma, so this keeps the tool that is already here.
#
# Written as ONE atomic replace rather than a series of kwriteconfig5 calls,
# because khotkeys watches khotkeysrc and rewrites it from its own model on
# every change.
#
# The UUID below is FIXED, not generated. kglobalaccel records the key binding
# in kglobalshortcutsrc against the action's UUID, so a fresh UUID on each run
# cannot take a key that the previous run's UUID already holds: the entry gets
# registered with no shortcut at all, and every re-run leaves another dead
# registration behind. A stable UUID means a re-run re-claims its own binding.
if ! grep -q "^Key=Meta+Shift+S$" ~/.config/khotkeysrc 2>/dev/null; then
python3 - <<'PYEOF'
import pathlib, re
# Fixed so re-runs reclaim the same kglobalaccel registration.
SHORTCUT_UUID = "87a4ba1e-4f01-4a79-9cd1-b73c17d459ea"
p = pathlib.Path.home() / ".config" / "khotkeysrc"
s = p.read_text() if p.exists() else "[Data]\nDataCount=0\n"
# Drop any half-written entry from an earlier failed attempt.
for m in re.findall(r"\[Data_(\d+)\]\n(?:[^\[]*)", s):
blk = re.search(r"\[Data_%s\]\n((?:[^\[]*))" % m, s)
if blk and "Screenshot region to clipboard" in blk.group(1):
s = re.sub(r"\n\[Data_%s[^\]]*\]\n(?:[^\[]*)" % m, "\n", s)
count = int(re.search(r"\[Data\]\nDataCount=(\d+)", s).group(1))
n = count + 1
s = re.sub(r"(\[Data\]\nDataCount=)\d+", r"\g<1>%d" % n, s, count=1)
s = s.rstrip("\n") + """
[Data_%d]
Comment=Select a region and copy it straight to the clipboard
Enabled=true
Name=Screenshot region to clipboard
Type=SIMPLE_ACTION_DATA
[Data_%dActions]
ActionsCount=1
[Data_%dActions0]
CommandURL=xfce4-screenshooter -rc
Type=COMMAND_URL
[Data_%dConditions]
Comment=
ConditionsCount=0
[Data_%dTriggers]
Comment=Simple_action
TriggersCount=1
[Data_%dTriggers0]
Key=Meta+Shift+S
Type=SHORTCUT
Uuid={%s}
""" % (n, n, n, n, n, n, SHORTCUT_UUID)
tmp = p.with_suffix(".nexustmp")
tmp.write_text(s)
tmp.replace(p)
PYEOF
qdbus org.kde.kded5 /modules/khotkeys reread_configuration &>/dev/null \
&& echo " [ok] Meta+Shift+S -> region screenshot to clipboard"
else
echo " [ok] screenshot shortcut already bound"
fi
# ── 12. Apply live changes (if Plasma is running) ────────────────────────
if qdbus org.kde.KWin /KWin reconfigure 2>/dev/null; then
echo " [ok] KWin reconfigured"
# Restart plasmashell to pick up new shell theme
# Restart plasmashell to pick up new shell theme.
# setsid + closed stdio is load-bearing, not tidiness: the restarted shell
# outlives this script, and if it inherits our stdout it holds the write end
# of the caller's pipe open forever. `ncp restore` captures output, so a
# plain `kstart5 plasmashell &` hangs the entire restore after the theme is
# already applied -- which looks like a failure and isn't one.
if command -v kquitapp5 &>/dev/null && command -v kstart5 &>/dev/null; then
kquitapp5 plasmashell 2>/dev/null
sleep 1
kstart5 plasmashell &
setsid kstart5 plasmashell </dev/null >/dev/null 2>&1 &
disown 2>/dev/null || true
echo " [ok] plasmashell restarted"
fi
fi
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1200" width="1920" height="1200">
<defs>
<linearGradient id="metalBase" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#1a1a2e;stop-opacity:1"/>
<stop offset="35%" style="stop-color:#16213e;stop-opacity:1"/>
<stop offset="65%" style="stop-color:#0f1626;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#0a0e1a;stop-opacity:1"/>
</linearGradient>
<radialGradient id="vignette" cx="50%" cy="50%" r="70%">
<stop offset="0%" style="stop-color:transparent;stop-opacity:0"/>
<stop offset="100%" style="stop-color:#000000;stop-opacity:0.6"/>
</radialGradient>
<pattern id="brushLines" x="0" y="0" width="4" height="4" patternUnits="userSpaceOnUse">
<line x1="0" y1="4" x2="4" y2="0" stroke="#ffffff" stroke-width="0.3" opacity="0.03"/>
</pattern>
<pattern id="machineLines" x="0" y="0" width="100" height="3" patternUnits="userSpaceOnUse">
<line x1="0" y1="1.5" x2="100" y2="1.5" stroke="#ffffff" stroke-width="0.2" opacity="0.025"/>
</pattern>
</defs>
<rect width="1920" height="1200" fill="url(#metalBase)"/>
<rect width="1920" height="1200" fill="url(#brushLines)"/>
<rect width="1920" height="1200" fill="url(#machineLines)"/>
<ellipse cx="960" cy="340" rx="1200" ry="280" fill="#ffffff" opacity="0.012"/>
<ellipse cx="960" cy="220" rx="400" ry="170" fill="#00d4ff" opacity="0.015"/>
<rect width="1920" height="1200" fill="url(#vignette)"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.kde.org/standards/kcfg/1.0
http://www.kde.org/standards/kcfg/1.0/kcfg.xsd">
<kcfgfile name=""/>
<group name="General">
</group>
</kcfg>
@@ -1,268 +0,0 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import org.kde.plasma.core 2.0 as PlasmaCore
/*
* NexusOS kscreenlocker theme.
* Mirrors the SDDM NexusOS-QML aesthetic: logo upper-center,
* clock/date bottom-center, centered password box.
*
* kscreenlocker API differences from SDDM:
* - authenticator.tryUnlock(password) instead of sddm.login(...)
* - authenticator.failed / authenticator.succeeded signals
* - No session selector, no power buttons
* - walletModel, userModel available but not mandatory
*/
Rectangle {
id: root
// kscreenlocker injects these from the surrounding PlasmaShell context
property bool locked: true
readonly property color accentColor: "#8cc63f" // brand_green
readonly property color bgDark: "#1e1526" // base_bg
readonly property color textPrimary: "#f2f2f2"
readonly property color textMuted: "#a8a8a8"
readonly property color fieldBg: "#1f2225" // surface_bg
readonly property color fieldBorder: "#4d3461" // menu_border
readonly property color errorColor: "#da4453"
anchors.fill: parent
color: bgDark
// ── Background image (reuses SDDM background asset) ────────────────
Image {
anchors.fill: parent
source: Qt.resolvedUrl("../../assets/background.svg")
fillMode: Image.PreserveAspectCrop
smooth: true
asynchronous: false
}
Rectangle {
anchors.fill: parent
color: "#000000"
opacity: 0.30
}
// ── Logo area ───────────────────────────────────────────────────────
Item {
id: logoArea
width: 100
height: 100
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: parent.height * 0.10
Rectangle {
anchors.centerIn: parent
width: parent.width * 1.6
height: parent.height * 1.6
radius: width / 2
color: accentColor
opacity: 0.06
}
Image {
anchors.fill: parent
source: Qt.resolvedUrl("../../assets/logo.png")
sourceSize: Qt.size(200, 200)
smooth: true
}
}
Text {
text: "NexusOS"
color: textPrimary
font.family: "Sans"
font.pixelSize: 26
font.letterSpacing: 6
font.weight: Font.Light
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: logoArea.bottom
anchors.topMargin: 14
}
// ── Password box ────────────────────────────────────────────────────
Rectangle {
id: loginBox
width: 340
height: loginColumn.height + 56
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
color: Qt.rgba(30/255, 21/255, 38/255, 0.80)
radius: 12
border.color: fieldBorder
border.width: 1
SequentialAnimation {
id: shakeAnim
NumberAnimation { target: loginBox; property: "x"; to: loginBox.x - 10; duration: 50 }
NumberAnimation { target: loginBox; property: "x"; to: loginBox.x + 10; duration: 50 }
NumberAnimation { target: loginBox; property: "x"; to: loginBox.x - 6; duration: 50 }
NumberAnimation { target: loginBox; property: "x"; to: loginBox.x + 6; duration: 50 }
NumberAnimation { target: loginBox; property: "x"; to: loginBox.x; duration: 40 }
}
Column {
id: loginColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 28
spacing: 12
// Username label (read-only; kscreenlocker always locks current session)
Text {
width: parent.width
text: kscreenlocker_userName || userModel.data(userModel.index(0, 0), Qt.DisplayRole) || ""
color: textPrimary
font.pixelSize: 14
font.weight: Font.Medium
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
// Password field
Rectangle {
width: parent.width
height: 44
color: fieldBg
radius: 6
border.color: passwordInput.activeFocus ? accentColor : fieldBorder
border.width: 1
TextInput {
id: passwordInput
anchors.fill: parent
anchors.leftMargin: 14
anchors.rightMargin: 14
verticalAlignment: TextInput.AlignVCenter
color: textPrimary
font.pixelSize: 14
echoMode: TextInput.Password
focus: true
clip: true
Keys.onReturnPressed: authenticator.tryUnlock(passwordInput.text)
Keys.onEnterPressed: authenticator.tryUnlock(passwordInput.text)
}
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 14
text: "Password"
color: textMuted
font.pixelSize: 14
visible: passwordInput.text.length === 0 && !passwordInput.activeFocus
}
}
// Error message
Text {
id: errorMsg
width: parent.width
text: ""
color: errorColor
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
visible: text !== ""
}
// Unlock button
Rectangle {
id: unlockButton
width: parent.width
height: 44
radius: 6
color: unlockMouse.pressed
? Qt.darker(accentColor, 1.3)
: unlockMouse.containsMouse
? Qt.lighter(accentColor, 1.15)
: accentColor
Behavior on color { ColorAnimation { duration: 150 } }
Text {
anchors.centerIn: parent
text: "UNLOCK"
color: "#0a0a00" // text_on_accent
font.pixelSize: 14
font.letterSpacing: 3
font.weight: Font.DemiBold
}
MouseArea {
id: unlockMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: authenticator.tryUnlock(passwordInput.text)
}
}
}
}
// ── Authenticator connections ───────────────────────────────────────
Connections {
target: authenticator
function onFailed() {
errorMsg.text = "Incorrect password — try again."
passwordInput.text = ""
passwordInput.forceActiveFocus()
shakeAnim.start()
}
function onSucceeded() {
errorMsg.text = ""
}
function onGraceLockedChanged() {}
function onMessage(msg) {}
function onError(err) {
errorMsg.text = err
}
}
// ── Clock / date (bottom) ───────────────────────────────────────────
Column {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: parent.height * 0.05
spacing: 2
Text {
id: clockText
anchors.horizontalCenter: parent.horizontalCenter
color: textPrimary
font.family: "Sans"
font.pixelSize: 42
font.weight: Font.Thin
}
Text {
id: dateText
anchors.horizontalCenter: parent.horizontalCenter
color: textMuted
font.family: "Sans"
font.pixelSize: 14
font.letterSpacing: 2
}
Timer {
interval: 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: {
var d = new Date()
clockText.text = Qt.formatTime(d, "hh:mm")
dateText.text = Qt.formatDate(d, "dddd, MMMM d")
}
}
}
}
@@ -1,10 +0,0 @@
[Desktop Entry]
Name=NexusOS
Comment=NexusOS lock screen — dark purple, lime-green accents
Type=Service
X-KDE-ServiceTypes=org.kde.kscreenlocker.Greeter
[ScreenLocker]
Name=NexusOS
Description=NexusOS lock screen — dark purple, lime-green accents
MainScript=contents/ui/LockScreenUi.qml
@@ -45,6 +45,30 @@ large.icon.size=32
button.icon.size=16
toolbar.icon.size=16
[GeneralColors]
# Palette. Without this section Kvantum falls back to its light default for
# view backgrounds, which rendered item views (e.g. the Add Bluetooth Device
# device list) as light-grey text on white. Values mirror ~/.config/kdeglobals.
window.color=#1e1526
base.color=#1e1526
alt.base.color=#1f2225
button.color=#2a2e32
light.color=#3a3d41
mid.light.color=#2e3236
dark.color=#140d1a
mid.color=#241a2d
highlight.color=#88008f
inactive.highlight.color=#5e0066
text.color=#f2f2f2
window.text.color=#f2f2f2
button.text.color=#f2f2f2
disabled.text.color=#6e7173
tooltip.text.color=#f2f2f2
highlight.text.color=#ffffff
link.color=#b8e373
link.visited.color=#a232a8
progress.indicator.text.color=#f2f2f2
[PanelButtonCommand]
inherits=PanelButtonTool
frame=false
@@ -0,0 +1,25 @@
[kdeglobals][KDE]
widgetStyle=kvantum-dark
[kdeglobals][General]
ColorScheme=NexusOS
[kdeglobals][Icons]
Theme=NexusOS
[plasmarc][Theme]
name=NexusOS
[Wallpaper]
Image=NexusOS
[kwinrc][org.kde.kdecoration2]
library=org.kde.kwin.aurorae
theme=__aurorae__svg__NexusOS
[kscreenlockerrc][Greeter]
Theme=org.kde.breeze.desktop
[KSplash]
Theme=com.nexusos.desktop
Engine=KSplashQML
@@ -0,0 +1,95 @@
/*
NexusOS Plasma startup splash.
Deliberately plain QtQuick -- no PlasmaCore import. ksplashqml runs this
before the session is up, and the fewer modules it has to resolve at that
point the fewer ways it has to fail; sizes are proportional to the screen
instead of Units.gridUnit for the same reason.
ksplashqml drives `stage` from 0 to 6 as the session comes up, which fills
the progress bar. The content is deliberately opaque from the start rather
than faded in on stage 2 the way Breeze's splash does: if that signal never
arrives -- as under `ksplashqml --test` -- a stage-gated splash shows nothing
but a blank coloured screen. Palette matches assets/themes/_palette.py.
*/
import QtQuick 2.15
Rectangle {
id: root
color: "#1e1526"
property int stage
onStageChanged: {
if (stage == 6) {
outroAnimation.running = true;
}
}
// Same brushed-metal ground as the Plymouth, SDDM and lock screens. Raster,
// not the source SVG: QtSvg is Tiny 1.2 and drops the <pattern> textures.
Image {
anchors.fill: parent
source: "images/background.png"
fillMode: Image.PreserveAspectCrop
smooth: true
asynchronous: false
}
Item {
id: content
anchors.fill: parent
Image {
id: logo
anchors.horizontalCenter: parent.horizontalCenter
y: parent.height * 0.32
source: "images/logo.png"
width: Math.round(parent.width * 0.15)
fillMode: Image.PreserveAspectFit
smooth: true
asynchronous: false
}
Text {
id: brand
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: logo.bottom
anchors.topMargin: Math.round(parent.height * 0.03)
text: "NexusOS"
color: "#f2f2f2"
font.family: "Sans"
font.pixelSize: Math.round(parent.height * 0.026)
font.letterSpacing: 6
font.weight: Font.Light
}
Rectangle {
id: track
anchors.horizontalCenter: parent.horizontalCenter
y: Math.round(parent.height * 0.62)
width: Math.round(parent.width * 0.18)
height: 3
radius: height / 2
color: "#2e3236"
Rectangle {
height: parent.height
radius: parent.radius
color: "#8cc63f"
width: parent.width * Math.min(root.stage / 6, 1)
Behavior on width {
NumberAnimation { duration: 250; easing.type: Easing.InOutQuad }
}
}
}
}
OpacityAnimator {
id: outroAnimation
target: content
from: 1; to: 0
duration: 400
running: false
}
}
@@ -0,0 +1 @@
../../../../../sddm/NexusOS-QML/assets/background.png

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 185 KiB

@@ -0,0 +1,22 @@
{
"KPlugin": {
"Authors": [
{
"Name": "NexusOS"
}
],
"Category": "",
"Description": "Dark purple NexusOS desktop with lime-green accents",
"Id": "com.nexusos.desktop",
"License": "GPL",
"Name": "NexusOS",
"ServiceTypes": [
"Plasma/LookAndFeel"
],
"Version": "1.0",
"Website": ""
},
"X-Plasma-APIVersion": "2",
"X-Plasma-MainScript": "defaults",
"KPackageStructure": "Plasma/LookAndFeel"
}
+63
View File
@@ -0,0 +1,63 @@
/*
NexusOS Plasma panel layout — the XFCE panel, rebuilt with native widgets.
Applied through Plasma's scripting API rather than by editing
plasma-org.kde.plasma.desktop-appletsrc directly: the file is owned by a
running plasmashell, which rewrites it from memory on exit and would
clobber anything written underneath it. Run with:
qdbus org.kde.plasmashell /PlasmaShell evaluateScript "$(cat panel-layout.js)"
Source layout is assets/themes/restore-snapshot/xfconf-xml/xfce4-panel.xml:
a 36px full-width top panel, Whisker menu at the far left with the Nexus N
as its button, everything else pushed to the right — tray, volume,
bluetooth, network, battery, clock.
The XFCE panel carried no window list; Plank showed running apps. So this
adds no task manager either, and the dock keeps that job.
Widget mapping from the XFCE plugins:
whiskermenu -> org.kde.plasma.kickoff (same N button icon)
separator (expand) -> org.kde.plasma.panelspacer
systray + notify -> org.kde.plasma.systemtray (notifications live in it)
genmon network -> networkmanagement, inside the tray
genmon bluetooth -> bluetooth, inside the tray
pulseaudio -> volume, inside the tray
power-manager -> battery, inside the tray
clock -> org.kde.plasma.digitalclock
genmon nexus -> not a panel widget; the dock keeps nexus-core
*/
var panel = panelById(panelIds[0]);
if (!panel) {
panel = new Panel;
}
panel.location = "top";
panel.height = 36; // xfce4-panel.xml: size=36
panel.alignment = "left";
panel.hiding = "none";
// Start from a known state so re-running is idempotent rather than additive.
var existing = panel.widgetIds;
for (var i = 0; i < existing.length; i++) {
var w = panel.widgetById(existing[i]);
if (w) { w.remove(); }
}
var launcher = panel.addWidget("org.kde.plasma.kickoff");
launcher.currentConfigGroup = ["General"];
// __NEXUS_ROOT__ is substituted by install-plasma.sh so a clone in another
// directory, or another user, gets a working path rather than this box's.
launcher.writeConfig("icon", "__NEXUS_ROOT__/assets/n-small.png");
launcher.writeConfig("favoritesPortedToKAstats", true);
panel.addWidget("org.kde.plasma.panelspacer");
var tray = panel.addWidget("org.kde.plasma.systemtray");
var clock = panel.addWidget("org.kde.plasma.digitalclock");
clock.currentConfigGroup = ["Appearance"];
clock.writeConfig("showDate", true);
panel.reloadConfig();
@@ -6,7 +6,7 @@ Type=Service
[KPackageStructure]
X-KDE-ServiceTypes=Plasma/Theme
X-KDE-PluginInfo-Author=Jon
X-KDE-PluginInfo-Author=NexusOS
X-KDE-PluginInfo-Email=
X-KDE-PluginInfo-Name=NexusOS
X-KDE-PluginInfo-Version=1.0
+11 -2
View File
@@ -31,7 +31,16 @@ Rectangle {
Image {
anchors.fill: parent
source: "assets/background.svg"
// background.png, not the .svg it was rendered from: QtSvg implements
// SVG Tiny 1.2, which has no <pattern>, so the brushed-metal and
// machine-line textures silently drop out ("Could not resolve property:
// #brushLines"). librsvg renders them fine, so the SVG is rasterized at
// 1920x1200 and committed. Regenerate after editing background.svg:
// python3 -c 'import gi; gi.require_version("GdkPixbuf","2.0"); \
// from gi.repository import GdkPixbuf as G; \
// G.Pixbuf.new_from_file_at_scale("assets/background.svg",1920,1200,False) \
// .savev("assets/background.png","png",["compression"],["9"])'
source: "assets/background.png"
fillMode: Image.PreserveAspectCrop
smooth: true
asynchronous: false
@@ -71,7 +80,7 @@ Rectangle {
Text {
id: brandLabel
text: "NexusOS"
text: "Welcome to NexusOS"
color: textPrimary
font.family: "Sans"
font.pixelSize: 28
Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

@@ -1,7 +1,7 @@
[SddmGreeterTheme]
Name=NexusOS-QML
Description=Dark brushed-metal lockscreen theme with flat N logo, upper-center branding, and centered login box.
Author=Jon
Author=NexusOS
Version=1.0
Website=
Screenshot=
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

@@ -0,0 +1,10 @@
{
"KPlugin": {
"Authors": [{ "Name": "NexusOS" }],
"Id": "NexusOS",
"License": "GPL",
"Name": "NexusOS",
"Version": "1.0"
},
"X-Plasma-APIVersion": "2"
}
+73 -655
View File
@@ -1,702 +1,120 @@
[Icon Theme]
Name=NexusOS
Comment=NexusOS accent icon theme
Inherits=Papirus-Dark,hicolor
Directories=16x16/apps,22x22/apps,24x24/apps,32x32/apps,48x48/apps,64x64/apps,128x128/apps,16x16/places,22x22/places,24x24/places,32x32/places,48x48/places,64x64/places,128x128/places,16x16/actions,22x22/actions,24x24/actions,32x32/actions,48x48/actions,64x64/actions,128x128/actions,scalable/status,8x8/emblems,16x16/devices,16x16/emblems,16x16/emotes,16x16/mimetypes,16x16/panel,16x16/status,16x16@2x/actions,16x16@2x/apps,16x16@2x/devices,16x16@2x/emblems,16x16@2x/emotes,16x16@2x/mimetypes,16x16@2x/panel,16x16@2x/places,16x16@2x/status,18x18/actions,18x18@2x/actions,22x22/animations,22x22/devices,22x22/emblems,22x22/emotes,22x22/mimetypes,22x22/panel,22x22/status,22x22@2x/actions,22x22@2x/animations,22x22@2x/apps,22x22@2x/devices,22x22@2x/emblems,22x22@2x/emotes,22x22@2x/mimetypes,22x22@2x/panel,22x22@2x/places,22x22@2x/status,24x24/animations,24x24/devices,24x24/emblems,24x24/emotes,24x24/mimetypes,24x24/panel,24x24/status,24x24@2x/actions,24x24@2x/animations,24x24@2x/apps,24x24@2x/devices,24x24@2x/emblems,24x24@2x/emotes,24x24@2x/mimetypes,24x24@2x/panel,24x24@2x/places,24x24@2x/status,32x32/devices,32x32/emblems,32x32/emotes,32x32/mimetypes,32x32/status,32x32@2x/actions,32x32@2x/apps,32x32@2x/devices,32x32@2x/emblems,32x32@2x/emotes,32x32@2x/mimetypes,32x32@2x/places,32x32@2x/status,42x42/apps,48x48/devices,48x48/emblems,48x48/emotes,48x48/mimetypes,48x48/status,48x48@2x/actions,48x48@2x/apps,48x48@2x/devices,48x48@2x/emblems,48x48@2x/emotes,48x48@2x/mimetypes,48x48@2x/places,48x48@2x/status,64x64/devices,64x64/mimetypes,64x64@2x/apps,64x64@2x/devices,64x64@2x/mimetypes,64x64@2x/places,84x84/apps,96x96/apps,96x96/devices,96x96/mimetypes,96x96/places,128x128/devices,128x128/mimetypes,symbolic/actions,symbolic/apps,symbolic/devices,symbolic/emblems,symbolic/emotes,symbolic/mimetypes,symbolic/places,symbolic/status,symbolic/up-to-32
# Inherit breeze-dark, not Papirus-Dark. Breeze's icons carry
# ColorScheme-Text / ColorScheme-Highlight classes that Plasma recolours from
# the active colour scheme, so everything this theme does not override picks up
# the NexusOS palette on its own. Papirus hardcodes its blues (#5294e2), which
# no colour scheme can touch — inheriting it left the whole un-themed surface
# permanently blue. hicolor last, as the freedesktop fallback of last resort.
Inherits=breeze-dark,hicolor
# Only directories that actually exist. This list previously declared 124
# sections against 21 real directories, most of them copied from Papirus.
Directories=16x16/apps,16x16/places,16x16/actions,22x22/apps,22x22/places,22x22/actions,24x24/apps,24x24/places,24x24/actions,32x32/apps,32x32/places,32x32/actions,48x48/apps,48x48/places,48x48/actions,64x64/apps,64x64/places,64x64/actions,128x128/apps,128x128/places,128x128/actions
[16x16/apps]
Context=Applications
Size=16
Type=Fixed
[16x16/places]
Context=Places
Size=16
Type=Fixed
[16x16/actions]
Context=Actions
Size=16
Type=Fixed
[22x22/apps]
Context=Applications
Size=22
Type=Fixed
[22x22/places]
Context=Places
Size=22
Type=Fixed
[22x22/actions]
Context=Actions
Size=22
Type=Fixed
[24x24/apps]
Context=Applications
Size=24
Type=Fixed
[24x24/places]
Context=Places
Size=24
Type=Fixed
[24x24/actions]
Context=Actions
Size=24
Type=Fixed
[32x32/apps]
Context=Applications
Size=32
Type=Fixed
[32x32/places]
Context=Places
Size=32
Type=Fixed
[32x32/actions]
Context=Actions
Size=32
Type=Fixed
[48x48/apps]
Context=Applications
Size=48
Type=Fixed
[48x48/places]
Context=Places
Size=48
Type=Fixed
[48x48/actions]
Context=Actions
Size=48
Type=Fixed
[64x64/apps]
Context=Applications
Size=64
Type=Fixed
[128x128/apps]
Context=Applications
Size=128
MinSize=128
MaxSize=512
Type=Scalable
[16x16/places]
Context=Places
Size=16
Type=Fixed
[22x22/places]
Context=Places
Size=22
Type=Fixed
[24x24/places]
Context=Places
Size=24
Type=Fixed
[32x32/places]
Context=Places
Size=32
Type=Fixed
[48x48/places]
Context=Places
Size=48
Type=Fixed
[64x64/places]
Context=Places
Size=64
Type=Fixed
[64x64/actions]
Context=Actions
Size=64
Type=Fixed
[128x128/apps]
Context=Applications
Size=128
Type=Fixed
[128x128/places]
Context=Places
Size=128
MinSize=128
MaxSize=512
Type=Scalable
[16x16/actions]
Context=Actions
Size=16
Type=Fixed
[22x22/actions]
Context=Actions
Size=22
Type=Fixed
[24x24/actions]
Context=Actions
Size=24
Type=Fixed
[32x32/actions]
Context=Actions
Size=32
Type=Fixed
[48x48/actions]
Context=Actions
Size=48
Type=Fixed
[64x64/actions]
Size=64
Context=Actions
Type=Fixed
[128x128/actions]
Context=Actions
Size=128
Context=Actions
Type=Fixed
[scalable/status]
Size=16
Context=Status
MinSize=8
MaxSize=512
Type=Scalable
[8x8/emblems]
Context=Emblems
Size=8
Type=Fixed
[16x16/devices]
Context=Devices
Size=16
Type=Fixed
[16x16/emblems]
Context=Emblems
Size=16
Type=Fixed
[16x16/emotes]
Context=Emotes
Size=16
Type=Fixed
[16x16/mimetypes]
Context=MimeTypes
Size=16
Type=Fixed
[16x16/panel]
Context=Status
Size=16
Type=Fixed
[16x16/status]
Context=Status
Size=16
Type=Fixed
[16x16@2x/actions]
Context=Actions
Size=16
Scale=2
Type=Fixed
[16x16@2x/apps]
Context=Applications
Size=16
Scale=2
Type=Fixed
[16x16@2x/devices]
Context=Devices
Size=16
Scale=2
Type=Fixed
[16x16@2x/emblems]
Context=Emblems
Size=16
Scale=2
Type=Fixed
[16x16@2x/emotes]
Context=Emotes
Size=16
Scale=2
Type=Fixed
[16x16@2x/mimetypes]
Context=MimeTypes
Size=16
Scale=2
Type=Fixed
[16x16@2x/panel]
Context=Status
Size=16
Scale=2
Type=Fixed
[16x16@2x/places]
Context=Places
Size=16
Scale=2
Type=Fixed
[16x16@2x/status]
Context=Status
Size=16
Scale=2
Type=Fixed
[18x18/actions]
Context=Actions
Size=18
Type=Fixed
[18x18@2x/actions]
Context=Actions
Size=18
Scale=2
Type=Fixed
[22x22/animations]
Context=Animations
Size=22
Type=Fixed
[22x22/devices]
Context=Devices
Size=22
Type=Fixed
[22x22/emblems]
Context=Emblems
Size=22
Type=Fixed
[22x22/emotes]
Context=Emotes
Size=22
Type=Fixed
[22x22/mimetypes]
Context=MimeTypes
Size=22
Type=Fixed
[22x22/panel]
Context=Status
Size=22
Type=Fixed
[22x22/status]
Context=Status
Size=22
Type=Fixed
[22x22@2x/actions]
Context=Actions
Size=22
Scale=2
Type=Fixed
[22x22@2x/animations]
Context=Animations
Size=22
Scale=2
Type=Fixed
[22x22@2x/apps]
Context=Applications
Size=22
Scale=2
Type=Fixed
[22x22@2x/devices]
Context=Devices
Size=22
Scale=2
Type=Fixed
[22x22@2x/emblems]
Context=Emblems
Size=22
Scale=2
Type=Fixed
[22x22@2x/emotes]
Context=Emotes
Size=22
Scale=2
Type=Fixed
[22x22@2x/mimetypes]
Context=MimeTypes
Size=22
Scale=2
Type=Fixed
[22x22@2x/panel]
Context=Status
Size=22
Scale=2
Type=Fixed
[22x22@2x/places]
Context=Places
Size=22
Scale=2
Type=Fixed
[22x22@2x/status]
Context=Status
Size=22
Scale=2
Type=Fixed
[24x24/animations]
Context=Animations
Size=24
Type=Fixed
[24x24/devices]
Context=Devices
Size=24
Type=Fixed
[24x24/emblems]
Context=Emblems
Size=24
Type=Fixed
[24x24/emotes]
Context=Emotes
Size=24
Type=Fixed
[24x24/mimetypes]
Context=MimeTypes
Size=24
Type=Fixed
[24x24/panel]
Context=Status
Size=24
Type=Fixed
[24x24/status]
Context=Status
Size=24
Type=Fixed
[24x24@2x/actions]
Context=Actions
Size=24
Scale=2
Type=Fixed
[24x24@2x/animations]
Context=Animations
Size=24
Scale=2
Type=Fixed
[24x24@2x/apps]
Context=Applications
Size=24
Scale=2
Type=Fixed
[24x24@2x/devices]
Context=Devices
Size=24
Scale=2
Type=Fixed
[24x24@2x/emblems]
Context=Emblems
Size=24
Scale=2
Type=Fixed
[24x24@2x/emotes]
Context=Emotes
Size=24
Scale=2
Type=Fixed
[24x24@2x/mimetypes]
Context=MimeTypes
Size=24
Scale=2
Type=Fixed
[24x24@2x/panel]
Context=Status
Size=24
Scale=2
Type=Fixed
[24x24@2x/places]
Context=Places
Size=24
Scale=2
Type=Fixed
[24x24@2x/status]
Context=Status
Size=24
Scale=2
Type=Fixed
[32x32/devices]
Context=Devices
Size=32
Type=Fixed
[32x32/emblems]
Context=Emblems
Size=32
Type=Fixed
[32x32/emotes]
Context=Emotes
Size=32
Type=Fixed
[32x32/mimetypes]
Context=MimeTypes
Size=32
Type=Fixed
[32x32/status]
Context=Status
Size=32
Type=Fixed
[32x32@2x/actions]
Context=Actions
Size=32
Scale=2
Type=Fixed
[32x32@2x/apps]
Context=Applications
Size=32
Scale=2
Type=Fixed
[32x32@2x/devices]
Context=Devices
Size=32
Scale=2
Type=Fixed
[32x32@2x/emblems]
Context=Emblems
Size=32
Scale=2
Type=Fixed
[32x32@2x/emotes]
Context=Emotes
Size=32
Scale=2
Type=Fixed
[32x32@2x/mimetypes]
Context=MimeTypes
Size=32
Scale=2
Type=Fixed
[32x32@2x/places]
Context=Places
Size=32
Scale=2
Type=Fixed
[32x32@2x/status]
Context=Status
Size=32
Scale=2
Type=Fixed
[42x42/apps]
Context=Applications
Size=42
Type=Fixed
[48x48/devices]
Context=Devices
Size=48
Type=Fixed
[48x48/emblems]
Context=Emblems
Size=48
Type=Fixed
[48x48/emotes]
Context=Emotes
Size=48
Type=Fixed
[48x48/mimetypes]
Context=MimeTypes
Size=48
Type=Fixed
[48x48/status]
Context=Status
Size=48
MinSize=48
MaxSize=512
Type=Scalable
[48x48@2x/actions]
Context=Actions
Size=48
Scale=2
Type=Fixed
[48x48@2x/apps]
Context=Applications
Size=48
Scale=2
Type=Fixed
[48x48@2x/devices]
Context=Devices
Size=48
Scale=2
Type=Fixed
[48x48@2x/emblems]
Context=Emblems
Size=48
Scale=2
Type=Fixed
[48x48@2x/emotes]
Context=Emotes
Size=48
Scale=2
Type=Fixed
[48x48@2x/mimetypes]
Context=MimeTypes
Size=48
Scale=2
Type=Fixed
[48x48@2x/places]
Context=Places
Size=48
Scale=2
Type=Fixed
[48x48@2x/status]
Context=Status
Size=48
MinSize=48
MaxSize=512
Scale=2
Type=Scalable
[64x64/devices]
Context=Devices
Size=64
Type=Fixed
[64x64/mimetypes]
Context=MimeTypes
Size=64
Type=Fixed
[64x64@2x/apps]
Context=Applications
Size=64
Scale=2
Type=Fixed
[64x64@2x/devices]
Context=Devices
Size=64
Scale=2
Type=Fixed
[64x64@2x/mimetypes]
Context=MimeTypes
Size=64
Scale=2
Type=Fixed
[64x64@2x/places]
Context=Places
Size=64
Scale=2
Type=Fixed
[84x84/apps]
Context=Applications
Size=84
Type=Fixed
[96x96/apps]
Context=Applications
Size=96
Type=Fixed
[96x96/devices]
Context=Devices
Size=96
Type=Fixed
[96x96/mimetypes]
Context=MimeTypes
Size=96
Type=Fixed
[96x96/places]
Context=Places
Size=96
Type=Fixed
[128x128/devices]
Context=Devices
Size=128
MinSize=128
MaxSize=512
Type=Scalable
[128x128/mimetypes]
Context=MimeTypes
Size=128
MinSize=128
MaxSize=512
Type=Scalable
[symbolic/actions]
Context=Actions
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/apps]
Context=Applications
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/devices]
Context=Devices
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/emblems]
Context=Emblems
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/emotes]
Context=Emotes
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/mimetypes]
Context=MimeTypes
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/places]
Context=Places
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/status]
Context=Status
Size=16
MinSize=16
MaxSize=512
Type=Scalable
[symbolic/up-to-32]
Context=Status
Size=16
MinSize=16
MaxSize=32
Type=Scalable
+11 -3
View File
@@ -112,8 +112,11 @@ KDE/
Main.qml # QML login UI — NexusOS purple/green palette
assets/background.svg assets/logo.png
kscreenlocker/NexusOS/ # Runtime screen lock (Meta+L in KDE)
contents/ui/LockScreenUi.qml # matches SDDM aesthetic; kscreenlocker API
look-and-feel/com.nexusos.desktop/ # Global Theme: selects every component
contents/defaults # what System Settings applies in one click
contents/splash/Splash.qml # startup splash, matches Plymouth/SDDM
wallpaper/NexusOS/ # desktop + lock wallpaper (brushed metal)
konsole/
NexusOS.colorscheme # general terminal colors
@@ -146,7 +149,12 @@ cp assets/themes/KDE/konsole/*.colorscheme assets/themes/KDE/konsole/Promethean.
# SDDM palette (already live; installer will redeploy if needed)
```
**Needs KDE session:** Aurorae decoration, Plasma shell theme, kscreenlocker.
**Needs KDE session:** Aurorae decoration, Plasma shell theme, Global Theme.
The lock screen is Breeze's, with the NexusOS wallpaper set on it. Plasma 5.27
draws the lock screen from a look-and-feel package's `contents/lockscreen`, and
replacing that QML risks a lock screen you cannot get back through, so only the
background is ours.
### First boot into Plasma
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 249 KiB

+9 -1
View File
@@ -74,7 +74,12 @@ echo "NexusOS theme — restoring wiring from $REPO"
echo "[1/4] Symlinks"
link "$REPO/assets/themes/NexusOS" "$HOME/.themes/NexusOS"
# Icons go in BOTH places on purpose. ~/.icons is the GTK/XFCE legacy path and
# is all this used to install; Qt/KF5 searches XDG data dirs only, so under
# Plasma the theme was never found and every icon silently fell back to Breeze
# (`kiconfinder5 nexusos-logo` returned nothing at all).
link "$REPO/assets/themes/NexusOS-icons" "$HOME/.icons/NexusOS"
link "$REPO/assets/themes/NexusOS-icons" "$HOME/.local/share/icons/NexusOS"
link "$REPO/assets/themes/gtk3-user-overrides.css" "$HOME/.config/gtk-3.0/gtk.css"
echo "[2/4] xfconf (xsettings + xfwm4)"
@@ -98,7 +103,10 @@ echo "[4/4] Icon caches"
gtk-update-icon-cache -f -t "$HOME/.icons/NexusOS" 2>/dev/null \
&& say "ok NexusOS icon cache rebuilt" \
|| say "WARN NexusOS icon cache rebuild failed"
INH="$(grep -i '^Inherits=' "$REPO/assets/themes/NexusOS-icons/index.theme" 2>/dev/null | cut -d= -f2)"
# Inherits is a comma-separated list, so check the first (primary) parent
# rather than the whole string — treating "breeze-dark,hicolor" as one theme
# name found no such directory and warned about a fallback that is installed.
INH="$(grep -i '^Inherits=' "$REPO/assets/themes/NexusOS-icons/index.theme" 2>/dev/null | cut -d= -f2 | cut -d, -f1)"
if [[ -n "$INH" ]]; then
INH_OK=0
for dir in /usr/share/icons ~/.icons ~/.local/share/icons; do
+39
View File
@@ -0,0 +1,39 @@
# NexusOS dock theme — matches the XFCE top panel.
# Colors are taken straight from xfce4-panel's background-rgba
# (0.588, 0.152, 0.773, 0.294) scaled to 0-255: 150;;39;;197;;75.
# Flat fill (start == end) because the panel has no gradient.
[PlankTheme]
TopRoundness=10
BottomRoundness=10
LineWidth=1
OuterStrokeColor=150;;39;;197;;110
FillStartColor=150;;39;;197;;75
FillEndColor=150;;39;;197;;75
InnerStrokeColor=150;;39;;197;;40
# Padding is NOT pixels: Plank renders it as value * icon_size / 10. These are
# tuned for the 48px icons set in dconf — 5px top/bottom, 10px left/right.
# Change icon-size and the borders scale with it; recompute as px * 10 / icon_size.
[PlankDockTheme]
HorizPadding=2.0833
TopPadding=1.0417
BottomPadding=1.0417
ItemPadding=2.0
IndicatorSize=6.0
IconShadowSize=1.0
UrgentBounceHeight=1.6667
LaunchBounceHeight=0.625
FadeOpacity=1.0
ClickTime=300
UrgentBounceTime=600
LaunchBounceTime=600
ActiveTime=300
SlideTime=300
FadeTime=250
HideTime=250
GlowSize=30
GlowTime=10000
GlowPulseTime=2000
UrgentHueShift=150
ItemMoveTime=450
CascadeHide=true
@@ -0,0 +1,28 @@
[Desktop Entry]
Name=Visual Studio Code
Comment=Code Editing. Redefined.
GenericName=Text Editor
Exec=/usr/share/code/code %F
Icon=vscode
Type=Application
StartupNotify=false
StartupWMClass=code
Categories=TextEditor;Development;IDE;
MimeType=application/x-code-workspace;
Actions=new-empty-window;
Keywords=vscode;
[Desktop Action new-empty-window]
Name=New Empty Window
Name[cs]=Nové prázdné okno
Name[de]=Neues leeres Fenster
Name[es]=Nueva ventana vacía
Name[fr]=Nouvelle fenêtre vide
Name[it]=Nuova finestra vuota
Name[ja]=新しい空のウィンドウ
Name[ko]=새 빈 창
Name[ru]=Новое пустое окно
Name[zh_CN]=新建空窗口
Name[zh_TW]=開新空視窗
Exec=/usr/share/code/code --new-window %F
Icon=vscode
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=NexusOS
Comment=Launch the NexusOS assistant (memory + backend + UI)
Exec=/home/jon/nexus-core/management/nexus-app.sh
Icon=/home/jon/nexus-core/assets/n-small.png
Terminal=false
Categories=Utility;
StartupNotify=false
@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=Promethean Terminal
Comment=Launch a terminal with the Promethean environment
Exec=/home/jon/.local/kitty.app/bin/kitty --config /home/jon/nexus-core/bin/promethean/kitty.conf --class promethean-terminal -e bash --rcfile /home/jon/nexus-core/.promethean_bashrc -i
Icon=/home/jon/nexus-core/assets/promethean-terminal.png
Terminal=false
Categories=Utility;TerminalEmulator;
StartupNotify=false
StartupWMClass=promethean-terminal
@@ -1,15 +1,6 @@
[Settings]
gtk-application-prefer-dark-theme=true
gtk-button-images=true
gtk-cursor-theme-name=DMZ-White
gtk-cursor-theme-size=24
gtk-decoration-layout=close,minimize,maximize:
gtk-enable-animations=true
gtk-font-name=Ubuntu 10
gtk-cursor-theme-size=24
gtk-cursor-theme-name=DMZ-White
gtk-icon-theme-name=NexusOS
gtk-menu-images=true
gtk-modules=colorreload-gtk-module
gtk-primary-button-warps-slider=false
gtk-theme-name=NexusOS
gtk-toolbar-style=3
gtk-xft-dpi=98304
@@ -1,12 +1,6 @@
[Settings]
gtk-application-prefer-dark-theme=true
gtk-cursor-theme-name=DMZ-White
gtk-cursor-theme-size=24
gtk-decoration-layout=close,minimize,maximize:
gtk-enable-animations=true
gtk-font-name=Ubuntu 10
gtk-cursor-theme-size=24
gtk-cursor-theme-name=DMZ-White
gtk-icon-theme-name=NexusOS
gtk-modules=colorreload-gtk-module
gtk-primary-button-warps-slider=false
gtk-theme-name=NexusOS
gtk-xft-dpi=98304
@@ -0,0 +1,19 @@
[docks/dock1]
alignment='center'
auto-pinning=true
current-workspace-only=false
dock-items=['microsoft-edge.dockitem', 'promethean-terminal.dockitem', 'nexus-core.dockitem', 'code.dockitem', 'thunar.dockitem']
hide-delay=0
hide-mode='intelligent'
icon-size=48
items-alignment='center'
lock-items=false
offset=0
pinned-only=false
pressure-reveal=false
show-dock-item=false
theme='NexusOS'
tooltips-enabled=true
unhide-delay=0
zoom-enabled=false
zoom-percent=150
@@ -1,2 +1,2 @@
[PlankDockItemPreferences]
Launcher=file:///home/jon/.local/share/applications/microsoft-edge.desktop
Launcher=file:///usr/share/applications/microsoft-edge.desktop
@@ -1,2 +0,0 @@
[PlankDockItemPreferences]
Launcher=file:///home/jon/.local/share/applications/nexus-activate.desktop
@@ -1,11 +1,11 @@
<?xml version="1.1" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfce4-desktop" version="1.0">
<property name="backdrop" type="empty">
<property name="screen0" type="empty">
<property name="monitor0" type="empty">
<property name="image-path" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-image" type="empty"/>
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-single-image" type="empty"/>
<property name="brightness" type="empty"/>
<property name="image-show" type="bool" value="true"/>
@@ -13,13 +13,13 @@
</property>
<property name="monitor1" type="empty">
<property name="image-path" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-image" type="empty"/>
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-single-image" type="empty"/>
<property name="brightness" type="empty"/>
</property>
<property name="monitor2" type="empty">
<property name="image-path" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-image" type="empty"/>
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
<property name="last-single-image" type="empty"/>
<property name="brightness" type="empty"/>
</property>
@@ -32,17 +32,17 @@
<property name="workspace1" type="empty">
<property name="color-style" type="int" value="0"/>
<property name="image-style" type="int" value="5"/>
<property name="last-image" type="string" value="/usr/share/xfce4/backdrops/linuxmint.jpg"/>
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
</property>
<property name="workspace2" type="empty">
<property name="color-style" type="int" value="0"/>
<property name="image-style" type="int" value="5"/>
<property name="last-image" type="string" value="/usr/share/xfce4/backdrops/linuxmint.jpg"/>
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
</property>
<property name="workspace3" type="empty">
<property name="color-style" type="int" value="0"/>
<property name="image-style" type="int" value="5"/>
<property name="last-image" type="string" value="/usr/share/xfce4/backdrops/linuxmint.jpg"/>
<property name="last-image" type="string" value="/home/jon/nexus-core/assets/background.png"/>
</property>
</property>
<property name="monitoreDP-1" type="empty">
@@ -1,4 +1,4 @@
<?xml version="1.1" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfce4-panel" version="1.0">
<property name="configver" type="int" value="2"/>
@@ -38,7 +38,14 @@
<property name="dark-mode" type="bool" value="true"/>
</property>
<property name="plugins" type="empty">
<property name="plugin-1" type="empty"/>
<property name="plugin-2" type="string" value="power-manager-plugin"/>
<property name="plugin-3" type="empty">
<property name="items" type="empty"/>
</property>
<property name="plugin-4" type="empty">
<property name="items" type="empty"/>
</property>
<property name="plugin-5" type="string" value="separator">
<property name="items" type="array">
<value type="string" value="17640946043.desktop"/>
@@ -81,15 +88,11 @@
</property>
<property name="button-icon" type="string" value="/home/jon/nexus-core/assets/n-small.png"/>
<property name="recent" type="array">
<value type="string" value="microsoft-edge.desktop"/>
<value type="string" value="xfce4-taskmanager.desktop"/>
<value type="string" value="virtualbox.desktop"/>
<value type="string" value="virt-manager.desktop"/>
<value type="string" value="org.gnome.DiskUtility.desktop"/>
<value type="string" value="org.x.editor.desktop"/>
<value type="string" value="promethean-terminal.desktop"/>
<value type="string" value="claude-usage-widget.desktop"/>
<value type="string" value="kitty.desktop"/>
<value type="string" value="putty.desktop"/>
<value type="string" value="code.desktop"/>
</property>
<property name="view-mode" type="int" value="2"/>
@@ -107,6 +110,7 @@
<property name="show-notifications" type="bool" value="true"/>
<property name="known-players" type="string" value="Microsoft Edge"/>
</property>
<property name="plugin-9" type="empty"/>
<property name="plugin-10" type="string" value="notification-plugin"/>
<property name="plugin-11" type="empty"/>
<property name="plugin-12" type="string" value="clock">
@@ -118,8 +122,13 @@
<property name="digital-date-format" type="string" value="%m/%d/%Y"/>
<property name="digital-date-font" type="string" value="Sans 8"/>
</property>
<property name="plugin-13" type="string" value="genmon">
<property name="digital-time-format" type="empty"/>
<property name="digital-layout" type="empty"/>
<property name="digital-date-font" type="empty"/>
<property name="digital-time-font" type="empty"/>
</property>
<property name="plugin-14" type="empty"/>
<property name="plugin-13" type="string" value="genmon"/>
<property name="plugin-15" type="string" value="genmon"/>
<property name="plugin-16" type="string" value="genmon"/>
</property>
@@ -1,4 +1,4 @@
<?xml version="1.1" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xsettings" version="1.0">
<property name="Net" type="empty">
@@ -27,6 +27,8 @@
<property name="MonospaceFontName" type="empty"/>
<property name="IconSizes" type="empty"/>
<property name="KeyThemeName" type="empty"/>
<property name="ToolbarStyle" type="empty"/>
<property name="ToolbarIconSize" type="empty"/>
<property name="MenuImages" type="bool" value="false"/>
<property name="ButtonImages" type="empty"/>
<property name="MenuBarAccel" type="empty"/>
@@ -35,8 +37,6 @@
<property name="DecorationLayout" type="string" value="icon,menu:minimize,maximize,close"/>
<property name="DialogsUseHeader" type="empty"/>
<property name="TitlebarMiddleClick" type="empty"/>
<property name="ToolbarStyle" type="empty"/>
<property name="ToolbarIconSize" type="empty"/>
<property name="IMPreeditStyle" type="empty"/>
<property name="IMStatusStyle" type="empty"/>
<property name="IMModule" type="empty"/>
+12
View File
@@ -51,6 +51,18 @@ if [ -d "$HOME/.config/plank" ]; then
mkdir -p "$snap/plank"
cp -rf "$HOME/.config/plank/." "$snap/plank/" 2>/dev/null || true
fi
# The .dockitem files alone are NOT the dock. Everything else — item list, theme,
# icon size, position, hide mode — lives in dconf, and on startup Plank DELETES
# every .dockitem not in its list, so copying files alone restores nothing.
# `monitor` is stripped: it's per-machine, and plank-primary-watch.sh sets it.
dconf dump /net/launchpad/plank/ 2>/dev/null | grep -v '^monitor=' > "$snap/plank-dconf.ini" || true
# Dock launchers point at .desktop files in ~/.local/share/applications, which
# is outside the repo — Plank prunes any item whose target is missing.
mkdir -p "$snap/applications"
for d in $(sed -n 's#.*applications/\([^.]*\.desktop\).*#\1#p' \
"$snap"/plank/dock1/launchers/*.dockitem 2>/dev/null); do
cp -f "$HOME/.local/share/applications/$d" "$snap/applications/$d" 2>/dev/null || true
done
# Copy the Claude memory notes (machine knowledge under ~/.claude) into the
# repo so they're versioned too. Some aren't Nexus-specific (Fusion 360, etc.).
+118
View File
@@ -0,0 +1,118 @@
#!/bin/bash
# NexusOS boot-time branding: the Plymouth boot splash and the LightDM greeter
# (which is also what you see on lock -- light-locker just switches to it).
#
# Both are system-level and need root, which is why this is a standalone script
# rather than part of assets/themes/install-theme.sh.
#
# Usage: sudo bin/boot-branding.sh [--uninstall]
set -euo pipefail
NEXUS_ROOT="${NEXUS_ROOT:-$(cd "$(dirname "$(realpath "$0")")/.." && pwd)}"
# Plymouth. A real copy is placed under /usr/share/plymouth/themes -- NOT a
# symlink into /home. The stock initramfs plymouth hook copies the theme path
# verbatim, so a symlink into an unmounted /home dangles at early boot and
# Plymouth falls back to text mode. That is what
# assets/boot/initramfs-hook-my-custom-logo exists to work around; with a real
# copy the hook is unnecessary.
SRC="$NEXUS_ROOT/assets/boot/my-custom-logo"
DEST=/usr/share/plymouth/themes/my-custom-logo
THEME="$DEST/my-custom-logo.plymouth"
# Greeter. slick-greeter cannot lay out a custom login screen the way the
# NexusOS-QML SDDM theme does, so the logo and wordmark are baked into the
# background image instead and the greeter's own centered login box lands under
# them. The SVG is used as-is: gdk-pixbuf's SVG loader is installed, so there is
# nothing to rasterize. Copied out of the repo because the greeter runs as user
# `lightdm` and the user home is 0750, so nothing in here is readable to it.
GREETER_ART="$NEXUS_ROOT/assets/themes/greeter/greeter-background.svg"
GREETER_DIR=/usr/share/nexusos
GREETER_CONF=/etc/lightdm/slick-greeter.conf
# SDDM. Its greeter is a QML app, so unlike slick-greeter it can draw the real
# layout (logo, wordmark, login box, clock) -- this is the theme the design was
# written for. Installed whenever sddm is present, regardless of which display
# manager is currently active, so switching between the two needs no re-run.
SDDM_SRC="$NEXUS_ROOT/assets/themes/KDE/sddm/NexusOS-QML"
SDDM_DEST=/usr/share/sddm/themes/NexusOS-QML
SDDM_CONF=/etc/sddm.conf.d/nexusos.conf
[ "$(id -u)" -eq 0 ] || { echo "run with sudo"; exit 1; }
if [ "${1:-}" = "--uninstall" ]; then
update-alternatives --remove default.plymouth "$THEME" 2>/dev/null || true
rm -rf "$DEST" "$GREETER_DIR" "$GREETER_CONF" "$SDDM_DEST" "$SDDM_CONF"
command -v update-initramfs >/dev/null && update-initramfs -u
echo "reverted to the distro defaults"
exit 0
fi
# --- Plymouth boot splash -----------------------------------------------------
if [ ! -d "$SRC" ]; then
echo "missing theme source: $SRC -- skipping boot splash"
elif ! command -v update-initramfs >/dev/null || [ ! -d /usr/share/plymouth/themes ]; then
echo "no Plymouth on this box -- skipping boot splash"
# update-initramfs is slow, and a restore runs this every time. Bail out when the
# installed copy already matches the repo and the alternative already points here.
elif diff -rq "$SRC" "$DEST" >/dev/null 2>&1 &&
[ "$(readlink -f /usr/share/plymouth/themes/default.plymouth)" = "$THEME" ]; then
echo "boot splash already installed"
else
rm -rf "$DEST"
cp -aL "$SRC" "$DEST"
chown -R root:root "$DEST"
chmod -R a+rX,go-w "$DEST"
update-alternatives --install /usr/share/plymouth/themes/default.plymouth \
default.plymouth "$THEME" 300
update-alternatives --set default.plymouth "$THEME"
update-initramfs -u
echo "boot splash installed"
fi
# --- LightDM greeter (login + lock) -------------------------------------------
# Only slick-greeter is configured here; it's what Mint/XFCE ships and it reads
# this file directly. theme-name is deliberately left alone -- the NexusOS GTK
# theme lives in ~/.themes, which the lightdm user cannot read, so pointing at it
# would just fall back silently. Colors below are the NexusOS-QML palette
# (theme.conf: bgDark).
if [ ! -f "$GREETER_ART" ]; then
echo "missing $GREETER_ART -- skipping greeter"
elif ! command -v slick-greeter >/dev/null; then
echo "no slick-greeter on this box -- skipping greeter"
else
install -D -m 644 -o root -g root "$GREETER_ART" "$GREETER_DIR/greeter-background.svg"
cat > "$GREETER_CONF" <<EOF
[Greeter]
background=$GREETER_DIR/greeter-background.svg
background-color=#0a0e1a
draw-user-backgrounds=false
draw-grid=false
content-align=center
stretch-background-across-monitors=false
EOF
chmod 644 "$GREETER_CONF"
echo "greeter background installed"
fi
# --- SDDM greeter -------------------------------------------------------------
if [ ! -d "$SDDM_SRC" ]; then
echo "missing $SDDM_SRC -- skipping SDDM theme"
elif [ ! -d /usr/share/sddm/themes ]; then
echo "no SDDM on this box -- skipping SDDM theme"
else
rm -rf "$SDDM_DEST"
cp -aL "$SDDM_SRC" "$SDDM_DEST"
chown -R root:root "$SDDM_DEST"
chmod -R a+rX,go-w "$SDDM_DEST"
install -d -m 755 /etc/sddm.conf.d
cat > "$SDDM_CONF" <<EOF
[Theme]
Current=NexusOS-QML
EOF
chmod 644 "$SDDM_CONF"
echo "SDDM theme installed"
fi
echo "done -- reboot to see it."
+34
View File
@@ -39,5 +39,39 @@ else
echo "-- skipped: pwsh not installed"
fi
echo "== shell parse =="
for f in scripts/install-termux.sh launch_nexus.sh management/nexus-cli.sh; do
[ -f "$f" ] && { bash -n "$f" || fail=1; }
done
echo "== packaging =="
# The wheel is the other shippable artifact, so it belongs in the same gate:
# a broken pyproject or a missing web build only shows up at build time.
if Promethean/bin/python -c "import build, twine" 2>/dev/null; then
rm -rf .build-check
if Promethean/bin/python -m build --outdir .build-check >/dev/null 2>&1; then
Promethean/bin/python -m twine check .build-check/* || fail=1
# The compiled UI has to actually be inside the wheel - a wheel that
# builds but ships no dist/ serves a blank page.
Promethean/bin/python - <<'PY' || fail=1
import glob, sys, zipfile
wheels = glob.glob(".build-check/*.whl")
if not wheels:
sys.exit("no wheel produced")
names = zipfile.ZipFile(wheels[0]).namelist()
if not any(n.startswith("synapse/_resources/web/") for n in names):
sys.exit("wheel is missing the compiled web UI (cd interface/web && npm run build)")
if not any(n.startswith("synapse/_resources/playbooks/") for n in names):
sys.exit("wheel is missing the seed playbooks")
print(f"wheel OK: {len(names)} files")
PY
else
echo "!! wheel build failed"; fail=1
fi
rm -rf .build-check
else
echo "-- skipped: build/twine missing (pip install -e '.[dev]')"
fi
[ "$fail" -eq 0 ] && echo "OK" || echo "FAILED"
exit "$fail"
+7 -16
View File
@@ -10,7 +10,7 @@ The window opens IMMEDIATELY on a loading page and navigates to the app once the
backend answers, rather than waiting up to 40s with nothing on screen and then
appearing. One window the whole time: nothing pops up and vanishes, and there is
no moment where the user is left wondering whether the command did anything.
The loading page's status line is updated live (memory -> backend -> ready) via
The loading page's status line is updated live (backend -> ready) via
evaluate_js, instead of sitting on one static sentence for the whole wait - the
point is a user watching the window can tell what stage it's stuck on, without
needing to go read a terminal or a log file to find out.
@@ -24,7 +24,6 @@ import time
import urllib.request
URL = "http://localhost:8000"
MEMORY_URL = "http://localhost:8001/"
ICON_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "NexusOS.ico")
# Inline so it renders with no server and no asset files - the whole point is
@@ -47,7 +46,7 @@ LOADING_HTML = """<!doctype html>
<div class="box">
<div class="ring"></div>
<h1>Starting NexusOS</h1>
<p id="status">Starting memory service...</p>
<p id="status">Starting backend...</p>
</div>
</body></html>"""
@@ -56,7 +55,7 @@ FAILED_HTML = LOADING_HTML.replace(
).replace(
"Starting NexusOS", "Backend did not start"
).replace(
'id="status">Starting memory service...',
'id="status">Starting backend...',
'id="status">Nothing answered on :8000 after 40s. Check: ncp logs -b',
)
@@ -80,23 +79,15 @@ def _http_ok(url: str, timeout: float = 2.0) -> bool:
def _wait_for_backend(win, timeout: float = 40.0) -> bool:
"""Poll memory, then the backend, updating the on-screen status as each
comes up - so the window never loads before the server is up (which shows
a localhost error the webview won't retry), and never sits on one sentence
while actually moving through two separate services."""
"""Poll the backend, updating the on-screen status as it comes up - so the
window never loads before the server is up, which shows a localhost error
the webview won't retry."""
status_url = URL.rstrip("/") + "/status"
deadline = time.time() + timeout
memory_ready = _http_ok(MEMORY_URL)
if memory_ready:
_set_status(win, "Starting backend...")
_set_status(win, "Starting backend...")
while time.time() < deadline:
if not memory_ready:
memory_ready = _http_ok(MEMORY_URL)
if memory_ready:
_set_status(win, "Starting backend...")
try:
# 5s, not 2: /status probes Ollama, and a wedged Ollama made it
# slower than a 2s ceiling - the backend was up and answering 200
+37 -6
View File
@@ -26,6 +26,9 @@ ln -sf "$NEXUS/management/autostart/nexus-popup.desktop" \
"$AUTOSTART/nexus-popup.desktop"
cp -f "$NEXUS/management/autostart/nm-tray.desktop" "$AUTOSTART/nm-tray.desktop"
cp -f "$NEXUS/management/autostart/nm-tray-autostart.desktop" "$AUTOSTART/nm-tray-autostart.desktop"
# Same for nm-applet (network-manager-gnome), which ships its own systray icon
# from /etc/xdg/autostart — the network genmon (plugin-13) replaces it.
cp -f "$NEXUS/management/autostart/nm-applet.desktop" "$AUTOSTART/nm-applet.desktop"
# Suppress blueman-applet autostart (both the system blueman.desktop and the
# user blueman-applet.desktop). blueman always forces its own tray icon, which
# the panel renders too small; the Bluetooth genmon (plugin-16) replaces it and
@@ -33,10 +36,22 @@ cp -f "$NEXUS/management/autostart/nm-tray-autostart.desktop" "$AUTOSTART/nm-tra
cp -f "$NEXUS/management/autostart/blueman.desktop" "$AUTOSTART/blueman.desktop"
cp -f "$NEXUS/management/autostart/blueman-applet.desktop" "$AUTOSTART/blueman-applet.desktop"
# Genmon configs (regular files — panel writes back to them)
cp -f "$NEXUS/management/panel/genmon-13.rc" "$PANEL_CFG/genmon-13.rc"
cp -f "$NEXUS/management/panel/genmon-15.rc" "$PANEL_CFG/genmon-15.rc"
cp -f "$NEXUS/management/panel/genmon-16.rc" "$PANEL_CFG/genmon-16.rc"
# Suppress the XApp tray icons (Update Manager, System Reports, nvidia-prime).
# XApp.StatusIcon only speaks its own org.x.StatusIcon protocol; with no XApp
# status monitor on Plasma it falls back to a legacy XEmbed icon, and
# xembedsniproxy renders those as solid black silhouettes. Plasma's own
# DiscoverNotifier already covers update notifications, and nvidia-prime's
# GPU-mux switching is reachable from `prime-select`. Delete the override to
# get one back -- it will still render black.
for app in mintupdate mintreport nvidia-prime; do
printf '[Desktop Entry]\nType=Application\nName=%s\nExec=/bin/true\nHidden=true\nX-XFCE-Autostart-Override=true\n' \
"$app" > "$AUTOSTART/$app.desktop"
done
# Plank runs under the primary-follow watcher, which also restarts it if it dies.
# Nothing else launches Plank — a restored ~/.config/plank only holds its
# launchers, so without this the dock is simply absent on a fresh box.
ln -sf "$NEXUS/management/autostart/plank.desktop" "$AUTOSTART/plank.desktop"
# ── Register the genmon applets into the XFCE panel ────────────────────────
# The network applet's genmon (plugin-13) was added by hand once; the Nexus
@@ -71,8 +86,24 @@ register_genmon() {
register_genmon 15 before # Nexus applet, left of the network applet
register_genmon 16 after # Bluetooth applet, right of the network applet
echo "Reloading panel…"
xfce4-panel -r >/dev/null 2>&1 || true
# Genmon configs. These MUST be written with the panel down: genmon keeps its
# config in memory and rewrites genmon-N.rc when the panel exits, so a copy made
# while it's running is overwritten by the very reload meant to pick it up — a
# newly registered plugin has an empty in-memory config, which is how all three
# applets came back blank (Command=, UseLabel=1, "(genmon)" label).
if command -v xfce4-panel >/dev/null 2>&1; then
echo "Restarting panel with applet configs…"
xfce4-panel -q >/dev/null 2>&1 || true
sleep 1
fi
for id in 13 15 16; do
cp -f "$NEXUS/management/panel/genmon-$id.rc" "$PANEL_CFG/genmon-$id.rc"
done
if command -v xfce4-panel >/dev/null 2>&1; then
setsid xfce4-panel >/dev/null 2>&1 < /dev/null &
sleep 1
fi
# Start the popup daemon now so the first click works without a re-login.
# Pin to the system python3 explicitly: PyGObject (gi) is a system package, and
+2 -5
View File
@@ -15,15 +15,13 @@ port_up() {
state() { port_up "$1" && echo "up" || echo "down"; }
BACKEND=$(state 8000) # Synapse
MEMORY=$(state 8001) # Memory service
FRONTEND=$(state 5173) # Vite frontend
up=0
[ "$BACKEND" = up ] && up=$((up + 1))
[ "$MEMORY" = up ] && up=$((up + 1))
[ "$FRONTEND" = up ] && up=$((up + 1))
if [ "$up" -eq 3 ]; then ICON="$ICON_DIR/nexus-on.png"
if [ "$up" -eq 2 ]; then ICON="$ICON_DIR/nexus-on.png"
elif [ "$up" -eq 0 ]; then ICON="$ICON_DIR/nexus-off.png"
else ICON="$ICON_DIR/nexus-partial.png"
fi
@@ -31,8 +29,7 @@ fi
dot() { [ "$1" = up ] && echo "●" || echo "○"; }
echo "<img>$ICON</img>"
echo "<tool>NexusOS — ${up}/3 services up
echo "<tool>NexusOS — ${up}/2 services up
$(dot "$BACKEND") Synapse backend :8000
$(dot "$MEMORY") Memory service :8001
$(dot "$FRONTEND") Frontend (Vite) :5173</tool>"
echo "<click>$HOME/.local/bin/nexus-popup.py</click>"
-1
View File
@@ -34,7 +34,6 @@ APP_URL = "http://localhost:5173"
# name, port, nexus-cli flag
SERVICES = [
("Synapse backend", 8000, "--backend"),
("Memory service", 8001, "--memory"),
("Frontend (Vite)", 5173, "--frontend"),
]
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Installs Promethean Terminal: downloads kitty, wires up the config and icon,
# and registers the desktop entry in the app menu and on the Desktop.
set -euo pipefail
# This script lives at <repo>/bin/promethean/install.sh; its own dir holds the
# kitty conf + desktop entry it installs.
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
KITTY_BIN="$HOME/.local/kitty.app/bin/kitty"
DESKTOP_FILE="$SELF_DIR/promethean-terminal.desktop"
CONF="$SELF_DIR/kitty.conf"
NEXUS_ROOT="$(cd "$SELF_DIR/../.." && pwd)"
RCFILE="$NEXUS_ROOT/.promethean_bashrc"
ICON="$NEXUS_ROOT/assets/promethean-terminal.png"
# ── 1. Download kitty ────────────────────────────────────────────────────────
if [[ -x "$KITTY_BIN" ]]; then
echo "kitty already at $KITTY_BIN — skipping download (run with --update to force)"
if [[ "${1:-}" == "--update" ]]; then
echo "→ Updating kitty..."
curl -fsSL https://sw.kovidgoyal.net/kitty/installer.sh | sh /dev/stdin launch=n
fi
else
echo "→ Downloading kitty to ~/.local/kitty.app/ ..."
curl -fsSL https://sw.kovidgoyal.net/kitty/installer.sh | sh /dev/stdin launch=n
fi
echo " kitty $("$KITTY_BIN" --version)"
# ── 2. Ensure .promethean_bashrc exists ──────────────────────────────────────
if [[ ! -f "$RCFILE" ]]; then
echo "→ Creating $RCFILE (sourcing ~/.bashrc) ..."
mkdir -p "$(dirname "$RCFILE")"
cat > "$RCFILE" <<'EOF'
# Promethean Terminal shell init
[[ -f ~/.bashrc ]] && source ~/.bashrc
export VIRTUAL_ENV_DISABLE_PROMPT=1
if [[ -f "$HOME/nexus-core/Promethean/bin/activate" ]]; then
source "$HOME/nexus-core/Promethean/bin/activate"
fi
export PS1='\[\e[0;35m\](Promethean)\[\e[0m\] ${debian_chroot:+($debian_chroot)}\[\e[0;37m\]\u@\h\[\e[0m\]:\[\e[1;32m\]\w\[\e[0m\]\$ '
printf '\e]0;Promethean Terminal\a'
EOF
fi
# ── 3. Write the .desktop file (repo copy + installed copies) ────────────────
write_desktop() {
local dest="$1"
cat > "$dest" <<EOF
[Desktop Entry]
Type=Application
Name=Promethean Terminal
Comment=Launch a terminal with the Promethean environment
Exec=$KITTY_BIN --config $CONF --class promethean-terminal -e bash --rcfile $RCFILE -i
Icon=$ICON
Terminal=false
Categories=Utility;TerminalEmulator;
StartupNotify=false
StartupWMClass=promethean-terminal
EOF
}
echo "→ Writing desktop entry..."
write_desktop "$DESKTOP_FILE"
write_desktop "$HOME/.local/share/applications/promethean-terminal.desktop"
# ── 4. Desktop icon ──────────────────────────────────────────────────────────
DESKTOP_DIR="$HOME/Desktop"
mkdir -p "$DESKTOP_DIR"
cp "$DESKTOP_FILE" "$DESKTOP_DIR/promethean-terminal.desktop"
chmod +x "$DESKTOP_DIR/promethean-terminal.desktop"
# Mark trusted so XFCE/Nemo doesn't show the "untrusted" banner
gio set "$DESKTOP_DIR/promethean-terminal.desktop" metadata::trusted true 2>/dev/null \
|| xfce4-file-manager --quit 2>/dev/null || true
# ── 5. Refresh app menu ──────────────────────────────────────────────────────
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
echo ""
echo "Done. Promethean Terminal is installed."
echo " Binary : $KITTY_BIN"
echo " Config : $CONF"
echo " Icon : $ICON"
echo " Desktop: $DESKTOP_DIR/promethean-terminal.desktop"
+23
View File
@@ -0,0 +1,23 @@
# Promethean Terminal — Project Nexus
# Loaded via: kitty --config /home/jon/nexus-core/bin/promethean/kitty.conf
# Window title — disable kitty's shell integration title override
# so the PS1 escape (\e]0;Promethean Terminal\a) controls the title
shell_integration enabled no-title
# Single-instance mode: subsequent launches reuse the running process (instant open)
allow_remote_control yes
single_instance yes
# Colors
background #1a0030
foreground #cccccc
cursor #b040c0
cursor_text_color #0d0010
# Selection
selection_background #3d0060
selection_foreground #ffffff
# Tab bar (hidden — single window use)
tab_bar_style hidden
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=Promethean Terminal
Comment=Launch a terminal with the Promethean environment
Exec=/home/jon/.local/kitty.app/bin/kitty --config /home/jon/nexus-core/bin/promethean/kitty.conf --class promethean-terminal -e bash --rcfile /home/jon/nexus-core/.promethean_bashrc -i
Icon=/home/jon/nexus-core/assets/promethean-terminal.png
Terminal=false
Categories=Utility;TerminalEmulator;
StartupNotify=false
StartupWMClass=promethean-terminal
+57 -12
View File
@@ -1,17 +1,20 @@
#!/bin/bash
# The Linux-only half of a restore: system packages, the bundled Ollama binary,
# and the XFCE desktop wiring (panel, wallpaper, theme, terminal, branding).
# and the desktop wiring (panel, wallpaper, theme, terminal, branding) for
# whichever of XFCE / Plasma is installed.
# None of it means anything on Windows, which is why it lives here instead of in
# bin/sync.py — sync.py owns the portable half and calls this in three stages:
#
# restore-linux.sh prep Before the rebuild: system packages the build needs.
# restore-linux.sh runtime After: Ollama binary + shell wiring. Any Linux box
# needs these to actually run NexusOS.
# restore-linux.sh desktop After: the XFCE look (panel, wallpaper, theme,
# branding). Cosmetic, this box's setup, and the only
# stage that rewrites files outside the repo and $HOME.
# Skippable with `--no-desktop`; auto-skipped off XFCE.
# restore-linux.sh desktop After: the desktop look (Plasma global theme,
# XFCE panel/wallpaper/theme, boot + distro
# branding). Cosmetic, this box's setup, and the
# only stage that rewrites files outside the repo
# and $HOME. Skippable with `--no-desktop`; each
# desktop's branch auto-skips when absent.
#
# Not meant to be run by hand — use `ncp restore` (python bin/sync.py restore).
@@ -123,15 +126,28 @@ EOF
exit 0
fi
# --- desktop stage: XFCE only, and it writes outside the repo -----------------
# Everything below reconfigures the logged-in user's desktop. On any other DE it
# would be actively wrong (copying XFCE channel XMLs onto a GNOME box does
# nothing good), so bail rather than make a mess of someone else's machine.
if ! command -v xfconf-query >/dev/null; then
echo "Not an XFCE session (no xfconf-query) — skipping desktop wiring."
exit 0
# --- desktop stage: reconfigures the logged-in user's desktop ------------------
# Keyed on what is installed, not on one either/or: XFCE and Plasma can both be
# present (this box runs both and picks at the SDDM greeter), and each branch
# writes only to its own config. Anything neither branch matches falls through
# to the boot/distro branding at the bottom, which is DE-independent.
# Plasma. install-plasma.sh is idempotent and applies live when a session is
# running; --no-sddm because bin/boot-branding.sh owns the SDDM theme, and
# letting both write it was how the setting ended up in two files.
if command -v lookandfeeltool >/dev/null && [ -x "$NEXUS_ROOT/assets/themes/KDE/install-plasma.sh" ]; then
echo ""
echo "Restoring NexusOS Plasma theme..."
"$NEXUS_ROOT/assets/themes/KDE/install-plasma.sh" --no-sddm \
|| echo "Warning: Plasma theme restore failed."
fi
# XFCE. Copying XFCE channel XMLs onto a box with no XFCE does nothing good, so
# this whole branch is skipped rather than half-applied.
if ! command -v xfconf-query >/dev/null; then
echo "No XFCE (no xfconf-query) — skipping the XFCE desktop wiring."
else
# Panel layout, wallpaper, compositing, keybindings and terminal profile all live
# in the xfconf channel XMLs — restore them by copying the files back. xfconfd
# caches channels in memory and rewrites them on exit, so it has to die first or
@@ -163,8 +179,26 @@ if [ -f "$NEXUS_ROOT/bin/panel/plank-primary-watch.sh" ]; then
fi
plank_snap="$NEXUS_ROOT/assets/themes/restore-snapshot/plank"
if [ -d "$plank_snap" ]; then
# Plank owns its item list in dconf and deletes any .dockitem missing from it
# on startup, so copying files alone silently restores nothing. Order here:
# kill Plank (a running one prunes what we copy), put the launcher .desktop
# targets back, copy the items, set the list, restart.
pkill -x plank 2>/dev/null && sleep 1
mkdir -p "$HOME/.local/share/applications"
cp -f "$NEXUS_ROOT/assets/themes/restore-snapshot/applications"/*.desktop \
"$HOME/.local/share/applications/" 2>/dev/null || true
mkdir -p "$HOME/.config/plank"
cp -rf "$plank_snap/." "$HOME/.config/plank/" # /. = contents, else it nests
plank_cfg="$NEXUS_ROOT/assets/themes/restore-snapshot/plank-dconf.ini"
if [ -s "$plank_cfg" ]; then
dconf load /net/launchpad/plank/ < "$plank_cfg" 2>/dev/null || true
fi
theme_snap="$NEXUS_ROOT/assets/themes/plank"
if [ -d "$theme_snap" ]; then
mkdir -p "$HOME/.local/share/plank/themes"
cp -rf "$theme_snap/." "$HOME/.local/share/plank/themes/"
fi
command -v plank >/dev/null && setsid plank >/dev/null 2>&1 </dev/null &
fi
echo ""
@@ -196,6 +230,17 @@ else
echo "Warning: $panel_installer not found — skipping panel install."
fi
fi
# Boot branding — the Plymouth splash. Not XFCE-specific, but it's cosmetic and
# writes outside the repo, so it belongs to the skippable stage like os-release
# below. Self-skips when the theme is already installed (initramfs rebuild is slow).
if [ -x "$NEXUS_ROOT/bin/boot-branding.sh" ]; then
echo ""
echo "Installing NexusOS boot branding (splash + greeter)..."
sudo "$NEXUS_ROOT/bin/boot-branding.sh" || echo "Warning: boot branding skipped."
fi
# Distro branding — the About dialog / neofetch read /etc/os-release.
if grep -q '^ID=linuxmint' /etc/os-release 2>/dev/null && ! grep -q 'NexusOS' /etc/os-release; then
echo ""
+89 -8
View File
@@ -18,7 +18,9 @@ bin/db-compare.sh could never work there.
A fresh machine clones first (git clone <repo> nexus-core), then runs this.
"""
import argparse
import contextlib
import os
import re
import shutil
import sqlite3
import subprocess
@@ -118,6 +120,42 @@ def requirements() -> str:
# -- memory DB -----------------------------------------------------------------
def _vec0_extension() -> "Path | None":
"""Path to the sqlite_vec native extension inside the venv, if built. Found
by glob rather than importing sqlite_vec - this script runs stdlib-only,
before the venv necessarily exists."""
matches = list((ROOT / "Promethean").glob("**/sqlite_vec/vec0.*"))
return matches[0] if matches else None
_DERIVED = re.compile(
r'^\s*(?:CREATE\s+(?:VIRTUAL\s+)?TABLE|INSERT\s+INTO)\s+"?vec_(?:messages|documents)',
re.IGNORECASE,
)
def _is_derived_stmt(stmt: str) -> bool:
"""True for a dump statement that builds or fills a sqlite_vec table.
Matched on the statement's TARGET, never as a substring: a chat message
whose text happens to mention vec_messages is an INSERT INTO "messages" and
has to survive. (It didn't, the first time - the test caught it.)
"""
if _DERIVED.match(stmt):
return True
# iterdump writes a virtual table straight into the schema table, and parks
# its shadow tables' AUTOINCREMENT counters in sqlite_sequence. Both name the
# vec table as a quoted VALUE rather than as the statement's target.
head = stmt.lstrip().upper()
if (head.startswith("INSERT INTO SQLITE_MASTER")
or head.startswith('INSERT INTO "SQLITE_SEQUENCE"')) and "'vec_" in stmt:
return True
# Only meaningful when replaying over an existing DB, and sqlite_sequence
# exists only if some AUTOINCREMENT table survives the filter - today true
# by luck alone. Restore deletes the DB file first, so drop it.
return stmt.lstrip().upper().startswith('DELETE FROM "SQLITE_SEQUENCE"')
def dump_db() -> bool:
"""Dump the (gitignored, binary, WAL) memory DB to a diff-friendly SQL file
so git backs up the assistant's memory + conversation history. sqlite3 reads
@@ -125,8 +163,28 @@ def dump_db() -> bool:
if not DB.exists():
return False
try:
with sqlite3.connect(f"file:{DB}?mode=ro", uri=True) as conn:
DB_SQL.write_text("\n".join(conn.iterdump()) + "\n", encoding="utf-8")
with contextlib.closing(sqlite3.connect(f"file:{DB}?mode=ro", uri=True)) as conn:
ext = _vec0_extension()
if ext:
try:
conn.enable_load_extension(True)
conn.load_extension(str(ext))
conn.enable_load_extension(False)
except (sqlite3.OperationalError, AttributeError):
pass # iterdump below will fail with a clear "no such module" if this was needed
# Drop the sqlite_vec virtual tables and their shadow tables.
# iterdump() serializes a virtual table as a raw
# INSERT INTO sqlite_master(...) followed by inserts into a table
# the connection can't see yet, so replaying the dump dies with
# "no such table: vec_messages" and leaves ZERO tables behind -
# the whole backup was unrestorable. They're derived data anyway:
# _backfill_vec_msgs()/_backfill_vec() rebuild both indexes from
# message_vectors and documents on the next search.
# Filter the generator, not the joined text - each yield is one
# complete statement, while splitting the text on ";\n" tears
# apart INSERTs whose content contains newlines.
stmts = [s for s in conn.iterdump() if not _is_derived_stmt(s)]
DB_SQL.write_text("\n".join(stmts) + "\n", encoding="utf-8")
except sqlite3.Error as exc:
print(f"Warning: could not dump {DB} ({exc}) - DB not captured")
return False
@@ -158,26 +216,37 @@ def _extra(a, b) -> int:
'one box is simply ahead' from a real divergence."""
a_conv, a_mem = a
b_conv, b_mem = b
newer = sum(1 for cid, ts in a_conv.items() if b_conv.get(cid, "") < ts)
# Membership test first, never a placeholder default: updated_at is a REAL
# column, so ANY typed default is a cross-type comparison against the one
# case this function exists to count - a conversation the other side lacks.
# ("" < 1778553309.83 raises TypeError; 0 only worked by accident.)
newer = sum(1 for cid, ts in a_conv.items() if cid not in b_conv or b_conv[cid] < ts)
return newer + len(a_mem - b_mem)
def compare(db: Path = DB, dump: Path = DB_SQL) -> str:
"""Which way the sync should go. One of: same, local-ahead, local-behind,
diverged, no-live, no-dump.
diverged, no-live, no-dump, unreadable.
ponytail: detects direction, does not merge. Diverged is reported, not resolved."""
if not dump.exists() or not dump.stat().st_size:
return "no-dump"
if not db.exists():
return "no-live"
try:
with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as live_conn:
# closing(), not sqlite3's context manager: that one commits without
# closing, and restore_db() unlinks the DB right after calling this -
# Windows fails that unlink while any handle is still open.
with contextlib.closing(sqlite3.connect(f"file:{db}?mode=ro", uri=True)) as live_conn:
live = _state(live_conn)
with sqlite3.connect(":memory:") as dump_conn:
with contextlib.closing(sqlite3.connect(":memory:")) as dump_conn:
dump_conn.executescript(dump.read_text(encoding="utf-8"))
backup = _state(dump_conn)
except (sqlite3.Error, OSError):
return "diverged" # can't tell: fail safe, refuse both directions
# A dump that won't replay is NOT a divergence. Reporting it as one
# blocked backup and restore alike with a verdict that looked like a
# real answer - dumps written before the vec-table filter above land
# here every time. Still refuses both directions, but says why.
return "unreadable"
ahead, behind = _extra(live, backup), _extra(backup, live)
if ahead and behind:
return "diverged"
@@ -208,6 +277,12 @@ def restore_db() -> None:
if state == "no-dump":
print("No memory dump in the backup - skipping DB restore.")
return
if state == "unreadable":
print("WARNING: the backup's memory dump will not replay (written before the")
print(" vec-table fix). Not applying it - it would leave an empty DB.")
print(" Run `backup --force-db` on the machine with the good history")
print(" to publish a clean dump, then restore here.")
return
print("Restoring memory DB from backup...")
rollback = DB.with_suffix(".db.pre-restore")
@@ -216,7 +291,7 @@ def restore_db() -> None:
for suffix in ("", "-wal", "-shm"):
Path(str(DB) + suffix).unlink(missing_ok=True)
try:
with sqlite3.connect(DB) as conn:
with contextlib.closing(sqlite3.connect(DB)) as conn, conn:
conn.executescript(DB_SQL.read_text(encoding="utf-8"))
print("Memory DB restored (conversations + history + facts).")
except sqlite3.Error as exc:
@@ -318,6 +393,12 @@ def check_db_direction(tmp_dir: Path) -> bool:
print(" the other lacks. Nothing merges these automatically.")
print(" Force this box's history to win: python bin/sync.py backup --force-db")
return False
if state == "unreadable":
print("REFUSING TO BACK UP: the remote memory dump will not replay, so there is")
print(" no way to tell whether it holds history this machine lacks.")
print(" If this box has the good history: python bin/sync.py backup --force-db")
print(" (that publishes a clean dump and clears this for good).")
return False
return True
@@ -0,0 +1,88 @@
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.'
+118
View File
@@ -0,0 +1,118 @@
# NexusOS CLI
The Python package installs three equivalent command names: `nexus`, `ncp`,
and `nexusos`. New documentation uses `nexus`; `ncp` remains available for
existing desktop installs and scripts. Legacy spellings such as `ncp web`,
`ncp start -b`, `ncp refresh`, `ncp backup`, and `ncp restore` remain supported;
checkout-specific operations report a clear error when invoked from a wheel.
## Install
From a source checkout. Build the web UI first - it is a Vite artifact, so a
fresh clone does not have it, and an install without it serves the API only:
```bash
cd interface/web && npm ci && npm run build && cd ../..
python -m pip install -e ".[standard]"
nexus init
nexus doctor
```
From the package index after a release is published:
```bash
python -m pip install "nexusos-ai[standard]"
nexus init
nexus serve
```
The base install contains the backend, memory service, compiled web UI, CLI,
and seed playbooks. Extras keep platform-sensitive dependencies optional:
- `standard`: documents, vector search, web search, and process control
- `documents`: PDF and DOCX ingestion
- `vector`: sqlite-vec semantic indexes
- `voice`: local faster-whisper transcription
- `process`: psutil-backed process and port inspection
- `desktop`: desktop process support and Windows pywebview
- `search`: DuckDuckGo web search for chat
- `mail`: IMAP mail reading
- `all`: every optional capability at once
## Common commands
```text
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
nexus status [--json] Show services and provider reachability
nexus monitor [--once] [--json] ASCII live dashboard (services, resources, tools)
nexus serve Run backend + memory in the foreground
nexus start|stop|restart Manage background services
nexus open Open the compiled web interface
nexus logs [service] --follow Tail service logs
nexus models list|pull|remove Manage Ollama-compatible models
nexus config list|get|set|unset Manage persistent settings
```
API commands are also available directly:
```bash
nexus chat send "Hello"
nexus history list
nexus memory list
nexus playbook list
```
Run `nexus COMMAND --help` for command-specific arguments.
## Providers
Local desktop installs can allow NexusOS to start and stop a local Ollama:
```bash
nexus provider use local
```
For Termux, containers, or a separate inference machine, configure a remote
Ollama-compatible endpoint. NexusOS probes it but never manages its process:
```bash
nexus provider use remote --url http://192.168.1.20:11434
nexus provider show --json
```
## State and configuration
Installed wheels never write into `site-packages`. Writable files use the
platform data directory, while configuration uses the platform config
directory. Inspect the exact locations with `nexus paths`.
Environment variables override persisted settings. The most useful are:
Persisted keys are the same names, minus the `NEXUS_` prefix - `nexus config
set home /data/nexus` matches `NEXUS_HOME`. `nexus config list` shows what is
set; `nexus config set` warns when a change would point NexusOS at a database
that does not exist yet (the file is never moved for you).
```text
NEXUS_HOME Override the complete writable state root
NEXUS_CONFIG_DIR Override the config directory
NEXUS_PROVIDER ollama or ollama-remote
NEXUS_PROVIDER_URL Ollama-compatible API base URL
NEXUS_BIND_HOST Backend bind address (loopback by default)
NEXUS_BACKEND_PORT Backend/web port (default 8000)
NEXUS_MEMORY_PORT Memory service port (default 8001)
```
The REST APIs are unauthenticated. `nexus serve` refuses non-loopback binds
unless `--allow-lan` is given; that flag is an explicit acknowledgement, not
an authentication layer.
`--allow-lan` widens the accepted `Host` headers and CORS origins to the
addresses the bind actually answers on - it does **not** set them to `*`.
That keeps `TrustedHostMiddleware` enforcing something, which is what stops a
web page you visit from resolving a name it controls to your machine and
driving the API through your browser. Export `NEXUS_ALLOWED_HOSTS` yourself if
you genuinely need a blanket, and understand that anyone who can reach the
port has full admin and data access.
+49
View File
@@ -0,0 +1,49 @@
# Termux installation path
NexusOS is packaged so its Python runtime, memory database, playbooks, and
compiled web UI can run without a source checkout or Node.js. Inference is
configured separately through an Ollama-compatible HTTP endpoint; NexusOS does
not attempt to manage that remote process.
## Bootstrap
After `nexusos-ai` and a compatible Android `pydantic-core` wheel are published:
```bash
curl -fsSLO https://git.enderofwings.com/enderofwings/NexusOS/raw/branch/main/scripts/install-termux.sh
chmod +x install-termux.sh
NEXUS_ANDROID_WHEEL_INDEX=https://packages.example.invalid/android/simple \
./install-termux.sh
```
For a local release artifact, pass the wheel path or URL as the first argument:
```bash
NEXUS_ANDROID_WHEEL_INDEX=https://packages.example.invalid/android/simple \
./scripts/install-termux.sh ./dist/nexusos_ai-1.0.0-py3-none-any.whl
```
Then configure inference and serve the UI:
```bash
nexus provider use remote --url http://192.168.1.20:11434
nexus serve
termux-open-url http://127.0.0.1:8000
```
## Native wheel gate
Current Termux Python is 3.14, so Pydantic 1 is not a safe fallback. Pydantic 2
depends on the Rust-based `pydantic-core`. PyPI publishes Linux, macOS, Windows,
and WebAssembly wheels but no Android wheel, while the current Termux Rust
package cannot build common Rust extensions on-device.
The bootstrap script therefore requires a binary `pydantic-core` and accepts a
trusted PEP 503 wheel index through `NEXUS_ANDROID_WHEEL_INDEX`. It fails early
with the detected Python ABI and CPU when that artifact is missing. The release
pipeline can publish the pure NexusOS wheel today; an Android wheel job/index is
the remaining prerequisite for a one-line public Termux install.
Do not work around this by downloading an unverified binary or by exposing the
NexusOS server with `--allow-lan`. Keep the UI on loopback and let the Android
browser connect to `127.0.0.1`.
+55
View File
@@ -0,0 +1,55 @@
"""Build hook that ships the compiled web UI without breaking `pip install -e .`.
interface/web/dist is gitignored - it is a Vite build artifact, not source - so
a fresh clone does not have it. A static force-include of a missing path aborts
the build, which would make the README's first step ("pip install -e .") fail
before the reader ever gets to `npm run build`.
So the include is decided here instead:
* editable install -> skip a missing dist, the UI just isn't served yet
* wheel / sdist -> hard error naming the exact command to run
Set NEXUS_ALLOW_UILESS_BUILD=1 to build a deliberately headless distribution
(API and CLI only).
"""
from __future__ import annotations
import os
from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
_UI_SOURCE = Path("interface") / "web" / "dist"
_UI_TARGETS = {
"wheel": "synapse/_resources/web",
"sdist": "interface/web/dist",
}
class NexusBuildHook(BuildHookInterface):
PLUGIN_NAME = "custom"
def initialize(self, version: str, build_data: dict) -> None:
target = _UI_TARGETS.get(self.target_name)
if target is None:
return
source = Path(self.root) / _UI_SOURCE
if (source / "index.html").is_file():
build_data.setdefault("force_include", {})[str(source)] = target
return
if version == "editable" or os.getenv("NEXUS_ALLOW_UILESS_BUILD") == "1":
self.app.display_warning(
f"No compiled web UI at {_UI_SOURCE} - the backend will serve the "
"API only. Build it with: cd interface/web && npm ci && npm run build"
)
return
raise RuntimeError(
f"Cannot build a {self.target_name}: the compiled web UI is missing from "
f"{_UI_SOURCE}.\n"
"Build it first:\n"
" cd interface/web && npm ci && npm run build\n"
"Or set NEXUS_ALLOW_UILESS_BUILD=1 to ship an API/CLI-only distribution."
)
+4
View File
@@ -24,6 +24,10 @@ export default defineConfig([
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
// react-hooks 7.1 added this to recommended and it fires on plain
// fetch-on-mount effects (async load, setState only after the await),
// which is the pattern React's own docs give. All 7 hits were that.
'react-hooks/set-state-in-effect': 'off',
},
},
])
+5 -2
View File
@@ -1,12 +1,12 @@
{
"name": "web",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
"version": "1.0.0",
"version": "1.2.0",
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4"
@@ -21,6 +21,9 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"vite": "^8.0.4"
},
"engines": {
"node": ">=20.19"
}
},
"node_modules/@babel/code-frame": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "1.0.0",
"version": "1.2.0",
"type": "module",
"engines": {
"node": ">=20.19"
+229 -50
View File
@@ -4,8 +4,9 @@ import { Playbook } from "./Playbook";
import { Models } from "./Models";
import { Settings } from "./Settings";
import { Memory } from "./Memory";
import { Documents } from "./Documents";
import { Projects } from "./Projects";
import { Logs } from "./Logs";
import { MODULES } from "./modules/registry";
import { API_BASE } from "./config";
@@ -25,6 +26,10 @@ function App() {
const [ollamaStatus, setOllamaStatus] = useState("checking");
const [ollamaBusy, setOllamaBusy] = useState(false);
const [showStatusTooltip, setShowStatusTooltip] = useState(false);
const [showModulesMenu, setShowModulesMenu] = useState(false);
const [update, setUpdate] = useState(null);
const [updateBusy, setUpdateBusy] = useState(false);
const [updating, setUpdating] = useState(false);
const [activeConversationId, setActiveConversationId] = useState(() => crypto.randomUUID());
const [isModelPulling, setIsModelPulling] = useState(false);
@@ -47,6 +52,36 @@ function App() {
});
};
// Update check git fetch on the backend, so it is manual (and once at startup),
// not polled with loadStatus.
const checkUpdate = async () => {
setUpdateBusy(true);
try {
const r = await fetch(`${API_BASE}/update/check`);
setUpdate(await r.json());
} catch {
setUpdate({ error: "Backend unreachable" });
}
setUpdateBusy(false);
};
// Install the update: the backend spawns `ncp upgrade` detached and then gets
// stopped by it, so this request is the last one this build answers. The
// overlay effect below waits for the new build to come back.
const applyUpdate = async () => {
if (!confirm(`Install v${update?.remote_version} (${update?.behind} commit(s))?\n\n`
+ "NexusOS will pull, rebuild and restart. This takes a few minutes and "
+ "the page reloads itself when the new build is up.")) return;
try {
const r = await fetch(`${API_BASE}/update/apply`, { method: "POST" });
const d = await r.json();
if (!d.started) { alert(`Update could not start: ${d.error || "unknown error"}`); return; }
setUpdating(true);
} catch {
alert("Update could not start: backend unreachable.");
}
};
// Manual AI control Ollama does not auto-start with the app.
const toggleOllama = async () => {
const action = ollamaStatus === "running" ? "stop" : "start";
@@ -81,6 +116,26 @@ function App() {
return () => clearInterval(interval);
}, []);
useEffect(() => { checkUpdate(); }, []);
// Reload only after the backend has actually gone away and come back - it
// stays up for a few seconds after /update/apply returns, so a naive
// "poll until online" would reload the OLD build immediately.
useEffect(() => {
if (!updating) return;
let wentDown = false;
const t = setInterval(async () => {
try {
const r = await fetch(`${API_BASE}/status`, { cache: "no-store" });
if (!r.ok) throw new Error("not ok");
if (wentDown) window.location.reload();
} catch {
wentDown = true;
}
}, 3000);
return () => clearInterval(t);
}, [updating]);
useEffect(() => {
let cancelled = false;
fetch(`${API_BASE}/conversations`)
@@ -154,21 +209,53 @@ function App() {
}
};
// Bulk delete of everything currently listed (respects the search filter).
const clearConversations = async () => {
const n = conversations.length;
if (!n) return;
const what = search ? `${n} matching conversation${n > 1 ? "s" : ""}` : `all ${n} conversation${n > 1 ? "s" : ""}`;
if (!window.confirm(`Delete ${what}? This cannot be undone.`)) return;
const ids = conversations.map(c => c.id);
await Promise.all(
ids.map(id => fetch(`${API_BASE}/conversations/${id}`, { method: "DELETE" }).catch(err =>
console.error("Failed to delete conversation:", err)))
);
if (ids.includes(activeConversationId)) setActiveConversationId(crypto.randomUUID());
loadConversations(search);
};
const navItems = [
{ key: "chatbot", icon: "💬", label: "Chat" },
{ key: "playbook", icon: "📖", label: "Playbooks" },
{ key: "models", icon: "🤖", label: "Models", badge: isModelPulling },
{ key: "memory", icon: "🧠", label: "Memory" },
{ key: "documents", icon: "📄", label: "Documents" },
{ key: "projects", icon: "📁", label: "Projects" },
{ key: "modules", icon: "📦", label: "Modules", isModulesMenu: true },
{ key: "logs", icon: "📜", label: "Logs" },
{ key: "settings", icon: "⚙️", label: "Settings" },
];
return (
<div style={{ background: "#111", color: "#eee", height: "100vh", overflow: "hidden", fontFamily: "system-ui", display: "flex" }}>
{updating && (
<div style={{
position: "fixed", inset: 0, zIndex: 5000, background: "rgba(0,0,0,0.88)",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: "0.6rem", textAlign: "center", padding: "2rem",
}}>
<h2 style={{ color: "#007acc", margin: 0 }}>Updating NexusOS</h2>
<p style={{ color: "#aaa", margin: 0, maxWidth: "34rem" }}>
Pulling, rebuilding and restarting. This page reloads itself when the
new build is up a few minutes is normal.
</p>
<p style={{ color: "#666", fontSize: "0.75rem", margin: 0 }}>
Progress is logged to runtime/logs/update.log
</p>
</div>
)}
{/* Sidebar */}
<aside style={{
width: "220px",
width: "400px",
flexShrink: 0,
background: "#0a0a0a",
borderRight: "1px solid #333",
@@ -189,7 +276,24 @@ function App() {
<img src="/n small.png" alt="Logo" style={{ width: "36px", height: "36px", objectFit: "contain", borderRadius: "6px" }} />
<h1 style={{ fontSize: "0.9rem", margin: 0, color: "#007acc", textAlign: "center" }}>NexusOS</h1>
{version && (
<span style={{ fontSize: "0.65rem", color: "#666", marginTop: "-0.35rem", letterSpacing: "0.02em" }}>v{version}</span>
<span
onClick={checkUpdate}
title={updateBusy ? "Checking for updates..."
: update?.error ? `Update check failed: ${update.error}`
: update?.behind ? `${update.behind} commit(s) behind origin/main (${update.latest})`
: "Up to date - click to check for updates"}
style={{ fontSize: "0.65rem", color: "#666", marginTop: "-0.35rem", letterSpacing: "0.02em", cursor: "pointer" }}
>v{version}</span>
)}
{update?.behind > 0 && !updating && (
<span
onClick={applyUpdate}
title={`Update available: v${update.remote_version} (${update.behind} commit(s) behind).\nClick to install and restart.`}
style={{
fontSize: "0.6rem", color: "#ff9800", border: "1px solid #ff9800",
borderRadius: "8px", padding: "0.05rem 0.4rem", cursor: "pointer",
}}
>update available</span>
)}
{/* Status Indicator */}
@@ -201,7 +305,7 @@ function App() {
borderRadius: "50%",
background: status === "Offline" || ollamaStatus === "unavailable" ? "#f44336"
: ollamaStatus === "running" ? "#4caf50" : "#ff9800",
cursor: "pointer",
cursor: (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") ? "not-allowed" : "pointer",
transition: "all 0.3s"
}}
onMouseEnter={() => {
@@ -209,7 +313,11 @@ function App() {
setShowStatusTooltip(true);
}}
onMouseLeave={() => setShowStatusTooltip(false)}
title="Hover to refresh"
onClick={() => {
if (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") return;
toggleOllama();
}}
title={ollamaStatus === "unavailable" ? "Ollama binary not found" : "Click to start/stop the AI"}
/>
{showStatusTooltip && (
<div style={{
@@ -230,58 +338,109 @@ function App() {
Synapse: <strong>{status}</strong>
</div>
<div style={{ color: ollamaStatus === "unavailable" ? "#f44336" : ollamaStatus === "running" ? "#4caf50" : "#aaa" }}>
Ollama: <strong>{ollamaStatus}</strong>
{/* Naming the phase matters: nearly all of the wait is the model
being read off disk (GBs), not the server booting. */}
Ollama: <strong>{ollamaBusy ? (ollamaStatus === "running" ? "stopping…" : "loading model…") : ollamaStatus}</strong>
</div>
</div>
)}
</div>
{/* Manual AI start/stop — Ollama is off until the user turns it on */}
<button
onClick={toggleOllama}
disabled={ollamaBusy || ollamaStatus === "unavailable" || status === "Offline"}
title={ollamaStatus === "unavailable" ? "Ollama binary not found" : "Start or stop the AI"}
style={{
width: "100%",
padding: "0.4rem 0.6rem",
background: ollamaStatus === "running" ? "#2a1a1a" : "#152a15",
color: ollamaStatus === "running" ? "#ff8a80" : "#8aff8a",
border: "1px solid " + (ollamaStatus === "running" ? "#5a2a2a" : "#2a5a2a"),
borderRadius: "6px",
cursor: (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") ? "not-allowed" : "pointer",
fontSize: "0.78rem",
opacity: (ollamaStatus === "unavailable" || status === "Offline") ? 0.5 : 1,
}}
>
{/* Naming the phase matters: nearly all of the wait is the model
being read off disk (GBs), not the server booting. "…" for a
minute reads as hung. */}
{ollamaBusy ? (ollamaStatus === "running" ? "Stopping…" : "Loading model…")
: ollamaStatus === "running" ? "⏹ Stop AI" : "▶ Start AI"}
</button>
{/* Start/Stop AI moved onto the status dot above — click it to toggle. */}
<nav style={{ display: "flex", flexDirection: "column", width: "100%", marginTop: "0.1rem" }}>
{navItems.map(item => {
const isActive = currentPage === item.key;
const isActive = item.isModulesMenu
? MODULES.some(m => m.key === currentPage)
: currentPage === item.key;
const buttonStyle = {
padding: "0.32rem 0.5rem",
background: "transparent",
color: isActive ? "#4aa3e0" : "#bbb",
fontWeight: isActive ? 600 : 400,
border: "none",
borderLeft: `2px solid ${isActive ? "#007acc" : "transparent"}`,
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
textAlign: "left",
transition: "background 0.12s, color 0.12s",
display: "flex",
alignItems: "center",
gap: "0.45rem",
width: "100%",
};
if (item.isModulesMenu) {
return (
<div
key={item.key}
style={{ position: "relative" }}
onMouseEnter={() => setShowModulesMenu(true)}
onMouseLeave={() => setShowModulesMenu(false)}
>
<button
onClick={() => setShowModulesMenu(v => !v)}
style={buttonStyle}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
>
<span style={{ fontSize: "0.85rem", width: "1rem", textAlign: "center", flexShrink: 0 }}>{item.icon}</span>
<span style={{ flexGrow: 1 }}>{item.label}</span>
</button>
{showModulesMenu && (
<div style={{
position: "absolute",
left: "100%",
top: 0,
marginLeft: "0.4rem",
background: "#1a1a1a",
border: "1px solid #333",
borderRadius: "6px",
padding: "0.35rem",
minWidth: "150px",
zIndex: 1000,
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
}}>
{MODULES.length === 0 ? (
<div style={{ padding: "0.35rem 0.5rem", color: "#666", fontSize: "0.78rem" }}>No modules installed.</div>
) : MODULES.map(m => {
const moduleActive = currentPage === m.key;
return (
<div
key={m.key}
onClick={() => { setCurrentPage(m.key); setShowModulesMenu(false); }}
style={{
padding: "0.35rem 0.5rem",
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
whiteSpace: "nowrap",
display: "flex",
alignItems: "center",
gap: "0.45rem",
background: "transparent",
borderLeft: `2px solid ${moduleActive ? "#007acc" : "transparent"}`,
color: moduleActive ? "#4aa3e0" : "#bbb",
fontWeight: moduleActive ? 600 : 400,
}}
>
<span style={{ fontSize: "0.85rem", width: "1rem", textAlign: "center", flexShrink: 0 }}>{m.icon}</span>
<span>{m.label}</span>
</div>
);
})}
</div>
)}
</div>
);
}
return (
<button
key={item.key}
onClick={() => setCurrentPage(item.key)}
style={{
padding: "0.32rem 0.5rem",
background: isActive ? "#1c2733" : "transparent",
color: isActive ? "#fff" : "#bbb",
border: "none",
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
textAlign: "left",
transition: "background 0.12s, color 0.12s",
display: "flex",
alignItems: "center",
gap: "0.45rem",
width: "100%",
}}
style={buttonStyle}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
>
@@ -330,6 +489,22 @@ function App() {
>
Export
</button>
<button
onClick={clearConversations}
title={search ? "Delete the conversations matching this search" : "Delete all conversations"}
disabled={conversations.length === 0}
style={{
padding: "0.2rem 0.5rem",
background: "#161616",
color: conversations.length === 0 ? "#555" : "#c66",
border: "1px solid #2a2a2a",
borderRadius: "6px",
cursor: conversations.length === 0 ? "default" : "pointer",
fontSize: "0.75rem",
}}
>
🗑 Clear
</button>
<button
onClick={startNewChat}
title="New chat"
@@ -385,8 +560,9 @@ function App() {
marginBottom: "0.3rem",
borderRadius: "6px",
cursor: "pointer",
background: isActive ? "#1c2a3a" : (isHover ? "#161616" : "transparent"),
border: `1px solid ${isActive ? "#2a4a6a" : "transparent"}`,
background: isHover ? "#161616" : "transparent",
border: "1px solid transparent",
borderLeft: `2px solid ${isActive ? "#007acc" : "transparent"}`,
display: "flex",
alignItems: "center",
gap: "0.4rem",
@@ -395,7 +571,8 @@ function App() {
<div style={{ flexGrow: 1, minWidth: 0 }}>
<div style={{
fontSize: "0.82rem",
color: isActive ? "#eee" : "#ccc",
color: isActive ? "#4aa3e0" : "#ccc",
fontWeight: isActive ? 600 : 400,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
@@ -485,6 +662,7 @@ function App() {
minHeight: 0,
}}>
<Chatbot
visible={currentPage === "chatbot"}
conversationId={activeConversationId}
setConversationId={setActiveConversationId}
onConversationChanged={() => loadConversations(search)}
@@ -492,7 +670,8 @@ function App() {
</div>
{currentPage === "playbook" && <Playbook />}
{currentPage === "memory" && <Memory />}
{currentPage === "documents" && <Documents />}
{currentPage === "projects" && <Projects onOpenChat={selectConversation} onNewChat={startNewChat} />}
{MODULES.map(m => currentPage === m.key && <m.Component key={m.key} />)}
{currentPage === "logs" && <Logs />}
{currentPage === "settings" && <Settings />}
</main>
+108 -61
View File
@@ -3,17 +3,17 @@ import { useState, useRef, useEffect } from "react";
import { API_BASE } from "./config";
import { Markdown } from "./Markdown";
export function Chatbot({ conversationId, setConversationId, onConversationChanged }) {
export function Chatbot({ visible = true, conversationId, setConversationId, onConversationChanged }) {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [queue, setQueue] = useState([]); // messages typed while a reply was streaming, sent in order once it's free
const [modelList, setModelList] = useState([]);
const [selectedModel, setSelectedModel] = useState(""); // "" = auto
const [autoModel, setAutoModel] = useState(null);
const [think, setThink] = useState(false); // extended thinking, mirrors Settings
const [showPicker, setShowPicker] = useState(false);
const [copiedIdx, setCopiedIdx] = useState(null);
const [lastStats, setLastStats] = useState(null);
const [memoryToast, setMemoryToast] = useState(null);
const [images, setImages] = useState([]); // {name, b64} for vision models
const [activeTool, setActiveTool] = useState(null); // playbook tool currently running
@@ -122,18 +122,27 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
if (!conversationId) return;
if (abortRef.current) abortRef.current.abort();
let cancelled = false;
setLastStats(null);
fetch(`${API_BASE}/conversations/${conversationId}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (cancelled) return;
setMessages(data?.messages?.map(m => ({ role: m.role, content: m.content })) || []);
// tokens/model are persisted per message; elapsed/rate are live-only.
setMessages(data?.messages?.map(m => ({
role: m.role, content: m.content, model: m.model,
stats: m.tokens ? { tokens: m.tokens } : undefined,
})) || []);
})
.catch(() => { if (!cancelled) setMessages([]); });
return () => { cancelled = true; };
}, [conversationId]);
// Keyed on `visible`, not []: this component stays mounted while other pages
// show (App hides it with display:none so an in-flight reply survives
// navigation), so a mount-once fetch left the picker showing whatever was
// installed when the tab first opened a model pulled on the Models page
// didn't appear here until a full browser reload.
useEffect(() => {
if (!visible) return;
Promise.all([
fetch(`${API_BASE}/models`).then(r => r.ok ? r.json() : null),
fetch(`${API_BASE}/settings`).then(r => r.ok ? r.json() : null),
@@ -145,7 +154,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
setThink(!!settings.think);
}
}).catch(() => {});
}, []);
}, [visible]);
useEffect(() => {
if (!showPicker) return;
@@ -156,6 +165,16 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
return () => document.removeEventListener("mousedown", handle);
}, [showPicker]);
// Declared ahead of sendMessage: it is called from there, and a const arrow
// defined further down is still in the TDZ as far as the linter is concerned.
const updateAssistant = (index, text) => {
setMessages(prev => {
const updated = [...prev];
updated[index] = { ...updated[index], content: text };
return updated;
});
};
const setModelChoice = async (model) => {
setSelectedModel(model);
setShowPicker(false);
@@ -183,8 +202,8 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
const startNewChat = () => {
if (abortRef.current) abortRef.current.abort();
setInput("");
setQueue([]);
setLoading(false);
setLastStats(null);
setConversationId(crypto.randomUUID());
};
@@ -259,15 +278,12 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
if (pendingEventType === "meta") {
try {
const stats = JSON.parse(payload);
setLastStats(stats);
// Tag this message with the model that answered
if (stats.model) {
setMessages(prev => {
const updated = [...prev];
updated[assistantIndex] = { ...updated[assistantIndex], model: stats.model };
return updated;
});
}
// Tag this message with the model that answered and its token stats
setMessages(prev => {
const updated = [...prev];
updated[assistantIndex] = { ...updated[assistantIndex], model: stats.model, stats };
return updated;
});
} catch { /* ignore */ }
pendingEventType = null;
continue;
@@ -365,14 +381,8 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
messages.slice(0, index).filter(m => m.content.trim() !== "")
.map(m => ({ role: m.role, content: m.content }));
const sendMessage = async () => {
if ((!input.trim() && images.length === 0) || loading) return;
const userMessage = input.trim();
const outImages = images.map(i => i.b64);
setInput("");
setImages([]);
// Shared by an immediate send and an auto-flushed queued message.
const doSend = async (userMessage, outImages = []) => {
// Note any attached images so image-only turns aren't blank
const shownContent = outImages.length
? `${userMessage}${userMessage ? "\n\n" : ""}📷 ${outImages.length} image${outImages.length === 1 ? "" : "s"} attached`
@@ -383,6 +393,35 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
await streamAssistant({ message: userMessage, history, images: outImages, assistantIndex });
};
const sendMessage = async () => {
if ((!input.trim() && images.length === 0) || loading) return;
const userMessage = input.trim();
const outImages = images.map(i => i.b64);
setInput("");
setImages([]);
await doSend(userMessage, outImages);
};
// Enter while a reply is streaming queues the message instead of sending it;
// it's auto-sent, in order, once the current reply finishes (see the flush effect below).
const queueMessage = () => {
const text = input.trim();
if (!text) return;
setQueue(prev => [...prev, text]);
setInput("");
};
const removeQueued = (idx) => setQueue(prev => prev.filter((_, i) => i !== idx));
// Flush one queued message each time the box goes idle.
useEffect(() => {
if (loading || queue.length === 0) return;
const [next, ...rest] = queue;
setQueue(rest);
doSend(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, queue]);
const regenerate = async () => {
if (loading) return;
const lastUserIdx = messages.map(m => m.role).lastIndexOf("user");
@@ -426,36 +465,16 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
if (pendingApproval) resolveApproval(false); // stopping = deny pending actions
};
const updateAssistant = (index, text) => {
setMessages(prev => {
const updated = [...prev];
updated[index] = { ...updated[index], content: text };
return updated;
});
};
const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
if (loading) queueMessage();
else sendMessage();
}
};
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", flexGrow: 1 }}>
{memoryToast && (
<div style={{
position: "fixed", bottom: "1.5rem", right: "1.5rem",
background: "#1a1a2e", border: "1px solid #7c3aed",
borderRadius: "8px", padding: "0.6rem 1rem",
fontSize: "0.85rem", color: "#c4b5fd",
boxShadow: "0 4px 12px rgba(0,0,0,0.5)", zIndex: 9999,
maxWidth: "320px"
}}>
🧠 <strong>Memory saved</strong> [{memoryToast.section}]<br />
<span style={{ color: "#aaa", fontSize: "0.8rem" }}>{memoryToast.text}</span>
</div>
)}
<div style={{
flexGrow: 1,
background: "#161616",
@@ -568,19 +587,6 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
{think ? "on" : "off"}
</span>
</button>
{lastStats && (
<span style={{
fontSize: "0.7rem",
color: "#888",
background: "#1a1a1a",
border: "1px solid #2a2a2a",
borderRadius: "4px",
padding: "0.15rem 0.45rem",
}}>
{lastStats.tokens} tok · {lastStats.elapsed_s}s
{lastStats.tokens_per_s > 0 ? ` · ${lastStats.tokens_per_s} t/s` : ""}
</span>
)}
</div>
<button
onClick={startNewChat}
@@ -698,6 +704,14 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
Regenerate
</button>
)}
{msg.stats && (
<span style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: "#555" }}>
{msg.stats.tokens} tok
{msg.stats.elapsed_s ? ` · ${msg.stats.elapsed_s}s` : ""}
{msg.stats.tokens_per_s > 0 ? ` · ${msg.stats.tokens_per_s} t/s` : ""}
{msg.model ? ` · ${msg.model}` : ""}
</span>
)}
{msg.role === "assistant" && ttsSupported && (
<button
onClick={() => speak(idx, msg.content)}
@@ -755,6 +769,28 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
))}
</div>
)}
{queue.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "0.3rem", marginBottom: "0.5rem" }}>
{queue.map((text, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.35rem 0.6rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "8px", color: "#999", fontSize: "0.8rem" }}>
<span style={{ color: "#557", flexShrink: 0 }}>Queued #{i + 1}</span>
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flexGrow: 1 }}>{text}</span>
<button onClick={() => removeQueued(i)}
style={{ background: "none", border: "none", color: "#ff8a80", cursor: "pointer", padding: 0, flexShrink: 0 }}></button>
</div>
))}
</div>
)}
{memoryToast && (
<div style={{
marginBottom: "0.5rem", padding: "0.5rem 0.9rem",
background: "#1a1a2e", border: "1px solid #7c3aed",
borderRadius: "10px", fontSize: "0.85rem", color: "#c4b5fd",
}}>
🧠 <strong>Memory saved</strong> [{memoryToast.section}]{" "}
<span style={{ color: "#aaa", fontSize: "0.8rem" }}>{memoryToast.text}</span>
</div>
)}
<div style={{ display: "flex", gap: "0.75rem", alignItems: "stretch" }}>
<label title="Attach image (needs a vision model)"
style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0 0.9rem", background: "#222", border: "1px solid #333", borderRadius: "10px", cursor: loading ? "default" : "pointer", opacity: loading ? 0.6 : 1, color: "#ccc" }}>
@@ -772,8 +808,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message... (Shift+Enter for new line)"
disabled={loading}
placeholder={loading ? "Keep typing — Enter queues it for after this reply..." : "Type your message... (Shift+Enter for new line)"}
rows={3}
style={{
flexGrow: 1,
@@ -784,9 +819,21 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
borderRadius: "10px",
fontFamily: "system-ui",
resize: "none",
opacity: loading ? 0.6 : 1
}}
/>
{loading && input.trim() && (
<button
onClick={queueMessage}
title="Queue this message for after the current reply"
style={{
display: "flex", alignItems: "center", justifyContent: "center",
padding: "0 1.25rem", background: "#2a2a4a", color: "#fff",
border: "1px solid #444a7a", borderRadius: "8px", cursor: "pointer",
}}
>
Queue
</button>
)}
<button
onClick={loading ? stopGeneration : sendMessage}
disabled={!loading && !input.trim() && images.length === 0}
-229
View File
@@ -1,229 +0,0 @@
import { useEffect, useState } from "react";
import { API_BASE } from "./config";
// RAG document manager: upload/paste text, chunked + embedded server-side, then
// retrieved into the chat system prompt. See synapse/memory/store.py.
export function Documents() {
const [docs, setDocs] = useState([]);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [viewing, setViewing] = useState(null); // {title, chunks} being previewed
const [projects, setProjects] = useState([]);
const [activeProject, setActiveProject] = useState(""); // "" = All
const loadProjects = async () => {
try {
const r = await fetch(`${API_BASE}/projects`);
if (r.ok) { const d = await r.json(); setProjects(d.projects || []); setActiveProject(d.active || ""); }
} catch { /* offline */ }
};
// Switch the active workspace (persisted in settings; scopes chat RAG too).
const setActive = async (pid) => {
await fetch(`${API_BASE}/settings`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active_project: pid }),
});
setActiveProject(pid);
load();
loadProjects();
};
const newProject = async () => {
const name = window.prompt("New project name:");
if (!name || !name.trim()) return;
const r = await fetch(`${API_BASE}/projects`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim() }),
});
if (r.ok) { const p = await r.json(); await setActive(p.id); }
};
const removeProject = async () => {
if (!activeProject) return;
if (!window.confirm("Delete this project? Its documents are kept but become unscoped.")) return;
await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, { method: "DELETE" });
await setActive("");
};
const openDoc = async (doc) => {
try {
const r = await fetch(`${API_BASE}/documents/${encodeURIComponent(doc.doc_id)}`);
if (r.ok) setViewing({ title: doc.title, chunks: (await r.json()).chunks || [] });
} catch { /* ignore */ }
};
const load = async () => {
try {
const r = await fetch(`${API_BASE}/documents`);
if (r.ok) setDocs((await r.json()).documents || []);
} catch { /* offline — leave list as-is */ }
};
useEffect(() => { load(); loadProjects(); }, []);
// Files (pdf/docx/txt/md) upload straight to the server, which extracts the
// text. Base64 in JSON no multipart dependency.
const onFile = (e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-selecting the same file
if (!file) return;
setBusy(true);
setMessage(`Reading ${file.name}`);
const reader = new FileReader();
reader.onload = async () => {
const b64 = String(reader.result || "").split(",")[1];
try {
const r = await fetch(`${API_BASE}/documents/upload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, data: b64 }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index file.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
}
};
reader.readAsDataURL(file);
};
const addDoc = async () => {
if (!title.trim() || !content.trim()) {
setMessage("Title and content are required.");
return;
}
setBusy(true);
setMessage("Chunking and embedding…");
try {
const r = await fetch(`${API_BASE}/documents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim(), content }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
setTitle("");
setContent("");
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
}
};
const removeDoc = async (docId) => {
try {
await fetch(`${API_BASE}/documents/${encodeURIComponent(docId)}`, { method: "DELETE" });
load();
} catch { /* ignore */ }
};
const input = { padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px", width: "100%", boxSizing: "border-box" };
return (
<div style={{ padding: "1.5rem", color: "#eee", maxWidth: "820px" }}>
<h2 style={{ marginTop: 0 }}>📄 Documents</h2>
<p style={{ color: "#aaa", marginTop: 0 }}>
Upload or paste text. It's chunked, embedded, and pulled into chat as source
material when a message is relevant.
</p>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1.2rem", flexWrap: "wrap" }}>
<span style={{ color: "#888", fontSize: "0.85rem" }}>Workspace:</span>
<select
value={activeProject}
onChange={e => (e.target.value === "__new__" ? newProject() : setActive(e.target.value))}
style={{ padding: "0.5rem 0.7rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px" }}
>
<option value="">All documents</option>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name} ({p.docs})</option>
))}
<option value="__new__"> New project</option>
</select>
{activeProject && (
<button onClick={removeProject} title="Delete this project (documents kept)"
style={{ padding: "0.4rem 0.7rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer", fontSize: "0.8rem" }}>
Delete project
</button>
)}
<span style={{ color: "#666", fontSize: "0.78rem" }}>
{activeProject ? "Chat scopes to this project's documents." : "Chat searches all documents."}
</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0.7rem", marginBottom: "1.5rem" }}>
<input type="text" placeholder="Title" value={title}
onChange={e => setTitle(e.target.value)} style={input} />
<textarea rows={8} placeholder="Paste text here, or upload a .pdf/.docx/.txt/.md file below"
value={content} onChange={e => setContent(e.target.value)} style={input} />
<div style={{ display: "flex", gap: "0.7rem", alignItems: "center" }}>
<input type="file" accept=".pdf,.docx,.txt,.md,.markdown,text/*" onChange={onFile} disabled={busy}
title="Upload a file — text extracted server-side"
style={{ color: "#aaa", flex: 1 }} />
<button onClick={addDoc} disabled={busy}
style={{ padding: "0.9rem 1.5rem", background: busy ? "#555" : "#007acc", color: "#fff", border: "none", borderRadius: "8px", cursor: busy ? "default" : "pointer" }}>
{busy ? "Indexing…" : "Add document"}
</button>
</div>
{message && <div style={{ color: "#8ab4ff" }}>{message}</div>}
</div>
{docs.length === 0 ? (
<div style={{ color: "#777" }}>No documents yet.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{docs.map(d => (
<div key={d.doc_id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.8rem 1rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "10px" }}>
<div onClick={() => openDoc(d)} style={{ cursor: "pointer", flex: 1 }} title="View chunks">
<div style={{ fontWeight: 600 }}>{d.title}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>{d.chunks} chunk{d.chunks === 1 ? "" : "s"}</div>
</div>
<button onClick={() => removeDoc(d.doc_id)}
style={{ padding: "0.5rem 0.9rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer" }}>
Delete
</button>
</div>
))}
</div>
)}
{viewing && (
<div onClick={() => setViewing(null)}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100, padding: "2rem" }}>
<div onClick={e => e.stopPropagation()}
style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", maxWidth: "700px", width: "100%", maxHeight: "80vh", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "1rem 1.25rem", borderBottom: "1px solid #2a2a2a" }}>
<strong>{viewing.title}</strong>
<button onClick={() => setViewing(null)}
style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}></button>
</div>
<div style={{ overflowY: "auto", padding: "1rem 1.25rem" }}>
{viewing.chunks.map(c => (
<div key={c.chunk_idx} style={{ marginBottom: "1rem" }}>
<div style={{ color: "#666", fontSize: "0.75rem", marginBottom: "0.3rem" }}>chunk {c.chunk_idx + 1}</div>
<div style={{ whiteSpace: "pre-wrap", color: "#ddd", fontSize: "0.9rem", background: "#141414", border: "1px solid #262626", borderRadius: "8px", padding: "0.7rem" }}>{c.text}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
+20 -1
View File
@@ -44,6 +44,7 @@ export function Memory() {
const [message, setMessage] = useState("");
const [draggingId, setDraggingId] = useState(null);
const [dragOver, setDragOver] = useState(null); // { id, position: "before"|"after" }
const [projectNames, setProjectNames] = useState({}); // id -> name, for the scope badge
const loadItems = useCallback(async () => {
try {
@@ -57,6 +58,15 @@ export function Memory() {
useEffect(() => { loadItems(); }, [loadItems]);
// Facts scoped to a project only reach that project's chats label them so
// this page doesn't read as "everything here applies everywhere".
useEffect(() => {
fetch(`${API_BASE}/projects`)
.then(r => r.ok ? r.json() : { projects: [] })
.then(d => setProjectNames(Object.fromEntries((d.projects || []).map(p => [p.id, p.name]))))
.catch(() => {});
}, []);
const flash = (msg) => { setMessage(msg); setTimeout(() => setMessage(""), 3000); };
const saveNew = async (section, text) => {
@@ -386,7 +396,16 @@ export function Memory() {
style={{ display: "flex", alignItems: "flex-start", gap: "0.5rem", cursor: "grab" }}
>
<span style={{ color: "#aaa", marginTop: "0.1rem", flexShrink: 0 }}>-</span>
<span style={{ color: "#ddd", fontSize: "0.9rem", flexGrow: 1 }}>{displayText}</span>
<span style={{ color: "#ddd", fontSize: "0.9rem", flexGrow: 1 }}>
{displayText}
{item.project_id && (
<span title="Only injected into this project's chats"
style={{ marginLeft: "0.5rem", padding: "0.05rem 0.4rem", background: "#1b2b3a",
border: "1px solid #2c4a63", borderRadius: "6px", color: "#8ab4ff", fontSize: "0.72rem" }}>
{projectNames[item.project_id] || "project"}
</span>
)}
</span>
<div style={{ display: "flex", gap: "0.3rem", flexShrink: 0, opacity: 0.4 }}
onMouseEnter={e => e.currentTarget.style.opacity = 1}
onMouseLeave={e => e.currentTarget.style.opacity = 0.4}
+10 -4
View File
@@ -20,6 +20,8 @@ const GRID_STYLE = {
alignItems: "stretch",
};
const withTag = (n) => (n.includes(":") ? n : `${n}:latest`).toLowerCase();
function ModelCard({ model, installed, pulling, pullingName, locked, onPull }) {
const fit = FIT[model.fit] || FIT.no;
const isPullingThis = pulling && pullingName === model.name;
@@ -224,8 +226,12 @@ export function Models({ onPullStateChange }) {
return () => clearInterval(interval);
}, [loadModels]);
// Ollama treats a bare name as ":latest" you pull "nomic-embed-text" and it
// comes back installed as "nomic-embed-text:latest". Comparing raw names left
// any untagged catalog entry permanently "missing", which locked the Required
// gate shut no matter how many times it was pulled.
const installedNames = useMemo(
() => new Set(allInstalled.map(m => m.name.toLowerCase())),
() => new Set(allInstalled.map(m => withTag(m.name))),
[allInstalled]
);
@@ -237,7 +243,7 @@ export function Models({ onPullStateChange }) {
() => (recommended?.models ?? []).filter(m => !m.required),
[recommended]
);
const requiredMissing = requiredModels.filter(m => !installedNames.has(m.name.toLowerCase()));
const requiredMissing = requiredModels.filter(m => !installedNames.has(withTag(m.name)));
const requiredSatisfied = recommended != null && requiredMissing.length === 0;
return (
@@ -351,7 +357,7 @@ export function Models({ onPullStateChange }) {
<ModelCard
key={m.name}
model={m}
installed={installedNames.has(m.name.toLowerCase())}
installed={installedNames.has(withTag(m.name))}
pulling={pulling}
pullingName={pullingName}
locked={false}
@@ -372,7 +378,7 @@ export function Models({ onPullStateChange }) {
<ModelCard
key={m.name}
model={m}
installed={installedNames.has(m.name.toLowerCase())}
installed={installedNames.has(withTag(m.name))}
pulling={pulling}
pullingName={pullingName}
locked={!requiredSatisfied}
-1
View File
@@ -29,7 +29,6 @@ export function Playbook() {
}
}, []);
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch on mount; setPlaybooks runs after await, not a synchronous cascading render
useEffect(() => { loadPlaybooks(); }, [loadPlaybooks]);
const safeString = (v) => (v == null ? "" : String(v));
+430
View File
@@ -0,0 +1,430 @@
import { useEffect, useState } from "react";
import { API_BASE } from "./config";
// Project workspace: a project groups chats and RAG documents. The active
// project is persisted in settings, and chat scopes its document retrieval to
// it (see synapse/main.py chat_stream_endpoint). "" = unscoped / All.
const input = { padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px", width: "100%", boxSizing: "border-box" };
const card = { padding: "0.8rem 1rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "10px", display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.6rem" };
const btn = { padding: "0.55rem 1rem", background: "#007acc", color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" };
const ghost = { padding: "0.55rem 1rem", background: "#1a1a1a", color: "#ccc", border: "1px solid #333", borderRadius: "8px", cursor: "pointer" };
const danger = { padding: "0.5rem 0.9rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer", fontSize: "0.8rem" };
function Modal({ title, onClose, children }) {
return (
<div onClick={onClose}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100, padding: "2rem" }}>
<div onClick={e => e.stopPropagation()}
style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", maxWidth: "700px", width: "100%", maxHeight: "80vh", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "1rem 1.25rem", borderBottom: "1px solid #2a2a2a" }}>
<strong>{title}</strong>
<button onClick={onClose} style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}></button>
</div>
<div style={{ overflowY: "auto", padding: "1rem 1.25rem" }}>{children}</div>
</div>
</div>
);
}
export function Projects({ onOpenChat, onNewChat }) {
const [docs, setDocs] = useState([]);
const [chats, setChats] = useState([]);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [viewing, setViewing] = useState(null); // {title, chunks} preview
const [uploading, setUploading] = useState(false); // upload modal open
const [picking, setPicking] = useState(null); // conversations available to add
const [projects, setProjects] = useState([]);
const [activeProject, setActiveProject] = useState(""); // "" = All
const [instructions, setInstructions] = useState(""); // per-project system prompt
const [savedInstructions, setSavedInstructions] = useState("");
const [facts, setFacts] = useState([]); // memory scoped to this project
const [newFact, setNewFact] = useState("");
const [dragging, setDragging] = useState(false); // file hovering the drop zone
const loadProjects = async () => {
try {
const r = await fetch(`${API_BASE}/projects`);
if (!r.ok) return;
const d = await r.json();
setProjects(d.projects || []);
setActiveProject(d.active || "");
const mine = (d.projects || []).find(p => p.id === (d.active || ""));
setInstructions(mine?.instructions || "");
setSavedInstructions(mine?.instructions || "");
} catch { /* offline */ }
};
const load = async (pid) => {
const scope = pid ?? activeProject;
if (!scope) { setDocs([]); setChats([]); setFacts([]); return; }
try {
const r = await fetch(`${API_BASE}/documents`);
if (r.ok) setDocs((await r.json()).documents || []);
const c = await fetch(`${API_BASE}/conversations?project=${encodeURIComponent(scope)}`);
if (c.ok) setChats((await c.json()).conversations || []);
const m = await fetch(`${API_BASE}/memory?project=${encodeURIComponent(scope)}`);
if (m.ok) setFacts((await m.json()).items || []);
} catch { /* offline — leave lists as-is */ }
};
useEffect(() => { loadProjects().then(() => {}); }, []);
useEffect(() => { load(activeProject); }, [activeProject]); // eslint-disable-line react-hooks/exhaustive-deps
// Switch the active workspace (persisted in settings; scopes chat RAG and
// any new conversation started from here).
const setActive = async (pid) => {
await fetch(`${API_BASE}/settings`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active_project: pid }),
});
setActiveProject(pid);
loadProjects();
};
const newProject = async () => {
const name = window.prompt("New project name:");
if (!name || !name.trim()) return;
const r = await fetch(`${API_BASE}/projects`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim() }),
});
if (r.ok) { const p = await r.json(); await setActive(p.id); }
};
const removeProject = async () => {
if (!activeProject) return;
if (!window.confirm("Delete this project? Its chats and documents are kept but become unscoped.")) return;
await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, { method: "DELETE" });
await setActive("");
};
// --- instructions --------------------------------------------------------
const saveInstructions = async () => {
await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instructions }),
});
setSavedInstructions(instructions);
loadProjects();
};
// --- memory facts scoped to this project ---------------------------------
const addFact = async () => {
const text = newFact.trim();
if (!text) return;
await fetch(`${API_BASE}/memory`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, section: "Projects", project_id: activeProject }),
});
setNewFact("");
load();
};
const removeFact = async (id) => {
await fetch(`${API_BASE}/memory/${encodeURIComponent(id)}`, { method: "DELETE" });
load();
};
// --- chats ---------------------------------------------------------------
const moveChat = async (id, pid) => {
await fetch(`${API_BASE}/conversations/${encodeURIComponent(id)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: pid }),
});
load();
};
const openPicker = async () => {
const r = await fetch(`${API_BASE}/conversations`);
if (r.ok) setPicking(((await r.json()).conversations || []).filter(c => c.project_id !== activeProject));
};
const addChat = async (id) => {
await moveChat(id, activeProject);
setPicking(p => (p || []).filter(c => c.id !== id));
};
// --- documents -----------------------------------------------------------
const openDoc = async (doc) => {
try {
const r = await fetch(`${API_BASE}/documents/${encodeURIComponent(doc.doc_id)}`);
if (r.ok) setViewing({ title: doc.title, chunks: (await r.json()).chunks || [] });
} catch { /* ignore */ }
};
// Files (pdf/docx/txt/md) upload straight to the server, which extracts the
// text. Base64 in JSON no multipart dependency.
const onFile = (e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-selecting the same file
if (file) uploadFile(file);
};
// Resolves when the file is indexed, so a multi-file drop uploads in order
// instead of racing (each one re-renders the list on completion).
const uploadFile = (file) => new Promise((resolve) => {
setBusy(true);
setMessage(`Reading ${file.name}`);
const reader = new FileReader();
reader.onload = async () => {
const b64 = String(reader.result || "").split(",")[1];
try {
const r = await fetch(`${API_BASE}/documents/upload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, data: b64 }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index file.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
resolve();
}
};
reader.readAsDataURL(file);
});
const onDrop = async (e) => {
e.preventDefault();
setDragging(false);
if (!activeProject) return;
const files = [...(e.dataTransfer?.files || [])];
if (!files.length) return;
setUploading(true);
for (const f of files) await uploadFile(f);
};
const addDoc = async () => {
if (!title.trim() || !content.trim()) {
setMessage("Title and content are required.");
return;
}
setBusy(true);
setMessage("Chunking and embedding…");
try {
const r = await fetch(`${API_BASE}/documents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim(), content }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
setTitle("");
setContent("");
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
}
};
const removeDoc = async (docId) => {
try {
await fetch(`${API_BASE}/documents/${encodeURIComponent(docId)}`, { method: "DELETE" });
load();
} catch { /* ignore */ }
};
const label = (c) => c.title || c.preview || "Untitled chat";
return (
<div
onDragOver={e => { e.preventDefault(); if (activeProject) setDragging(true); }}
onDragLeave={e => { if (e.currentTarget === e.target) setDragging(false); }}
onDrop={onDrop}
style={{ padding: "1.5rem", color: "#eee", maxWidth: "820px", position: "relative",
outline: dragging ? "2px dashed #007acc" : "none", borderRadius: "12px" }}
>
{!activeProject ? (
<>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1rem" }}>
<h2 style={{ margin: 0, flex: 1 }}>📁 Projects</h2>
<button onClick={newProject} style={btn}> New project</button>
</div>
{projects.length === 0 ? (
<div style={{ color: "#777" }}>
No projects. A project keeps its own chats, documents, memory and instructions.
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{projects.map(p => (
<div key={p.id} onClick={() => setActive(p.id)} style={{ ...card, cursor: "pointer" }} title="Open project">
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{p.name}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>
{p.chats} chat{p.chats === 1 ? "" : "s"} · {p.docs} document{p.docs === 1 ? "" : "s"}
</div>
</div>
<span style={{ color: "#555" }}></span>
</div>
))}
</div>
)}
</>
) : (
<>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1.4rem" }}>
<button onClick={() => setActive("")} title="Back to all projects" style={ghost}> Projects</button>
<h2 style={{ margin: 0, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{projects.find(p => p.id === activeProject)?.name || "Project"}
</h2>
<button onClick={removeProject} title="Delete this project (chats and documents kept)" style={danger}>
Delete project
</button>
</div>
{dragging && (
<div style={{ position: "sticky", top: 0, zIndex: 5, background: "#0d2233", border: "1px solid #007acc",
borderRadius: "10px", padding: "0.7rem 1rem", marginBottom: "0.8rem", color: "#8ab4ff" }}>
Drop files to index them in this project.
</div>
)}
<div style={{ marginBottom: "1.5rem" }}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "1rem" }}>How the assistant should act here</h3>
<textarea rows={4} value={instructions} onChange={e => setInstructions(e.target.value)}
placeholder="e.g. Answer as a build engineer. Prefer bash over Python. Always cite the doc you used."
style={input} />
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginTop: "0.5rem" }}>
<button onClick={saveInstructions} disabled={instructions === savedInstructions}
style={{ ...btn, background: instructions === savedInstructions ? "#333" : "#007acc",
cursor: instructions === savedInstructions ? "default" : "pointer" }}>
Save instructions
</button>
<span style={{ color: "#666", fontSize: "0.78rem" }}>
Layered under the active playbook, for chats in this project only.
</span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "0.7rem" }}>
<h3 style={{ margin: 0, flex: 1, fontSize: "1rem" }}>Chats</h3>
<button onClick={onNewChat} style={btn}> New chat</button>
<button onClick={openPicker} style={ghost}>Add existing</button>
</div>
{chats.length === 0 ? (
<div style={{ color: "#777", marginBottom: "1.5rem" }}>No chats in this project yet.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", marginBottom: "1.5rem" }}>
{chats.map(c => (
<div key={c.id} style={card}>
<div onClick={() => onOpenChat(c.id)} style={{ cursor: "pointer", flex: 1, minWidth: 0 }} title="Open in chat">
<div style={{ fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label(c)}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>{new Date(c.updated_at * 1000).toLocaleString()}</div>
</div>
<button onClick={() => moveChat(c.id, "")} title="Remove from project (chat kept)" style={danger}>Remove</button>
</div>
))}
</div>
)}
<div style={{ marginBottom: "1.5rem" }}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "1rem" }}>Project memory</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{facts.map(f => (
<div key={f.id} style={card}>
<span style={{ flex: 1, minWidth: 0, color: "#ddd", fontSize: "0.9rem" }}>{f.text}</span>
<button onClick={() => removeFact(f.id)} style={danger}>Delete</button>
</div>
))}
<div style={{ display: "flex", gap: "0.6rem" }}>
<input type="text" value={newFact} onChange={e => setNewFact(e.target.value)}
onKeyDown={e => e.key === "Enter" && addFact()}
placeholder="Fact the assistant should remember in this project only" style={input} />
<button onClick={addFact} style={btn}>Add</button>
</div>
</div>
<span style={{ color: "#666", fontSize: "0.78rem" }}>
Facts learned in this project's chats land here. Global facts stay on the Memory page.
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "0.7rem" }}>
<h3 style={{ margin: 0, flex: 1, fontSize: "1rem" }}>Documents</h3>
<button onClick={() => { setMessage(""); setUploading(true); }} style={btn}> Add documents</button>
</div>
{docs.length === 0 ? (
<div style={{ color: "#777" }}>No documents yet.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{docs.map(d => (
<div key={d.doc_id} style={card}>
<div onClick={() => openDoc(d)} style={{ cursor: "pointer", flex: 1 }} title="View chunks">
<div style={{ fontWeight: 600 }}>{d.title}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>{d.chunks} chunk{d.chunks === 1 ? "" : "s"}</div>
</div>
<button onClick={() => removeDoc(d.doc_id)} style={danger}>Delete</button>
</div>
))}
</div>
)}
</>
)}
{uploading && (
<Modal title="Add documents to this project" onClose={() => setUploading(false)}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.7rem" }}>
<input type="text" placeholder="Title" value={title}
onChange={e => setTitle(e.target.value)} style={input} />
<textarea rows={8} placeholder="Paste text here, or drop / pick a .pdf/.docx/.txt/.md file"
value={content} onChange={e => setContent(e.target.value)} style={input} />
<div style={{ display: "flex", gap: "0.7rem", alignItems: "center" }}>
<input type="file" accept=".pdf,.docx,.txt,.md,.markdown,text/*" onChange={onFile} disabled={busy}
title="Upload a file — text extracted server-side"
style={{ color: "#aaa", flex: 1 }} />
<button onClick={addDoc} disabled={busy}
style={{ ...btn, padding: "0.9rem 1.5rem", background: busy ? "#555" : "#007acc", cursor: busy ? "default" : "pointer" }}>
{busy ? "Indexing…" : "Add document"}
</button>
</div>
{message && <div style={{ color: "#8ab4ff" }}>{message}</div>}
</div>
</Modal>
)}
{picking && (
<Modal title="Add an existing chat" onClose={() => setPicking(null)}>
{picking.length === 0 ? (
<div style={{ color: "#777" }}>No other chats.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{picking.map(c => (
<div key={c.id} style={card}>
<div style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label(c)}</div>
<button onClick={() => addChat(c.id)} style={ghost}>Add</button>
</div>
))}
</div>
)}
</Modal>
)}
{viewing && (
<Modal title={viewing.title} onClose={() => setViewing(null)}>
{viewing.chunks.map(c => (
<div key={c.chunk_idx} style={{ marginBottom: "1rem" }}>
<div style={{ color: "#666", fontSize: "0.75rem", marginBottom: "0.3rem" }}>chunk {c.chunk_idx + 1}</div>
<div style={{ whiteSpace: "pre-wrap", color: "#ddd", fontSize: "0.9rem", background: "#141414", border: "1px solid #262626", borderRadius: "8px", padding: "0.7rem" }}>{c.text}</div>
</div>
))}
</Modal>
)}
</div>
);
}
+253
View File
@@ -0,0 +1,253 @@
import { useEffect, useState } from "react";
import { API_BASE } from "../../config";
import { MailAccounts } from "./MailAccounts";
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", width: "100%", boxSizing: "border-box" };
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
const accountLabel = (a) => a.label || a.from_addr || a.username || "(unnamed)";
export function Mail() {
const [accounts, setAccounts] = useState(null); // null until loaded
const [expanded, setExpanded] = useState({}); // {accountId: bool}
const [foldersByAccount, setFoldersByAccount] = useState({}); // {accountId: [folders]}
const [selection, setSelection] = useState(null); // {accountId, folder}
const [messages, setMessages] = useState([]);
const [selected, setSelected] = useState(null); // full message
const [composing, setComposing] = useState(null); // {accountId,to,cc,subject,body}
const [busy, setBusy] = useState(false);
const [note, setNote] = useState("");
const [showAccounts, setShowAccounts] = useState(false);
const loadFoldersFor = async (accountId) => {
const r = await fetch(`${API_BASE}/mail/folders?account_id=${encodeURIComponent(accountId)}`);
const folders = r.ok ? (await r.json()).folders || [] : [];
setFoldersByAccount(prev => ({ ...prev, [accountId]: folders }));
return folders;
};
const loadMessages = async (sel = selection) => {
if (!sel) return;
setBusy(true); setSelected(null);
try {
const r = await fetch(`${API_BASE}/mail/messages?account_id=${encodeURIComponent(sel.accountId)}&folder=${encodeURIComponent(sel.folder)}&limit=40`);
const data = r.ok ? await r.json() : null;
setMessages(data ? data.messages || [] : []);
if (!r.ok) setNote((await r.json().catch(() => ({}))).detail || "Failed to load messages.");
} finally { setBusy(false); }
};
const bootstrap = async () => {
const r = await fetch(`${API_BASE}/mail/accounts`);
const list = r.ok ? (await r.json()).accounts || [] : [];
setAccounts(list);
const configured = list.filter(a => a.configured);
setExpanded(Object.fromEntries(list.map(a => [a.id, true])));
await Promise.all(configured.map(a => loadFoldersFor(a.id)));
if (configured.length) {
const sel = { accountId: configured[0].id, folder: "INBOX" };
setSelection(sel);
loadMessages(sel);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { bootstrap(); }, []);
const onAccountsChanged = async (list) => {
setAccounts(list);
setExpanded(prev => ({ ...Object.fromEntries(list.map(a => [a.id, true])), ...prev }));
const configured = list.filter(a => a.configured);
await Promise.all(configured.map(a => loadFoldersFor(a.id)));
// if the active selection's account vanished, fall back to the first configured one
if (selection && !configured.some(a => a.id === selection.accountId)) {
if (configured.length) {
const sel = { accountId: configured[0].id, folder: "INBOX" };
setSelection(sel);
loadMessages(sel);
} else {
setSelection(null); setMessages([]); setSelected(null);
}
} else if (!selection && configured.length) {
const sel = { accountId: configured[0].id, folder: "INBOX" };
setSelection(sel);
loadMessages(sel);
}
};
const openMessage = async (m) => {
setBusy(true);
try {
const r = await fetch(`${API_BASE}/mail/message?account_id=${encodeURIComponent(selection.accountId)}&folder=${encodeURIComponent(selection.folder)}&uid=${encodeURIComponent(m.uid)}`);
if (r.ok) { setSelected(await r.json()); setMessages(prev => prev.map(x => x.uid === m.uid ? { ...x, seen: true } : x)); }
} finally { setBusy(false); }
};
const deleteMessage = async (m) => {
if (!window.confirm("Delete this message?")) return;
await fetch(`${API_BASE}/mail/delete`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ account_id: selection.accountId, folder: selection.folder, uid: m.uid }),
});
setSelected(null); loadMessages();
};
const sendCompose = async () => {
setBusy(true); setNote("Sending…");
try {
const r = await fetch(`${API_BASE}/mail/send`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(composing),
});
const d = await r.json().catch(() => ({}));
if (r.ok) { setNote(`Sent to ${(d.to || []).join(", ")}.`); setComposing(null); }
else setNote(d.detail || "Send failed.");
} finally { setBusy(false); }
};
const startCompose = () => setComposing({
accountId: (selection && selection.accountId) || (accounts || []).find(a => a.configured)?.id || "",
to: "", cc: "", subject: "", body: "",
});
const reply = (m) => setComposing({
accountId: selection.accountId,
to: m.from, cc: "", subject: /^re:/i.test(m.subject || "") ? m.subject : `Re: ${m.subject || ""}`,
body: `\n\n---\nOn ${m.date ? new Date(m.date).toLocaleString() : ""}, ${m.from} wrote:\n${(m.text || "").slice(0, 2000)}`,
});
const selectFolder = (accountId, folder) => {
const sel = { accountId, folder };
setSelection(sel);
loadMessages(sel);
};
const toggleExpanded = (accountId) => setExpanded(prev => ({ ...prev, [accountId]: !prev[accountId] }));
const configuredAccounts = (accounts || []).filter(a => a.configured);
if (accounts === null) return <div style={{ padding: "1.5rem", color: "#888" }}>Loading</div>;
// ---- empty state: no configured accounts yet ----
if (configuredAccounts.length === 0) {
return (
<div style={{ padding: "1.5rem", color: "#eee", maxWidth: "560px" }}>
<h2 style={{ marginTop: 0 }}> Mail</h2>
<p style={{ color: "#aaa" }}>No mail accounts configured yet.</p>
<button onClick={() => setShowAccounts(true)} style={btn("#007acc")}>+ Add mail account</button>
{showAccounts && <MailAccounts onClose={() => setShowAccounts(false)} onChanged={onAccountsChanged} />}
</div>
);
}
// ---- client ----
return (
<div style={{ display: "flex", height: "100%", color: "#eee" }}>
<div style={{ width: "200px", borderRight: "1px solid #2a2a2a", padding: "0.75rem", overflowY: "auto", flexShrink: 0, display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", gap: "0.4rem", marginBottom: "0.75rem" }}>
<button onClick={startCompose} style={{ ...btn("#007acc"), flexGrow: 1 }}> Compose</button>
<button onClick={() => setShowAccounts(true)} title="Manage accounts"
style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc", padding: "0.5rem 0.6rem" }}></button>
</div>
<div style={{ flexGrow: 1, overflowY: "auto" }}>
{(accounts || []).map(a => (
<div key={a.id} style={{ marginBottom: "0.5rem" }}>
<div onClick={() => toggleExpanded(a.id)}
style={{ display: "flex", alignItems: "center", gap: "0.35rem", padding: "0.3rem 0.3rem", cursor: "pointer", color: a.configured ? "#ddd" : "#c9a227", fontSize: "0.82rem", fontWeight: 600 }}>
<span style={{ display: "inline-block", width: "0.8em", transform: expanded[a.id] ? "rotate(90deg)" : "none", transition: "transform 0.1s" }}></span>
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{accountLabel(a)}</span>
</div>
{expanded[a.id] && (
!a.configured ? (
<div style={{ padding: "0.2rem 0.5rem 0.2rem 1.4rem", fontSize: "0.75rem", color: "#c9a227" }}>needs password to fix</div>
) : (foldersByAccount[a.id] || []).map(f => {
const isActive = selection && selection.accountId === a.id && selection.folder === f;
return (
<div key={f} onClick={() => selectFolder(a.id, f)}
style={{ padding: "0.32rem 0.5rem 0.32rem 1.4rem", borderRadius: "6px", cursor: "pointer", fontSize: "0.8rem", background: isActive ? "#1c2a3a" : "transparent", color: isActive ? "#fff" : "#aaa", marginBottom: "0.1rem", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{f}
</div>
);
})
)}
</div>
))}
</div>
</div>
<div style={{ width: "320px", borderRight: "1px solid #2a2a2a", overflowY: "auto", flexShrink: 0 }}>
<div style={{ padding: "0.5rem 0.75rem", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: "1px solid #2a2a2a" }}>
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{selection ? selection.folder : "—"}
</span>
<button onClick={() => loadMessages()} disabled={!selection} style={{ background: "none", border: "none", color: "#888", cursor: "pointer" }}></button>
</div>
{!selection ? <div style={{ padding: "1rem", color: "#777" }}>Select a folder.</div>
: busy && messages.length === 0 ? <div style={{ padding: "1rem", color: "#777" }}>Loading</div>
: messages.length === 0 ? <div style={{ padding: "1rem", color: "#777" }}>{note || "No messages."}</div>
: messages.map(m => (
<div key={m.uid} onClick={() => openMessage(m)}
style={{ padding: "0.6rem 0.75rem", borderBottom: "1px solid #1e1e1e", cursor: "pointer", background: selected && selected.uid === m.uid ? "#1c2a3a" : "transparent" }}>
<div style={{ fontSize: "0.8rem", color: m.seen ? "#999" : "#fff", fontWeight: m.seen ? 400 : 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.from}</div>
<div style={{ fontSize: "0.82rem", color: m.seen ? "#aaa" : "#eee", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.subject || "(no subject)"}</div>
<div style={{ fontSize: "0.72rem", color: "#666", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.preview}</div>
</div>
))}
</div>
<div style={{ flexGrow: 1, overflowY: "auto", padding: "1rem", minWidth: 0 }}>
{composing ? (
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem", maxWidth: "640px" }}>
<h3 style={{ margin: 0 }}>New message</h3>
{configuredAccounts.length > 1 && (
<div>
<label style={{ display: "block", fontSize: "0.75rem", color: "#888", marginBottom: "0.3rem" }}>From</label>
<select value={composing.accountId} onChange={e => setComposing({ ...composing, accountId: e.target.value })}
style={{ ...inputStyle, width: "100%" }}>
{configuredAccounts.map(a => <option key={a.id} value={a.id}>{accountLabel(a)} {a.from_addr || a.username}</option>)}
</select>
</div>
)}
<input placeholder="To" value={composing.to} onChange={e => setComposing({ ...composing, to: e.target.value })} style={inputStyle} />
<input placeholder="Cc" value={composing.cc} onChange={e => setComposing({ ...composing, cc: e.target.value })} style={inputStyle} />
<input placeholder="Subject" value={composing.subject} onChange={e => setComposing({ ...composing, subject: e.target.value })} style={inputStyle} />
<textarea rows={14} placeholder="Body" value={composing.body} onChange={e => setComposing({ ...composing, body: e.target.value })} style={inputStyle} />
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
<button onClick={sendCompose} disabled={busy || !composing.to.trim()} style={btn(busy || !composing.to.trim() ? "#555" : "#007acc")}>Send</button>
<button onClick={() => setComposing(null)} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc" }}>Cancel</button>
<span style={{ color: "#8ab4ff", fontSize: "0.85rem" }}>{note}</span>
</div>
</div>
) : selected ? (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "1rem" }}>
<h3 style={{ margin: "0 0 0.5rem" }}>{selected.subject || "(no subject)"}</h3>
<div style={{ display: "flex", gap: "0.4rem", flexShrink: 0 }}>
<button onClick={() => reply(selected)} style={btn("#2a4a6a")}>Reply</button>
<button onClick={() => deleteMessage(selected)} style={{ ...btn("#3a1a1a"), color: "#ff8a80" }}>Delete</button>
</div>
</div>
<div style={{ color: "#aaa", fontSize: "0.85rem", marginBottom: "0.75rem" }}>
<div><b>From:</b> {selected.from}</div>
<div><b>To:</b> {(selected.to || []).join(", ")}</div>
{selected.date && <div>{new Date(selected.date).toLocaleString()}</div>}
</div>
{/* Sandboxed iframe: no scripts, no same-origin — email HTML can't touch the app. */}
{selected.html
? <iframe title="message" sandbox="" srcDoc={selected.html} style={{ width: "100%", height: "60vh", border: "1px solid #2a2a2a", borderRadius: "8px", background: "#fff" }} />
: <pre style={{ whiteSpace: "pre-wrap", fontFamily: "system-ui", color: "#ddd" }}>{selected.text || "(empty)"}</pre>}
{selected.attachments && selected.attachments.length > 0 && (
<div style={{ marginTop: "0.75rem", color: "#888", fontSize: "0.8rem" }}>
📎 {selected.attachments.map(a => a.name).join(", ")}
</div>
)}
</div>
) : (
<div style={{ color: "#777", paddingTop: "2rem", textAlign: "center" }}>Select a message.</div>
)}
</div>
{showAccounts && <MailAccounts onClose={() => setShowAccounts(false)} onChanged={onAccountsChanged} />}
</div>
);
}
@@ -0,0 +1,182 @@
import { useEffect, useState } from "react";
import { API_BASE } from "../../config";
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", width: "100%", boxSizing: "border-box" };
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
const labelStyle = { display: "block", fontSize: "0.72rem", color: "#888", marginBottom: "0.3rem", textTransform: "uppercase", letterSpacing: "0.05em" };
const BLANK = {
label: "", username: "", password: "", from_addr: "", from_name: "",
imap_host: "imap.mail.me.com", imap_port: 993, smtp_host: "smtp.mail.me.com", smtp_port: 587,
};
// macOS-Mail-style "Internet Accounts" window: accounts on the left, the
// selected account's config on the right. Lets you manage several mailboxes
// that happen to share the same IMAP/SMTP server.
export function MailAccounts({ onClose, onChanged }) {
const [accounts, setAccounts] = useState(null); // null = loading
const [selectedId, setSelectedId] = useState(null); // null = "add account" form
const [form, setForm] = useState(BLANK);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState("");
const selectAccount = (id, list = accounts) => {
setNote("");
setSelectedId(id);
if (id === null) { setForm(BLANK); return; }
const a = (list || []).find(x => x.id === id);
if (a) setForm({ ...a, password: "" });
};
const load = async (selectAfter) => {
const r = await fetch(`${API_BASE}/mail/accounts`);
const list = r.ok ? (await r.json()).accounts || [] : [];
setAccounts(list);
onChanged && onChanged(list);
if (selectAfter !== undefined) selectAccount(selectAfter, list);
return list;
};
useEffect(() => {
load().then(list => { if (list.length) selectAccount(list[0].id, list); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const save = async () => {
setBusy(true); setNote("Saving…");
try {
const url = selectedId ? `${API_BASE}/mail/accounts/${selectedId}` : `${API_BASE}/mail/accounts`;
const r = await fetch(url, {
method: selectedId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!r.ok) { setNote("Save failed."); return; }
const saved = await r.json();
setNote("Saved.");
await load(saved.id);
} finally { setBusy(false); }
};
const test = async () => {
if (!selectedId) { setNote("Save the account first."); return; }
setBusy(true); setNote("Testing…");
try {
const r = await fetch(`${API_BASE}/mail/accounts/${selectedId}/test`, { method: "POST" });
const d = await r.json().catch(() => ({}));
setNote(d.ok ? `Connected — ${d.folders} folders.` : `Connection failed: ${d.error || "check credentials"}`);
} finally { setBusy(false); }
};
const remove = async () => {
if (!selectedId) return;
if (!window.confirm(`Delete account "${form.label || form.from_addr || form.username}"?`)) return;
setBusy(true);
try {
await fetch(`${API_BASE}/mail/accounts/${selectedId}`, { method: "DELETE" });
const list = await load();
selectAccount(list.length ? list[0].id : null, list);
} finally { setBusy(false); }
};
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
return (
<div onClick={onClose}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 200, padding: "2rem" }}>
<div onClick={e => e.stopPropagation()}
style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", width: "720px", maxWidth: "100%", height: "520px", maxHeight: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.85rem 1.1rem", borderBottom: "1px solid #2a2a2a", flexShrink: 0 }}>
<strong>Mail Accounts</strong>
<button onClick={onClose} style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}></button>
</div>
<div style={{ display: "flex", flexGrow: 1, minHeight: 0 }}>
{/* Left: accounts bar */}
<div style={{ width: "200px", flexShrink: 0, borderRight: "1px solid #2a2a2a", display: "flex", flexDirection: "column" }}>
<div style={{ flexGrow: 1, overflowY: "auto", padding: "0.5rem" }}>
{accounts === null ? (
<div style={{ color: "#666", fontSize: "0.82rem", padding: "0.5rem" }}>Loading</div>
) : accounts.length === 0 ? (
<div style={{ color: "#666", fontSize: "0.8rem", padding: "0.5rem" }}>No accounts yet.</div>
) : accounts.map(a => (
<div key={a.id} onClick={() => selectAccount(a.id)}
style={{
padding: "0.5rem 0.6rem", borderRadius: "6px", cursor: "pointer", marginBottom: "0.2rem",
background: selectedId === a.id ? "#1c2a3a" : "transparent",
color: selectedId === a.id ? "#fff" : "#ccc",
}}>
<div style={{ fontSize: "0.85rem", fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{a.label || a.from_addr || a.username || "(unnamed)"}
</div>
<div style={{ fontSize: "0.72rem", color: a.configured ? "#8aff8a" : "#c9a227", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{a.configured ? (a.from_addr || a.username) : "needs password"}
</div>
</div>
))}
</div>
<div style={{ padding: "0.5rem", borderTop: "1px solid #2a2a2a" }}>
<button onClick={() => selectAccount(null)} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc", width: "100%" }}>+ Add account</button>
</div>
</div>
{/* Right: selected account form */}
<div style={{ flexGrow: 1, padding: "1.1rem", overflowY: "auto" }}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#eee" }}>
{selectedId ? "Edit account" : "New account"}
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem", maxWidth: "440px" }}>
<div>
<label style={labelStyle}>Nickname (optional)</label>
<input value={form.label} onChange={e => update("label", e.target.value)} placeholder="e.g. Personal, Work" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>Apple ID (login)</label>
<input value={form.username} onChange={e => update("username", e.target.value)} placeholder="you@icloud.com" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{selectedId ? "App-specific password (blank = keep current)" : "App-specific password"}</label>
<input type="password" value={form.password} onChange={e => update("password", e.target.value)} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>From address</label>
<input value={form.from_addr} onChange={e => update("from_addr", e.target.value)} placeholder="nexus@enderofwings.com" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>From name (optional)</label>
<input value={form.from_name} onChange={e => update("from_name", e.target.value)} style={inputStyle} />
</div>
<div style={{ display: "flex", gap: "0.6rem" }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>IMAP host</label>
<input value={form.imap_host} onChange={e => update("imap_host", e.target.value)} style={inputStyle} />
</div>
<div style={{ width: "90px" }}>
<label style={labelStyle}>Port</label>
<input type="number" value={form.imap_port} onChange={e => update("imap_port", parseInt(e.target.value) || 0)} style={inputStyle} />
</div>
</div>
<div style={{ display: "flex", gap: "0.6rem" }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>SMTP host</label>
<input value={form.smtp_host} onChange={e => update("smtp_host", e.target.value)} style={inputStyle} />
</div>
<div style={{ width: "90px" }}>
<label style={labelStyle}>Port</label>
<input type="number" value={form.smtp_port} onChange={e => update("smtp_port", parseInt(e.target.value) || 0)} style={inputStyle} />
</div>
</div>
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center", marginTop: "0.4rem" }}>
<button onClick={save} disabled={busy} style={btn(busy ? "#555" : "#007acc")}>{selectedId ? "Save" : "Create"}</button>
{selectedId && <button onClick={test} disabled={busy} style={btn("#2a4a6a")}>Test connection</button>}
{selectedId && <button onClick={remove} disabled={busy} style={{ ...btn("#3a1a1a"), color: "#ff8a80" }}>Delete</button>}
</div>
<span style={{ color: "#8ab4ff", fontSize: "0.85rem" }}>{note}</span>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,7 @@
// Entry point picked up by ../registry.js's auto-discovery every module
// folder needs one of these exporting MANIFEST + Component. This file is
// registration glue, not itself a hot-reloaded component.
/* eslint-disable react-refresh/only-export-components */
export { Mail as Component } from "./Mail";
export const MANIFEST = { key: "mail", label: "Mail", icon: "✉️", order: 1 };
@@ -0,0 +1,159 @@
import { useEffect, useState } from "react";
import { API_BASE } from "../../config";
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", boxSizing: "border-box" };
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
const sectionStyle = { background: "#161616", border: "1px solid #333", borderRadius: "12px", padding: "1.25rem", marginBottom: "1rem" };
const dot = (ok) => ({
display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", flexShrink: 0,
background: ok === null ? "#555" : ok ? "#4caf50" : "#f44336",
});
export function Network() {
const [status, setStatus] = useState(null); // {hostname, connection, vpn}
const [targets, setTargets] = useState(null); // [{id,label,host,ok,latency_ms}]
const [newLabel, setNewLabel] = useState("");
const [newHost, setNewHost] = useState("");
const [busy, setBusy] = useState(false);
const [vpnBusy, setVpnBusy] = useState(false);
const [note, setNote] = useState("");
const loadStatus = async () => {
const r = await fetch(`${API_BASE}/network/status`);
if (r.ok) setStatus(await r.json());
};
const loadAndPingTargets = async () => {
const r = await fetch(`${API_BASE}/network/ping`);
setTargets(r.ok ? (await r.json()).targets || [] : []);
};
const refresh = async () => {
setBusy(true);
try { await Promise.all([loadStatus(), loadAndPingTargets()]); }
finally { setBusy(false); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { refresh(); }, []);
const toggleVpn = async () => {
if (!status || !status.vpn.configured) return;
setVpnBusy(true); setNote("");
try {
const r = await fetch(`${API_BASE}/network/vpn/toggle`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable: !status.vpn.connected }),
});
const d = await r.json().catch(() => ({}));
if (r.ok) setStatus(s => ({ ...s, vpn: d }));
else setNote(d.detail || "VPN toggle failed.");
} finally { setVpnBusy(false); }
};
const addTarget = async () => {
if (!newHost.trim()) return;
setBusy(true);
try {
const r = await fetch(`${API_BASE}/network/targets`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: newLabel.trim(), host: newHost.trim() }),
});
if (r.ok) { setNewLabel(""); setNewHost(""); await loadAndPingTargets(); }
} finally { setBusy(false); }
};
const removeTarget = async (id) => {
setBusy(true);
try {
await fetch(`${API_BASE}/network/targets/${id}`, { method: "DELETE" });
await loadAndPingTargets();
} finally { setBusy(false); }
};
if (status === null) return <div style={{ padding: "1.5rem", color: "#888" }}>Loading</div>;
const conn = status.connection;
const vpn = status.vpn;
return (
<div style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.25rem" }}>
<h2 style={{ margin: 0, fontSize: "1.1rem", color: "#eee" }}>📡 Network</h2>
<button onClick={refresh} disabled={busy} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc" }}>
{busy ? "Refreshing…" : "⟳ Refresh"}
</button>
</div>
{/* Connection */}
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>Connection</h3>
<div style={{ display: "flex", gap: "2rem", flexWrap: "wrap", fontSize: "0.88rem" }}>
<div><span style={{ color: "#666" }}>Host</span><div style={{ color: "#eee" }}>{status.hostname}</div></div>
<div><span style={{ color: "#666" }}>Type</span><div style={{ color: "#eee", textTransform: "capitalize" }}>{conn.type}</div></div>
<div><span style={{ color: "#666" }}>Interface</span><div style={{ color: "#eee" }}>{conn.interface || "—"}</div></div>
<div><span style={{ color: "#666" }}>IP</span><div style={{ color: "#eee" }}>{conn.ip || "—"}</div></div>
</div>
</div>
{/* VPN */}
{vpn.available && (
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>WireGuard VPN</h3>
{!vpn.configured ? (
<p style={{ margin: 0, fontSize: "0.85rem", color: "#666" }}>No WireGuard tunnel configured in NetworkManager.</p>
) : (
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<span style={dot(vpn.connected)} />
<span style={{ fontSize: "0.88rem", color: "#ccc" }}>{vpn.name}</span>
<span style={{ fontSize: "0.82rem", color: vpn.connected ? "#4caf50" : "#888" }}>
{vpn.connected ? "Connected" : "Disconnected"}
</span>
<button onClick={toggleVpn} disabled={vpnBusy}
style={{ ...btn(vpn.connected ? "#2a1a1a" : "#152a15"), color: vpn.connected ? "#ff8a80" : "#8aff8a", marginLeft: "auto" }}>
{vpnBusy ? "Working…" : vpn.connected ? "Disconnect" : "Connect"}
</button>
</div>
)}
{note && <p style={{ margin: "0.5rem 0 0", color: "#f44336", fontSize: "0.82rem" }}>{note}</p>}
</div>
)}
{/* Ping targets */}
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "0.95rem", color: "#bbb" }}>Ping targets</h3>
<p style={{ margin: "0 0 1rem", fontSize: "0.78rem", color: "#555" }}>
Hosts to check reachability for a router, a VPN endpoint, anything on your network.
</p>
{(targets || []).length === 0 ? (
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "1rem" }}>No targets yet.</div>
) : (
<div style={{ marginBottom: "1rem" }}>
{targets.map(t => (
<div key={t.id} style={{ display: "flex", alignItems: "center", gap: "0.6rem", padding: "0.45rem 0", borderBottom: "1px solid #222" }}>
<span style={dot(t.ok)} />
<span style={{ fontSize: "0.88rem", color: "#eee", minWidth: "120px" }}>{t.label}</span>
<span style={{ fontSize: "0.82rem", color: "#888" }}>{t.host}</span>
<span style={{ fontSize: "0.8rem", color: "#666", marginLeft: "auto" }}>
{t.ok ? (t.latency_ms != null ? `${t.latency_ms.toFixed(0)} ms` : "reachable") : t.ok === false ? "unreachable" : ""}
</span>
<button onClick={() => removeTarget(t.id)} disabled={busy}
style={{ background: "transparent", border: "none", color: "#888", cursor: "pointer", fontSize: "0.9rem", padding: "0 4px" }}></button>
</div>
))}
</div>
)}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input placeholder="Label (optional)" value={newLabel} onChange={e => setNewLabel(e.target.value)}
style={{ ...inputStyle, width: "160px" }} />
<input placeholder="Host or IP" value={newHost} onChange={e => setNewHost(e.target.value)}
onKeyDown={e => e.key === "Enter" && addTarget()} style={{ ...inputStyle, width: "200px" }} />
<button onClick={addTarget} disabled={busy || !newHost.trim()} style={btn(!newHost.trim() ? "#555" : "#007acc")}>+ Add</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,7 @@
// Entry point picked up by ../registry.js's auto-discovery every module
// folder needs one of these exporting MANIFEST + Component. This file is
// registration glue, not itself a hot-reloaded component.
/* eslint-disable react-refresh/only-export-components */
export { Network as Component } from "./Network";
export const MANIFEST = { key: "network", label: "Network", icon: "📡", order: 2 };
+10
View File
@@ -0,0 +1,10 @@
// Auto-discovers feature modules under modules/ — any folder with a
// module.jsx exporting MANIFEST ({key,label,icon,order}) + Component is
// picked up automatically. Add a new module folder and it appears in the
// Modules hover menu with no edits here.
const discovered = import.meta.glob("./*/module.jsx", { eager: true });
export const MODULES = Object.values(discovered)
.filter(m => m.MANIFEST && m.Component)
.map(m => ({ ...m.MANIFEST, Component: m.Component }))
.sort((a, b) => (a.order ?? 99) - (b.order ?? 99));
+4 -9
View File
@@ -1,7 +1,7 @@
# NexusOS launcher for Windows (native, no Vite).
#
# Single-process app: the backend on :8000 serves the built web UI itself, so
# this starts only the memory service + backend, opening a native app window
# this starts only the backend, opening a native app window
# (bin\nexus_window.py, pywebview/WebView2) immediately alongside them with its
# own live-updating loading screen. The AI (Ollama) does NOT auto-start - turn
# it on from the UI's Start AI button. Closing the app window stops the
@@ -40,19 +40,15 @@ Write-Host "Starting NexusOS..." -ForegroundColor Cyan
# can be squatting the port without ever responding, and treating that as
# "already running" skips starting a real service - the window then sits on
# its loading screen for the full 40s with no way to tell why.
$memory = $null
if (-not (Test-ServiceUp "http://127.0.0.1:8001/")) {
$memory = Start-Svc (Join-Path $Runtime "memory.log") @("-m","uvicorn","synapse.memory.service:app","--host","127.0.0.1","--port","8001")
}
$backend = $null
if (-not (Test-ServiceUp "http://127.0.0.1:8000/status")) {
$backend = Start-Svc (Join-Path $Runtime "backend.log") @("-m","uvicorn","synapse.main:sio_app","--host","127.0.0.1","--port","8000")
}
# Open the UI in a native window (pywebview / WebView2) - no browser, no Edge
# profile cold-start - IMMEDIATELY, in parallel with memory/backend still
# profile cold-start - IMMEDIATELY, in parallel with the backend still
# coming up. bin\nexus_window.py opens on its own loading page and polls
# :8001 then :8000 itself, updating that page's status line live as each
# :8000 itself, updating that page's status line live as it
# comes up, so the window is on screen from the first instant instead of
# this script blocking silently (in a hidden window) for up to 30s first and
# only then showing anything. It blocks until the window is closed and falls
@@ -67,10 +63,9 @@ Write-Host "NexusOS window opened; services are starting at http://localhost:800
# shortcut runs hidden, where a prompt no one can answer would hang forever.
if ($app) { $app.WaitForExit() }
elseif ($backend) { $backend.WaitForExit() }
elseif ($memory) { $memory.WaitForExit() }
# Shut the services down.
Write-Host "Stopping NexusOS..." -ForegroundColor Cyan
foreach ($p in @($backend, $memory)) {
foreach ($p in @($backend)) {
if ($p -and -not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
}
+6 -7
View File
@@ -1,4 +1,9 @@
#!/bin/bash
# Loopback by default: the backend REST API is unauthenticated, so
# binding 0.0.0.0 hands the whole admin+data plane to any host on the LAN.
# Same knob management/ncp.py uses -- set NEXUS_BIND_HOST=0.0.0.0 to opt in.
BIND_HOST="${NEXUS_BIND_HOST:-127.0.0.1}"
PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
PYTHON="$PROJECT_ROOT/Promethean/bin/python3"
FRONTEND_DIR="$PROJECT_ROOT/interface/web"
@@ -6,7 +11,6 @@ LOG_DIR="$PROJECT_ROOT/runtime"
mkdir -p "$LOG_DIR"
MEMORY_LOG="$LOG_DIR/memory.log"
BACKEND_LOG="$LOG_DIR/backend.log"
FRONTEND_LOG="$LOG_DIR/frontend.log"
@@ -29,15 +33,10 @@ _check() {
fi
}
# Launch memory + backend together
MEMORY_PID=$(_start "NEXUS MEMORY SERVICE" "$MEMORY_LOG" "$PROJECT_ROOT" \
"$PYTHON" -m uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload)
BACKEND_PID=$(_start "NEXUS BACKEND SERVICE" "$BACKEND_LOG" "$PROJECT_ROOT" \
"$PYTHON" -m uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload)
"$PYTHON" -m uvicorn synapse.main:sio_app --host "$BIND_HOST" --port 8000 --reload)
sleep 3
_check "NEXUS MEMORY SERVICE" "$MEMORY_PID" "$MEMORY_LOG"
_check "NEXUS BACKEND SERVICE" "$BACKEND_PID" "$BACKEND_LOG"
# Frontend last
+5
View File
@@ -0,0 +1,5 @@
"""Desktop-only NexusOS management helpers (Tk control panel, XFCE panel).
The portable CLI lives in the `nexusos_cli` package - that is what the
wheel ships and what `ncp`/`nexus`/`nexusos` dispatch to.
"""
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Network
Comment=Manage your network connections
Icon=nm-device-wireless
TryExec=nm-applet
Exec=nm-applet
Hidden=true
X-XFCE-Autostart-Override=true
+6
View File
@@ -0,0 +1,6 @@
[Desktop Entry]
Type=Application
Name=Plank (Nexus primary-follow)
Exec=/home/jon/.local/bin/plank-primary-watch.sh
Hidden=false
X-XFCE-Autostart-Override=true
+15 -89
View File
@@ -1,4 +1,3 @@
# /home/jon/nexus-core/management/controlpanel.py
import time
import tkinter as tk
@@ -20,7 +19,9 @@ try:
except Exception:
VERSION = "0.0.0"
# Force 0.0.0.0 to ensure the Windows Host bridge works for Project Nexus
# Loopback by default -- the REST APIs are unauthenticated, so 0.0.0.0 exposed
# the whole admin+data plane to the LAN. Same knob management/ncp.py uses.
BIND_HOST = os.environ.get("NEXUS_BIND_HOST", "127.0.0.1")
FRONTEND_DIR = PROJECT_ROOT / "interface" / "web"
def _find_npm():
"""Prefer nvm's newest node. When the panel is launched from the desktop
@@ -51,28 +52,15 @@ BACKEND_CMD = [
str(PROJECT_ROOT / "Promethean" / "bin" / "python"),
"-m", "uvicorn",
"synapse.main:sio_app",
"--host", "0.0.0.0",
"--host", BIND_HOST,
"--port", "8000",
"--reload",
"--reload-dir", str(PROJECT_ROOT / "synapse"),
"--log-level", "info"
]
MEMORY_DIR = PROJECT_ROOT
MEMORY_CMD = [
str(PROJECT_ROOT / "Promethean" / "bin" / "python"),
"-m", "uvicorn",
"synapse.memory.service:app",
"--host", "0.0.0.0",
"--port", "8001",
"--reload",
"--reload-dir", str(PROJECT_ROOT / "synapse"),
"--log-level", "info"
]
FRONTEND_PID = PROJECT_ROOT / "runtime" / "pids" / "frontend.pid"
BACKEND_PID = PROJECT_ROOT / "runtime" / "pids" / "backend.pid"
MEMORY_PID = PROJECT_ROOT / "runtime" / "pids" / "memory.pid"
CHAT_LOG = PROJECT_ROOT / "runtime" / "logs" / "chat.log"
class NexusControlPanel:
@@ -109,10 +97,8 @@ class NexusControlPanel:
self.frontend_process = None
self.backend_process = None
self.memory_process = None
self._frontend_starting = False
self._backend_starting = False
self._memory_starting = False
self._closing = False
self._setup_custom_titlebar()
@@ -132,9 +118,6 @@ class NexusControlPanel:
if self._is_running(frontend_pid):
threading.Thread(target=self._monitor_frontend, daemon=True).start()
if self._is_running(MEMORY_PID):
threading.Thread(target=self._monitor_memory, daemon=True).start()
threading.Thread(target=self._monitor_synapses, daemon=True).start()
self.update_master_ui()
@@ -167,12 +150,11 @@ class NexusControlPanel:
def _init_log_tags(self):
for console in [self.sys_console, self.front_console, self.back_console, self.mind_console, self.mem_console]:
for console in [self.sys_console, self.front_console, self.back_console, self.mind_console]:
console.tag_config("SYSTEM", foreground="#ff00ea")
console.tag_config("FRONTEND", foreground="#61dbfb")
console.tag_config("BACKEND", foreground="#ffd43b")
console.tag_config("MINDTRACE", foreground="#cc88ff")
console.tag_config("MEMORY", foreground="#78e08f")
console.tag_config("ERROR", foreground="#ff0000")
console.tag_config("GPU_NVIDIA", foreground="#76b900")
console.tag_config("GPU_AMD", foreground="#ed1c24")
@@ -193,7 +175,7 @@ class NexusControlPanel:
self.root.geometry(f"+{self.root.winfo_x() + (event.x - self.x)}+{self.root.winfo_y() + (event.y - self.y)}")
def _clear_all_logs(self):
for console in [self.sys_console, self.front_console, self.back_console, self.mem_console, self.mind_console]:
for console in [self.sys_console, self.front_console, self.back_console, self.mind_console]:
console.delete("1.0", tk.END)
def _copy_selection(self, widget):
@@ -260,19 +242,13 @@ class NexusControlPanel:
self.btn_back_stop = tk.Button(controls, text="OFF", **STOP_STYLE, command=self.stop_backend)
self.btn_back_stop.pack(side=tk.LEFT, padx=2)
# Memory Service Controls
self.btn_mem_start = tk.Button(controls, text="MEMORY", fg="#78e08f", **START_STYLE, command=self.start_memory)
self.btn_mem_start.pack(side=tk.LEFT, padx=(8, 2))
self.btn_mem_stop = tk.Button(controls, text="OFF", **STOP_STYLE, command=self.stop_memory)
self.btn_mem_stop.pack(side=tk.LEFT, padx=2)
# Terminal Displays
sys_f, self.sys_console = self._create_terminal(self.root, " >> SYSTEM STATUS", height=5, text_color="#ff00ea")
sys_f.pack(fill=tk.X, padx=10, pady=(0, 8))
bottom_f = tk.Frame(self.root, bg="#1e1e1e")
bottom_f.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 8))
for col in range(4):
for col in range(3):
bottom_f.columnconfigure(col, weight=1, uniform="term")
bottom_f.rowconfigure(0, weight=1)
@@ -280,10 +256,8 @@ class NexusControlPanel:
f_f.grid(row=0, column=0, sticky="nsew", padx=(0, 4))
b_f, self.back_console = self._create_terminal(bottom_f, " >> SYNAPSE (UVICORN)", text_color="#ffd43b")
b_f.grid(row=0, column=1, sticky="nsew", padx=4)
mm_f, self.mem_console = self._create_terminal(bottom_f, " >> MEMORY SERVICE", text_color="#78e08f")
mm_f.grid(row=0, column=2, sticky="nsew", padx=4)
m_f, self.mind_console = self._create_terminal(bottom_f, " >> MINDTRACE", text_color="#cc88ff")
m_f.grid(row=0, column=3, sticky="nsew", padx=(4, 0))
m_f.grid(row=0, column=2, sticky="nsew", padx=(4, 0))
def _safe_after(self, delay, func):
"""Schedule a Tk callback without ever raising.
@@ -305,7 +279,6 @@ class NexusControlPanel:
con = self.sys_console
if tag == "FRONTEND": con = self.front_console
elif tag == "BACKEND": con = self.back_console
elif tag == "MEMORY": con = self.mem_console
elif tag == "MINDTRACE": con = self.mind_console
con.insert(tk.END, f"[{tag}] {message}\n", tag)
con.see(tk.END)
@@ -358,16 +331,14 @@ class NexusControlPanel:
def update_master_ui(self):
if self._closing: # window may be destroyed; stop_* still calls this
return
f, b, m = self._frontend_is_active(), self._backend_is_active(), self._memory_is_active()
self.btn_master_start.config(state=tk.DISABLED if (f and b and m) else tk.NORMAL)
self.btn_master_stop.config(state=tk.NORMAL if (f or b or m) else tk.DISABLED)
f, b = self._frontend_is_active(), self._backend_is_active()
self.btn_master_start.config(state=tk.DISABLED if (f and b) else tk.NORMAL)
self.btn_master_stop.config(state=tk.NORMAL if (f or b) else tk.DISABLED)
self.btn_front_start.config(state=tk.DISABLED if f else tk.NORMAL, bg="#0b3d0b" if f else "#2d2d2d")
self.btn_back_start.config(state=tk.DISABLED if b else tk.NORMAL, bg="#3d3d0b" if b else "#2d2d2d")
self.btn_mem_start.config(state=tk.DISABLED if m else tk.NORMAL, bg="#0b2d14" if m else "#2d2d2d")
self.btn_front_stop.config(state=tk.NORMAL if f else tk.DISABLED)
self.btn_front_view.config(state=tk.NORMAL if f else tk.DISABLED)
self.btn_back_stop.config(state=tk.NORMAL if b else tk.DISABLED)
self.btn_mem_stop.config(state=tk.NORMAL if m else tk.DISABLED)
def open_frontend_view(self):
webbrowser.open_new_tab(FRONTEND_URL)
@@ -426,10 +397,6 @@ class NexusControlPanel:
self._frontend_starting = False
self.frontend_process = p
self._write_pid(FRONTEND_PID, p.pid)
elif name == "MEMORY":
self._memory_starting = False
self.memory_process = p
self._write_pid(MEMORY_PID, p.pid)
else:
self._backend_starting = False
self.backend_process = p
@@ -472,10 +439,6 @@ class NexusControlPanel:
self._frontend_starting = False
self.frontend_process = None
FRONTEND_PID.unlink(missing_ok=True)
elif name == "MEMORY":
self._memory_starting = False
self.memory_process = None
MEMORY_PID.unlink(missing_ok=True)
else:
self._backend_starting = False
self.backend_process = None
@@ -576,47 +539,13 @@ class NexusControlPanel:
self._terminate_pid_file(BACKEND_PID)
self.update_master_ui()
def start_memory(self):
if self._memory_is_active():
return
self._memory_starting = True
self.update_master_ui()
self.log("SYSTEM", "Starting MEMORY SERVICE...")
threading.Thread(target=self._worker, args=("MEMORY", MEMORY_CMD, MEMORY_DIR), daemon=True).start()
def stop_memory(self):
if self.memory_process:
self._terminate_process(self.memory_process)
self.memory_process = None
elif self._is_running(MEMORY_PID):
self._terminate_pid_file(MEMORY_PID)
self.update_master_ui()
def _memory_is_active(self):
return self._memory_starting or self.memory_process is not None or self._is_running(MEMORY_PID)
def _monitor_memory(self):
log_path = PROJECT_ROOT / "runtime/memory.log"
try:
with open(log_path, "r") as f:
f.seek(0, os.SEEK_END)
while self._is_running(MEMORY_PID):
line = f.readline()
if line:
self.log("MEMORY", line.rstrip())
else:
time.sleep(0.1)
except Exception as e:
self.log("ERROR", f"MEMORY monitor fault: {e}")
def start_all(self):
self.start_memory()
self.start_frontend()
self.start_backend()
def kill_all(self):
self.log("SYSTEM", "FORCE KILL — terminating all Nexus processes by port...")
ports = {8000: "SYNAPSE", 8001: "MEMORY", 5173: "INTERFACE"}
ports = {8000: "SYNAPSE", 5173: "INTERFACE"}
for port, name in ports.items():
try:
r = subprocess.run(["fuser", "-k", f"{port}/tcp"], capture_output=True, timeout=5)
@@ -628,21 +557,18 @@ class NexusControlPanel:
subprocess.run(["pkill", "-9", "-f", pat], capture_output=True, timeout=5)
except Exception:
pass
for pid_file in [FRONTEND_PID, BACKEND_PID, MEMORY_PID]:
for pid_file in [FRONTEND_PID, BACKEND_PID]:
pid_file.unlink(missing_ok=True)
self.frontend_process = None
self.backend_process = None
self.memory_process = None
self._frontend_starting = False
self._backend_starting = False
self._memory_starting = False
self._safe_after(0, self.update_master_ui)
self.log("SYSTEM", "Force kill complete.")
def stop_all(self):
self.stop_frontend()
self.stop_backend()
self.stop_memory()
def on_close(self):
if self._closing:
@@ -675,13 +601,13 @@ class NexusControlPanel:
except Exception:
pass
# Services the panel spawned itself (each in its own setsid group):
for proc in (self.frontend_process, self.backend_process, self.memory_process):
for proc in (self.frontend_process, self.backend_process):
if proc:
self._terminate_process(proc)
# Services attached via pid file (ncp-started, one shared group);
# _terminate_pid_file verifies ownership and unlinks. Guarantee the
# unlink even for a pid that's already dead so no stale files remain.
for pf in (FRONTEND_PID, BACKEND_PID, MEMORY_PID):
for pf in (FRONTEND_PID, BACKEND_PID):
if self._is_running(pf):
self._terminate_pid_file(pf)
else:
+7 -4
View File
@@ -12,17 +12,20 @@ REM exactly what this file exists to avoid.
REM
REM ASCII only, same rule as the .ps1 files - a test in tests/test_smoke.py enforces it.
setlocal
REM ncp.py pins its stdout to UTF-8 (it prints check marks and em-dashes, and a
REM The CLI pins its stdout to UTF-8 (it prints check marks and em-dashes, and a
REM redirected stdout would otherwise raise UnicodeEncodeError). A console still
REM on codepage 437 renders those bytes as mojibake, so switch it to UTF-8 here.
REM ponytail: chcp changes the calling console's codepage and does not restore
REM it on exit. Harmless in practice; if that ever matters, set the console CP
REM from inside ncp.py with ctypes SetConsoleOutputCP instead.
REM from inside the CLI with ctypes SetConsoleOutputCP instead.
chcp 65001 >nul 2>&1
set "ROOT=%~dp0.."
REM System Python fallback so backup/restore work before the venv exists; those
REM delegate to the stdlib-only bin/sync.py. Mirrors the same fallback in ncp.ps1.
set "PY=%ROOT%\Promethean\Scripts\python.exe"
if not exist "%PY%" set "PY=python"
"%PY%" "%ROOT%\management\ncp.py" %*
exit /b %ERRORLEVEL%
pushd "%ROOT%"
"%PY%" -m nexusos_cli.cli %*
set "NEXUS_EXIT=%ERRORLEVEL%"
popd
exit /b %NEXUS_EXIT%
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Launch NexusOS as a standalone Edge app window. Single-process mode: the
# backend on :8000 serves the built web UI itself (no separate Vite), so this
# only starts the memory service + backend. The AI (Ollama) does NOT auto-start
# only starts the backend. The AI (Ollama) does NOT auto-start
# — turn it on from the UI's Start AI button. Closing the app window shuts the
# services back down (matches the launcher's "closing the window shuts it down").
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)"
@@ -22,7 +22,7 @@ if curl -s --max-time 1 "$APP_URL/" >/dev/null 2>&1; then
OWN_SERVICES=0
else
OWN_SERVICES=1
# Single-process app: memory + backend only. Backend serves the built UI at
# Single-process app: backend only. Backend serves the built UI at
# :8000; no frontend/Vite process, no AI auto-start.
"$NEXUS_ROOT/management/nexus-cli.sh" start -m
"$NEXUS_ROOT/management/nexus-cli.sh" start -b
+5 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# ncp - Linux entry point. The CLI itself is management/ncp.py, which runs
# ncp - Linux entry point. The CLI itself is nexusos_cli/cli.py, which runs
# unchanged on Windows too (see management/ncp.cmd); this stays a shell script
# because ~/.bashrc, launch_nexus.sh, bin/restore-linux.sh, controlpanel.py,
# bin/panel/nexus-popup.py and management/nexus-app.sh all invoke this path.
@@ -8,9 +8,10 @@
# directory drives itself instead of reaching into the real install.
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)"
# ncp.py needs psutil (venv), but its backup/restore path delegates to the
# stdlib-only bin/sync.py and must work before the venv is built.
# Prefer the project venv for the full desktop dependency set. The portable CLI
# falls back to system Python when the package is installed without that venv.
PY="$NEXUS_ROOT/Promethean/bin/python3"
[ -x "$PY" ] || PY=python3
exec "$PY" "$NEXUS_ROOT/management/ncp.py" "$@"
cd "$NEXUS_ROOT"
exec "$PY" -m nexusos_cli.cli "$@"
+9 -1
View File
@@ -6,7 +6,15 @@ worker's `finally` aborted before unlinking its PID file -> stale runtime/pids/*
These asserts pin the two invariants that prevent that. Run: python test_controlpanel_close.py
"""
from types import SimpleNamespace
from controlpanel import NexusControlPanel
import pytest
pytest.importorskip("tkinter", reason="controlpanel is a Tk GUI; headless boxes lack python3-tk")
try:
from management.controlpanel import NexusControlPanel # noqa: E402
except ModuleNotFoundError: # direct: python management/test_controlpanel_close.py
from controlpanel import NexusControlPanel # type: ignore[no-redef] # noqa: E402
def _fake(closing, after_raises):
View File
View File
+283
View File
@@ -0,0 +1,283 @@
"""Email backend for the NexusOS mail client.
IMAP read via imap-tools, SMTP send via the stdlib. Defaults target iCloud
(imap.mail.me.com / smtp.mail.me.com); with a custom-domain iCloud account the
LOGIN is the primary Apple ID + an app-specific password, and the custom-domain
address is the From alias.
Multiple accounts are supported (e.g. two iCloud aliases sharing the same
server config) each gets its own id, credentials, and cached IMAP
connection. Credentials live in a gitignored file under runtime/ NEVER the
settings table, which bin/sync.py dumps to git. Password is write-only over
the API.
"""
from __future__ import annotations
import json
import os
import smtplib
import ssl
import threading
import uuid
from email.message import EmailMessage
from typing import Any, Callable, Dict, List, Optional
from synapse.nexus_config import RUNTIME_DIR
_ACCOUNT_FILE = RUNTIME_DIR / "mail_accounts.json"
# Both imaplib.IMAP4_SSL and smtplib.SMTP.starttls() fall back to
# ssl._create_stdlib_context() when handed no context, and that one sets
# verify_mode=CERT_NONE with check_hostname=False -- encrypted, but to nobody in
# particular. Anything positioned to intercept the connection can present its
# own certificate and collect the app-specific password on login. The default
# context verifies the chain and the hostname.
_TLS = ssl.create_default_context()
_DEFAULTS: Dict[str, Any] = {
"label": "", # optional nickname shown in the account list
"imap_host": "imap.mail.me.com",
"imap_port": 993,
"smtp_host": "smtp.mail.me.com",
"smtp_port": 587,
"username": "", # primary Apple ID address (login), not the alias
"password": "", # app-specific password
"from_addr": "", # e.g. nexus@enderofwings.com
"from_name": "",
}
def _new_id() -> str:
return uuid.uuid4().hex[:12]
def _write_accounts(accounts: List[Dict[str, Any]]) -> None:
_ACCOUNT_FILE.parent.mkdir(parents=True, exist_ok=True)
# Create the file 0600 and rename it into place, rather than write-then-chmod:
# that left it at the umask (usually world-readable) for the length of the
# write, and a crash partway through truncated the real account file. The
# temp file inherits the mode through os.replace.
tmp = _ACCOUNT_FILE.with_name(_ACCOUNT_FILE.name + ".tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as fh:
json.dump({"accounts": accounts}, fh, indent=2)
os.replace(tmp, _ACCOUNT_FILE)
def load_accounts() -> List[Dict[str, Any]]:
try:
raw = json.loads(_ACCOUNT_FILE.read_text())
except Exception:
return []
if isinstance(raw, dict) and "accounts" in raw:
accounts = raw["accounts"]
elif isinstance(raw, dict) and raw.get("username"):
# Legacy single-account file (pre-multi-account) — migrate in place.
accounts = [{**_DEFAULTS, **raw, "id": _new_id()}]
_write_accounts(accounts)
else:
accounts = []
return [{**_DEFAULTS, **a} for a in accounts]
def get_account(account_id: str) -> Dict[str, Any]:
for a in load_accounts():
if a["id"] == account_id:
return a
raise KeyError(account_id)
def save_account(account_id: Optional[str], update: Dict[str, Any]) -> Dict[str, Any]:
"""Create (account_id=None) or merge-update an account. A blank/missing
password on update keeps the stored one."""
accounts = load_accounts()
if account_id is None:
data = dict(_DEFAULTS)
data["id"] = _new_id()
for k in _DEFAULTS:
if k in update:
data[k] = update[k]
accounts.append(data)
else:
for a in accounts:
if a["id"] != account_id:
continue
for k in _DEFAULTS:
if k == "password":
if update.get("password"):
a["password"] = update["password"]
elif k in update:
a[k] = update[k]
data = a
break
else:
raise KeyError(account_id)
_write_accounts(accounts)
_reset_conn(data["id"]) # creds/host may have changed
return public_account(data)
def delete_account(account_id: str) -> None:
accounts = [a for a in load_accounts() if a["id"] != account_id]
_write_accounts(accounts)
_reset_conn(account_id)
def public_account(a: Dict[str, Any]) -> Dict[str, Any]:
"""Account config for the UI — password replaced by a has_password flag."""
configured = _is_configured(a)
data = dict(a)
pw = data.pop("password", "")
data["has_password"] = bool(pw)
data["configured"] = configured
return data
def public_accounts() -> List[Dict[str, Any]]:
return [public_account(a) for a in load_accounts()]
def _is_configured(a: Dict[str, Any]) -> bool:
return bool(a.get("username") and a.get("password") and a.get("imap_host"))
def is_configured(account_id: str) -> bool:
try:
return _is_configured(get_account(account_id))
except KeyError:
return False
# --- IMAP ----------------------------------------------------------------------
# One authenticated connection per account, reused across requests — a fresh
# login per click is the ~1-2s iCloud handshake, and that was the visible lag.
# imap-tools/imaplib is single-command-at-a-time, so a lock serialises the
# thread-pooled endpoints.
_conns: Dict[str, Any] = {}
_conn_lock = threading.Lock()
def _new_mailbox(a: Dict[str, Any]):
from imap_tools import MailBox
return MailBox(a["imap_host"], a["imap_port"], ssl_context=_TLS).login(a["username"], a["password"])
def _reset_conn(account_id: str) -> None:
with _conn_lock:
conn = _conns.pop(account_id, None)
try:
if conn is not None:
conn.logout()
except Exception:
pass
def _run(account_id: str, fn: Callable):
"""Run fn(mailbox) on the account's shared connection, rebuilding it once
if the connection was dropped (iCloud closes idle sockets)."""
a = get_account(account_id)
with _conn_lock:
for attempt in (1, 2):
try:
conn = _conns.get(account_id)
if conn is None:
conn = _conns[account_id] = _new_mailbox(a)
return fn(conn)
except Exception:
conn = _conns.pop(account_id, None)
try:
if conn is not None:
conn.logout()
except Exception:
pass
if attempt == 2:
raise
def test_connection(account_id: str) -> Dict[str, Any]:
_reset_conn(account_id) # force a fresh login so the test reflects the saved creds
try:
return {"ok": True, "folders": len(_run(account_id, lambda mb: mb.folder.list()))}
except Exception as e:
return {"ok": False, "error": str(e)}
def list_folders(account_id: str) -> List[str]:
return _run(account_id, lambda mb: [f.name for f in mb.folder.list()])
def _summary(msg) -> Dict[str, Any]:
return {
"uid": msg.uid,
"subject": msg.subject,
"from": msg.from_,
"to": list(msg.to),
"date": msg.date.isoformat() if msg.date else None,
"seen": "\\Seen" in msg.flags,
"preview": (msg.text or msg.html or "")[:160].strip(),
}
def list_messages(account_id: str, folder: str = "INBOX", limit: int = 30, offset: int = 0) -> List[Dict[str, Any]]:
def op(mb):
mb.folder.set(folder)
# Newest first; over-fetch by offset then slice (fine at personal scale).
msgs = list(mb.fetch(reverse=True, limit=offset + limit, headers_only=True, bulk=True, mark_seen=False))
return [_summary(m) for m in msgs[offset:offset + limit]]
return _run(account_id, op)
def get_message(account_id: str, folder: str, uid: str) -> Optional[Dict[str, Any]]:
from imap_tools import AND
def op(mb):
mb.folder.set(folder)
for msg in mb.fetch(AND(uid=uid), mark_seen=True, bulk=True):
return {
"uid": msg.uid,
"subject": msg.subject,
"from": msg.from_,
"to": list(msg.to),
"cc": list(msg.cc),
"date": msg.date.isoformat() if msg.date else None,
"html": msg.html,
"text": msg.text,
"attachments": [{"name": a.filename, "size": a.size} for a in msg.attachments],
}
return None
return _run(account_id, op)
def set_seen(account_id: str, folder: str, uid: str, seen: bool = True) -> None:
def op(mb):
mb.folder.set(folder)
mb.flag(uid, "\\Seen", seen)
_run(account_id, op)
def delete_message(account_id: str, folder: str, uid: str) -> None:
def op(mb):
mb.folder.set(folder)
mb.delete(uid)
_run(account_id, op)
# --- SMTP ----------------------------------------------------------------------
def send_message(account_id: str, to: str, subject: str, body: str,
cc: str = "", from_addr: str = "") -> Dict[str, Any]:
a = get_account(account_id)
sender = from_addr or a["from_addr"] or a["username"]
msg = EmailMessage()
msg["From"] = f'{a["from_name"]} <{sender}>' if a["from_name"] else sender
msg["To"] = to
if cc:
msg["Cc"] = cc
msg["Subject"] = subject
msg.set_content(body)
recipients = [r.strip() for r in (to + "," + cc).split(",") if r.strip()]
with smtplib.SMTP(a["smtp_host"], a["smtp_port"]) as s:
s.starttls(context=_TLS)
s.login(a["username"], a["password"])
s.send_message(msg, from_addr=sender, to_addrs=recipients)
return {"sent": True, "to": recipients}
+113
View File
@@ -0,0 +1,113 @@
"""HTTP routes for the mail module — mounted onto the main app via modules/registry.py."""
from __future__ import annotations
import asyncio as _asyncio
from typing import Any, Dict
from fastapi import APIRouter, Body, HTTPException
from . import backend as mail
MANIFEST = {"key": "mail", "label": "Mail", "icon": "✉️"}
router = APIRouter(prefix="/mail", tags=["mail"])
def _require_mail(account_id: str):
if not mail.is_configured(account_id):
raise HTTPException(status_code=400, detail="mail account not configured")
# --- accounts --------------------------------------------------------------------
@router.get("/accounts")
async def mail_accounts():
return {"accounts": mail.public_accounts()}
@router.post("/accounts")
async def mail_account_create(payload: Dict[str, Any] = Body(...)):
return mail.save_account(None, payload)
@router.put("/accounts/{account_id}")
async def mail_account_update(account_id: str, payload: Dict[str, Any] = Body(...)):
try:
return mail.save_account(account_id, payload)
except KeyError:
raise HTTPException(status_code=404, detail="account not found")
@router.delete("/accounts/{account_id}")
async def mail_account_delete(account_id: str):
mail.delete_account(account_id)
return {"status": "deleted"}
@router.post("/accounts/{account_id}/test")
async def mail_account_test(account_id: str):
_require_mail(account_id)
return await _asyncio.to_thread(mail.test_connection, account_id)
# --- mailbox -----------------------------------------------------------------------
@router.get("/folders")
async def mail_folders(account_id: str):
_require_mail(account_id)
try:
return {"folders": await _asyncio.to_thread(mail.list_folders, account_id)}
except Exception as e:
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
@router.get("/messages")
async def mail_messages(account_id: str, folder: str = "INBOX", limit: int = 30, offset: int = 0):
_require_mail(account_id)
try:
msgs = await _asyncio.to_thread(mail.list_messages, account_id, folder, limit, offset)
return {"messages": msgs}
except Exception as e:
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
@router.get("/message")
async def mail_message(account_id: str, folder: str, uid: str):
_require_mail(account_id)
try:
msg = await _asyncio.to_thread(mail.get_message, account_id, folder, uid)
except Exception as e:
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
if not msg:
raise HTTPException(status_code=404, detail="message not found")
return msg
@router.post("/send")
async def mail_send(payload: Dict[str, Any] = Body(...)):
account_id = payload.get("account_id", "")
_require_mail(account_id)
to = (payload.get("to") or "").strip()
if not to:
raise HTTPException(status_code=400, detail="'to' is required")
try:
return await _asyncio.to_thread(
mail.send_message, account_id, to, payload.get("subject", ""), payload.get("body", ""),
payload.get("cc", ""), payload.get("from_addr", ""),
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"SMTP error: {e}")
@router.post("/seen")
async def mail_seen(payload: Dict[str, Any] = Body(...)):
account_id = payload.get("account_id", "")
_require_mail(account_id)
await _asyncio.to_thread(mail.set_seen, account_id, payload["folder"], payload["uid"], payload.get("seen", True))
return {"status": "ok"}
@router.post("/delete")
async def mail_delete(payload: Dict[str, Any] = Body(...)):
account_id = payload.get("account_id", "")
_require_mail(account_id)
await _asyncio.to_thread(mail.delete_message, account_id, payload["folder"], payload["uid"])
return {"status": "deleted"}
View File
+141
View File
@@ -0,0 +1,141 @@
"""Network status module.
Cross-platform connection info (via psutil), a WireGuard VPN status/toggle
that only works where NetworkManager + nmcli exist (Linux this mirrors the
XFCE panel's network-popup.py, minus the GTK UI), and a small user-defined
list of ping targets so "is my router/VPN endpoint reachable" isn't tied to
any one hardcoded host.
"""
from __future__ import annotations
import json
import os
import platform
import re
import socket
import subprocess
import uuid
from typing import Any, Dict, List
from synapse.nexus_config import RUNTIME_DIR
_TARGETS_FILE = RUNTIME_DIR / "network_targets.json"
_PING_LATENCY_RE = re.compile(r"time[=<]\s*([\d.]+)\s*ms", re.IGNORECASE)
# --- connection info ---------------------------------------------------------------
def hostname() -> str:
return socket.gethostname()
def primary_connection() -> Dict[str, Any]:
import psutil
stats = psutil.net_if_stats()
addrs = psutil.net_if_addrs()
for name, addr_list in addrs.items():
st = stats.get(name)
if not st or not st.isup:
continue
lname = name.lower()
if lname.startswith(("lo", "loopback")):
continue
for a in addr_list:
if a.family == socket.AF_INET and not a.address.startswith("169.254"):
iface_type = "wifi" if any(k in lname for k in ("wlan", "wi-fi", "wireless", "wl")) else "ethernet"
return {"interface": name, "ip": a.address, "type": iface_type}
return {"interface": None, "ip": None, "type": "offline"}
# --- WireGuard / VPN (Linux + NetworkManager only) ----------------------------------
def _nmcli(*args: str) -> str:
try:
return subprocess.check_output(
["nmcli", "-t", "--escape", "no", *args],
text=True, stderr=subprocess.DEVNULL, timeout=5,
).strip()
except Exception:
return ""
def _nmcli_available() -> bool:
if os.name != "posix":
return False
try:
subprocess.check_output(["nmcli", "--version"], stderr=subprocess.DEVNULL, timeout=3)
return True
except Exception:
return False
def vpn_status() -> Dict[str, Any]:
if not _nmcli_available():
return {"available": False}
wgs = [p for p in (line.split(":") for line
in _nmcli("-f", "NAME,TYPE,STATE", "connection", "show").splitlines())
if len(p) >= 3 and p[1] == "wireguard"]
for name, _t, state in wgs:
if state == "activated":
return {"available": True, "configured": True, "name": name, "connected": True}
if wgs:
return {"available": True, "configured": True, "name": wgs[0][0], "connected": False}
return {"available": True, "configured": False, "name": None, "connected": False}
def vpn_toggle(enable: bool) -> Dict[str, Any]:
status = vpn_status()
if not status.get("configured"):
raise RuntimeError("no WireGuard tunnel configured")
action = "up" if enable else "down"
subprocess.check_output(
["nmcli", "connection", action, status["name"]],
stderr=subprocess.STDOUT, text=True, timeout=15,
)
return vpn_status()
# --- ping targets --------------------------------------------------------------------
def _load_targets() -> List[Dict[str, Any]]:
try:
return json.loads(_TARGETS_FILE.read_text()).get("targets", [])
except Exception:
return []
def _write_targets(targets: List[Dict[str, Any]]) -> None:
_TARGETS_FILE.parent.mkdir(parents=True, exist_ok=True)
_TARGETS_FILE.write_text(json.dumps({"targets": targets}, indent=2))
def list_targets() -> List[Dict[str, Any]]:
return _load_targets()
def add_target(label: str, host: str) -> Dict[str, Any]:
targets = _load_targets()
t = {"id": uuid.uuid4().hex[:12], "label": label, "host": host}
targets.append(t)
_write_targets(targets)
return t
def delete_target(target_id: str) -> None:
_write_targets([t for t in _load_targets() if t["id"] != target_id])
def ping(host: str) -> Dict[str, Any]:
is_windows = platform.system() == "Windows"
cmd = ["ping", "-n", "1", "-w", "1000", host] if is_windows else ["ping", "-c", "1", "-W", "1", host]
try:
out = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT, timeout=3)
m = _PING_LATENCY_RE.search(out)
return {"ok": True, "latency_ms": float(m.group(1)) if m else None}
except subprocess.CalledProcessError:
return {"ok": False, "latency_ms": None}
except Exception as e:
return {"ok": False, "latency_ms": None, "error": str(e)}
def ping_targets() -> List[Dict[str, Any]]:
return [{**t, **ping(t["host"])} for t in _load_targets()]
+52
View File
@@ -0,0 +1,52 @@
"""HTTP routes for the network module — mounted onto the main app via modules/registry.py."""
from __future__ import annotations
import asyncio as _asyncio
from typing import Any, Dict
from fastapi import APIRouter, Body, HTTPException
from . import backend as net
MANIFEST = {"key": "network", "label": "Network", "icon": "📡"}
router = APIRouter(prefix="/network", tags=["network"])
@router.get("/status")
async def network_status():
return {"hostname": net.hostname(), "connection": net.primary_connection(), "vpn": net.vpn_status()}
@router.post("/vpn/toggle")
async def network_vpn_toggle(payload: Dict[str, Any] = Body(...)):
try:
return await _asyncio.to_thread(net.vpn_toggle, bool(payload.get("enable")))
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=502, detail=f"nmcli error: {e}")
@router.get("/targets")
async def network_targets():
return {"targets": net.list_targets()}
@router.post("/targets")
async def network_target_create(payload: Dict[str, Any] = Body(...)):
host = (payload.get("host") or "").strip()
if not host:
raise HTTPException(status_code=400, detail="'host' is required")
return net.add_target((payload.get("label") or "").strip() or host, host)
@router.delete("/targets/{target_id}")
async def network_target_delete(target_id: str):
net.delete_target(target_id)
return {"status": "deleted"}
@router.get("/ping")
async def network_ping_all():
return {"targets": await _asyncio.to_thread(net.ping_targets)}
+37
View File
@@ -0,0 +1,37 @@
"""Auto-discovers backend modules under modules/ — any subpackage with a
router.py exposing `router` (an APIRouter) is mounted automatically. Drop a
new module folder in and it's picked up on next start; nothing here needs
editing.
A missing router.py just means the folder isn't a module (skipped quietly).
An import error *inside* an existing router.py is a real bug and is left to
raise silently swallowing it would hide broken modules instead of
surfacing them at startup.
"""
from __future__ import annotations
import importlib
import pkgutil
from pathlib import Path
from typing import List
from fastapi import APIRouter
_MODULES_DIR = Path(__file__).resolve().parent
def _discover() -> List[APIRouter]:
routers = []
for info in sorted(pkgutil.iter_modules([str(_MODULES_DIR)]), key=lambda m: m.name):
if not info.ispkg:
continue
if not (_MODULES_DIR / info.name / "router.py").exists():
continue
mod = importlib.import_module(f"modules.{info.name}.router")
router = getattr(mod, "router", None)
if isinstance(router, APIRouter):
routers.append(router)
return routers
ROUTERS = _discover()
+7
View File
@@ -0,0 +1,7 @@
"""The portable NexusOS command line, shipped in the wheel.
Kept out of `management/` so the installed distribution does not claim a
top-level `management` package name in site-packages. `management/` stays in
the source checkout for the desktop-only pieces - the Tk control panel, the
shell wrappers, the XFCE panel and .desktop wiring.
"""
+849
View File
@@ -0,0 +1,849 @@
"""Portable NexusOS command line used by the ``nexus`` and ``ncp`` scripts."""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import webbrowser
from pathlib import Path
from urllib.parse import urlparse
from synapse import nexus_config as config
from synapse.nexus_config import settings
from . import ncp as services
CONFIG_SCHEMA = {
"home": "path",
"api_url": "url",
"memory_url": "url",
"bind_host": "text",
"backend_port": "port",
"memory_port": "port",
"provider": "provider",
"provider_url": "url",
"provider_timeout": "positive_int",
"data_dir": "path",
"models_dir": "path",
"runtime_dir": "path",
"memory_dir": "path",
"memory_db": "path",
}
LEGACY_TARGETS = {
"-m": "memory",
"--memory": "memory",
"-b": "backend",
"--backend": "backend",
"-f": "frontend",
"--frontend": "frontend",
"-a": "ai",
"--ai": "ai",
}
def _emit(payload, json_output: bool = False) -> None:
if json_output:
print(json.dumps(payload, indent=2, sort_keys=True))
elif isinstance(payload, str):
print(payload)
else:
for key, value in payload.items():
print(f"{key}: {value}")
def _http_ok(url: str, timeout: float = 1.0) -> bool:
try:
urllib.request.urlopen(url, timeout=timeout).read(1)
return True
except urllib.error.HTTPError:
return True
except Exception:
return False
def _is_termux() -> bool:
prefix = os.getenv("PREFIX", "")
return "com.termux" in prefix or bool(os.getenv("TERMUX_VERSION"))
def _validate_config(key: str, raw: str):
kind = CONFIG_SCHEMA[key]
value = raw.strip()
if kind == "url":
parsed = urlparse(value)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise ValueError(f"{key} must be an http(s) URL")
return value.rstrip("/")
if kind == "port":
number = int(value)
if not 1 <= number <= 65535:
raise ValueError(f"{key} must be between 1 and 65535")
return number
if kind == "positive_int":
number = int(value)
if number <= 0:
raise ValueError(f"{key} must be greater than zero")
return number
if kind == "provider":
if value not in ("ollama", "ollama-remote"):
raise ValueError("provider must be ollama or ollama-remote")
return value
if kind == "path":
return str(Path(value).expanduser().resolve())
if not value:
raise ValueError(f"{key} cannot be empty")
return value
def cmd_init(args) -> int:
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
copied = list(config.INITIALIZED_FILES) + config.init_state()
payload = {
"status": "initialized",
"state_dir": str(settings.state_dir),
"config_file": str(settings.config_file),
"data_dir": str(settings.data_dir),
"models_dir": str(settings.models_dir),
"runtime_dir": str(settings.runtime_dir),
"seeded_playbooks": len(copied),
}
_emit(payload, args.json)
return 0
def cmd_paths(args) -> int:
payload = {
"install_mode": "checkout" if settings.source_checkout else "wheel",
"project_root": str(settings.project_root),
"resource_root": str(settings.resource_root),
"state_dir": str(settings.state_dir),
"config_file": str(settings.config_file),
"data_dir": str(settings.data_dir),
"memory_db": str(settings.memory_db),
"models_dir": str(settings.models_dir),
"runtime_dir": str(settings.runtime_dir),
"web_dist_dir": str(settings.web_dist_dir),
}
_emit(payload, args.json)
return 0
# Keys whose value decides where the SQLite database is looked up. Changing one
# does not move the file, so the next start would quietly open a fresh, empty
# database and the user's conversations and memories would look deleted.
_DB_LOCATION_KEYS = ("memory_db", "memory_dir", "data_dir", "home")
def _relocation_warning(key: str, value) -> str | None:
"""Warn when a config change points the database somewhere with no data."""
if key not in _DB_LOCATION_KEYS:
return None
current = settings.memory_db
if not current.is_file():
return None
if key == "memory_db":
new_db = Path(str(value))
elif key == "memory_dir":
new_db = Path(str(value)) / current.name
else:
# data_dir/home only decide the DB location when nothing more specific
# does, and only in the layout where the DB actually sits under them -
# a source checkout keeps it in synapse/memory/ regardless.
existing = config.read_user_config()
if "memory_dir" in existing or "memory_db" in existing:
return None
anchor = settings.data_dir if key == "data_dir" else settings.state_dir
try:
tail = current.resolve().relative_to(anchor.resolve())
except ValueError:
return None
new_db = Path(str(value)) / tail
if new_db.resolve() == current.resolve() or new_db.is_file():
return None
return (
f"{current} holds your existing conversations and memories, but this "
f"change points NexusOS at {new_db}, which does not exist yet - it will "
"start with an empty database. Stop NexusOS and move the .db (plus any "
"-wal/-shm files) to the new path to keep your history."
)
def cmd_config(args) -> int:
values = config.read_user_config()
if args.action == "path":
print(config.CONFIG_FILE)
return 0
if args.action == "list":
_emit(values, args.json)
return 0
if args.action == "get":
if args.key not in CONFIG_SCHEMA:
print(f"Unknown configuration key: {args.key}", file=sys.stderr)
return 2
value = values.get(args.key, getattr(settings, args.key, None))
_emit({args.key: value}, args.json)
return 0
if args.action == "set":
if args.key not in CONFIG_SCHEMA:
print(f"Unknown configuration key: {args.key}", file=sys.stderr)
print("Known keys: " + ", ".join(CONFIG_SCHEMA), file=sys.stderr)
return 2
try:
values[args.key] = _validate_config(args.key, args.value)
except (TypeError, ValueError) as exc:
print(f"Invalid value: {exc}", file=sys.stderr)
return 2
payload = {"updated": args.key, "value": values[args.key], "restart_required": True}
moved = _relocation_warning(args.key, values[args.key])
if moved:
payload["warning"] = moved
config.write_user_config(values)
_emit(payload, args.json)
if moved and not args.json:
print(f"\nWARNING: {moved}", file=sys.stderr)
return 0
if args.action == "unset":
if args.key not in CONFIG_SCHEMA:
print(f"Unknown configuration key: {args.key}", file=sys.stderr)
return 2
values.pop(args.key, None)
config.write_user_config(values)
_emit({"removed": args.key, "restart_required": True}, args.json)
return 0
return 2
def _provider_payload() -> dict:
url = settings.ollama_host.rstrip("/")
return {
"provider": settings.provider,
"url": url,
"managed_by_nexus": settings.manage_ollama,
"reachable": _http_ok(url + "/api/tags", timeout=2.0),
}
def cmd_provider(args) -> int:
if args.action == "show":
_emit(_provider_payload(), args.json)
return 0
values = config.read_user_config()
if args.mode == "local":
values["provider"] = "ollama"
try:
values["provider_url"] = _validate_config(
"provider_url", args.url or "http://127.0.0.1:11434"
)
except ValueError as exc:
print(f"Invalid value: {exc}", file=sys.stderr)
return 2
else:
if not args.url:
print("Remote provider setup requires --url", file=sys.stderr)
return 2
try:
values["provider_url"] = _validate_config("provider_url", args.url)
except ValueError as exc:
print(f"Invalid value: {exc}", file=sys.stderr)
return 2
values["provider"] = "ollama-remote"
config.write_user_config(values)
_emit({
"provider": values["provider"],
"url": values["provider_url"],
"restart_required": True,
}, args.json)
return 0
def _check_import(module: str) -> bool:
return importlib.util.find_spec(module) is not None
def _writable(path: Path) -> bool:
try:
path.mkdir(parents=True, exist_ok=True)
probe = path / ".nexus-write-test"
probe.write_text("ok", encoding="utf-8")
probe.unlink()
return True
except OSError:
return False
def diagnostics() -> dict:
checks: list[dict] = []
def add(name: str, ok: bool, detail: str, required: bool = True):
checks.append({
"name": name,
"status": "pass" if ok else ("fail" if required else "warn"),
"detail": detail,
"required": required,
})
add("python", sys.version_info >= (3, 11), sys.version.split()[0])
add("state", _writable(settings.state_dir), str(settings.state_dir))
add("database directory", _writable(settings.memory_db.parent), str(settings.memory_db.parent))
add("web assets", (settings.web_dist_dir / "index.html").is_file(), str(settings.web_dist_dir))
add(
"provider mode",
settings.provider in ("ollama", "ollama-remote"),
settings.provider,
)
add(
"service ports",
all(1 <= port <= 65535 for port in (settings.backend_port, settings.memory_port)),
f"backend={settings.backend_port}, memory={settings.memory_port}",
)
for module in ("fastapi", "uvicorn", "httpx", "pydantic", "yaml"):
add(f"import:{module}", _check_import(module), module)
add("backend", _http_ok(settings.api_url + "/status"), settings.api_url, required=False)
add("memory service", _http_ok(settings.memory_url + "/"), settings.memory_url, required=False)
provider = _provider_payload()
add("provider", provider["reachable"], provider["url"], required=False)
if settings.manage_ollama:
add("ollama executable", bool(services.ollama_bin()), services.ollama_bin() or "not on PATH", required=False)
for label, module in (
("process control", "psutil"),
("vector search", "sqlite_vec"),
("voice transcription", "faster_whisper"),
("PDF documents", "pypdf"),
("Word documents", "docx"),
("web search", "duckduckgo_search"),
):
add(label, _check_import(module), module, required=False)
return {
"ok": not any(c["status"] == "fail" for c in checks),
"version": settings.version,
"install_mode": "checkout" if settings.source_checkout else "wheel",
"platform": sys.platform,
"termux": _is_termux(),
"checks": checks,
}
def _doctor_fix() -> None:
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config.init_state()
index = settings.web_dist_dir / "index.html"
npm = shutil.which("npm.cmd" if os.name == "nt" else "npm")
if settings.source_checkout and not index.exists() and npm:
subprocess.run([npm, "run", "build"], cwd=str(settings.frontend_source_dir), check=False)
def cmd_doctor(args) -> int:
if args.fix:
_doctor_fix()
result = diagnostics()
if args.json:
_emit(result, True)
else:
print(f"NexusOS {result['version']} diagnostics ({result['install_mode']})\n")
marks = {"pass": "OK", "warn": "WARN", "fail": "FAIL"}
for check in result["checks"]:
print(f" {marks[check['status']]:<4} {check['name']:<20} {check['detail']}")
print("\nCore runtime is ready." if result["ok"] else "\nCore runtime has required failures.")
return 0 if result["ok"] else 1
def service_status() -> dict:
payload = {}
for key in ("backend", "memory", "frontend"):
svc = services.SERVICES[key]
pid = services.read_pid(svc)
payload[key] = {
"running": services.alive(pid) or _http_ok(svc.url),
"pid": pid if services.alive(pid) else None,
"url": svc.url,
}
payload["provider"] = _provider_payload()
return payload
def cmd_status(args) -> int:
payload = service_status()
if args.json:
_emit(payload, True)
return 0
print("Nexus Service Status:\n")
for key in ("backend", "memory", "frontend"):
info = payload[key]
suffix = f" (PID {info['pid']})" if info["pid"] else ""
print(f" {key:<10} {'RUNNING' if info['running'] else 'STOPPED'}{suffix} {info['url']}")
p = payload["provider"]
print(f" provider {'RUNNING' if p['reachable'] else 'STOPPED'} {p['provider']} @ {p['url']}")
return 0
def cmd_monitor(args) -> int:
from .monitor import run_monitor
return run_monitor(
interval=getattr(args, "interval", 1.5),
once=bool(getattr(args, "once", False) or getattr(args, "json", False)),
json_output=bool(getattr(args, "json", False)),
)
def _target_flag(target: str | None):
return {
"memory": "--memory",
"backend": "--backend",
"frontend": "--frontend",
"ai": "--ai",
}.get(target or "all")
def cmd_start(args) -> int:
services.cmd_start(_target_flag(args.target))
return 0
def cmd_stop(args) -> int:
services.cmd_stop(_target_flag(args.target))
return 0
def cmd_refresh(args) -> int:
services.cmd_stop(None)
services.cmd_start(None)
return 0
def _lan_hostnames(host: str) -> list[str]:
"""Every name/address a --allow-lan bind should accept in a Host header.
A wildcard bind answers on all interfaces, so enumerate them; an explicit
address answers only as itself. The machine hostname comes along because
that is what people actually type."""
import socket
names: list[str] = []
def add(value: str) -> None:
if value and value not in names:
names.append(value)
if host in ("0.0.0.0", "::", "*"):
hostname = socket.gethostname()
add(hostname)
add(hostname.split(".")[0] + ".local")
for family in (socket.AF_INET, socket.AF_INET6):
try:
for info in socket.getaddrinfo(hostname, None, family):
add(info[4][0])
except OSError:
pass
# getaddrinfo(hostname) misses the routable address on hosts that map
# their own name to loopback; a connectionless UDP socket finds it.
for probe, family in (("8.8.8.8", socket.AF_INET), ("2001:4860:4860::8888", socket.AF_INET6)):
sock = socket.socket(family, socket.SOCK_DGRAM)
try:
sock.connect((probe, 80))
add(sock.getsockname()[0])
except OSError:
pass
finally:
sock.close()
else:
add(host.strip("[]"))
# A Host header carries an IPv6 literal bracketed; allow both spellings so
# the check matches however the client wrote it.
for value in list(names):
if ":" in value:
add(f"[{value}]")
return names
def _origin_host(name: str) -> str:
"""Origin-safe spelling: IPv6 literals must be bracketed in a URL."""
if ":" in name and not name.startswith("["):
return f"[{name}]"
return name
def cmd_serve(args) -> int:
host = args.host or settings.bind_host
if host not in ("127.0.0.1", "localhost", "::1") and not args.allow_lan:
print("Refusing an unauthenticated LAN bind. Add --allow-lan to acknowledge the exposure.", file=sys.stderr)
return 2
if _http_ok(f"http://127.0.0.1:{args.port}/"):
print(f"Port {args.port} is already serving HTTP.", file=sys.stderr)
return 2
settings.backend_port = args.port
settings.memory_port = args.memory_port
settings.bind_host = host
settings.api_url = f"http://127.0.0.1:{args.port}"
settings.memory_url = f"http://127.0.0.1:{args.memory_port}"
os.environ["NEXUS_BACKEND_PORT"] = str(args.port)
os.environ["NEXUS_MEMORY_PORT"] = str(args.memory_port)
os.environ["NEXUS_BIND_HOST"] = host
for origin_host in ("localhost", "127.0.0.1"):
for port in (args.port, args.memory_port):
origin = f"http://{origin_host}:{port}"
if origin not in config.ALLOWED_ORIGINS:
config.ALLOWED_ORIGINS.append(origin)
if args.allow_lan:
# Widen to the addresses this bind actually answers on - NOT "*".
# ALLOWED_HOSTS drives TrustedHostMiddleware, which is the DNS-rebinding
# defense: with "*" any site the user browses could resolve a name it
# controls to this machine and drive the unauthenticated API. Naming the
# real addresses keeps that check doing its job. An explicitly exported
# NEXUS_ALLOWED_HOSTS still wins, for anyone who needs the old blanket.
names = _lan_hostnames(host)
for name in names:
if name not in config.ALLOWED_HOSTS:
config.ALLOWED_HOSTS.append(name)
for port in (args.port, args.memory_port):
origin = f"http://{_origin_host(name)}:{port}"
if origin not in config.ALLOWED_ORIGINS:
config.ALLOWED_ORIGINS.append(origin)
os.environ.setdefault("NEXUS_ALLOWED_HOSTS", ",".join(config.ALLOWED_HOSTS))
os.environ.setdefault("NEXUS_ALLOWED_ORIGINS", ",".join(config.ALLOWED_ORIGINS))
print(
"LAN exposure enabled for: " + ", ".join(names)
+ "\nThe REST API is unauthenticated - anyone who can reach this port"
" has full admin and data access."
)
memory_proc = None
memory_log = None
try:
if not args.no_memory and not _http_ok(settings.memory_url + "/"):
log_path = settings.runtime_dir / "memory.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
memory_log = open(log_path, "ab")
memory_proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "synapse.memory.service:app",
"--host", host, "--port", str(args.memory_port)],
stdout=memory_log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
)
print(f"Memory service starting on {host}:{args.memory_port} (log: {log_path})")
print(f"NexusOS serving on http://{host}:{args.port}")
import uvicorn
uvicorn.run(
"synapse.main:sio_app", host=host, port=args.port,
reload=bool(args.reload and settings.source_checkout),
log_level=args.log_level,
)
finally:
if memory_proc is not None and memory_proc.poll() is None:
memory_proc.terminate()
try:
memory_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
memory_proc.kill()
if memory_log is not None:
memory_log.close()
return 0
def cmd_open(args) -> int:
if not _http_ok(settings.api_url + "/status") and not args.no_start:
services.cmd_start(None)
url = args.url or settings.api_url
opener = shutil.which("termux-open-url") if _is_termux() else None
opened = False
if opener:
opened = subprocess.run([opener, url], check=False).returncode == 0
else:
opened = webbrowser.open(url)
print(f"{'Opened' if opened else 'NexusOS is available at'} {url}")
return 0
def cmd_web(args) -> int:
"""Preserve ``ncp web`` for desktop checkouts; wheels use the browser UI."""
if settings.source_checkout:
return services.cmd_web()
return cmd_open(argparse.Namespace(url=None, no_start=False))
def cmd_panel(_args) -> int:
if not settings.source_checkout:
print("The legacy Tk control panel is only available in a desktop source install.", file=sys.stderr)
return 2
return services.main(["panel"])
def cmd_backup(args) -> int:
if not settings.source_checkout:
print("Backup is a source-checkout command; wheel state should be backed up from nexus paths.", file=sys.stderr)
return 2
if args.check:
return services.sync_py("backup", "--check")
if args.full:
return services.sync_py("backup", "--full")
return services.sync_py("backup")
def cmd_restore(args) -> int:
if not settings.source_checkout:
print("Restore is a source-checkout command; reinstall the wheel and restore its state directory.", file=sys.stderr)
return 2
return services.cmd_restore("--check" if args.check else None)
def cmd_nvidia_reqs(_args) -> int:
if not settings.source_checkout:
print("nvidia-reqs is only available in a desktop source install.", file=sys.stderr)
return 2
return subprocess.run(
[sys.executable, str(settings.project_root / "bin" / "gen-nvidia-reqs.py")]
).returncode
def cmd_logs(args) -> int:
keys = ("backend", "memory", "frontend") if args.target == "all" else (args.target,)
paths = [services.SERVICES[key].log_file for key in keys]
for path in paths:
print(f"=== {path.name} ===")
services._tail(path, args.lines)
if not args.follow:
return 0
offsets = {path: path.stat().st_size if path.exists() else 0 for path in paths}
try:
while True:
for path in paths:
if not path.exists():
continue
size = path.stat().st_size
if size < offsets[path]:
offsets[path] = 0
if size > offsets[path]:
with open(path, encoding="utf-8", errors="replace") as stream:
stream.seek(offsets[path])
sys.stdout.write(stream.read())
sys.stdout.flush()
offsets[path] = stream.tell()
time.sleep(0.5)
except KeyboardInterrupt:
print()
return 130
def cmd_clean(args) -> int:
for path in (settings.runtime_dir / "pids", settings.runtime_dir / "logs"):
if path.is_dir():
for file in path.glob("*"):
if file.is_file():
file.unlink(missing_ok=True)
for pattern in ("*.log", "*.pid"):
for file in settings.runtime_dir.glob(pattern):
file.unlink(missing_ok=True)
print(f"Cleaned runtime files under {settings.runtime_dir}")
return 0
def cmd_update(args) -> int:
if settings.source_checkout:
services.cmd_update()
return 0
command = [sys.executable, "-m", "pip", "install", "--upgrade", "nexusos-ai"]
if args.pre:
command.append("--pre")
return subprocess.run(command).returncode
def cmd_models(args) -> int:
import httpx
base = settings.ollama_host.rstrip("/")
try:
if args.action == "list":
response = httpx.get(base + "/api/tags", timeout=5.0)
response.raise_for_status()
models = response.json().get("models", [])
if args.json:
_emit(models, True)
elif not models:
print("No models installed.")
else:
for model in models:
print(f"{model.get('name', ''):<36} {model.get('size', 0) / 1024**3:5.1f} GB")
elif args.action == "available":
services.cmd_models("available", None)
elif args.action in ("pull", "install"):
with httpx.stream("POST", base + "/api/pull", json={"name": args.name}, timeout=None) as response:
response.raise_for_status()
for line in response.iter_lines():
if line:
try:
item = json.loads(line)
status = item.get("status") or item.get("error")
if status:
print(status)
except ValueError:
print(line)
elif args.action in ("remove", "rm"):
response = httpx.request("DELETE", base + "/api/delete", json={"name": args.name}, timeout=30.0)
response.raise_for_status()
print(f"Removed {args.name}")
return 0
except httpx.HTTPError as exc:
print(f"Provider request failed at {base}: {exc}", file=sys.stderr)
return 1
def cmd_api(args) -> int:
from . import nexus_api
nexus_api.BASE = args.api_url or settings.api_url
rest = list(args.rest)
if args.command == "chat" and rest[:1] == ["send"]:
rest.pop(0)
if args.command == "history" and rest[:1] == ["list"]:
rest.pop(0)
return nexus_api.main([args.command, *rest], prog=f"nexus {args.command}")
def _add_json(parser) -> None:
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
def _port(value: str) -> int:
try:
return _validate_config("backend_port", value)
except (TypeError, ValueError) as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="nexus", description="NexusOS local AI runtime and API client")
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)
p = sub.add_parser("init", help="create user state and seed default playbooks"); _add_json(p); p.set_defaults(fn=cmd_init)
p = sub.add_parser("paths", help="show resolved package and writable paths"); _add_json(p); p.set_defaults(fn=cmd_paths)
p = sub.add_parser("config", help="manage persistent CLI/runtime configuration")
p.add_argument("action", choices=["list", "get", "set", "unset", "path"])
p.add_argument("key", nargs="?"); p.add_argument("value", nargs="?"); _add_json(p); p.set_defaults(fn=cmd_config)
p = sub.add_parser("provider", help="configure the Ollama-compatible model provider")
provider_sub = p.add_subparsers(dest="action", required=True)
show = provider_sub.add_parser("show"); _add_json(show); show.set_defaults(fn=cmd_provider)
use = provider_sub.add_parser("use"); use.add_argument("mode", choices=["local", "remote"])
use.add_argument("--url"); _add_json(use); use.set_defaults(fn=cmd_provider)
p = sub.add_parser("doctor", help="check the core runtime and optional capabilities")
p.add_argument("--fix", action="store_true"); _add_json(p); p.set_defaults(fn=cmd_doctor)
p = sub.add_parser("status", help="show service and provider status"); _add_json(p); p.set_defaults(fn=cmd_status)
p = sub.add_parser("monitor", help="ASCII dashboard for services, resources, and tool stats")
p.add_argument("--once", action="store_true", help="print one frame and exit")
p.add_argument("--interval", type=float, default=1.5, help="refresh seconds (live mode)")
_add_json(p)
p.set_defaults(fn=cmd_monitor)
p = sub.add_parser("serve", help="run NexusOS in the foreground")
p.add_argument("--host"); p.add_argument("--port", type=_port, default=settings.backend_port)
p.add_argument("--memory-port", type=_port, default=settings.memory_port)
p.add_argument("--no-memory", action="store_true"); p.add_argument("--allow-lan", action="store_true")
p.add_argument("--reload", action="store_true"); p.add_argument("--log-level", default="info")
p.set_defaults(fn=cmd_serve)
for name, fn, help_text in (
("start", cmd_start, "start services in the background"),
("stop", cmd_stop, "stop background services"),
):
p = sub.add_parser(name, help=help_text)
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend", "ai"], default="all")
p.set_defaults(fn=fn)
sub.add_parser("restart", aliases=["refresh"], help="restart all services").set_defaults(fn=cmd_refresh)
sub.add_parser("kill", help="force-stop NexusOS-owned processes").set_defaults(fn=lambda _a: services.cmd_kill() or 0)
p = sub.add_parser("open", help="open the web interface")
p.add_argument("--url"); p.add_argument("--no-start", action="store_true"); p.set_defaults(fn=cmd_open)
sub.add_parser("web", help="legacy desktop alias for open").set_defaults(fn=cmd_web)
sub.add_parser("panel", help="launch the legacy desktop control panel").set_defaults(fn=cmd_panel)
p = sub.add_parser("logs", help="read or follow service logs")
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend"], default="all")
p.add_argument("--lines", type=int, choices=range(1, 10001), default=50, metavar="1..10000")
p.add_argument("--follow", "-f", action="store_true"); p.set_defaults(fn=cmd_logs)
sub.add_parser("clean", help="remove runtime logs and stale PID files").set_defaults(fn=cmd_clean)
p = sub.add_parser("update", help="update dependencies or the installed wheel"); p.add_argument("--pre", action="store_true"); p.set_defaults(fn=cmd_update)
p = sub.add_parser("backup", help="back up a desktop source checkout")
p.add_argument("--full", "-f", action="store_true"); p.add_argument("--check", "-c", action="store_true")
p.set_defaults(fn=cmd_backup)
p = sub.add_parser("restore", help="restore a desktop source checkout")
p.add_argument("--check", "-c", action="store_true"); p.set_defaults(fn=cmd_restore)
sub.add_parser("nvidia-reqs", help="regenerate source-checkout NVIDIA requirements").set_defaults(fn=cmd_nvidia_reqs)
p = sub.add_parser("models", help="list, pull, and remove provider models")
model_sub = p.add_subparsers(dest="action", required=True)
item = model_sub.add_parser("list"); _add_json(item); item.set_defaults(fn=cmd_models)
model_sub.add_parser("available", aliases=["search"]).set_defaults(fn=cmd_models, action="available")
item = model_sub.add_parser("pull", aliases=["install"]); item.add_argument("name"); item.set_defaults(fn=cmd_models, action="pull")
item = model_sub.add_parser("remove", aliases=["rm"]); item.add_argument("name"); item.set_defaults(fn=cmd_models, action="remove")
for command in ("chat", "memory", "playbook", "history"):
p = sub.add_parser(command, add_help=False, help=f"use the {command} API from the terminal")
p.add_argument("rest", nargs=argparse.REMAINDER)
p.set_defaults(fn=cmd_api)
return parser
def _normalize_legacy_argv(argv) -> list[str]:
"""Translate the old shell CLI spelling before argparse sees it."""
normalized = list(argv or [])
if normalized == ["help"]:
return ["--help"]
# start/stop only. `logs` registers -f as the short form of --follow, so
# translating it here would silently rewrite `logs -f` to `logs frontend`
# - a tail of the wrong file instead of a follow, with no error.
if normalized and normalized[0] in ("start", "stop"):
normalized[1:] = [LEGACY_TARGETS.get(value, value) for value in normalized[1:]]
elif normalized[:1] == ["logs"]:
normalized[1:] = [
value if value in ("-f", "--follow") else LEGACY_TARGETS.get(value, value)
for value in normalized[1:]
]
if normalized[:2] == ["backup", "full"]:
normalized[1] = "--full"
elif len(normalized) > 1 and normalized[0] == "backup" and normalized[1] in ("check", "--claude"):
normalized[1] = "--check"
elif len(normalized) > 1 and normalized[0] == "restore":
if normalized[1] in ("check", "--claude"):
normalized[1] = "--check"
elif normalized[1] in ("full", "-f", "--full"):
normalized.pop(1)
return normalized
def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(_normalize_legacy_argv(argv))
if args.command == "config":
if args.action in ("get", "unset") and not args.key:
parser.error(f"config {args.action} requires KEY")
if args.action == "set" and (not args.key or args.value is None):
parser.error("config set requires KEY VALUE")
try:
result = args.fn(args)
return result if isinstance(result, int) else 0
except KeyboardInterrupt:
print()
return 130
def entrypoint() -> int:
return main(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(entrypoint())
+458
View File
@@ -0,0 +1,458 @@
"""ASCII dashboard for live NexusOS service / tool / resource stats.
No curses, no rich pure box-drawing + optional ANSI color so it works in
Termux, plain SSH, and Windows Terminal alike. The collector is separate from
the renderer so tests can feed fixtures without a running stack.
"""
from __future__ import annotations
import json
import shutil
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from synapse.nexus_config import settings
from . import ncp as services
# Box drawing — ASCII fallbacks when the terminal can't do Unicode.
_BOX = {
"tl": "", "tr": "", "bl": "", "br": "",
"h": "", "v": "", "l": "", "r": "",
}
_BOX_ASCII = {
"tl": "+", "tr": "+", "bl": "+", "br": "+",
"h": "-", "v": "|", "l": "+", "r": "+",
}
_FILL = ""
_EMPTY = ""
_FILL_ASCII = "#"
_EMPTY_ASCII = "-"
def _use_unicode() -> bool:
enc = (getattr(__import__("sys").stdout, "encoding", None) or "").lower()
return "utf" in enc or enc in ("cp65001",)
def _http_ok(url: str, timeout: float = 1.0) -> bool:
try:
urllib.request.urlopen(url, timeout=timeout).read(1)
return True
except urllib.error.HTTPError:
return True
except Exception:
return False
def _provider_payload() -> dict:
url = settings.ollama_host.rstrip("/")
return {
"provider": settings.provider,
"url": url,
"managed_by_nexus": settings.manage_ollama,
"reachable": _http_ok(url + "/api/tags", timeout=2.0),
}
def _service_status() -> dict:
payload = {}
for key in ("backend", "memory", "frontend"):
svc = services.SERVICES[key]
pid = services.read_pid(svc)
payload[key] = {
"running": services.alive(pid) or _http_ok(svc.url),
"pid": pid if services.alive(pid) else None,
"url": svc.url,
}
payload["provider"] = _provider_payload()
return payload
def _get_json(url: str, timeout: float = 1.5) -> Any | None:
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8", errors="replace"))
except Exception:
return None
def _bar(ratio: float, width: int = 20, unicode: bool = True) -> str:
ratio = max(0.0, min(1.0, float(ratio)))
filled = int(round(ratio * width))
fill = _FILL if unicode else _FILL_ASCII
empty = _EMPTY if unicode else _EMPTY_ASCII
return fill * filled + empty * (width - filled)
def _fmt_bytes(n: float | int | None) -> str:
if n is None:
return ""
n = float(n)
for unit in ("B", "K", "M", "G", "T"):
if abs(n) < 1024 or unit == "T":
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
n /= 1024
return f"{n:.1f}T"
def _pid_stats(pids: list[int | None]) -> dict:
"""Aggregate CPU%/RSS for known service PIDs. Soft-depends on psutil."""
live = [int(p) for p in pids if p]
if not live:
return {"cpu_pct": None, "rss": None, "pids": []}
try:
import psutil # type: ignore
except ImportError:
return {"cpu_pct": None, "rss": None, "pids": live}
cpu = 0.0
rss = 0
seen: list[int] = []
for pid in live:
try:
proc = psutil.Process(pid)
cpu += proc.cpu_percent(interval=0.0)
rss += proc.memory_info().rss
seen.append(pid)
except (psutil.Error, ProcessLookupError, ValueError):
continue
return {"cpu_pct": cpu, "rss": rss, "pids": seen}
def _host_stats() -> dict:
try:
import psutil # type: ignore
except ImportError:
return {"cpu_pct": None, "mem_used": None, "mem_total": None, "mem_pct": None}
vm = psutil.virtual_memory()
return {
"cpu_pct": psutil.cpu_percent(interval=0.05),
"mem_used": vm.used,
"mem_total": vm.total,
"mem_pct": vm.percent,
}
def _api_counts(api_url: str) -> dict:
"""Pull cheap inventory counts from the backend when it is up."""
base = api_url.rstrip("/")
out = {
"online": False,
"version": None,
"ollama": None,
"memories": None,
"conversations": None,
"playbooks": None,
"models": None,
"action_tool_policy": None,
}
status = _get_json(base + "/status")
if not isinstance(status, dict):
return out
out["online"] = True
out["version"] = status.get("version")
out["ollama"] = status.get("ollama")
mem = _get_json(base + "/memory")
if isinstance(mem, list):
out["memories"] = len(mem)
elif isinstance(mem, dict) and isinstance(mem.get("memories"), list):
out["memories"] = len(mem["memories"])
conv = _get_json(base + "/conversations")
if isinstance(conv, list):
out["conversations"] = len(conv)
elif isinstance(conv, dict):
items = conv.get("conversations") or conv.get("items") or []
if isinstance(items, list):
out["conversations"] = len(items)
pbs = _get_json(base + "/playbooks")
if isinstance(pbs, list):
out["playbooks"] = len(pbs)
elif isinstance(pbs, dict) and isinstance(pbs.get("playbooks"), list):
out["playbooks"] = len(pbs["playbooks"])
models = _get_json(base + "/models")
if isinstance(models, list):
out["models"] = len(models)
elif isinstance(models, dict):
items = models.get("models") or models.get("items") or []
if isinstance(items, list):
out["models"] = len(items)
settings_payload = _get_json(base + "/settings")
if isinstance(settings_payload, dict):
out["action_tool_policy"] = settings_payload.get("action_tool_policy")
return out
def _toolchain_stats() -> list[dict]:
"""Which run_snippet languages have a host toolchain right now."""
try:
from synapse import code_run
except Exception:
return []
rows = []
for name, spec in code_run.RUN_LANGS.items():
tool = None
try:
tool = spec["tool"]()
except Exception:
tool = None
rows.append({
"lang": name,
"ready": bool(tool),
"tool": tool or None,
"summary": spec.get("summary") or name,
})
return rows
def _recent_tools(log_path: Path, limit: int = 8) -> list[str]:
"""Best-effort scrape of recent tool names from chat.log."""
if not log_path.is_file():
return []
try:
# Read the tail without pulling a multi-MB log into memory.
data = log_path.read_bytes()
if len(data) > 64_000:
data = data[-64_000:]
text = data.decode("utf-8", errors="replace")
except OSError:
return []
found: list[str] = []
for line in reversed(text.splitlines()):
# Chat tool loop yields "__status__<tool>"; logs may also name tools
# in JSON payloads. Keep the match narrow.
if "__status__" in line:
name = line.split("__status__", 1)[-1].strip().split()[0].strip(",\"'")
if name and name not in ("tools",) and name not in found:
found.append(name)
elif '"name":' in line and any(
t in line for t in ("render_preview", "run_snippet", "web_search",
"fetch_url", "remember", "get_time")
):
for t in ("run_snippet", "render_preview", "web_search", "fetch_url",
"remember", "get_time", "search_documents"):
if t in line and t not in found:
found.append(t)
if len(found) >= limit:
break
return found
def collect_snapshot() -> dict:
"""Gather one monitoring frame. Safe when services are down."""
services_payload = _service_status()
pids = [
services_payload.get("backend", {}).get("pid"),
services_payload.get("memory", {}).get("pid"),
services_payload.get("frontend", {}).get("pid"),
]
api = _api_counts(settings.api_url)
return {
"ts": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"version": settings.version,
"services": services_payload,
"api": api,
"host": _host_stats(),
"procs": _pid_stats(pids),
"toolchains": _toolchain_stats(),
"recent_tools": _recent_tools(settings.logs_dir / "chat.log"),
"paths": {
"api_url": settings.api_url,
"memory_url": settings.memory_url,
"runtime_dir": str(settings.runtime_dir),
},
}
def _pad(text: str, width: int) -> str:
# Visual width ≈ len for our ASCII/box content (no wide East-Asian chars).
if len(text) > width:
return text[: width - 1] + "" if width > 1 else text[:width]
return text + " " * (width - len(text))
def _row(box: dict, inner: str, width: int) -> str:
return f"{box['v']} {_pad(inner, width - 4)} {box['v']}"
def _rule(box: dict, width: int, kind: str = "mid") -> str:
h = box["h"] * (width - 2)
if kind == "top":
return f"{box['tl']}{h}{box['tr']}"
if kind == "bot":
return f"{box['bl']}{h}{box['br']}"
return f"{box['l']}{h}{box['r']}"
def _svc_line(name: str, running: bool, detail: str, unicode: bool) -> str:
mark = (_FILL if unicode else _FILL_ASCII) * 3 if running else (_EMPTY if unicode else _EMPTY_ASCII) * 3
state = "UP " if running else "DOWN"
return f"{name:<10} {mark} {state} {detail}"
def render_frame(snapshot: dict, *, width: int | None = None, unicode: bool | None = None) -> str:
"""Turn a snapshot into a single multi-line ASCII panel."""
if unicode is None:
unicode = _use_unicode()
box = _BOX if unicode else _BOX_ASCII
cols = shutil.get_terminal_size((80, 24)).columns if width is None else width
width = max(56, min(100, cols))
lines: list[str] = []
lines.append(_rule(box, width, "top"))
title = f"NexusOS {snapshot.get('version', '')} monitor"
raw_ts = snapshot.get("ts") or ""
# Prefer local clock HH:MM:SS from an ISO stamp; fall back to wall clock.
stamp = ""
if "T" in raw_ts:
try:
stamp = raw_ts.split("T", 1)[1][:8]
except Exception:
stamp = ""
if not stamp:
stamp = datetime.now().strftime("%H:%M:%S")
gap = max(1, width - 4 - len(title) - len(stamp))
header = f"{title}{' ' * gap}{stamp}"
lines.append(_row(box, header, width))
lines.append(_rule(box, width, "mid"))
lines.append(_row(box, "SERVICES", width))
svcs = snapshot.get("services") or {}
for key, label in (("backend", "backend"), ("memory", "memory"), ("frontend", "frontend")):
info = svcs.get(key) or {}
running = bool(info.get("running"))
pid = info.get("pid")
url = info.get("url") or ""
detail = url
if pid:
detail = f"pid {pid} {url}"
lines.append(_row(box, _svc_line(label, running, detail, unicode), width))
provider = svcs.get("provider") or _provider_payload()
pref = f"{provider.get('provider', '?')} @ {provider.get('url', '')}"
lines.append(_row(box, _svc_line("provider", bool(provider.get("reachable")), pref, unicode), width))
lines.append(_rule(box, width, "mid"))
lines.append(_row(box, "RESOURCES", width))
host = snapshot.get("host") or {}
procs = snapshot.get("procs") or {}
cpu = host.get("cpu_pct")
if cpu is not None:
lines.append(_row(
box,
f"host CPU [{_bar(cpu / 100.0, 22, unicode)}] {cpu:5.1f}%",
width,
))
else:
lines.append(_row(box, "host CPU (install psutil for live bars)", width))
mem_pct = host.get("mem_pct")
if mem_pct is not None:
lines.append(_row(
box,
f"host MEM [{_bar(mem_pct / 100.0, 22, unicode)}] "
f"{_fmt_bytes(host.get('mem_used'))} / {_fmt_bytes(host.get('mem_total'))}",
width,
))
proc_cpu = procs.get("cpu_pct")
proc_rss = procs.get("rss")
if proc_cpu is not None or proc_rss is not None:
lines.append(_row(
box,
f"nexus cpu={proc_cpu if proc_cpu is not None else '':>5} "
f"rss={_fmt_bytes(proc_rss)} pids={','.join(str(p) for p in (procs.get('pids') or [])) or ''}",
width,
))
lines.append(_rule(box, width, "mid"))
lines.append(_row(box, "DATA / TOOLS", width))
api = snapshot.get("api") or {}
if api.get("online"):
policy = api.get("action_tool_policy") or ""
lines.append(_row(
box,
f"api UP v{api.get('version') or '?'} ollama={api.get('ollama') or ''} "
f"tools={policy}",
width,
))
lines.append(_row(
box,
f"memories={_n(api.get('memories'))} "
f"chats={_n(api.get('conversations'))} "
f"playbooks={_n(api.get('playbooks'))} "
f"models={_n(api.get('models'))}",
width,
))
else:
lines.append(_row(box, "api DOWN — start with: nexus start", width))
recent = snapshot.get("recent_tools") or []
lines.append(_row(
box,
"recent " + (", ".join(recent) if recent else "(none in chat.log)"),
width,
))
lines.append(_rule(box, width, "mid"))
lines.append(_row(box, "RUN TOOLCHAINS (run_snippet)", width))
chains = snapshot.get("toolchains") or []
if not chains:
lines.append(_row(box, "(code_run unavailable)", width))
else:
# Pack ready/missing into one or two compact lines.
ready = [c["lang"] for c in chains if c.get("ready")]
missing = [c["lang"] for c in chains if not c.get("ready")]
lines.append(_row(
box,
f"ready {', '.join(ready) if ready else ''}",
width,
))
lines.append(_row(
box,
f"missing {', '.join(missing) if missing else ''}",
width,
))
lines.append(_rule(box, width, "bot"))
return "\n".join(lines)
def _n(value) -> str:
return "" if value is None else str(value)
def run_monitor(*, interval: float = 1.5, once: bool = False, json_output: bool = False) -> int:
"""Print one frame, or refresh in place until interrupted."""
clear = "\033[H\033[J"
first = True
while True:
snap = collect_snapshot()
if json_output:
print(json.dumps(snap, indent=2, sort_keys=True))
else:
frame = render_frame(snap)
if once or not first:
# Replacing the screen keeps the panel stable; first frame of a
# live session also clears so leftover shell output doesn't mix.
if not once:
print(clear + frame, end="", flush=True)
else:
print(frame)
else:
print(clear + frame, end="", flush=True)
first = False
if once:
return 0
try:
time.sleep(max(0.3, float(interval)))
except KeyboardInterrupt:
print()
return 130
+141 -66
View File
@@ -25,17 +25,24 @@ import subprocess
import sys
import time
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PID_DIR = ROOT / "runtime" / "pids"
LOG_DIR = ROOT / "runtime"
FRONTEND_DIR = ROOT / "interface" / "web"
from synapse import proc_util
from synapse.nexus_config import SOURCE_CHECKOUT, settings
ROOT = settings.project_root
PID_DIR = settings.runtime_dir / "pids"
LOG_DIR = settings.runtime_dir
FRONTEND_DIR = settings.frontend_source_dir
OLLAMA_BIN = ROOT / "ollama" / "bin" / ("ollama.exe" if os.name == "nt" else "ollama")
OLLAMA_MODELS_DIR = ROOT / "models"
OLLAMA_MODELS_DIR = settings.models_dir
WINDOWS = os.name == "nt"
PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
_VENV_PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
PYTHON = Path(os.getenv("NEXUS_PYTHON", "")) if os.getenv("NEXUS_PYTHON") else (
_VENV_PYTHON if _VENV_PYTHON.exists() else Path(sys.executable)
)
PID_DIR.mkdir(parents=True, exist_ok=True)
LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -92,17 +99,25 @@ def _psutil():
import psutil
return psutil
except ImportError:
sys.exit("psutil is missing - run ./install.sh (or install-windows.ps1) to rebuild the venv.")
return None
# -- services ------------------------------------------------------------------
@dataclass
class Service:
def __init__(self, key, label, port, cwd, patterns, argv=None):
self.key, self.label, self.port = key, label, port
self.cwd, self.patterns, self._argv = cwd, patterns, argv
self.pid_file = PID_DIR / f"{key}.pid"
self.log_file = LOG_DIR / f"{key}.log"
key: str
label: str
port: int
cwd: Path
patterns: list
_argv: object = None
pid_file: Path = field(init=False)
log_file: Path = field(init=False)
def __post_init__(self):
self.pid_file = PID_DIR / f"{self.key}.pid"
self.log_file = LOG_DIR / f"{self.key}.log"
@property
def url(self) -> str:
@@ -112,10 +127,10 @@ class Service:
return self._argv() if callable(self._argv) else self._argv
# Bind loopback by default: the backend/memory REST APIs are unauthenticated, so
# Bind loopback by default: the backend REST API is unauthenticated, so
# binding 0.0.0.0 handed the full admin+data plane to any host on the LAN. Set
# NEXUS_BIND_HOST=0.0.0.0 to opt into LAN exposure once real auth is in place.
BIND_HOST = os.environ.get("NEXUS_BIND_HOST", "127.0.0.1")
BIND_HOST = settings.bind_host
def _uvicorn(app: str, port: int):
@@ -131,12 +146,12 @@ def _uvicorn(app: str, port: int):
SERVICES = {
"memory": Service("memory", "NEXUS MEMORY SERVICE", 8001, ROOT,
"memory": Service("memory", "NEXUS MEMORY SERVICE", settings.memory_port, settings.state_dir,
["uvicorn synapse.memory"],
lambda: _uvicorn("synapse.memory.service:app", 8001)),
"backend": Service("backend", "NEXUS BACKEND SERVICE", 8000, ROOT,
lambda: _uvicorn("synapse.memory.service:app", settings.memory_port)),
"backend": Service("backend", "NEXUS BACKEND SERVICE", settings.backend_port, settings.state_dir,
["uvicorn synapse.main"],
lambda: _uvicorn("synapse.main:sio_app", 8000)),
lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)),
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
["vite --host", "npm run dev"],
lambda: [npm(), "run", "dev", "--", "--host", "0.0.0.0"]),
@@ -152,7 +167,13 @@ def read_pid(svc: Service):
def alive(pid) -> bool:
ps = _psutil()
return pid is not None and ps.pid_exists(pid)
if pid is None:
return False
if ps is not None:
return ps.pid_exists(pid)
# Not os.kill(pid, 0): that reports False for a live process owned by
# another user, and Windows has no signals to fall back on.
return proc_util.pid_alive(pid)
def pid_is_ours(pid, patterns) -> bool:
@@ -162,7 +183,10 @@ def pid_is_ours(pid, patterns) -> bool:
is not enough. TERMing a recycled PID can log the user out."""
ps = _psutil()
try:
cmd = " ".join(ps.Process(pid).cmdline())
if ps is not None:
cmd = " ".join(ps.Process(pid).cmdline())
else:
cmd = proc_util.pid_cmdline(pid)
except Exception:
return False
return any(p in cmd for p in patterns)
@@ -218,10 +242,10 @@ _POLL_STEP = 0.25
def wait_for_port(svc: Service, timeout: int = 30) -> bool:
if http_ok(svc.url):
# "READY", not "already running": `ncp start` launches memory and backend
# together and only then waits on each, so by the time the backend's turn
# comes it is normally up - and reporting "already running" for a service
# this same command started two seconds ago reads like a stale process.
# "READY", not "already running": `ncp start` launches its services and
# only then waits on each, so by the time a service's turn comes it is
# normally up - and reporting "already running" for a service this same
# command started two seconds ago reads like a stale process.
print(f" {svc.label} READY (:{svc.port})")
return True
for _ in range(int(timeout / _POLL_STEP)):
@@ -249,6 +273,13 @@ def kill_matching(patterns, force=False) -> int:
ps = _psutil()
me = os.getpid()
hit = 0
if ps is None:
for pid, cmd in proc_util.iter_processes():
if pid == me or not any(pattern in cmd for pattern in patterns):
continue
if proc_util.terminate_pid(pid, force=force):
hit += 1
return hit
for proc in ps.process_iter(["pid", "cmdline"]):
if proc.info["pid"] == me:
continue
@@ -266,6 +297,12 @@ def kill_port(port: int) -> bool:
"""Whatever holds the port IS the service - this is the backstop that makes
stop reliable regardless of process-tree shape or PID-file accuracy."""
ps = _psutil()
if ps is None:
killed = False
for pid in proc_util.pids_listening_on(port):
if pid != os.getpid() and proc_util.terminate_pid(pid, force=True):
killed = True
return killed
killed = False
try:
conns = ps.net_connections(kind="inet")
@@ -289,13 +326,18 @@ def stop_service(svc: Service) -> bool:
if alive(pid) and pid_is_ours(pid, svc.patterns):
ps = _psutil()
try:
proc = ps.Process(pid)
for child in proc.children(recursive=True):
try:
child.terminate()
except Exception:
pass
proc.terminate()
if ps is not None:
proc = ps.Process(pid)
for child in proc.children(recursive=True):
try:
child.terminate()
except Exception:
pass
proc.terminate()
else:
# No psutil means no process tree; the kill_matching sweep
# below is what catches reparented children here.
proc_util.terminate_pid(pid)
except Exception:
pass
svc.pid_file.unlink(missing_ok=True)
@@ -326,8 +368,15 @@ def start_ollama(background: bool = False) -> None:
background=False (`ncp start --ai`): blocks until the model is warmed - weights
read off disk into RAM/VRAM, routinely about a minute - so "started" means the
AI can actually answer."""
if not settings.manage_ollama:
running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags", timeout=3)
print(
f"REMOTE OLLAMA {'REACHABLE' if running else 'UNREACHABLE'} "
f"({settings.ollama_host})"
)
return
print("Starting OLLAMA...")
url = "http://localhost:8000/ollama/start" + ("?background=true" if background else "")
url = settings.api_url + "/ollama/start" + ("?background=true" if background else "")
req = urllib.request.Request(url, method="POST")
try:
urllib.request.urlopen(req, timeout=180).read(1)
@@ -340,7 +389,10 @@ def stop_ollama() -> None:
"""Prefer the backend endpoint for a clean OllamaManager shutdown; if the
backend is already down, kill `ollama serve` directly so it never lingers
holding VRAM/RAM. Must run BEFORE the backend is torn down."""
req = urllib.request.Request("http://localhost:8000/ollama/stop", method="POST")
if not settings.manage_ollama:
print(f"REMOTE OLLAMA IS EXTERNALLY MANAGED ({settings.ollama_host})")
return
req = urllib.request.Request(settings.api_url + "/ollama/stop", method="POST")
try:
urllib.request.urlopen(req, timeout=5).read(1)
print("NEXUS OLLAMA STOPPED")
@@ -357,12 +409,13 @@ def stop_ollama() -> None:
# -- commands ------------------------------------------------------------------
def cmd_start(target) -> None:
if target in ("--memory", "-m"):
launch(SERVICES["memory"]); wait_for_port(SERVICES["memory"])
elif target in ("--backend", "-b"):
if target in ("--backend", "-b"):
launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"])
start_ollama()
elif target in ("--frontend", "-f"):
if not SOURCE_CHECKOUT:
print("Vite source is unavailable in wheel installs; the backend serves the bundled UI.")
return
launch(SERVICES["frontend"]); wait_for_port(SERVICES["frontend"])
elif target in ("--ai", "-a"):
start_ollama()
@@ -381,17 +434,15 @@ def cmd_start(target) -> None:
# and falls through to the ~30s force-kill path). Still available on
# demand via `ncp start --frontend`.
t0 = time.perf_counter()
launch(SERVICES["memory"])
launch(SERVICES["backend"])
wait_for_port(SERVICES["memory"])
wait_for_port(SERVICES["backend"])
t_services = time.perf_counter()
start_ollama(background=True)
if not WINDOWS:
if SOURCE_CHECKOUT and not WINDOWS:
launch(SERVICES["frontend"])
t_bg = time.perf_counter()
print("\nBoot timing:")
print(f" services (memory+backend) : {t_services - t0:5.1f}s")
print(f" backend : {t_services - t0:5.1f}s")
print(f" ollama + frontend (bg kickoff) : {t_bg - t_services:5.1f}s")
print(f" total to interactive : {t_bg - t0:5.1f}s")
else:
@@ -399,14 +450,13 @@ def cmd_start(target) -> None:
def cmd_stop(target) -> None:
if target in ("--memory", "-m"):
stop_service(SERVICES["memory"])
elif target in ("--backend", "-b"):
if target in ("--backend", "-b"):
stop_ollama(); stop_service(SERVICES["backend"])
elif target in ("--frontend", "-f"):
stop_service(SERVICES["frontend"])
elif target in ("--ai", "-a"):
stop_ollama()
elif target in (None, "", "all"):
stop_service(SERVICES["memory"])
stop_ollama()
stop_service(SERVICES["backend"])
stop_service(SERVICES["frontend"])
@@ -416,14 +466,21 @@ def cmd_stop(target) -> None:
def cmd_kill() -> None:
print("Force-killing all Nexus processes...")
for port, name in ((8000, "SYNAPSE"), (8001, "MEMORY"),
(5173, "INTERFACE"), (11434, "OLLAMA")):
targets = [
(settings.backend_port, "SYNAPSE"),
(settings.memory_port, "MEMORY"),
(5173, "INTERFACE"),
]
patterns = ["uvicorn synapse", "npm run dev", "vite --host"]
if settings.manage_ollama:
targets.append((11434, "OLLAMA"))
patterns.append("ollama serve")
for port, name in targets:
if kill_port(port):
print(f" KILLED: {name} (:{port})")
else:
print(f" NOT RUNNING: {name} (:{port})")
kill_matching(["uvicorn synapse", "npm run dev", "vite --host", "ollama serve"],
force=True)
kill_matching(patterns, force=True)
for pid_file in PID_DIR.glob("*.pid"):
pid_file.unlink(missing_ok=True)
print("Done.")
@@ -443,12 +500,12 @@ def cmd_status() -> None:
print("Backend:")
one("Synapse ", SERVICES["backend"])
one("Memory service", SERVICES["memory"])
print("\nFrontend:")
one("Vite ", SERVICES["frontend"])
print("\nModel server:")
running = http_ok("http://localhost:11434/api/tags")
print(f" Ollama : {'RUNNING (:11434)' if running else 'STOPPED'}")
running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags")
mode = "remote" if not settings.manage_ollama else "local"
print(f" Ollama ({mode}) : {'RUNNING' if running else 'STOPPED'} ({settings.ollama_host})")
def _tail(path: Path, n: int) -> None:
@@ -459,26 +516,25 @@ def _tail(path: Path, n: int) -> None:
print(f" (no log at {path})")
LOG_HEADINGS = {"memory": "MEMORY SERVICE", "backend": "BACKEND", "frontend": "FRONTEND"}
LOG_HEADINGS = {"backend": "BACKEND", "frontend": "FRONTEND"}
def cmd_logs(target) -> None:
named = {"--frontend": "frontend", "-f": "frontend",
"--backend": "backend", "-b": "backend",
"--memory": "memory", "-m": "memory"}
"--backend": "backend", "-b": "backend"}
if target in named:
key = named[target]
print(f"=== {LOG_HEADINGS[key]} LOGS ===")
_tail(SERVICES[key].log_file, 50)
elif target in (None, "", "all"):
for i, key in enumerate(("memory", "backend", "frontend")):
for i, key in enumerate(("backend", "frontend")):
if i:
print()
print(f"=== {LOG_HEADINGS[key]} LOGS ===")
_tail(SERVICES[key].log_file, 30)
else:
print(f"Unknown logs target: '{target}'")
print("Usage: ncp logs [frontend|backend|memory|all]")
print("Usage: ncp logs [frontend|backend|all]")
def _apply_fixes() -> None:
@@ -586,11 +642,11 @@ def cmd_doctor(fix: bool = False) -> None:
mark(importable("from synapse.main import sio_app"),
"Backend module importable", "Backend module failed to import")
print("\nChecking memory service...")
print("\nChecking memory...")
mark((ROOT / "synapse" / "memory").is_dir(),
"Memory module directory found", "Missing memory module directory")
mark(importable("from synapse.memory.service import app"),
"Memory service module importable", "Memory service module failed to import")
mark(importable("from synapse.memory.curator import extract_for_conversation"),
"Memory curator importable", "Memory curator failed to import")
mark(os.access(ROOT / "synapse" / "memory", os.W_OK),
"Memory database directory writable", "Memory database directory not writable")
@@ -631,6 +687,26 @@ def cmd_update() -> None:
cmd_doctor()
def cmd_upgrade() -> int:
"""Pull + rebuild + restart the backend. This is what the web UI's "install
update" button runs, so it must be spawned DETACHED from the backend - it
stops the very server that asked for it.
Ollama is deliberately left alone (stop_service, not cmd_stop): it is a
separate process on :11434, the restore does not touch a present binary, and
reloading a multi-GB model is the slowest part of a restart.
"""
print("Stopping backend...")
stop_service(SERVICES["backend"])
# --no-desktop: an in-app update should not rewrite XFCE panels/theme.
rc = sync_py("restore", "--no-desktop")
print("\nRestarting backend...")
launch(SERVICES["backend"])
ok = wait_for_port(SERVICES["backend"])
print("Backend is back up." if ok else "Backend did not come back - see runtime/backend.log")
return rc if ok else 1
def cmd_clean() -> None:
print("Cleaning Nexus runtime files...\n")
print("Removing PID files...")
@@ -670,10 +746,10 @@ MODEL_CATALOG = [
def cmd_models(action, name) -> None:
if action == "list":
if not http_ok("http://localhost:11434/api/tags"):
if not http_ok(settings.ollama_host.rstrip("/") + "/api/tags"):
print("Ollama is not running. Start the backend first with: ncp start -b")
return
raw = urllib.request.urlopen("http://localhost:11434/api/tags", timeout=5).read()
raw = urllib.request.urlopen(settings.ollama_host.rstrip("/") + "/api/tags", timeout=5).read()
models = json.loads(raw).get("models", [])
print("Installed models:\n")
if not models:
@@ -731,7 +807,6 @@ def cmd_web() -> int:
started. Windows uses the pywebview window launch_nexus.ps1 opens."""
if WINDOWS:
if not http_ok(SERVICES["backend"].url):
launch(SERVICES["memory"])
launch(SERVICES["backend"])
wait_for_port(SERVICES["backend"])
return subprocess.run([str(PYTHON), str(ROOT / "bin" / "nexus_window.py")]).returncode
@@ -755,14 +830,12 @@ Commands:
show <id> Print a playbook's goal + instructions
history [query] Recent conversations (optional keyword)
start Start ALL Nexus services (memory + backend + frontend)
--memory, -m Start only the memory service
start Start ALL Nexus services (backend + frontend)
--frontend,-f Start only the frontend
--backend, -b Start only the backend
--ai, -a Start only the AI (Ollama); `start`/`start -b` already include it
stop Stop ALL Nexus services
--memory, -m Stop only the memory service
--frontend,-f Stop only the frontend
--backend, -b Stop only the backend
@@ -771,12 +844,12 @@ Commands:
status Show service status
logs Show logs for all services
--memory, -m Memory service logs
--frontend,-f Frontend logs
--backend, -b Backend logs
doctor [--fix] Run Nexus diagnostics (--fix applies safe repairs)
update Update Nexus dependencies
upgrade Pull, rebuild and restart the backend (what the UI's update button runs)
clean Remove runtime files and caches
models
@@ -824,6 +897,8 @@ def main(argv) -> int:
cmd_doctor(fix="--fix" in rest)
elif cmd == "update":
cmd_update()
elif cmd == "upgrade":
return cmd_upgrade()
elif cmd == "clean":
cmd_clean()
elif cmd == "nvidia-reqs":
@@ -11,7 +11,9 @@ import sys
import httpx
BASE = __import__("os").environ.get("NEXUS_API", "http://localhost:8000")
from synapse.nexus_config import settings
BASE = settings.api_url
def _client():
@@ -118,8 +120,8 @@ def cmd_history(args):
_die_if_down(e)
def main():
p = argparse.ArgumentParser(prog="ncp")
def main(argv=None, prog="nexus"):
p = argparse.ArgumentParser(prog=prog)
sub = p.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("chat"); c.add_argument("message", nargs="+")
@@ -135,8 +137,9 @@ def main():
h = sub.add_parser("history"); h.add_argument("query", nargs="?")
h.add_argument("--limit", type=int, default=20); h.set_defaults(fn=cmd_history)
args = p.parse_args()
args.fn(args)
args = p.parse_args(argv)
result = args.fn(args)
return result if isinstance(result, int) else 0
if __name__ == "__main__":
+128
View File
@@ -0,0 +1,128 @@
[build-system]
requires = ["hatchling>=1.26"]
build-backend = "hatchling.build"
[project]
name = "nexusos-ai"
dynamic = ["version"]
description = "Local-first AI assistant runtime, web UI, and portable CLI"
readme = "README.md"
requires-python = ">=3.11"
authors = [{name = "EnderOfWings"}]
keywords = ["ai", "assistant", "ollama", "local-ai", "termux"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Framework :: FastAPI",
"Operating System :: Android",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Communications :: Chat",
]
dependencies = [
"fastapi>=0.115,<1",
"httpx>=0.27,<1",
"pydantic>=2.7,<3",
"python-dotenv>=1,<2",
"PyYAML>=6,<7",
"uvicorn>=0.30,<1",
]
[project.optional-dependencies]
documents = ["pypdf>=5,<7", "python-docx>=1.1,<2"]
process = ["psutil>=5.9,<8"]
vector = ["sqlite-vec>=0.1,<1"]
voice = ["faster-whisper>=1.1,<2"]
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"]
desktop = [
"psutil>=5.9,<8",
"pywebview>=5,<7; platform_system == 'Windows'",
]
standard = [
"pypdf>=5,<7",
"python-docx>=1.1,<2",
"psutil>=5.9,<8",
"sqlite-vec>=0.1,<1",
"duckduckgo-search>=6,<9",
]
# Every optional capability at once. Kept in sync with the extras above by
# tests/test_packaging_deps.py, which also checks that nothing synapse imports
# is missing from this file.
all = [
"pypdf>=5,<7",
"python-docx>=1.1,<2",
"psutil>=5.9,<8",
"sqlite-vec>=0.1,<1",
"faster-whisper>=1.1,<2",
"imap-tools>=1.7,<2",
"duckduckgo-search>=6,<9",
"pywebview>=5,<7; platform_system == 'Windows'",
]
dev = [
"build>=1.2,<2",
"pytest>=8,<10",
"twine>=7,<8",
]
[project.scripts]
nexus = "nexusos_cli.cli:entrypoint"
ncp = "nexusos_cli.cli:entrypoint"
nexusos = "nexusos_cli.cli:entrypoint"
[project.urls]
Homepage = "https://git.enderofwings.com/enderofwings/NexusOS"
Issues = "https://git.enderofwings.com/enderofwings/NexusOS/issues"
Repository = "https://git.enderofwings.com/enderofwings/NexusOS"
[tool.hatch.version]
path = "VERSION"
pattern = "^(?P<version>[^\\s]+)$"
[tool.hatch.build]
skip-excluded-dirs = true
# Ships interface/web/dist when it has been built. It is gitignored, so a
# static force-include would abort `pip install -e .` on a fresh clone.
[tool.hatch.build.hooks.custom]
path = "hatch_build.py"
[tool.hatch.build.targets.wheel]
packages = ["synapse", "nexusos_cli", "modules"]
[tool.hatch.build.targets.wheel.force-include]
"VERSION" = "synapse/_resources/VERSION"
"data/playbooks" = "synapse/_resources/playbooks"
"assets/n-small.png" = "synapse/_resources/assets/n-small.png"
"assets/themes/NexusOS-icons-src/nexus-underlay.svg" = "synapse/_resources/assets/themes/NexusOS-icons-src/nexus-underlay.svg"
"assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg" = "synapse/_resources/assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg"
[tool.hatch.build.targets.sdist]
include = [
"/assets/n-small.png",
"/assets/themes/NexusOS-icons-src/nexus-underlay.svg",
"/assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg",
"/data/playbooks",
"/docs",
"/management",
"/modules",
"/nexusos_cli",
"/scripts",
"/synapse",
"/tests",
"/README.md",
"/VERSION",
"/pyproject.toml",
"/hatch_build.py",
]
[tool.pytest.ini_options]
testpaths = ["tests", "management"]
+3
View File
@@ -38,6 +38,9 @@ sqlite-vec
faster-whisper
# Email client: IMAP read (SMTP send is stdlib). Pure-Python, no native deps.
imap-tools
# Chat web search (synapse/search.py). Imported lazily behind a bare except,
# so a missing install shows up as search silently returning nothing.
duckduckgo-search
# Documentation Support
markdown-it-py
+5
View File
@@ -17,7 +17,12 @@ python-docx
sqlite-vec
# Local speech-to-text: CTranslate2-based, no torch, keeps dictation on-device.
faster-whisper
# Email client: IMAP read (SMTP send is stdlib). Pure-Python, no native deps.
imap-tools
# Native desktop window for the UI (WebView2 on Windows; pulls pythonnet).
# Used by bin/nexus_window.py, launched from launch_nexus.ps1.
pywebview
# Testing (bin/check.sh is the release gate; it shells out to pytest)
pytest
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Install the portable NexusOS wheel in Termux.
#
# NEXUS_PACKAGE may be a PyPI requirement, wheel URL, or local wheel path.
# NEXUS_ANDROID_WHEEL_INDEX may point at a trusted PEP 503 index containing
# Android pydantic-core wheels for the device's Python/CPU combination.
set -euo pipefail
if [[ "${PREFIX:-}" != *com.termux* ]]; then
echo "This installer must be run inside Termux." >&2
exit 2
fi
PACKAGE_SPEC="${1:-${NEXUS_PACKAGE:-nexusos-ai}}"
echo "==> Installing Termux prerequisites"
pkg update -y
pkg install -y python python-pip curl clang make
PYTHON_TAG="$(python -c 'import sys; print(f"cp{sys.version_info.major}{sys.version_info.minor}")')"
ARCH="$(uname -m)"
echo "==> Python ${PYTHON_TAG}; architecture ${ARCH}"
PIP_INDEX_ARGS=()
if [[ -n "${NEXUS_ANDROID_WHEEL_INDEX:-}" ]]; then
PIP_INDEX_ARGS+=(--extra-index-url "${NEXUS_ANDROID_WHEEL_INDEX}")
fi
# Pydantic 2 is required on Python 3.14+, and its Rust extension is not built
# reliably on-device. Require a binary before asking pip to resolve NexusOS so
# failures are immediate and actionable. Older Termux environments also benefit
# from using a wheel instead of compiling the extension on a phone.
echo "==> Checking for an Android pydantic-core wheel"
if ! python -m pip install \
--only-binary=pydantic-core \
"${PIP_INDEX_ARGS[@]}" \
"pydantic>=2.7,<3"; then
cat >&2 <<EOF
No compatible pydantic-core wheel was found for ${PYTHON_TAG}/${ARCH}.
PyPI does not currently publish Android wheels for this native dependency.
Set NEXUS_ANDROID_WHEEL_INDEX to a trusted NexusOS Android wheel index and
run this installer again. Do not force a source build on a memory-limited phone.
EOF
exit 1
fi
echo "==> Installing ${PACKAGE_SPEC}"
# Same extra index as the pydantic-core probe: a NexusOS wheel hosted there
# would otherwise be invisible to the resolver.
python -m pip install "${PIP_INDEX_ARGS[@]}" "${PACKAGE_SPEC}"
echo "==> Initializing NexusOS"
nexus init
if [[ -n "${NEXUS_PROVIDER_URL:-}" ]]; then
nexus provider use remote --url "${NEXUS_PROVIDER_URL}"
fi
# Report health without aborting: `set -e` would otherwise skip the guidance
# below whenever doctor finds a failing required check - exactly when the
# reader most needs to see what to do next. The status is re-raised at exit.
DOCTOR_STATUS=0
nexus doctor || DOCTOR_STATUS=$?
cat <<'EOF'
NexusOS is installed. Configure an Ollama-compatible provider, then run:
nexus provider use remote --url http://YOUR-OLLAMA-HOST:11434
nexus serve
Open http://127.0.0.1:8000 in the Android browser.
EOF
exit "${DOCTOR_STATUS}"

Some files were not shown because too many files have changed in this diff Show More