From 5a27420809645b37aeadd3f1627209f90e4649f0 Mon Sep 17 00:00:00 2001 From: Alessandro Costa Date: Sat, 4 Apr 2026 19:14:31 -0300 Subject: [PATCH] feat: add claude-mem-sync for multi-machine observation synchronization (#1570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bidirectional sync of observations and session summaries between machines via SSH/SCP. Exports to JSON, transfers, imports with deduplication by (created_at, title). Commands: claude-mem-sync push # local → remote claude-mem-sync pull # remote → local claude-mem-sync sync # bidirectional claude-mem-sync status # compare counts Features: - Deduplication prevents duplicates on repeated runs - Configurable paths via CLAUDE_MEM_DB / CLAUDE_MEM_REMOTE_DB - Automatic temp file cleanup - Requires only Python 3 + SSH on both machines Tested syncing 3,400+ observations between two physical servers. After sync, a session on the remote server used the transferred memory to deliver a real feature PR — proving productive cross-machine workflows. Co-authored-by: Alessandro Costa Co-authored-by: Claude Opus 4.6 (1M context) --- scripts/claude-mem-sync | 181 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100755 scripts/claude-mem-sync diff --git a/scripts/claude-mem-sync b/scripts/claude-mem-sync new file mode 100755 index 00000000..f49e55e1 --- /dev/null +++ b/scripts/claude-mem-sync @@ -0,0 +1,181 @@ +#!/bin/bash +# claude-mem-sync — Synchronize claude-mem observations between machines +# +# Usage: +# claude-mem-sync push # local → remote +# claude-mem-sync pull # remote → local +# claude-mem-sync sync # bidirectional (push + pull) +# claude-mem-sync status # compare counts +# +# Prerequisites: +# - SSH access to remote host (key-based auth recommended) +# - Python 3 on both machines +# - claude-mem installed on both machines (~/.claude-mem/claude-mem.db) +# +# Environment variables: +# CLAUDE_MEM_DB Local database path (default: ~/.claude-mem/claude-mem.db) +# CLAUDE_MEM_REMOTE_DB Remote database path (default: ~/.claude-mem/claude-mem.db) + +set -euo pipefail + +LOCAL_DB="${CLAUDE_MEM_DB:-$HOME/.claude-mem/claude-mem.db}" +COMMAND="${1:?Usage: claude-mem-sync }" +REMOTE_HOST="${2:?Missing remote host. Usage: claude-mem-sync $COMMAND }" +REMOTE_DB="${CLAUDE_MEM_REMOTE_DB:-\$HOME/.claude-mem/claude-mem.db}" +TMPDIR="/tmp/claude-mem-sync-$$" + +mkdir -p "$TMPDIR" +trap "rm -rf $TMPDIR" EXIT + +# Column lists for observations and session_summaries +OBS_COLS="memory_session_id,project,text,type,title,subtitle,facts,narrative,concepts,files_read,files_modified,prompt_number,discovery_tokens,created_at,created_at_epoch" +SUM_COLS="memory_session_id,project,request,investigated,learned,completed,next_steps,files_read,files_edited,notes,prompt_number,discovery_tokens,created_at,created_at_epoch" + +export_obs() { + local db="$1" output="$2" + python3 -c " +import sqlite3, json, sys +conn = sqlite3.connect('$db') +cur = conn.cursor() +cur.execute('''SELECT $OBS_COLS FROM observations ORDER BY created_at''') +cols = '$OBS_COLS'.split(',') +rows = [dict(zip(cols, r)) for r in cur.fetchall()] +cur.execute('''SELECT $SUM_COLS FROM session_summaries ORDER BY created_at''') +cols2 = '$SUM_COLS'.split(',') +sums = [dict(zip(cols2, r)) for r in cur.fetchall()] +json.dump({'observations': rows, 'summaries': sums}, open('$output', 'w')) +print(f'{len(rows)} obs, {len(sums)} sums exported', file=sys.stderr) +conn.close() +" +} + +import_obs() { + local db="$1" input="$2" + python3 -c " +import sqlite3, json, sys +conn = sqlite3.connect('$db') +cur = conn.cursor() +cur.execute('SELECT created_at, title FROM observations') +existing = set((r[0],r[1]) for r in cur.fetchall()) +cur.execute('SELECT created_at, request FROM session_summaries') +existing_s = set((r[0],r[1]) for r in cur.fetchall()) +data = json.load(open('$input')) +oi, si = 0, 0 +obs_cols = '$OBS_COLS'.split(',') +sum_cols = '$SUM_COLS'.split(',') +obs_placeholders = ','.join(['?'] * len(obs_cols)) +sum_placeholders = ','.join(['?'] * len(sum_cols)) +for o in data['observations']: + if (o['created_at'], o['title']) not in existing: + cur.execute(f'INSERT INTO observations ($OBS_COLS) VALUES ({obs_placeholders})', + tuple(o[k] for k in obs_cols)) + oi += 1 +for s in data['summaries']: + if (s['created_at'], s['request']) not in existing_s: + cur.execute(f'INSERT INTO session_summaries ($SUM_COLS) VALUES ({sum_placeholders})', + tuple(s[k] for k in sum_cols)) + si += 1 +conn.commit() +print(f'{oi} new obs, {si} new sums imported', file=sys.stderr) +conn.close() +" +} + +count_db() { + local db="$1" + python3 -c " +import sqlite3 +conn = sqlite3.connect('$db') +cur = conn.cursor() +cur.execute('SELECT COUNT(*) FROM observations') +obs = cur.fetchone()[0] +cur.execute('SELECT COUNT(*) FROM session_summaries') +sums = cur.fetchone()[0] +cur.execute('SELECT MAX(created_at) FROM observations') +last = cur.fetchone()[0] or 'empty' +print(f'{obs} obs, {sums} sums (last: {last[:19]})') +conn.close() +" +} + +case "$COMMAND" in + push) + echo "=== Push: local → $REMOTE_HOST ===" + export_obs "$LOCAL_DB" "$TMPDIR/export.json" + scp -q "$TMPDIR/export.json" "$REMOTE_HOST:/tmp/mem-import.json" + # Run import on remote + ssh "$REMOTE_HOST" "python3 -c \" +import sqlite3, json, sys +conn = sqlite3.connect('$REMOTE_DB') +cur = conn.cursor() +cur.execute('SELECT created_at, title FROM observations') +existing = set((r[0],r[1]) for r in cur.fetchall()) +cur.execute('SELECT created_at, request FROM session_summaries') +existing_s = set((r[0],r[1]) for r in cur.fetchall()) +data = json.load(open('/tmp/mem-import.json')) +obs_cols = '$OBS_COLS'.split(',') +sum_cols = '$SUM_COLS'.split(',') +obs_ph = ','.join(['?'] * len(obs_cols)) +sum_ph = ','.join(['?'] * len(sum_cols)) +oi, si = 0, 0 +for o in data['observations']: + if (o['created_at'], o['title']) not in existing: + cur.execute(f'INSERT INTO observations ($OBS_COLS) VALUES ({obs_ph})', tuple(o[k] for k in obs_cols)) + oi += 1 +for s in data['summaries']: + if (s['created_at'], s['request']) not in existing_s: + cur.execute(f'INSERT INTO session_summaries ($SUM_COLS) VALUES ({sum_ph})', tuple(s[k] for k in sum_cols)) + si += 1 +conn.commit() +print(f'Remote: {oi} new obs, {si} new sums imported', file=sys.stderr) +conn.close() +\"" + ;; + pull) + echo "=== Pull: $REMOTE_HOST → local ===" + ssh "$REMOTE_HOST" "python3 -c \" +import sqlite3, json +conn = sqlite3.connect('$REMOTE_DB') +cur = conn.cursor() +cur.execute('SELECT $OBS_COLS FROM observations ORDER BY created_at') +cols = '$OBS_COLS'.split(',') +obs = [dict(zip(cols, r)) for r in cur.fetchall()] +cur.execute('SELECT $SUM_COLS FROM session_summaries ORDER BY created_at') +cols2 = '$SUM_COLS'.split(',') +sums = [dict(zip(cols2, r)) for r in cur.fetchall()] +json.dump({'observations': obs, 'summaries': sums}, open('/tmp/mem-export.json', 'w')) +print(f'{len(obs)} obs, {len(sums)} sums exported') +conn.close() +\"" + scp -q "$REMOTE_HOST:/tmp/mem-export.json" "$TMPDIR/import.json" + import_obs "$LOCAL_DB" "$TMPDIR/import.json" + ;; + sync) + echo "=== Bidirectional sync with $REMOTE_HOST ===" + "$0" push "$REMOTE_HOST" + "$0" pull "$REMOTE_HOST" + "$0" status "$REMOTE_HOST" + ;; + status) + echo "=== Local ===" + count_db "$LOCAL_DB" + echo "=== Remote ($REMOTE_HOST) ===" + ssh "$REMOTE_HOST" "python3 -c \" +import sqlite3 +conn = sqlite3.connect('$REMOTE_DB') +cur = conn.cursor() +cur.execute('SELECT COUNT(*) FROM observations') +obs = cur.fetchone()[0] +cur.execute('SELECT COUNT(*) FROM session_summaries') +sums = cur.fetchone()[0] +cur.execute('SELECT MAX(created_at) FROM observations') +last = cur.fetchone()[0] or 'empty' +print(f'{obs} obs, {sums} sums (last: {last[:19]})') +conn.close() +\"" + ;; + *) + echo "Usage: claude-mem-sync " + exit 1 + ;; +esac