The CLI shipped from `management/`, which also holds desktop-only pieces (the Tk control panel, the XFCE panel wiring, the shell wrappers). Packaging that directory meant the wheel either dragged in tkinter or shipped a broken import. Split it: `nexusos_cli/` is what the wheel ships and what `nexus`/`ncp`/ `nexusos` dispatch to, `management/` keeps the desktop half. Alongside the move: * hatch_build.py decides the interface/web/dist include at build time. dist/ is gitignored, so a static force-include aborts `pip install -e .` on a fresh clone - before the reader reaches the `npm run build` step. Editable installs now skip a missing dist; wheels and sdists hard-error naming the command to run. * synapse/proc_util.py gives frontend_manager and ncp process inspection and termination without psutil, which became an optional extra when the wheel landed. It routes around Windows having no signals, where os.kill(pid, 15) is an unblockable TerminateProcess rather than a polite request. * nexusos_cli/monitor.py adds `ncp monitor`, an ASCII dashboard with no curses or rich dependency so it works in Termux, plain SSH and Windows Terminal. Collector and renderer are separate so tests feed fixtures, no stack needed. * tests/test_packaging_deps.py fails the gate when synapse or nexusos_cli import a distribution pyproject does not declare, and when an optional dependency is imported at module scope instead of lazily. * bin/check.sh now builds the wheel, twine-checks it, and asserts the compiled UI and seed playbooks are actually inside it. A wheel that builds but ships no dist/ serves a blank page, which only shows up after release. tests/test_nexus_api.py moves to tests/ with the module it covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
440 lines
17 KiB
Python
440 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.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", "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)}")
|