cross-node-sync

$npx mdskill add sonichi/sutando/cross-node-sync

Synchronizes memory and notes between Sutando nodes using rsync-over-ssh

  • Shares bot memory and user notes between Mac Studio and MacBook
  • Uses macOS-native rsync and existing SSH trust relationships
  • Syncs 17 memory files and 34 notes with union semantics via --update
  • Runs automatically during proactive-loop cron for consistent convergence
SKILL.md
.github/skills/cross-node-syncView on GitHub ↗
---
name: cross-node-sync
description: "Rsync-over-ssh sync between Sutando nodes (Mac Studio and MacBook) for shared memory + notes. Optional — core runs fine without it; enables automatic cross-bot learning and note propagation by running from the proactive-loop cron on each pass."
user-invocable: false
---

# cross-node-sync

Rsync-over-ssh cross-node sync for Sutando-Studio (Mac Studio) and Sutando-Mini (MacBook). Shares bot memory and user notes so both nodes converge automatically on each proactive loop pass.

## Why rsync (not Syncthing)

Initial design used Syncthing (peer-to-peer daemon, continuous sync). Pivoted to rsync-over-ssh after manifest comparison showed the sync scope is narrow (17 memory files, 34 notes) — a daemon + web UI is overkill. Rsync wins on:

- **No new binary** — rsync is macOS-native, no brew install needed.
- **No daemon** — fires from the existing proactive-loop cron, no process to babysit.
- **Auditable** — each sync pass logs exactly what moved; `--dry-run` preview is first-class.
- **SSH-keyed auth** — reuses the ssh trust relationship you already have for git, no Device-ID pairing dance.
- **Lighter on disk** — no `.stversions/` per-file versioning, no config.xml, no index database.

Syncthing would still be the right call if: scope grew past a few hundred files, we hit conflict frequency > daily, or we wanted sub-second propagation. Neither is true today.

## Scope

**Syncs (both directions, union semantics via `rsync --update`):**
- `~/.claude/projects/-Users-xueqingliu-Documents-sutando-sutando/memory/` — cross-session bot memory
- `<repo>/notes/` — user's second-brain notes
- `<repo>/assets/` — owner personal runtime assets (e.g. gitignored `stand-avatar.png`); `/assets` is entirely gitignored, rsync is the only transport

**Excluded (per-node state):**
- `state/`, `tasks/`, `results/`, `logs/` — per-bot queues + histories
- `core-status.json`, `build_log.md`, `contextual-chips.json` — per-bot proactive state
- `.env`, `~/.claude/channels/*/.env` — different tokens per node
- `data/voice-metrics.jsonl`, `data/call-metrics.jsonl` — frozen historical archives (writers removed in #603; new session rollups go to `data/conversation.sqlite` instead)
- `src/.discord-pending-replies.json` (legacy location), `src/Sutando/SutandoApp` (Mac binary build artifact)
- `~/.claude/projects/` (other projects), `~/.claude/skills/` (installed per-node)
- `.DS_Store`, `*.swp`, `*.swo`, `.stversions`, `.stfolder` (OS/editor/Syncthing-legacy noise)

## Setup

Two steps, once per node:

```bash
# 1. Generate / authorize SSH key on the peer (prints instructions)
bash skills/cross-node-sync/scripts/setup-rsync-sync.sh --setup

# 2. Set the peer host — the ONLY required env var
echo 'export SUTANDO_SYNC_PEER="susan@MacBook-Pro.local"' >> .env   # on Studio
echo 'export SUTANDO_SYNC_PEER="susan@Mac-Studio.local"'  >> .env   # on Mini
```

`SUTANDO_PEER_MEM_DIR` / `SUTANDO_PEER_NOTES_DIR` default to the **same literal paths as local**, so the defaults only work when both nodes share the same OS username AND the same repo location. In practice that's rare (e.g. Studio's `/Users/xueqingliu/...` vs MacBook's `/Users/xliu/...`), so most setups will want to set them explicitly:

```bash
# Example: Studio talking to a MacBook with a different username + repo path
export SUTANDO_PEER_MEM_DIR="/Users/xliu/.claude/projects/-Users-xliu-.../memory/"
export SUTANDO_PEER_NOTES_DIR="/Users/xliu/path/to/sutando/notes/"
```

Get the peer's values with `ssh $SUTANDO_SYNC_PEER 'echo $HOME; ls -d ~/.claude/projects/-*sutando*'`.

**Cron wiring (explicit opt-in):** as of #936, the `cross-node-sync` entry is **no longer** in `skills/schedule-crons/crons.example.json` — the default `cp ... crons.json` doesn't wire it. Cross-node-sync requires `SUTANDO_SYNC_PEER` + SSH peer auth + the per-file-frontmatter memory model, none of which apply to a single-host setup, so it's opt-in.

If you want the 7-minute sync, add this block to your live `skills/schedule-crons/crons.json` (inside the top-level array):

```json
{
  "name": "cross-node-sync",
  "cron": "*/7 * * * *",
  "prompt": "Run bash skills/cross-node-sync/scripts/setup-rsync-sync.sh to sync memory/ + notes/ with the peer node (requires SUTANDO_SYNC_PEER in .env). Then regenerate MEMORY.md from frontmatter."
}
```

(7 min chosen to avoid `:00/:30` collision with other crons.) Verify with:

```bash
jq '.[].name' skills/schedule-crons/crons.json | grep cross-node-sync || echo "MISSING — add manually"
```

**Manual sync (optional):**
```bash
bash skills/cross-node-sync/scripts/setup-rsync-sync.sh --dry-run   # preview
bash skills/cross-node-sync/scripts/setup-rsync-sync.sh             # actual run
```

## Conflict handling

Two-direction rsync with `--update` flag: files are copied to the receiver only if newer than the receiver's copy. If both nodes edited the same file since last sync, the node with the later mtime wins. For our scope (mostly append-only memory + notes), conflicts are rare.

If conflicts become a problem, add `--backup --backup-dir=../.sutando-sync-conflicts/` to preserve losers for manual merge. Not needed day one.

## Diagnostics

```bash
# Show what would sync without doing it
bash skills/cross-node-sync/scripts/setup-rsync-sync.sh --dry-run

# Show SSH keypair state + setup instructions
bash skills/cross-node-sync/scripts/setup-rsync-sync.sh --setup

# Run the smoke tests
bash skills/cross-node-sync/scripts/test-setup-rsync-sync.sh
```

## Status

- 2026-04-17: Design approved in #susan. Syncthing prototype replaced with rsync after manifest comparison. `setup-rsync-sync.sh` + `test-setup-rsync-sync.sh` (10/10 pass) committed locally, NOT pushed. Still awaiting Mini's actual file inventory to validate the sync scope closes the gap.
More from sonichi/sutando