diff --git a/push.sh b/push.sh new file mode 100644 index 0000000..67685c3 --- /dev/null +++ b/push.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Hermes Sync Script - Push local memories and skills to Gitea with merge +set -e + +SYNC_DIR="/root/hermes-sync-tmp" +HERMES_HOME="$HOME/.hermes" +cd "$SYNC_DIR" + +echo "[$(date '+%H:%M:%S')] Starting bidirectional sync..." + +# Stage local changes +cp "$HERMES_HOME/memories/MEMORY.md" memories/MEMORY.md 2>/dev/null || true +if [ -d "$HERMES_HOME/skills" ]; then + mkdir -p memories + rsync -a --delete "$HERMES_HOME/skills/" memories/ 2>/dev/null || true +fi +git add -A + +# Check if there are local changes +HAS_LOCAL=false +if ! git diff --cached --quiet || ! git diff --quiet; then + HAS_LOCAL=true +fi + +# Fetch and merge remote +git fetch origin main + +# Check if remote is ahead +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 "[$(date '+%H:%M:%S')] Remote has new changes, merging..." + + if [ "$HAS_LOCAL" = true ]; then + # Both changed: stash, merge, then rebase + git stash push -m "local $(date)" 2>/dev/null || true + if ! git merge origin/main --no-edit 2>/dev/null; then + # Conflict: auto-resolve memories by keeping ours + git checkout --ours 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 + # Only remote changed + 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 + +# Commit and push local +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 "[$(date '+%H:%M:%S')] Push rejected, pulling and retrying..." + git pull origin main --no-edit 2>/dev/null || true + git push origin main 2>&1 || echo "[$(date '+%H:%M:%S')] Push failed" + else + echo "[$(date '+%H:%M:%S')] Push successful" + fi +else + echo "[$(date '+%H:%M:%S')] No local changes" +fi + +echo "[$(date '+%H:%M:%S')] Sync complete" diff --git a/state_10-40-29-186.db b/state_10-40-29-186.db new file mode 100644 index 0000000..10c2c83 Binary files /dev/null and b/state_10-40-29-186.db differ diff --git a/sync.sh b/sync.sh index b5a36e9..be69c1d 100755 --- a/sync.sh +++ b/sync.sh @@ -1,26 +1,43 @@ #!/bin/bash set -e -SYNC_DIR="$HOME/.hermes-sync" -HERMES_HOME="$HOME/.hermes" + +SYNC_DIR="${SYNC_DIR:-$HOME/.hermes-sync}" +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +HOSTNAME=$(hostname) cd "$SYNC_DIR" -echo "[$(date '+%H:%M:%S')] Syncing..." +echo "[$(date '+%H:%M:%S')] Sync from $HOSTNAME..." -# Stage local changes -cp "$HERMES_HOME/memories/MEMORY.md" "$SYNC_DIR/memories/MEMORY.md" 2>/dev/null || true -if [ -d "$HERMES_HOME/skills" ]; then - mkdir -p "$SYNC_DIR/skills" - rsync -a --delete "$HERMES_HOME/skills/" "$SYNC_DIR/skills/" 2>/dev/null || true -fi +# ── 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 -# Fetch and merge remote +# ── 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 @@ -28,7 +45,7 @@ if git rev-parse HEAD >/dev/null 2>&1 && \ 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 memories/MEMORY.md 2>/dev/null || true + 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 @@ -40,11 +57,107 @@ if git rev-parse HEAD >/dev/null 2>&1 && \ } 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 + 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 -# Push local +# ── 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 @@ -52,13 +165,50 @@ if [ "$HAS_LOCAL" = true ]; then 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 -# Apply to hermes +# ── 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 "Done" \ No newline at end of file +echo "[$(date '+%H:%M:%S')] Done"