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)
This commit is contained in:
janvanwan
2026-08-25 09:13:55 -05:00
parent fe5d18afa7
commit 42eaed647a
88 changed files with 4280 additions and 1888 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+26 -13
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/.
@@ -80,13 +89,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 +105,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 +137,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) |
+7 -9
View File
@@ -5,8 +5,8 @@
# 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, a bundled Ollama instance for inference, persistent
memory, and a React frontend. No external AI provider is called.
</div>
@@ -99,7 +99,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 +151,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 +162,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 +214,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,7 +231,7 @@ 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
+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
@@ -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
+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,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.).
+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
+16
View File
@@ -36,6 +36,22 @@ cp -f "$NEXUS/management/autostart/nm-applet.desktop" "$AUTOSTART/nm-appl
cp -f "$NEXUS/management/autostart/blueman.desktop" "$AUTOSTART/blueman.desktop"
cp -f "$NEXUS/management/autostart/blueman-applet.desktop" "$AUTOSTART/blueman-applet.desktop"
# 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
+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
+48 -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,8 @@ 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).
+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"
+181 -22
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`)
@@ -174,13 +229,30 @@ function App() {
{ 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: "400px",
@@ -204,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 */}
@@ -261,28 +350,97 @@ function App() {
<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: "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%",
}}
style={buttonStyle}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
>
@@ -512,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>
+92 -53
View File
@@ -7,13 +7,13 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
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,12 +122,15 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
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; };
@@ -136,7 +139,7 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
// 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
// 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;
@@ -151,7 +154,7 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
setThink(!!settings.think);
}
}).catch(() => {});
}, [visible]);
}, [visible]);
useEffect(() => {
if (!showPicker) return;
@@ -199,8 +202,8 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
const startNewChat = () => {
if (abortRef.current) abortRef.current.abort();
setInput("");
setQueue([]);
setLoading(false);
setLastStats(null);
setConversationId(crypto.randomUUID());
};
@@ -275,15 +278,12 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
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;
@@ -381,14 +381,8 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
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`
@@ -399,6 +393,35 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
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");
@@ -445,25 +468,13 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
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",
@@ -576,19 +587,6 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
{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}
@@ -706,6 +704,14 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
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)}
@@ -763,6 +769,28 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
))}
</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" }}>
@@ -780,8 +808,7 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
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,
@@ -792,9 +819,21 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
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}
+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
+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:
+54 -38
View File
@@ -25,6 +25,7 @@ import subprocess
import sys
import time
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@@ -97,12 +98,20 @@ def _psutil():
# -- 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,7 +121,7 @@ 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")
@@ -131,9 +140,6 @@ def _uvicorn(app: str, port: int):
SERVICES = {
"memory": Service("memory", "NEXUS MEMORY SERVICE", 8001, ROOT,
["uvicorn synapse.memory"],
lambda: _uvicorn("synapse.memory.service:app", 8001)),
"backend": Service("backend", "NEXUS BACKEND SERVICE", 8000, ROOT,
["uvicorn synapse.main"],
lambda: _uvicorn("synapse.main:sio_app", 8000)),
@@ -218,10 +224,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)):
@@ -357,9 +363,7 @@ 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"):
@@ -381,9 +385,7 @@ 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)
@@ -391,7 +393,7 @@ def cmd_start(target) -> None:
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 +401,11 @@ 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 (None, "", "all"):
stop_service(SERVICES["memory"])
stop_ollama()
stop_service(SERVICES["backend"])
stop_service(SERVICES["frontend"])
@@ -416,7 +415,7 @@ def cmd_stop(target) -> None:
def cmd_kill() -> None:
print("Force-killing all Nexus processes...")
for port, name in ((8000, "SYNAPSE"), (8001, "MEMORY"),
for port, name in ((8000, "SYNAPSE"),
(5173, "INTERFACE"), (11434, "OLLAMA")):
if kill_port(port):
print(f" KILLED: {name} (:{port})")
@@ -443,7 +442,6 @@ def cmd_status() -> None:
print("Backend:")
one("Synapse ", SERVICES["backend"])
one("Memory service", SERVICES["memory"])
print("\nFrontend:")
one("Vite ", SERVICES["frontend"])
print("\nModel server:")
@@ -459,26 +457,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 +583,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 +628,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...")
@@ -731,7 +748,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 +771,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 +785,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 +838,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":
+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
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()
+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
+244 -61
View File
@@ -13,6 +13,7 @@ from uuid import UUID
import httpx
import os as _os
import platform as _platform
import sys as _sys
from pathlib import Path
from fastapi import FastAPI, HTTPException, Body, Request
from fastapi.middleware.cors import CORSMiddleware
@@ -28,8 +29,9 @@ from .chat import generate_chat_response, stream_chat_response, _synapse_trace
from . import chat as _chat
from .ollama_manager import initialize_ollama, initialize_ollama_async, get_ollama_manager
from . import frontend_manager as _frontend_manager
from .playbook_manager import PlaybookManager
from . import playbook_manager
from . import tools as _tools
from .memory.curator import extract_for_conversation
def _render_memory_block(facts) -> str:
"""Render memory items as grouped ## Section / - bullet markdown.
@@ -61,8 +63,11 @@ def _render_memory_block(facts) -> str:
# the user. (A minimal header is kept so the facts have context; drop it entirely
# to inject the raw facts block with no framing.)
_MEMORY_PREAMBLE = (
"\n\n---\nWhat you know about the user — use these facts freely and naturally to "
"inform and personalize your replies:\n\n"
"\n\n---\nThe user is the person you are talking to right now — every user turn in "
"this conversation is his. The facts below are about him, written in the third "
"person only because that is how they are stored; address him as \"you\", never "
"discuss him as an absent third party. Use them freely and naturally to inform "
"and personalize your replies:\n\n"
)
@@ -179,7 +184,6 @@ from .memory.store import store, MemoryItem
from .playbooks.store import playbook_store, PlaybookItem
from .search import needs_web_search, web_search
MEMORY_SERVICE = "http://localhost:8001"
app = FastAPI(title="Synapse Backend", version=VERSION)
@@ -205,6 +209,13 @@ app.add_middleware(
allow_headers=["*"],
)
# --- Modules ---
# Installed feature modules (mail, and whatever comes next) live under the
# repo-root modules/ package and mount their own APIRouter here.
from modules.registry import ROUTERS as _MODULE_ROUTERS
for _module_router in _MODULE_ROUTERS:
app.include_router(_module_router)
# --- Request-size cap ---
# Reject oversized bodies before they are buffered/decoded (a base64 upload or a
@@ -263,7 +274,6 @@ _CHAT_INFLIGHT = _InFlightLimiter(MAX_CONCURRENT_CHATS, "chat")
_UPLOAD_INFLIGHT = _InFlightLimiter(MAX_CONCURRENT_UPLOADS, "document ingest")
# --- GLOBALS ---
playbooks = PlaybookManager()
ollama = None
@@ -284,6 +294,17 @@ async def startup_event():
print("[Synapse] Ready. Ollama not auto-started (manual control).")
async def _ollama_status_async() -> Optional[str]:
"""to_thread, not a direct call: get_status() does blocking IO (a sync httpx
request and, once, a subprocess). Awaiting it inline stalls the whole event
loop for its duration - and both callers below are polled continuously by
the UI, so an inline call froze the entire server in lockstep with its own
health check."""
if ollama is None or not hasattr(ollama, "get_status"):
return None
return await _asyncio.to_thread(ollama.get_status)
# -------------------------
# Status
# -------------------------
@@ -291,17 +312,150 @@ async def startup_event():
@app.get("/status")
async def root():
try:
# to_thread, not a direct call: get_status() does blocking IO (an httpx
# request and, once, a subprocess). Awaiting it inline stalled the whole
# event loop on every poll - and the UI polls /status continuously, so
# the server froze in lockstep with its own health check.
status = (await _asyncio.to_thread(ollama.get_status)
if (ollama is not None and hasattr(ollama, "get_status")) else None)
status = await _ollama_status_async()
except Exception:
status = None
return {"status": "online", "version": VERSION, "ollama": status, "platform": _platform.system().lower()}
# -------------------------
# Update check
# -------------------------
# The update mechanism is git: `git pull` + rebuild, i.e. `python bin/sync.py
# restore` (./install.sh on Linux). So "is a newer build out there" is just
# "how many commits is origin/main ahead of HEAD" — no version server needed.
def _git(*args: str, timeout: int = 30) -> str:
import subprocess
out = subprocess.run(
["git", *args], cwd=str(settings.project_root),
capture_output=True, text=True, timeout=timeout,
)
if out.returncode:
raise RuntimeError((out.stderr or out.stdout).strip() or "git failed")
return out.stdout.strip()
def _check_update() -> Dict[str, Any]:
_git("fetch", "--quiet", "origin", timeout=60)
behind = int(_git("rev-list", "--count", "HEAD..origin/main") or 0)
remote_version = VERSION
if behind:
with _contextlib.suppress(Exception):
remote_version = _git("show", "origin/main:VERSION").strip() or VERSION
return {
"version": VERSION,
"remote_version": remote_version,
"behind": behind,
"latest": _git("log", "-1", "--format=%h %s", "origin/main") if behind else "",
}
@app.get("/update/check")
async def update_check():
"""Compare this checkout against origin/main. Network call + subprocess, so
it runs off the event loop like the Ollama health check does."""
try:
return await _asyncio.to_thread(_check_update)
except Exception as e:
return {"version": VERSION, "behind": 0, "error": str(e)[:300]}
_update_running = False
@app.post("/update/apply")
async def update_apply():
"""Run `ncp upgrade` (git pull, rebuild, restart) fully detached.
It stops this very process, so it cannot be a child of it: a child would be
killed halfway through its own upgrade. Output goes to runtime/logs/update.log
because the UI cannot read /logs while the backend is down - that file is the
only record if the restart fails.
"""
global _update_running
import subprocess
if _update_running:
return {"started": False, "error": "An update is already running."}
log_path = settings.logs_dir / "update.log"
kwargs = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
| getattr(subprocess, "CREATE_NO_WINDOW", 0)}
if _os.name == "nt" else {"start_new_session": True})
try:
log = open(log_path, "wb")
subprocess.Popen(
[_sys.executable, str(settings.project_root / "management" / "ncp.py"), "upgrade"],
cwd=str(settings.project_root), stdout=log, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, **kwargs,
)
except Exception as e:
return {"started": False, "error": str(e)[:300]}
_update_running = True
return {"started": True, "log": str(log_path)}
# --- Deferred memory extraction ------------------------------------------
# The curator reads a conversation when it has been quiet for a while, rather
# than after every exchange. Each new message reschedules, so "idle" means the
# user actually stopped — which is also the only reliable signal that a
# half-said fact is now complete.
_pending_extractions: Dict[str, _asyncio.Task] = {}
def _extract_idle_seconds() -> float:
try:
return max(5.0, float(store.get_settings().get("memory_extract_idle", 120)))
except (TypeError, ValueError):
return 120.0
async def _extract_after_idle(conversation_id: str, project_id: str, delay: float) -> None:
try:
await _asyncio.sleep(delay)
saved = await extract_for_conversation(conversation_id, project_id)
if saved:
_synapse_trace(
f"\n◆ CURATOR (idle sweep): {len(saved)} fact(s) — "
+ "; ".join(f"[{i['section']}] {i['text']}" for i in saved) + "\n"
)
except _asyncio.CancelledError:
raise # rescheduled by a newer message; the watermark still has the work
except Exception as e:
_synapse_trace(f"\n⚠ idle extraction failed: {e}\n")
finally:
_pending_extractions.pop(conversation_id, None)
def _schedule_extraction(conversation_id: str, project_id: str) -> None:
"""(Re)arm the idle sweep for one conversation."""
previous = _pending_extractions.pop(conversation_id, None)
if previous:
previous.cancel()
_pending_extractions[conversation_id] = _asyncio.create_task(
_extract_after_idle(conversation_id, project_id, _extract_idle_seconds())
)
@app.on_event("startup")
async def _resume_dropped_extractions() -> None:
"""Pick up conversations whose sweep was lost to a restart. The watermark
makes this idempotent, so a conversation already read is skipped without
ever reaching the model."""
async def _bg():
await _asyncio.sleep(5) # let the app finish coming up first
try:
stale = store.conversations_awaiting_extraction(_extract_idle_seconds())
except Exception:
return
for cid in stale:
try:
# One at a time: Ollama serialises anyway, and a burst here would
# sit in front of the user's first message of the session.
await extract_for_conversation(cid, store.conversation_project(cid) or "")
except Exception:
continue
_asyncio.create_task(_bg())
# -------------------------
# Chat (streaming)
# -------------------------
@@ -316,7 +470,7 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
message = payload.get("message", "")
app_settings = store.get_settings()
# Model precedence: explicit request > active playbook's pinned model > auto-select.
_active_pb = playbooks.get_main_playbook()
_active_pb = playbook_manager.get_main_playbook()
_pb_model = _active_pb.model if (_active_pb and _active_pb.model) else ""
model = payload.get("model") or _pb_model or await _auto_select_model(message)
context = payload.get("context", {})
@@ -331,14 +485,28 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
if not message:
raise HTTPException(status_code=400, detail="Missing 'message'")
rendered_message = message # chat has no template vars; render_prompt is for the playbook path
system_prompt = playbooks.get_system_prompt() or app_settings.get("system_prompt", "")
# Resolve the project scope: an existing conversation keeps its bound project;
# a brand-new one inherits the current workspace (active_project setting).
# Everything project-scoped below (instructions, memory facts, RAG) uses it.
_conv_proj = store.conversation_project(conversation_id)
rag_scope = _conv_proj if _conv_proj is not None else app_settings.get("active_project", "")
# Fetch memory facts once — used for both playbook routing and system prompt injection
memory_facts = store.all()
rendered_message = message # chat has no template vars; render_prompt is for the playbook path
system_prompt = playbook_manager.get_system_prompt() or app_settings.get("system_prompt", "")
# Fetch memory facts once — used for both playbook routing and system prompt
# injection. Global facts ("") always apply; the rest only inside their project.
memory_facts = [m for m in store.all() if m.project_id in ("", rag_scope)]
# Per-project instructions sit right under the playbook: they say how the
# assistant should behave for this project specifically.
project_instructions = store.project_instructions(rag_scope)
if project_instructions:
separator = "\n\n---\nProject instructions (follow these for this project):\n\n"
system_prompt = (system_prompt + separator + project_instructions) if system_prompt else project_instructions
# Append the best-matching reference playbook(s) to the system prompt
context_pbs = _route_playbooks(rendered_message, playbooks.get_context_playbooks())
context_pbs = _route_playbooks(rendered_message, playbook_manager.get_context_playbooks())
if context_pbs:
refs = "\n\n".join(
f"### {pb.title}\nGoal: {pb.goal}\n\n{pb.instructions}"
@@ -369,11 +537,6 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
separator = "\n\n---\nRelevant past exchanges (use as background context only):\n\n"
system_prompt = (system_prompt + separator + memory_block) if system_prompt else memory_block
# Resolve the RAG scope: an existing conversation keeps its bound project;
# a brand-new one inherits the current workspace (active_project setting).
_conv_proj = store.conversation_project(conversation_id)
rag_scope = _conv_proj if _conv_proj is not None else app_settings.get("active_project", "")
# Retrieve relevant uploaded documents (RAG) and inject the top chunks.
doc_hits = await store.search_documents(
message, get_ollama_manager().embed,
@@ -414,7 +577,7 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
else:
_synapse_trace(f" INTENT: chat\n")
_main_pb = playbooks.get_main_playbook()
_main_pb = playbook_manager.get_main_playbook()
if _main_pb:
_synapse_trace(f" PLAYBOOK: {_main_pb.title}\n")
if _main_pb.goal:
@@ -442,6 +605,8 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
if past_context:
_synapse_trace(f" CONTEXT : {len(past_context)} past conversation match(es) injected\n")
if rag_scope:
_synapse_trace(f" PROJECT : {rag_scope}{' [+instructions]' if project_instructions else ''}\n")
_synapse_trace(f" SYS LEN : {len(system_prompt)} chars\n")
_synapse_trace(f"{'' * 55}\n")
# ── end MindTrace pre-flight ──────────────────────────────────────
@@ -453,19 +618,26 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
if images:
metadata["images"] = images
# Tool-using playbook: advertise the active playbook's allowlisted tools.
# Action tools follow action_tool_policy: off (withheld) / ask (per-call
# approval, handled in the tool loop) / allow (run freely).
# Tool-using playbook: advertise the allowlisted tools of the active
# playbook AND of the reference playbooks _route_playbooks picked for
# this message — a routed playbook's instructions are already in the
# prompt, so its abilities have to come with them or the model narrates
# tools it was never given. Action tools follow action_tool_policy:
# off (withheld) / ask (per-call approval, in the tool loop) / allow.
_policy = app_settings.get("action_tool_policy", "off")
if _main_pb and getattr(_main_pb, "tools", None):
_pb_tools = list(dict.fromkeys(
(getattr(_main_pb, "tools", None) or [] if _main_pb else [])
+ [t for pb in context_pbs for t in (getattr(pb, "tools", None) or [])]
))
if _pb_tools:
allow_actions = _policy != "off"
schemas = _tools.schemas_for(_main_pb.tools, allow_actions)
schemas = _tools.schemas_for(_pb_tools, allow_actions)
if schemas:
metadata["tools"] = schemas
metadata["action_tool_policy"] = _policy
metadata["conversation_id"] = conversation_id
_granted = [t for t in _main_pb.tools if not _tools.is_action(t) or allow_actions]
_withheld = [t for t in _main_pb.tools if _tools.is_action(t) and not allow_actions]
_granted = [t for t in _pb_tools if not _tools.is_action(t) or allow_actions]
_withheld = [t for t in _pb_tools if _tools.is_action(t) and not allow_actions]
_synapse_trace(f" TOOLS : {', '.join(_granted)} [actions: {_policy}]\n")
if _withheld:
_synapse_trace(f" WITHHELD: {', '.join(_withheld)} (action tools off)\n")
@@ -546,24 +718,12 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
except Exception:
pass
# Ask the memory service curator to evaluate this exchange
# Hand the conversation to the curator once it goes quiet. Not now:
# the curator is the chat model, and Ollama runs one request at a
# time (OLLAMA_NUM_PARALLEL=1), so extracting here would put the
# next message in a queue behind it.
if response_chunks:
try:
async with httpx.AsyncClient(timeout=310.0) as _mc:
r = await _mc.post(
f"{MEMORY_SERVICE}/memories/extract",
json={
"user_message": rendered_message,
"assistant_response": "".join(response_chunks),
},
)
if r.status_code == 200:
data = r.json()
for it in data.get("items", []):
mem_result = {"section": it["section"], "text": it["text"]}
yield f"event: memory\ndata: {_json.dumps(mem_result)}\n\n"
except Exception as e:
_synapse_trace(f"\n⚠ memory extraction call failed: {e}\n")
_schedule_extraction(conversation_id, rag_scope)
# Hold the concurrency slot for the stream's lifetime, then release it
# exactly once when the generator is exhausted or closed (client
@@ -671,8 +831,13 @@ async def put_settings_endpoint(payload: Dict[str, Any] = Body(...)):
# Memory
# -------------------------
@app.get("/memory")
async def get_memory():
return {"items": [{"id": m.id, "section": m.section, "text": m.text, "tags": m.tags} for m in store.all()]}
async def get_memory(project: Optional[str] = None):
"""All facts, or — with ?project=<id> — just that scope's ('' = global)."""
items = store.all()
if project is not None:
items = [m for m in items if m.project_id == project]
return {"items": [{"id": m.id, "section": m.section, "text": m.text, "tags": m.tags,
"project_id": m.project_id} for m in items]}
@app.post("/memory")
@@ -687,9 +852,11 @@ async def add_memory(payload: Dict[str, Any] = Body(...)):
section=(payload.get("section") or "General").strip(),
text=text,
tags=payload.get("tags", []),
project_id=payload.get("project_id") or "",
)
store.add(item)
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags}
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags,
"project_id": item.project_id}
@app.patch("/memory/{item_id}")
@@ -703,9 +870,11 @@ async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
section=(payload.get("section") or existing.section or "General").strip(),
text=(payload.get("text") or existing.text).rstrip(),
tags=payload.get("tags", existing.tags),
project_id=payload.get("project_id", existing.project_id),
)
store.update(updated)
return {"id": updated.id, "section": updated.section, "text": updated.text, "tags": updated.tags}
return {"id": updated.id, "section": updated.section, "text": updated.text,
"tags": updated.tags, "project_id": updated.project_id}
@app.delete("/memory/{item_id}")
@@ -818,10 +987,7 @@ async def delete_model(name: str):
@app.get("/ollama/status")
async def ollama_status_endpoint():
try:
if ollama is not None and hasattr(ollama, "get_status"):
status = ollama.get_status()
else:
status = None
status = await _ollama_status_async()
return {"status": status}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -1148,6 +1314,8 @@ async def stt_transcribe(payload: Dict[str, Any] = Body(...)):
return {"text": text}
# -------------------------
# Projects / workspaces
# -------------------------
@@ -1169,6 +1337,15 @@ async def create_project(payload: Dict[str, Any] = Body(...)):
return store.create_project(name)
@app.patch("/projects/{project_id}")
async def update_project(project_id: str, payload: Dict[str, Any] = Body(...)):
"""Set the project's instructions — layered into the system prompt of every
chat bound to this project."""
if not store.set_project_instructions(project_id, payload.get("instructions") or ""):
raise HTTPException(status_code=404, detail="Project not found")
return {"id": project_id, "instructions": payload.get("instructions") or ""}
@app.delete("/projects/{project_id}")
async def delete_project(project_id: str):
if not store.delete_project(project_id):
@@ -1281,9 +1458,11 @@ async def delete_document(doc_id: str):
# Conversations
# -------------------------
@app.get("/conversations")
async def get_conversations(q: Optional[str] = None):
async def get_conversations(q: Optional[str] = None, project: Optional[str] = None):
try:
conversations = store.all_conversations()
if project is not None:
conversations = [c for c in conversations if c.project_id == project]
if q:
q_lower = q.lower()
conversations = [
@@ -1299,6 +1478,7 @@ async def get_conversations(q: Optional[str] = None):
"updated_at": c.updated_at,
"preview": c.preview,
"title": c.title,
"project_id": c.project_id,
}
for c in conversations
]
@@ -1376,17 +1556,20 @@ async def get_conversation(conversation_id: str):
@app.patch("/conversations/{conversation_id}")
async def rename_conversation(conversation_id: str, payload: Dict[str, Any] = Body(...)):
"""Manually set a conversation's title."""
"""Set a conversation's title and/or move it into a project."""
try:
conv = store.get_conversation(conversation_id)
if not conv:
raise HTTPException(status_code=404, detail="Conversation not found")
if "project_id" in payload:
store.set_conversation_project(conversation_id, payload.get("project_id") or "")
title = (payload.get("title") or "").strip()
if not title:
if title:
title = " ".join(title.split())[:120]
store.set_conversation_title(conversation_id, title)
elif "project_id" not in payload:
raise HTTPException(status_code=400, detail="Missing 'title'")
title = " ".join(title.split())[:120]
store.set_conversation_title(conversation_id, title)
return {"id": conversation_id, "title": title}
return {"id": conversation_id, "title": title or conv.title}
except HTTPException:
raise
except Exception as e:
+118
View File
@@ -0,0 +1,118 @@
"""Curated memory extraction — read a finished conversation, save what's new.
This is the whole memory-writing path: pick up the messages the curator has not
read yet, ask the model which permanent facts they contain, merge each result
against what is already stored, and move the conversation's watermark.
It runs in-process. Extraction used to sit behind an HTTP call to a separate
service on :8001 holding a second, smaller model, because that model could not
share the GPU with the chat model. The curator is the chat model now already
resident, already warm so the extra process bought nothing but a hop.
"""
from __future__ import annotations
import asyncio
import math
import uuid
from .store import store, MemoryItem
from .extractor import extract_memory
from ..ollama_manager import get_ollama_manager
_EXTRACT_TIMEOUT = 300.0
def _cosine(a: list, b: list) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
async def extract_for_conversation(conversation_id: str, project_id: str = "") -> list[dict]:
"""Extract and save facts from a conversation's unread messages.
Returns the saved/updated items. Safe to call more than once: the watermark
means a second run with no new messages does nothing and never reaches the
model. Never raises memory extraction must not take the caller down.
"""
messages, last_id = store.pending_extraction(conversation_id)
if not messages:
return []
# Only global facts and this project's are in scope: another project's facts
# must not be shown to the curator as "already known", or as a merge target.
existing = [m for m in store.all() if m.project_id in ("", project_id)]
existing_sections = list({i.section for i in existing})
existing_texts = [i.text for i in existing]
settings = store.get_settings()
mgr = get_ollama_manager()
model = settings.get("memory_model") or await mgr.select_best_model()
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", -1), model)
try:
merge_threshold = float(settings.get("memory_merge_threshold", 0.88))
except (TypeError, ValueError):
merge_threshold = 0.88
try:
results = await asyncio.wait_for(
extract_memory(
messages, existing_sections, existing_texts, mgr,
model=model, num_gpu=num_gpu,
),
timeout=_EXTRACT_TIMEOUT,
)
except Exception:
# Leave the watermark alone so the next sweep retries these messages.
return []
saved = []
try:
# Embed existing facts once so each new fact can be matched against them.
# A near-duplicate UPDATES the matched fact in place (edit with new info)
# rather than appending a copy. Best effort: if embeddings are down we
# fall back to plain append. Merge disabled unless 0 < threshold < 1.
existing_embeds: dict = {}
if results and 0 < merge_threshold < 1:
vecs = await asyncio.gather(*(mgr.embed(it.text) for it in existing))
existing_embeds = {it.id: v for it, v in zip(existing, vecs) if v}
for result in results:
new_vec = await mgr.embed(result["text"]) if existing_embeds else None
match_id, best = None, 0.0
if new_vec:
for eid, ev in existing_embeds.items():
sim = _cosine(new_vec, ev)
if sim > best:
best, match_id = sim, eid
if best < merge_threshold:
match_id = None
target = store.get(match_id) if match_id else None
if target:
# Near-duplicate of an existing fact — overwrite with the newer
# statement, keeping the original id/section/position.
updated = MemoryItem(id=target.id, section=target.section,
text=result["text"], tags=target.tags,
project_id=target.project_id)
store.update(updated)
if new_vec:
existing_embeds[updated.id] = new_vec
saved.append({"id": updated.id, "section": updated.section,
"text": updated.text, "updated": True})
else:
item = MemoryItem(id=str(uuid.uuid4()), section=result["section"],
text=result["text"], project_id=project_id)
store.add(item)
if new_vec:
existing_embeds[item.id] = new_vec
saved.append({"id": item.id, "section": item.section, "text": item.text})
except Exception:
pass
# Watermark even when nothing was saved — the model has read these messages
# and re-reading them would just spend another call to reach the same "no".
store.set_extracted_through(conversation_id, last_id)
return saved
+55 -20
View File
@@ -1,5 +1,12 @@
"""Memory extraction — asks Mistral to evaluate a conversation exchange and
decide if it contains a new permanent personal fact worth saving."""
"""Memory extraction — asks the curator model to read a finished conversation
and decide which new permanent personal facts it contains.
Reads the whole transcript, not a single exchange. A fact is rarely complete in
the turn that introduces it ("I have a Honda" at turn 3 becomes a 2006 Accord
with 312k miles by turn 7), and the store is append-only for the curator, so
extracting per-exchange could only ever accumulate fragments of the same fact.
Waiting until the conversation is idle is also the only reliable signal that a
statement is finished rather than half-said."""
from __future__ import annotations
@@ -17,13 +24,19 @@ _TR = "┅" * 55
_PROMPT = """\
You are a memory curator for a personal AI assistant named Nexus.
Extract EVERY new, permanent personal fact the USER stated in this exchange.
There may be SEVERAL facts in one message output one JSON object for each.
Extract EVERY new, permanent personal fact the USER stated in this conversation.
There may be SEVERAL facts across the conversation output one JSON object for
each.
ONLY the USER line is a source of facts. The ASSISTANT line and the existing
memory below are context to help you understand the USER line never extract
ONLY the USER lines are a source of facts. The ASSISTANT lines and the existing
memory below are context to help you understand the USER lines never extract
anything from them. If the assistant said it and the user did not, it is NOT a
fact. Copy what the user actually said; do not infer, embellish, or add a
fact.
You are reading the WHOLE conversation, so record the FINAL, most complete form
of each fact. If the user gives a detail early and refines it later, output one
object with the refined version never one per stage. If the user corrects or
retracts something, keep only what they ended up saying. Copy what the user actually said; do not infer, embellish, or add a
judgement the user did not make (never call something their "favorite",
"main", or "best" unless the user used that word).
@@ -56,8 +69,7 @@ Only invent a new section if none fit, and make it a SHORT single word
(e.g. Hobbies, Pets, Health). Never use a sentence or long phrase as a section.
---
USER: {user_message}
ASSISTANT: {assistant_response}
{transcript}
---
Respond with JSON only no prose, no markdown fences. Output one object PER
@@ -95,11 +107,11 @@ def _reject_reason(fact: str, user_message: str) -> str | None:
prompt is worded (verified against mistral:7b):
1. Absence claims. It reads the existing-memory block and writes things like
"Jon does not have any pets" - which contradicted four cats already on
"the user does not have any pets" - which contradicted four cats already on
file. Silence is not a fact.
2. Assistant-sourced specifics. It lifts names the ASSISTANT said and
attributes them to the user: a reply that echoed a stale memory row
produced "Jon's main development machine is a MacBook Pro" off the user
produced "the user's main development machine is a MacBook Pro" off the user
message "What am I developing on?".
The grounding test only fires when a fact carries distinctive tokens and
@@ -114,20 +126,44 @@ def _reject_reason(fact: str, user_message: str) -> str | None:
return None
def render_transcript(messages: list[dict]) -> str:
"""The conversation as the curator sees it. Assistant turns are truncated
hard: they are context for reading the user's lines, never a fact source,
and a long reply would otherwise crowd out the lines that matter."""
lines = []
for m in messages:
role = (m.get("role") or "").upper()
if role not in ("USER", "ASSISTANT"):
continue
text = (m.get("content") or "").strip()
if not text:
continue
lines.append(f"{role}: {text[:3000] if role == 'USER' else text[:600]}")
return "\n".join(lines)
async def extract_memory(
user_message: str,
assistant_response: str,
messages: list[dict],
existing_sections: list[str],
existing_texts: list[str],
ollama_manager,
model: str = DEFAULT_MEMORY_MODEL,
num_gpu: int | None = 0,
) -> list[dict]:
"""Ask Mistral to extract saveable memory facts from a conversation exchange.
"""Extract saveable memory facts from a whole conversation.
Returns a list of {"section": ..., "text": ...} possibly empty. A single
exchange can hold several facts, and Mistral emits one JSON object per fact.
`messages` is the transcript as [{role, content}]. Returns a list of
{"section": ..., "text": ...} possibly empty. One conversation can hold
several facts, and the model emits one JSON object per fact.
"""
transcript = render_transcript(messages)
if not transcript:
return []
# Grounding is checked against everything the user actually typed, so a fact
# assembled from details spread across several of their turns still passes.
user_text = "\n".join(
(m.get("content") or "") for m in messages if (m.get("role") or "") == "user"
)
if existing_texts:
# ponytail: only the 12 most-recent facts go in the dedup context, not all
# ~40. On a CPU-bound curator (num_gpu=0) prompt-eval dominates, and 40
@@ -148,8 +184,7 @@ async def extract_memory(
prompt = _PROMPT.format(
existing_texts=texts_block,
existing_sections=sections_line,
user_message=user_message[:3000],
assistant_response=assistant_response[:800],
transcript=transcript,
)
# MindTrace: curator pre-flight (full prompt) so its reasoning is visible in
# the same console as the frontline model, not just Python warnings on failure.
@@ -194,7 +229,7 @@ async def extract_memory(
def _keep(o):
if isinstance(o, dict) and o.get("save") and o.get("section") and o.get("text"):
fact = str(o["text"]).strip()
reason = _reject_reason(fact, user_message)
reason = _reject_reason(fact, user_text)
if reason:
_synapse_trace(f"◆ CURATOR DROPPED ({reason}): {fact}\n")
return
@@ -217,7 +252,7 @@ async def extract_memory(
else:
_keep(obj)
if not results:
_log.warning("memory: nothing saved. mistral said: %.300r", text)
_log.warning("memory: nothing saved. curator said: %.300r", text)
_synapse_trace(f"◆ CURATOR VERDICT: nothing to save\n{_TR}\n\n")
else:
_facts = "; ".join(f"[{r['section']}] {r['text']}" for r in results)
-215
View File
@@ -1,215 +0,0 @@
"""Dedicated memory curator service — run alongside Synapse on port 8001.
Endpoints:
GET / health check
GET /memories list all memory items (optional ?section= filter)
POST /memories direct write no LLM, saves immediately
PATCH /memories/{id} update a memory item
DELETE /memories/{id} delete a memory item
POST /memories/extract LLM-curated: evaluate a conversation exchange and
optionally save a new permanent fact
"""
from __future__ import annotations
import asyncio
import math
import uuid
from typing import Any, Dict, List, Optional
from fastapi import FastAPI, HTTPException, Body
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from pydantic import BaseModel
from .store import store, MemoryItem
from .extractor import extract_memory
from ..ollama_manager import get_ollama_manager
from ..nexus_config import ALLOWED_HOSTS, ALLOWED_ORIGINS
app = FastAPI(title="Nexus Memory Service", version="1.0")
# Same unauthenticated-local-only posture as the main backend: reject foreign
# Host headers (anti DNS-rebind) and scope CORS to known local origins rather
# than "*". See nexus_config.ALLOWED_HOSTS / ALLOWED_ORIGINS for env overrides.
if "*" not in ALLOWED_HOSTS:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def _warm_curator():
"""Preload the curator model (in RAM, num_gpu=0 by default) so the first
extraction isn't a cold load that blows the timeout. Runs in the background
so it never delays startup. keep_alive then holds it warm between messages."""
async def _bg():
try:
mgr = get_ollama_manager()
# Ollama is started by the Synapse backend (a separate process), so
# at our startup it usually isn't reachable yet. Wait for it before
# warming instead of failing with "All connection attempts failed" —
# which leaves the curator cold and makes the first extraction slow.
for _ in range(60): # up to ~2 min
if await asyncio.to_thread(mgr.is_running):
break
await asyncio.sleep(2)
else:
return
settings = store.get_settings()
model = settings.get("memory_model") or await mgr.select_best_model()
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
await mgr.warm(model, num_gpu=num_gpu)
except Exception:
pass
asyncio.create_task(_bg())
@app.get("/")
async def health():
return {"status": "ok", "count": len(store.all())}
@app.get("/memories")
async def list_memories(section: Optional[str] = None):
items = store.all()
if section:
items = [i for i in items if i.section.lower() == section.lower()]
return {"items": [{"id": i.id, "section": i.section, "text": i.text, "tags": i.tags} for i in items]}
@app.post("/memories")
async def add_memory(payload: Dict[str, Any] = Body(...)):
text = (payload.get("text") or "").strip()
if not text:
raise HTTPException(status_code=400, detail="Missing 'text'")
item = MemoryItem(
id=str(uuid.uuid4()),
section=(payload.get("section") or "General").strip(),
text=text,
tags=payload.get("tags", []),
)
store.add(item)
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags}
@app.patch("/memories/{item_id}")
async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
existing = store.get(item_id)
if not existing:
raise HTTPException(status_code=404, detail="Not found")
updated = MemoryItem(
id=item_id,
section=(payload.get("section") or existing.section).strip(),
text=(payload.get("text") or existing.text).strip(),
tags=payload.get("tags", existing.tags),
)
store.update(updated)
return {"id": updated.id, "section": updated.section, "text": updated.text, "tags": updated.tags}
@app.delete("/memories/{item_id}")
async def delete_memory(item_id: str):
if not store.get(item_id):
raise HTTPException(status_code=404, detail="Not found")
store.delete(item_id)
return {"status": "deleted"}
def _cosine(a: list, b: list) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
class ExtractRequest(BaseModel):
user_message: str
assistant_response: str
@app.post("/memories/extract")
async def extract_and_save(req: ExtractRequest):
"""LLM-curated extraction — asks Mistral to evaluate the exchange against all
existing memory items and save only new, permanent personal facts."""
existing = store.all()
existing_sections = list({i.section for i in existing})
existing_texts = [i.text for i in existing]
# Adaptable per-machine curator config (see store _SETTINGS_DEFAULTS):
# which model does extraction, and whether it runs on CPU/RAM or the GPU.
settings = store.get_settings()
mgr = get_ollama_manager()
model = settings.get("memory_model") or await mgr.select_best_model()
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
try:
merge_threshold = float(settings.get("memory_merge_threshold", 0.88))
except (TypeError, ValueError):
merge_threshold = 0.88
try:
results = await asyncio.wait_for(
extract_memory(
req.user_message,
req.assistant_response,
existing_sections,
existing_texts,
mgr,
model=model,
num_gpu=num_gpu,
),
timeout=300.0,
)
# Embed existing facts once so each new fact can be matched against them.
# A near-duplicate UPDATES the matched fact in place (edit with new info)
# rather than appending a copy. Best effort: if embeddings are down we
# fall back to plain append. Merge disabled unless 0 < threshold < 1.
existing_embeds: dict = {}
if results and 0 < merge_threshold < 1:
vecs = await asyncio.gather(*(mgr.embed(it.text) for it in existing))
existing_embeds = {it.id: v for it, v in zip(existing, vecs) if v}
saved = []
for result in results:
new_vec = await mgr.embed(result["text"]) if existing_embeds else None
match_id, best = None, 0.0
if new_vec:
for eid, ev in existing_embeds.items():
sim = _cosine(new_vec, ev)
if sim > best:
best, match_id = sim, eid
if best < merge_threshold:
match_id = None
target = store.get(match_id) if match_id else None
if target:
# Near-duplicate of an existing fact — overwrite with the newer
# statement, keeping the original id/section/position.
updated = MemoryItem(id=target.id, section=target.section,
text=result["text"], tags=target.tags)
store.update(updated)
if new_vec:
existing_embeds[updated.id] = new_vec # keep cache fresh for later facts in this batch
saved.append({"id": updated.id, "section": updated.section,
"text": updated.text, "updated": True})
else:
item = MemoryItem(id=str(uuid.uuid4()),
section=result["section"], text=result["text"])
store.add(item)
if new_vec:
existing_embeds[item.id] = new_vec
saved.append({"id": item.id, "section": item.section, "text": item.text})
if saved:
first = {k: saved[0][k] for k in ("id", "section", "text")}
return {"saved": True, "items": saved, **first}
except asyncio.TimeoutError:
pass
except Exception:
pass
return {"saved": False}
+160 -14
View File
@@ -30,6 +30,7 @@ class MemoryItem(BaseModel):
text: str
tags: List[str] = []
position: int = 0
project_id: str = "" # "" = global: injected into every chat
class MessageItem(BaseModel):
role: str # "user" or "assistant"
@@ -44,6 +45,7 @@ class ConversationItem(BaseModel):
created_at: float
updated_at: float
title: Optional[str] = None
project_id: str = ""
@property
def preview(self) -> str:
@@ -127,6 +129,12 @@ class PersistentMemoryStore:
if cur.fetchone()[0] == 0:
cur.execute("UPDATE memory SET position = rowid")
# Migrate: scope a fact to a project ("" = global, applies everywhere).
try:
cur.execute("ALTER TABLE memory ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
except Exception:
pass
cur.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
@@ -145,6 +153,24 @@ class PersistentMemoryStore:
cur.execute("ALTER TABLE conversations ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
except Exception:
pass
# Migrate: high-water mark for memory extraction — the id of the last
# message the curator has already read. Extraction runs once the
# conversation goes idle rather than after every exchange, so this is
# what makes it idempotent and restart-safe: a backend that dies with a
# pending sweep resumes from here instead of re-reading the whole
# transcript and re-saving facts it already saved.
try:
cur.execute("ALTER TABLE conversations ADD COLUMN extracted_through INTEGER NOT NULL DEFAULT 0")
# Only reached the first time the column is added. Everything already
# in the database was extracted per-exchange under the old design, so
# watermark it as read — without this the first idle sweep would
# re-read every historical conversation and re-save its facts.
cur.execute(
"UPDATE conversations SET extracted_through = "
"COALESCE((SELECT MAX(id) FROM messages WHERE conversation_id = conversations.id), 0)"
)
except Exception:
pass
cur.execute("""
CREATE TABLE IF NOT EXISTS messages (
@@ -204,6 +230,11 @@ class PersistentMemoryStore:
created_at REAL NOT NULL
)
""")
# projects.instructions — per-project system prompt ("" = none).
try:
cur.execute("ALTER TABLE projects ADD COLUMN instructions TEXT NOT NULL DEFAULT ''")
except Exception:
pass
# documents.project_id — "" (or missing) means unscoped / All.
try:
cur.execute("ALTER TABLE documents ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
@@ -229,7 +260,7 @@ class PersistentMemoryStore:
def _load_all_memory(self) -> Dict[str, MemoryItem]:
conn = self._connect()
cur = conn.cursor()
cur.execute("SELECT id, section, text, tags, position FROM memory ORDER BY position ASC, rowid ASC")
cur.execute("SELECT id, section, text, tags, position, project_id FROM memory ORDER BY position ASC, rowid ASC")
rows = cur.fetchall()
conn.close()
@@ -245,6 +276,7 @@ class PersistentMemoryStore:
text=row["text"],
tags=tags,
position=row["position"] or 0,
project_id=row["project_id"] or "",
)
return cache
@@ -271,8 +303,10 @@ class PersistentMemoryStore:
try:
cur = conn.cursor()
cur.execute(
"INSERT OR REPLACE INTO memory (id, section, text, tags, position) VALUES (?, ?, ?, ?, ?)",
(item.id, item.section or "General", item.text, json.dumps(item.tags), item.position)
"INSERT OR REPLACE INTO memory (id, section, text, tags, position, project_id)"
" VALUES (?, ?, ?, ?, ?, ?)",
(item.id, item.section or "General", item.text, json.dumps(item.tags),
item.position, item.project_id or "")
)
conn.commit()
finally:
@@ -318,12 +352,12 @@ class PersistentMemoryStore:
return item
def all(self) -> List[MemoryItem]:
# Always read from DB — the memory service and backend run in separate processes
# with separate caches, so the cache can be stale for facts extracted by the
# memory service after this process started.
# Always read from DB, never the cache: the CLI, the control panel and
# the backend are separate processes with separate caches, so a fact
# written by one is invisible to another's cache.
conn = self._connect()
cur = conn.cursor()
cur.execute("SELECT id, section, text, tags, position FROM memory ORDER BY position ASC, rowid ASC")
cur.execute("SELECT id, section, text, tags, position, project_id FROM memory ORDER BY position ASC, rowid ASC")
rows = cur.fetchall()
conn.close()
items = []
@@ -338,6 +372,7 @@ class PersistentMemoryStore:
text=row["text"],
tags=tags,
position=row["position"] or 0,
project_id=row["project_id"] or "",
))
return items
@@ -405,6 +440,18 @@ class PersistentMemoryStore:
finally:
conn.close()
def set_conversation_project(self, conversation_id: str, project_id: str):
"""Move a conversation into a project ('' = unscoped)."""
conn = self._connect()
try:
conn.execute(
"UPDATE conversations SET project_id = ? WHERE id = ?",
(project_id or "", conversation_id),
)
conn.commit()
finally:
conn.close()
def add_message(self, conversation_id: str, role: str, content: str, model: Optional[str] = None, tokens: Optional[int] = None):
now = time.time()
conn = self._connect()
@@ -452,7 +499,7 @@ class PersistentMemoryStore:
conn = self._connect()
cur = conn.cursor()
cur.execute("""
SELECT c.id, c.created_at, c.updated_at, c.title,
SELECT c.id, c.created_at, c.updated_at, c.title, c.project_id,
m.role, m.content, m.timestamp, m.model, m.tokens
FROM conversations c
LEFT JOIN messages m ON m.conversation_id = c.id
@@ -471,6 +518,7 @@ class PersistentMemoryStore:
created_at=row["created_at"],
updated_at=row["updated_at"],
title=row["title"],
project_id=row["project_id"] or "",
)
order.append(cid)
if row["role"] is not None:
@@ -479,10 +527,79 @@ class PersistentMemoryStore:
)
return [convs[cid] for cid in order]
def pending_extraction(self, conversation_id: str) -> tuple[list, int]:
"""Messages the curator has not read yet, and the id to watermark to.
Returns ([{role, content}], last_id). An empty list means nothing new,
so callers can skip the model call entirely.
"""
conn = self._connect()
try:
row = conn.execute(
"SELECT extracted_through FROM conversations WHERE id = ?", (conversation_id,)
).fetchone()
if row is None:
return [], 0
rows = conn.execute(
"SELECT id, role, content FROM messages "
"WHERE conversation_id = ? AND id > ? ORDER BY id ASC",
(conversation_id, row["extracted_through"] or 0),
).fetchall()
finally:
conn.close()
if not rows:
return [], 0
return ([{"role": r["role"], "content": r["content"]} for r in rows], rows[-1]["id"])
def set_extracted_through(self, conversation_id: str, message_id: int) -> None:
conn = self._connect()
try:
conn.execute(
"UPDATE conversations SET extracted_through = ? WHERE id = ?",
(int(message_id), conversation_id),
)
conn.commit()
finally:
conn.close()
def conversations_awaiting_extraction(self, idle_seconds: float) -> list[str]:
"""Conversations with unread messages that have been quiet long enough
to count as finished. Used to resume sweeps dropped by a restart."""
conn = self._connect()
try:
rows = conn.execute(
"SELECT c.id FROM conversations c JOIN messages m ON m.conversation_id = c.id "
"WHERE m.id > c.extracted_through GROUP BY c.id "
"HAVING MAX(m.timestamp) < ?",
(time.time() - idle_seconds,),
).fetchall()
finally:
conn.close()
return [r["id"] for r in rows]
def delete_conversation(self, conversation_id: str):
conn = self._connect()
try:
cur = conn.cursor()
# Drop the embeddings first, while the message ids still resolve.
# Stale vectors are inert (the search joins messages) but they still
# occupy slots in the ANN over-fetch, so leaving them behind quietly
# thins recall of the conversations that are still here.
cur.execute(
"DELETE FROM message_vectors WHERE message_id IN "
"(SELECT id FROM messages WHERE conversation_id = ?)",
(conversation_id,),
)
# vec_enabled only says the extension loaded; the virtual table is
# created lazily on the first semantic search, so check for it.
if self.vec_enabled and cur.execute(
"SELECT 1 FROM sqlite_master WHERE name = 'vec_messages'"
).fetchone():
cur.execute(
"DELETE FROM vec_messages WHERE rowid IN "
"(SELECT id FROM messages WHERE conversation_id = ?)",
(conversation_id,),
)
cur.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,))
cur.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
conn.commit()
@@ -810,17 +927,40 @@ class PersistentMemoryStore:
def list_projects(self) -> List[dict]:
conn = self._connect()
rows = conn.execute("""
SELECT p.id, p.name, p.created_at,
(SELECT COUNT(DISTINCT doc_id) FROM documents d WHERE d.project_id = p.id) AS docs
SELECT p.id, p.name, p.created_at, p.instructions,
(SELECT COUNT(DISTINCT doc_id) FROM documents d WHERE d.project_id = p.id) AS docs,
(SELECT COUNT(*) FROM conversations c WHERE c.project_id = p.id) AS chats
FROM projects p ORDER BY p.created_at ASC
""").fetchall()
conn.close()
return [dict(r) for r in rows]
def delete_project(self, project_id: str) -> bool:
"""Delete a project; its documents survive but become unscoped ("")."""
def set_project_instructions(self, project_id: str, instructions: str) -> bool:
"""Per-project system prompt, layered into chats bound to the project."""
conn = self._connect()
conn.execute("UPDATE documents SET project_id = '' WHERE project_id = ?", (project_id,))
try:
cur = conn.execute("UPDATE projects SET instructions = ? WHERE id = ?",
(instructions or "", project_id))
conn.commit()
return cur.rowcount > 0
finally:
conn.close()
def project_instructions(self, project_id: str) -> str:
"""'' when the project has none, or doesn't exist."""
if not project_id:
return ""
conn = self._connect()
row = conn.execute("SELECT instructions FROM projects WHERE id = ?", (project_id,)).fetchone()
conn.close()
return (row["instructions"] or "") if row else ""
def delete_project(self, project_id: str) -> bool:
"""Delete a project; its documents, chats and facts survive but become
unscoped ("")."""
conn = self._connect()
for table in ("documents", "conversations", "memory"):
conn.execute(f"UPDATE {table} SET project_id = '' WHERE project_id = ?", (project_id,))
cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
deleted = cur.rowcount
conn.commit()
@@ -1024,7 +1164,10 @@ class PersistentMemoryStore:
# Curator CPU/GPU offload — same scale as gpu_offload above. Default 0
# (all CPU/RAM): OS-neutral and never evicts the chat model from a small
# GPU. Boxes with spare VRAM can set -1 (Auto) or a percent to use the GPU.
"memory_gpu_offload": 0,
# -1 = Auto (let Ollama fit it). The curator is the resident chat model
# now, so there is nothing to keep off the GPU; pinning it to CPU (0)
# only bought coexistence with a second, separate curator model.
"memory_gpu_offload": -1,
# Similar-fact merge: when a newly extracted fact's embedding is at least
# this cosine-similar to an existing fact, UPDATE that fact in place
# instead of appending a duplicate ("edit with new info"). 0 disables
@@ -1033,6 +1176,9 @@ class PersistentMemoryStore:
# top out ~0.61 — so 0.80 catches updates and never merges unrelated
# facts. Lower to catch looser rephrases; raise toward 1.0 to be stricter.
"memory_merge_threshold": 0.80,
# Seconds of quiet before the curator reads a conversation. This is the
# "conversation is over" signal; each new message restarts the clock.
"memory_extract_idle": 120,
}
def get_settings(self) -> Dict[str, Any]:
+8 -3
View File
@@ -29,12 +29,17 @@ except Exception:
# Chat: llama3.1:8b - a strong non-reasoning instruct model (~4.9 GB; overflows a
# 4 GB GPU into CPU/RAM). Chosen over Qwen3 because Qwen3 is a reasoning model:
# smart only with its slow <think> step, weak without it.
# Memory: mistral - the curator that extracts facts and titles conversations.
# Memory: the curator that extracts facts and titles conversations. It is the
# CHAT model on purpose, not a second one: the chat model is already resident in
# VRAM and warm, so extraction costs no extra load. A distinct curator (mistral)
# did not fit alongside it and had to be pinned to CPU (num_gpu=0), which made
# every extraction a slow prompt-eval on a model too small to follow the
# curator prompt's negative rules reliably.
# Embed: nomic-embed-text - powers semantic recall of past conversations
# (OllamaManager.embed / store.semantic_search_conversations). Without it,
# recall silently degrades to lexical substring matching.
DEFAULT_CHAT_MODEL = "llama3.1:8b"
DEFAULT_MEMORY_MODEL = "mistral:latest"
DEFAULT_MEMORY_MODEL = DEFAULT_CHAT_MODEL
DEFAULT_EMBED_MODEL = "nomic-embed-text"
# --- CORE DIRECTORIES ---
@@ -161,7 +166,7 @@ _LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1", "testserver"]
_LOCAL_ORIGINS = [
f"http://{h}:{p}"
for h in ("localhost", "127.0.0.1")
for p in (8000, 8001, 5173)
for p in (8000, 5173)
]
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
ALLOWED_ORIGINS = _csv_env("NEXUS_ALLOWED_ORIGINS", _LOCAL_ORIGINS)
+22 -24
View File
@@ -2,30 +2,28 @@ from typing import List
from .playbooks.store import playbook_store, PlaybookItem
class PlaybookManager:
@classmethod
def _all(cls) -> List[PlaybookItem]:
"""Return all playbooks sorted by order (position 0 is always main)."""
return playbook_store.all_playbooks()
def _all() -> List[PlaybookItem]:
"""Return all playbooks sorted by order (position 0 is always main)."""
return playbook_store.all_playbooks()
@classmethod
def get_main_playbook(cls) -> PlaybookItem | None:
playbooks = cls._all()
return playbooks[0] if playbooks else None
@classmethod
def get_context_playbooks(cls) -> List[PlaybookItem]:
"""All playbooks after the first — injected as reference context."""
playbooks = cls._all()
return playbooks[1:] if len(playbooks) > 1 else []
def get_main_playbook() -> PlaybookItem | None:
playbooks = _all()
return playbooks[0] if playbooks else None
@classmethod
def get_system_prompt(cls) -> str:
playbook = cls.get_main_playbook()
if not playbook:
return ""
goal = (getattr(playbook, "goal", "") or "").strip()
instructions = (getattr(playbook, "instructions", "") or "").strip()
if goal and instructions:
return f"{goal}\n\n{instructions}"
return goal or instructions
def get_context_playbooks() -> List[PlaybookItem]:
"""All playbooks after the first — injected as reference context."""
playbooks = _all()
return playbooks[1:] if len(playbooks) > 1 else []
def get_system_prompt() -> str:
playbook = get_main_playbook()
if not playbook:
return ""
goal = (getattr(playbook, "goal", "") or "").strip()
instructions = (getattr(playbook, "instructions", "") or "").strip()
if goal and instructions:
return f"{goal}\n\n{instructions}"
return goal or instructions
+89
View File
@@ -155,6 +155,66 @@ async def _remember(text: str = "", section: str = "General", **_) -> str:
return json.dumps({"saved": text, "section": section or "General"})
# --- Repo file access (read-only, scoped to PROJECT_ROOT) -------------------
# Paths never leave the repo: every request is resolve()d and checked against
# PROJECT_ROOT, which also kills symlink escapes. _DENIED covers the parts of
# the tree that are either secrets, private data, or multi-GB noise.
_DENIED = {
".git", ".env", "Promethean", "node_modules", "models", "ollama",
"runtime", "dist", "__pycache__", ".git-credentials",
}
_READ_MAX = 60_000
def _repo_path(rel: str) -> "tuple[object, str | None]":
"""Resolve a repo-relative path. Returns (path, error-string)."""
from .nexus_config import PROJECT_ROOT
rel = (rel or "").strip().lstrip("/")
if not rel:
return None, "path is required"
target = (PROJECT_ROOT / rel).resolve()
if not target.is_relative_to(PROJECT_ROOT):
return None, "path escapes the project root"
parts = set(target.relative_to(PROJECT_ROOT).parts)
if parts & _DENIED or target.name.endswith((".db", ".db.sql", ".pem", ".key")):
return None, f"{rel} is not readable"
return target, None
async def _read_file(path: str = "", **_) -> str:
target, err = _repo_path(path)
if err:
return json.dumps({"error": err})
if not target.is_file():
return json.dumps({"error": f"{path} does not exist"})
try:
text = target.read_text(encoding="utf-8", errors="replace")
except OSError as e:
return json.dumps({"error": f"cannot read {path}: {e}"})
return json.dumps({
"path": path,
"truncated": len(text) > _READ_MAX,
"content": text[:_READ_MAX],
})
async def _list_files(pattern: str = "", **_) -> str:
"""Glob the repo so the model discovers real paths instead of inventing them."""
from .nexus_config import PROJECT_ROOT
pattern = (pattern or "**/*.py").strip().lstrip("/")
hits = []
for f in PROJECT_ROOT.glob(pattern):
if not f.is_file():
continue
target, err = _repo_path(str(f.relative_to(PROJECT_ROOT)))
if err:
continue
hits.append(str(f.relative_to(PROJECT_ROOT)))
if len(hits) >= 200:
break
return json.dumps(sorted(hits))
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
"search_memory": (
@@ -197,6 +257,35 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
},
_list_models,
),
"read_file": (
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a source file from the NexusOS repository. Path is relative to the project root, e.g. 'synapse/main.py'.",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string", "description": "repo-relative file path"}},
"required": ["path"],
},
},
},
_read_file,
),
"list_files": (
{
"type": "function",
"function": {
"name": "list_files",
"description": "List files in the NexusOS repository matching a glob, e.g. 'synapse/**/*.py' or 'interface/web/src/*.jsx'. Use this to find real paths before reading.",
"parameters": {
"type": "object",
"properties": {"pattern": {"type": "string", "description": "glob relative to the project root"}},
},
},
},
_list_files,
),
"search_documents": (
{
"type": "function",
+100
View File
@@ -68,6 +68,45 @@ def test_conversation_project_binding():
assert s.conversation_project("c2") == "" # unscoped
def test_conversations_move_between_projects():
# The Projects page lists chats by project_id and moves them with a PATCH;
# if all_conversations() drops the column the list is silently empty.
s = _store()
s.create_conversation("c1", "projX")
s.create_conversation("c2")
assert {c.id: c.project_id for c in s.all_conversations()} == {"c1": "projX", "c2": ""}
s.set_conversation_project("c2", "projX")
s.set_conversation_project("c1", "") # removed from the project
assert {c.id: c.project_id for c in s.all_conversations()} == {"c1": "", "c2": "projX"}
def test_project_instructions_and_scoped_memory():
# The chat system prompt takes the project's instructions plus global facts
# and this project's facts only — another project's must never leak in.
from synapse.memory.store import MemoryItem
s = _store()
p = s.create_project("Roof rebuild")
assert s.project_instructions(p["id"]) == "" # default: no instructions
assert s.project_instructions("ghost") == "" # unknown project
assert s.set_project_instructions(p["id"], "answer as a roofer")
assert not s.set_project_instructions("ghost", "x")
assert s.project_instructions(p["id"]) == "answer as a roofer"
s.add(MemoryItem(id="g", text="lives in Ohio")) # global
s.add(MemoryItem(id="a", text="uses metal panels", project_id=p["id"])) # this project
s.add(MemoryItem(id="b", text="prefers Lua", project_id="other")) # elsewhere
in_scope = [m.id for m in s.all() if m.project_id in ("", p["id"])]
assert in_scope == ["g", "a"]
# Deleting a project keeps its chats and facts, unscoped.
s.create_conversation("c1", p["id"])
s.delete_project(p["id"])
assert s.conversation_project("c1") == ""
assert s.get("a").project_id == ""
def test_conversation_recall_uses_vec_and_matches_brute_force():
s = _store()
if not s.vec_enabled:
@@ -178,3 +217,64 @@ def test_extract_text_by_type():
out = _extract_text("blank.pdf", buf.getvalue())
assert isinstance(out, str) # blank page -> "" or whitespace, never raises
assert PdfReader(io.BytesIO(buf.getvalue())).pages # sanity: it was a valid PDF
def test_delete_conversation_takes_its_embeddings_with_it(tmp_path, monkeypatch):
"""Stale vectors are inert — the search joins messages — but they still
occupy slots in the ANN over-fetch, so recall of the surviving
conversations quietly thins out as deleted ones pile up."""
import sqlite3
from synapse.memory.store import PersistentMemoryStore
db = tmp_path / "t.db"
s = PersistentMemoryStore(db)
s.create_conversation("keep", "")
s.create_conversation("drop", "")
kept = s.add_message("keep", "user", "hello")
doomed = s.add_message("drop", "user", "goodbye")
conn = sqlite3.connect(db)
for mid in (kept, doomed):
conn.execute(
"INSERT OR REPLACE INTO message_vectors (message_id, embedding) VALUES (?, ?)",
(mid, "[0.0, 1.0]"),
)
conn.commit()
s.delete_conversation("drop")
left = {r[0] for r in conn.execute("SELECT message_id FROM message_vectors")}
assert left == {kept}, left
def test_extraction_watermark_is_idempotent(tmp_path):
"""The curator reads a conversation when it goes idle, so the watermark is
what stops a restart (or a second sweep) from re-reading messages and
re-saving the facts it already saved."""
from synapse.memory.store import PersistentMemoryStore
s = PersistentMemoryStore(tmp_path / "t.db")
s.create_conversation("c", "")
s.add_message("c", "user", "i bought a bike")
last = s.add_message("c", "assistant", "nice")
pending, mark = s.pending_extraction("c")
assert [m["role"] for m in pending] == ["user", "assistant"]
assert mark == last
s.set_extracted_through("c", mark)
assert s.pending_extraction("c") == ([], 0) # nothing new -> no model call
s.add_message("c", "user", "a 2019 trek")
pending, _ = s.pending_extraction("c")
assert [m["content"] for m in pending] == ["a 2019 trek"] # only the unread tail
def test_idle_sweep_only_claims_quiet_conversations(tmp_path):
from synapse.memory.store import PersistentMemoryStore
s = PersistentMemoryStore(tmp_path / "t.db")
s.create_conversation("fresh", "")
s.add_message("fresh", "user", "still typing")
assert s.conversations_awaiting_extraction(3600) == [] # too recent to be "over"
assert s.conversations_awaiting_extraction(0) == ["fresh"]
+214
View File
@@ -0,0 +1,214 @@
"""Guards for the KDE theme's silent-failure modes.
Every check here corresponds to something that broke without producing an error
message. They are static reads of the scripts and packages because the failures
are configuration-shaped -- there is nothing to import and nothing that raises.
"""
import json
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
KDE = REPO / "assets" / "themes" / "KDE"
INSTALLER = KDE / "install-plasma.sh"
LNF = KDE / "look-and-feel" / "com.nexusos.desktop"
ICONS = REPO / "assets" / "themes" / "NexusOS-icons"
THEME_INSTALLER = REPO / "assets" / "themes" / "install-theme.sh"
def test_look_and_feel_is_copied_never_symlinked():
"""KPackage skips symlinked package directories without a word, so a
symlinked Global Theme simply never appears in System Settings."""
text = INSTALLER.read_text()
assert "cp -rL" in text, "look-and-feel/wallpaper must be copied into place"
for line in text.splitlines():
if line.strip().startswith("ln -s"):
assert "look-and-feel" not in line and "wallpapers" not in line, \
f"KPackage package dir must not be symlinked: {line.strip()}"
def test_plasmashell_restart_is_detached_from_the_callers_stdout():
"""The restarted shell outlives the script. Inheriting stdout keeps the
caller's pipe open forever, which hangs `ncp restore` after a successful
apply."""
text = INSTALLER.read_text()
restart = [l for l in text.splitlines()
if "kstart5 plasmashell" in l and not l.strip().startswith("#")]
assert restart, "no plasmashell restart found"
for line in restart:
assert "setsid" in line, f"restart not detached: {line.strip()}"
assert "</dev/null" in line and ">/dev/null" in line, \
f"restart still holds the caller's stdio: {line.strip()}"
def test_splash_renders_without_the_stage_signal():
"""A splash gated on `stage == 2` shows a blank coloured screen if that
signal never arrives -- what `ksplashqml --test` does."""
qml = (LNF / "contents" / "splash" / "Splash.qml").read_text()
content = qml[qml.index("id: content"):]
body = content[:content.index("OpacityAnimator")]
assert "opacity: 0" not in body, "splash content starts invisible"
assert "introAnimation" not in qml, "visibility still gated on a stage change"
def test_sddm_theme_is_configured_in_exactly_one_place():
"""boot-branding.sh and install-plasma.sh both deploy the SDDM theme; two
different config files meant the setting could disagree with itself."""
for script in (INSTALLER, REPO / "bin" / "boot-branding.sh"):
text = script.read_text()
stray = re.findall(r"/etc/sddm\.conf(?!\.d)", text)
assert not stray, f"{script.name} writes bare /etc/sddm.conf; use conf.d"
def test_restore_desktop_stage_covers_plasma_as_well_as_xfce():
"""The desktop stage used to bail out entirely without xfconf-query, so a
Plasma box got no theme back from `ncp restore` at all."""
text = (REPO / "bin" / "restore-linux.sh").read_text()
assert "install-plasma.sh" in text, "restore never invokes the Plasma installer"
assert "--no-sddm" in text, "restore should leave SDDM to boot-branding.sh"
# The XFCE check must not be able to skip the Plasma branch or the branding.
assert "exit 0\nfi\n" not in text.split("desktop stage")[1][:900], \
"XFCE guard still exits the whole stage"
def test_global_theme_package_is_well_formed():
meta = json.loads((LNF / "metadata.json").read_text())
assert meta["KPlugin"]["Id"] == LNF.name, "package Id must match its directory"
assert "Plasma/LookAndFeel" in meta["KPlugin"]["ServiceTypes"]
defaults = (LNF / "contents" / "defaults").read_text()
# Every component the Global Theme selects has to exist in the repo.
assert "ColorScheme=NexusOS" in defaults
assert (KDE / "plasma" / "NexusOS").is_dir()
assert (KDE / "aurorae" / "NexusOS").is_dir()
assert (KDE / "wallpaper" / "NexusOS" / "metadata.json").is_file()
assert "Theme=com.nexusos.desktop" in defaults, "splash not wired to this package"
def test_patterned_backgrounds_are_referenced_as_raster_not_svg():
"""QtSvg is SVG Tiny 1.2 and has no <pattern>, so the brushed-metal and
machine-line textures vanish and the gradient renders flat. Every QML that
shows that artwork must load the rasterized PNG."""
qml_files = [
KDE / "sddm" / "NexusOS-QML" / "Main.qml",
LNF / "contents" / "splash" / "Splash.qml",
]
for f in qml_files:
# Only the source: lines -- the comments deliberately mention the SVG,
# since that is the file you edit and re-rasterize.
sources = [l for l in f.read_text().splitlines()
if "source:" in l and not l.strip().startswith("//")]
bg = [l for l in sources if "background" in l]
assert bg, f"{f.name} loads no background"
for line in bg:
assert "background.png" in line, \
f"{f.name} loads a patterned SVG through QtSvg: {line.strip()}"
def _defaults_sections():
"""Parse the look-and-feel defaults into {section: {key: value}}."""
out, section = {}, None
for line in (LNF / "contents" / "defaults").read_text().splitlines():
line = line.strip()
if line.startswith("["):
section = line
out[section] = {}
elif line and "=" in line and section:
k, v = line.split("=", 1)
out[section][k] = v
return out
def test_lock_screen_theme_names_a_look_and_feel_package():
"""Plasma 5.27 draws the lock screen from a look-and-feel package, and
[Greeter]Theme names that package. A bare theme name (the old "NexusOS")
is not one, so Plasma falls back to Breeze without saying anything."""
greeter = _defaults_sections()["[kscreenlockerrc][Greeter]"]
assert greeter["Theme"].endswith(".desktop"), \
f"lock theme must be a look-and-feel package id, got {greeter['Theme']!r}"
installer = INSTALLER.read_text()
lock_lines = [l for l in installer.splitlines()
if "kscreenlockerrc" in l and "--key Theme" in l]
assert lock_lines, "installer never sets the lock screen theme"
for line in lock_lines:
assert ".desktop" in line, f"installer sets a non-package lock theme: {line.strip()}"
# And the lock wallpaper is the raster metal background.
assert "background.png" in installer, "lock wallpaper not set to the metal raster"
def _index_theme():
"""Parse index.theme into (header dict, list of declared directories)."""
header, section, dirs = {}, None, []
for line in (ICONS / "index.theme").read_text().splitlines():
line = line.strip()
if line.startswith("[") and line != "[Icon Theme]":
section = line.strip("[]")
dirs.append(section)
elif "=" in line and not line.startswith("#") and section is None:
k, v = line.split("=", 1)
header[k.strip()] = v.strip()
return header, dirs
def test_every_declared_icon_directory_exists():
"""index.theme declared 124 directories against 21 real ones, most copied
from Papirus. A declared-but-missing directory is dead weight the icon
loader walks on every lookup."""
_, dirs = _index_theme()
missing = [d for d in dirs if not (ICONS / d).is_dir()]
assert not missing, f"index.theme declares directories that do not exist: {missing}"
listed = set(_index_theme()[0].get("Directories", "").split(","))
assert listed == set(dirs), "Directories= and the [section] list disagree"
def test_icon_theme_inherits_a_recolourable_parent():
"""Papirus hardcodes its blues, so nothing the colour scheme does can reach
them and the un-themed surface stayed blue forever. Breeze's icons carry
ColorScheme-* classes that Plasma recolours from the active scheme."""
header, _ = _index_theme()
parents = [p.strip() for p in header["Inherits"].split(",")]
assert not parents[0].lower().startswith("papirus"), \
"primary parent cannot recolour from the colour scheme"
assert parents[0] == "breeze-dark", f"expected breeze-dark first, got {parents[0]!r}"
assert parents[-1] == "hicolor", "hicolor must remain the last-resort fallback"
def test_icon_theme_is_installed_where_qt_looks():
"""~/.icons is the GTK/XFCE legacy path. Qt/KF5 searches XDG data dirs only,
so installing there alone meant Plasma never found the theme and every icon
fell back to Breeze without a word."""
text = THEME_INSTALLER.read_text()
links = [l for l in text.splitlines()
if l.strip().startswith("link ") and "NexusOS-icons" in l]
assert any(".local/share/icons" in l for l in links), \
"icon theme is not installed to an XDG data dir; Plasma will not see it"
assert any(".icons/NexusOS" in l for l in links), \
"dropping ~/.icons would break the XFCE session and GTK apps"
def test_inherits_check_reads_only_the_primary_parent():
"""The installer compared the whole comma-separated Inherits value against a
directory name, so a valid multi-parent list warned that an installed
fallback was missing."""
text = THEME_INSTALLER.read_text()
inh = [l for l in text.splitlines() if "INH=" in l and "Inherits" in l]
assert inh, "inheritance check not found"
assert any("-f1" in l for l in inh), \
"inheritance check still treats the whole Inherits list as one theme name"
def test_panel_layout_is_portable():
"""The panel script sets the launcher icon by absolute path. Hardcoding
this box's home would give any other clone or user a missing icon, so the
path is a placeholder the installer substitutes."""
js = (KDE / "panel-layout.js").read_text()
assert "/home/" not in js, "panel-layout.js hardcodes a home directory"
assert "__NEXUS_ROOT__" in js, "no placeholder for the repo path"
installer = INSTALLER.read_text()
assert "__NEXUS_ROOT__" in installer, "installer never substitutes the repo path"
# Rewriting the panel wholesale on every restore would wipe later additions.
assert "PANEL_MARKER" in installer, "panel layout is not guarded by a marker"
+75
View File
@@ -0,0 +1,75 @@
"""Mail account config — offline (no live IMAP/SMTP)."""
from modules.mail import backend as mail
def test_account_roundtrip_and_password_masking(tmp_path, monkeypatch):
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "acct.json")
assert mail.load_accounts() == []
pub = mail.save_account(None, {
"username": "me@icloud.com",
"password": "app-specific-pw",
"imap_host": "imap.mail.me.com",
"from_addr": "nexus@enderofwings.com",
})
account_id = pub["id"]
# public view exposes a flag, never the secret
assert pub["has_password"] is True
assert "password" not in pub
assert pub["configured"] is True
assert mail.is_configured(account_id) is True
# a blank password on update keeps the stored one (write-only field)
mail.save_account(account_id, {"from_name": "Nexus"})
assert mail.get_account(account_id)["password"] == "app-specific-pw"
assert mail.get_account(account_id)["from_name"] == "Nexus"
# defaults target iCloud
assert mail.get_account(account_id)["smtp_host"] == "smtp.mail.me.com"
def test_multiple_accounts_are_independent(tmp_path, monkeypatch):
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "acct.json")
a = mail.save_account(None, {"username": "a@icloud.com", "password": "pw-a", "label": "Personal"})
b = mail.save_account(None, {"username": "b@icloud.com", "password": "pw-b", "label": "Work"})
assert a["id"] != b["id"]
accounts = mail.public_accounts()
assert {x["id"] for x in accounts} == {a["id"], b["id"]}
mail.delete_account(a["id"])
remaining = mail.public_accounts()
assert len(remaining) == 1
assert remaining[0]["id"] == b["id"]
assert mail.is_configured(a["id"]) is False
def test_creds_file_lives_outside_the_db(monkeypatch, tmp_path):
# Mail secrets must never ride in memory.db (which bin/sync.py dumps to git).
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "acct.json")
mail.save_account(None, {"username": "u", "password": "p"})
assert (tmp_path / "acct.json").exists()
from synapse.memory.store import PersistentMemoryStore
assert "password" not in PersistentMemoryStore._SETTINGS_DEFAULTS
def test_legacy_single_account_file_migrates(tmp_path, monkeypatch):
import json
f = tmp_path / "acct.json"
f.write_text(json.dumps({
"imap_host": "imap.mail.me.com", "imap_port": 993,
"smtp_host": "smtp.mail.me.com", "smtp_port": 587,
"username": "legacy@icloud.com", "password": "old-pw",
"from_addr": "legacy@enderofwings.com", "from_name": "",
}))
monkeypatch.setattr(mail, "_ACCOUNT_FILE", f)
accounts = mail.load_accounts()
assert len(accounts) == 1
assert accounts[0]["username"] == "legacy@icloud.com"
assert "id" in accounts[0]
# migration is persisted — the file is rewritten in list form
assert json.loads(f.read_text())["accounts"][0]["username"] == "legacy@icloud.com"
+53
View File
@@ -0,0 +1,53 @@
"""Credential handling for the mail module.
Both checks guard fixes for real defects: the account file used to be written at
the umask and chmodded afterwards, and the IMAP/SMTP connections used to take
Python's stdlib SSL context, which verifies nothing.
"""
import json
import os
import ssl
import stat
from modules.mail import backend as mail
def test_account_file_is_never_group_or_world_readable(tmp_path, monkeypatch):
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "mail_accounts.json")
mail._write_accounts([{**mail._DEFAULTS, "id": "abc", "username": "u", "password": "secret"}])
mode = stat.S_IMODE((tmp_path / "mail_accounts.json").stat().st_mode)
assert mode == 0o600, f"account file is {oct(mode)}, expected 0o600"
assert not list(tmp_path.glob("*.tmp")), "temp file left behind"
# The password round-trips to disk but never to the API.
assert mail.load_accounts()[0]["password"] == "secret"
assert "password" not in mail.public_account(mail.get_account("abc"))
assert mail.public_account(mail.get_account("abc"))["has_password"] is True
assert json.loads((tmp_path / "mail_accounts.json").read_text())["accounts"]
def test_account_file_is_0600_while_it_is_being_written(tmp_path, monkeypatch):
"""The old code wrote at the umask and chmodded afterwards, so the file sat
world-readable for the length of the write. Assert the handle it is written
through is already 0600 -- the pre-fix version never wrote through a handle
at all (json.dumps to a string, then write_text), so this fails against it."""
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "mail_accounts.json")
seen = {}
real_dump = mail.json.dump
def spy(obj, fh, **kw):
seen["mode"] = stat.S_IMODE(os.fstat(fh.fileno()).st_mode)
return real_dump(obj, fh, **kw)
monkeypatch.setattr(mail.json, "dump", spy)
mail._write_accounts([{**mail._DEFAULTS, "id": "abc", "username": "u", "password": "secret"}])
assert seen["mode"] == 0o600, f"written through a {oct(seen['mode'])} handle"
def test_tls_context_verifies_certificate_and_hostname():
# ssl._create_stdlib_context(), the imaplib/smtplib fallback, gives
# CERT_NONE + check_hostname False -- which is what this replaced.
assert mail._TLS.verify_mode is ssl.CERT_REQUIRED
assert mail._TLS.check_hostname is True
+22
View File
@@ -0,0 +1,22 @@
"""modules/registry.py auto-discovery — no per-module registration required."""
from fastapi import APIRouter
from modules.registry import ROUTERS
def test_discovers_every_module_with_a_router():
prefixes = sorted(r.prefix for r in ROUTERS)
assert prefixes == ["/mail", "/network"]
assert all(isinstance(r, APIRouter) for r in ROUTERS)
def test_folder_without_router_py_is_skipped(tmp_path, monkeypatch):
# A module folder that hasn't grown a router.py yet (e.g. mid-scaffold)
# must not blow up discovery.
import modules.registry as registry
(tmp_path / "not_a_module").mkdir()
(tmp_path / "not_a_module" / "__init__.py").touch()
monkeypatch.setattr(registry, "_MODULES_DIR", tmp_path)
# No router.py in the folder, so discovery must skip it without raising
# (it never even attempts the import).
assert registry._discover() == []
+80
View File
@@ -0,0 +1,80 @@
"""Network module — offline (no real pings, no real nmcli/NetworkManager calls)."""
import subprocess
from modules.network import backend as net
def test_primary_connection_returns_expected_shape():
conn = net.primary_connection()
assert set(conn.keys()) == {"interface", "ip", "type"}
assert conn["type"] in ("wifi", "ethernet", "offline")
def test_vpn_status_unavailable_when_nmcli_missing(monkeypatch):
monkeypatch.setattr(net, "_nmcli_available", lambda: False)
assert net.vpn_status() == {"available": False}
def test_vpn_status_configured_but_disconnected(monkeypatch):
monkeypatch.setattr(net, "_nmcli_available", lambda: True)
monkeypatch.setattr(net, "_nmcli", lambda *a: "wgs_client:wireguard:disconnected")
status = net.vpn_status()
assert status == {"available": True, "configured": True, "name": "wgs_client", "connected": False}
def test_vpn_status_connected(monkeypatch):
monkeypatch.setattr(net, "_nmcli_available", lambda: True)
monkeypatch.setattr(net, "_nmcli", lambda *a: "wgs_client:wireguard:activated")
status = net.vpn_status()
assert status["connected"] is True
def test_vpn_toggle_raises_without_a_configured_tunnel(monkeypatch):
monkeypatch.setattr(net, "vpn_status", lambda: {"available": True, "configured": False, "name": None, "connected": False})
try:
net.vpn_toggle(True)
assert False, "expected RuntimeError"
except RuntimeError:
pass
def test_target_crud_roundtrip(tmp_path, monkeypatch):
monkeypatch.setattr(net, "_TARGETS_FILE", tmp_path / "targets.json")
assert net.list_targets() == []
t = net.add_target("Router", "192.168.50.1")
assert t["label"] == "Router" and t["host"] == "192.168.50.1"
targets = net.list_targets()
assert len(targets) == 1
assert targets[0]["id"] == t["id"]
net.delete_target(t["id"])
assert net.list_targets() == []
def test_ping_parses_latency_on_success(monkeypatch):
monkeypatch.setattr(subprocess, "check_output", lambda *a, **k: "64 bytes from 1.1.1.1: icmp_seq=1 ttl=56 time=12.3 ms")
result = net.ping("1.1.1.1")
assert result["ok"] is True
assert result["latency_ms"] == 12.3
def test_ping_reports_failure(monkeypatch):
def raise_failed(*a, **k):
raise subprocess.CalledProcessError(1, "ping")
monkeypatch.setattr(subprocess, "check_output", raise_failed)
result = net.ping("10.255.255.1")
assert result == {"ok": False, "latency_ms": None}
def test_ping_targets_merges_target_and_result(tmp_path, monkeypatch):
monkeypatch.setattr(net, "_TARGETS_FILE", tmp_path / "targets.json")
net.add_target("Router", "192.168.50.1")
monkeypatch.setattr(net, "ping", lambda host: {"ok": True, "latency_ms": 5.0})
results = net.ping_targets()
assert len(results) == 1
assert results[0]["host"] == "192.168.50.1"
assert results[0]["ok"] is True
assert results[0]["latency_ms"] == 5.0
+69 -18
View File
@@ -19,7 +19,7 @@ from synapse.nexus_config import DEFAULT_CHAT_MODEL, DEFAULT_MEMORY_MODEL
from synapse.ollama_manager import OllamaManager
from synapse import ollama_manager
from synapse.icons.compositor import _is_allowed_path
from synapse.playbook_manager import PlaybookManager
from synapse import playbook_manager
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -162,9 +162,9 @@ def test_first_playbook_is_the_system_prompt(tmp_path, monkeypatch):
instructions="Answer briefly.", order=0))
monkeypatch.setattr("synapse.playbook_manager.playbook_store", store)
assert PlaybookManager.get_main_playbook().id == "main"
assert [p.id for p in PlaybookManager.get_context_playbooks()] == ["ctx"]
assert PlaybookManager.get_system_prompt() == "Be useful.\n\nAnswer briefly."
assert playbook_manager.get_main_playbook().id == "main"
assert [p.id for p in playbook_manager.get_context_playbooks()] == ["ctx"]
assert playbook_manager.get_system_prompt() == "Be useful.\n\nAnswer briefly."
def test_settings_round_trip_over_defaults(tmp_path):
@@ -180,10 +180,10 @@ def test_past_conversations_are_searchable(tmp_path):
# silently drops the assistant's recall of past chats.
store = PersistentMemoryStore(tmp_path / "memory.db")
store.create_conversation("c1")
store.add_message("c1", "user", "how do I mount the Wingdrive?")
store.add_message("c1", "user", "how do I mount the backup drive?")
store.add_message("c1", "assistant", "use rsync over ssh")
assert store.search_conversations("wingdrive") # case-insensitive substring
assert store.search_conversations("BACKUP drive") # case-insensitive substring
assert store.search_conversations("nothing here") == []
assert store.search_conversations(" ") == []
@@ -271,10 +271,10 @@ def test_sync_compare_detects_direction(tmp_path):
def write(rows):
db.unlink(missing_ok=True)
with sq.connect(db) as conn:
# updated_at REAL, matching the production schema in store.py. A TEXT
# column here hid a real TypeError for months: the comparison in
# _extra() ran str-vs-str in the test and str-vs-float in the field.
conn.executescript(
# updated_at REAL, matching the production schema in store.py. A TEXT
# column here hid a real TypeError for months: the comparison in
# _extra() ran str-vs-str in the test and str-vs-float in the field.
"create table conversations (id text primary key, updated_at real not null);"
"create table memory (id text primary key);"
)
@@ -352,6 +352,15 @@ def test_genmon_configs_are_written_with_the_panel_down():
assert quit_at < copy_at < start_at, "genmon rc copy must happen with the panel stopped"
def test_plank_is_actually_launched():
"""Restoring ~/.config/plank only brings back the dock's launchers - nothing
in it starts Plank. The primary-follow watcher is what launches and revives
it, so it needs an autostart entry or a fresh box has no dock at all."""
desktop = REPO_ROOT / "management" / "autostart" / "plank.desktop"
assert "plank-primary-watch.sh" in desktop.read_text()
assert "plank.desktop" in (REPO_ROOT / "bin" / "panel" / "install.sh").read_text()
_VULKANINFO_IGPU_AND_LLVMPIPE = """\
Devices:
========
@@ -455,30 +464,72 @@ def test_dump_round_trips_a_db_holding_vec_tables(tmp_path):
def test_curator_drops_fabricated_facts():
"""The curator model invents two classes of fact no prompt wording stopped
(verified against mistral:7b), and both reached the real memory DB: absence
claims read off the existing-memory block ("Jon does not have any pets",
claims read off the existing-memory block ("the user does not have any pets",
which contradicted four cats on file) and specifics lifted from the
ASSISTANT's reply ("Jon's main development machine is a MacBook Pro", from
ASSISTANT's reply ("the user's main development machine is a MacBook Pro", from
the user message "What am I developing on?"). Deterministic guard, so it
holds whatever the model does."""
from synapse.memory.extractor import _reject_reason
# Absence claims are never facts.
assert _reject_reason("Jon does not have any pets", "do i have any pets?")
assert _reject_reason("Jon's favorite episode is unknown", "what's my favorite episode?")
assert _reject_reason("Jon has not specified an interest", "tell me about stargate")
assert _reject_reason("the user does not have any pets", "do i have any pets?")
assert _reject_reason("the user's favorite episode is unknown", "what's my favorite episode?")
assert _reject_reason("the user has not specified an interest", "tell me about stargate")
# Specifics the user never typed came from the assistant.
assert _reject_reason("Jon's main dev machine is a MacBook Pro", "What am I developing on?")
assert _reject_reason("the user's main dev machine is a MacBook Pro", "What am I developing on?")
# ...but the same shape grounded in the user's own words must survive.
assert _reject_reason(
"Jon owns a 2000 Ford Ranger with a 3.0L V6",
"the user owns a 2000 Ford Ranger with a 3.0L V6",
"i also have a 2000 Ford Ranger, it's a five-speed with a 3.0L V6") is None
assert _reject_reason(
"Jon has a beagle named Biscuit",
"the user has a beagle named Biscuit",
"i just adopted a dog named Biscuit, he's a beagle") is None
# A fact carrying no proper nouns or numbers can't be grounding-checked;
# the prompt owns that case, so the guard must let it through.
assert _reject_reason(
"Jon prefers short answers over long explanations",
"the user prefers short answers over long explanations",
"i really prefer short answers over long explanations") is None
def test_update_check_reports_behind_and_survives_git_failure(monkeypatch):
from synapse import main
# Fake git so the test never touches the network. Behind → the remote
# VERSION file, not this checkout's, is what the UI advertises.
calls = {
("rev-list", "--count", "HEAD..origin/main"): "3",
("show", "origin/main:VERSION"): "9.9.9\n",
("log", "-1", "--format=%h %s", "origin/main"): "abc1234 feat: thing",
}
monkeypatch.setattr(main, "_git", lambda *a, **kw: calls.get(a, ""))
body = TestClient(app).get("/update/check").json()
assert body["behind"] == 3 and body["remote_version"] == "9.9.9"
# An unreachable remote must not 500 the sidebar.
def boom(*a, **kw):
raise RuntimeError("could not resolve host")
monkeypatch.setattr(main, "_git", boom)
body = TestClient(app).get("/update/check").json()
assert body["behind"] == 0 and "could not resolve host" in body["error"]
def test_update_apply_spawns_detached_and_refuses_a_second_run(monkeypatch):
import subprocess
from synapse import main
seen = {}
def fake_popen(argv, **kw):
seen["argv"], seen["kw"] = argv, kw
return object()
monkeypatch.setattr(main, "_update_running", False)
monkeypatch.setattr(subprocess, "Popen", fake_popen)
client = TestClient(app)
assert client.post("/update/apply").json()["started"] is True
assert seen["argv"][-2:] == [str(REPO_ROOT / "management" / "ncp.py"), "upgrade"]
# Detached, or `ncp upgrade` dies with the backend it is about to stop.
assert seen["kw"].get("start_new_session") or seen["kw"].get("creationflags")
# Double-click must not launch a second pull/rebuild over the first.
assert client.post("/update/apply").json()["started"] is False
+57
View File
@@ -149,3 +149,60 @@ def test_tool_loop_degrades_when_model_returns_no_dict():
statuses = asyncio.run(_drain(_run_tool_loop(_NoToolManager(), messages, "m", [{}], None, None)))
assert statuses == [] # no tool ran
assert messages == before # untouched -> falls back to a plain stream
def test_read_file_stays_inside_the_repo():
"""The repo-file tools are the fix for the model inventing paths like
`nexus/nlp.py`; the deny-list is what keeps them from reading secrets."""
import json
def read(p):
return asyncio.run(tools._read_file(p))
assert "escapes" in read("../../etc/passwd")
# a leading slash is treated as repo-relative, so it lands nowhere real
assert "root:" not in read("/etc/passwd")
assert "required" in read("")
# private data and heavy trees are refused even though they're in-repo
for denied in ("synapse/memory/memory.db", ".git/config", "Promethean/pyvenv.cfg"):
assert "not readable" in read(denied), denied
assert "does not exist" in read("nexus/nlp.py")
assert "PROJECT_ROOT" in json.loads(read("synapse/nexus_config.py"))["content"]
def test_list_files_globs_the_repo_without_leaking_denied_paths():
import json
hits = json.loads(asyncio.run(tools._list_files("synapse/**/*")))
assert "synapse/main.py" in hits
assert not [h for h in hits if h.endswith(".db") or "__pycache__" in h], hits
def test_routed_reference_playbook_contributes_its_tools(tmp_path, monkeypatch):
"""A reference playbook routed into the prompt must bring its tools with it.
Without this the model reads instructions like "you can read the codebase"
while being advertised zero tools and narrates tool calls it never made."""
from synapse.main import _route_playbooks
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
# Own store, not data/playbooks: the live set is the operator's, and a
# published clone ships different playbooks - this asserted on data that
# travels with one machine.
store = PlaybookFileStore(tmp_path)
store.add_playbook(PlaybookItem(id="main", title="Main", goal="g",
instructions="i", order=0))
store.add_playbook(PlaybookItem(id="dev", title="NexusOS Developer", goal="g",
instructions="You can read the codebase.", order=1,
tags=["synapse", "backend"],
tools=["read_file", "list_files"]))
monkeypatch.setattr("synapse.playbook_manager.playbook_store", store)
import synapse.playbook_manager as pm
routed = _route_playbooks("why is the memory endpoint in synapse returning 500", pm.get_context_playbooks())
names = {pb.title for pb in routed}
assert "NexusOS Developer" in names, names
granted = {t for pb in routed for t in (pb.tools or [])}
assert {"read_file", "list_files"} <= granted, granted
# none of them are action tools, so they survive the default policy (off)
assert tools.schemas_for(sorted(granted), allow_actions=False)