--- name: hermes-state-merge description: Merge skills and memory between two Hermes instances using a shared backup repository, without overwriting existing state. version: 1.0.0 metadata: hermes: tags: [backup, merge, sync, state, skills, memory, migration] --- # Hermes State Merge Incrementally merge skills, memory, and config between Hermes instances sharing a common backup repository (e.g., https://git.opencomputing.cn/yumoqing/hermes-backup). Unlike full restore which **overwrites**, merge only adds what's missing and reconciles differences. ## When to Use - Two Hermes instances (A and B) both push to the same backup repo - Instance B wants to get A's new skills/memory without losing its own - Periodic synchronization between instances ## What Gets Merged | Component | Strategy | Conflict handling | |-----------|----------|-------------------| | **Skills** | Incremental file copy | `--ignore-existing` for new, manual review for modified | | **Memory** | Read from backup DB, write via `memory` tool | Skip duplicates by content hash | | **Config** | YAML merge with precedence rules | Local config wins unless explicitly overridden | | **Cron jobs** | Add missing jobs only | Skip if job_id exists | ## Workflow ### Phase 1: Clone Backup to Temp Directory ```bash CLONE_DIR=$(mktemp -d) git clone --depth 1 git@:/.git "$CLONE_DIR" ``` If HTTPS with token: ```bash git clone --depth 1 https://@//.git "$CLONE_DIR" ``` ### Phase 2: Skills Merge ```bash # 1. List skills in both directories BACKUP_SKILLS="$CLONE_DIR/skills" LOCAL_SKILLS="$HOME/.hermes/skills" # 2. Copy new skills (don't overwrite existing) rsync -av --ignore-existing "$BACKUP_SKILLS/" "$LOCAL_SKILLS/" 2>/dev/null # 3. Identify modified skills (different content) diff -rq "$BACKUP_SKILLS" "$LOCAL_SKILLS" 2>/dev/null | grep "differ" # 4. For each modified skill, compare and decide: # - If backup version has new content not in local → merge # - If local version has fixes not in backup → keep local # - If both changed significantly → keep backup version (newer source of truth) ``` **Modified skill merge pattern:** ```bash # For each modified skill, use diff to decide diff -u "$LOCAL_SKILLS/$skill/SKILL.md" "$BACKUP_SKILLS/$skill/SKILL.md" # If backup has significant additions → cp from backup # If local has fixes → keep local ``` ### Phase 3: Memory Merge Memory is stored in SQLite (`memory_store.db`). Cannot directly copy — must merge by content. ```python # Read memory entries from backup DB import sqlite3, json backup_db = sqlite3.connect(f"{CLONE_DIR}/memory_store.db") local_db = sqlite3.connect(f"{HOME}/.hermes/memory_store.db") # Extract entries from backup backup_entries = backup_db.execute("SELECT * FROM memory").fetchall() local_entries = local_db.execute("SELECT * FROM memory").fetchall() local_set = {row[1] for row in local_entries} # assuming content is index 1 for entry in backup_entries: content = entry[1] if content not in local_set: # This entry doesn't exist locally — add it local_db.execute("INSERT INTO memory VALUES (?, ?, ?)", entry) local_db.commit() backup_db.close() local_db.close() ``` **Alternative (via Hermes memory tool):** For each new memory entry found in backup, call `memory(action='add', target='memory', content='...')` in a Hermes session. ### Phase 4: Config Merge ```python import yaml def deep_merge(base, override, overwrite=False): """Merge override into base. If overwrite=False, base wins on conflicts.""" for key, val in override.items(): if key not in base: base[key] = val elif isinstance(val, dict) and isinstance(base.get(key), dict): deep_merge(base[key], val, overwrite) elif overwrite: base[key] = val return base with open(f"{CLONE_DIR}/config.yaml") as f: backup_config = yaml.safe_load(f) with open(f"{HOME}/.hermes/config.yaml") as f: local_config = yaml.safe_load(f) # Merge: local wins on conflicts (preserve local API keys, etc.) merged = deep_merge(backup_config, local_config, overwrite=False) # Write merged config with open(f"{HOME}/.hermes/config.yaml", 'w') as f: yaml.dump(merged, f, default_flow_style=False) ``` ### Phase 5: Cleanup ```bash rm -rf "$CLONE_DIR" ``` ## Related Skills - **hermes-instance-migration** — Full rsync-based migration to a new machine (includes venv rebuild + multi-user setup). Use that when moving to a new server; use this skill for periodic state sync between running instances. - **hermes-state-backup** — Git-based backup to remote repo. ## Pitfalls 1. **Never merge `state.db` directly** — it contains session state and can cause corruption if merged between instances. Let each instance maintain its own state. 2. **Never include `state.db` in git backups** — it is a binary SQLite file (~1-2GB per snapshot) that git cannot delta-compress. Over 100+ commits this bloats the repo to tens of GB (observed: 176 commits × 1.4GB = 81GB pack). The backup script must exclude `state.db`, `state.db-shm`, `state.db-wal`. Each instance regenerates its own state.db from sessions. 3. **Backup script must clean dirty working tree before checkout** — add `git reset --hard HEAD` and `git clean -fdx -e .git` after fetch and before `git checkout -B`. Without this, any uncommitted changes (e.g. from a prior failed backup or concurrent cron output) will abort the checkout and fail the entire backup silently. 4. **`auth.json` should never be merged** — OAuth tokens are instance-specific. Each instance needs its own authentication. 5. **Skills with same name but different content** — compare carefully. A skill modified locally may have instance-specific fixes. 6. **Memory entries are plain text** — use content-based dedup, not ID-based, since IDs may differ between instances. 7. **Config merge should NOT overwrite API keys** — always let local config win on sensitive fields. 8. **`memory_store.db` schema may differ** — if the backup is from a significantly older Hermes version, the schema may differ. Check schema first. 9. **Cron job conflicts** — cron jobs have unique IDs. Don't merge cron jobs; instead, compare schedules and prompts manually. 10. **Large skill repos** — some skill sets include templates, schemas, assets. `rsync --ignore-existing` is fast for initial merge, but `diff -rq` for subsequent comparisons may be slow with hundreds of skills. ## Automation Script Save as `~/.hermes/scripts/merge-from-backup.sh`: ```bash #!/bin/bash set -e REMOTE="git@:/.git" CLONE_DIR=$(mktemp -d) LOCAL_HERMES="$HOME/.hermes" echo "Cloning backup repo..." git clone --depth 1 "$REMOTE" "$CLONE_DIR" echo "Merging skills (incremental)..." rsync -av --ignore-existing "$CLONE_DIR/skills/" "$LOCAL_HERMES/skills/" 2>/dev/null || true echo "Checking modified skills..." diff -rq "$CLONE_DIR/skills" "$LOCAL_HERMES/skills" 2>/dev/null | grep "differ" || echo "No modified skills" echo "Merging memory..." python3 -c " import sqlite3, sys backup_db = sqlite3.connect('$CLONE_DIR/memory_store.db') local_db = sqlite3.connect('$LOCAL_HERMES/memory_store.db') try: backup_rows = backup_db.execute('SELECT * FROM memory').fetchall() local_rows = local_db.execute('SELECT * FROM memory').fetchall() local_set = set() for row in local_rows: # Use all columns as dedup key local_set.add(tuple(row)) added = 0 for row in backup_rows: if tuple(row) not in local_set: cols = ','.join(['?' for _ in row]) local_db.execute(f'INSERT INTO memory VALUES ({cols})', row) added += 1 local_db.commit() print(f'Added {added} new memory entries') except Exception as e: print(f'Memory merge error: {e}', file=sys.stderr) finally: backup_db.close() local_db.close() " echo "Cleanup..." rm -rf "$CLONE_DIR" echo "Merge complete at $(date)" echo "Note: Restart Hermes to reload merged state" ``` ## Schedule Run after each backup push: - After instance A pushes to backup → instance B runs merge - Or schedule cron to run merge every 6 hours ```bash # In Hermes cronjob: # Create job: schedule='every 6h' # Prompt: 'Run the merge script: bash ~/.hermes/scripts/merge-from-backup.sh' # no_agent: true ```