SaKoRy/HermesFace
0
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 