Files
NexusOS/synapse/nexus_config.py
Jon Wingender a0f033142f fix(packaging): remove the memory-service integration this CLI reintroduced
synapse/memory/service.py was deleted on 2026-08-25 when memory curation
moved in-process (curator.py) - there is no longer a second FastAPI app
to run on :8001. This CLI was evidently built against a pre-curator
baseline: `nexus serve` spawned `synapse.memory.service:app` (fails
with ModuleNotFoundError, logged only to memory.log where nobody would
see it), `nexus start memory`/`stop memory` had no handler at all
(silently fell through to show_help()), and doctor/status/monitor all
carried a "memory service" row that could never be anything but down.

Removed rather than repaired, since there's nothing to repair: the
service, its SERVICES entry, --memory-port/--no-memory, the -m/--memory
target everywhere it was offered (start/stop/logs/LEGACY_TARGETS), and
the memory_port/memory_url settings this PR had added. The `nexus
memory list|add|rm` data commands (nexus_api.py, hitting the backend's
own /memory REST endpoint) are untouched - unrelated, and still work.
2026-08-26 13:40:50 -05:00

433 lines
17 KiB
Python

# config.py
from __future__ import annotations
import json
import os
import shutil
from importlib import metadata
from pathlib import Path
from typing import Dict, Any
# --- ENV ---
try:
from dotenv import load_dotenv # optional
load_dotenv()
except Exception:
pass
# --- INSTALL / RESOURCE LAYOUT ---
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()
)
# 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:
# os.getenv's default only applies when a variable is *unset*. An exported
# but empty XDG_DATA_HOME / LOCALAPPDATA would otherwise give Path("") ==
# ".", scattering state through whatever the cwd happened to be. The XDG
# spec says to treat an empty value as unset, so `or` - not a default arg.
override = os.getenv(env_name, "").strip()
if override:
return Path(override).expanduser().resolve()
if os.name == "nt":
base = Path(os.getenv("LOCALAPPDATA", "").strip() or Path.home() / "AppData" / "Local")
return (base / windows_leaf).expanduser().resolve()
base = Path(os.getenv(xdg_name, "").strip() or Path.home() / xdg_fallback).expanduser()
return (base / "nexusos").resolve()
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:
if SOURCE_CHECKOUT:
VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip()
else:
VERSION = metadata.version("nexusos-ai")
except Exception:
try:
VERSION = (RESOURCE_ROOT / "VERSION").read_text(encoding="utf-8").strip()
except Exception:
VERSION = "0.0.0"
# --- MODEL DEFAULTS ---
# Single source of truth for the three models NexusOS ships with. The installers
# pull and pin these, the runtime falls back to them; keeping them in one place
# is what stops installer and backend from drifting apart.
#
# Chat: llama3.1:8b - a strong non-reasoning instruct model (~4.9 GB; overflows a
# 4 GB GPU into CPU/RAM). Chosen over Qwen3 because Qwen3 is a reasoning model:
# smart only with its slow <think> step, weak without it.
# Memory: the curator that extracts facts and titles conversations. It is the
# CHAT model on purpose, not a second one: the chat model is already resident in
# VRAM and warm, so extraction costs no extra load. A distinct curator (mistral)
# did not fit alongside it and had to be pinned to CPU (num_gpu=0), which made
# every extraction a slow prompt-eval on a model too small to follow the
# curator prompt's negative rules reliably.
# Embed: nomic-embed-text - powers semantic recall of past conversations
# (OllamaManager.embed / store.semantic_search_conversations). Without it,
# recall silently degrades to lexical substring matching.
DEFAULT_CHAT_MODEL = "llama3.1:8b"
DEFAULT_MEMORY_MODEL = DEFAULT_CHAT_MODEL
DEFAULT_EMBED_MODEL = "nomic-embed-text"
# --- CORE DIRECTORIES ---
_DEFAULT_STATE = PROJECT_ROOT if SOURCE_CHECKOUT else _user_dir(
"NEXUS_HOME", "NexusOS", "XDG_DATA_HOME", ".local/share"
)
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 = _configured_path("memory_dir", "NEXUS_MEMORY_DIR", (
PROJECT_ROOT / "synapse" / "memory" if _USE_CHECKOUT_STATE else DATA_DIR
))
LOGS_DIR = RUNTIME_DIR / "logs"
CACHE_DIR = RUNTIME_DIR / "cache"
TEMP_DIR = RUNTIME_DIR / "tmp"
# --- APPLICATION SUBSYSTEM DIRECTORIES ---
PLAYBOOK_DIR = DATA_DIR / "playbooks" # YAML playbook files (PlaybookFileStore)
UPLOADS_DIR = DATA_DIR / "uploads"
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) ---
MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db")
# --- LOG FILES ---
BACKEND_LOG = RUNTIME_DIR / "backend.log"
OLLAMA_LOG = LOGS_DIR / "ollama.log"
CHAT_LOG = LOGS_DIR / "chat.log"
# --- ENSURE REQUIRED DIRECTORIES EXIST ---
_REQUIRED_DIRS = (
STATE_DIR,
DATA_DIR,
MODELS_DIR,
RUNTIME_DIR,
LOGS_DIR,
MEMORY_DIR,
CACHE_DIR,
TEMP_DIR,
PLAYBOOK_DIR,
UPLOADS_DIR,
EXPORTS_DIR,
MEMORY_DB.parent,
)
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) ---
def path(name: str) -> Path:
"""
Return a Path for a known name. Raises KeyError if name is unknown.
"""
mapping = {
"root": PROJECT_ROOT,
"resources": RESOURCE_ROOT,
"state": STATE_DIR,
"config": CONFIG_FILE,
"data": DATA_DIR,
"models": MODELS_DIR,
"runtime": RUNTIME_DIR,
"logs": LOGS_DIR,
"memory": MEMORY_DIR,
"cache": CACHE_DIR,
"tmp": TEMP_DIR,
"playbooks": PLAYBOOK_DIR,
"uploads": UPLOADS_DIR,
"exports": EXPORTS_DIR,
"web": WEB_DIST_DIR,
"assets": ASSETS_DIR,
"memory_db": MEMORY_DB,
"backend_log": BACKEND_LOG,
"ollama_log": OLLAMA_LOG,
"chat_log": CHAT_LOG,
}
try:
return mapping[name]
except KeyError:
raise KeyError(f"Unknown config path name: {name}")
# --- Settings class and exported instance ---
def _normalize_ollama_host(raw: str) -> str:
"""Turn an OLLAMA_HOST value into a URL a client can actually connect to.
OLLAMA_HOST is Ollama's *server bind* variable, and the common way to expose
Ollama on a LAN is `OLLAMA_HOST=0.0.0.0:11434`. Taken literally as a client
base URL that is unusable twice over: 0.0.0.0 means "every local interface"
to a listener but is not a destination, and there is no scheme for httpx to
parse. The result was a silent empty model list, because list_models()
catches everything and returns [].
So: supply the scheme when it's missing, and rewrite wildcard binds to
loopback. An explicit host is left alone — someone pointing at a real remote
Ollama means it.
"""
host = (raw or "").strip().rstrip("/")
if not host:
return "http://127.0.0.1:11434"
if "://" not in host:
host = f"http://{host}"
scheme, _, rest = host.partition("://")
hostport = rest.split("/", 1)[0]
name, sep, port = hostport.rpartition(":")
if not sep: # no port given
name, port = hostport, ""
# 0.0.0.0 and :: are bind-any; from a client they mean "this machine".
if name.strip("[]") in ("0.0.0.0", "::", ""):
name = "127.0.0.1"
return f"{scheme}://{name}:{port}" if port else f"{scheme}://{name}"
class Settings:
"""
Lightweight settings container. Use `settings` instance for runtime access,
or `Settings` class for typing/tests.
"""
def __init__(self) -> None:
self.version: str = VERSION
self.source_checkout: bool = SOURCE_CHECKOUT
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.models_dir: Path = MODELS_DIR
self.runtime_dir: Path = RUNTIME_DIR
self.memory_dir: Path = MEMORY_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
self.memory_db: Path = MEMORY_DB
# Logs
self.backend_log: Path = BACKEND_LOG
self.ollama_log: Path = OLLAMA_LOG
self.chat_log: Path = CHAT_LOG
# Env overrides. Two values from one variable, because OLLAMA_HOST means
# two different things: where a server should LISTEN, and where a client
# 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
# connectable form for our own requests.
self.provider: str = str(_value("provider", "NEXUS_PROVIDER", "ollama"))
# A Nexus provider setting is more specific than the legacy Ollama bind
# 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_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.api_url: str = str(_value(
"api_url", "NEXUS_API", f"http://127.0.0.1:{self.backend_port}"
)).rstrip("/")
def as_dict(self) -> Dict[str, Any]:
return {
"version": self.version,
"source_checkout": self.source_checkout,
"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),
"models_dir": str(self.models_dir),
"runtime_dir": str(self.runtime_dir),
"memory_dir": str(self.memory_dir),
"memory_db": str(self.memory_db),
"web_dist_dir": str(self.web_dist_dir),
"provider": self.provider,
"ollama_host": self.ollama_host,
"ollama_timeout": self.ollama_timeout,
"api_url": self.api_url,
"bind_host": self.bind_host,
"backend_port": self.backend_port,
}
# --- local-access allowlists (shared by the backend + memory FastAPI apps) ---
# The REST APIs are unauthenticated, so they are meant to be reached only from
# this machine. Two independent browser-side defenses depend on these lists:
# * ALLOWED_ORIGINS drives CORS — blocks a malicious page from *reading*
# responses cross-origin (was previously "*", which let any site read them).
# * ALLOWED_HOSTS drives TrustedHostMiddleware — rejects a foreign Host header,
# which is what stops DNS-rebinding (same-origin from the browser's view, so
# CORS can't help there).
# Both accept a comma-separated env override for the intentional-LAN case, to be
# paired with real auth. NEXUS_ALLOWED_HOSTS=* disables the Host check.
def _csv_env(name: str, default: list) -> list:
raw = os.getenv(name, "").strip()
return [p.strip() for p in raw.split(",") if p.strip()] if raw else list(default)
_LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1", "testserver"]
_LOCAL_ORIGINS = [
f"http://{h}:{p}"
for h in ("localhost", "127.0.0.1")
for p in (
_int_value("backend_port", "NEXUS_BACKEND_PORT", 8000),
5173,
)
]
_LOCAL_ORIGINS.extend(["capacitor://localhost", "https://localhost"])
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
ALLOWED_ORIGINS = _csv_env("NEXUS_ALLOWED_ORIGINS", _LOCAL_ORIGINS)
# --- resource limits (DoS guardrails for the unauthenticated local APIs) ---
# Even local-only, an unbounded base64 upload or a flood of concurrent inference
# requests can exhaust RAM/CPU. These are generous defaults for single-user use,
# all env-overridable.
def _int_env(name: str, default: int) -> int:
try:
return int((os.getenv(name) or "").strip() or default)
except ValueError:
return default
MAX_REQUEST_BYTES = _int_env("NEXUS_MAX_REQUEST_MB", 32) * 1024 * 1024
MAX_UPLOAD_BYTES = _int_env("NEXUS_MAX_UPLOAD_MB", 20) * 1024 * 1024
MAX_PDF_PAGES = _int_env("NEXUS_MAX_PDF_PAGES", 500)
MAX_CONCURRENT_CHATS = _int_env("NEXUS_MAX_CONCURRENT_CHATS", 4)
MAX_CONCURRENT_UPLOADS = _int_env("NEXUS_MAX_CONCURRENT_UPLOADS", 2)
# Opt-in allowlist for /models/pull. Empty (default) = unrestricted, preserving
# current behaviour. Set NEXUS_MODEL_ALLOWLIST=mistral,llama3 to bound which
# models can be downloaded; a bare repo name (before the ':tag') matches all of
# its tags, so "mistral" permits "mistral:latest", "mistral:7b", etc.
MODEL_ALLOWLIST = _csv_env("NEXUS_MODEL_ALLOWLIST", [])
def model_pull_allowed(name: str) -> bool:
"""True if `name` may be pulled: always when no allowlist is configured,
otherwise when the full name or its repo part (before the first ':') is
listed. Case-insensitive."""
if not MODEL_ALLOWLIST:
return True
n = (name or "").strip().lower()
if not n:
return False
allow = {a.lower() for a in MODEL_ALLOWLIST}
return n in allow or n.split(":", 1)[0] in allow
# exported instance
settings = Settings()
# explicit exports for static checkers and IDEs
__all__ = ["Settings", "settings", "path", "VERSION",
"DEFAULT_CHAT_MODEL", "DEFAULT_MEMORY_MODEL", "DEFAULT_EMBED_MODEL",
"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",
"EXPORTS_DIR", "MEMORY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
"ASSETS_DIR", "SEED_PLAYBOOK_DIR",
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
"ALLOWED_HOSTS", "ALLOWED_ORIGINS",
"MAX_REQUEST_BYTES", "MAX_UPLOAD_BYTES", "MAX_PDF_PAGES",
"MAX_CONCURRENT_CHATS", "MAX_CONCURRENT_UPLOADS",
"MODEL_ALLOWLIST", "model_pull_allowed"]
# --- quick runtime sanity check when run directly (no side effects on import) ---
if __name__ == "__main__":
print("Config paths:")
for key in ("root", "data", "models", "runtime", "memory", "memory_db"):
print(f" {key}: {path(key)}")