plans/inbox/2026-07-09-cloud-sync-native.md
Date: 2026-07-09
Supersedes: plans/inbox/2026-07-08-cloud-sync-skill.md (skill-managed standalone daemon — rejected: it babysits a process that shouldn't exist).
Design in one paragraph: Every memory row gets a synced_at column (NULL = not in the cloud). The worker — which already performs every write and is already kept alive by hooks — nudges a debounced flusher after each write. The flusher drains WHERE synced_at IS NULL in batches, POSTs to cmem.ai, and stamps rows on success. That single mechanism IS live sync, backfill, offline catch-up, and retry. No second process, no polling, no pid files, no JSON cursor files. Config is a token in settings.json like every other API key. The skill shrinks to: paste token → restart worker → show status.
Wire contract (fixed — the cmem.ai server is already deployed): POST {observations,summaries,prompts}/batch with camelCase row mappers, headers Authorization: Bearer, X-User-Id, X-Device-Id, X-Device-Name; server upserts on (user_id, device_id, local_id); ≤2MB body, ≤200KB field clamps; GET /pull?kind=&afterId=&limit=. The reference implementation for all mappers/clamps is the vetted standalone client at ~/.claude-mem/cloud-sync.mjs (source-reviewed 2026-07-08; verified live against production — 129,777 rows). Port its toCloud mappers, selectCols prompt-clamp, and body-size batching verbatim; do not redesign the wire format.
Allowed APIs / copy sources:
| Concern | Copy from | Evidence |
|---|---|---|
| Column migration | ensureDiscoveryTokensColumn() — schema_versions-gated + PRAGMA table_info guard + ALTER TABLE ADD COLUMN | src/services/sqlite/SessionStore.ts:931–953 |
| Write-site hook shape | dbManager.getChromaSync()?.syncObservation(...) — fire-and-forget, optional-chained | src/services/worker/agents/ResponseProcessor.ts:343 (obs), :430 (summaries), src/services/worker/http/routes/SessionRoutes.ts:587 (prompts) |
| Service accessor | getChromaSync(): ChromaSync | null | src/services/worker/DatabaseManager.ts:62 |
| Sibling service class | ChromaSync (constructor, per-write methods, static backfillAllProjects) | src/services/sync/ChromaSync.ts:83,89,361,914 |
| Background wiring point | WorkerService.initializeBackground() — settings loaded at :460, Chroma gate :504, backfill :604 | src/services/worker-service.ts:452–622 |
| Settings keys | SettingsDefaults interface + DEFAULTS; Chroma remote group (CLAUDE_MEM_CHROMA_HOST/PORT/API_KEY/...) is the endpoint+credential precedent; file mode 0600 | src/shared/SettingsDefaultsManager.ts:27–130, 133–236, 101–106, 9–25 |
| HTTP route | LogsRoutes extends BaseRouteHandler; late registration like SearchRoutes | src/services/worker/http/routes/LogsRoutes.ts:70–137, src/services/worker-service.ts:528 |
| Tests | bun test over tests/ (subdirs: tests/sqlite/, tests/worker/...) | root package.json:99–105 |
| Skill authoring | auto-discovered from plugin/skills/<name>/SKILL.md; frontmatter name/description/allowed-tools; shipped verbatim by scripts/sync-marketplace.cjs:126–167 | plugin/skills/standup/SKILL.md |
| Wire mappers to port | KINDS[].toCloud, prompts selectCols (SQL-side substr(prompt_text,1,200000) — 7MB prompts OOM node if clamped post-read), MAX_BODY_BYTES/clampRow | ~/.claude-mem/cloud-sync.mjs:132–281 |
Anti-patterns (do NOT): invent a skills key in plugin.json (auto-discovery); use ${CLAUDE_PLUGIN_ROOT} (the real var is ${CLAUDE_SKILL_DIR}, and this skill no longer bundles a script anyway); use node:sqlite/better-sqlite3 (repo is bun:sqlite); block the write path on network I/O (Chroma calls are local-fast; cloud is not — nudge, don't await); edit CHANGELOG; add pm2/launchd anything; mint a new device id when a legacy one exists (server keys on device_id — a new id forks every cloud row into a duplicate).
synced_atWhat: One migration, copied from the ensureDiscoveryTokensColumn template (SessionStore.ts:931–953), next free schema_versions number (executor: SELECT MAX(version) FROM schema_versions first):
ALTER TABLE observations ADD COLUMN synced_at INTEGER — same for session_summaries, user_prompts. (NULL = unsynced; value = epoch ms of successful upload.)CREATE INDEX IF NOT EXISTS idx_<table>_unsynced ON <table>(id) WHERE synced_at IS NULL.Legacy adoption (same migration, runs once): if ~/.claude-mem/cloud-sync-state.json exists (standalone client's state — format: {deviceId, lastId, lastSummaryId, lastPromptId, ...}):
UPDATE observations SET synced_at = <now> WHERE id <= lastId AND synced_at IS NULL (and the two analogs). Skipping this is safe (server upserts) but re-uploads ~130k rows — stamp them.deviceId from it, and the skill (Phase 4) retires it.Verification:
bun test tests/sqlite/ passes; new test: migration on a fixture DB adds columns idempotently (run twice), stamps rows ≤ cursor when a fixture state file is present, leaves rows > cursor NULL.PRAGMA table_info(observations) shows synced_at; EXPLAIN QUERY PLAN SELECT id FROM observations WHERE synced_at IS NULL uses the partial index.Anti-pattern guards: don't touch existing columns or rebuild tables; don't stamp when no legacy state file exists.
CloudSync service — the flusherWhat: New src/services/sync/CloudSync.ts, structurally a sibling of ChromaSync.ts. Core:
SettingsDefaultsManager interface + DEFAULTS, mirroring the Chroma group at :101–106): CLAUDE_MEM_CLOUD_SYNC_TOKEN (default ''), CLAUDE_MEM_CLOUD_SYNC_USER_ID (''), CLAUDE_MEM_CLOUD_SYNC_URL (https://cmem.ai/api/pro/sync), CLAUDE_MEM_CLOUD_SYNC_DEVICE_ID ('' — resolved at first start: adopt from legacy cloud-sync-state.json if present, else randomUUID(), then persisted back to settings), CLAUDE_MEM_CLOUD_SYNC_DEVICE_NAME (default hostname()). Active ⇔ token AND user id are non-empty. No separate enabled flag — credentials present = on, blank token = off.notify() — called by write sites; debounce ~1500ms trailing; coalesces write bursts into one flush.flush() — single-flight (skip if already running). Per kind: SELECT ... WHERE synced_at IS NULL ORDER BY id LIMIT 200 (prompts use the ported SQL clamp), map with the ported toCloud mappers, pack into ≤2MB bodies, POST with AbortSignal.timeout(30_000) (fixes the standalone client's no-timeout hang), on 2xx stamp that batch: UPDATE <table> SET synced_at = ? WHERE id IN (...). Loop until drained. On failure: log via the repo logger, leave rows NULL, retry on next notify + a capped exponential backoff timer (30s → 10min, .unref()).start() — kick one flush() (non-blocking). This IS backfill: a never-synced install simply has everything NULL.status() — {configured, deviceId, pending: {observations, summaries, prompts}, lastFlushAt, lastError} (pending = COUNT(*) WHERE synced_at IS NULL).fetchImpl = globalThis.fetch.Wiring (3 + 2 lines):
DatabaseManager: hold instance + getCloudSync(): CloudSync | null accessor (copy :62).initializeBackground() (worker-service.ts:452–622): after settings load (:460), if active → construct + start(), near the Chroma gate (:504)/backfill (:604).dbManager.getCloudSync()?.notify() at ResponseProcessor.ts:343 area, :430 area, SessionRoutes.ts:587 area.Verification:
tests/worker/sync/cloud-sync.test.ts (mock fetch): burst of notifies → exactly one flush; drain stamps rows and empties pending; failed POST leaves NULL + retries; body packing respects 2MB; prompt >200KB arrives clamped with the truncation marker; blank token → start() no-ops.bun test green.Anti-pattern guards: never await sync in the write path; no new deps; don't log the token (log its length or last 4 chars only); don't re-push pulled/foreign rows (moot until Phase 5, but the drain query gains AND origin_device_id IS NULL then).
What: GET /api/sync/status → cloudSync.status() (plus {configured: false} when inactive). New CloudSyncRoutes extends BaseRouteHandler copied from LogsRoutes.ts:70–137; register in initializeBackground() after the service exists (SearchRoutes pattern, worker-service.ts:528).
Verification: [ ] curl 127.0.0.1:<port>/api/sync/status returns pending counts; unconfigured worker returns configured: false, not 500.
What: plugin/skills/cloud-sync/SKILL.md (auto-discovered; no bundled script). Frontmatter: name: cloud-sync, allowed-tools: [Bash, Read, AskUserQuestion], description triggering on "set up cloud sync / sync my memories / cmem pro / cloud backup / sync status".
Runbook the skill encodes:
GET /api/sync/status (worker port from ~/.claude-mem/settings.json → CLAUDE_MEM_WORKER_PORT).~/.claude-mem/.cloud-sync.env exists → read token/user-id from it, tell the user they're being migrated; (b) else ask the user to paste token + user id from cmem.ai → Connect. Write both into ~/.claude-mem/settings.json (preserve 0600; never echo the token, never put it on a command line).~/.claude-mem/cloud-sync.pid holds a live pid → kill it; then archive the standalone artifacts (cloud-sync.mjs, .cloud-sync.env, cloud-sync.pid → rename with .retired suffix; keep cloud-sync-state.json untouched — the Phase 1 migration and Phase 2 device-id adoption read it).POST /api/admin/restart, then poll /api/sync/status until pending counts drain; report.Verification:
.cloud-sync.env, kills the daemon, worker adopts device id ee1b7637-… (verify in settings + status), pending ≈ 0 immediately (rows stamped by Phase 1 from cursors), a new observation lands in the cloud within seconds of the write — no duplicate rows appear in the cmem.ai dashboard (device-id continuity proof).grep -rn "CLAUDE_PLUGIN_ROOT\|nohup\|\.pid" plugin/skills/cloud-sync/ → only the retirement step touches .pid.What: columns origin_device_id TEXT, origin_local_id TEXT on the three tables (+ UNIQUE partial index on the pair WHERE origin_device_id IS NOT NULL — replaces the standalone client's JSON "imported ledger"); worker pulls GET /pull per kind at startup and after each flush, inserting only rows from other devices (INSERT OR IGNORE against the unique index; skip device_id IN (mine, 'legacy')); push drain gains AND origin_device_id IS NULL so foreign rows never echo back up. Pull cursors (pullAfter) live in a small sync_meta KV table, not a file.
Verification: [ ] two-fixture-device test: device B's rows appear locally once (re-pull = no dupes), never re-uploaded under A's id.
docs/public/cloud-sync.mdx (model on hosted-server.mdx): what syncs, privacy note, /cloud-sync quick start, settings keys table, status endpoint, "no daemon — the worker syncs on write" architecture note. Register in docs/public/docs.json nav (Hosted Server group, lines 102–108). README pointer near README.md:428; don't touch i18n READMEs.npm run build-and-sync; confirm worker restart clean, skill visible in marketplace + cache dirs.grep -rn "cm_pro_" — repo must be clean); no node:sqlite in src; write sites show paired chroma+cloud one-liners.bun test + live smoke (Phase 4's migration checklist) — then delete plans/inbox/2026-07-08-cloud-sync-skill.md's obsolete sibling artifacts if any remain.cloud-sync.mjs in the skill + ${CLAUDE_SKILL_DIR} invocation — no script to bundle anymore..cloud-sync.env — settings.json, like every other credential (Chroma precedent).