#!/bin/bash set -e SYNC_DIR="${SYNC_DIR:-$HOME/.hermes-sync}" HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" HOSTNAME=$(hostname) cd "$SYNC_DIR" echo "[$(date '+%H:%M:%S')] Sync from $HOSTNAME..." # ── Step 1: Export local state.db → state_.db ────────────────── python3 << PYEOF import sqlite3, os, shutil local_db = os.path.join(os.environ['HERMES_HOME'], 'state.db') export_db = os.path.join(os.environ['SYNC_DIR'], f"state_{os.environ['HOSTNAME']}.db") # Checkpoint WAL try: conn = sqlite3.connect(local_db) conn.execute('PRAGMA wal_checkpoint(TRUNCATE)') conn.execute('PRAGMA optimize') conn.close() except Exception as e: print(f'Checkpoint: {e}', file=__import__('sys').stderr) shutil.copy2(local_db, export_db) print(f'Exported: {export_db}') PYEOF # ── Step 2: Git stage ──────────────────────────────────────────────────── git add -A HAS_LOCAL=false if ! git diff --cached --quiet || ! git diff --quiet; then HAS_LOCAL=true fi # ── Step 3: Fetch + merge ───────────────────────────────────────────────── git fetch origin main if git rev-parse HEAD >/dev/null 2>&1 && \ git rev-parse origin/main >/dev/null 2>&1 && \ ! git merge-base --is-ancestor HEAD origin/main 2>/dev/null; then echo "Merging remote..." if [ "$HAS_LOCAL" = true ]; then git stash push -m "local $(date)" 2>/dev/null || true if ! git merge origin/main --no-edit 2>/dev/null; then git checkout --ours sync.sh memories/MEMORY.md 2>/dev/null || true git add -A git commit -m "Auto-resolve $(date)" 2>/dev/null || true fi if git stash list | grep -q "local "; then git stash pop 2>/dev/null || true git rebase origin/main 2>/dev/null || { git rebase --abort 2>/dev/null || true git merge origin/main --no-edit 2>/dev/null || true } fi else git merge origin/main --no-edit 2>/dev/null || \ git merge --ff-only origin/main 2>/dev/null || \ git reset --hard origin/main fi fi # ── Step 4: Merge all state_*.db → state_merged.db ────────────────────── python3 << 'PYEOF' import sqlite3, os, glob, shutil sync_dir = os.environ['SYNC_DIR'] hermes_home = os.environ['HERMES_HOME'] merged_path = os.path.join(sync_dir, 'state_merged.db') db_files = sorted(glob.glob(os.path.join(sync_dir, 'state_*.db'))) db_files = [f for f in db_files if not f.endswith('_merged.db')] print(f'DBs to merge: {[os.path.basename(f) for f in db_files]}') if os.path.exists(merged_path): os.remove(merged_path) def get_schema(conn, table): cols = conn.execute(f'PRAGMA table_info("{table}")').fetchall() return [c[1] for c in cols] # column names def copy_table(src_conn, dst_conn, table): cols = get_schema(src_conn, table) placeholders = ','.join(['?'] * len(cols)) col_names = ','.join(f'"{c}"' for c in cols) rows = src_conn.execute(f'SELECT {col_names} FROM "{table}"').fetchall() for row in rows: dst_conn.execute(f'INSERT OR REPLACE INTO "{table}" ({col_names}) VALUES ({placeholders})', row) return len(rows) # Open merged DB and rebuild from scratch conn_merged = sqlite3.connect(merged_path) conn_merged.execute('PRAGMA journal_mode=WAL') conn_merged.execute('PRAGMA synchronous=NORMAL') # We need at least one source to initialize schema if not db_files: print('No state DBs found, creating empty merged DB') conn_merged.execute(''' CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, source TEXT NOT NULL, user_id TEXT, model TEXT, model_config TEXT, system_prompt TEXT, parent_session_id TEXT, started_at REAL, ended_at REAL, end_reason TEXT, message_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, cache_read_tokens INTEGER DEFAULT 0, cache_write_tokens INTEGER DEFAULT 0, reasoning_tokens INTEGER DEFAULT 0, billing_provider TEXT, billing_base_url TEXT, billing_mode TEXT, estimated_cost_usd REAL, actual_cost_usd REAL, cost_status TEXT, cost_source TEXT, pricing_version TEXT, title TEXT )''') conn_merged.execute(''' CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, timestamp REAL NOT NULL, token_count INTEGER, finish_reason TEXT, reasoning TEXT, reasoning_details TEXT, codex_reasoning_items TEXT )''') conn_merged.commit() else: first_db = db_files[0] src_conn = sqlite3.connect(first_db) src_conn.execute('PRAGMA wal_checkpoint(TRUNCATE)') for table in ['sessions', 'messages']: cols = get_schema(src_conn, table) col_defs = [] for c in cols: if c == 'id' and table == 'messages': col_defs.append('id INTEGER PRIMARY KEY') elif c == 'id': col_defs.append(f'{c} TEXT PRIMARY KEY') else: col_defs.append(f'{c} TEXT') conn_merged.execute(f'DROP TABLE IF EXISTS "{table}"') conn_merged.execute(f'CREATE TABLE "{table}" ({", ".join(col_defs)})') placeholders = ','.join(['?'] * len(cols)) col_names = ','.join(f'"{c}"' for c in cols) rows = src_conn.execute(f'SELECT {col_names} FROM "{table}"').fetchall() for row in rows: conn_merged.execute(f'INSERT OR REPLACE INTO "{table}" ({col_names}) VALUES ({placeholders})', row) print(f' {os.path.basename(first_db)}.{table}: {len(rows)} rows') src_conn.close() conn_merged.execute('PRAGMA wal_checkpoint(TRUNCATE)') conn_merged.commit() conn_merged.close() print(f'Merged: {merged_path} ({os.path.getsize(merged_path)/1024:.0f} KB)') PYEOF # ── Step 5: Push ───────────────────────────────────────────────────────── if [ "$HAS_LOCAL" = true ]; then git commit -m "Sync $(date '+%Y-%m-%d %H:%M')" 2>/dev/null || true if ! git push origin main 2>&1; then echo "Push rejected, pulling..." git pull origin main --no-edit 2>/dev/null || true git push origin main 2>&1 || echo "Push failed" fi else echo "No local changes" fi # ── Step 6: Restore merged state to local hermes ───────────────────────── python3 << 'PYEOF' import sqlite3, os, shutil sync_dir = os.environ['SYNC_DIR'] hermes_home = os.environ['HERMES_HOME'] merged_path = os.path.join(sync_dir, 'state_merged.db') local_db = os.path.join(hermes_home, 'state.db') if not os.path.exists(merged_path): print('No merged DB, skipping restore') else: # Checkpoint + close local try: conn = sqlite3.connect(local_db) conn.execute('PRAGMA wal_checkpoint(TRUNCATE)') conn.execute('PRAGMA optimize') conn.close() except Exception as e: print(f'Local checkpoint: {e}') # Backup shutil.copy2(local_db, local_db + '.pre_sync_bak') # Replace with merged shutil.copy2(merged_path, local_db) print(f'Restored merged state to {local_db}') # Remove backup after successful restore bak = local_db + '.pre_sync_bak' if os.path.exists(bak): os.remove(bak) PYEOF # ── Step 7: Sync memories + skills (additive) ──────────────────────────── cp "$SYNC_DIR/memories/MEMORY.md" "$HERMES_HOME/memories/MEMORY.md" 2>/dev/null || true if [ -d "$SYNC_DIR/skills" ]; then mkdir -p "$HERMES_HOME/skills" rsync -a --ignore-existing "$SYNC_DIR/skills/" "$HERMES_HOME/skills/" 2>/dev/null || \ cp -rn "$SYNC_DIR/skills/"* "$HERMES_HOME/skills/" 2>/dev/null || true fi echo "[$(date '+%H:%M:%S')] Done"