# 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 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), _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_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)}")