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/store.js (14940 bytes)

1 // The idea archive: one JSON file shared by every Claude session, project, and model.
2 // Writers take a directory lock and replace the file atomically, so concurrent sessions,
3 // the hook, and the CLI can all write at once without losing ideas.
4 
5 import crypto from 'node:crypto';
6 import fs from 'node:fs';
7 import os from 'node:os';
8 import path from 'node:path';
9 import { findProject, samePath } from './projects.js';
10 import { clip, deriveTitle, extractTags, isSimilar, normalizeTags, wordSet } from './text.js';
11 
12 export const VERDICTS = ['do', 'maybe', 'skip'];
13 export const SIZES = ['xs', 's', 'm', 'l', 'xl'];
14 export const SIZE_COST = { xs: 1, s: 2, m: 3, l: 5, xl: 8 };
15 export const MODEL_ALIASES = ['haiku', 'sonnet', 'opus', 'fable'];
16 export const STATUSES = ['inbox', 'triaged', 'doing', 'done', 'dropped'];
17 export const FILTERS = ['open', 'inbox', 'do', 'maybe', 'skip', 'doing', 'done', 'dropped', 'all'];
18 
19 const LOCK_STALE_MS = 5000;
20 const LOCK_WAIT_MS = 8000;
21 
22 export function home() {
23   return process.env.IDEAMINE_HOME || path.join(os.homedir(), '.ideamine');
24 }
25 
26 export function dbPath() {
27   return path.join(home(), 'ideas.json');
28 }
29 
30 function sleepSync(ms) {
31   Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
32 }
33 
34 const now = () => new Date().toISOString();
35 
36 function emptyDb() {
37   return { version: 1, next_id: 1, ideas: [] };
38 }
39 
40 /** Read-only snapshot. Safe without the lock because writes are atomic renames. */
41 export function load() {
42   const file = dbPath();
43   let raw;
44   try {
45     raw = fs.readFileSync(file, 'utf8').replace(/^/, '');
46   } catch (e) {
47     if (e.code === 'ENOENT') return emptyDb();
48     throw e;
49   }
50   if (!raw.trim()) return emptyDb();
51   let db;
52   try {
53     db = JSON.parse(raw);
54   } catch (e) {
55     throw new Error(`${file} is not valid JSON (${e.message}). Fix it, or restore ${file}.bak`);
56   }
57   if (!db || !Array.isArray(db.ideas)) throw new Error(`${file} has no "ideas" array`);
58   const maxId = db.ideas.reduce((m, i) => Math.max(m, Number(i.id) || 0), 0);
59   db.next_id = Math.max(Number(db.next_id) || 1, maxId + 1);
60   return db;
61 }
62 
63 function acquireLock() {
64   fs.mkdirSync(home(), { recursive: true });
65   const lockDir = path.join(home(), '.lock');
66   const deadline = Date.now() + LOCK_WAIT_MS;
67   for (let attempt = 0; ; attempt++) {
68     try {
69       fs.mkdirSync(lockDir);
70       return () => fs.rmSync(lockDir, { recursive: true, force: true });
71     } catch (e) {
72       if (e.code !== 'EEXIST') throw e;
73     }
74     try {
75       // A writer holds the lock for milliseconds; an old lock means a crashed process.
76       if (Date.now() - fs.statSync(lockDir).mtimeMs > LOCK_STALE_MS) {
77         fs.rmSync(lockDir, { recursive: true, force: true });
78         continue;
79       }
80     } catch {
81       // The lock vanished between mkdir and stat; the next attempt will likely get it.
82     }
83     if (Date.now() > deadline) {
84       throw new Error(`idea store is busy (lock: ${lockDir}). Delete that folder if no ideamine process is running.`);
85     }
86     sleepSync(Math.min(10 + attempt * 5, 100));
87   }
88 }
89 
90 function renameWithRetry(from, to) {
91   // Windows refuses to replace a file that another program (editor, antivirus, sync) has open.
92   for (let attempt = 0; ; attempt++) {
93     try {
94       fs.renameSync(from, to);
95       return;
96     } catch (e) {
97       if (attempt >= 40 || !['EPERM', 'EBUSY', 'EACCES'].includes(e.code)) {
98         fs.rmSync(from, { force: true });
99         throw e;
100       }
101       sleepSync(25);
102     }
103   }
104 }
105 
106 /** Replace `file` in one step, so a reader never sees half a file. Call it while you hold the lock. */
107 export function writeAtomic(file, text, { backup = false } = {}) {
108   const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
109   fs.writeFileSync(tmp, text);
110   if (backup) {
111     try {
112       fs.copyFileSync(file, `${file}.bak`);
113     } catch (e) {
114       if (e.code !== 'ENOENT') throw e;
115     }
116   }
117   renameWithRetry(tmp, file);
118 }
119 
120 /**
121  * Write the archive. `uid` names this archive and `rev` counts its writes, so that a machine that
122  * syncs with a server can tell an old copy of the server's archive from a new one.
123  */
124 function save(db) {
125   db.uid ||= crypto.randomUUID();
126   db.rev = (Number(db.rev) || 0) + 1;
127   writeAtomic(dbPath(), JSON.stringify(db, null, 2) + '\n', { backup: true });
128 }
129 
130 /**
131  * The absolute form of a project folder. On a POSIX server, a Windows path from a machine that
132  * syncs with it stays as that machine wrote it.
133  */
134 export function absolute(dir, platform = process.platform) {
135   const s = String(dir);
136   if (platform !== 'win32' && /^(?:[A-Za-z]:[\\/]|\\\\)/.test(s)) return s;
137   return path.resolve(s);
138 }
139 
140 /** The last part of a folder path, for Windows and POSIX paths on any machine. */
141 export function baseName(dir) {
142   return String(dir).split(/[\\/]+/).filter(Boolean).pop() || String(dir);
143 }
144 
145 /** Run `fn` while this process holds the archive lock. Other files in home() can share the lock. */
146 export function withLock(fn) {
147   const release = acquireLock();
148   try {
149     return fn();
150   } finally {
151     release();
152   }
153 }
154 
155 /** Lock, read, apply `fn(db)`, write. Nothing is written if `fn` throws. */
156 export function mutate(fn) {
157   return withLock(() => {
158     const db = load();
159     const result = fn(db);
160     save(db);
161     return result;
162   });
163 }
164 
165 // ---------------------------------------------------------------------------------------------
166 // Queries
167 
168 export function findIdea(db, id) {
169   const n = Number(String(id).replace(/^#/, ''));
170   return db.ideas.find((i) => i.id === n) || null;
171 }
172 
173 export function verdictOf(idea) {
174   return idea.status === 'triaged' ? idea.triage?.verdict || null : null;
175 }
176 
177 /** Value per unit of effort; untriaged ideas sort last. */
178 export function priority(idea) {
179   const t = idea.triage;
180   if (!t) return 0;
181   return (Number(t.impact) || 3) / (SIZE_COST[t.size] || 3);
182 }
183 
184 /** Lane an idea shows up in on the board. */
185 export function lane(idea) {
186   if (idea.status === 'triaged') return idea.triage?.verdict || 'maybe';
187   return idea.status;
188 }
189 
190 /** The lanes of the board, in the order they are shown. */
191 export const LANES = ['doing', 'do', 'maybe', 'inbox', 'skip', 'done', 'dropped'];
192 
193 export function matchesFilter(idea, filter) {
194   const l = lane(idea);
195   switch (filter) {
196     case 'all':
197       return true;
198     case 'open':
199       return ['doing', 'do', 'maybe', 'inbox'].includes(l);
200     default:
201       return l === filter;
202   }
203 }
204 
205 export function listIdeas(db, { filter = 'open', project = null, query = '', limit = 0 } = {}) {
206   if (!FILTERS.includes(filter)) throw new Error(`unknown filter "${filter}" (use: ${FILTERS.join(', ')})`);
207   const words = String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
208   let ideas = db.ideas.filter((i) => matchesFilter(i, filter));
209   if (project) ideas = ideas.filter((i) => samePath(i.project, project));
210   if (words.length) {
211     ideas = ideas.filter((i) => {
212       const hay = `${i.title}\n${i.text}\n${(i.tags || []).join(' ')}`.toLowerCase();
213       return words.every((w) => hay.includes(w));
214     });
215   }
216   ideas.sort((a, b) => {
217     const la = LANES.indexOf(lane(a));
218     const lb = LANES.indexOf(lane(b));
219     if (la !== lb) return la - lb;
220     if (['done', 'dropped', 'inbox'].includes(lane(a))) return b.id - a.id; // newest first
221     return priority(b) - priority(a) || a.id - b.id;
222   });
223   return limit > 0 ? ideas.slice(0, limit) : ideas;
224 }
225 
226 export function counts(db) {
227   const c = { inbox: 0, do: 0, maybe: 0, skip: 0, doing: 0, done: 0, dropped: 0 };
228   for (const i of db.ideas) c[lane(i)] = (c[lane(i)] || 0) + 1;
229   c.open = c.inbox + c.do + c.maybe + c.doing;
230   return c;
231 }
232 
233 const READY_LANES = ['do', 'maybe'];
234 
235 /**
236  * Best idea to build next: verdict "do" before "maybe", not started, current project first, then
237  * value per effort. A "maybe" idea is still in the queue, so /ideas-go builds it when no "do" is left.
238  */
239 export function pickNext(db, { project = null, only = false } = {}) {
240   let ready = db.ideas.filter((i) => READY_LANES.includes(lane(i)));
241   if (only) ready = ready.filter((i) => samePath(i.project, project));
242   ready.sort((a, b) => {
243     const pa = project && samePath(a.project, project) ? 1 : 0;
244     const pb = project && samePath(b.project, project) ? 1 : 0;
245     return READY_LANES.indexOf(lane(a)) - READY_LANES.indexOf(lane(b)) || pb - pa || priority(b) - priority(a) || a.id - b.id;
246   });
247   return ready[0] || null;
248 }
249 
250 function similarTo(db, text, excludeId) {
251   const words = wordSet(text);
252   if (words.size < 2) return [];
253   return db.ideas
254     .filter((i) => i.id !== excludeId && i.status !== 'dropped' && isSimilar(words, wordSet(`${i.title} ${i.text}`)))
255     .slice(-2)
256     .map((i) => ({ id: i.id, title: i.title, status: lane(i) }));
257 }
258 
259 // ---------------------------------------------------------------------------------------------
260 // Mutations
261 
262 export function addIdeas(texts, { source = 'mcp', project = null, session = null, tags = [], host = null } = {}) {
263   const items = (Array.isArray(texts) ? texts : [texts]).map((t) => String(t ?? '').trim()).filter(Boolean);
264   if (!items.length) throw new Error('idea text is empty');
265   return mutate((db) =>
266     items.map((text) => {
267       const similar = similarTo(db, text, null);
268       const stamp = now();
269       const idea = {
270         id: db.next_id++,
271         title: deriveTitle(text),
272         text,
273         status: 'inbox',
274         tags: normalizeTags([...extractTags(text), ...normalizeTags(tags)]),
275         project: project ? absolute(project) : null,
276         source,
277         created: stamp,
278         updated: stamp,
279         triage: null,
280         notes: [],
281       };
282       if (session) idea.session = String(session);
283       if (host) idea.host = String(host);
284       db.ideas.push(idea);
285       return { idea, similar };
286     }),
287   );
288 }
289 
290 export function normalizeModel(model) {
291   const m = String(model || '').toLowerCase();
292   return MODEL_ALIASES.find((alias) => m.includes(alias)) || null;
293 }
294 
295 function cleanVerdict(v) {
296   const verdict = String(v.verdict || '').toLowerCase();
297   if (!VERDICTS.includes(verdict)) throw new Error(`verdict must be one of ${VERDICTS.join('/')}`);
298   const impact = Math.min(5, Math.max(1, Math.round(Number(v.impact) || 3)));
299   const size = SIZES.includes(String(v.size || '').toLowerCase()) ? String(v.size).toLowerCase() : 'm';
300   const model = normalizeModel(v.model) || 'sonnet';
301   return {
302     verdict,
303     impact,
304     size,
305     model,
306     why: clip(v.why || '', 300),
307     brief: verdict === 'skip' ? '' : String(v.brief || '').trim().slice(0, 2000),
308   };
309 }
310 
311 /**
312  * Save triage verdicts. Bad items are reported individually instead of failing the batch.
313  * `projects` are the folders that the triage could pair ideas with (from knownProjects). A verdict
314  * that names one of them moves its idea there.
315  */
316 export function applyTriage(verdicts, { by = null, projects = null } = {}) {
317   if (!Array.isArray(verdicts) || !verdicts.length) throw new Error('no verdicts given');
318   return mutate((db) =>
319     verdicts.map((v) => {
320       const idea = findIdea(db, v?.id);
321       if (!idea) return { id: v?.id, error: 'no such idea' };
322       let t;
323       try {
324         t = cleanVerdict(v);
325       } catch (e) {
326         return { id: idea.id, error: e.message };
327       }
328       idea.triage = { ...t, at: now(), ...(by ? { by } : {}), ...(projects ? { paired: true } : {}) };
329       if (v.title) idea.title = clip(v.title, 90);
330       if (Array.isArray(v.tags)) idea.tags = normalizeTags([...(idea.tags || []), ...v.tags]);
331       const dup = Number(v.dup_of);
332       if (dup && dup !== idea.id && findIdea(db, dup)) idea.dup_of = dup;
333       const target = projects && findProject(projects, v.project);
334       let moved = null;
335       if (target && !samePath(target.dir, idea.project)) {
336         idea.notes ||= [];
337         idea.notes.push({ at: now(), text: `paired with ${target.dir} (was ${idea.project || 'no folder'})` });
338         idea.project = target.dir;
339         moved = target.dir;
340       }
341       if (idea.status === 'inbox') idea.status = 'triaged';
342       idea.updated = now();
343       return { id: idea.id, verdict: t.verdict, model: t.model, size: t.size, title: idea.title, ...(moved ? { moved } : {}) };
344     }),
345   );
346 }
347 
348 /** Delete ideas for good and return them. An unknown id is an error, and then nothing is deleted. */
349 export function removeIdeas(ids) {
350   return mutate((db) => {
351     const gone = ids.map((id) => {
352       const idea = findIdea(db, id);
353       if (!idea) throw new Error(`no idea #${String(id).replace(/^#/, '')}`);
354       return idea;
355     });
356     db.ideas = db.ideas.filter((i) => !gone.includes(i));
357     return [...new Set(gone)];
358   });
359 }
360 
361 const STATUS_ALIASES = { start: 'doing', started: 'doing', finish: 'done', finished: 'done', drop: 'dropped' };
362 
363 export function updateIdea(id, patch = {}) {
364   return mutate((db) => {
365     const idea = findIdea(db, id);
366     if (!idea) throw new Error(`no idea #${id}`);
367     if (patch.status) {
368       let status = String(patch.status).toLowerCase();
369       status = STATUS_ALIASES[status] || status;
370       if (status === 'reopen' || status === 'open') status = idea.triage ? 'triaged' : 'inbox';
371       if (!STATUSES.includes(status)) throw new Error(`status must be one of ${STATUSES.join(', ')}, or reopen`);
372       // The dashboard timeline shows work from the latest start.
373       if (status === 'doing' && idea.status !== 'doing') idea.started = now();
374       idea.status = status;
375       if (status === 'done' || status === 'dropped') idea.closed = now();
376       else delete idea.closed;
377       if (status !== 'doing') delete idea.agent; // an agent works only on an idea in Doing
378     }
379     // agent "any": an agent of coop builds the idea (see coop.js). Only an idea in Doing can wait for one.
380     if (patch.agent != null) {
381       if (patch.agent !== 'any') throw new Error('agent must be "any"');
382       if (idea.status !== 'doing') throw new Error('only an idea in Doing can wait for an agent');
383       if (!idea.agent) {
384         idea.agent = { name: 'any', at: now() };
385         idea.notes ||= [];
386         idea.notes.push({ at: now(), text: 'waits for a free coop agent' });
387       }
388     }
389     if (patch.text) idea.text = String(patch.text).trim();
390     if (patch.title) idea.title = clip(patch.title, 90);
391     else if (patch.text) idea.title = deriveTitle(idea.text);
392     if (Array.isArray(patch.tags)) idea.tags = normalizeTags(patch.tags);
393     if (patch.project !== undefined) idea.project = patch.project ? absolute(patch.project) : null;
394     if (patch.model) {
395       const model = normalizeModel(patch.model);
396       if (!model) throw new Error(`model must be one of ${MODEL_ALIASES.join(', ')}`);
397       idea.triage = { ...(idea.triage || { verdict: 'do', impact: 3, size: 'm', why: '', brief: '' }), model };
398       if (idea.status === 'inbox') idea.status = 'triaged';
399     }
400     if (patch.note) {
401       idea.notes ||= [];
402       idea.notes.push({ at: now(), text: String(patch.note).trim() });
403     }
404     idea.updated = now();
405     return idea;
406   });
407 }