Files
webui/api/config.py
Nathan Esquenazi 7ddd896b36 feat: add GLM-5.1 to Z.AI model list
Adds the newly available GLM-5.1 model to the hardcoded Z.AI provider
model list so it appears in the model dropdown. Fixes #17.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:02:10 -07:00

641 lines
26 KiB
Python

"""
Hermes Web UI -- Shared configuration, constants, and global state.
Imported by all other api/* modules and by server.py.
Discovery order for all paths:
1. Explicit environment variable
2. Filesystem heuristics (sibling checkout, parent dir, common install locations)
3. Hardened defaults relative to $HOME
4. Fail loudly with a human-readable fix-it message if required modules are missing
"""
import collections
import json
import os
import sys
import threading
import time
import traceback
import uuid
from pathlib import Path
from urllib.parse import parse_qs, urlparse
# ── Basic layout ──────────────────────────────────────────────────────────────
HOME = Path.home()
# REPO_ROOT is the directory that contains this file's parent (api/ -> repo root)
REPO_ROOT = Path(__file__).parent.parent.resolve()
# ── Network config (env-overridable) ─────────────────────────────────────────
HOST = os.getenv('HERMES_WEBUI_HOST', '127.0.0.1')
PORT = int(os.getenv('HERMES_WEBUI_PORT', '8787'))
# ── State directory (env-overridable, never inside repo) ──────────────────────
STATE_DIR = Path(os.getenv(
'HERMES_WEBUI_STATE_DIR',
str(HOME / '.hermes' / 'webui-mvp')
)).expanduser().resolve()
SESSION_DIR = STATE_DIR / 'sessions'
WORKSPACES_FILE = STATE_DIR / 'workspaces.json'
SESSION_INDEX_FILE = SESSION_DIR / '_index.json'
SETTINGS_FILE = STATE_DIR / 'settings.json'
LAST_WORKSPACE_FILE = STATE_DIR / 'last_workspace.txt'
PROJECTS_FILE = STATE_DIR / 'projects.json'
# ── Hermes agent directory discovery ─────────────────────────────────────────
def _discover_agent_dir() -> Path:
"""
Locate the hermes-agent checkout using a multi-strategy search.
Priority:
1. HERMES_WEBUI_AGENT_DIR env var -- explicit override always wins
2. HERMES_HOME / hermes-agent -- e.g. ~/.hermes/hermes-agent
3. Sibling of this repo -- ../hermes-agent
4. Parent of this repo -- ../../hermes-agent (nested layout)
5. Common install paths -- ~/.hermes/hermes-agent (again as fallback)
6. HOME / hermes-agent -- ~/hermes-agent (simple flat layout)
"""
candidates = []
# 1. Explicit env var
if os.getenv('HERMES_WEBUI_AGENT_DIR'):
candidates.append(Path(os.getenv('HERMES_WEBUI_AGENT_DIR')).expanduser().resolve())
# 2. HERMES_HOME / hermes-agent
hermes_home = os.getenv('HERMES_HOME', str(HOME / '.hermes'))
candidates.append(Path(hermes_home).expanduser() / 'hermes-agent')
# 3. Sibling: <repo-root>/../hermes-agent
candidates.append(REPO_ROOT.parent / 'hermes-agent')
# 4. Parent is the agent repo itself (repo cloned inside hermes-agent/)
if (REPO_ROOT.parent / 'run_agent.py').exists():
candidates.append(REPO_ROOT.parent)
# 5. ~/.hermes/hermes-agent (explicit common path)
candidates.append(HOME / '.hermes' / 'hermes-agent')
# 6. ~/hermes-agent
candidates.append(HOME / 'hermes-agent')
for path in candidates:
if path.exists() and (path / 'run_agent.py').exists():
return path.resolve()
return None
def _discover_python(agent_dir: Path) -> str:
"""
Locate a Python executable that has the Hermes agent dependencies installed.
Priority:
1. HERMES_WEBUI_PYTHON env var
2. Agent venv at <agent_dir>/venv/bin/python
3. Local .venv inside this repo
4. System python3
"""
if os.getenv('HERMES_WEBUI_PYTHON'):
return os.getenv('HERMES_WEBUI_PYTHON')
if agent_dir:
venv_py = agent_dir / 'venv' / 'bin' / 'python'
if venv_py.exists():
return str(venv_py)
# Windows layout
venv_py_win = agent_dir / 'venv' / 'Scripts' / 'python.exe'
if venv_py_win.exists():
return str(venv_py_win)
# Local .venv inside this repo
local_venv = REPO_ROOT / '.venv' / 'bin' / 'python'
if local_venv.exists():
return str(local_venv)
# Fall back to system python3
import shutil
for name in ('python3', 'python'):
found = shutil.which(name)
if found:
return found
return 'python3'
# Run discovery
_AGENT_DIR = _discover_agent_dir()
PYTHON_EXE = _discover_python(_AGENT_DIR)
# ── Inject agent dir into sys.path so Hermes modules are importable ───────────
if _AGENT_DIR is not None:
if str(_AGENT_DIR) not in sys.path:
sys.path.insert(0, str(_AGENT_DIR))
_HERMES_FOUND = True
else:
_HERMES_FOUND = False
# ── Config file (optional YAML) ──────────────────────────────────────────────
CONFIG_PATH = Path(os.getenv(
'HERMES_CONFIG_PATH',
str(HOME / '.hermes' / 'config.yaml')
)).expanduser()
try:
import yaml as _yaml
cfg = _yaml.safe_load(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
except Exception:
cfg = {}
# ── Default workspace discovery ───────────────────────────────────────────────
def _discover_default_workspace() -> Path:
"""
Resolve the default workspace in order:
1. HERMES_WEBUI_DEFAULT_WORKSPACE env var
2. ~/workspace (common Hermes convention)
3. STATE_DIR / workspace (isolated fallback)
"""
if os.getenv('HERMES_WEBUI_DEFAULT_WORKSPACE'):
return Path(os.getenv('HERMES_WEBUI_DEFAULT_WORKSPACE')).expanduser().resolve()
common = HOME / 'workspace'
if common.exists():
return common.resolve()
return (STATE_DIR / 'workspace').resolve()
DEFAULT_WORKSPACE = _discover_default_workspace()
DEFAULT_MODEL = os.getenv('HERMES_WEBUI_DEFAULT_MODEL', 'openai/gpt-5.4-mini')
# ── Startup diagnostics ───────────────────────────────────────────────────────
def print_startup_config():
"""Print detected configuration at startup so the user can verify what was found."""
ok = '\033[32m[ok]\033[0m'
warn = '\033[33m[!!]\033[0m'
err = '\033[31m[XX]\033[0m'
lines = [
'',
' Hermes Web UI -- startup config',
' --------------------------------',
f' repo root : {REPO_ROOT}',
f' agent dir : {_AGENT_DIR if _AGENT_DIR else "NOT FOUND"} {ok if _AGENT_DIR else err}',
f' python : {PYTHON_EXE}',
f' state dir : {STATE_DIR}',
f' workspace : {DEFAULT_WORKSPACE}',
f' host:port : {HOST}:{PORT}',
f' config file : {CONFIG_PATH} {"(found)" if CONFIG_PATH.exists() else "(not found, using defaults)"}',
'',
]
print('\n'.join(lines), flush=True)
if not _HERMES_FOUND:
print(
f'{err} Could not find the Hermes agent directory.\n'
' The server will start but agent features will not work.\n'
'\n'
' To fix, set one of:\n'
' export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent\n'
' export HERMES_HOME=/path/to/.hermes\n'
'\n'
' Or clone hermes-agent as a sibling of this repo:\n'
' git clone <hermes-agent-repo> ../hermes-agent\n',
flush=True
)
def verify_hermes_imports():
"""
Attempt to import the key Hermes modules.
Returns (ok: bool, missing: list[str]).
"""
required = ['run_agent']
missing = []
for mod in required:
try:
__import__(mod)
except ImportError:
missing.append(mod)
return (len(missing) == 0), missing
# ── Limits ───────────────────────────────────────────────────────────────────
MAX_FILE_BYTES = 200_000
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
# ── File type maps ───────────────────────────────────────────────────────────
IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.bmp'}
MD_EXTS = {'.md', '.markdown', '.mdown'}
CODE_EXTS = {'.py', '.js', '.ts', '.jsx', '.tsx', '.css', '.html', '.json',
'.yaml', '.yml', '.toml', '.sh', '.bash', '.txt', '.log', '.env',
'.csv', '.xml', '.sql', '.rs', '.go', '.java', '.c', '.cpp', '.h'}
MIME_MAP = {
'.png':'image/png', '.jpg':'image/jpeg', '.jpeg':'image/jpeg',
'.gif':'image/gif', '.svg':'image/svg+xml', '.webp':'image/webp',
'.ico':'image/x-icon', '.bmp':'image/bmp',
'.pdf':'application/pdf', '.json':'application/json',
}
# ── Toolsets (from config.yaml or hardcoded default) ─────────────────────────
CLI_TOOLSETS = cfg.get('platform_toolsets', {}).get('cli', [
'browser', 'clarify', 'code_execution', 'cronjob', 'delegation', 'file',
'image_gen', 'memory', 'session_search', 'skills', 'terminal', 'todo',
'web', 'webhook',
])
# ── Model / provider discovery ───────────────────────────────────────────────
# Hardcoded fallback models (used when no config.yaml or agent is available)
_FALLBACK_MODELS = [
{'provider': 'OpenAI', 'id': 'openai/gpt-5.4-mini', 'label': 'GPT-5.4 Mini'},
{'provider': 'OpenAI', 'id': 'openai/gpt-4o', 'label': 'GPT-4o'},
{'provider': 'OpenAI', 'id': 'openai/o3', 'label': 'o3'},
{'provider': 'OpenAI', 'id': 'openai/o4-mini', 'label': 'o4-mini'},
{'provider': 'Anthropic', 'id': 'anthropic/claude-sonnet-4.6', 'label': 'Claude Sonnet 4.6'},
{'provider': 'Anthropic', 'id': 'anthropic/claude-sonnet-4-5', 'label': 'Claude Sonnet 4.5'},
{'provider': 'Anthropic', 'id': 'anthropic/claude-haiku-3-5', 'label': 'Claude Haiku 3.5'},
{'provider': 'Other', 'id': 'google/gemini-2.5-pro', 'label': 'Gemini 2.5 Pro'},
{'provider': 'Other', 'id': 'deepseek/deepseek-chat-v3-0324', 'label': 'DeepSeek V3'},
{'provider': 'Other', 'id': 'meta-llama/llama-4-scout', 'label': 'Llama 4 Scout'},
]
# Provider display names for known Hermes provider IDs
_PROVIDER_DISPLAY = {
'nous': 'Nous Portal', 'openrouter': 'OpenRouter', 'anthropic': 'Anthropic',
'openai': 'OpenAI', 'openai-codex': 'OpenAI Codex', 'copilot': 'GitHub Copilot',
'zai': 'Z.AI / GLM', 'kimi-coding': 'Kimi / Moonshot', 'deepseek': 'DeepSeek',
'minimax': 'MiniMax', 'google': 'Google', 'meta-llama': 'Meta Llama',
'huggingface': 'HuggingFace', 'alibaba': 'Alibaba',
'ollama': 'Ollama', 'lmstudio': 'LM Studio',
}
# Well-known models per provider (used to populate dropdown for direct API providers)
_PROVIDER_MODELS = {
'anthropic': [
{'id': 'claude-opus-4.6', 'label': 'Claude Opus 4.6'},
{'id': 'claude-sonnet-4.6', 'label': 'Claude Sonnet 4.6'},
{'id': 'claude-sonnet-4-5', 'label': 'Claude Sonnet 4.5'},
{'id': 'claude-haiku-3-5', 'label': 'Claude Haiku 3.5'},
],
'openai': [
{'id': 'gpt-5.4-mini', 'label': 'GPT-5.4 Mini'},
{'id': 'gpt-4o', 'label': 'GPT-4o'},
{'id': 'o3', 'label': 'o3'},
{'id': 'o4-mini', 'label': 'o4-mini'},
],
'openai-codex': [
{'id': 'codex-mini-latest', 'label': 'Codex Mini'},
],
'google': [
{'id': 'gemini-2.5-pro', 'label': 'Gemini 2.5 Pro'},
],
'deepseek': [
{'id': 'deepseek-chat-v3-0324', 'label': 'DeepSeek V3'},
{'id': 'deepseek-reasoner', 'label': 'DeepSeek Reasoner'},
],
'nous': [
{'id': 'claude-opus-4.6', 'label': 'Claude Opus 4.6 (via Nous)'},
{'id': 'claude-sonnet-4.6', 'label': 'Claude Sonnet 4.6 (via Nous)'},
{'id': 'gpt-5.4-mini', 'label': 'GPT-5.4 Mini (via Nous)'},
{'id': 'gemini-2.5-pro', 'label': 'Gemini 2.5 Pro (via Nous)'},
],
'zai': [
{'id': 'glm-5.1', 'label': 'GLM-5.1'},
{'id': 'glm-5', 'label': 'GLM-5'},
{'id': 'glm-5-turbo', 'label': 'GLM-5 Turbo'},
{'id': 'glm-4.7', 'label': 'GLM-4.7'},
{'id': 'glm-4.5', 'label': 'GLM-4.5'},
{'id': 'glm-4.5-flash', 'label': 'GLM-4.5 Flash'},
],
'kimi-coding': [
{'id': 'moonshot-v1-8k', 'label': 'Moonshot v1 8k'},
{'id': 'moonshot-v1-32k', 'label': 'Moonshot v1 32k'},
{'id': 'moonshot-v1-128k', 'label': 'Moonshot v1 128k'},
{'id': 'kimi-latest', 'label': 'Kimi Latest'},
],
'minimax': [
{'id': 'MiniMax-M2.7', 'label': 'MiniMax M2.7'},
{'id': 'MiniMax-M2.7-highspeed', 'label': 'MiniMax M2.7 Highspeed'},
{'id': 'MiniMax-M2.5', 'label': 'MiniMax M2.5'},
{'id': 'MiniMax-M2.5-highspeed', 'label': 'MiniMax M2.5 Highspeed'},
{'id': 'MiniMax-M2.1', 'label': 'MiniMax M2.1'},
],
}
def resolve_model_provider(model_id: str):
"""Resolve bare model name, provider, and base_url for AIAgent.
Model IDs from the dropdown may include a provider prefix
(e.g. 'anthropic/claude-sonnet-4.6'). Direct-API providers expect
bare model names, while OpenRouter expects the full provider/model path.
Also reads base_url from config.yaml so providers with custom endpoints
(e.g. MiniMax, Z.AI) are routed correctly.
Returns (model, provider, base_url) where provider and base_url may be None.
"""
config_provider = None
config_base_url = None
model_cfg = cfg.get('model', {})
if isinstance(model_cfg, dict):
config_provider = model_cfg.get('provider')
config_base_url = model_cfg.get('base_url')
model_id = (model_id or '').strip()
if not model_id:
return model_id, config_provider, config_base_url
if '/' in model_id:
prefix, bare = model_id.split('/', 1)
# If prefix matches config provider, strip it and use that provider directly
if config_provider and prefix == config_provider:
return bare, config_provider, config_base_url
# If the config provider is openrouter (or unset/None), pass the full
# provider/model string through -- OpenRouter uses this as its model ID.
# Only strip the prefix and switch to a direct-API provider when the
# config is explicitly set to that direct provider.
if config_provider and config_provider != 'openrouter' and prefix in _PROVIDER_MODELS:
return bare, prefix, None
return model_id, config_provider, config_base_url
def get_available_models() -> dict:
"""
Return available models grouped by provider.
Discovery order:
1. Read config.yaml 'model' section for active provider info
2. Check for known API keys in env or ~/.hermes/.env
3. Fetch models from custom endpoint if base_url is configured
4. Fall back to hardcoded model list (OpenRouter-style)
Returns: {
'active_provider': str|None,
'default_model': str,
'groups': [{'provider': str, 'models': [{'id': str, 'label': str}]}]
}
"""
active_provider = None
default_model = DEFAULT_MODEL
groups = []
# 1. Read config.yaml model section
model_cfg = cfg.get('model', {})
if isinstance(model_cfg, str):
default_model = model_cfg
elif isinstance(model_cfg, dict):
active_provider = model_cfg.get('provider')
cfg_default = model_cfg.get('default', '')
cfg_base_url = model_cfg.get('base_url', '')
if cfg_default:
default_model = cfg_default
# 2. Also check env vars for model override
env_model = os.getenv('HERMES_MODEL') or os.getenv('OPENAI_MODEL') or os.getenv('LLM_MODEL')
if env_model:
default_model = env_model.strip()
# 3. Try to read auth store for active provider (if hermes is installed)
if not active_provider:
auth_store_path = HOME / '.hermes' / 'auth.json'
if auth_store_path.exists():
try:
import json as _j
auth_store = _j.loads(auth_store_path.read_text())
active_provider = auth_store.get('active_provider')
except Exception:
pass
# 4. Check for API keys that imply available providers
hermes_env_path = HOME / '.hermes' / '.env'
env_keys = {}
if hermes_env_path.exists():
try:
for line in hermes_env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
env_keys[k.strip()] = v.strip().strip('"').strip("'")
except Exception:
pass
# Merge with actual env
all_env = {**env_keys}
for k in ('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY',
'GOOGLE_API_KEY', 'GLM_API_KEY', 'KIMI_API_KEY', 'DEEPSEEK_API_KEY'):
val = os.getenv(k)
if val:
all_env[k] = val
detected_providers = set()
if active_provider:
detected_providers.add(active_provider)
if all_env.get('ANTHROPIC_API_KEY'):
detected_providers.add('anthropic')
if all_env.get('OPENAI_API_KEY'):
detected_providers.add('openai')
if all_env.get('OPENROUTER_API_KEY'):
detected_providers.add('openrouter')
if all_env.get('GOOGLE_API_KEY'):
detected_providers.add('google')
if all_env.get('GLM_API_KEY'):
detected_providers.add('zai')
if all_env.get('KIMI_API_KEY'):
detected_providers.add('kimi-coding')
if all_env.get('MINIMAX_API_KEY') or all_env.get('MINIMAX_CN_API_KEY'):
detected_providers.add('minimax')
if all_env.get('DEEPSEEK_API_KEY'):
detected_providers.add('deepseek')
# 3. Fetch models from custom endpoint if base_url is configured
auto_detected_models = []
if cfg_base_url:
try:
import ipaddress
import urllib.request
# Normalize the base_url and build models endpoint
base_url = cfg_base_url.strip()
if base_url.endswith('/v1'):
endpoint_url = base_url[:-3] + '/models'
else:
endpoint_url = base_url + '/v1/models'
# Detect provider from base_url
provider = 'custom'
parsed = urlparse(base_url if '://' in base_url else f'http://{base_url}')
host = (parsed.netloc or parsed.path).lower()
if parsed.hostname:
try:
addr = ipaddress.ip_address(parsed.hostname)
if addr.is_private or addr.is_loopback or addr.is_link_local:
if 'ollama' in host or '127.0.0.1' in host or 'localhost' in host:
provider = 'ollama'
elif 'lmstudio' in host or 'lm-studio' in host:
provider = 'lmstudio'
else:
provider = 'local'
except ValueError:
pass
# Resolve API key from environment
headers = {}
api_key_vars = ('HERMES_API_KEY', 'HERMES_OPENAI_API_KEY', 'OPENAI_API_KEY',
'LOCAL_API_KEY', 'OPENROUTER_API_KEY', 'API_KEY')
for key in api_key_vars:
api_key = os.getenv(key)
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
break
# Fetch model list from endpoint
req = urllib.request.Request(endpoint_url, method='GET')
for k, v in headers.items():
req.add_header(k, v)
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode('utf-8'))
# Handle both OpenAI-compatible and llama.cpp response formats
models_list = []
if 'data' in data and isinstance(data['data'], list):
models_list = data['data']
elif 'models' in data and isinstance(data['models'], list):
models_list = data['models']
for model in models_list:
if not isinstance(model, dict):
continue
model_id = model.get('id', '') or model.get('name', '') or model.get('model', '')
model_name = model.get('name', '') or model.get('model', '') or model_id
if model_id and model_name:
auto_detected_models.append({'id': model_id, 'label': model_name})
detected_providers.add(provider.lower())
except Exception as e:
logger.debug(f"Failed to fetch models from custom endpoint: {e}")
# 5. Build model groups
if detected_providers:
for pid in sorted(detected_providers):
provider_name = _PROVIDER_DISPLAY.get(pid, pid.title())
if pid == 'openrouter':
# OpenRouter uses provider/model format -- show the fallback list
groups.append({
'provider': 'OpenRouter',
'models': [{'id': m['id'], 'label': m['label']} for m in _FALLBACK_MODELS],
})
elif pid in _PROVIDER_MODELS:
groups.append({
'provider': provider_name,
'models': _PROVIDER_MODELS[pid],
})
else:
# Unknown provider -- use auto-detected models if available,
# otherwise fall back to default model placeholder
if auto_detected_models:
groups.append({
'provider': provider_name,
'models': auto_detected_models,
})
else:
groups.append({
'provider': provider_name,
'models': [{'id': default_model, 'label': default_model.split('/')[-1]}],
})
else:
# No providers detected -- use fallback grouped list
by_provider = {}
for m in _FALLBACK_MODELS:
by_provider.setdefault(m['provider'], []).append(
{'id': m['id'], 'label': m['label']}
)
for provider_name, models in by_provider.items():
groups.append({'provider': provider_name, 'models': models})
return {
'active_provider': active_provider,
'default_model': default_model,
'groups': groups,
}
# ── Static file path ─────────────────────────────────────────────────────────
_INDEX_HTML_PATH = REPO_ROOT / 'static' / 'index.html'
# ── Thread synchronisation ───────────────────────────────────────────────────
LOCK = threading.Lock()
SESSIONS_MAX = 100
CHAT_LOCK = threading.Lock()
STREAMS: dict = {}
STREAMS_LOCK = threading.Lock()
CANCEL_FLAGS: dict = {}
SERVER_START_TIME = time.time()
# ── Thread-local env context ─────────────────────────────────────────────────
_thread_ctx = threading.local()
def _set_thread_env(**kwargs):
_thread_ctx.env = kwargs
def _clear_thread_env():
_thread_ctx.env = {}
# ── Per-session agent locks ───────────────────────────────────────────────────
SESSION_AGENT_LOCKS: dict = {}
SESSION_AGENT_LOCKS_LOCK = threading.Lock()
def _get_session_agent_lock(session_id: str) -> threading.Lock:
with SESSION_AGENT_LOCKS_LOCK:
if session_id not in SESSION_AGENT_LOCKS:
SESSION_AGENT_LOCKS[session_id] = threading.Lock()
return SESSION_AGENT_LOCKS[session_id]
# ── Settings persistence ─────────────────────────────────────────────────────
_SETTINGS_DEFAULTS = {
'default_model': DEFAULT_MODEL,
'default_workspace': str(DEFAULT_WORKSPACE),
}
def load_settings() -> dict:
"""Load settings from disk, merging with defaults for any missing keys."""
settings = dict(_SETTINGS_DEFAULTS)
if SETTINGS_FILE.exists():
try:
stored = json.loads(SETTINGS_FILE.read_text(encoding='utf-8'))
if isinstance(stored, dict):
settings.update(stored)
except Exception:
pass
return settings
_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys())
def save_settings(settings: dict) -> dict:
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
current = load_settings()
for k, v in settings.items():
if k in _SETTINGS_ALLOWED_KEYS:
current[k] = v
SETTINGS_FILE.write_text(
json.dumps(current, ensure_ascii=False, indent=2),
encoding='utf-8',
)
# Update runtime defaults so new sessions use them immediately
global DEFAULT_MODEL, DEFAULT_WORKSPACE
if 'default_model' in current:
DEFAULT_MODEL = current['default_model']
if 'default_workspace' in current:
DEFAULT_WORKSPACE = Path(current['default_workspace']).expanduser().resolve()
return current
# Apply saved settings on startup (override env-derived defaults)
_startup_settings = load_settings()
if SETTINGS_FILE.exists():
if _startup_settings.get('default_model'):
DEFAULT_MODEL = _startup_settings['default_model']
if _startup_settings.get('default_workspace'):
DEFAULT_WORKSPACE = Path(_startup_settings['default_workspace']).expanduser().resolve()
# ── SESSIONS in-memory cache (LRU OrderedDict) ───────────────────────────────
SESSIONS: collections.OrderedDict = collections.OrderedDict()