Recently Written · git

ideamine

An idea inbox for Claude Code: /idea saves ideas at zero tokens; Claude triages them and routes each to the cheapest model that can build it.

git clone https://github.com/equwal/ideamine

Log | Files | Refs


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 }