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 }