forked from enderofwings/NexusOS
feat: add portable NexusOS CLI and packaging
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
name: package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, code-preview]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
wheel:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: interface/web/package-lock.json
|
||||||
|
|
||||||
|
- name: Build and test web UI
|
||||||
|
working-directory: interface/web
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Test Python runtime
|
||||||
|
run: |
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
python -m pytest -q tests management
|
||||||
|
bash -n scripts/install-termux.sh
|
||||||
|
|
||||||
|
- name: Build wheel and sdist
|
||||||
|
run: |
|
||||||
|
python -m build
|
||||||
|
python -m twine check dist/*
|
||||||
|
|
||||||
|
- name: Verify clean wheel install
|
||||||
|
run: |
|
||||||
|
python -m venv "$RUNNER_TEMP/nexus-wheel"
|
||||||
|
"$RUNNER_TEMP/nexus-wheel/bin/python" -m pip install dist/*.whl
|
||||||
|
cd "$RUNNER_TEMP"
|
||||||
|
export NEXUS_HOME="$RUNNER_TEMP/nexus-home"
|
||||||
|
export NEXUS_CONFIG_DIR="$RUNNER_TEMP/nexus-config"
|
||||||
|
"$RUNNER_TEMP/nexus-wheel/bin/nexus" init --json
|
||||||
|
"$RUNNER_TEMP/nexus-wheel/bin/nexus" doctor --json
|
||||||
|
"$RUNNER_TEMP/nexus-wheel/bin/python" -c "from synapse.main import sio_app; assert sio_app"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: nexusos-python-dist
|
||||||
|
path: dist/*
|
||||||
|
|
||||||
|
- name: Publish tagged release to PyPI
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
env:
|
||||||
|
TWINE_USERNAME: __token__
|
||||||
|
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||||
|
run: python -m twine upload --non-interactive dist/*
|
||||||
@@ -2,6 +2,9 @@ Promethean/
|
|||||||
ollama/
|
ollama/
|
||||||
interface/web/node_modules/
|
interface/web/node_modules/
|
||||||
interface/web/dist/
|
interface/web/dist/
|
||||||
|
/.build-check/
|
||||||
|
/dist/
|
||||||
|
/*.egg-info/
|
||||||
runtime/
|
runtime/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
# NexusOS
|
# NexusOS
|
||||||
|
|
||||||
**A local-first AI assistant platform.** Runs entirely on your machine — a
|
**A local-first AI assistant platform.** Runs entirely on your machine — a
|
||||||
Python/FastAPI backend, a bundled Ollama instance for inference, persistent
|
Python/FastAPI backend, an Ollama-compatible endpoint for inference, a
|
||||||
memory, and a React frontend. No external AI provider is called.
|
persistent memory service, and a React frontend. Ollama is local by default;
|
||||||
|
Termux and container installs can explicitly point at a separately managed
|
||||||
|
endpoint.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -15,10 +17,12 @@ memory, and a React frontend. No external AI provider is called.
|
|||||||
## What it is
|
## What it is
|
||||||
|
|
||||||
NexusOS ("Nexus") is a self-hosted assistant you actually own. All inference
|
NexusOS ("Nexus") is a self-hosted assistant you actually own. All inference
|
||||||
runs through a **locally bundled Ollama** on `localhost`; conversations, facts,
|
runs through **Ollama** on `localhost` by default; conversations, facts, and
|
||||||
and settings live in local SQLite. It ships with desktop branding (XFCE theme,
|
settings live in local SQLite. It ships with desktop branding (XFCE theme,
|
||||||
icons, boot splash) so it can be run as a full assistant environment on Linux,
|
icons, boot splash) so it can be run as a full assistant environment on Linux,
|
||||||
not just a web app.
|
not just a web app. A remote Ollama-compatible URL is an explicit configuration
|
||||||
|
option for lightweight clients; NexusOS never starts or stops that remote
|
||||||
|
process.
|
||||||
|
|
||||||
Chat with vision and voice, persistent memory, tool-using playbooks, document
|
Chat with vision and voice, persistent memory, tool-using playbooks, document
|
||||||
RAG scoped to Projects, gated action tools, and full model management — see
|
RAG scoped to Projects, gated action tools, and full model management — see
|
||||||
@@ -88,6 +92,28 @@ NexusOS runs **single-process**: the backend on `:8000` serves the built web UI
|
|||||||
itself, so there's no separate frontend server at runtime. Ollama is started
|
itself, so there's no separate frontend server at runtime. Ollama is started
|
||||||
manually from the app (**Start AI** in the sidebar), not at boot.
|
manually from the app (**Start AI** in the sidebar), not at boot.
|
||||||
|
|
||||||
|
### Python package and portable CLI
|
||||||
|
|
||||||
|
The portable package installs `nexus`, `ncp`, and `nexusos` as equivalent
|
||||||
|
commands. From a checkout today:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install -e ".[standard]"
|
||||||
|
nexus init
|
||||||
|
nexus doctor
|
||||||
|
nexus serve
|
||||||
|
```
|
||||||
|
|
||||||
|
After a package release, the install becomes `python -m pip install
|
||||||
|
"nexusos-ai[standard]"`. The wheel includes the compiled web UI and default
|
||||||
|
playbooks; it keeps writable state outside `site-packages`. See
|
||||||
|
[the CLI reference](docs/CLI.md) for commands, configuration, and dependency
|
||||||
|
profiles.
|
||||||
|
|
||||||
|
Termux uses the base package with a remote Ollama-compatible provider. Its
|
||||||
|
bootstrap and the current Android native-wheel gate are documented in
|
||||||
|
[the Termux guide](docs/TERMUX.md).
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
Nexus was built using an Apple T2 computer running Linux Mint XFCE. The desktop
|
Nexus was built using an Apple T2 computer running Linux Mint XFCE. The desktop
|
||||||
|
|||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
# NexusOS CLI
|
||||||
|
|
||||||
|
The Python package installs three equivalent command names: `nexus`, `ncp`,
|
||||||
|
and `nexusos`. New documentation uses `nexus`; `ncp` remains available for
|
||||||
|
existing desktop installs and scripts. Legacy spellings such as `ncp web`,
|
||||||
|
`ncp start -b`, `ncp refresh`, `ncp backup`, and `ncp restore` remain supported;
|
||||||
|
checkout-specific operations report a clear error when invoked from a wheel.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
From a source checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install -e ".[standard]"
|
||||||
|
nexus init
|
||||||
|
nexus doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
From the package index after a release is published:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install "nexusos-ai[standard]"
|
||||||
|
nexus init
|
||||||
|
nexus serve
|
||||||
|
```
|
||||||
|
|
||||||
|
The base install contains the backend, memory service, compiled web UI, CLI,
|
||||||
|
and seed playbooks. Extras keep platform-sensitive dependencies optional:
|
||||||
|
|
||||||
|
- `standard`: documents, vector search, and process control
|
||||||
|
- `documents`: PDF and DOCX ingestion
|
||||||
|
- `vector`: sqlite-vec semantic indexes
|
||||||
|
- `voice`: local faster-whisper transcription
|
||||||
|
- `process`: psutil-backed process and port inspection
|
||||||
|
- `desktop`: desktop process support and Windows pywebview
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
```text
|
||||||
|
nexus init Create writable state and seed playbooks
|
||||||
|
nexus doctor [--fix] [--json] Diagnose the install and provider
|
||||||
|
nexus paths [--json] Show package, state, and asset locations
|
||||||
|
nexus status [--json] Show services and provider reachability
|
||||||
|
nexus serve Run backend + memory in the foreground
|
||||||
|
nexus start|stop|restart Manage background services
|
||||||
|
nexus open Open the compiled web interface
|
||||||
|
nexus logs [service] --follow Tail service logs
|
||||||
|
nexus models list|pull|remove Manage Ollama-compatible models
|
||||||
|
nexus config list|get|set|unset Manage persistent settings
|
||||||
|
```
|
||||||
|
|
||||||
|
API commands are also available directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nexus chat send "Hello"
|
||||||
|
nexus history list
|
||||||
|
nexus memory list
|
||||||
|
nexus playbook list
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `nexus COMMAND --help` for command-specific arguments.
|
||||||
|
|
||||||
|
## Providers
|
||||||
|
|
||||||
|
Local desktop installs can allow NexusOS to start and stop a local Ollama:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nexus provider use local
|
||||||
|
```
|
||||||
|
|
||||||
|
For Termux, containers, or a separate inference machine, configure a remote
|
||||||
|
Ollama-compatible endpoint. NexusOS probes it but never manages its process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nexus provider use remote --url http://192.168.1.20:11434
|
||||||
|
nexus provider show --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## State and configuration
|
||||||
|
|
||||||
|
Installed wheels never write into `site-packages`. Writable files use the
|
||||||
|
platform data directory, while configuration uses the platform config
|
||||||
|
directory. Inspect the exact locations with `nexus paths`.
|
||||||
|
|
||||||
|
Environment variables override persisted settings. The most useful are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NEXUS_HOME Override the complete writable state root
|
||||||
|
NEXUS_CONFIG_DIR Override the config directory
|
||||||
|
NEXUS_PROVIDER ollama or ollama-remote
|
||||||
|
NEXUS_PROVIDER_URL Ollama-compatible API base URL
|
||||||
|
NEXUS_BIND_HOST Backend bind address (loopback by default)
|
||||||
|
NEXUS_BACKEND_PORT Backend/web port (default 8000)
|
||||||
|
NEXUS_MEMORY_PORT Memory service port (default 8001)
|
||||||
|
```
|
||||||
|
|
||||||
|
The REST APIs are unauthenticated. `nexus serve` refuses non-loopback binds
|
||||||
|
unless `--allow-lan` is given; that flag is an explicit acknowledgement, not
|
||||||
|
an authentication layer.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Termux installation path
|
||||||
|
|
||||||
|
NexusOS is packaged so its Python runtime, memory database, playbooks, and
|
||||||
|
compiled web UI can run without a source checkout or Node.js. Inference is
|
||||||
|
configured separately through an Ollama-compatible HTTP endpoint; NexusOS does
|
||||||
|
not attempt to manage that remote process.
|
||||||
|
|
||||||
|
## Bootstrap
|
||||||
|
|
||||||
|
After `nexusos-ai` and a compatible Android `pydantic-core` wheel are published:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSLO https://git.enderofwings.com/enderofwings/NexusOS/raw/branch/main/scripts/install-termux.sh
|
||||||
|
chmod +x install-termux.sh
|
||||||
|
NEXUS_ANDROID_WHEEL_INDEX=https://packages.example.invalid/android/simple \
|
||||||
|
./install-termux.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For a local release artifact, pass the wheel path or URL as the first argument:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXUS_ANDROID_WHEEL_INDEX=https://packages.example.invalid/android/simple \
|
||||||
|
./scripts/install-termux.sh ./dist/nexusos_ai-1.0.0-py3-none-any.whl
|
||||||
|
```
|
||||||
|
|
||||||
|
Then configure inference and serve the UI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nexus provider use remote --url http://192.168.1.20:11434
|
||||||
|
nexus serve
|
||||||
|
termux-open-url http://127.0.0.1:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Native wheel gate
|
||||||
|
|
||||||
|
Current Termux Python is 3.14, so Pydantic 1 is not a safe fallback. Pydantic 2
|
||||||
|
depends on the Rust-based `pydantic-core`. PyPI publishes Linux, macOS, Windows,
|
||||||
|
and WebAssembly wheels but no Android wheel, while the current Termux Rust
|
||||||
|
package cannot build common Rust extensions on-device.
|
||||||
|
|
||||||
|
The bootstrap script therefore requires a binary `pydantic-core` and accepts a
|
||||||
|
trusted PEP 503 wheel index through `NEXUS_ANDROID_WHEEL_INDEX`. It fails early
|
||||||
|
with the detected Python ABI and CPU when that artifact is missing. The release
|
||||||
|
pipeline can publish the pure NexusOS wheel today; an Android wheel job/index is
|
||||||
|
the remaining prerequisite for a one-line public Termux install.
|
||||||
|
|
||||||
|
Do not work around this by downloading an unverified binary or by exposing the
|
||||||
|
NexusOS server with `--allow-lan`. Keep the UI on loopback and let the Android
|
||||||
|
browser connect to `127.0.0.1`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""NexusOS command-line and desktop management helpers."""
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
"""Portable NexusOS command line used by the ``nexus`` and ``ncp`` scripts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from synapse import nexus_config as config
|
||||||
|
from synapse.nexus_config import settings
|
||||||
|
|
||||||
|
from . import ncp as services
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = {
|
||||||
|
"api_url": "url",
|
||||||
|
"memory_url": "url",
|
||||||
|
"bind_host": "text",
|
||||||
|
"backend_port": "port",
|
||||||
|
"memory_port": "port",
|
||||||
|
"provider": "provider",
|
||||||
|
"provider_url": "url",
|
||||||
|
"provider_timeout": "positive_int",
|
||||||
|
"data_dir": "path",
|
||||||
|
"models_dir": "path",
|
||||||
|
"runtime_dir": "path",
|
||||||
|
"memory_dir": "path",
|
||||||
|
"memory_db": "path",
|
||||||
|
}
|
||||||
|
|
||||||
|
LEGACY_TARGETS = {
|
||||||
|
"-m": "memory",
|
||||||
|
"--memory": "memory",
|
||||||
|
"-b": "backend",
|
||||||
|
"--backend": "backend",
|
||||||
|
"-f": "frontend",
|
||||||
|
"--frontend": "frontend",
|
||||||
|
"-a": "ai",
|
||||||
|
"--ai": "ai",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _emit(payload, json_output: bool = False) -> None:
|
||||||
|
if json_output:
|
||||||
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
elif isinstance(payload, str):
|
||||||
|
print(payload)
|
||||||
|
else:
|
||||||
|
for key, value in payload.items():
|
||||||
|
print(f"{key}: {value}")
|
||||||
|
|
||||||
|
|
||||||
|
def _http_ok(url: str, timeout: float = 1.0) -> bool:
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(url, timeout=timeout).read(1)
|
||||||
|
return True
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_termux() -> bool:
|
||||||
|
prefix = os.getenv("PREFIX", "")
|
||||||
|
return "com.termux" in prefix or bool(os.getenv("TERMUX_VERSION"))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_config(key: str, raw: str):
|
||||||
|
kind = CONFIG_SCHEMA[key]
|
||||||
|
value = raw.strip()
|
||||||
|
if kind == "url":
|
||||||
|
parsed = urlparse(value)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
|
raise ValueError(f"{key} must be an http(s) URL")
|
||||||
|
return value.rstrip("/")
|
||||||
|
if kind == "port":
|
||||||
|
number = int(value)
|
||||||
|
if not 1 <= number <= 65535:
|
||||||
|
raise ValueError(f"{key} must be between 1 and 65535")
|
||||||
|
return number
|
||||||
|
if kind == "positive_int":
|
||||||
|
number = int(value)
|
||||||
|
if number <= 0:
|
||||||
|
raise ValueError(f"{key} must be greater than zero")
|
||||||
|
return number
|
||||||
|
if kind == "provider":
|
||||||
|
if value not in ("ollama", "ollama-remote"):
|
||||||
|
raise ValueError("provider must be ollama or ollama-remote")
|
||||||
|
return value
|
||||||
|
if kind == "path":
|
||||||
|
return str(Path(value).expanduser().resolve())
|
||||||
|
if not value:
|
||||||
|
raise ValueError(f"{key} cannot be empty")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_init(args) -> int:
|
||||||
|
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
copied = list(config.INITIALIZED_FILES) + config.init_state()
|
||||||
|
payload = {
|
||||||
|
"status": "initialized",
|
||||||
|
"state_dir": str(settings.state_dir),
|
||||||
|
"config_file": str(settings.config_file),
|
||||||
|
"data_dir": str(settings.data_dir),
|
||||||
|
"models_dir": str(settings.models_dir),
|
||||||
|
"runtime_dir": str(settings.runtime_dir),
|
||||||
|
"seeded_playbooks": len(copied),
|
||||||
|
}
|
||||||
|
_emit(payload, args.json)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_paths(args) -> int:
|
||||||
|
payload = {
|
||||||
|
"install_mode": "checkout" if settings.source_checkout else "wheel",
|
||||||
|
"project_root": str(settings.project_root),
|
||||||
|
"resource_root": str(settings.resource_root),
|
||||||
|
"state_dir": str(settings.state_dir),
|
||||||
|
"config_file": str(settings.config_file),
|
||||||
|
"data_dir": str(settings.data_dir),
|
||||||
|
"memory_db": str(settings.memory_db),
|
||||||
|
"models_dir": str(settings.models_dir),
|
||||||
|
"runtime_dir": str(settings.runtime_dir),
|
||||||
|
"web_dist_dir": str(settings.web_dist_dir),
|
||||||
|
}
|
||||||
|
_emit(payload, args.json)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_config(args) -> int:
|
||||||
|
values = config.read_user_config()
|
||||||
|
if args.action == "path":
|
||||||
|
print(config.CONFIG_FILE)
|
||||||
|
return 0
|
||||||
|
if args.action == "list":
|
||||||
|
_emit(values, args.json)
|
||||||
|
return 0
|
||||||
|
if args.action == "get":
|
||||||
|
if args.key not in CONFIG_SCHEMA:
|
||||||
|
print(f"Unknown configuration key: {args.key}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
value = values.get(args.key, getattr(settings, args.key, None))
|
||||||
|
_emit({args.key: value}, args.json)
|
||||||
|
return 0
|
||||||
|
if args.action == "set":
|
||||||
|
if args.key not in CONFIG_SCHEMA:
|
||||||
|
print(f"Unknown configuration key: {args.key}", file=sys.stderr)
|
||||||
|
print("Known keys: " + ", ".join(CONFIG_SCHEMA), file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
values[args.key] = _validate_config(args.key, args.value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
print(f"Invalid value: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
config.write_user_config(values)
|
||||||
|
_emit({"updated": args.key, "value": values[args.key], "restart_required": True}, args.json)
|
||||||
|
return 0
|
||||||
|
if args.action == "unset":
|
||||||
|
if args.key not in CONFIG_SCHEMA:
|
||||||
|
print(f"Unknown configuration key: {args.key}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
values.pop(args.key, None)
|
||||||
|
config.write_user_config(values)
|
||||||
|
_emit({"removed": args.key, "restart_required": True}, args.json)
|
||||||
|
return 0
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_payload() -> dict:
|
||||||
|
url = settings.ollama_host.rstrip("/")
|
||||||
|
return {
|
||||||
|
"provider": settings.provider,
|
||||||
|
"url": url,
|
||||||
|
"managed_by_nexus": settings.manage_ollama,
|
||||||
|
"reachable": _http_ok(url + "/api/tags", timeout=2.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_provider(args) -> int:
|
||||||
|
if args.action == "show":
|
||||||
|
_emit(_provider_payload(), args.json)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
values = config.read_user_config()
|
||||||
|
if args.mode == "local":
|
||||||
|
values["provider"] = "ollama"
|
||||||
|
try:
|
||||||
|
values["provider_url"] = _validate_config(
|
||||||
|
"provider_url", args.url or "http://127.0.0.1:11434"
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"Invalid value: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
else:
|
||||||
|
if not args.url:
|
||||||
|
print("Remote provider setup requires --url", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
values["provider_url"] = _validate_config("provider_url", args.url)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"Invalid value: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
values["provider"] = "ollama-remote"
|
||||||
|
config.write_user_config(values)
|
||||||
|
_emit({
|
||||||
|
"provider": values["provider"],
|
||||||
|
"url": values["provider_url"],
|
||||||
|
"restart_required": True,
|
||||||
|
}, args.json)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _check_import(module: str) -> bool:
|
||||||
|
return importlib.util.find_spec(module) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _writable(path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
probe = path / ".nexus-write-test"
|
||||||
|
probe.write_text("ok", encoding="utf-8")
|
||||||
|
probe.unlink()
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def diagnostics() -> dict:
|
||||||
|
checks: list[dict] = []
|
||||||
|
|
||||||
|
def add(name: str, ok: bool, detail: str, required: bool = True):
|
||||||
|
checks.append({
|
||||||
|
"name": name,
|
||||||
|
"status": "pass" if ok else ("fail" if required else "warn"),
|
||||||
|
"detail": detail,
|
||||||
|
"required": required,
|
||||||
|
})
|
||||||
|
|
||||||
|
add("python", sys.version_info >= (3, 11), sys.version.split()[0])
|
||||||
|
add("state", _writable(settings.state_dir), str(settings.state_dir))
|
||||||
|
add("database directory", _writable(settings.memory_db.parent), str(settings.memory_db.parent))
|
||||||
|
add("web assets", (settings.web_dist_dir / "index.html").is_file(), str(settings.web_dist_dir))
|
||||||
|
add(
|
||||||
|
"provider mode",
|
||||||
|
settings.provider in ("ollama", "ollama-remote"),
|
||||||
|
settings.provider,
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
"service ports",
|
||||||
|
all(1 <= port <= 65535 for port in (settings.backend_port, settings.memory_port)),
|
||||||
|
f"backend={settings.backend_port}, memory={settings.memory_port}",
|
||||||
|
)
|
||||||
|
for module in ("fastapi", "uvicorn", "httpx", "pydantic", "yaml"):
|
||||||
|
add(f"import:{module}", _check_import(module), module)
|
||||||
|
|
||||||
|
add("backend", _http_ok(settings.api_url + "/status"), settings.api_url, required=False)
|
||||||
|
add("memory service", _http_ok(settings.memory_url + "/"), settings.memory_url, required=False)
|
||||||
|
provider = _provider_payload()
|
||||||
|
add("provider", provider["reachable"], provider["url"], required=False)
|
||||||
|
if settings.manage_ollama:
|
||||||
|
add("ollama executable", bool(services.ollama_bin()), services.ollama_bin() or "not on PATH", required=False)
|
||||||
|
|
||||||
|
for label, module in (
|
||||||
|
("process control", "psutil"),
|
||||||
|
("vector search", "sqlite_vec"),
|
||||||
|
("voice transcription", "faster_whisper"),
|
||||||
|
("PDF documents", "pypdf"),
|
||||||
|
("Word documents", "docx"),
|
||||||
|
):
|
||||||
|
add(label, _check_import(module), module, required=False)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": not any(c["status"] == "fail" for c in checks),
|
||||||
|
"version": settings.version,
|
||||||
|
"install_mode": "checkout" if settings.source_checkout else "wheel",
|
||||||
|
"platform": sys.platform,
|
||||||
|
"termux": _is_termux(),
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _doctor_fix() -> None:
|
||||||
|
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
config.init_state()
|
||||||
|
index = settings.web_dist_dir / "index.html"
|
||||||
|
npm = shutil.which("npm.cmd" if os.name == "nt" else "npm")
|
||||||
|
if settings.source_checkout and not index.exists() and npm:
|
||||||
|
subprocess.run([npm, "run", "build"], cwd=str(settings.frontend_source_dir), check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_doctor(args) -> int:
|
||||||
|
if args.fix:
|
||||||
|
_doctor_fix()
|
||||||
|
result = diagnostics()
|
||||||
|
if args.json:
|
||||||
|
_emit(result, True)
|
||||||
|
else:
|
||||||
|
print(f"NexusOS {result['version']} diagnostics ({result['install_mode']})\n")
|
||||||
|
marks = {"pass": "OK", "warn": "WARN", "fail": "FAIL"}
|
||||||
|
for check in result["checks"]:
|
||||||
|
print(f" {marks[check['status']]:<4} {check['name']:<20} {check['detail']}")
|
||||||
|
print("\nCore runtime is ready." if result["ok"] else "\nCore runtime has required failures.")
|
||||||
|
return 0 if result["ok"] else 1
|
||||||
|
|
||||||
|
|
||||||
|
def service_status() -> dict:
|
||||||
|
payload = {}
|
||||||
|
for key in ("backend", "memory", "frontend"):
|
||||||
|
svc = services.SERVICES[key]
|
||||||
|
pid = services.read_pid(svc)
|
||||||
|
payload[key] = {
|
||||||
|
"running": services.alive(pid) or _http_ok(svc.url),
|
||||||
|
"pid": pid if services.alive(pid) else None,
|
||||||
|
"url": svc.url,
|
||||||
|
}
|
||||||
|
payload["provider"] = _provider_payload()
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(args) -> int:
|
||||||
|
payload = service_status()
|
||||||
|
if args.json:
|
||||||
|
_emit(payload, True)
|
||||||
|
return 0
|
||||||
|
print("Nexus Service Status:\n")
|
||||||
|
for key in ("backend", "memory", "frontend"):
|
||||||
|
info = payload[key]
|
||||||
|
suffix = f" (PID {info['pid']})" if info["pid"] else ""
|
||||||
|
print(f" {key:<10} {'RUNNING' if info['running'] else 'STOPPED'}{suffix} {info['url']}")
|
||||||
|
p = payload["provider"]
|
||||||
|
print(f" provider {'RUNNING' if p['reachable'] else 'STOPPED'} {p['provider']} @ {p['url']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _target_flag(target: str | None):
|
||||||
|
return {
|
||||||
|
"memory": "--memory",
|
||||||
|
"backend": "--backend",
|
||||||
|
"frontend": "--frontend",
|
||||||
|
"ai": "--ai",
|
||||||
|
}.get(target or "all")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_start(args) -> int:
|
||||||
|
services.cmd_start(_target_flag(args.target))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_stop(args) -> int:
|
||||||
|
services.cmd_stop(_target_flag(args.target))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_refresh(args) -> int:
|
||||||
|
services.cmd_stop(None)
|
||||||
|
services.cmd_start(None)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_serve(args) -> int:
|
||||||
|
host = args.host or settings.bind_host
|
||||||
|
if host not in ("127.0.0.1", "localhost", "::1") and not args.allow_lan:
|
||||||
|
print("Refusing an unauthenticated LAN bind. Add --allow-lan to acknowledge the exposure.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if _http_ok(f"http://127.0.0.1:{args.port}/"):
|
||||||
|
print(f"Port {args.port} is already serving HTTP.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
settings.backend_port = args.port
|
||||||
|
settings.memory_port = args.memory_port
|
||||||
|
settings.bind_host = host
|
||||||
|
settings.api_url = f"http://127.0.0.1:{args.port}"
|
||||||
|
settings.memory_url = f"http://127.0.0.1:{args.memory_port}"
|
||||||
|
os.environ["NEXUS_BACKEND_PORT"] = str(args.port)
|
||||||
|
os.environ["NEXUS_MEMORY_PORT"] = str(args.memory_port)
|
||||||
|
os.environ["NEXUS_BIND_HOST"] = host
|
||||||
|
for origin_host in ("localhost", "127.0.0.1"):
|
||||||
|
for port in (args.port, args.memory_port):
|
||||||
|
origin = f"http://{origin_host}:{port}"
|
||||||
|
if origin not in config.ALLOWED_ORIGINS:
|
||||||
|
config.ALLOWED_ORIGINS.append(origin)
|
||||||
|
if args.allow_lan:
|
||||||
|
os.environ.setdefault("NEXUS_ALLOWED_HOSTS", "*")
|
||||||
|
os.environ.setdefault("NEXUS_ALLOWED_ORIGINS", "*")
|
||||||
|
config.ALLOWED_HOSTS[:] = ["*"]
|
||||||
|
config.ALLOWED_ORIGINS[:] = ["*"]
|
||||||
|
|
||||||
|
memory_proc = None
|
||||||
|
memory_log = None
|
||||||
|
try:
|
||||||
|
if not args.no_memory and not _http_ok(settings.memory_url + "/"):
|
||||||
|
log_path = settings.runtime_dir / "memory.log"
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
memory_log = open(log_path, "ab")
|
||||||
|
memory_proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "uvicorn", "synapse.memory.service:app",
|
||||||
|
"--host", host, "--port", str(args.memory_port)],
|
||||||
|
stdout=memory_log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
print(f"Memory service starting on {host}:{args.memory_port} (log: {log_path})")
|
||||||
|
print(f"NexusOS serving on http://{host}:{args.port}")
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(
|
||||||
|
"synapse.main:sio_app", host=host, port=args.port,
|
||||||
|
reload=bool(args.reload and settings.source_checkout),
|
||||||
|
log_level=args.log_level,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if memory_proc is not None and memory_proc.poll() is None:
|
||||||
|
memory_proc.terminate()
|
||||||
|
try:
|
||||||
|
memory_proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
memory_proc.kill()
|
||||||
|
if memory_log is not None:
|
||||||
|
memory_log.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_open(args) -> int:
|
||||||
|
if not _http_ok(settings.api_url + "/status") and not args.no_start:
|
||||||
|
services.cmd_start(None)
|
||||||
|
url = args.url or settings.api_url
|
||||||
|
opener = shutil.which("termux-open-url") if _is_termux() else None
|
||||||
|
opened = False
|
||||||
|
if opener:
|
||||||
|
opened = subprocess.run([opener, url], check=False).returncode == 0
|
||||||
|
else:
|
||||||
|
opened = webbrowser.open(url)
|
||||||
|
print(f"{'Opened' if opened else 'NexusOS is available at'} {url}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_web(args) -> int:
|
||||||
|
"""Preserve ``ncp web`` for desktop checkouts; wheels use the browser UI."""
|
||||||
|
if settings.source_checkout:
|
||||||
|
return services.cmd_web()
|
||||||
|
return cmd_open(argparse.Namespace(url=None, no_start=False))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_panel(_args) -> int:
|
||||||
|
if not settings.source_checkout:
|
||||||
|
print("The legacy Tk control panel is only available in a desktop source install.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
return services.main(["panel"])
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_backup(args) -> int:
|
||||||
|
if not settings.source_checkout:
|
||||||
|
print("Backup is a source-checkout command; wheel state should be backed up from nexus paths.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if args.check:
|
||||||
|
return services.sync_py("backup", "--check")
|
||||||
|
if args.full:
|
||||||
|
return services.sync_py("backup", "--full")
|
||||||
|
return services.sync_py("backup")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_restore(args) -> int:
|
||||||
|
if not settings.source_checkout:
|
||||||
|
print("Restore is a source-checkout command; reinstall the wheel and restore its state directory.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
return services.cmd_restore("--check" if args.check else None)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_nvidia_reqs(_args) -> int:
|
||||||
|
if not settings.source_checkout:
|
||||||
|
print("nvidia-reqs is only available in a desktop source install.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(settings.project_root / "bin" / "gen-nvidia-reqs.py")]
|
||||||
|
).returncode
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_logs(args) -> int:
|
||||||
|
keys = ("backend", "memory", "frontend") if args.target == "all" else (args.target,)
|
||||||
|
paths = [services.SERVICES[key].log_file for key in keys]
|
||||||
|
for path in paths:
|
||||||
|
print(f"=== {path.name} ===")
|
||||||
|
services._tail(path, args.lines)
|
||||||
|
if not args.follow:
|
||||||
|
return 0
|
||||||
|
offsets = {path: path.stat().st_size if path.exists() else 0 for path in paths}
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
for path in paths:
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size < offsets[path]:
|
||||||
|
offsets[path] = 0
|
||||||
|
if size > offsets[path]:
|
||||||
|
with open(path, encoding="utf-8", errors="replace") as stream:
|
||||||
|
stream.seek(offsets[path])
|
||||||
|
sys.stdout.write(stream.read())
|
||||||
|
sys.stdout.flush()
|
||||||
|
offsets[path] = stream.tell()
|
||||||
|
time.sleep(0.5)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print()
|
||||||
|
return 130
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_clean(args) -> int:
|
||||||
|
for path in (settings.runtime_dir / "pids", settings.runtime_dir / "logs"):
|
||||||
|
if path.is_dir():
|
||||||
|
for file in path.glob("*"):
|
||||||
|
if file.is_file():
|
||||||
|
file.unlink(missing_ok=True)
|
||||||
|
for pattern in ("*.log", "*.pid"):
|
||||||
|
for file in settings.runtime_dir.glob(pattern):
|
||||||
|
file.unlink(missing_ok=True)
|
||||||
|
print(f"Cleaned runtime files under {settings.runtime_dir}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_update(args) -> int:
|
||||||
|
if settings.source_checkout:
|
||||||
|
services.cmd_update()
|
||||||
|
return 0
|
||||||
|
command = [sys.executable, "-m", "pip", "install", "--upgrade", "nexusos-ai"]
|
||||||
|
if args.pre:
|
||||||
|
command.append("--pre")
|
||||||
|
return subprocess.run(command).returncode
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_models(args) -> int:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
base = settings.ollama_host.rstrip("/")
|
||||||
|
try:
|
||||||
|
if args.action == "list":
|
||||||
|
response = httpx.get(base + "/api/tags", timeout=5.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
models = response.json().get("models", [])
|
||||||
|
if args.json:
|
||||||
|
_emit(models, True)
|
||||||
|
elif not models:
|
||||||
|
print("No models installed.")
|
||||||
|
else:
|
||||||
|
for model in models:
|
||||||
|
print(f"{model.get('name', ''):<36} {model.get('size', 0) / 1024**3:5.1f} GB")
|
||||||
|
elif args.action == "available":
|
||||||
|
services.cmd_models("available", None)
|
||||||
|
elif args.action in ("pull", "install"):
|
||||||
|
with httpx.stream("POST", base + "/api/pull", json={"name": args.name}, timeout=None) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
for line in response.iter_lines():
|
||||||
|
if line:
|
||||||
|
try:
|
||||||
|
item = json.loads(line)
|
||||||
|
status = item.get("status") or item.get("error")
|
||||||
|
if status:
|
||||||
|
print(status)
|
||||||
|
except ValueError:
|
||||||
|
print(line)
|
||||||
|
elif args.action in ("remove", "rm"):
|
||||||
|
response = httpx.request("DELETE", base + "/api/delete", json={"name": args.name}, timeout=30.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
print(f"Removed {args.name}")
|
||||||
|
return 0
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
print(f"Provider request failed at {base}: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_api(args) -> int:
|
||||||
|
from . import nexus_api
|
||||||
|
nexus_api.BASE = args.api_url or settings.api_url
|
||||||
|
rest = list(args.rest)
|
||||||
|
if args.command == "chat" and rest[:1] == ["send"]:
|
||||||
|
rest.pop(0)
|
||||||
|
if args.command == "history" and rest[:1] == ["list"]:
|
||||||
|
rest.pop(0)
|
||||||
|
return nexus_api.main([args.command, *rest], prog=f"nexus {args.command}")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_json(parser) -> None:
|
||||||
|
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
||||||
|
|
||||||
|
|
||||||
|
def _port(value: str) -> int:
|
||||||
|
try:
|
||||||
|
return _validate_config("backend_port", value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise argparse.ArgumentTypeError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(prog="nexus", description="NexusOS local AI runtime and API client")
|
||||||
|
parser.add_argument("--version", action="version", version=f"NexusOS {settings.version}")
|
||||||
|
parser.add_argument("--api-url", help="override the NexusOS backend URL for this command")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
p = sub.add_parser("init", help="create user state and seed default playbooks"); _add_json(p); p.set_defaults(fn=cmd_init)
|
||||||
|
p = sub.add_parser("paths", help="show resolved package and writable paths"); _add_json(p); p.set_defaults(fn=cmd_paths)
|
||||||
|
|
||||||
|
p = sub.add_parser("config", help="manage persistent CLI/runtime configuration")
|
||||||
|
p.add_argument("action", choices=["list", "get", "set", "unset", "path"])
|
||||||
|
p.add_argument("key", nargs="?"); p.add_argument("value", nargs="?"); _add_json(p); p.set_defaults(fn=cmd_config)
|
||||||
|
|
||||||
|
p = sub.add_parser("provider", help="configure the Ollama-compatible model provider")
|
||||||
|
provider_sub = p.add_subparsers(dest="action", required=True)
|
||||||
|
show = provider_sub.add_parser("show"); _add_json(show); show.set_defaults(fn=cmd_provider)
|
||||||
|
use = provider_sub.add_parser("use"); use.add_argument("mode", choices=["local", "remote"])
|
||||||
|
use.add_argument("--url"); _add_json(use); use.set_defaults(fn=cmd_provider)
|
||||||
|
|
||||||
|
p = sub.add_parser("doctor", help="check the core runtime and optional capabilities")
|
||||||
|
p.add_argument("--fix", action="store_true"); _add_json(p); p.set_defaults(fn=cmd_doctor)
|
||||||
|
p = sub.add_parser("status", help="show service and provider status"); _add_json(p); p.set_defaults(fn=cmd_status)
|
||||||
|
|
||||||
|
p = sub.add_parser("serve", help="run NexusOS in the foreground")
|
||||||
|
p.add_argument("--host"); p.add_argument("--port", type=_port, default=settings.backend_port)
|
||||||
|
p.add_argument("--memory-port", type=_port, default=settings.memory_port)
|
||||||
|
p.add_argument("--no-memory", action="store_true"); p.add_argument("--allow-lan", action="store_true")
|
||||||
|
p.add_argument("--reload", action="store_true"); p.add_argument("--log-level", default="info")
|
||||||
|
p.set_defaults(fn=cmd_serve)
|
||||||
|
|
||||||
|
for name, fn, help_text in (
|
||||||
|
("start", cmd_start, "start services in the background"),
|
||||||
|
("stop", cmd_stop, "stop background services"),
|
||||||
|
):
|
||||||
|
p = sub.add_parser(name, help=help_text)
|
||||||
|
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend", "ai"], default="all")
|
||||||
|
p.set_defaults(fn=fn)
|
||||||
|
sub.add_parser("restart", aliases=["refresh"], help="restart all services").set_defaults(fn=cmd_refresh)
|
||||||
|
sub.add_parser("kill", help="force-stop NexusOS-owned processes").set_defaults(fn=lambda _a: services.cmd_kill() or 0)
|
||||||
|
|
||||||
|
p = sub.add_parser("open", help="open the web interface")
|
||||||
|
p.add_argument("--url"); p.add_argument("--no-start", action="store_true"); p.set_defaults(fn=cmd_open)
|
||||||
|
sub.add_parser("web", help="legacy desktop alias for open").set_defaults(fn=cmd_web)
|
||||||
|
sub.add_parser("panel", help="launch the legacy desktop control panel").set_defaults(fn=cmd_panel)
|
||||||
|
p = sub.add_parser("logs", help="read or follow service logs")
|
||||||
|
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend"], default="all")
|
||||||
|
p.add_argument("--lines", type=int, choices=range(1, 10001), default=50, metavar="1..10000")
|
||||||
|
p.add_argument("--follow", "-f", action="store_true"); p.set_defaults(fn=cmd_logs)
|
||||||
|
sub.add_parser("clean", help="remove runtime logs and stale PID files").set_defaults(fn=cmd_clean)
|
||||||
|
p = sub.add_parser("update", help="update dependencies or the installed wheel"); p.add_argument("--pre", action="store_true"); p.set_defaults(fn=cmd_update)
|
||||||
|
|
||||||
|
p = sub.add_parser("backup", help="back up a desktop source checkout")
|
||||||
|
p.add_argument("--full", "-f", action="store_true"); p.add_argument("--check", "-c", action="store_true")
|
||||||
|
p.set_defaults(fn=cmd_backup)
|
||||||
|
p = sub.add_parser("restore", help="restore a desktop source checkout")
|
||||||
|
p.add_argument("--check", "-c", action="store_true"); p.set_defaults(fn=cmd_restore)
|
||||||
|
sub.add_parser("nvidia-reqs", help="regenerate source-checkout NVIDIA requirements").set_defaults(fn=cmd_nvidia_reqs)
|
||||||
|
|
||||||
|
p = sub.add_parser("models", help="list, pull, and remove provider models")
|
||||||
|
model_sub = p.add_subparsers(dest="action", required=True)
|
||||||
|
item = model_sub.add_parser("list"); _add_json(item); item.set_defaults(fn=cmd_models)
|
||||||
|
model_sub.add_parser("available", aliases=["search"]).set_defaults(fn=cmd_models, action="available")
|
||||||
|
item = model_sub.add_parser("pull", aliases=["install"]); item.add_argument("name"); item.set_defaults(fn=cmd_models, action="pull")
|
||||||
|
item = model_sub.add_parser("remove", aliases=["rm"]); item.add_argument("name"); item.set_defaults(fn=cmd_models, action="remove")
|
||||||
|
|
||||||
|
for command in ("chat", "memory", "playbook", "history"):
|
||||||
|
p = sub.add_parser(command, add_help=False, help=f"use the {command} API from the terminal")
|
||||||
|
p.add_argument("rest", nargs=argparse.REMAINDER)
|
||||||
|
p.set_defaults(fn=cmd_api)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_legacy_argv(argv) -> list[str]:
|
||||||
|
"""Translate the old shell CLI spelling before argparse sees it."""
|
||||||
|
normalized = list(argv or [])
|
||||||
|
if normalized == ["help"]:
|
||||||
|
return ["--help"]
|
||||||
|
if normalized and normalized[0] in ("start", "stop", "logs"):
|
||||||
|
normalized[1:] = [LEGACY_TARGETS.get(value, value) for value in normalized[1:]]
|
||||||
|
if normalized[:2] == ["backup", "full"]:
|
||||||
|
normalized[1] = "--full"
|
||||||
|
elif len(normalized) > 1 and normalized[0] == "backup" and normalized[1] in ("check", "--claude"):
|
||||||
|
normalized[1] = "--check"
|
||||||
|
elif len(normalized) > 1 and normalized[0] == "restore":
|
||||||
|
if normalized[1] in ("check", "--claude"):
|
||||||
|
normalized[1] = "--check"
|
||||||
|
elif normalized[1] in ("full", "-f", "--full"):
|
||||||
|
normalized.pop(1)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(_normalize_legacy_argv(argv))
|
||||||
|
if args.command == "config":
|
||||||
|
if args.action in ("get", "unset") and not args.key:
|
||||||
|
parser.error(f"config {args.action} requires KEY")
|
||||||
|
if args.action == "set" and (not args.key or args.value is None):
|
||||||
|
parser.error("config set requires KEY VALUE")
|
||||||
|
try:
|
||||||
|
result = args.fn(args)
|
||||||
|
return result if isinstance(result, int) else 0
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print()
|
||||||
|
return 130
|
||||||
|
|
||||||
|
|
||||||
|
def entrypoint() -> int:
|
||||||
|
return main(sys.argv[1:])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(entrypoint())
|
||||||
+7
-4
@@ -12,17 +12,20 @@ REM exactly what this file exists to avoid.
|
|||||||
REM
|
REM
|
||||||
REM ASCII only, same rule as the .ps1 files - a test in tests/test_smoke.py enforces it.
|
REM ASCII only, same rule as the .ps1 files - a test in tests/test_smoke.py enforces it.
|
||||||
setlocal
|
setlocal
|
||||||
REM ncp.py pins its stdout to UTF-8 (it prints check marks and em-dashes, and a
|
REM The CLI pins its stdout to UTF-8 (it prints check marks and em-dashes, and a
|
||||||
REM redirected stdout would otherwise raise UnicodeEncodeError). A console still
|
REM redirected stdout would otherwise raise UnicodeEncodeError). A console still
|
||||||
REM on codepage 437 renders those bytes as mojibake, so switch it to UTF-8 here.
|
REM on codepage 437 renders those bytes as mojibake, so switch it to UTF-8 here.
|
||||||
REM ponytail: chcp changes the calling console's codepage and does not restore
|
REM ponytail: chcp changes the calling console's codepage and does not restore
|
||||||
REM it on exit. Harmless in practice; if that ever matters, set the console CP
|
REM it on exit. Harmless in practice; if that ever matters, set the console CP
|
||||||
REM from inside ncp.py with ctypes SetConsoleOutputCP instead.
|
REM from inside the CLI with ctypes SetConsoleOutputCP instead.
|
||||||
chcp 65001 >nul 2>&1
|
chcp 65001 >nul 2>&1
|
||||||
set "ROOT=%~dp0.."
|
set "ROOT=%~dp0.."
|
||||||
REM System Python fallback so backup/restore work before the venv exists; those
|
REM System Python fallback so backup/restore work before the venv exists; those
|
||||||
REM delegate to the stdlib-only bin/sync.py. Mirrors the same fallback in ncp.ps1.
|
REM delegate to the stdlib-only bin/sync.py. Mirrors the same fallback in ncp.ps1.
|
||||||
set "PY=%ROOT%\Promethean\Scripts\python.exe"
|
set "PY=%ROOT%\Promethean\Scripts\python.exe"
|
||||||
if not exist "%PY%" set "PY=python"
|
if not exist "%PY%" set "PY=python"
|
||||||
"%PY%" "%ROOT%\management\ncp.py" %*
|
pushd "%ROOT%"
|
||||||
exit /b %ERRORLEVEL%
|
"%PY%" -m management.cli %*
|
||||||
|
set "NEXUS_EXIT=%ERRORLEVEL%"
|
||||||
|
popd
|
||||||
|
exit /b %NEXUS_EXIT%
|
||||||
|
|||||||
+94
-30
@@ -28,15 +28,20 @@ import urllib.request
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
from synapse.nexus_config import SOURCE_CHECKOUT, settings
|
||||||
PID_DIR = ROOT / "runtime" / "pids"
|
|
||||||
LOG_DIR = ROOT / "runtime"
|
ROOT = settings.project_root
|
||||||
FRONTEND_DIR = ROOT / "interface" / "web"
|
PID_DIR = settings.runtime_dir / "pids"
|
||||||
|
LOG_DIR = settings.runtime_dir
|
||||||
|
FRONTEND_DIR = settings.frontend_source_dir
|
||||||
OLLAMA_BIN = ROOT / "ollama" / "bin" / ("ollama.exe" if os.name == "nt" else "ollama")
|
OLLAMA_BIN = ROOT / "ollama" / "bin" / ("ollama.exe" if os.name == "nt" else "ollama")
|
||||||
OLLAMA_MODELS_DIR = ROOT / "models"
|
OLLAMA_MODELS_DIR = settings.models_dir
|
||||||
|
|
||||||
WINDOWS = os.name == "nt"
|
WINDOWS = os.name == "nt"
|
||||||
PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
|
_VENV_PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
|
||||||
|
PYTHON = Path(os.getenv("NEXUS_PYTHON", "")) if os.getenv("NEXUS_PYTHON") else (
|
||||||
|
_VENV_PYTHON if _VENV_PYTHON.exists() else Path(sys.executable)
|
||||||
|
)
|
||||||
|
|
||||||
PID_DIR.mkdir(parents=True, exist_ok=True)
|
PID_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -93,7 +98,7 @@ def _psutil():
|
|||||||
import psutil
|
import psutil
|
||||||
return psutil
|
return psutil
|
||||||
except ImportError:
|
except ImportError:
|
||||||
sys.exit("psutil is missing - run ./install.sh (or install-windows.ps1) to rebuild the venv.")
|
return None
|
||||||
|
|
||||||
|
|
||||||
# -- services ------------------------------------------------------------------
|
# -- services ------------------------------------------------------------------
|
||||||
@@ -124,7 +129,7 @@ class Service:
|
|||||||
# Bind loopback by default: the backend REST API is 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
|
# 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.
|
# NEXUS_BIND_HOST=0.0.0.0 to opt into LAN exposure once real auth is in place.
|
||||||
BIND_HOST = os.environ.get("NEXUS_BIND_HOST", "127.0.0.1")
|
BIND_HOST = settings.bind_host
|
||||||
|
|
||||||
|
|
||||||
def _uvicorn(app: str, port: int):
|
def _uvicorn(app: str, port: int):
|
||||||
@@ -140,9 +145,12 @@ def _uvicorn(app: str, port: int):
|
|||||||
|
|
||||||
|
|
||||||
SERVICES = {
|
SERVICES = {
|
||||||
"backend": Service("backend", "NEXUS BACKEND SERVICE", 8000, ROOT,
|
"memory": Service("memory", "NEXUS MEMORY SERVICE", settings.memory_port, settings.state_dir,
|
||||||
|
["uvicorn synapse.memory"],
|
||||||
|
lambda: _uvicorn("synapse.memory.service:app", settings.memory_port)),
|
||||||
|
"backend": Service("backend", "NEXUS BACKEND SERVICE", settings.backend_port, settings.state_dir,
|
||||||
["uvicorn synapse.main"],
|
["uvicorn synapse.main"],
|
||||||
lambda: _uvicorn("synapse.main:sio_app", 8000)),
|
lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)),
|
||||||
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
|
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
|
||||||
["vite --host", "npm run dev"],
|
["vite --host", "npm run dev"],
|
||||||
lambda: [npm(), "run", "dev", "--", "--host", "0.0.0.0"]),
|
lambda: [npm(), "run", "dev", "--", "--host", "0.0.0.0"]),
|
||||||
@@ -158,7 +166,15 @@ def read_pid(svc: Service):
|
|||||||
|
|
||||||
def alive(pid) -> bool:
|
def alive(pid) -> bool:
|
||||||
ps = _psutil()
|
ps = _psutil()
|
||||||
return pid is not None and ps.pid_exists(pid)
|
if pid is None:
|
||||||
|
return False
|
||||||
|
if ps is not None:
|
||||||
|
return ps.pid_exists(pid)
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def pid_is_ours(pid, patterns) -> bool:
|
def pid_is_ours(pid, patterns) -> bool:
|
||||||
@@ -168,7 +184,13 @@ def pid_is_ours(pid, patterns) -> bool:
|
|||||||
is not enough. TERMing a recycled PID can log the user out."""
|
is not enough. TERMing a recycled PID can log the user out."""
|
||||||
ps = _psutil()
|
ps = _psutil()
|
||||||
try:
|
try:
|
||||||
cmd = " ".join(ps.Process(pid).cmdline())
|
if ps is not None:
|
||||||
|
cmd = " ".join(ps.Process(pid).cmdline())
|
||||||
|
elif os.name != "nt":
|
||||||
|
cmd = (Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||||
|
.replace(b"\0", b" ").decode(errors="replace"))
|
||||||
|
else:
|
||||||
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
return any(p in cmd for p in patterns)
|
return any(p in cmd for p in patterns)
|
||||||
@@ -255,6 +277,20 @@ def kill_matching(patterns, force=False) -> int:
|
|||||||
ps = _psutil()
|
ps = _psutil()
|
||||||
me = os.getpid()
|
me = os.getpid()
|
||||||
hit = 0
|
hit = 0
|
||||||
|
if ps is None:
|
||||||
|
if os.name == "nt":
|
||||||
|
return 0
|
||||||
|
for entry in Path("/proc").iterdir():
|
||||||
|
if not entry.name.isdigit() or int(entry.name) == me:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cmd = (entry / "cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace")
|
||||||
|
if any(pattern in cmd for pattern in patterns):
|
||||||
|
os.kill(int(entry.name), 9 if force else 15)
|
||||||
|
hit += 1
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return hit
|
||||||
for proc in ps.process_iter(["pid", "cmdline"]):
|
for proc in ps.process_iter(["pid", "cmdline"]):
|
||||||
if proc.info["pid"] == me:
|
if proc.info["pid"] == me:
|
||||||
continue
|
continue
|
||||||
@@ -272,6 +308,8 @@ def kill_port(port: int) -> bool:
|
|||||||
"""Whatever holds the port IS the service - this is the backstop that makes
|
"""Whatever holds the port IS the service - this is the backstop that makes
|
||||||
stop reliable regardless of process-tree shape or PID-file accuracy."""
|
stop reliable regardless of process-tree shape or PID-file accuracy."""
|
||||||
ps = _psutil()
|
ps = _psutil()
|
||||||
|
if ps is None:
|
||||||
|
return False
|
||||||
killed = False
|
killed = False
|
||||||
try:
|
try:
|
||||||
conns = ps.net_connections(kind="inet")
|
conns = ps.net_connections(kind="inet")
|
||||||
@@ -295,13 +333,16 @@ def stop_service(svc: Service) -> bool:
|
|||||||
if alive(pid) and pid_is_ours(pid, svc.patterns):
|
if alive(pid) and pid_is_ours(pid, svc.patterns):
|
||||||
ps = _psutil()
|
ps = _psutil()
|
||||||
try:
|
try:
|
||||||
proc = ps.Process(pid)
|
if ps is not None:
|
||||||
for child in proc.children(recursive=True):
|
proc = ps.Process(pid)
|
||||||
try:
|
for child in proc.children(recursive=True):
|
||||||
child.terminate()
|
try:
|
||||||
except Exception:
|
child.terminate()
|
||||||
pass
|
except Exception:
|
||||||
proc.terminate()
|
pass
|
||||||
|
proc.terminate()
|
||||||
|
elif os.name != "nt":
|
||||||
|
os.kill(pid, 15)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
svc.pid_file.unlink(missing_ok=True)
|
svc.pid_file.unlink(missing_ok=True)
|
||||||
@@ -332,8 +373,15 @@ def start_ollama(background: bool = False) -> None:
|
|||||||
background=False (`ncp start --ai`): blocks until the model is warmed - weights
|
background=False (`ncp start --ai`): blocks until the model is warmed - weights
|
||||||
read off disk into RAM/VRAM, routinely about a minute - so "started" means the
|
read off disk into RAM/VRAM, routinely about a minute - so "started" means the
|
||||||
AI can actually answer."""
|
AI can actually answer."""
|
||||||
|
if not settings.manage_ollama:
|
||||||
|
running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags", timeout=3)
|
||||||
|
print(
|
||||||
|
f"REMOTE OLLAMA {'REACHABLE' if running else 'UNREACHABLE'} "
|
||||||
|
f"({settings.ollama_host})"
|
||||||
|
)
|
||||||
|
return
|
||||||
print("Starting OLLAMA...")
|
print("Starting OLLAMA...")
|
||||||
url = "http://localhost:8000/ollama/start" + ("?background=true" if background else "")
|
url = settings.api_url + "/ollama/start" + ("?background=true" if background else "")
|
||||||
req = urllib.request.Request(url, method="POST")
|
req = urllib.request.Request(url, method="POST")
|
||||||
try:
|
try:
|
||||||
urllib.request.urlopen(req, timeout=180).read(1)
|
urllib.request.urlopen(req, timeout=180).read(1)
|
||||||
@@ -346,7 +394,10 @@ def stop_ollama() -> None:
|
|||||||
"""Prefer the backend endpoint for a clean OllamaManager shutdown; if the
|
"""Prefer the backend endpoint for a clean OllamaManager shutdown; if the
|
||||||
backend is already down, kill `ollama serve` directly so it never lingers
|
backend is already down, kill `ollama serve` directly so it never lingers
|
||||||
holding VRAM/RAM. Must run BEFORE the backend is torn down."""
|
holding VRAM/RAM. Must run BEFORE the backend is torn down."""
|
||||||
req = urllib.request.Request("http://localhost:8000/ollama/stop", method="POST")
|
if not settings.manage_ollama:
|
||||||
|
print(f"REMOTE OLLAMA IS EXTERNALLY MANAGED ({settings.ollama_host})")
|
||||||
|
return
|
||||||
|
req = urllib.request.Request(settings.api_url + "/ollama/stop", method="POST")
|
||||||
try:
|
try:
|
||||||
urllib.request.urlopen(req, timeout=5).read(1)
|
urllib.request.urlopen(req, timeout=5).read(1)
|
||||||
print("NEXUS OLLAMA STOPPED")
|
print("NEXUS OLLAMA STOPPED")
|
||||||
@@ -367,6 +418,9 @@ def cmd_start(target) -> None:
|
|||||||
launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"])
|
launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"])
|
||||||
start_ollama()
|
start_ollama()
|
||||||
elif target in ("--frontend", "-f"):
|
elif target in ("--frontend", "-f"):
|
||||||
|
if not SOURCE_CHECKOUT:
|
||||||
|
print("Vite source is unavailable in wheel installs; the backend serves the bundled UI.")
|
||||||
|
return
|
||||||
launch(SERVICES["frontend"]); wait_for_port(SERVICES["frontend"])
|
launch(SERVICES["frontend"]); wait_for_port(SERVICES["frontend"])
|
||||||
elif target in ("--ai", "-a"):
|
elif target in ("--ai", "-a"):
|
||||||
start_ollama()
|
start_ollama()
|
||||||
@@ -389,7 +443,7 @@ def cmd_start(target) -> None:
|
|||||||
wait_for_port(SERVICES["backend"])
|
wait_for_port(SERVICES["backend"])
|
||||||
t_services = time.perf_counter()
|
t_services = time.perf_counter()
|
||||||
start_ollama(background=True)
|
start_ollama(background=True)
|
||||||
if not WINDOWS:
|
if SOURCE_CHECKOUT and not WINDOWS:
|
||||||
launch(SERVICES["frontend"])
|
launch(SERVICES["frontend"])
|
||||||
t_bg = time.perf_counter()
|
t_bg = time.perf_counter()
|
||||||
print("\nBoot timing:")
|
print("\nBoot timing:")
|
||||||
@@ -405,6 +459,8 @@ def cmd_stop(target) -> None:
|
|||||||
stop_ollama(); stop_service(SERVICES["backend"])
|
stop_ollama(); stop_service(SERVICES["backend"])
|
||||||
elif target in ("--frontend", "-f"):
|
elif target in ("--frontend", "-f"):
|
||||||
stop_service(SERVICES["frontend"])
|
stop_service(SERVICES["frontend"])
|
||||||
|
elif target in ("--ai", "-a"):
|
||||||
|
stop_ollama()
|
||||||
elif target in (None, "", "all"):
|
elif target in (None, "", "all"):
|
||||||
stop_ollama()
|
stop_ollama()
|
||||||
stop_service(SERVICES["backend"])
|
stop_service(SERVICES["backend"])
|
||||||
@@ -415,14 +471,21 @@ def cmd_stop(target) -> None:
|
|||||||
|
|
||||||
def cmd_kill() -> None:
|
def cmd_kill() -> None:
|
||||||
print("Force-killing all Nexus processes...")
|
print("Force-killing all Nexus processes...")
|
||||||
for port, name in ((8000, "SYNAPSE"),
|
targets = [
|
||||||
(5173, "INTERFACE"), (11434, "OLLAMA")):
|
(settings.backend_port, "SYNAPSE"),
|
||||||
|
(settings.memory_port, "MEMORY"),
|
||||||
|
(5173, "INTERFACE"),
|
||||||
|
]
|
||||||
|
patterns = ["uvicorn synapse", "npm run dev", "vite --host"]
|
||||||
|
if settings.manage_ollama:
|
||||||
|
targets.append((11434, "OLLAMA"))
|
||||||
|
patterns.append("ollama serve")
|
||||||
|
for port, name in targets:
|
||||||
if kill_port(port):
|
if kill_port(port):
|
||||||
print(f" KILLED: {name} (:{port})")
|
print(f" KILLED: {name} (:{port})")
|
||||||
else:
|
else:
|
||||||
print(f" NOT RUNNING: {name} (:{port})")
|
print(f" NOT RUNNING: {name} (:{port})")
|
||||||
kill_matching(["uvicorn synapse", "npm run dev", "vite --host", "ollama serve"],
|
kill_matching(patterns, force=True)
|
||||||
force=True)
|
|
||||||
for pid_file in PID_DIR.glob("*.pid"):
|
for pid_file in PID_DIR.glob("*.pid"):
|
||||||
pid_file.unlink(missing_ok=True)
|
pid_file.unlink(missing_ok=True)
|
||||||
print("Done.")
|
print("Done.")
|
||||||
@@ -445,8 +508,9 @@ def cmd_status() -> None:
|
|||||||
print("\nFrontend:")
|
print("\nFrontend:")
|
||||||
one("Vite ", SERVICES["frontend"])
|
one("Vite ", SERVICES["frontend"])
|
||||||
print("\nModel server:")
|
print("\nModel server:")
|
||||||
running = http_ok("http://localhost:11434/api/tags")
|
running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags")
|
||||||
print(f" Ollama : {'RUNNING (:11434)' if running else 'STOPPED'}")
|
mode = "remote" if not settings.manage_ollama else "local"
|
||||||
|
print(f" Ollama ({mode}) : {'RUNNING' if running else 'STOPPED'} ({settings.ollama_host})")
|
||||||
|
|
||||||
|
|
||||||
def _tail(path: Path, n: int) -> None:
|
def _tail(path: Path, n: int) -> None:
|
||||||
@@ -687,10 +751,10 @@ MODEL_CATALOG = [
|
|||||||
|
|
||||||
def cmd_models(action, name) -> None:
|
def cmd_models(action, name) -> None:
|
||||||
if action == "list":
|
if action == "list":
|
||||||
if not http_ok("http://localhost:11434/api/tags"):
|
if not http_ok(settings.ollama_host.rstrip("/") + "/api/tags"):
|
||||||
print("Ollama is not running. Start the backend first with: ncp start -b")
|
print("Ollama is not running. Start the backend first with: ncp start -b")
|
||||||
return
|
return
|
||||||
raw = urllib.request.urlopen("http://localhost:11434/api/tags", timeout=5).read()
|
raw = urllib.request.urlopen(settings.ollama_host.rstrip("/") + "/api/tags", timeout=5).read()
|
||||||
models = json.loads(raw).get("models", [])
|
models = json.loads(raw).get("models", [])
|
||||||
print("Installed models:\n")
|
print("Installed models:\n")
|
||||||
if not models:
|
if not models:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ncp - Linux entry point. The CLI itself is management/ncp.py, which runs
|
# ncp - Linux entry point. The CLI itself is management/cli.py, which runs
|
||||||
# unchanged on Windows too (see management/ncp.cmd); this stays a shell script
|
# unchanged on Windows too (see management/ncp.cmd); this stays a shell script
|
||||||
# because ~/.bashrc, launch_nexus.sh, bin/restore-linux.sh, controlpanel.py,
|
# because ~/.bashrc, launch_nexus.sh, bin/restore-linux.sh, controlpanel.py,
|
||||||
# bin/panel/nexus-popup.py and management/nexus-app.sh all invoke this path.
|
# bin/panel/nexus-popup.py and management/nexus-app.sh all invoke this path.
|
||||||
@@ -8,9 +8,10 @@
|
|||||||
# directory drives itself instead of reaching into the real install.
|
# directory drives itself instead of reaching into the real install.
|
||||||
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)"
|
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)"
|
||||||
|
|
||||||
# ncp.py needs psutil (venv), but its backup/restore path delegates to the
|
# Prefer the project venv for the full desktop dependency set. The portable CLI
|
||||||
# stdlib-only bin/sync.py and must work before the venv is built.
|
# falls back to system Python when the package is installed without that venv.
|
||||||
PY="$NEXUS_ROOT/Promethean/bin/python3"
|
PY="$NEXUS_ROOT/Promethean/bin/python3"
|
||||||
[ -x "$PY" ] || PY=python3
|
[ -x "$PY" ] || PY=python3
|
||||||
|
|
||||||
exec "$PY" "$NEXUS_ROOT/management/ncp.py" "$@"
|
cd "$NEXUS_ROOT"
|
||||||
|
exec "$PY" -m management.cli "$@"
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import sys
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
BASE = __import__("os").environ.get("NEXUS_API", "http://localhost:8000")
|
from synapse.nexus_config import settings
|
||||||
|
|
||||||
|
BASE = settings.api_url
|
||||||
|
|
||||||
|
|
||||||
def _client():
|
def _client():
|
||||||
@@ -118,8 +120,8 @@ def cmd_history(args):
|
|||||||
_die_if_down(e)
|
_die_if_down(e)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main(argv=None, prog="nexus"):
|
||||||
p = argparse.ArgumentParser(prog="ncp")
|
p = argparse.ArgumentParser(prog=prog)
|
||||||
sub = p.add_subparsers(dest="cmd", required=True)
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
c = sub.add_parser("chat"); c.add_argument("message", nargs="+")
|
c = sub.add_parser("chat"); c.add_argument("message", nargs="+")
|
||||||
@@ -135,8 +137,9 @@ def main():
|
|||||||
h = sub.add_parser("history"); h.add_argument("query", nargs="?")
|
h = sub.add_parser("history"); h.add_argument("query", nargs="?")
|
||||||
h.add_argument("--limit", type=int, default=20); h.set_defaults(fn=cmd_history)
|
h.add_argument("--limit", type=int, default=20); h.set_defaults(fn=cmd_history)
|
||||||
|
|
||||||
args = p.parse_args()
|
args = p.parse_args(argv)
|
||||||
args.fn(args)
|
result = args.fn(args)
|
||||||
|
return result if isinstance(result, int) else 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import pytest
|
|||||||
|
|
||||||
pytest.importorskip("tkinter", reason="controlpanel is a Tk GUI; headless boxes lack python3-tk")
|
pytest.importorskip("tkinter", reason="controlpanel is a Tk GUI; headless boxes lack python3-tk")
|
||||||
|
|
||||||
from controlpanel import NexusControlPanel # noqa: E402
|
try:
|
||||||
|
from management.controlpanel import NexusControlPanel # noqa: E402
|
||||||
|
except ModuleNotFoundError: # direct: python management/test_controlpanel_close.py
|
||||||
|
from controlpanel import NexusControlPanel # type: ignore[no-redef] # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _fake(closing, after_raises):
|
def _fake(closing, after_raises):
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"""Pin the SSE parser in nexus_api. Run: python management/test_nexus_api.py"""
|
"""Pin the SSE parser in nexus_api. Run: python management/test_nexus_api.py"""
|
||||||
import sys, os
|
import sys, os
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
from nexus_api import iter_chunks
|
try:
|
||||||
|
from management.nexus_api import iter_chunks
|
||||||
|
except ModuleNotFoundError: # direct: python management/test_nexus_api.py
|
||||||
|
from nexus_api import iter_chunks
|
||||||
|
|
||||||
# A realistic /chat/stream frame: two token chunks, a meta block, then done.
|
# A realistic /chat/stream frame: two token chunks, a meta block, then done.
|
||||||
lines = [
|
lines = [
|
||||||
|
|||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling>=1.26"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "nexusos-ai"
|
||||||
|
dynamic = ["version"]
|
||||||
|
description = "Local-first AI assistant runtime, web UI, and portable CLI"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
authors = [{name = "EnderOfWings"}]
|
||||||
|
keywords = ["ai", "assistant", "ollama", "local-ai", "termux"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Environment :: Console",
|
||||||
|
"Framework :: FastAPI",
|
||||||
|
"Operating System :: Android",
|
||||||
|
"Operating System :: Microsoft :: Windows",
|
||||||
|
"Operating System :: POSIX :: Linux",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
|
"Topic :: Communications :: Chat",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115,<1",
|
||||||
|
"httpx>=0.27,<1",
|
||||||
|
"pydantic>=2.7,<3",
|
||||||
|
"python-dotenv>=1,<2",
|
||||||
|
"PyYAML>=6,<7",
|
||||||
|
"uvicorn>=0.30,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
documents = ["pypdf>=5,<7", "python-docx>=1.1,<2"]
|
||||||
|
process = ["psutil>=5.9,<8"]
|
||||||
|
vector = ["sqlite-vec>=0.1,<1"]
|
||||||
|
voice = ["faster-whisper>=1.1,<2"]
|
||||||
|
mail = ["imap-tools>=1.7,<2"]
|
||||||
|
desktop = [
|
||||||
|
"psutil>=5.9,<8",
|
||||||
|
"pywebview>=5,<7; platform_system == 'Windows'",
|
||||||
|
]
|
||||||
|
standard = [
|
||||||
|
"pypdf>=5,<7",
|
||||||
|
"python-docx>=1.1,<2",
|
||||||
|
"psutil>=5.9,<8",
|
||||||
|
"sqlite-vec>=0.1,<1",
|
||||||
|
]
|
||||||
|
dev = [
|
||||||
|
"build>=1.2,<2",
|
||||||
|
"pytest>=8,<10",
|
||||||
|
"twine>=7,<8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
nexus = "management.cli:entrypoint"
|
||||||
|
ncp = "management.cli:entrypoint"
|
||||||
|
nexusos = "management.cli:entrypoint"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://git.enderofwings.com/enderofwings/NexusOS"
|
||||||
|
Issues = "https://git.enderofwings.com/enderofwings/NexusOS/issues"
|
||||||
|
Repository = "https://git.enderofwings.com/enderofwings/NexusOS"
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
path = "VERSION"
|
||||||
|
pattern = "^(?P<version>[^\\s]+)$"
|
||||||
|
|
||||||
|
[tool.hatch.build]
|
||||||
|
skip-excluded-dirs = true
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["synapse", "management"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel.force-include]
|
||||||
|
"VERSION" = "synapse/_resources/VERSION"
|
||||||
|
"interface/web/dist" = "synapse/_resources/web"
|
||||||
|
"data/playbooks" = "synapse/_resources/playbooks"
|
||||||
|
"assets/n-small.png" = "synapse/_resources/assets/n-small.png"
|
||||||
|
"assets/themes/NexusOS-icons-src/nexus-underlay.svg" = "synapse/_resources/assets/themes/NexusOS-icons-src/nexus-underlay.svg"
|
||||||
|
"assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg" = "synapse/_resources/assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist]
|
||||||
|
include = [
|
||||||
|
"/assets/n-small.png",
|
||||||
|
"/assets/themes/NexusOS-icons-src/nexus-underlay.svg",
|
||||||
|
"/assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg",
|
||||||
|
"/data/playbooks",
|
||||||
|
"/docs",
|
||||||
|
"/interface/web/dist",
|
||||||
|
"/management",
|
||||||
|
"/scripts",
|
||||||
|
"/synapse",
|
||||||
|
"/tests",
|
||||||
|
"/README.md",
|
||||||
|
"/VERSION",
|
||||||
|
"/pyproject.toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist.force-include]
|
||||||
|
"interface/web/dist" = "interface/web/dist"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests", "management"]
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install the portable NexusOS wheel in Termux.
|
||||||
|
#
|
||||||
|
# NEXUS_PACKAGE may be a PyPI requirement, wheel URL, or local wheel path.
|
||||||
|
# NEXUS_ANDROID_WHEEL_INDEX may point at a trusted PEP 503 index containing
|
||||||
|
# Android pydantic-core wheels for the device's Python/CPU combination.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${PREFIX:-}" != *com.termux* ]]; then
|
||||||
|
echo "This installer must be run inside Termux." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
PACKAGE_SPEC="${1:-${NEXUS_PACKAGE:-nexusos-ai}}"
|
||||||
|
|
||||||
|
echo "==> Installing Termux prerequisites"
|
||||||
|
pkg update -y
|
||||||
|
pkg install -y python python-pip curl clang make
|
||||||
|
|
||||||
|
PYTHON_TAG="$(python -c 'import sys; print(f"cp{sys.version_info.major}{sys.version_info.minor}")')"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
echo "==> Python ${PYTHON_TAG}; architecture ${ARCH}"
|
||||||
|
|
||||||
|
PIP_INDEX_ARGS=()
|
||||||
|
if [[ -n "${NEXUS_ANDROID_WHEEL_INDEX:-}" ]]; then
|
||||||
|
PIP_INDEX_ARGS+=(--extra-index-url "${NEXUS_ANDROID_WHEEL_INDEX}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pydantic 2 is required on Python 3.14+, and its Rust extension is not built
|
||||||
|
# reliably on-device. Require a binary before asking pip to resolve NexusOS so
|
||||||
|
# failures are immediate and actionable. Older Termux environments also benefit
|
||||||
|
# from using a wheel instead of compiling the extension on a phone.
|
||||||
|
echo "==> Checking for an Android pydantic-core wheel"
|
||||||
|
if ! python -m pip install \
|
||||||
|
--only-binary=pydantic-core \
|
||||||
|
"${PIP_INDEX_ARGS[@]}" \
|
||||||
|
"pydantic>=2.7,<3"; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
|
||||||
|
No compatible pydantic-core wheel was found for ${PYTHON_TAG}/${ARCH}.
|
||||||
|
PyPI does not currently publish Android wheels for this native dependency.
|
||||||
|
Set NEXUS_ANDROID_WHEEL_INDEX to a trusted NexusOS Android wheel index and
|
||||||
|
run this installer again. Do not force a source build on a memory-limited phone.
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Installing ${PACKAGE_SPEC}"
|
||||||
|
python -m pip install "${PACKAGE_SPEC}"
|
||||||
|
|
||||||
|
echo "==> Initializing NexusOS"
|
||||||
|
nexus init
|
||||||
|
|
||||||
|
if [[ -n "${NEXUS_PROVIDER_URL:-}" ]]; then
|
||||||
|
nexus provider use remote --url "${NEXUS_PROVIDER_URL}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
nexus doctor
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
NexusOS is installed. Configure an Ollama-compatible provider, then run:
|
||||||
|
nexus provider use remote --url http://YOUR-OLLAMA-HOST:11434
|
||||||
|
nexus serve
|
||||||
|
|
||||||
|
Open http://127.0.0.1:8000 in the Android browser.
|
||||||
|
EOF
|
||||||
+42
-15
@@ -12,12 +12,16 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import psutil
|
try:
|
||||||
|
import psutil
|
||||||
|
except ImportError: # optional in the portable/Termux core install
|
||||||
|
psutil = None
|
||||||
|
|
||||||
from .nexus_config import PROJECT_ROOT, RUNTIME_DIR
|
from .nexus_config import FRONTEND_SOURCE_DIR, RUNTIME_DIR
|
||||||
|
|
||||||
FRONTEND_DIR = PROJECT_ROOT / "interface" / "web"
|
FRONTEND_DIR = FRONTEND_SOURCE_DIR
|
||||||
PID_FILE = RUNTIME_DIR / "pids" / "frontend.pid"
|
PID_FILE = RUNTIME_DIR / "pids" / "frontend.pid"
|
||||||
LOG_FILE = RUNTIME_DIR / "frontend.log"
|
LOG_FILE = RUNTIME_DIR / "frontend.log"
|
||||||
PORT = 5173
|
PORT = 5173
|
||||||
@@ -41,12 +45,30 @@ def _is_ours(pid: int) -> bool:
|
|||||||
# the tracked PID's own cmdline. Loosen to "vite" (matches once the tree
|
# the tracked PID's own cmdline. Loosen to "vite" (matches once the tree
|
||||||
# gets that far) or "npm"+"dev" both present (matches the wrapper hop too).
|
# gets that far) or "npm"+"dev" both present (matches the wrapper hop too).
|
||||||
try:
|
try:
|
||||||
cmd = " ".join(psutil.Process(pid).cmdline())
|
if psutil is not None:
|
||||||
|
cmd = " ".join(psutil.Process(pid).cmdline())
|
||||||
|
elif os.name != "nt":
|
||||||
|
cmd = (Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||||
|
.replace(b"\0", b" ").decode(errors="replace"))
|
||||||
|
else:
|
||||||
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
return "vite" in cmd or ("npm" in cmd and "dev" in cmd)
|
return "vite" in cmd or ("npm" in cmd and "dev" in cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def _alive(pid: int | None) -> bool:
|
||||||
|
if pid is None:
|
||||||
|
return False
|
||||||
|
if psutil is not None:
|
||||||
|
return psutil.pid_exists(pid)
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _http_up() -> bool:
|
def _http_up() -> bool:
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1.5) as r:
|
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1.5) as r:
|
||||||
@@ -60,7 +82,7 @@ def is_running() -> bool:
|
|||||||
port actually answers (covers Vite started by another process, or a lost
|
port actually answers (covers Vite started by another process, or a lost
|
||||||
PID file) - not the stricter pattern match `stop()` uses before killing."""
|
PID file) - not the stricter pattern match `stop()` uses before killing."""
|
||||||
pid = _read_pid()
|
pid = _read_pid()
|
||||||
if pid is not None and psutil.pid_exists(pid):
|
if _alive(pid):
|
||||||
return True
|
return True
|
||||||
return _http_up()
|
return _http_up()
|
||||||
|
|
||||||
@@ -68,6 +90,8 @@ def is_running() -> bool:
|
|||||||
def start() -> dict:
|
def start() -> dict:
|
||||||
if is_running():
|
if is_running():
|
||||||
return {"status": "already_running"}
|
return {"status": "already_running"}
|
||||||
|
if not FRONTEND_DIR.is_dir():
|
||||||
|
return {"status": "unavailable", "detail": "Vite source is not included in wheel installs"}
|
||||||
npm = _npm()
|
npm = _npm()
|
||||||
if not npm:
|
if not npm:
|
||||||
return {"status": "error", "detail": "npm not found - install Node.js"}
|
return {"status": "error", "detail": "npm not found - install Node.js"}
|
||||||
@@ -92,17 +116,20 @@ def start() -> dict:
|
|||||||
|
|
||||||
def stop() -> dict:
|
def stop() -> dict:
|
||||||
pid = _read_pid()
|
pid = _read_pid()
|
||||||
if pid is not None and psutil.pid_exists(pid) and _is_ours(pid):
|
if _alive(pid) and _is_ours(pid):
|
||||||
try:
|
try:
|
||||||
proc = psutil.Process(pid)
|
if psutil is not None:
|
||||||
# npm.cmd -> node -> vite is a multi-hop tree; kill it depth-first
|
proc = psutil.Process(pid)
|
||||||
# so the parent doesn't outlive its children as an orphaned shell.
|
# npm.cmd -> node -> vite is a multi-hop tree; kill it depth-first
|
||||||
for child in proc.children(recursive=True):
|
# so the parent doesn't outlive its children as an orphaned shell.
|
||||||
try:
|
for child in proc.children(recursive=True):
|
||||||
child.terminate()
|
try:
|
||||||
except Exception:
|
child.terminate()
|
||||||
pass
|
except Exception:
|
||||||
proc.terminate()
|
pass
|
||||||
|
proc.terminate()
|
||||||
|
elif os.name != "nt":
|
||||||
|
os.kill(pid, 15)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
PID_FILE.unlink(missing_ok=True)
|
PID_FILE.unlink(missing_ok=True)
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import tempfile
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
_ROOT = Path(__file__).resolve().parents[2]
|
from ..nexus_config import settings
|
||||||
UNDERLAY_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay.svg"
|
|
||||||
RING_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg"
|
_ROOT = settings.project_root
|
||||||
|
UNDERLAY_FILE = settings.assets_dir / "themes/NexusOS-icons-src/nexus-underlay.svg"
|
||||||
|
RING_FILE = settings.assets_dir / "themes/NexusOS-icons-src/nexus-underlay-ring.svg"
|
||||||
ICONS_OUT = Path.home() / ".icons" / "NexusOS"
|
ICONS_OUT = Path.home() / ".icons" / "NexusOS"
|
||||||
SIZES = [16, 22, 24, 32, 48, 64, 128]
|
SIZES = [16, 22, 24, 32, 48, 64, 128]
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ _ALLOWED_ROOTS = [
|
|||||||
"/opt",
|
"/opt",
|
||||||
str(Path.home() / ".local/share/icons"),
|
str(Path.home() / ".local/share/icons"),
|
||||||
str(Path.home() / ".icons"),
|
str(Path.home() / ".icons"),
|
||||||
str(_ROOT / "assets"),
|
str(settings.assets_dir),
|
||||||
]
|
]
|
||||||
|
|
||||||
_UNDERLAY_FALLBACK = """\
|
_UNDERLAY_FALLBACK = """\
|
||||||
|
|||||||
+3
-2
@@ -184,6 +184,7 @@ from .memory.store import store, MemoryItem
|
|||||||
from .playbooks.store import playbook_store, PlaybookItem
|
from .playbooks.store import playbook_store, PlaybookItem
|
||||||
from .search import needs_web_search, web_search
|
from .search import needs_web_search, web_search
|
||||||
|
|
||||||
|
MEMORY_SERVICE = settings.memory_url
|
||||||
|
|
||||||
app = FastAPI(title="Synapse Backend", version=VERSION)
|
app = FastAPI(title="Synapse Backend", version=VERSION)
|
||||||
|
|
||||||
@@ -1591,7 +1592,7 @@ async def delete_conversation(conversation_id: str):
|
|||||||
|
|
||||||
# ── Icon branding routes ──────────────────────────────────────────────────────
|
# ── Icon branding routes ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
_REPO_ASSETS = str(Path(__file__).resolve().parents[1] / "assets")
|
_REPO_ASSETS = str(settings.assets_dir)
|
||||||
_ALLOWED_ICON_ROOTS = [
|
_ALLOWED_ICON_ROOTS = [
|
||||||
"/usr/share/icons",
|
"/usr/share/icons",
|
||||||
"/usr/share/pixmaps",
|
"/usr/share/pixmaps",
|
||||||
@@ -1672,7 +1673,7 @@ async def apply_icon_cache_route():
|
|||||||
# and uses the Vite dev server as before.
|
# and uses the Vite dev server as before.
|
||||||
from fastapi.staticfiles import StaticFiles # noqa: E402
|
from fastapi.staticfiles import StaticFiles # noqa: E402
|
||||||
|
|
||||||
_DIST = Path(__file__).resolve().parent.parent / "interface" / "web" / "dist"
|
_DIST = settings.web_dist_dir
|
||||||
if _DIST.is_dir():
|
if _DIST.is_dir():
|
||||||
app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="ui")
|
app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="ui")
|
||||||
|
|
||||||
|
|||||||
+189
-20
@@ -1,7 +1,10 @@
|
|||||||
# config.py
|
# config.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
from importlib import metadata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
@@ -12,14 +15,84 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# --- PROJECT ROOT ---
|
# --- INSTALL / RESOURCE LAYOUT ---
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PACKAGE_DIR = Path(__file__).resolve().parent
|
||||||
|
_CHECKOUT_ROOT = PACKAGE_DIR.parent
|
||||||
|
SOURCE_CHECKOUT = (
|
||||||
|
(_CHECKOUT_ROOT / "VERSION").is_file()
|
||||||
|
and (_CHECKOUT_ROOT / "interface" / "web" / "package.json").is_file()
|
||||||
|
)
|
||||||
|
|
||||||
# --- VERSION (single source of truth: the VERSION file at the repo root) ---
|
# PROJECT_ROOT remains the source checkout for developer installs. In a wheel it
|
||||||
|
# is the installed package directory; writable state is deliberately elsewhere.
|
||||||
|
PROJECT_ROOT = Path(os.getenv("NEXUS_PROJECT_ROOT", "")).expanduser() if os.getenv(
|
||||||
|
"NEXUS_PROJECT_ROOT"
|
||||||
|
) else (_CHECKOUT_ROOT if SOURCE_CHECKOUT else PACKAGE_DIR)
|
||||||
|
PROJECT_ROOT = PROJECT_ROOT.resolve()
|
||||||
|
RESOURCE_ROOT = PROJECT_ROOT if SOURCE_CHECKOUT else PACKAGE_DIR / "_resources"
|
||||||
|
|
||||||
|
|
||||||
|
def _user_dir(env_name: str, windows_leaf: str, xdg_name: str, xdg_fallback: str) -> Path:
|
||||||
|
override = os.getenv(env_name, "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override).expanduser().resolve()
|
||||||
|
if os.name == "nt":
|
||||||
|
base = Path(os.getenv("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
||||||
|
return base / windows_leaf
|
||||||
|
base = Path(os.getenv(xdg_name, Path.home() / xdg_fallback)).expanduser()
|
||||||
|
return base / "nexusos"
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_DIR = _user_dir("NEXUS_CONFIG_DIR", "NexusOS", "XDG_CONFIG_HOME", ".config")
|
||||||
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def read_user_config() -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def write_user_config(values: dict[str, Any]) -> None:
|
||||||
|
"""Atomically persist CLI-managed configuration."""
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = CONFIG_FILE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(values, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
tmp.replace(CONFIG_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
USER_CONFIG = read_user_config()
|
||||||
|
|
||||||
|
|
||||||
|
def _value(key: str, env_name: str, default: Any) -> Any:
|
||||||
|
raw = os.getenv(env_name)
|
||||||
|
return raw if raw not in (None, "") else USER_CONFIG.get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _int_value(key: str, env_name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(_value(key, env_name, default))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_path(key: str, env_name: str, default: Path) -> Path:
|
||||||
|
return Path(str(_value(key, env_name, default))).expanduser().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
# --- VERSION (repo file in a checkout; distribution metadata in a wheel) ---
|
||||||
try:
|
try:
|
||||||
VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip() or "0.0.0"
|
if SOURCE_CHECKOUT:
|
||||||
|
VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
||||||
|
else:
|
||||||
|
VERSION = metadata.version("nexusos-ai")
|
||||||
except Exception:
|
except Exception:
|
||||||
VERSION = "0.0.0"
|
try:
|
||||||
|
VERSION = (RESOURCE_ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
||||||
|
except Exception:
|
||||||
|
VERSION = "0.0.0"
|
||||||
|
|
||||||
# --- MODEL DEFAULTS ---
|
# --- MODEL DEFAULTS ---
|
||||||
# Single source of truth for the three models NexusOS ships with. The installers
|
# Single source of truth for the three models NexusOS ships with. The installers
|
||||||
@@ -43,11 +116,24 @@ DEFAULT_MEMORY_MODEL = DEFAULT_CHAT_MODEL
|
|||||||
DEFAULT_EMBED_MODEL = "nomic-embed-text"
|
DEFAULT_EMBED_MODEL = "nomic-embed-text"
|
||||||
|
|
||||||
# --- CORE DIRECTORIES ---
|
# --- CORE DIRECTORIES ---
|
||||||
DATA_DIR = PROJECT_ROOT / "data"
|
_DEFAULT_STATE = PROJECT_ROOT if SOURCE_CHECKOUT else _user_dir(
|
||||||
MODELS_DIR = PROJECT_ROOT / "models"
|
"NEXUS_HOME", "NexusOS", "XDG_DATA_HOME", ".local/share"
|
||||||
RUNTIME_DIR = PROJECT_ROOT / "runtime"
|
)
|
||||||
|
STATE_DIR = _configured_path("home", "NEXUS_HOME", _DEFAULT_STATE)
|
||||||
|
_USE_CHECKOUT_STATE = SOURCE_CHECKOUT and not os.getenv("NEXUS_HOME", "").strip()
|
||||||
|
DATA_DIR = _configured_path("data_dir", "NEXUS_DATA_DIR", (
|
||||||
|
PROJECT_ROOT / "data" if _USE_CHECKOUT_STATE else STATE_DIR / "data"
|
||||||
|
))
|
||||||
|
MODELS_DIR = _configured_path("models_dir", "NEXUS_MODELS_DIR", (
|
||||||
|
PROJECT_ROOT / "models" if _USE_CHECKOUT_STATE else STATE_DIR / "models"
|
||||||
|
))
|
||||||
|
RUNTIME_DIR = _configured_path("runtime_dir", "NEXUS_RUNTIME_DIR", (
|
||||||
|
PROJECT_ROOT / "runtime" if _USE_CHECKOUT_STATE else STATE_DIR / "runtime"
|
||||||
|
))
|
||||||
|
|
||||||
MEMORY_DIR = PROJECT_ROOT / "synapse" / "memory"
|
MEMORY_DIR = _configured_path("memory_dir", "NEXUS_MEMORY_DIR", (
|
||||||
|
PROJECT_ROOT / "synapse" / "memory" if _USE_CHECKOUT_STATE else DATA_DIR
|
||||||
|
))
|
||||||
|
|
||||||
LOGS_DIR = RUNTIME_DIR / "logs"
|
LOGS_DIR = RUNTIME_DIR / "logs"
|
||||||
CACHE_DIR = RUNTIME_DIR / "cache"
|
CACHE_DIR = RUNTIME_DIR / "cache"
|
||||||
@@ -57,9 +143,19 @@ TEMP_DIR = RUNTIME_DIR / "tmp"
|
|||||||
PLAYBOOK_DIR = DATA_DIR / "playbooks" # YAML playbook files (PlaybookFileStore)
|
PLAYBOOK_DIR = DATA_DIR / "playbooks" # YAML playbook files (PlaybookFileStore)
|
||||||
UPLOADS_DIR = DATA_DIR / "uploads"
|
UPLOADS_DIR = DATA_DIR / "uploads"
|
||||||
EXPORTS_DIR = DATA_DIR / "exports"
|
EXPORTS_DIR = DATA_DIR / "exports"
|
||||||
|
WEB_DIST_DIR = (
|
||||||
|
PROJECT_ROOT / "interface" / "web" / "dist"
|
||||||
|
if SOURCE_CHECKOUT else RESOURCE_ROOT / "web"
|
||||||
|
)
|
||||||
|
FRONTEND_SOURCE_DIR = PROJECT_ROOT / "interface" / "web"
|
||||||
|
ASSETS_DIR = PROJECT_ROOT / "assets" if SOURCE_CHECKOUT else RESOURCE_ROOT / "assets"
|
||||||
|
SEED_PLAYBOOK_DIR = (
|
||||||
|
PROJECT_ROOT / "data" / "playbooks"
|
||||||
|
if SOURCE_CHECKOUT else RESOURCE_ROOT / "playbooks"
|
||||||
|
)
|
||||||
|
|
||||||
# --- DATABASE / STORAGE FILES (match your repo) ---
|
# --- DATABASE / STORAGE FILES (match your repo) ---
|
||||||
MEMORY_DB = MEMORY_DIR / "memory.db"
|
MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db")
|
||||||
|
|
||||||
# --- LOG FILES ---
|
# --- LOG FILES ---
|
||||||
BACKEND_LOG = RUNTIME_DIR / "backend.log"
|
BACKEND_LOG = RUNTIME_DIR / "backend.log"
|
||||||
@@ -67,7 +163,8 @@ OLLAMA_LOG = LOGS_DIR / "ollama.log"
|
|||||||
CHAT_LOG = LOGS_DIR / "chat.log"
|
CHAT_LOG = LOGS_DIR / "chat.log"
|
||||||
|
|
||||||
# --- ENSURE REQUIRED DIRECTORIES EXIST ---
|
# --- ENSURE REQUIRED DIRECTORIES EXIST ---
|
||||||
for d in (
|
_REQUIRED_DIRS = (
|
||||||
|
STATE_DIR,
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
MODELS_DIR,
|
MODELS_DIR,
|
||||||
RUNTIME_DIR,
|
RUNTIME_DIR,
|
||||||
@@ -78,8 +175,25 @@ for d in (
|
|||||||
PLAYBOOK_DIR,
|
PLAYBOOK_DIR,
|
||||||
UPLOADS_DIR,
|
UPLOADS_DIR,
|
||||||
EXPORTS_DIR,
|
EXPORTS_DIR,
|
||||||
):
|
MEMORY_DB.parent,
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init_state() -> list[Path]:
|
||||||
|
"""Create writable state and seed playbooks on a first wheel install."""
|
||||||
|
for directory in _REQUIRED_DIRS:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
copied: list[Path] = []
|
||||||
|
if SEED_PLAYBOOK_DIR.resolve() != PLAYBOOK_DIR.resolve() and SEED_PLAYBOOK_DIR.is_dir():
|
||||||
|
for source in SEED_PLAYBOOK_DIR.glob("*.yaml"):
|
||||||
|
target = PLAYBOOK_DIR / source.name
|
||||||
|
if not target.exists():
|
||||||
|
shutil.copy2(source, target)
|
||||||
|
copied.append(target)
|
||||||
|
return copied
|
||||||
|
|
||||||
|
|
||||||
|
INITIALIZED_FILES = init_state()
|
||||||
|
|
||||||
# --- PATH ACCESSOR (fail-fast) ---
|
# --- PATH ACCESSOR (fail-fast) ---
|
||||||
def path(name: str) -> Path:
|
def path(name: str) -> Path:
|
||||||
@@ -88,6 +202,9 @@ def path(name: str) -> Path:
|
|||||||
"""
|
"""
|
||||||
mapping = {
|
mapping = {
|
||||||
"root": PROJECT_ROOT,
|
"root": PROJECT_ROOT,
|
||||||
|
"resources": RESOURCE_ROOT,
|
||||||
|
"state": STATE_DIR,
|
||||||
|
"config": CONFIG_FILE,
|
||||||
"data": DATA_DIR,
|
"data": DATA_DIR,
|
||||||
"models": MODELS_DIR,
|
"models": MODELS_DIR,
|
||||||
"runtime": RUNTIME_DIR,
|
"runtime": RUNTIME_DIR,
|
||||||
@@ -98,6 +215,8 @@ def path(name: str) -> Path:
|
|||||||
"playbooks": PLAYBOOK_DIR,
|
"playbooks": PLAYBOOK_DIR,
|
||||||
"uploads": UPLOADS_DIR,
|
"uploads": UPLOADS_DIR,
|
||||||
"exports": EXPORTS_DIR,
|
"exports": EXPORTS_DIR,
|
||||||
|
"web": WEB_DIST_DIR,
|
||||||
|
"assets": ASSETS_DIR,
|
||||||
"memory_db": MEMORY_DB,
|
"memory_db": MEMORY_DB,
|
||||||
"backend_log": BACKEND_LOG,
|
"backend_log": BACKEND_LOG,
|
||||||
"ollama_log": OLLAMA_LOG,
|
"ollama_log": OLLAMA_LOG,
|
||||||
@@ -146,12 +265,19 @@ class Settings:
|
|||||||
"""
|
"""
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.version: str = VERSION
|
self.version: str = VERSION
|
||||||
|
self.source_checkout: bool = SOURCE_CHECKOUT
|
||||||
self.project_root: Path = PROJECT_ROOT
|
self.project_root: Path = PROJECT_ROOT
|
||||||
|
self.resource_root: Path = RESOURCE_ROOT
|
||||||
|
self.state_dir: Path = STATE_DIR
|
||||||
|
self.config_file: Path = CONFIG_FILE
|
||||||
self.data_dir: Path = DATA_DIR
|
self.data_dir: Path = DATA_DIR
|
||||||
self.models_dir: Path = MODELS_DIR
|
self.models_dir: Path = MODELS_DIR
|
||||||
self.runtime_dir: Path = RUNTIME_DIR
|
self.runtime_dir: Path = RUNTIME_DIR
|
||||||
self.memory_dir: Path = MEMORY_DIR
|
self.memory_dir: Path = MEMORY_DIR
|
||||||
self.logs_dir: Path = LOGS_DIR
|
self.logs_dir: Path = LOGS_DIR
|
||||||
|
self.web_dist_dir: Path = WEB_DIST_DIR
|
||||||
|
self.frontend_source_dir: Path = FRONTEND_SOURCE_DIR
|
||||||
|
self.assets_dir: Path = ASSETS_DIR
|
||||||
|
|
||||||
# DB files
|
# DB files
|
||||||
self.memory_db: Path = MEMORY_DB
|
self.memory_db: Path = MEMORY_DB
|
||||||
@@ -166,23 +292,57 @@ class Settings:
|
|||||||
# should CONNECT. `ollama_bind` keeps the user's literal intent for a
|
# should CONNECT. `ollama_bind` keeps the user's literal intent for a
|
||||||
# serve we spawn (0.0.0.0 to expose it on the LAN); `ollama_host` is the
|
# serve we spawn (0.0.0.0 to expose it on the LAN); `ollama_host` is the
|
||||||
# connectable form for our own requests.
|
# connectable form for our own requests.
|
||||||
self.ollama_bind: str = os.getenv("OLLAMA_HOST", "") or "127.0.0.1:11434"
|
self.provider: str = str(_value("provider", "NEXUS_PROVIDER", "ollama"))
|
||||||
self.ollama_host: str = _normalize_ollama_host(
|
# A Nexus provider setting is more specific than the legacy Ollama bind
|
||||||
os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
# variable. This matters on a desktop that has OLLAMA_HOST globally set
|
||||||
|
# but configures NexusOS to use a different remote inference machine.
|
||||||
|
provider_url = str(_value("provider_url", "NEXUS_PROVIDER_URL", "")).strip()
|
||||||
|
configured_host = (
|
||||||
|
provider_url
|
||||||
|
or os.getenv("OLLAMA_HOST", "").strip()
|
||||||
|
or "http://127.0.0.1:11434"
|
||||||
)
|
)
|
||||||
self.ollama_timeout: int = int(os.getenv("OLLAMA_TIMEOUT", "120"))
|
self.ollama_bind: str = configured_host or "127.0.0.1:11434"
|
||||||
|
self.ollama_host: str = _normalize_ollama_host(
|
||||||
|
configured_host
|
||||||
|
)
|
||||||
|
self.provider_url: str = self.ollama_host
|
||||||
|
self.manage_ollama: bool = self.provider == "ollama"
|
||||||
|
self.ollama_timeout: int = _int_value("provider_timeout", "OLLAMA_TIMEOUT", 120)
|
||||||
|
self.bind_host: str = str(_value(
|
||||||
|
"bind_host", "NEXUS_BIND_HOST", "127.0.0.1"
|
||||||
|
))
|
||||||
|
self.backend_port: int = _int_value("backend_port", "NEXUS_BACKEND_PORT", 8000)
|
||||||
|
self.memory_port: int = _int_value("memory_port", "NEXUS_MEMORY_PORT", 8001)
|
||||||
|
self.api_url: str = str(_value(
|
||||||
|
"api_url", "NEXUS_API", f"http://127.0.0.1:{self.backend_port}"
|
||||||
|
)).rstrip("/")
|
||||||
|
self.memory_url: str = str(_value(
|
||||||
|
"memory_url", "NEXUS_MEMORY_URL", f"http://127.0.0.1:{self.memory_port}"
|
||||||
|
)).rstrip("/")
|
||||||
|
|
||||||
def as_dict(self) -> Dict[str, Any]:
|
def as_dict(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"version": self.version,
|
"version": self.version,
|
||||||
|
"source_checkout": self.source_checkout,
|
||||||
"project_root": str(self.project_root),
|
"project_root": str(self.project_root),
|
||||||
|
"resource_root": str(self.resource_root),
|
||||||
|
"state_dir": str(self.state_dir),
|
||||||
|
"config_file": str(self.config_file),
|
||||||
"data_dir": str(self.data_dir),
|
"data_dir": str(self.data_dir),
|
||||||
"models_dir": str(self.models_dir),
|
"models_dir": str(self.models_dir),
|
||||||
"runtime_dir": str(self.runtime_dir),
|
"runtime_dir": str(self.runtime_dir),
|
||||||
"memory_dir": str(self.memory_dir),
|
"memory_dir": str(self.memory_dir),
|
||||||
"memory_db": str(self.memory_db),
|
"memory_db": str(self.memory_db),
|
||||||
|
"web_dist_dir": str(self.web_dist_dir),
|
||||||
|
"provider": self.provider,
|
||||||
"ollama_host": self.ollama_host,
|
"ollama_host": self.ollama_host,
|
||||||
"ollama_timeout": self.ollama_timeout,
|
"ollama_timeout": self.ollama_timeout,
|
||||||
|
"api_url": self.api_url,
|
||||||
|
"bind_host": self.bind_host,
|
||||||
|
"backend_port": self.backend_port,
|
||||||
|
"memory_port": self.memory_port,
|
||||||
|
"memory_url": self.memory_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- local-access allowlists (shared by the backend + memory FastAPI apps) ---
|
# --- local-access allowlists (shared by the backend + memory FastAPI apps) ---
|
||||||
@@ -203,8 +363,13 @@ _LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1", "testserver"]
|
|||||||
_LOCAL_ORIGINS = [
|
_LOCAL_ORIGINS = [
|
||||||
f"http://{h}:{p}"
|
f"http://{h}:{p}"
|
||||||
for h in ("localhost", "127.0.0.1")
|
for h in ("localhost", "127.0.0.1")
|
||||||
for p in (8000, 5173)
|
for p in (
|
||||||
|
_int_value("backend_port", "NEXUS_BACKEND_PORT", 8000),
|
||||||
|
_int_value("memory_port", "NEXUS_MEMORY_PORT", 8001),
|
||||||
|
5173,
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
_LOCAL_ORIGINS.extend(["capacitor://localhost", "https://localhost"])
|
||||||
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
|
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
|
||||||
ALLOWED_ORIGINS = _csv_env("NEXUS_ALLOWED_ORIGINS", _LOCAL_ORIGINS)
|
ALLOWED_ORIGINS = _csv_env("NEXUS_ALLOWED_ORIGINS", _LOCAL_ORIGINS)
|
||||||
|
|
||||||
@@ -250,9 +415,13 @@ settings = Settings()
|
|||||||
# explicit exports for static checkers and IDEs
|
# explicit exports for static checkers and IDEs
|
||||||
__all__ = ["Settings", "settings", "path", "VERSION",
|
__all__ = ["Settings", "settings", "path", "VERSION",
|
||||||
"DEFAULT_CHAT_MODEL", "DEFAULT_MEMORY_MODEL", "DEFAULT_EMBED_MODEL",
|
"DEFAULT_CHAT_MODEL", "DEFAULT_MEMORY_MODEL", "DEFAULT_EMBED_MODEL",
|
||||||
"PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
"PACKAGE_DIR", "PROJECT_ROOT", "RESOURCE_ROOT", "SOURCE_CHECKOUT",
|
||||||
|
"STATE_DIR", "CONFIG_DIR", "CONFIG_FILE", "USER_CONFIG",
|
||||||
|
"read_user_config", "write_user_config", "init_state", "INITIALIZED_FILES",
|
||||||
|
"DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
||||||
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
|
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
|
||||||
"EXPORTS_DIR", "MEMORY_DB",
|
"EXPORTS_DIR", "MEMORY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
|
||||||
|
"ASSETS_DIR", "SEED_PLAYBOOK_DIR",
|
||||||
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
|
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
|
||||||
"ALLOWED_HOSTS", "ALLOWED_ORIGINS",
|
"ALLOWED_HOSTS", "ALLOWED_ORIGINS",
|
||||||
"MAX_REQUEST_BYTES", "MAX_UPLOAD_BYTES", "MAX_PDF_PAGES",
|
"MAX_REQUEST_BYTES", "MAX_UPLOAD_BYTES", "MAX_PDF_PAGES",
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ _log = logging.getLogger("nexus.ollama")
|
|||||||
_ollama_manager = None
|
_ollama_manager = None
|
||||||
|
|
||||||
# Bundled binary ships alongside the project; fall back to system PATH
|
# Bundled binary ships alongside the project; fall back to system PATH
|
||||||
_BUNDLED_OLLAMA = Path(__file__).resolve().parent.parent / "ollama" / "bin" / "ollama"
|
_BUNDLED_OLLAMA = settings.project_root / "ollama" / "bin" / (
|
||||||
|
"ollama.exe" if os.name == "nt" else "ollama"
|
||||||
|
)
|
||||||
|
|
||||||
# POSIX: detach the child into its own session so we can signal the whole group.
|
# POSIX: detach the child into its own session so we can signal the whole group.
|
||||||
# Windows has no setsid/killpg — run the child normally and terminate() it.
|
# Windows has no setsid/killpg — run the child normally and terminate() it.
|
||||||
@@ -340,7 +342,7 @@ class OllamaManager:
|
|||||||
self.running = False
|
self.running = False
|
||||||
self._available = None # see is_available()
|
self._available = None # see is_available()
|
||||||
|
|
||||||
self.runtime_dir = Path(runtime_dir) if runtime_dir else Path(__file__).resolve().parent.parent / "runtime"
|
self.runtime_dir = Path(runtime_dir) if runtime_dir else settings.runtime_dir
|
||||||
(self.runtime_dir / "logs").mkdir(parents=True, exist_ok=True)
|
(self.runtime_dir / "logs").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
self.log_file = self.runtime_dir / "logs" / "ollama.log"
|
self.log_file = self.runtime_dir / "logs" / "ollama.log"
|
||||||
@@ -406,6 +408,10 @@ class OllamaManager:
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
def is_available(self):
|
def is_available(self):
|
||||||
|
if not settings.manage_ollama:
|
||||||
|
# A remote provider has no local executable to discover. Availability
|
||||||
|
# means it is configured; is_running() performs the live probe.
|
||||||
|
return True
|
||||||
# ponytail: cached for the life of the process. This spawns a subprocess,
|
# ponytail: cached for the life of the process. This spawns a subprocess,
|
||||||
# and /status calls it on every poll - the frontend polls continuously,
|
# and /status calls it on every poll - the frontend polls continuously,
|
||||||
# so it was a process spawn per tick to answer a question whose answer
|
# so it was a process spawn per tick to answer a question whose answer
|
||||||
@@ -433,6 +439,9 @@ class OllamaManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
if not settings.manage_ollama:
|
||||||
|
_log.info("Remote Ollama is externally managed at %s", self._api_base)
|
||||||
|
return self.is_running()
|
||||||
if not self.is_available():
|
if not self.is_available():
|
||||||
_log.warning("Ollama not found at %s; skipping startup", _ollama_bin())
|
_log.warning("Ollama not found at %s; skipping startup", _ollama_bin())
|
||||||
return False
|
return False
|
||||||
@@ -474,6 +483,9 @@ class OllamaManager:
|
|||||||
|
|
||||||
async def start_async(self):
|
async def start_async(self):
|
||||||
"""Async-safe version of start() for use inside async startup handlers."""
|
"""Async-safe version of start() for use inside async startup handlers."""
|
||||||
|
if not settings.manage_ollama:
|
||||||
|
_log.info("Remote Ollama is externally managed at %s", self._api_base)
|
||||||
|
return self.is_running()
|
||||||
if not self.is_available():
|
if not self.is_available():
|
||||||
_log.warning("Ollama not found at %s; skipping startup", _ollama_bin())
|
_log.warning("Ollama not found at %s; skipping startup", _ollama_bin())
|
||||||
return False
|
return False
|
||||||
@@ -514,6 +526,9 @@ class OllamaManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
if not settings.manage_ollama:
|
||||||
|
_log.info("Not stopping externally managed Ollama at %s", self._api_base)
|
||||||
|
return False
|
||||||
# Terminate a server we spawned ourselves.
|
# Terminate a server we spawned ourselves.
|
||||||
if self.process:
|
if self.process:
|
||||||
try:
|
try:
|
||||||
@@ -902,4 +917,4 @@ def shutdown_ollama() -> None:
|
|||||||
global _ollama_manager
|
global _ollama_manager
|
||||||
if _ollama_manager is not None:
|
if _ollama_manager is not None:
|
||||||
_ollama_manager.stop()
|
_ollama_manager.stop()
|
||||||
_ollama_manager = None
|
_ollama_manager = None
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def run_cli(tmp_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
for key in tuple(env):
|
||||||
|
if key.startswith("NEXUS_"):
|
||||||
|
env.pop(key)
|
||||||
|
env["NEXUS_HOME"] = str(tmp_path / "state")
|
||||||
|
env["NEXUS_CONFIG_DIR"] = str(tmp_path / "config")
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-m", "management.cli", *args],
|
||||||
|
cwd=ROOT,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_exposes_portable_command_tree(tmp_path):
|
||||||
|
result = run_cli(tmp_path, "--help")
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat"):
|
||||||
|
assert command in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_cli_spellings_remain_compatible():
|
||||||
|
from management.cli import _normalize_legacy_argv
|
||||||
|
|
||||||
|
assert _normalize_legacy_argv(["start", "-b"]) == ["start", "backend"]
|
||||||
|
assert _normalize_legacy_argv(["stop", "--ai"]) == ["stop", "ai"]
|
||||||
|
assert _normalize_legacy_argv(["logs", "-m", "--follow"]) == ["logs", "memory", "--follow"]
|
||||||
|
assert _normalize_legacy_argv(["backup", "full"]) == ["backup", "--full"]
|
||||||
|
assert _normalize_legacy_argv(["restore", "-f"]) == ["restore"]
|
||||||
|
assert _normalize_legacy_argv(["help"]) == ["--help"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_uses_external_state_and_seeds_playbooks(tmp_path):
|
||||||
|
result = run_cli(tmp_path, "init", "--json")
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
assert Path(payload["state_dir"]) == (tmp_path / "state").resolve()
|
||||||
|
assert payload["seeded_playbooks"] == len(list((ROOT / "data" / "playbooks").glob("*.yaml")))
|
||||||
|
assert len(list((tmp_path / "state" / "data" / "playbooks").glob("*.yaml"))) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_persists_validated_values(tmp_path):
|
||||||
|
set_result = run_cli(tmp_path, "config", "set", "backend_port", "8123", "--json")
|
||||||
|
assert set_result.returncode == 0, set_result.stderr
|
||||||
|
|
||||||
|
get_result = run_cli(tmp_path, "config", "get", "backend_port", "--json")
|
||||||
|
assert get_result.returncode == 0, get_result.stderr
|
||||||
|
assert json.loads(get_result.stdout)["backend_port"] == 8123
|
||||||
|
|
||||||
|
invalid = run_cli(tmp_path, "config", "set", "backend_port", "70000")
|
||||||
|
assert invalid.returncode == 2
|
||||||
|
assert "between 1 and 65535" in invalid.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_provider_configuration_is_explicit(tmp_path):
|
||||||
|
result = run_cli(
|
||||||
|
tmp_path, "provider", "use", "remote", "--url", "http://phone-lan:11434", "--json"
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
assert payload["provider"] == "ollama-remote"
|
||||||
|
assert payload["url"] == "http://phone-lan:11434"
|
||||||
|
|
||||||
|
values = json.loads((tmp_path / "config" / "config.json").read_text(encoding="utf-8"))
|
||||||
|
assert values["provider"] == "ollama-remote"
|
||||||
|
|
||||||
|
show = run_cli(tmp_path, "provider", "show", "--json")
|
||||||
|
assert show.returncode == 0, show.stderr
|
||||||
|
assert json.loads(show.stdout)["url"] == "http://phone-lan:11434"
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_rejects_invalid_ports_before_startup(tmp_path):
|
||||||
|
result = run_cli(tmp_path, "serve", "--port", "0")
|
||||||
|
assert result.returncode == 2
|
||||||
|
assert "between 1 and 65535" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_provider_is_never_stopped_or_force_killed(monkeypatch):
|
||||||
|
from management import ncp
|
||||||
|
|
||||||
|
killed_ports = []
|
||||||
|
killed_patterns = []
|
||||||
|
monkeypatch.setattr(ncp.settings, "manage_ollama", False)
|
||||||
|
monkeypatch.setattr(ncp.settings, "ollama_host", "http://remote.test:11434")
|
||||||
|
monkeypatch.setattr(ncp, "kill_port", lambda port: killed_ports.append(port) or False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ncp, "kill_matching", lambda patterns, force=False: killed_patterns.extend(patterns) or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
ncp.stop_ollama()
|
||||||
|
ncp.cmd_kill()
|
||||||
|
|
||||||
|
assert 11434 not in killed_ports
|
||||||
|
assert "ollama serve" not in killed_patterns
|
||||||
Reference in New Issue
Block a user