Hermes WebUI v0.1.0 — initial public release
This commit is contained in:
273
api/config.py
Normal file
273
api/config.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Hermes WebUI -- 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'
|
||||
LAST_WORKSPACE_FILE = STATE_DIR / 'last_workspace.txt'
|
||||
|
||||
# ── 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',
|
||||
])
|
||||
|
||||
# ── 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]
|
||||
|
||||
# ── SESSIONS in-memory cache (LRU OrderedDict) ───────────────────────────────
|
||||
SESSIONS: collections.OrderedDict = collections.OrderedDict()
|
||||
Reference in New Issue
Block a user