Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| Hermes Agent HF Spaces Persistence β Full Directory Sync | |
| ========================================================= | |
| Simplified persistence: upload/download the entire /opt/data directory | |
| as-is to/from a Hugging Face Dataset repo. | |
| - Startup: snapshot_download β /opt/data | |
| - Periodic: upload_folder β dataset hermes_data/ | |
| - Shutdown: final upload_folder β dataset hermes_data/ | |
| """ | |
| import os | |
| import sys | |
| import time | |
| import json | |
| import hashlib | |
| import threading | |
| import subprocess | |
| import signal | |
| import shutil | |
| import secrets | |
| import tempfile | |
| import traceback | |
| from pathlib import Path | |
| from datetime import datetime | |
| # Set timeout BEFORE importing huggingface_hub | |
| os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "300") | |
| os.environ.setdefault("HF_HUB_UPLOAD_TIMEOUT", "600") | |
| os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1") | |
| os.environ.setdefault("HF_HUB_VERBOSITY", "warning") | |
| import logging as _logging | |
| _logging.getLogger("huggingface_hub").setLevel(_logging.WARNING) | |
| _logging.getLogger("huggingface_hub.utils").setLevel(_logging.WARNING) | |
| _logging.getLogger("filelock").setLevel(_logging.WARNING) | |
| from huggingface_hub import HfApi, snapshot_download | |
| from runtime_policy import apply_optional_mcp_policy | |
| # ββ Logging helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TeeLogger: | |
| """Duplicate output to stream and file.""" | |
| def __init__(self, filename, stream): | |
| self.stream = stream | |
| self.file = open(filename, "a", encoding="utf-8") | |
| def write(self, message): | |
| self.stream.write(message) | |
| self.file.write(message) | |
| self.flush() | |
| def flush(self): | |
| self.stream.flush() | |
| self.file.flush() | |
| def fileno(self): | |
| return self.stream.fileno() | |
| # ββ Configuration βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| HF_TOKEN = os.environ.get("HF_TOKEN") | |
| HERMES_DATA = Path("/opt/data") | |
| APP_DIR = Path("/opt/hermes") | |
| DATASET_PATH = "hermes_data" | |
| AGENT_NAME = os.environ.get("AGENT_NAME", "HermesFace") | |
| # HF Spaces built-in env vars (auto-set by HF runtime) | |
| SPACE_HOST = os.environ.get("SPACE_HOST", "") | |
| SPACE_ID = os.environ.get("SPACE_ID", "") | |
| SYNC_INTERVAL = int(os.environ.get("SYNC_INTERVAL", "60")) | |
| AUTO_CREATE_DATASET = os.environ.get("AUTO_CREATE_DATASET", "true").lower() in ("true", "1", "yes") | |
| # Dataset repo: auto-derive from SPACE_ID when not explicitly set. | |
| # Format: {username}/{SpaceName}-data | |
| HF_REPO_ID = os.environ.get("HERMES_DATASET_REPO", "") | |
| if not HF_REPO_ID and SPACE_ID: | |
| HF_REPO_ID = f"{SPACE_ID}-data" | |
| print(f"[SYNC] HERMES_DATASET_REPO not set β auto-derived from SPACE_ID: {HF_REPO_ID}") | |
| elif not HF_REPO_ID and HF_TOKEN: | |
| try: | |
| _api = HfApi(token=HF_TOKEN) | |
| _username = _api.whoami()["name"] | |
| HF_REPO_ID = f"{_username}/HermesFace-data" | |
| print(f"[SYNC] HERMES_DATASET_REPO not set β auto-derived from HF_TOKEN: {HF_REPO_ID}") | |
| del _api, _username | |
| except Exception as e: | |
| print(f"[SYNC] WARNING: Could not derive username from HF_TOKEN: {e}") | |
| HF_REPO_ID = "" | |
| # Setup logging | |
| log_dir = HERMES_DATA / "logs" | |
| log_dir.mkdir(parents=True, exist_ok=True) | |
| sys.stdout = TeeLogger(log_dir / "sync.log", sys.stdout) | |
| sys.stderr = sys.stdout | |
| # ββ Sync Manager ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class HermesFullSync: | |
| """Upload/download the entire /opt/data directory to HF Dataset.""" | |
| def __init__(self): | |
| self.enabled = False | |
| self.dataset_exists = False | |
| self.api = None | |
| if not HF_TOKEN: | |
| print("[SYNC] WARNING: HF_TOKEN not set. Persistence disabled.") | |
| return | |
| if not HF_REPO_ID: | |
| print("[SYNC] WARNING: Could not determine dataset repo (no SPACE_ID or HERMES_DATASET_REPO).") | |
| print("[SYNC] Persistence disabled.") | |
| return | |
| self.enabled = True | |
| self.api = HfApi(token=HF_TOKEN) | |
| self.dataset_exists = self._ensure_repo_exists() | |
| # ββ Repo management ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _ensure_repo_exists(self): | |
| """Check if dataset repo exists; auto-create only when AUTO_CREATE_DATASET=true.""" | |
| try: | |
| self.api.repo_info(repo_id=HF_REPO_ID, repo_type="dataset") | |
| print(f"[SYNC] Dataset repo found: {HF_REPO_ID}") | |
| return True | |
| except Exception: | |
| if not AUTO_CREATE_DATASET: | |
| print(f"[SYNC] Dataset repo NOT found: {HF_REPO_ID}") | |
| print("[SYNC] Set AUTO_CREATE_DATASET=true to auto-create.") | |
| print("[SYNC] Persistence disabled (app will still run normally).") | |
| return False | |
| print(f"[SYNC] Dataset repo NOT found: {HF_REPO_ID} β creating...") | |
| try: | |
| self.api.create_repo( | |
| repo_id=HF_REPO_ID, | |
| repo_type="dataset", | |
| private=True, | |
| ) | |
| print(f"[SYNC] Dataset repo created: {HF_REPO_ID}") | |
| return True | |
| except Exception as e: | |
| print(f"[SYNC] Failed to create dataset repo: {e}") | |
| return False | |
| # ββ Restore (startup) βββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_from_repo(self): | |
| """Download from dataset β /opt/data""" | |
| if not self.enabled: | |
| print("[SYNC] Persistence disabled - skipping restore") | |
| self._ensure_default_config() | |
| return | |
| if not self.dataset_exists: | |
| print(f"[SYNC] Dataset {HF_REPO_ID} does not exist - starting fresh") | |
| self._ensure_default_config() | |
| return | |
| print(f"[SYNC] Restoring /opt/data from dataset {HF_REPO_ID} ...") | |
| HERMES_DATA.mkdir(parents=True, exist_ok=True) | |
| try: | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| snapshot_download( | |
| repo_id=HF_REPO_ID, | |
| repo_type="dataset", | |
| allow_patterns=f"{DATASET_PATH}/**", | |
| ignore_patterns=[ | |
| f"{DATASET_PATH}/.brv-cli/**", | |
| f"{DATASET_PATH}/.npm/**", | |
| f"{DATASET_PATH}/.local/share/uv/**", | |
| f"{DATASET_PATH}/home/.local/share/uv/**", | |
| f"{DATASET_PATH}/hermes_overlay/**", | |
| ], | |
| local_dir=tmpdir, | |
| token=HF_TOKEN, | |
| ) | |
| downloaded_root = Path(tmpdir) / DATASET_PATH | |
| if downloaded_root.exists(): | |
| for item in downloaded_root.rglob("*"): | |
| if item.is_file(): | |
| rel = item.relative_to(downloaded_root) | |
| dest = HERMES_DATA / rel | |
| dest.parent.mkdir(parents=True, exist_ok=True) | |
| shutil.copy2(str(item), str(dest)) | |
| print("[SYNC] Restore completed.") | |
| else: | |
| print("[SYNC] Downloaded snapshot but dir not found. Starting fresh.") | |
| except Exception as e: | |
| print(f"[SYNC] Restore failed: {e}") | |
| traceback.print_exc() | |
| self._ensure_default_config() | |
| self._debug_list_files() | |
| # ββ Save (periodic + shutdown) βββββββββββββββββββββββββββββββββββββ | |
| def save_to_repo(self): | |
| """Upload entire /opt/data directory β dataset (all files, no filtering)""" | |
| if not self.enabled: | |
| return | |
| if not HERMES_DATA.exists(): | |
| print("[SYNC] /opt/data does not exist, nothing to save.") | |
| return | |
| if not self._ensure_repo_exists(): | |
| print(f"[SYNC] Dataset {HF_REPO_ID} unavailable - skipping save") | |
| return | |
| print(f"[SYNC] Uploading /opt/data β dataset {HF_REPO_ID}/{DATASET_PATH}/ ...") | |
| try: | |
| total_size = 0 | |
| file_count = 0 | |
| for root, dirs, fls in os.walk(HERMES_DATA): | |
| for fn in fls: | |
| fp = os.path.join(root, fn) | |
| total_size += os.path.getsize(fp) | |
| file_count += 1 | |
| print(f"[SYNC] Uploading: {file_count} files, {total_size} bytes total") | |
| if file_count == 0: | |
| print("[SYNC] Nothing to upload.") | |
| return | |
| self.api.upload_folder( | |
| folder_path=str(HERMES_DATA), | |
| path_in_repo=DATASET_PATH, | |
| repo_id=HF_REPO_ID, | |
| repo_type="dataset", | |
| token=HF_TOKEN, | |
| commit_message=f"Sync hermes_data β {datetime.now().isoformat()}", | |
| ignore_patterns=[ | |
| "*.log", # Log files β regenerated on boot | |
| "*.lock", # Lock files β stale after restart | |
| "*.tmp", # Temp files | |
| "*.pid", # PID files | |
| "*.db-shm", # SQLite shared-memory sidecar β transient, rotates mid-upload | |
| "*.db-wal", # SQLite write-ahead-log sidecar β transient, rotates mid-upload | |
| "*.db-journal", # SQLite rollback-journal sidecar β transient | |
| "__pycache__", # Python cache | |
| "scripts/*", # HermesFace scripts β from git, not data | |
| "assets/*", # Static assets β from git, not data | |
| "hermes_overlay/**", # Immutable overlay β from current Space image | |
| ".brv-cli/**", # Generated browser CLI + node_modules | |
| ".npm/**", # npm cache/update metadata | |
| ".local/share/uv/**", # uv package cache | |
| "home/.local/share/uv/**", # nested uv package cache | |
| ".cache/**", # uv/pip cache β HF rejects paths under .cache/ | |
| "**/.cache/**", # nested cache dirs anywhere under /opt/data | |
| ], | |
| ) | |
| print(f"[SYNC] Upload completed at {datetime.now().isoformat()}") | |
| try: | |
| files = self.api.list_repo_files(repo_id=HF_REPO_ID, repo_type="dataset") | |
| data_files = [f for f in files if f.startswith(f"{DATASET_PATH}/")] | |
| print(f"[SYNC] Dataset now has {len(data_files)} files under {DATASET_PATH}/") | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| print(f"[SYNC] Upload failed: {e}") | |
| traceback.print_exc() | |
| # ββ Config helpers βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _ensure_default_config(self): | |
| """Ensure Hermes has config.yaml and .env for HF Spaces.""" | |
| config_path = HERMES_DATA / "config.yaml" | |
| env_path = HERMES_DATA / ".env" | |
| soul_path = HERMES_DATA / "SOUL.md" | |
| # Bootstrap from Hermes templates if available | |
| if not config_path.exists(): | |
| template = APP_DIR / "cli-config.yaml.example" | |
| if template.exists(): | |
| shutil.copy2(str(template), str(config_path)) | |
| print("[SYNC] Created config.yaml from Hermes template") | |
| else: | |
| # Minimal fallback config | |
| import yaml | |
| config = { | |
| "agent": {"name": AGENT_NAME}, | |
| "server": {"host": "0.0.0.0", "port": 7860}, | |
| } | |
| with open(config_path, "w") as f: | |
| yaml.dump(config, f, default_flow_style=False) | |
| print(f"[SYNC] Created minimal config.yaml (agent={AGENT_NAME}, port=7860)") | |
| if not env_path.exists(): | |
| template = APP_DIR / ".env.example" | |
| if template.exists(): | |
| shutil.copy2(str(template), str(env_path)) | |
| print("[SYNC] Created .env from Hermes template") | |
| else: | |
| env_lines = [] | |
| for key in [ | |
| "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", | |
| "NOUS_API_KEY", "GOOGLE_API_KEY", "MISTRAL_API_KEY", | |
| "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", | |
| ]: | |
| val = os.environ.get(key, "") | |
| if val: | |
| env_lines.append(f"{key}={val}") | |
| if env_lines: | |
| with open(env_path, "w") as f: | |
| f.write("\n".join(env_lines) + "\n") | |
| print(f"[SYNC] Created .env with {len(env_lines)} keys") | |
| if not soul_path.exists(): | |
| template = APP_DIR / "docker" / "SOUL.md" | |
| if template.exists(): | |
| shutil.copy2(str(template), str(soul_path)) | |
| print("[SYNC] Created SOUL.md from Hermes template") | |
| else: | |
| with open(soul_path, "w") as f: | |
| f.write(f"# {AGENT_NAME}\n\nI am {AGENT_NAME}, a self-improving AI assistant powered by Hermes Agent.\n") | |
| print("[SYNC] Created default SOUL.md") | |
| self._ensure_fallback_chain() | |
| def _debug_list_files(self): | |
| try: | |
| count = sum(1 for _, _, files in os.walk(HERMES_DATA) for _ in files) | |
| print(f"[SYNC] Local /opt/data: {count} files") | |
| except Exception as e: | |
| print(f"[SYNC] listing failed: {e}") | |
| # ββ Admin auth wiring βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _configure_admin_auth(self): | |
| """Map HERMES_ADMIN_PASSWORD onto Hermes' real dashboard auth provider. | |
| Hermes' bundled password provider (plugins/dashboard_auth/basic) reads | |
| HERMES_DASHBOARD_BASIC_AUTH_USERNAME / _PASSWORD / _SECRET β nothing in | |
| Hermes reads HERMES_ADMIN_PASSWORD. Previously this Secret was set but | |
| unused, and since Hermes now requires an auth provider on any | |
| non-loopback bind (HF Spaces binds 0.0.0.0), the dashboard was failing | |
| closed (401 on every route) with no way to log in. This wires the | |
| existing secret into the mechanism Hermes actually checks. | |
| """ | |
| admin_password = os.environ.get("HERMES_ADMIN_PASSWORD", "").strip() | |
| if not admin_password: | |
| print("[SYNC] WARNING: HERMES_ADMIN_PASSWORD not set β dashboard " | |
| "login will be unavailable (non-loopback bind requires an " | |
| "auth provider; the basic-auth plugin won't register without " | |
| "a username+password).") | |
| return | |
| os.environ.setdefault("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin") | |
| os.environ["HERMES_DASHBOARD_BASIC_AUTH_PASSWORD"] = admin_password | |
| # Persist a stable HMAC signing secret across restarts so logged-in | |
| # sessions survive a Space restart instead of all being invalidated. | |
| if "HERMES_DASHBOARD_BASIC_AUTH_SECRET" not in os.environ: | |
| secret_path = HERMES_DATA / ".dashboard_auth_secret" | |
| try: | |
| if secret_path.exists(): | |
| secret = secret_path.read_text().strip() | |
| else: | |
| secret = secrets.token_hex(32) | |
| secret_path.parent.mkdir(parents=True, exist_ok=True) | |
| secret_path.write_text(secret) | |
| try: | |
| os.chmod(secret_path, 0o600) | |
| except Exception: | |
| pass | |
| if secret: | |
| os.environ["HERMES_DASHBOARD_BASIC_AUTH_SECRET"] = secret | |
| except Exception as e: | |
| print(f"[SYNC] WARNING: could not persist dashboard auth secret: {e}") | |
| print("[SYNC] Admin login wired: HERMES_DASHBOARD_BASIC_AUTH_USERNAME/PASSWORD " | |
| "set from HERMES_ADMIN_PASSWORD (username=admin unless overridden)") | |
| # ββ Provider fallback chain βββββββββββββββββββββββββββββββββββββββββ | |
| def _ensure_fallback_chain(self): | |
| """Write the required model fallback order into config.yaml. | |
| Order: deepseek/deepseek-r1-0528:free -> deepseek/deepseek-r1-0528-qwen3-8b:free | |
| (both via OpenRouter) -> Google AI Studio (gemini, GOOGLE_API_KEY) -> | |
| openrouter/free. Only writes if fallback_providers isn't already | |
| configured, so it never clobbers a config the operator (or a restored | |
| dataset snapshot) already set up. | |
| """ | |
| config_path = HERMES_DATA / "config.yaml" | |
| try: | |
| import yaml | |
| data = {} | |
| if config_path.exists(): | |
| with open(config_path) as f: | |
| data = yaml.safe_load(f) or {} | |
| if data.get("fallback_providers"): | |
| print("[SYNC] fallback_providers already configured β leaving as-is") | |
| return | |
| data["fallback_providers"] = [ | |
| {"provider": "openrouter", "model": "deepseek/deepseek-r1-0528:free"}, | |
| {"provider": "openrouter", "model": "deepseek/deepseek-r1-0528-qwen3-8b:free"}, | |
| {"provider": "gemini", "model": "gemini-3.5-flash"}, | |
| {"provider": "openrouter", "model": "openrouter/free"}, | |
| ] | |
| with open(config_path, "w") as f: | |
| yaml.dump(data, f, default_flow_style=False) | |
| print("[SYNC] fallback_providers written to config.yaml: " | |
| "deepseek-r1 -> deepseek-r1-qwen3-8b -> gemini -> openrouter/free") | |
| except Exception as e: | |
| print(f"[SYNC] WARNING: could not write fallback_providers: {e}") | |
| def _park_unavailable_optional_mcp(self): | |
| """Keep optional local MCP servers from entering a reconnect loop. | |
| Unreal Engine and Linear are opt-in in a headless Space. Linear also | |
| requires cached OAuth credentials before it is exposed to child | |
| processes; otherwise one sanitized warning is emitted and the entry is | |
| parked for this boot. All other MCP entries remain untouched. | |
| """ | |
| config_path = HERMES_DATA / "config.yaml" | |
| try: | |
| import yaml | |
| data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} | |
| policy = apply_optional_mcp_policy( | |
| data, | |
| linear_enabled=os.environ.get("LINEAR_MCP_ENABLED", "false").strip().lower() == "true", | |
| linear_tokens_available=(HERMES_DATA / "mcp-tokens" / "linear.json").is_file(), | |
| unreal_enabled=os.environ.get("UNREAL_ENGINE_MCP_ENABLED", "false").lower() in {"1", "true", "yes", "on"}, | |
| ) | |
| if policy["removed"]: | |
| config_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") | |
| for name in policy["removed"]: | |
| if name.lower() == "unreal-engine": | |
| print("[SYNC] Optional unreal-engine MCP parked (not enabled)") | |
| elif name.lower() == "linear" and not policy["linear_unconfigured"]: | |
| print("[SYNC] Linear MCP disabled; OAuth discovery skipped") | |
| if policy["linear_unconfigured"]: | |
| print("[SYNC] WARNING: Linear MCP enabled but no cached OAuth credentials are available; parked for this process") | |
| except Exception as exc: | |
| print(f"[SYNC] Optional MCP parking skipped: {exc}") | |
| def _disable_legacy_telegram_gateway(self): | |
| """Make the Gateway's persisted platform map agree with webhook mode. | |
| Hermes loads ~/.hermes/.env after the child environment is built, so | |
| removing TELEGRAM_BOT_TOKEN from one env dict is insufficient. An | |
| explicit YAML ``enabled: false`` is the authoritative propagation | |
| point and leaves the dashboard webhook process independent. | |
| """ | |
| enabled = os.environ.get("TELEGRAM_ENABLED", "false").strip().lower() == "true" | |
| mode = os.environ.get("TELEGRAM_MODE", "webhook").strip().lower() | |
| if enabled and mode != "webhook": | |
| return | |
| config_path = HERMES_DATA / "config.yaml" | |
| try: | |
| import yaml | |
| data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} | |
| platforms = data.setdefault("platforms", {}) | |
| if isinstance(platforms, dict): | |
| telegram = platforms.setdefault("telegram", {}) | |
| if isinstance(telegram, dict): | |
| telegram["enabled"] = False | |
| config_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") | |
| if enabled and mode == "webhook": | |
| print("[SYNC] Telegram runtime: webhook-only; Gateway polling adapter disabled") | |
| else: | |
| print("[SYNC] Telegram runtime: disabled; no adapter or reconnect task started") | |
| except Exception as exc: | |
| print(f"[SYNC] Telegram Gateway isolation skipped: {exc}") | |
| # ββ Background sync loop ββββββββββββββββββββββββββββββββββββββββββ | |
| def background_sync_loop(self, stop_event): | |
| print(f"[SYNC] Background sync started (interval={SYNC_INTERVAL}s)") | |
| while not stop_event.is_set(): | |
| if stop_event.wait(timeout=SYNC_INTERVAL): | |
| break | |
| print(f"[SYNC] Periodic sync triggered at {datetime.now().isoformat()}") | |
| self.save_to_repo() | |
| # ββ Application runner βββββββββββββββββββββββββββββββββββββββββββββ | |
| def _patch_web_server_cors(self): | |
| """Patch Hermes web_server.py: | |
| - Allow any origin (HF Spaces iframe, custom domains) | |
| - Allow iframe embedding in huggingface.co + *.hf.space | |
| - Add /health and /ready aliases (HermesFace-specific; Hermes itself | |
| only exposes the equivalent public status at /api/status) | |
| """ | |
| ws_path = APP_DIR / "hermes_cli" / "web_server.py" | |
| if not ws_path.exists(): | |
| return | |
| try: | |
| code = ws_path.read_text() | |
| changed = False | |
| old_cors = 'allow_origin_regex=r"^https?://(localhost|127\\.0\\.0\\.1)(:\\d+)?$"' | |
| new_cors = 'allow_origins=["*"]' | |
| if old_cors in code: | |
| code = code.replace(old_cors, new_cors) | |
| changed = True | |
| print("[SYNC] Patched web_server.py CORS for HF Spaces") | |
| # Neutralise X-Frame-Options so HF Spaces can embed the dashboard. | |
| for pat in ('X-Frame-Options", "DENY"', 'X-Frame-Options", "SAMEORIGIN"'): | |
| if pat in code: | |
| code = code.replace(pat, 'X-Frame-Options", "ALLOWALL"') | |
| changed = True | |
| print("[SYNC] Relaxed X-Frame-Options for HF Spaces") | |
| # Relax CSP frame-ancestors if present. | |
| csp_old = 'frame-ancestors \'none\'' | |
| csp_new = "frame-ancestors 'self' https://huggingface.co https://*.hf.space" | |
| if csp_old in code: | |
| code = code.replace(csp_old, csp_new) | |
| changed = True | |
| print("[SYNC] Relaxed CSP frame-ancestors for HF Spaces") | |
| # Add /health and /ready β plain, non-/api/ paths, so they are | |
| # never gated by the dashboard auth middleware (which only | |
| # intercepts paths starting with /api/). Both just delegate to | |
| # the existing public /api/status handler. | |
| health_marker = '@app.get("/health")\nasync def _hermesface_health' | |
| old_status_route = '@app.get("/api/status")\nasync def get_status(' | |
| if health_marker not in code and old_status_route in code: | |
| new_routes = ( | |
| '@app.get("/health")\n' | |
| 'async def _hermesface_health():\n' | |
| ' """HermesFace: liveness alias for /api/status."""\n' | |
| ' return await get_status()\n\n\n' | |
| '@app.get("/ready")\n' | |
| 'async def _hermesface_ready():\n' | |
| ' """HermesFace: readiness alias for /api/status."""\n' | |
| ' return await get_status()\n\n\n' | |
| + old_status_route | |
| ) | |
| code = code.replace(old_status_route, new_routes, 1) | |
| changed = True | |
| print("[SYNC] Added /health and /ready aliases (delegate to /api/status)") | |
| if changed: | |
| ws_path.write_text(code) | |
| except Exception as e: | |
| print(f"[SYNC] web_server patch failed (non-fatal): {e}") | |
| def _install_futures_overlay(self): | |
| """Copy the HermesFace Futures trading overlay into /opt/hermes. | |
| Source lives at /opt/data/hermes_overlay (COPY'd from this repo's | |
| `hermes_overlay/` at build time, see Dockerfile). `tools/` is | |
| auto-discovered by tools.registry.discover_builtin_tools() purely by | |
| living in /opt/hermes/tools β no explicit import wiring needed. | |
| `trading/` is a plain importable package alongside `tools/`, `agent/`, | |
| etc. at the /opt/hermes project root. | |
| """ | |
| # Keep the deployed source outside /opt/data: persistence restore may | |
| # contain an older hermes_overlay snapshot and must never downgrade | |
| # the code shipped in the current Space image. | |
| overlay_src = Path("/opt/hermesface_overlay") | |
| if not overlay_src.exists(): | |
| overlay_src = HERMES_DATA / "hermes_overlay" | |
| if not overlay_src.exists(): | |
| print("[SYNC] No hermes_overlay found at /opt/data/hermes_overlay β skipping Futures install") | |
| return | |
| try: | |
| trading_src = overlay_src / "trading" | |
| if trading_src.exists(): | |
| shutil.copytree(trading_src, APP_DIR / "trading", dirs_exist_ok=True) | |
| tools_src = overlay_src / "tools" | |
| if tools_src.exists(): | |
| for f in tools_src.glob("*.py"): | |
| shutil.copy2(f, APP_DIR / "tools" / f.name) | |
| external_ai_src = overlay_src / "external_ai" | |
| if external_ai_src.exists(): | |
| shutil.copytree(external_ai_src, APP_DIR / "external_ai", dirs_exist_ok=True) | |
| templates_src = tools_src / "templates" | |
| if templates_src.exists(): | |
| shutil.copytree(templates_src, APP_DIR / "tools" / "templates", dirs_exist_ok=True) | |
| tracked = { | |
| "router": ( | |
| tools_src / "futures_dashboard_api.py", | |
| APP_DIR / "tools" / "futures_dashboard_api.py", | |
| ), | |
| "template": ( | |
| templates_src / "hermes_futures_desk_luxury.html", | |
| APP_DIR / "tools" / "templates" / "hermes_futures_desk_luxury.html", | |
| ), | |
| } | |
| def _sha256(path): | |
| if not path.is_file(): | |
| return None | |
| digest = hashlib.sha256() | |
| with path.open("rb") as handle: | |
| for chunk in iter(lambda: handle.read(1024 * 1024), b""): | |
| digest.update(chunk) | |
| return digest.hexdigest() | |
| manifest = { | |
| "installedAt": datetime.now().astimezone().isoformat(), | |
| "sourceRoot": str(overlay_src), | |
| "destinationRoot": str(APP_DIR), | |
| "files": {}, | |
| } | |
| for name, (source_path, destination_path) in tracked.items(): | |
| source_hash = _sha256(source_path) | |
| destination_hash = _sha256(destination_path) | |
| manifest["files"][name] = { | |
| "sourcePath": str(source_path), | |
| "destinationPath": str(destination_path), | |
| "sourceSha256": source_hash, | |
| "destinationSha256": destination_hash, | |
| "matches": bool(source_hash and source_hash == destination_hash), | |
| } | |
| manifest_path = APP_DIR / ".hermes_futures_overlay_manifest.json" | |
| manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") | |
| mismatch = [name for name, item in manifest["files"].items() if not item["matches"]] | |
| if mismatch: | |
| print(f"[SYNC] WARNING: Futures overlay hash mismatch: {', '.join(mismatch)}") | |
| else: | |
| print("[SYNC] Futures overlay hashes verified") | |
| print("[SYNC] Futures trading overlay installed into /opt/hermes") | |
| except Exception as e: | |
| print(f"[SYNC] Futures overlay install failed (non-fatal): {e}") | |
| def _patch_toolsets_futures_trading(self): | |
| """Register the HermesFace Futures toolset with Hermes' static TOOLSETS map. | |
| The futures tools themselves self-register into the tools.registry | |
| under toolset="futures_trading" just by living in /opt/hermes/tools | |
| (see tools/futures_trading_tool.py, discovered the same way as any | |
| built-in tool). But `toolsets.py`'s static TOOLSETS dict is what | |
| config.yaml's `toolsets:` list and the dashboard toolset picker read, | |
| so without this entry the toolset can't be enabled from config. Same | |
| patch-on-boot approach as _patch_web_server_cors -- no new plugin | |
| framework, just extending the one already used here. | |
| """ | |
| ts_path = APP_DIR / "toolsets.py" | |
| if not ts_path.exists(): | |
| return | |
| try: | |
| code = ts_path.read_text(encoding="utf-8") | |
| if '"futures_trading"' in code: | |
| return # already patched (e.g. re-run after restart) | |
| marker = "TOOLSETS = {\n" | |
| if marker not in code: | |
| print("[SYNC] WARNING: toolsets.py TOOLSETS marker not found β skipping patch") | |
| return | |
| entry = ( | |
| marker | |
| + ' "futures_trading": {\n' | |
| + ' "description": (\n' | |
| + ' "HermesFace dual-datasource Futures trading desk: ' | |
| + 'get_market_context, get_alpha_signals, get_futures_account, ' | |
| + 'get_futures_positions, calculate_futures_size, ' | |
| + 'set_leverage_and_margin, execute_futures_position, ' | |
| + 'close_futures_position."\n' | |
| + ' ),\n' | |
| + ' "tools": [\n' | |
| + ' "get_market_context", "get_alpha_signals", ' | |
| + '"get_futures_account", "get_futures_positions", ' | |
| + '"calculate_futures_size", "set_leverage_and_margin", ' | |
| + '"execute_futures_position", "close_futures_position",\n' | |
| + ' ],\n' | |
| + ' "includes": [],\n' | |
| + ' },\n\n' | |
| ) | |
| code = code.replace(marker, entry, 1) | |
| ts_path.write_text(code, encoding="utf-8") | |
| print("[SYNC] Patched toolsets.py: added 'futures_trading' toolset") | |
| except Exception as e: | |
| print(f"[SYNC] toolsets.py patch failed (non-fatal): {e}") | |
| def _patch_web_server_futures_dashboard(self): | |
| """Mount the Futures dashboard router onto the existing FastAPI `app`. | |
| Same server, same port, same process as the rest of HermesFace -- | |
| this is the additive "extend the dashboard" hook, not a separate | |
| frontend. See tools/futures_dashboard_api.py for the actual routes | |
| (/api/futures/status, /api/futures/positions, /futures). | |
| """ | |
| ws_path = APP_DIR / "hermes_cli" / "web_server.py" | |
| if not ws_path.exists(): | |
| return | |
| try: | |
| code = ws_path.read_text(encoding="utf-8") | |
| if "futures_dashboard_api" in code and "telegram_bot" in code: | |
| return # already patched | |
| marker = 'app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan)\n' | |
| if marker not in code: | |
| print("[SYNC] WARNING: web_server.py FastAPI app marker not found β skipping Futures dashboard patch") | |
| return | |
| patch = ( | |
| marker | |
| + "try:\n" | |
| + " from tools.futures_dashboard_api import router as _futures_dashboard_router\n" | |
| + " app.include_router(_futures_dashboard_router)\n" | |
| + " from tools.telegram_bot import router as _telegram_router\n" | |
| + " app.include_router(_telegram_router)\n" | |
| + "except Exception as _futures_dash_exc: # never block boot on this\n" | |
| + " print(f\"[HermesFace] Futures dashboard not mounted: {_futures_dash_exc}\")\n" | |
| ) | |
| code = code.replace(marker, patch, 1) | |
| ws_path.write_text(code, encoding="utf-8") | |
| print("[SYNC] Patched web_server.py: mounted Futures and Telegram webhook routers") | |
| except Exception as e: | |
| print(f"[SYNC] web_server.py Futures dashboard patch failed (non-fatal): {e}") | |
| def _patch_telegram_webhook_public_path(self): | |
| """Let Telegram reach its own HMAC-protected webhook before cookie auth.""" | |
| path = APP_DIR / "hermes_cli" / "dashboard_auth" / "public_paths.py" | |
| if not path.exists(): | |
| return | |
| try: | |
| code = path.read_text(encoding="utf-8") | |
| marker = ' "/api/cron/fire",\n' | |
| if marker in code: | |
| additions = [] | |
| if '"/api/telegram/webhook"' not in code: | |
| additions.append(' "/api/telegram/webhook", # HMAC-protected Telegram ingress\n') | |
| if '"/api/telegram/bootstrap/status"' not in code: | |
| additions.append(' "/api/telegram/bootstrap/status", # secret-protected activation status\n') | |
| if not additions: | |
| return | |
| code = code.replace(marker, marker + "".join(additions), 1) | |
| path.write_text(code, encoding="utf-8") | |
| print("[SYNC] Added HMAC-protected Telegram webhook to public ingress paths") | |
| except Exception as exc: | |
| print(f"[SYNC] Telegram webhook auth-path patch skipped: {exc}") | |
| def _start_process(self, cmd, label, env, log_path): | |
| """Helper to start a subprocess with output logging.""" | |
| log_fh = open(log_path, "a") | |
| try: | |
| process = subprocess.Popen( | |
| cmd, | |
| cwd=str(APP_DIR), | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, | |
| text=True, | |
| bufsize=1, | |
| env=env, | |
| ) | |
| def copy_output(): | |
| try: | |
| for line in process.stdout: | |
| log_fh.write(line) | |
| log_fh.flush() | |
| stripped = line.strip() | |
| if not stripped: | |
| continue | |
| if any(skip in stripped for skip in [ | |
| 'Downloading', 'Fetching', '%|', 'β', 'βββ', | |
| 'Already cached', 'Using cache', 'tokenizer', | |
| '.safetensors', 'model-', 'shard', | |
| ]): | |
| continue | |
| print(line, end='') | |
| except Exception as e: | |
| print(f"[SYNC] {label} output error: {e}") | |
| finally: | |
| log_fh.close() | |
| threading.Thread(target=copy_output, daemon=True).start() | |
| print(f"[SYNC] {label} started (PID {process.pid})") | |
| return process | |
| except Exception as e: | |
| log_fh.close() | |
| print(f"[SYNC] ERROR starting {label}: {e}") | |
| traceback.print_exc() | |
| return None | |
| def run_hermes(self): | |
| """Start Hermes: web dashboard on port 7860, gateway in background if messaging tokens configured.""" | |
| log_dir = HERMES_DATA / "logs" | |
| log_dir.mkdir(parents=True, exist_ok=True) | |
| if not APP_DIR.exists(): | |
| print(f"[SYNC] ERROR: App directory does not exist: {APP_DIR}") | |
| return None | |
| hermes_bin = shutil.which("hermes") or str(APP_DIR / ".venv" / "bin" / "hermes") | |
| if not Path(hermes_bin).exists(): | |
| print("[SYNC] ERROR: hermes CLI not found") | |
| return None | |
| self._configure_admin_auth() | |
| env = os.environ.copy() | |
| env["HERMES_HOME"] = str(HERMES_DATA) | |
| env["GATEWAY_ALLOW_ALL_USERS"] = "true" | |
| # Prevent gateway from grabbing port 7860 | |
| env.pop("API_SERVER_ENABLED", None) | |
| env.pop("API_SERVER_PORT", None) | |
| # Telegram is an isolated optional platform. It is opt-in for the | |
| # Space so regional egress failures cannot create reconnect storms. | |
| if env.get("TELEGRAM_ENABLED", "false").lower() not in {"1", "true", "yes", "on"}: | |
| env.pop("TELEGRAM_BOT_TOKEN", None) | |
| print("[SYNC] Telegram platform disabled (set TELEGRAM_ENABLED=true to enable)") | |
| self._park_unavailable_optional_mcp() | |
| self._disable_legacy_telegram_gateway() | |
| # ββ 1. Install the HermesFace Futures overlay + patch dashboard ββ | |
| self._install_futures_overlay() | |
| self._patch_web_server_cors() | |
| self._patch_toolsets_futures_trading() | |
| self._patch_telegram_webhook_public_path() | |
| self._patch_web_server_futures_dashboard() | |
| # ββ 2. Start web dashboard on port 7860 (HF Spaces frontend) β | |
| # --insecure: required to bind 0.0.0.0; HF Spaces already sandboxes the | |
| # container and Repository Secrets are never exposed to the browser. | |
| dashboard_cmd = [hermes_bin, "dashboard", "--host", "0.0.0.0", "--port", "7860", | |
| "--no-open", "--insecure"] | |
| print("[SYNC] Starting web dashboard on port 7860...") | |
| dashboard_proc = self._start_process( | |
| dashboard_cmd, "Dashboard", env, log_dir / "dashboard.log" | |
| ) | |
| # ββ 3. Start gateway in background (messaging platforms + cron) β | |
| time.sleep(2) # Let dashboard bind 7860 first | |
| gateway_env = env.copy() | |
| gateway_env["GATEWAY_ALLOW_ALL_USERS"] = "true" | |
| # Webhook mode owns Telegram updates; never start Hermes' polling | |
| # adapter alongside it. The dashboard process still retains the token | |
| # for optional direct replies/relay delivery. | |
| if gateway_env.get("TELEGRAM_MODE", "webhook").lower() == "webhook": | |
| gateway_env["TELEGRAM_ENABLED"] = "false" | |
| gateway_env.pop("TELEGRAM_BOT_TOKEN", None) | |
| gateway_cmd = [hermes_bin, "gateway"] | |
| print("[SYNC] Starting gateway (messaging platforms)...") | |
| self.gateway_proc = self._start_process( | |
| gateway_cmd, "Gateway", gateway_env, log_dir / "gateway.log" | |
| ) | |
| return dashboard_proc | |
| # ββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main(): | |
| try: | |
| t_main_start = time.time() | |
| t0 = time.time() | |
| sync = HermesFullSync() | |
| print(f"[TIMER] sync_hf init: {time.time() - t0:.1f}s") | |
| # 1. Restore | |
| t0 = time.time() | |
| sync.load_from_repo() | |
| print(f"[TIMER] load_from_repo (restore): {time.time() - t0:.1f}s") | |
| # 2. Background sync | |
| stop_event = threading.Event() | |
| t = threading.Thread(target=sync.background_sync_loop, args=(stop_event,), daemon=True) | |
| t.start() | |
| # 3. Start application (Hermes API server will bind port 7860) | |
| t0 = time.time() | |
| process = sync.run_hermes() | |
| print(f"[TIMER] run_hermes launch: {time.time() - t0:.1f}s") | |
| print(f"[TIMER] Total startup (init β app launched): {time.time() - t_main_start:.1f}s") | |
| # Signal handler | |
| def handle_signal(sig, frame): | |
| print(f"\n[SYNC] Signal {sig} received. Shutting down...") | |
| stop_event.set() | |
| t.join(timeout=10) | |
| # Stop gateway | |
| if hasattr(sync, 'gateway_proc') and sync.gateway_proc: | |
| sync.gateway_proc.terminate() | |
| try: | |
| sync.gateway_proc.wait(timeout=5) | |
| except subprocess.TimeoutExpired: | |
| sync.gateway_proc.kill() | |
| # Stop dashboard | |
| if process: | |
| process.terminate() | |
| try: | |
| process.wait(timeout=5) | |
| except subprocess.TimeoutExpired: | |
| process.kill() | |
| print("[SYNC] Final sync...") | |
| sync.save_to_repo() | |
| sys.exit(0) | |
| signal.signal(signal.SIGINT, handle_signal) | |
| signal.signal(signal.SIGTERM, handle_signal) | |
| # Wait | |
| if process is None: | |
| print("[SYNC] ERROR: Failed to start Hermes process. Exiting.") | |
| stop_event.set() | |
| t.join(timeout=5) | |
| sys.exit(1) | |
| exit_code = process.wait() | |
| print(f"[SYNC] Hermes exited with code {exit_code}") | |
| stop_event.set() | |
| t.join(timeout=10) | |
| print("[SYNC] Final sync...") | |
| sync.save_to_repo() | |
| sys.exit(exit_code) | |
| except Exception as e: | |
| print(f"[SYNC] FATAL ERROR in main: {e}") | |
| traceback.print_exc() | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() | |