| |
| """ |
| Hermes Agent Data Sync Service |
| Handles data persistence to/from Hugging Face Dataset strictly on SYNC_INTERVAL schedule |
| """ |
|
|
| import os |
| import sys |
| import time |
| import json |
| import shutil |
| import hashlib |
| import argparse |
| from pathlib import Path |
| from datetime import datetime |
| from typing import Optional, Dict, List |
|
|
| from huggingface_hub import HfApi, hf_hub_download, upload_folder |
| from loguru import logger |
|
|
|
|
| class DatasetManager: |
| """Manages data synchronization with Hugging Face Dataset""" |
| |
| def __init__(self, dataset_repo: Optional[str] = None, token: Optional[str] = None): |
| self.dataset_repo = dataset_repo or os.environ.get('HF_DATASET_REPO') |
| self.token = token or os.environ.get('HF_TOKEN') or os.environ.get('HUGGING_FACE_HUB_TOKEN') |
| self.api = HfApi(token=self.token) |
| self.hermes_home = Path(os.environ.get('HERMES_HOME', '/data/.hermes')) |
| self.webui_home = Path(os.environ.get('HERMES_WEB_UI_HOME', '/data/.hermes-web-ui')) |
| self.temp_dir = Path('/tmp/hermes_sync') |
| self.last_sync_hash = None |
| |
| |
| self.backup_dirs = [ |
| 'memories', |
| 'skills', |
| 'sessions', |
| 'cron', |
| 'script', |
| 'plugins', |
| 'extensions', |
| 'prompts', |
| 'knowledge', |
| ] |
| |
| |
| self.backup_files = [ |
| 'config.yaml', |
| '.env', |
| 'auth.json', |
| 'SOUL.md', |
| 'state.db', |
| 'channel_directory.json', |
| ] |
| |
| |
| self.exclude_patterns = { |
| '__pycache__', '.git', 'node_modules', '.cache', 'tmp', 'reports', 'src', |
| 'logs', 'gateway.lock', 'gateway.pid', 'gateway_state.json', |
| '*.log', '*.db-shm', '*.db-wal', 'bridge.log', 'server.log', |
| '.login-lock.json', 'preview-action.log' |
| } |
| |
| def validate(self) -> bool: |
| if not self.dataset_repo: |
| logger.error("HF_DATASET_REPO not set") |
| return False |
| if not self.token: |
| logger.warning("HF_TOKEN not set") |
| return True |
|
|
| def calculate_data_hash(self) -> str: |
| """Calculate hash of actual user data (sessions, memories, skills, config, webui users)""" |
| hasher = hashlib.md5() |
| |
| for fn in self.backup_files: |
| fp = self.hermes_home / fn |
| if fp.exists() and fp.is_file(): |
| try: |
| stat = fp.stat() |
| hasher.update(f"{fn}:{stat.st_size}:{stat.st_mtime}".encode()) |
| except: |
| pass |
| |
| for dname in self.backup_dirs: |
| dp = self.hermes_home / dname |
| if dp.exists(): |
| for root, _, files in os.walk(dp): |
| for f in sorted(files): |
| if any(f.endswith(ext) for ext in ['.log', '.lock', '.pid', '.tmp', '.json']): |
| |
| if 'request_dump' in f: |
| continue |
| try: |
| stat = os.stat(os.path.join(root, f)) |
| hasher.update(f"{f}:{stat.st_size}:{stat.st_mtime}".encode()) |
| except: |
| pass |
|
|
| webui_db = self.webui_home / 'hermes-web-ui.db' |
| if webui_db.exists(): |
| try: |
| stat = webui_db.stat() |
| hasher.update(f"webui_db:{stat.st_size}".encode()) |
| except: |
| pass |
| |
| return hasher.hexdigest() |
| |
| def prepare_backup_data(self) -> Path: |
| if self.temp_dir.exists(): |
| shutil.rmtree(self.temp_dir) |
| self.temp_dir.mkdir(parents=True) |
| |
| for dirname in self.backup_dirs: |
| (self.temp_dir / dirname).mkdir(exist_ok=True) |
| |
| def should_exclude(src: str, names: list) -> bool: |
| ignored = [] |
| for n in names: |
| if n in self.exclude_patterns or any(n.endswith(ext) for ext in ['.log', '.lock', '.pid', '.db-shm', '.db-wal']): |
| ignored.append(n) |
| if 'request_dump' in n: |
| ignored.append(n) |
| return ignored |
| |
| try: |
| for dirname in self.backup_dirs: |
| src_dir = self.hermes_home / dirname |
| if src_dir.exists(): |
| shutil.copytree(src_dir, self.temp_dir / dirname, dirs_exist_ok=True, ignore=should_exclude) |
| |
| for filename in self.backup_files: |
| src_file = self.hermes_home / filename |
| if src_file.exists(): |
| shutil.copy2(src_file, self.temp_dir / filename) |
| |
| |
| if self.webui_home.exists(): |
| webui_dst = self.temp_dir / 'webui_data' |
| webui_dst.mkdir(parents=True, exist_ok=True) |
| for f in os.listdir(self.webui_home): |
| if f.endswith(('.db', '.token')) and not f.endswith(('.db-shm', '.db-wal', '.lock.json')): |
| src_p = self.webui_home / f |
| if src_p.is_file(): |
| shutil.copy2(src_p, webui_dst / f) |
|
|
| return self.temp_dir |
| |
| except Exception as e: |
| logger.error(f"Failed to prepare backup: {e}") |
| raise |
| |
| def upload_to_dataset(self, force: bool = False) -> bool: |
| try: |
| current_hash = self.calculate_data_hash() |
| if not force and self.last_sync_hash is not None and current_hash == self.last_sync_hash: |
| logger.info("No data changes detected. Skipping backup upload.") |
| return True |
|
|
| backup_dir = self.prepare_backup_data() |
| logger.info(f"Uploading scheduled backup to dataset: {self.dataset_repo}") |
| |
| self.api.upload_folder( |
| folder_path=str(backup_dir), |
| repo_id=self.dataset_repo, |
| repo_type="dataset", |
| commit_message=f"Hermes Agent backup - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" |
| ) |
| |
| self.last_sync_hash = current_hash |
| logger.success("Backup uploaded successfully") |
| return True |
| |
| except Exception as e: |
| logger.error(f"Failed to upload to dataset: {e}") |
| return False |
| |
| def download_from_dataset(self) -> bool: |
| try: |
| logger.info(f"Downloading from dataset: {self.dataset_repo}") |
| download_dir = Path('/tmp/hermes_download') |
| if download_dir.exists(): |
| shutil.rmtree(download_dir) |
| download_dir.mkdir(parents=True) |
| |
| self.api.snapshot_download( |
| repo_id=self.dataset_repo, |
| repo_type="dataset", |
| local_dir=str(download_dir) |
| ) |
| |
| self.restore_from_download(download_dir) |
| return True |
| except Exception as e: |
| logger.error(f"Failed to download from dataset: {e}") |
| return False |
| |
| def restore_from_download(self, download_dir: Path): |
| self.hermes_home.mkdir(parents=True, exist_ok=True) |
| self.webui_home.mkdir(parents=True, exist_ok=True) |
| |
| skip_restore = os.environ.get('SKIP_CONFIG_RESTORE', 'true').lower() in ('true', '1', 'yes') |
| |
| restore_list = [] |
| for dirname in self.backup_dirs: |
| restore_list.append((dirname, self.hermes_home / dirname)) |
| for filename in self.backup_files: |
| restore_list.append((filename, self.hermes_home / filename)) |
| |
| if not skip_restore: |
| restore_list.append(('config.yaml', self.hermes_home / 'config.yaml')) |
| else: |
| restored_path = self.hermes_home / 'config.yaml.restored' |
| src = download_dir / 'config.yaml' |
| if src.exists(): |
| shutil.copy2(src, restored_path) |
| |
| for src_rel, dst in restore_list: |
| src = download_dir / src_rel |
| if src.exists(): |
| try: |
| if src.is_file(): |
| dst.parent.mkdir(parents=True, exist_ok=True) |
| shutil.copy2(src, dst) |
| elif src.is_dir(): |
| if dst.exists(): |
| shutil.rmtree(dst) |
| shutil.copytree(src, dst) |
| except Exception as e: |
| logger.error(f"Failed to restore {src_rel}: {e}") |
|
|
| webui_src = download_dir / 'webui_data' |
| if webui_src.exists(): |
| shutil.copytree(webui_src, self.webui_home, dirs_exist_ok=True) |
| |
| logger.success("Data restoration completed") |
|
|
|
|
| def run_daemon(): |
| logger.info("Starting data sync daemon...") |
| sync_interval = int(os.environ.get('SYNC_INTERVAL', '1800')) |
| manager = DatasetManager() |
| |
| if not manager.validate(): |
| logger.error("Configuration invalid, exiting") |
| sys.exit(1) |
| |
| logger.info(f"Sync interval strictly set to: {sync_interval} seconds") |
| |
| try: |
| while True: |
| time.sleep(sync_interval) |
| |
| manager.upload_to_dataset(force=False) |
| except KeyboardInterrupt: |
| pass |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description='Hermes Agent Data Sync') |
| parser.add_argument('action', choices=['backup', 'restore', 'daemon'], help='Action') |
| parser.add_argument('--force', '-f', action='store_true', help='Force') |
| args = parser.parse_args() |
| manager = DatasetManager() |
| if not manager.validate(): sys.exit(1) |
| if args.action == 'backup': sys.exit(0 if manager.upload_to_dataset(force=args.force) else 1) |
| elif args.action == 'restore': sys.exit(0 if manager.download_from_dataset() else 1) |
| elif args.action == 'daemon': run_daemon() |
|
|
| if __name__ == '__main__': |
| main() |
|
|