CoolFace
Apppublic

SaKoRy/HermesFace

sourceHugging Facemitupdated 5mo agoView on Hugging Face
0likes
sync_hf.py537 linesDownload Raw Back to scripts
1#!/usr/bin/env python32"""3Hermes Agent HF Spaces Persistence — Full Directory Sync4=========================================================5 6Simplified persistence: upload/download the entire /opt/data directory7as-is to/from a Hugging Face Dataset repo.8 9- Startup:  snapshot_download  →  /opt/data10- Periodic: upload_folder      →  dataset hermes_data/11- Shutdown: final upload_folder →  dataset hermes_data/12"""13 14import os15import sys16import time17import threading18import subprocess19import signal20import json21import shutil22import tempfile23import traceback24from pathlib import Path25from datetime import datetime26# Set timeout BEFORE importing huggingface_hub27os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "300")28os.environ.setdefault("HF_HUB_UPLOAD_TIMEOUT", "600")29os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")30os.environ.setdefault("HF_HUB_VERBOSITY", "warning")31 32import logging as _logging33_logging.getLogger("huggingface_hub").setLevel(_logging.WARNING)34_logging.getLogger("huggingface_hub.utils").setLevel(_logging.WARNING)35_logging.getLogger("filelock").setLevel(_logging.WARNING)36 37from huggingface_hub import HfApi, snapshot_download38 39# ── Logging helper ──────────────────────────────────────────────────────────40 41class TeeLogger:42    """Duplicate output to stream and file."""43    def __init__(self, filename, stream):44        self.stream = stream45        self.file = open(filename, "a", encoding="utf-8")46    def write(self, message):47        self.stream.write(message)48        self.file.write(message)49        self.flush()50    def flush(self):51        self.stream.flush()52        self.file.flush()53    def fileno(self):54        return self.stream.fileno()55 56# ── Configuration ───────────────────────────────────────────────────────────57 58HF_TOKEN      = os.environ.get("HF_TOKEN")59HERMES_DATA   = Path("/opt/data")60APP_DIR       = Path("/opt/hermes")61DATASET_PATH  = "hermes_data"62 63AGENT_NAME = os.environ.get("AGENT_NAME", "HermesFace")64 65# HF Spaces built-in env vars (auto-set by HF runtime)66SPACE_HOST = os.environ.get("SPACE_HOST", "")67SPACE_ID   = os.environ.get("SPACE_ID", "")68 69SYNC_INTERVAL = int(os.environ.get("SYNC_INTERVAL", "60"))70AUTO_CREATE_DATASET = os.environ.get("AUTO_CREATE_DATASET", "true").lower() in ("true", "1", "yes")71 72# Dataset repo: auto-derive from SPACE_ID when not explicitly set.73# Format: {username}/{SpaceName}-data74HF_REPO_ID = os.environ.get("HERMES_DATASET_REPO", "")75if not HF_REPO_ID and SPACE_ID:76    HF_REPO_ID = f"{SPACE_ID}-data"77    print(f"[SYNC] HERMES_DATASET_REPO not set — auto-derived from SPACE_ID: {HF_REPO_ID}")78elif not HF_REPO_ID and HF_TOKEN:79    try:80        _api = HfApi(token=HF_TOKEN)81        _username = _api.whoami()["name"]82        HF_REPO_ID = f"{_username}/HermesFace-data"83        print(f"[SYNC] HERMES_DATASET_REPO not set — auto-derived from HF_TOKEN: {HF_REPO_ID}")84        del _api, _username85    except Exception as e:86        print(f"[SYNC] WARNING: Could not derive username from HF_TOKEN: {e}")87        HF_REPO_ID = ""88 89# Setup logging90log_dir = HERMES_DATA / "logs"91log_dir.mkdir(parents=True, exist_ok=True)92sys.stdout = TeeLogger(log_dir / "sync.log", sys.stdout)93sys.stderr = sys.stdout94 95 96# ── Sync Manager ────────────────────────────────────────────────────────────97 98class HermesFullSync:99    """Upload/download the entire /opt/data directory to HF Dataset."""100 101    def __init__(self):102        self.enabled = False103        self.dataset_exists = False104        self.api = None105 106        if not HF_TOKEN:107            print("[SYNC] WARNING: HF_TOKEN not set. Persistence disabled.")108            return109        if not HF_REPO_ID:110            print("[SYNC] WARNING: Could not determine dataset repo (no SPACE_ID or HERMES_DATASET_REPO).")111            print("[SYNC] Persistence disabled.")112            return113 114        self.enabled = True115        self.api = HfApi(token=HF_TOKEN)116        self.dataset_exists = self._ensure_repo_exists()117 118    # ── Repo management ────────────────────────────────────────────────119 120    def _ensure_repo_exists(self):121        """Check if dataset repo exists; auto-create only when AUTO_CREATE_DATASET=true."""122        try:123            self.api.repo_info(repo_id=HF_REPO_ID, repo_type="dataset")124            print(f"[SYNC] Dataset repo found: {HF_REPO_ID}")125            return True126        except Exception:127            if not AUTO_CREATE_DATASET:128                print(f"[SYNC] Dataset repo NOT found: {HF_REPO_ID}")129                print(f"[SYNC]   Set AUTO_CREATE_DATASET=true to auto-create.")130                print(f"[SYNC] Persistence disabled (app will still run normally).")131                return False132            print(f"[SYNC] Dataset repo NOT found: {HF_REPO_ID} — creating...")133            try:134                self.api.create_repo(135                    repo_id=HF_REPO_ID,136                    repo_type="dataset",137                    private=True,138                )139                print(f"[SYNC] Dataset repo created: {HF_REPO_ID}")140                return True141            except Exception as e:142                print(f"[SYNC] Failed to create dataset repo: {e}")143                return False144 145    # ── Restore (startup) ─────────────────────────────────────────────146 147    def load_from_repo(self):148        """Download from dataset → /opt/data"""149        if not self.enabled:150            print("[SYNC] Persistence disabled - skipping restore")151            self._ensure_default_config()152            return153 154        if not self.dataset_exists:155            print(f"[SYNC] Dataset {HF_REPO_ID} does not exist - starting fresh")156            self._ensure_default_config()157            return158 159        print(f"[SYNC] Restoring /opt/data from dataset {HF_REPO_ID} ...")160        HERMES_DATA.mkdir(parents=True, exist_ok=True)161 162        try:163            files = self.api.list_repo_files(repo_id=HF_REPO_ID, repo_type="dataset")164            data_files = [f for f in files if f.startswith(f"{DATASET_PATH}/")]165            if not data_files:166                print(f"[SYNC] No {DATASET_PATH}/ folder in dataset. Starting fresh.")167                self._ensure_default_config()168                return169 170            print(f"[SYNC] Found {len(data_files)} files under {DATASET_PATH}/ in dataset")171 172            with tempfile.TemporaryDirectory() as tmpdir:173                snapshot_download(174                    repo_id=HF_REPO_ID,175                    repo_type="dataset",176                    allow_patterns=f"{DATASET_PATH}/**",177                    local_dir=tmpdir,178                    token=HF_TOKEN,179                )180                downloaded_root = Path(tmpdir) / DATASET_PATH181                if downloaded_root.exists():182                    for item in downloaded_root.rglob("*"):183                        if item.is_file():184                            rel = item.relative_to(downloaded_root)185                            dest = HERMES_DATA / rel186                            dest.parent.mkdir(parents=True, exist_ok=True)187                            shutil.copy2(str(item), str(dest))188                    print("[SYNC] Restore completed.")189                else:190                    print("[SYNC] Downloaded snapshot but dir not found. Starting fresh.")191 192        except Exception as e:193            print(f"[SYNC] Restore failed: {e}")194            traceback.print_exc()195 196        self._ensure_default_config()197        self._debug_list_files()198 199    # ── Save (periodic + shutdown) ─────────────────────────────────────200 201    def save_to_repo(self):202        """Upload entire /opt/data directory → dataset (all files, no filtering)"""203        if not self.enabled:204            return205        if not HERMES_DATA.exists():206            print("[SYNC] /opt/data does not exist, nothing to save.")207            return208 209        if not self._ensure_repo_exists():210            print(f"[SYNC] Dataset {HF_REPO_ID} unavailable - skipping save")211            return212 213        print(f"[SYNC] Uploading /opt/data → dataset {HF_REPO_ID}/{DATASET_PATH}/ ...")214 215        try:216            total_size = 0217            file_count = 0218            for root, dirs, fls in os.walk(HERMES_DATA):219                for fn in fls:220                    fp = os.path.join(root, fn)221                    total_size += os.path.getsize(fp)222                    file_count += 1223            print(f"[SYNC] Uploading: {file_count} files, {total_size} bytes total")224 225            if file_count == 0:226                print("[SYNC] Nothing to upload.")227                return228 229            self.api.upload_folder(230                folder_path=str(HERMES_DATA),231                path_in_repo=DATASET_PATH,232                repo_id=HF_REPO_ID,233                repo_type="dataset",234                token=HF_TOKEN,235                commit_message=f"Sync hermes_data — {datetime.now().isoformat()}",236                ignore_patterns=[237                    "*.log",        # Log files — regenerated on boot238                    "*.lock",       # Lock files — stale after restart239                    "*.tmp",        # Temp files240                    "*.pid",        # PID files241                    "__pycache__",  # Python cache242                    "scripts/*",    # HermesFace scripts — from git, not data243                    "assets/*",     # Static assets — from git, not data244                ],245            )246            print(f"[SYNC] Upload completed at {datetime.now().isoformat()}")247 248            try:249                files = self.api.list_repo_files(repo_id=HF_REPO_ID, repo_type="dataset")250                data_files = [f for f in files if f.startswith(f"{DATASET_PATH}/")]251                print(f"[SYNC] Dataset now has {len(data_files)} files under {DATASET_PATH}/")252            except Exception:253                pass254 255        except Exception as e:256            print(f"[SYNC] Upload failed: {e}")257            traceback.print_exc()258 259    # ── Config helpers ─────────────────────────────────────────────────260 261    def _ensure_default_config(self):262        """Ensure Hermes has config.yaml and .env for HF Spaces."""263        config_path = HERMES_DATA / "config.yaml"264        env_path = HERMES_DATA / ".env"265        soul_path = HERMES_DATA / "SOUL.md"266 267        # Bootstrap from Hermes templates if available268        if not config_path.exists():269            template = APP_DIR / "cli-config.yaml.example"270            if template.exists():271                shutil.copy2(str(template), str(config_path))272                print("[SYNC] Created config.yaml from Hermes template")273            else:274                # Minimal fallback config275                import yaml276                config = {277                    "agent": {"name": AGENT_NAME},278                    "server": {"host": "0.0.0.0", "port": 7860},279                }280                with open(config_path, "w") as f:281                    yaml.dump(config, f, default_flow_style=False)282                print(f"[SYNC] Created minimal config.yaml (agent={AGENT_NAME}, port=7860)")283 284        if not env_path.exists():285            template = APP_DIR / ".env.example"286            if template.exists():287                shutil.copy2(str(template), str(env_path))288                print("[SYNC] Created .env from Hermes template")289            else:290                env_lines = []291                for key in [292                    "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",293                    "NOUS_API_KEY", "GOOGLE_API_KEY", "MISTRAL_API_KEY",294                    "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN",295                ]:296                    val = os.environ.get(key, "")297                    if val:298                        env_lines.append(f"{key}={val}")299                if env_lines:300                    with open(env_path, "w") as f:301                        f.write("\n".join(env_lines) + "\n")302                    print(f"[SYNC] Created .env with {len(env_lines)} keys")303 304        if not soul_path.exists():305            template = APP_DIR / "docker" / "SOUL.md"306            if template.exists():307                shutil.copy2(str(template), str(soul_path))308                print("[SYNC] Created SOUL.md from Hermes template")309            else:310                with open(soul_path, "w") as f:311                    f.write(f"# {AGENT_NAME}\n\nI am {AGENT_NAME}, a self-improving AI assistant powered by Hermes Agent.\n")312                print("[SYNC] Created default SOUL.md")313 314    def _debug_list_files(self):315        try:316            count = sum(1 for _, _, files in os.walk(HERMES_DATA) for _ in files)317            print(f"[SYNC] Local /opt/data: {count} files")318        except Exception as e:319            print(f"[SYNC] listing failed: {e}")320 321    # ── Background sync loop ──────────────────────────────────────────322 323    def background_sync_loop(self, stop_event):324        print(f"[SYNC] Background sync started (interval={SYNC_INTERVAL}s)")325        while not stop_event.is_set():326            if stop_event.wait(timeout=SYNC_INTERVAL):327                break328            print(f"[SYNC] Periodic sync triggered at {datetime.now().isoformat()}")329            self.save_to_repo()330 331    # ── Application runner ─────────────────────────────────────────────332 333    def _patch_web_server_cors(self):334        """Patch Hermes web_server.py:335        - Allow any origin (HF Spaces iframe, custom domains)336        - Allow iframe embedding in huggingface.co + *.hf.space337        """338        ws_path = APP_DIR / "hermes_cli" / "web_server.py"339        if not ws_path.exists():340            return341        try:342            code = ws_path.read_text()343            changed = False344 345            old_cors = 'allow_origin_regex=r"^https?://(localhost|127\\.0\\.0\\.1)(:\\d+)?$"'346            new_cors = 'allow_origins=["*"]'347            if old_cors in code:348                code = code.replace(old_cors, new_cors)349                changed = True350                print("[SYNC] Patched web_server.py CORS for HF Spaces")351 352            # Neutralise X-Frame-Options so HF Spaces can embed the dashboard.353            for pat in ('X-Frame-Options", "DENY"', 'X-Frame-Options", "SAMEORIGIN"'):354                if pat in code:355                    code = code.replace(pat, 'X-Frame-Options", "ALLOWALL"')356                    changed = True357                    print("[SYNC] Relaxed X-Frame-Options for HF Spaces")358 359            # Relax CSP frame-ancestors if present.360            csp_old = 'frame-ancestors \'none\''361            csp_new = "frame-ancestors 'self' https://huggingface.co https://*.hf.space"362            if csp_old in code:363                code = code.replace(csp_old, csp_new)364                changed = True365                print("[SYNC] Relaxed CSP frame-ancestors for HF Spaces")366 367            if changed:368                ws_path.write_text(code)369        except Exception as e:370            print(f"[SYNC] web_server patch failed (non-fatal): {e}")371 372    def _start_process(self, cmd, label, env, log_path):373        """Helper to start a subprocess with output logging."""374        log_fh = open(log_path, "a")375        try:376            process = subprocess.Popen(377                cmd,378                cwd=str(APP_DIR),379                stdout=subprocess.PIPE,380                stderr=subprocess.STDOUT,381                text=True,382                bufsize=1,383                env=env,384            )385 386            def copy_output():387                try:388                    for line in process.stdout:389                        log_fh.write(line)390                        log_fh.flush()391                        stripped = line.strip()392                        if not stripped:393                            continue394                        if any(skip in stripped for skip in [395                            'Downloading', 'Fetching', '%|', '━', '───',396                            'Already cached', 'Using cache', 'tokenizer',397                            '.safetensors', 'model-', 'shard',398                        ]):399                            continue400                        print(line, end='')401                except Exception as e:402                    print(f"[SYNC] {label} output error: {e}")403                finally:404                    log_fh.close()405 406            threading.Thread(target=copy_output, daemon=True).start()407            print(f"[SYNC] {label} started (PID {process.pid})")408            return process409        except Exception as e:410            log_fh.close()411            print(f"[SYNC] ERROR starting {label}: {e}")412            traceback.print_exc()413            return None414 415    def run_hermes(self):416        """Start Hermes: web dashboard on port 7860, gateway in background if messaging tokens configured."""417        log_dir = HERMES_DATA / "logs"418        log_dir.mkdir(parents=True, exist_ok=True)419 420        if not APP_DIR.exists():421            print(f"[SYNC] ERROR: App directory does not exist: {APP_DIR}")422            return None423 424        hermes_bin = shutil.which("hermes") or str(APP_DIR / ".venv" / "bin" / "hermes")425        if not Path(hermes_bin).exists():426            print(f"[SYNC] ERROR: hermes CLI not found")427            return None428 429        env = os.environ.copy()430        env["HERMES_HOME"] = str(HERMES_DATA)431        env["GATEWAY_ALLOW_ALL_USERS"] = "true"432        # Prevent gateway from grabbing port 7860433        env.pop("API_SERVER_ENABLED", None)434        env.pop("API_SERVER_PORT", None)435 436        # ── 1. Patch web dashboard CORS for HF Spaces ────────────────437        self._patch_web_server_cors()438 439        # ── 2. Start web dashboard on port 7860 (HF Spaces frontend) ─440        # --insecure: required to bind 0.0.0.0; HF Spaces already sandboxes the441        # container and Repository Secrets are never exposed to the browser.442        dashboard_cmd = [hermes_bin, "dashboard", "--host", "0.0.0.0", "--port", "7860",443                         "--no-open", "--insecure"]444        print(f"[SYNC] Starting web dashboard on port 7860...")445        dashboard_proc = self._start_process(446            dashboard_cmd, "Dashboard", env, log_dir / "dashboard.log"447        )448 449        # ── 3. Start gateway in background (messaging platforms + cron) ─450        time.sleep(2)  # Let dashboard bind 7860 first451        gateway_env = env.copy()452        gateway_env["GATEWAY_ALLOW_ALL_USERS"] = "true"453        gateway_cmd = [hermes_bin, "gateway"]454        print(f"[SYNC] Starting gateway (messaging platforms)...")455        self.gateway_proc = self._start_process(456            gateway_cmd, "Gateway", gateway_env, log_dir / "gateway.log"457        )458 459        return dashboard_proc460 461 462# ── Main ────────────────────────────────────────────────────────────────────463 464def main():465    try:466        t_main_start = time.time()467 468        t0 = time.time()469        sync = HermesFullSync()470        print(f"[TIMER] sync_hf init: {time.time() - t0:.1f}s")471 472        # 1. Restore473        t0 = time.time()474        sync.load_from_repo()475        print(f"[TIMER] load_from_repo (restore): {time.time() - t0:.1f}s")476 477        # 2. Background sync478        stop_event = threading.Event()479        t = threading.Thread(target=sync.background_sync_loop, args=(stop_event,), daemon=True)480        t.start()481 482        # 3. Start application (Hermes API server will bind port 7860)483        t0 = time.time()484        process = sync.run_hermes()485        print(f"[TIMER] run_hermes launch: {time.time() - t0:.1f}s")486        print(f"[TIMER] Total startup (init → app launched): {time.time() - t_main_start:.1f}s")487 488        # Signal handler489        def handle_signal(sig, frame):490            print(f"\n[SYNC] Signal {sig} received. Shutting down...")491            stop_event.set()492            t.join(timeout=10)493            # Stop gateway494            if hasattr(sync, 'gateway_proc') and sync.gateway_proc:495                sync.gateway_proc.terminate()496                try:497                    sync.gateway_proc.wait(timeout=5)498                except subprocess.TimeoutExpired:499                    sync.gateway_proc.kill()500            # Stop dashboard501            if process:502                process.terminate()503                try:504                    process.wait(timeout=5)505                except subprocess.TimeoutExpired:506                    process.kill()507            print("[SYNC] Final sync...")508            sync.save_to_repo()509            sys.exit(0)510 511        signal.signal(signal.SIGINT, handle_signal)512        signal.signal(signal.SIGTERM, handle_signal)513 514        # Wait515        if process is None:516            print("[SYNC] ERROR: Failed to start Hermes process. Exiting.")517            stop_event.set()518            t.join(timeout=5)519            sys.exit(1)520 521        exit_code = process.wait()522        print(f"[SYNC] Hermes exited with code {exit_code}")523        stop_event.set()524        t.join(timeout=10)525        print("[SYNC] Final sync...")526        sync.save_to_repo()527        sys.exit(exit_code)528 529    except Exception as e:530        print(f"[SYNC] FATAL ERROR in main: {e}")531        traceback.print_exc()532        sys.exit(1)533 534 535if __name__ == "__main__":536    main()537