CoolFace
Apppublic

rahmad7/hermes-openmodel

sourceHugging Facemitupdated 3mo 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 shutil21import tempfile22import traceback23from pathlib import Path24from datetime import datetime25# Set timeout BEFORE importing huggingface_hub26os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "300")27os.environ.setdefault("HF_HUB_UPLOAD_TIMEOUT", "600")28os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")29os.environ.setdefault("HF_HUB_VERBOSITY", "warning")30 31import logging as _logging32_logging.getLogger("huggingface_hub").setLevel(_logging.WARNING)33_logging.getLogger("huggingface_hub.utils").setLevel(_logging.WARNING)34_logging.getLogger("filelock").setLevel(_logging.WARNING)35 36from huggingface_hub import HfApi, snapshot_download37 38# ── Logging helper ──────────────────────────────────────────────────────────39 40class TeeLogger:41    """Duplicate output to stream and file."""42    def __init__(self, filename, stream):43        self.stream = stream44        self.file = open(filename, "a", encoding="utf-8")45    def write(self, message):46        self.stream.write(message)47        self.file.write(message)48        self.flush()49    def flush(self):50        self.stream.flush()51        self.file.flush()52    def fileno(self):53        return self.stream.fileno()54 55# ── Configuration ───────────────────────────────────────────────────────────56 57HF_TOKEN      = os.environ.get("HF_TOKEN")58HERMES_DATA   = Path("/opt/data")59APP_DIR       = Path("/opt/hermes")60DATASET_PATH  = "hermes_data"61 62AGENT_NAME = os.environ.get("AGENT_NAME", "HermesFace")63 64# HF Spaces built-in env vars (auto-set by HF runtime)65SPACE_HOST = os.environ.get("SPACE_HOST", "")66SPACE_ID   = os.environ.get("SPACE_ID", "")67 68SYNC_INTERVAL = int(os.environ.get("SYNC_INTERVAL", "60"))69AUTO_CREATE_DATASET = os.environ.get("AUTO_CREATE_DATASET", "true").lower() in ("true", "1", "yes")70 71# Dataset repo: auto-derive from SPACE_ID when not explicitly set.72# Format: {username}/{SpaceName}-data73HF_REPO_ID = os.environ.get("HERMES_DATASET_REPO", "")74if not HF_REPO_ID and SPACE_ID:75    HF_REPO_ID = f"{SPACE_ID}-data"76    print(f"[SYNC] HERMES_DATASET_REPO not set — auto-derived from SPACE_ID: {HF_REPO_ID}")77elif not HF_REPO_ID and HF_TOKEN:78    try:79        _api = HfApi(token=HF_TOKEN)80        _username = _api.whoami()["name"]81        HF_REPO_ID = f"{_username}/HermesFace-data"82        print(f"[SYNC] HERMES_DATASET_REPO not set — auto-derived from HF_TOKEN: {HF_REPO_ID}")83        del _api, _username84    except Exception as e:85        print(f"[SYNC] WARNING: Could not derive username from HF_TOKEN: {e}")86        HF_REPO_ID = ""87 88# Setup logging89log_dir = HERMES_DATA / "logs"90log_dir.mkdir(parents=True, exist_ok=True)91sys.stdout = TeeLogger(log_dir / "sync.log", sys.stdout)92sys.stderr = sys.stdout93 94 95# ── Sync Manager ────────────────────────────────────────────────────────────96 97class HermesFullSync:98    """Upload/download the entire /opt/data directory to HF Dataset."""99 100    def __init__(self):101        self.enabled = False102        self.dataset_exists = False103        self.api = None104 105        if not HF_TOKEN:106            print("[SYNC] WARNING: HF_TOKEN not set. Persistence disabled.")107            return108        if not HF_REPO_ID:109            print("[SYNC] WARNING: Could not determine dataset repo (no SPACE_ID or HERMES_DATASET_REPO).")110            print("[SYNC] Persistence disabled.")111            return112 113        self.enabled = True114        self.api = HfApi(token=HF_TOKEN)115        self.dataset_exists = self._ensure_repo_exists()116 117    # ── Repo management ────────────────────────────────────────────────118 119    def _ensure_repo_exists(self):120        """Check if dataset repo exists; auto-create only when AUTO_CREATE_DATASET=true."""121        try:122            self.api.repo_info(repo_id=HF_REPO_ID, repo_type="dataset")123            print(f"[SYNC] Dataset repo found: {HF_REPO_ID}")124            return True125        except Exception:126            if not AUTO_CREATE_DATASET:127                print(f"[SYNC] Dataset repo NOT found: {HF_REPO_ID}")128                print("[SYNC]   Set AUTO_CREATE_DATASET=true to auto-create.")129                print("[SYNC] Persistence disabled (app will still run normally).")130                return False131            print(f"[SYNC] Dataset repo NOT found: {HF_REPO_ID} — creating...")132            try:133                self.api.create_repo(134                    repo_id=HF_REPO_ID,135                    repo_type="dataset",136                    private=True,137                )138                print(f"[SYNC] Dataset repo created: {HF_REPO_ID}")139                return True140            except Exception as e:141                print(f"[SYNC] Failed to create dataset repo: {e}")142                return False143 144    # ── Restore (startup) ─────────────────────────────────────────────145 146    def load_from_repo(self):147        """Download from dataset → /opt/data"""148        if not self.enabled:149            print("[SYNC] Persistence disabled - skipping restore")150            self._ensure_default_config()151            return152 153        if not self.dataset_exists:154            print(f"[SYNC] Dataset {HF_REPO_ID} does not exist - starting fresh")155            self._ensure_default_config()156            return157 158        print(f"[SYNC] Restoring /opt/data from dataset {HF_REPO_ID} ...")159        HERMES_DATA.mkdir(parents=True, exist_ok=True)160 161        try:162            files = self.api.list_repo_files(repo_id=HF_REPO_ID, repo_type="dataset")163            data_files = [f for f in files if f.startswith(f"{DATASET_PATH}/")]164            if not data_files:165                print(f"[SYNC] No {DATASET_PATH}/ folder in dataset. Starting fresh.")166                self._ensure_default_config()167                return168 169            print(f"[SYNC] Found {len(data_files)} files under {DATASET_PATH}/ in dataset")170 171            with tempfile.TemporaryDirectory() as tmpdir:172                snapshot_download(173                    repo_id=HF_REPO_ID,174                    repo_type="dataset",175                    allow_patterns=f"{DATASET_PATH}/**",176                    local_dir=tmpdir,177                    token=HF_TOKEN,178                )179                downloaded_root = Path(tmpdir) / DATASET_PATH180                if downloaded_root.exists():181                    for item in downloaded_root.rglob("*"):182                        if item.is_file():183                            rel = item.relative_to(downloaded_root)184                            # NEVER restore scripts or assets — they come from Docker image, not dataset185                            if rel.parts and rel.parts[0] in ("scripts", "assets"):186                                continue187                            dest = HERMES_DATA / rel188                            dest.parent.mkdir(parents=True, exist_ok=True)189                            shutil.copy2(str(item), str(dest))190                    print("[SYNC] Restore completed (scripts/assets skipped — from Docker image).")191                else:192                    print("[SYNC] Downloaded snapshot but dir not found. Starting fresh.")193 194        except Exception as e:195            print(f"[SYNC] Restore failed: {e}")196            traceback.print_exc()197 198        self._ensure_default_config()199        self._debug_list_files()200 201    # ── Save (periodic + shutdown) ─────────────────────────────────────202 203    def save_to_repo(self):204        """Upload entire /opt/data directory → dataset (all files, no filtering)"""205        if not self.enabled:206            return207        if not HERMES_DATA.exists():208            print("[SYNC] /opt/data does not exist, nothing to save.")209            return210 211        if not self._ensure_repo_exists():212            print(f"[SYNC] Dataset {HF_REPO_ID} unavailable - skipping save")213            return214 215        print(f"[SYNC] Uploading /opt/data → dataset {HF_REPO_ID}/{DATASET_PATH}/ ...")216 217        try:218            total_size = 0219            file_count = 0220            for root, dirs, fls in os.walk(HERMES_DATA):221                for fn in fls:222                    fp = os.path.join(root, fn)223                    total_size += os.path.getsize(fp)224                    file_count += 1225            print(f"[SYNC] Uploading: {file_count} files, {total_size} bytes total")226 227            if file_count == 0:228                print("[SYNC] Nothing to upload.")229                return230 231            self.api.upload_folder(232                folder_path=str(HERMES_DATA),233                path_in_repo=DATASET_PATH,234                repo_id=HF_REPO_ID,235                repo_type="dataset",236                token=HF_TOKEN,237                commit_message=f"Sync hermes_data — {datetime.now().isoformat()}",238                ignore_patterns=[239                    "*.log",        # Log files — regenerated on boot240                    "*.lock",       # Lock files — stale after restart241                    "*.tmp",        # Temp files242                    "*.pid",        # PID files243                    "*.env",        # NEVER upload env files — contain secrets244                    ".env",         # NEVER upload .env — contain secrets245                    "__pycache__",  # Python cache246                    ".cache/",      # Cache dirs (uv, pip, etc.)247                    "scripts/*",    # HermesFace scripts — from git, not data248                    "assets/*",     # Static assets — from git, not data249                ],250            )251            print(f"[SYNC] Upload completed at {datetime.now().isoformat()}")252 253            try:254                files = self.api.list_repo_files(repo_id=HF_REPO_ID, repo_type="dataset")255                data_files = [f for f in files if f.startswith(f"{DATASET_PATH}/")]256                print(f"[SYNC] Dataset now has {len(data_files)} files under {DATASET_PATH}/")257            except Exception:258                pass259 260        except Exception as e:261            print(f"[SYNC] Upload failed: {e}")262            traceback.print_exc()263 264    # ── Config helpers ─────────────────────────────────────────────────265 266    def _ensure_default_config(self):267        """Ensure Hermes has config.yaml and .env for HF Spaces."""268        config_path = HERMES_DATA / "config.yaml"269        env_path = HERMES_DATA / ".env"270        soul_path = HERMES_DATA / "SOUL.md"271 272        # Bootstrap from Hermes templates if available273        if not config_path.exists():274            template = APP_DIR / "cli-config.yaml.example"275            if template.exists():276                shutil.copy2(str(template), str(config_path))277                print("[SYNC] Created config.yaml from Hermes template")278            else:279                # Minimal fallback config280                import yaml281                config = {282                    "agent": {"name": AGENT_NAME},283                    "server": {"host": "0.0.0.0", "port": 7860},284                }285                with open(config_path, "w") as f:286                    yaml.dump(config, f, default_flow_style=False)287                print(f"[SYNC] Created minimal config.yaml (agent={AGENT_NAME}, port=7860)")288 289        if not env_path.exists():290            template = APP_DIR / ".env.example"291            if template.exists():292                shutil.copy2(str(template), str(env_path))293                print("[SYNC] Created .env from Hermes template")294            else:295                env_lines = []296                for key in [297                    "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",298                    "NOUS_API_KEY", "GOOGLE_API_KEY", "MISTRAL_API_KEY",299                    "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN",300                ]:301                    val = os.environ.get(key, "")302                    if val:303                        env_lines.append(f"{key}={val}")304                if env_lines:305                    with open(env_path, "w") as f:306                        f.write("\n".join(env_lines) + "\n")307                    print(f"[SYNC] Created .env with {len(env_lines)} keys")308 309        if not soul_path.exists():310            template = APP_DIR / "docker" / "SOUL.md"311            if template.exists():312                shutil.copy2(str(template), str(soul_path))313                print("[SYNC] Created SOUL.md from Hermes template")314            else:315                with open(soul_path, "w") as f:316                    f.write(f"# {AGENT_NAME}\n\nI am {AGENT_NAME}, a self-improving AI assistant powered by Hermes Agent.\n")317                print("[SYNC] Created default SOUL.md")318 319    def _debug_list_files(self):320        try:321            count = sum(1 for _, _, files in os.walk(HERMES_DATA) for _ in files)322            print(f"[SYNC] Local /opt/data: {count} files")323        except Exception as e:324            print(f"[SYNC] listing failed: {e}")325 326    # ── Background sync loop ──────────────────────────────────────────327 328    def background_sync_loop(self, stop_event):329        print(f"[SYNC] Background sync started (interval={SYNC_INTERVAL}s)")330        while not stop_event.is_set():331            if stop_event.wait(timeout=SYNC_INTERVAL):332                break333            print(f"[SYNC] Periodic sync triggered at {datetime.now().isoformat()}")334            self.save_to_repo()335 336    # ── Application runner ─────────────────────────────────────────────337 338    def _patch_web_server_cors(self):339        """Patch Hermes web_server.py:340        - Allow any origin (HF Spaces iframe, custom domains)341        - Allow iframe embedding in huggingface.co + *.hf.space342        """343        ws_path = APP_DIR / "hermes_cli" / "web_server.py"344        if not ws_path.exists():345            return346        try:347            code = ws_path.read_text()348            changed = False349 350            old_cors = 'allow_origin_regex=r"^https?://(localhost|127\\.0\\.0\\.1)(:\\d+)?$"'351            new_cors = 'allow_origins=["*"]'352            if old_cors in code:353                code = code.replace(old_cors, new_cors)354                changed = True355                print("[SYNC] Patched web_server.py CORS for HF Spaces")356 357            # Neutralise X-Frame-Options so HF Spaces can embed the dashboard.358            for pat in ('X-Frame-Options", "DENY"', 'X-Frame-Options", "SAMEORIGIN"'):359                if pat in code:360                    code = code.replace(pat, 'X-Frame-Options", "ALLOWALL"')361                    changed = True362                    print("[SYNC] Relaxed X-Frame-Options for HF Spaces")363 364            # Relax CSP frame-ancestors if present.365            csp_old = 'frame-ancestors \'none\''366            csp_new = "frame-ancestors 'self' https://huggingface.co https://*.hf.space"367            if csp_old in code:368                code = code.replace(csp_old, csp_new)369                changed = True370                print("[SYNC] Relaxed CSP frame-ancestors for HF Spaces")371 372            if changed:373                ws_path.write_text(code)374        except Exception as e:375            print(f"[SYNC] web_server patch failed (non-fatal): {e}")376 377    def _start_process(self, cmd, label, env, log_path):378        """Helper to start a subprocess with output logging."""379        log_fh = open(log_path, "a")380        try:381            process = subprocess.Popen(382                cmd,383                cwd=str(APP_DIR),384                stdout=subprocess.PIPE,385                stderr=subprocess.STDOUT,386                text=True,387                bufsize=1,388                env=env,389            )390 391            def copy_output():392                try:393                    for line in process.stdout:394                        log_fh.write(line)395                        log_fh.flush()396                        stripped = line.strip()397                        if not stripped:398                            continue399                        if any(skip in stripped for skip in [400                            'Downloading', 'Fetching', '%|', '━', '───',401                            'Already cached', 'Using cache', 'tokenizer',402                            '.safetensors', 'model-', 'shard',403                        ]):404                            continue405                        print(line, end='')406                except Exception as e:407                    print(f"[SYNC] {label} output error: {e}")408                finally:409                    log_fh.close()410 411            threading.Thread(target=copy_output, daemon=True).start()412            print(f"[SYNC] {label} started (PID {process.pid})")413            return process414        except Exception as e:415            log_fh.close()416            print(f"[SYNC] ERROR starting {label}: {e}")417            traceback.print_exc()418            return None419 420    def run_hermes(self):421        """Start Hermes gateway on port 7860 — auth gate on web UI, Telegram bridge outbound."""422        log_dir = HERMES_DATA / "logs"423        log_dir.mkdir(parents=True, exist_ok=True)424 425        if not APP_DIR.exists():426            print(f"[SYNC] ERROR: App directory does not exist: {APP_DIR}")427            return None428 429        hermes_bin = shutil.which("hermes") or str(APP_DIR / ".venv" / "bin" / "hermes")430        if not Path(hermes_bin).exists():431            print("[SYNC] ERROR: hermes CLI not found")432            return None433 434        env = os.environ.copy()435        env["HERMES_HOME"] = str(HERMES_DATA)436        env["GATEWAY_ALLOW_ALL_USERS"] = "true"437        env["PORT"] = "7860"438 439        # ── 1. Patch web dashboard CORS for HF Spaces ────────────────440        self._patch_web_server_cors()441 442        # ── 2. Start hermes gateway on port 7860 ─────────────────────443        gateway_cmd = [hermes_bin, "gateway", "run"]444        print("[SYNC] Starting gateway on port 7860...")445        self.gateway_proc = self._start_process(446            gateway_cmd, "Gateway", env, log_dir / "gateway.log"447        )448 449        return self.gateway_proc450 451 452# ── Main ────────────────────────────────────────────────────────────────────453 454def main():455    try:456        t_main_start = time.time()457 458        t0 = time.time()459        sync = HermesFullSync()460        print(f"[TIMER] sync_hf init: {time.time() - t0:.1f}s")461 462        # 1. Restore463        t0 = time.time()464        sync.load_from_repo()465        print(f"[TIMER] load_from_repo (restore): {time.time() - t0:.1f}s")466 467        # 1b. Build config (OpenModel provider setup)468        print("[SYNC] Building config via build_config.py...")469        t_bc = time.time()470        bc_path = HERMES_DATA / "scripts" / "build_config.py"471        if bc_path.exists():472            subprocess.run([sys.executable, str(bc_path)], check=False)473            print(f"[TIMER] build_config: {time.time() - t_bc:.1f}s")474        else:475            print("[SYNC] WARNING: build_config.py not found, skipping")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