src/sync.js (16159 bytes)
1 // Sync with an ideamine server, so that every Claude on every machine sees one archive. The server 2 // (`ideamine serve`, for example behind nginx on a WireGuard address) holds the archive, and 3 // ideas.json on this machine is a copy of it. A change goes into the outbox first and then to the 4 // server. When the server does not answer, the change waits in the outbox, so no idea is lost. The 5 // prompt log takes the same way. No process stays alive: the hook calls kick() for each prompt, and 6 // kick() starts a background sync when work waits or the copy is old. 7 8 import { spawn } from 'node:child_process'; 9 import crypto from 'node:crypto'; 10 import fs from 'node:fs'; 11 import os from 'node:os'; 12 import path from 'node:path'; 13 import { fileURLToPath } from 'node:url'; 14 import * as config from './config.js'; 15 import { stamp } from './render.js'; 16 import { dbPath, home, withLock, writeAtomic } from './store.js'; 17 import { clip } from './text.js'; 18 19 const PULL_EVERY_MS = 60 * 1000; // a copy older than this is pulled again in the background 20 const PROMPTS_EVERY_MS = 20 * 1000; // prompts wait at least this long, so that a prompt starts no process each time 21 const WAIT_AFTER_ERROR_MS = 2 * 60 * 1000; // after a failed sync, before kick() tries again 22 const LOCK_STALE_MS = 10 * 60 * 1000; 23 const OPS_PER_REQUEST = 100; 24 const PROMPT_BYTES_PER_REQUEST = 256 * 1024; 25 const USAGE_EVERY_MS = 10 * 60 * 1000; // the token use goes to the server at most this often 26 const USAGE_DAYS = 365; // the rows that go to the server, so that one request stays small 27 const MAX_PROMPT_CHARS = 100 * 1000; // a longer paste is cut, so that each prompt fits in a request 28 29 const outboxPath = () => path.join(home(), 'outbox.jsonl'); 30 const promptsPath = () => path.join(home(), 'prompts-outbox.jsonl'); 31 const statePath = () => path.join(home(), 'sync.json'); 32 const lockPath = () => path.join(home(), '.sync'); 33 34 /** The server does not answer, or it refuses the request. The change stays in the outbox. */ 35 export class SyncError extends Error {} 36 37 /** The address of the server, with a slash at the end, or '' when sync is off. */ 38 export function serverUrl() { 39 const url = config.get('sync_url'); 40 return url ? (url.endsWith('/') ? url : `${url}/`) : ''; 41 } 42 43 export const enabled = () => serverUrl() !== ''; 44 export const logsPrompts = () => enabled() && /^(on|true|yes|1)$/i.test(config.get('prompt_log')); 45 46 function readLines(file) { 47 let raw; 48 try { 49 raw = fs.readFileSync(file, 'utf8'); 50 } catch { 51 return []; 52 } 53 const out = []; 54 for (const line of raw.split('\n')) { 55 if (!line.trim()) continue; 56 try { 57 out.push(JSON.parse(line)); 58 } catch { 59 // A line that a crash cut in half. The rest of the file is still good. 60 } 61 } 62 return out; 63 } 64 65 const writeLines = (file, items) => writeAtomic(file, items.map((i) => `${JSON.stringify(i)}\n`).join('')); 66 const append = (file, items) => withLock(() => fs.appendFileSync(file, items.map((i) => `${JSON.stringify(i)}\n`).join(''))); 67 const size = (file) => fs.statSync(file, { throwIfNoEntry: false })?.size || 0; 68 69 export function readState() { 70 try { 71 return JSON.parse(fs.readFileSync(statePath(), 'utf8')); 72 } catch { 73 return {}; 74 } 75 } 76 77 // Call it while you hold the lock. 78 const saveState = (patch) => writeAtomic(statePath(), `${JSON.stringify({ ...readState(), ...patch }, null, 2)}\n`); 79 const writeState = (patch) => withLock(() => saveState(patch)); 80 const failed = (e) => writeState({ error: e.message, errorAt: new Date().toISOString() }); 81 82 async function request(route, { body, timeoutMs }) { 83 const target = new URL(route, serverUrl()); 84 const init = { signal: AbortSignal.timeout(timeoutMs) }; 85 if (body !== undefined) Object.assign(init, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); 86 let res; 87 try { 88 res = await fetch(target, init); 89 } catch (e) { 90 const why = e.name === 'TimeoutError' ? `no answer in ${timeoutMs / 1000} s` : e.cause?.code || e.cause?.message || e.message; 91 throw new SyncError(`cannot reach ${target.origin} (${why})`); 92 } 93 const text = await res.text(); 94 let json = null; 95 try { 96 json = JSON.parse(text); 97 } catch { 98 // Not JSON: the error below shows the text. 99 } 100 if (!res.ok || !json?.ok) throw new SyncError(`${target.origin} answered ${res.status}: ${json?.error || clip(text, 160)}`); 101 return json; 102 } 103 104 /** Keep the archive of the server as the copy here, unless the copy is newer: a slow answer must not undo a later one. */ 105 function keep(db) { 106 if (!db || !Array.isArray(db.ideas)) throw new SyncError('the server sent no archive'); 107 withLock(() => { 108 const state = readState(); 109 if (db.uid === state.uid && (Number(db.rev) || 0) < (Number(state.rev) || 0)) return; 110 writeAtomic(dbPath(), `${JSON.stringify(db, null, 2)}\n`, { backup: true }); 111 saveState({ uid: db.uid, rev: db.rev, pulled: new Date().toISOString(), error: null, errorAt: null }); 112 }); 113 } 114 115 /** 116 * Send the changes of the outbox, oldest first. `extra` must go even when another process took it 117 * from the outbox already: the server applies a change only once, and it answers with the result. 118 * Resolves to the results by oid, and to the archive of the server after the last change. 119 */ 120 async function sendOps({ extra = null, timeoutMs }) { 121 const ops = withLock(() => readLines(outboxPath())); 122 if (extra && !ops.some((op) => op.oid === extra.oid)) ops.push(extra); 123 const results = new Map(); 124 let db = null; 125 for (let i = 0; i < ops.length; i += OPS_PER_REQUEST) { 126 const part = ops.slice(i, i + OPS_PER_REQUEST); 127 const out = await request('api/ops', { body: { ops: part }, timeoutMs }); 128 part.forEach((op, k) => results.set(op.oid, out.results[k])); 129 withLock(() => writeLines(outboxPath(), readLines(outboxPath()).filter((op) => !results.has(op.oid)))); 130 db = out.db; 131 } 132 return { results, db }; 133 } 134 135 async function sendPrompts({ timeoutMs }) { 136 const prompts = withLock(() => readLines(promptsPath())); 137 let part = []; 138 let bytes = 0; 139 const sendPart = async () => { 140 if (!part.length) return; 141 await request('api/prompts', { body: { prompts: part }, timeoutMs }); 142 const sent = new Set(part.map((p) => p.id)); 143 withLock(() => writeLines(promptsPath(), readLines(promptsPath()).filter((p) => !sent.has(p.id)))); 144 part = []; 145 bytes = 0; 146 }; 147 for (const p of prompts) { 148 const n = Buffer.byteLength(JSON.stringify(p)); 149 if (bytes + n > PROMPT_BYTES_PER_REQUEST) await sendPart(); 150 part.push(p); 151 bytes += n; 152 } 153 await sendPart(); 154 } 155 156 /** The id of this machine, so that the server keeps the token use of each machine apart. */ 157 export function machineId() { 158 const state = readState(); 159 if (state.machine) return state.machine; 160 const machine = crypto.randomUUID(); 161 writeState({ machine }); 162 return machine; 163 } 164 165 /** 166 * Send the token use of this machine. The server keeps one set of rows for each machine, so the new 167 * rows take the place of the rows from before. The scan reads only the new bytes of the transcripts. 168 */ 169 async function sendUsage({ timeoutMs }) { 170 const state = readState(); 171 if (Date.now() - Date.parse(state.usageAt || 0) < USAGE_EVERY_MS) return; 172 const usage = await import('./usage.js'); 173 const rows = usage.since(usage.scan().rows, USAGE_DAYS); 174 if (!rows.length) return; 175 await request('api/usage', { body: { machine: machineId(), host: os.hostname(), rows }, timeoutMs }); 176 writeState({ usageAt: new Date().toISOString() }); 177 } 178 179 /** 180 * Bring the copy here up to date: send the changes that wait, then take the archive of the server. 181 * With `prompts`, send the prompts and the token use too. Throws SyncError when the server does not 182 * answer. 183 */ 184 export async function pull({ timeoutMs = 10000, prompts = false } = {}) { 185 let { db } = await sendOps({ timeoutMs }); 186 if (prompts) { 187 await sendPrompts({ timeoutMs }); 188 await sendUsage({ timeoutMs }); 189 } 190 db ||= (await request('api/db', { timeoutMs })).db; 191 keep(db); 192 } 193 194 /** 195 * Change the archive on the server. The change goes into the outbox, then to the server. Resolves 196 * to { value }, the result of the change, or to { queued, reason } when the server does not answer: 197 * the change then waits in the outbox. A change that the server refuses throws. 198 */ 199 export async function change(op, { timeoutMs = 3000 } = {}) { 200 const item = { ...op, oid: crypto.randomUUID(), at: new Date().toISOString() }; 201 append(outboxPath(), [item]); 202 let sent; 203 try { 204 sent = await sendOps({ extra: item, timeoutMs }); 205 } catch (e) { 206 if (!(e instanceof SyncError)) throw e; 207 failed(e); 208 return { queued: true, reason: e.message }; 209 } 210 keep(sent.db); 211 const r = sent.results.get(item.oid); 212 if (!r?.ok) throw new Error(r?.error || 'the server sent no result'); 213 return { value: r.value }; 214 } 215 216 /** How many changes wait in the outbox. */ 217 export const waiting = () => readLines(outboxPath()).length; 218 219 /** 220 * The text of a prompt for the log, or null when nothing is left. The notes that Claude Code and the 221 * desktop app put into a prompt (<system-reminder> blocks) go, and a huge paste is cut. 222 */ 223 function promptText(raw) { 224 const text = String(raw ?? '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim(); 225 if (!text) return null; 226 return text.length > MAX_PROMPT_CHARS ? `${text.slice(0, MAX_PROMPT_CHARS)}\n… (cut: ${text.length - MAX_PROMPT_CHARS} more characters)` : text; 227 } 228 229 /** Put a prompt into the prompt outbox. The next background sync sends it. */ 230 export function logPrompt({ prompt, session = null, cwd = null, at = new Date().toISOString() }) { 231 const text = promptText(prompt); 232 if (!text) return; 233 append(promptsPath(), [{ id: crypto.randomUUID(), at, host: os.hostname(), session, cwd, prompt: text }]); 234 if (!readState().promptsSince) writeState({ promptsSince: at }); 235 } 236 237 // The text that the user typed, from one line of a Claude Code transcript. Null for everything 238 // else: tool results, subagent prompts, command output, and notes that Claude Code adds itself. 239 function typedText(entry) { 240 if (entry?.type !== 'user' || entry.isMeta || entry.isSidechain || entry.toolUseResult) return null; 241 const content = entry.message?.content; 242 let text = null; 243 if (typeof content === 'string') text = content; 244 else if (Array.isArray(content) && content.every((b) => b?.type === 'text' || b?.type === 'image')) { 245 text = content.filter((b) => b.type === 'text').map((b) => b.text).join('\n'); 246 } 247 if (!text?.trim()) return null; 248 const name = text.match(/<command-name>([^<]*)<\/command-name>/); 249 if (name) { 250 const args = text.match(/<command-args>([\s\S]*?)<\/command-args>/); 251 return `${name[1]} ${args ? args[1] : ''}`.trim(); 252 } 253 if (/^(<(local-command-stdout|local-command-stderr|command-message|bash-input|bash-stdout|bash-stderr)>|\[Request interrupted)/.test(text.trim())) return null; 254 return text; 255 } 256 257 /** The transcripts folder of Claude Code on this machine. */ 258 export function transcriptsDir() { 259 return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects'); 260 } 261 262 /** 263 * Prompts from the Claude Code transcripts of this machine, for the prompt log. Only prompts from 264 * before the live prompt log started are taken, so that no prompt comes twice. An id made from the 265 * session, the time, and the text lets the server skip a prompt that an earlier import sent. 266 */ 267 export function importPrompts({ dir = transcriptsDir() } = {}) { 268 const before = readState().promptsSince || null; 269 const found = []; 270 let files = []; 271 try { 272 files = fs.readdirSync(dir, { recursive: true }).filter((f) => String(f).endsWith('.jsonl')); 273 } catch { 274 return 0; 275 } 276 for (const file of files) { 277 let raw; 278 try { 279 raw = fs.readFileSync(path.join(dir, String(file)), 'utf8'); 280 } catch { 281 continue; 282 } 283 for (const line of raw.split('\n')) { 284 if (!line.includes('"user"')) continue; 285 let entry; 286 try { 287 entry = JSON.parse(line); 288 } catch { 289 continue; 290 } 291 const prompt = promptText(typedText(entry)); 292 if (!prompt || !entry.timestamp || (before && entry.timestamp >= before)) continue; 293 const id = crypto.createHash('sha256').update(`${entry.sessionId}\n${entry.timestamp}\n${prompt}`).digest('hex').slice(0, 32); 294 found.push({ id, at: entry.timestamp, host: os.hostname(), session: entry.sessionId || null, cwd: entry.cwd || null, prompt }); 295 } 296 } 297 if (found.length) append(promptsPath(), found); 298 return found.length; 299 } 300 301 function isRunning(now) { 302 try { 303 return now - fs.statSync(lockPath()).mtimeMs < LOCK_STALE_MS; 304 } catch { 305 return false; 306 } 307 } 308 309 function startProcess() { 310 const bin = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url)); 311 const child = spawn(process.execPath, [bin, 'sync', '--background'], { cwd: home(), detached: true, stdio: 'ignore', windowsHide: true }); 312 // A sync that cannot start must not stop the caller. 313 child.on('error', (e) => failed(new Error(`cannot start a sync: ${e.message}`))); 314 child.unref(); 315 } 316 317 /** 318 * Start a background sync when sync is on, no sync runs, no sync failed a short time ago, and 319 * changes wait, prompts wait since the last sync 20 seconds ago, or the copy is older than a minute. 320 */ 321 export function kick({ start = startProcess, now = Date.now() } = {}) { 322 if (!enabled()) return false; 323 const state = readState(); 324 if (state.errorAt && now - Date.parse(state.errorAt) < WAIT_AFTER_ERROR_MS) return false; 325 if (isRunning(now)) return false; 326 const age = state.pulled ? now - Date.parse(state.pulled) : Infinity; 327 const due = age > PULL_EVERY_MS || size(outboxPath()) > 0 || (size(promptsPath()) > 0 && age > PROMPTS_EVERY_MS); 328 if (!due) return false; 329 start(); 330 return true; 331 } 332 333 /** One background sync, from kick(). Only one runs at a time. */ 334 export async function backgroundSync() { 335 fs.mkdirSync(home(), { recursive: true }); 336 try { 337 fs.mkdirSync(lockPath()); 338 } catch (e) { 339 if (e.code !== 'EEXIST' || isRunning(Date.now())) return 'busy'; 340 fs.utimesSync(lockPath(), new Date(), new Date()); // take over the lock of a crashed sync 341 } 342 try { 343 await pull({ timeoutMs: 30000, prompts: true }); 344 return 'done'; 345 } catch (e) { 346 failed(e); 347 return 'error'; 348 } finally { 349 fs.rmSync(lockPath(), { recursive: true, force: true }); 350 } 351 } 352 353 /** 354 * Turn sync on with the server at `url`, or off with ''. Before the first sync with a server, the 355 * archive here goes to a backup file, because the archive of the server takes its place. 356 */ 357 export function setServer(url) { 358 const clean = String(url || '').trim(); 359 if (clean && !/^https?:\/\//i.test(clean)) throw new Error(`"${clean}" is not an http:// or https:// address`); 360 let backup = null; 361 if (clean && fs.existsSync(dbPath()) && !readState().uid) { 362 backup = path.join(home(), `ideas.before-sync-${new Date().toISOString().slice(0, 10)}.json`); 363 fs.copyFileSync(dbPath(), backup); 364 } 365 config.set('sync_url', clean); 366 if (clean) config.set('publish_url', ''); // the server shows the live archive: no uploads 367 else writeState({ uid: null, rev: null, pulled: null, error: null, errorAt: null }); 368 return backup; 369 } 370 371 /** On or off, the last sync, and what waits. */ 372 export function status() { 373 const url = serverUrl(); 374 if (!url) return 'ideamine sync: off. The archive is on this machine. /ideas-sync <url> shares it through an ideamine server.'; 375 const s = readState(); 376 const lines = [`ideamine sync: ${url}`]; 377 lines.push(s.pulled ? `last sync ${stamp(s.pulled)}${s.rev ? `, revision ${s.rev}` : ''}.` : 'Not synced yet.'); 378 const changes = waiting(); 379 if (changes) lines.push(`${changes} change${changes === 1 ? ' waits' : 's wait'} for the server.`); 380 const prompts = readLines(promptsPath()).length; 381 if (prompts) lines.push(`${prompts} prompt${prompts === 1 ? ' waits' : 's wait'} for the server.`); 382 lines.push(logsPrompts() ? 'The prompt log is on.' : 'The prompt log is off. `ideamine config prompt_log on` turns it on.'); 383 if (s.error) lines.push(`The last sync failed at ${stamp(s.errorAt)}: ${s.error}`); 384 return lines.join('\n'); 385 }