Spaces:
Paused
Paused
| #!/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 threading | |
| import subprocess | |
| import signal | |
| import json | |
| import shutil | |
| 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 | |
| # ββ 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(f"[SYNC] Set AUTO_CREATE_DATASET=true to auto-create.") | |
| print(f"[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: | |
| 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}/")] | |
| if not data_files: | |
| print(f"[SYNC] No {DATASET_PATH}/ folder in dataset. Starting fresh.") | |
| self._ensure_default_config() | |
| return | |
| print(f"[SYNC] Found {len(data_files)} files under {DATASET_PATH}/ in dataset") | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| snapshot_download( | |
| repo_id=HF_REPO_ID, | |
| repo_type="dataset", | |
| allow_patterns=f"{DATASET_PATH}/**", | |
| 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 | |
| "__pycache__", # Python cache | |
| "scripts/*", # HermesFace scripts β from git, not data | |
| "assets/*", # Static assets β from git, not 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") | |
| 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}") | |
| # ββ 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 | |
| """ | |
| 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") | |
| if changed: | |
| ws_path.write_text(code) | |
| except Exception as e: | |
| print(f"[SYNC] web_server patch failed (non-fatal): {e}") | |
| 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(f"[SYNC] ERROR: hermes CLI not found") | |
| return None | |
| 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) | |
| # ββ 1. Patch web dashboard CORS for HF Spaces ββββββββββββββββ | |
| self._patch_web_server_cors() | |
| # ββ 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(f"[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" | |
| gateway_cmd = [hermes_bin, "gateway"] | |
| print(f"[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() | |