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 (14293 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 const LANE_ORDER = ['doing', 'do', 'maybe', 'inbox', 'skip', 'done', 'dropped'];
191 
192 export function matchesFilter(idea, filter) {
193   const l = lane(idea);
194   switch (filter) {
195     case 'all':
196       return true;
197     case 'open':
198       return ['doing', 'do', 'maybe', 'inbox'].includes(l);
199     default:
200       return l === filter;
201   }
202 }
203 
204 export function listIdeas(db, { filter = 'open', project = null, query = '', limit = 0 } = {}) {
205   if (!FILTERS.includes(filter)) throw new Error(`unknown filter "${filter}" (use: ${FILTERS.join(', ')})`);
206   const words = String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
207   let ideas = db.ideas.filter((i) => matchesFilter(i, filter));
208   if (project) ideas = ideas.filter((i) => samePath(i.project, project));
209   if (words.length) {
210     ideas = ideas.filter((i) => {
211       const hay = `${i.title}\n${i.text}\n${(i.tags || []).join(' ')}`.toLowerCase();
212       return words.every((w) => hay.includes(w));
213     });
214   }
215   ideas.sort((a, b) => {
216     const la = LANE_ORDER.indexOf(lane(a));
217     const lb = LANE_ORDER.indexOf(lane(b));
218     if (la !== lb) return la - lb;
219     if (['done', 'dropped', 'inbox'].includes(lane(a))) return b.id - a.id; // newest first
220     return priority(b) - priority(a) || a.id - b.id;
221   });
222   return limit > 0 ? ideas.slice(0, limit) : ideas;
223 }
224 
225 export function counts(db) {
226   const c = { inbox: 0, do: 0, maybe: 0, skip: 0, doing: 0, done: 0, dropped: 0 };
227   for (const i of db.ideas) c[lane(i)] = (c[lane(i)] || 0) + 1;
228   c.open = c.inbox + c.do + c.maybe + c.doing;
229   return c;
230 }
231 
232 const READY_LANES = ['do', 'maybe'];
233 
234 /**
235  * Best idea to build next: verdict "do" before "maybe", not started, current project first, then
236  * value per effort. A "maybe" idea is still in the queue, so /ideas-go builds it when no "do" is left.
237  */
238 export function pickNext(db, { project = null, only = false } = {}) {
239   let ready = db.ideas.filter((i) => READY_LANES.includes(lane(i)));
240   if (only) ready = ready.filter((i) => samePath(i.project, project));
241   ready.sort((a, b) => {
242     const pa = project && samePath(a.project, project) ? 1 : 0;
243     const pb = project && samePath(b.project, project) ? 1 : 0;
244     return READY_LANES.indexOf(lane(a)) - READY_LANES.indexOf(lane(b)) || pb - pa || priority(b) - priority(a) || a.id - b.id;
245   });
246   return ready[0] || null;
247 }
248 
249 function similarTo(db, text, excludeId) {
250   const words = wordSet(text);
251   if (words.size < 2) return [];
252   return db.ideas
253     .filter((i) => i.id !== excludeId && i.status !== 'dropped' && isSimilar(words, wordSet(`${i.title} ${i.text}`)))
254     .slice(-2)
255     .map((i) => ({ id: i.id, title: i.title, status: lane(i) }));
256 }
257 
258 // ---------------------------------------------------------------------------------------------
259 // Mutations
260 
261 export function addIdeas(texts, { source = 'mcp', project = null, session = null, tags = [], host = null } = {}) {
262   const items = (Array.isArray(texts) ? texts : [texts]).map((t) => String(t ?? '').trim()).filter(Boolean);
263   if (!items.length) throw new Error('idea text is empty');
264   return mutate((db) =>
265     items.map((text) => {
266       const similar = similarTo(db, text, null);
267       const stamp = now();
268       const idea = {
269         id: db.next_id++,
270         title: deriveTitle(text),
271         text,
272         status: 'inbox',
273         tags: normalizeTags([...extractTags(text), ...normalizeTags(tags)]),
274         project: project ? absolute(project) : null,
275         source,
276         created: stamp,
277         updated: stamp,
278         triage: null,
279         notes: [],
280       };
281       if (session) idea.session = String(session);
282       if (host) idea.host = String(host);
283       db.ideas.push(idea);
284       return { idea, similar };
285     }),
286   );
287 }
288 
289 export function normalizeModel(model) {
290   const m = String(model || '').toLowerCase();
291   return MODEL_ALIASES.find((alias) => m.includes(alias)) || null;
292 }
293 
294 function cleanVerdict(v) {
295   const verdict = String(v.verdict || '').toLowerCase();
296   if (!VERDICTS.includes(verdict)) throw new Error(`verdict must be one of ${VERDICTS.join('/')}`);
297   const impact = Math.min(5, Math.max(1, Math.round(Number(v.impact) || 3)));
298   const size = SIZES.includes(String(v.size || '').toLowerCase()) ? String(v.size).toLowerCase() : 'm';
299   const model = normalizeModel(v.model) || 'sonnet';
300   return {
301     verdict,
302     impact,
303     size,
304     model,
305     why: clip(v.why || '', 300),
306     brief: verdict === 'skip' ? '' : String(v.brief || '').trim().slice(0, 2000),
307   };
308 }
309 
310 /**
311  * Save triage verdicts. Bad items are reported individually instead of failing the batch.
312  * `projects` are the folders that the triage could pair ideas with (from knownProjects). A verdict
313  * that names one of them moves its idea there.
314  */
315 export function applyTriage(verdicts, { by = null, projects = null } = {}) {
316   if (!Array.isArray(verdicts) || !verdicts.length) throw new Error('no verdicts given');
317   return mutate((db) =>
318     verdicts.map((v) => {
319       const idea = findIdea(db, v?.id);
320       if (!idea) return { id: v?.id, error: 'no such idea' };
321       let t;
322       try {
323         t = cleanVerdict(v);
324       } catch (e) {
325         return { id: idea.id, error: e.message };
326       }
327       idea.triage = { ...t, at: now(), ...(by ? { by } : {}), ...(projects ? { paired: true } : {}) };
328       if (v.title) idea.title = clip(v.title, 90);
329       if (Array.isArray(v.tags)) idea.tags = normalizeTags([...(idea.tags || []), ...v.tags]);
330       const dup = Number(v.dup_of);
331       if (dup && dup !== idea.id && findIdea(db, dup)) idea.dup_of = dup;
332       const target = projects && findProject(projects, v.project);
333       let moved = null;
334       if (target && !samePath(target.dir, idea.project)) {
335         idea.notes ||= [];
336         idea.notes.push({ at: now(), text: `paired with ${target.dir} (was ${idea.project || 'no folder'})` });
337         idea.project = target.dir;
338         moved = target.dir;
339       }
340       if (idea.status === 'inbox') idea.status = 'triaged';
341       idea.updated = now();
342       return { id: idea.id, verdict: t.verdict, model: t.model, size: t.size, title: idea.title, ...(moved ? { moved } : {}) };
343     }),
344   );
345 }
346 
347 /** Delete ideas for good and return them. An unknown id is an error, and then nothing is deleted. */
348 export function removeIdeas(ids) {
349   return mutate((db) => {
350     const gone = ids.map((id) => {
351       const idea = findIdea(db, id);
352       if (!idea) throw new Error(`no idea #${String(id).replace(/^#/, '')}`);
353       return idea;
354     });
355     db.ideas = db.ideas.filter((i) => !gone.includes(i));
356     return [...new Set(gone)];
357   });
358 }
359 
360 const STATUS_ALIASES = { start: 'doing', started: 'doing', finish: 'done', finished: 'done', drop: 'dropped' };
361 
362 export function updateIdea(id, patch = {}) {
363   return mutate((db) => {
364     const idea = findIdea(db, id);
365     if (!idea) throw new Error(`no idea #${id}`);
366     if (patch.status) {
367       let status = String(patch.status).toLowerCase();
368       status = STATUS_ALIASES[status] || status;
369       if (status === 'reopen' || status === 'open') status = idea.triage ? 'triaged' : 'inbox';
370       if (!STATUSES.includes(status)) throw new Error(`status must be one of ${STATUSES.join(', ')}, or reopen`);
371       // The dashboard timeline shows work from the latest start.
372       if (status === 'doing' && idea.status !== 'doing') idea.started = now();
373       idea.status = status;
374       if (status === 'done' || status === 'dropped') idea.closed = now();
375       else delete idea.closed;
376     }
377     if (patch.text) idea.text = String(patch.text).trim();
378     if (patch.title) idea.title = clip(patch.title, 90);
379     else if (patch.text) idea.title = deriveTitle(idea.text);
380     if (Array.isArray(patch.tags)) idea.tags = normalizeTags(patch.tags);
381     if (patch.project !== undefined) idea.project = patch.project ? absolute(patch.project) : null;
382     if (patch.model) {
383       const model = normalizeModel(patch.model);
384       if (!model) throw new Error(`model must be one of ${MODEL_ALIASES.join(', ')}`);
385       idea.triage = { ...(idea.triage || { verdict: 'do', impact: 3, size: 'm', why: '', brief: '' }), model };
386       if (idea.status === 'inbox') idea.status = 'triaged';
387     }
388     if (patch.note) {
389       idea.notes ||= [];
390       idea.notes.push({ at: now(), text: String(patch.note).trim() });
391     }
392     idea.updated = now();
393     return idea;
394   });
395 }