Files
NexusOS/synapse/nexus_config.py
T
Athena 26b471d259 Merge origin/main (v1.2.0: Projects, modules, in-app updates)
Reconciles 17 commits of this session's work (self-alteration tools,
vendored Curry, slash-command dispatch, Windows toolchain/gate fixes)
against origin/main's v1.2.0 sync (Projects/RAG scoping, a new modules/
system for mail and network, in-app updates, the standalone memory
microservice folded into an in-process curator, KDE desktop theme
overhaul). Nine real conflicts, each resolved by hand after reading both
sides' actual diffs rather than picking one side wholesale:

- synapse/tools.py, tests/test_tools.py: origin/main's diff here was
  small and clean (read_file/list_files, two new tests) despite git's
  diff3 flagging the whole file as one conflict blob -- reset to this
  branch's version and hand-spliced their addition in at the same
  points they used, rather than trying to reconcile a false 800-line
  conflict. Found and fixed a real bug while verifying: _list_files
  returned backslash-separated paths on Windows, which don't match the
  forward-slash glob patterns the tool's own schema documents.
- synapse/main.py: kept this branch's cue-based standing advertisement
  of render_preview/run_snippet (independent of any playbook granting
  them) AND adopted origin/main's fix for routed reference playbooks
  not bringing their own tools along -- dropping either would have been
  a real regression, not just a style difference. Also: the standalone
  memory service (port 8001) is gone upstream, so its dead CORS/kill-
  target entries were removed; NEXUS_BACKEND_PORT parameterization and
  the manage_ollama-conditional kill logic (this branch's remote-Ollama
  support) were kept over origin/main's hardcoded equivalents.
- synapse/memory/store.py: kept this branch's _delete_message_vectors
  helper (already reused elsewhere, batches to stay under SQLite's
  variable limit) over origin/main's inline duplicate of the same fix.
- synapse/nexus_config.py, nexusos_cli/ncp.py: dropped the now-dead
  memory-service port/service entries; kept NEXUS_BACKEND_PORT env
  override and the manage_ollama-conditional kill-target list.
- CLAUDE.md, README.md: merged both sides' additions, no real conflict.

Found and fixed three more issues while independently verifying the
merged tree, none of them mine or origin/main's alone -- only visible
once both sides actually ran together:

- modules/ (the new mail+network package) was never added to
  pyproject.toml's wheel `packages` list OR the sdist's `include`
  allowlist, so `from modules.registry import ROUTERS` in main.py would
  ImportError on any wheel install. Fixed both; bin/check.sh's
  packaging gate now asserts modules/ actually ships. tests/
  test_packaging_deps.py's FIRST_PARTY/SHIPPED_PACKAGES sets were
  updated to recognize the new package.
- tests/test_mail_creds.py's 0600-mode assertions are POSIX-only --
  NTFS has no equivalent permission bits, so os.open(path, 0o600) on
  Windows just creates a normal file and stat.S_IMODE reports 0o666
  regardless. Made the assertions platform-aware rather than skip real
  coverage (the temp-file-cleanup and password round-trip checks in the
  same test still run on Windows) or paper over a genuine OS
  limitation with a fake pass.
- tests/test_kde_theme.py used bare Path.read_text() in fifteen places;
  Windows' default locale encoding (cp1252, not UTF-8) can't decode a
  real UTF-8 byte in the QML it reads, and did fail on one of the
  fifteen. Fixed all fifteen, not just the one that happened to trip
  today, since the other fourteen were equally fragile.

Verified: full bin/check.sh reports OK end-to-end on this Windows
checkout -- pytest (tests + management): 295 passed, 0 failed, 9
skipped; eslint clean; frontend node:test 57/57; PowerShell/shell
parse clean; wheel + sdist pass twine check and now correctly carry
modules/ (60 files, up from 52 pre-merge). synapse.main:app builds
with 74 routes (up from 54 pre-merge, matching the new Projects/mail/
network endpoints).
2026-08-26 02:09:23 -05:00

442 lines
18 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")
# Vendored Curry (synapse/curry_core.py) database: immutable versioned
# constants/functions/models + inference provenance. Separate file from
# MEMORY_DB on purpose - Curry's schema and lifecycle are independent of the
# memory/conversation store.
CURRY_DB = _configured_path("curry_db", "NEXUS_CURRY_DB", DATA_DIR / "curry.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,
CURRY_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.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]:
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,
"memory_port": self.memory_port,
"memory_url": self.memory_url,
}
# --- 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", "CURRY_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)}")