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/publish.js (12986 bytes)

1 // The dashboard: a static page (dashboard/index.html) and a snapshot of the archive (data.json).
2 // `ideamine publish` uploads both with HTTP PUT, for example to nginx on a WireGuard address, or
3 // writes them to a folder. Like the watcher, no process stays alive: the hook calls kick() for
4 // each prompt, and kick() starts a background publish when the archive changed since the last one.
5 
6 import { spawn } from 'node:child_process';
7 import crypto from 'node:crypto';
8 import fs from 'node:fs';
9 import path from 'node:path';
10 import { fileURLToPath } from 'node:url';
11 import * as config from './config.js';
12 import * as embed from './embed.js';
13 import { baseName, counts, dbPath, home, lane, LANES, listIdeas, load, MODEL_ALIASES, SIZES } from './store.js';
14 
15 const PAGE = new URL('../dashboard/index.html', import.meta.url);
16 const WAIT_AFTER_ERROR_MS = 10 * 60 * 1000; // after a failed publish, before kick() tries again
17 const LOCK_STALE_MS = 10 * 60 * 1000;
18 
19 const statePath = () => path.join(home(), 'publish.json');
20 const lockPath = () => path.join(home(), '.publish');
21 
22 /** The earliest of the given times; null when none is set. */
23 const earliest = (...times) => times.filter(Boolean).sort()[0] || null;
24 
25 /**
26  * Bars for the timeline: the time an idea waited in the inbox, waited in the queue, and was in work.
27  * `to: null` means "until now". Ideas that started before ideamine recorded start times get an
28  * `estimated` work bar from the triage (or the creation) to the end.
29  */
30 export function phases(idea) {
31   const triaged = idea.triage?.at || null;
32   const started = idea.started || null;
33   const closed = idea.closed || null;
34   const out = [];
35   const add = (phase, from, to, estimated = false) => {
36     if (from && (!to || to >= from)) out.push(estimated ? { phase, from, to, estimated } : { phase, from, to });
37   };
38   if (!started && (idea.status === 'doing' || idea.status === 'done')) {
39     if (triaged) add('inbox', idea.created, triaged);
40     add('doing', triaged || idea.created, closed, true);
41     return out;
42   }
43   add('inbox', idea.created, earliest(triaged, started, closed));
44   if (triaged && (!started || triaged < started)) add('queued', triaged, started || closed);
45   if (started) add('doing', started, closed);
46   return out;
47 }
48 
49 const DAY = 86400000;
50 const OPEN_LANES = ['inbox', 'do', 'maybe', 'doing'];
51 
52 /**
53  * The lane of an idea at time `t` (ms), with the same rules as phases(): null before the idea
54  * exists. A done idea without a recorded close time closed at its last update.
55  */
56 export function laneAt(idea, t) {
57   const at = (time) => (time ? Date.parse(time) : NaN);
58   if (!(t >= at(idea.created))) return null;
59   const closedStatus = idea.status === 'done' || idea.status === 'dropped';
60   if (closedStatus && t >= at(idea.closed || idea.updated)) return idea.status;
61   if (t >= at(idea.started)) return 'doing';
62   const triaged = at(idea.triage?.at);
63   if (!idea.started && idea.status === 'doing' && t >= (triaged || at(idea.created))) return 'doing';
64   if (t >= triaged) return idea.triage?.verdict || 'maybe';
65   return 'inbox';
66 }
67 
68 /**
69  * Cumulative flow: `times` (ISO, up to `samples` evenly spaced from the first idea to now) and
70  * `series`, a count for each lane at each time. At every time, the lanes add up to the ideas that
71  * existed then.
72  */
73 export function flow(ideas, { samples = 60, now = Date.now() } = {}) {
74   const series = Object.fromEntries(LANES.map((l) => [l, []]));
75   const starts = ideas.map((i) => Date.parse(i.created)).filter(Number.isFinite);
76   if (!starts.length) return { times: [], series };
77   const first = Math.min(...starts, now);
78   const span = Math.max(now - first, 1);
79   const n = Math.max(2, Math.min(samples, span + 1)); // a step of 1 ms at least, so the times differ
80   const times = [];
81   for (let k = 0; k < n; k++) {
82     const t = first + Math.round((span * k) / (n - 1));
83     times.push(new Date(t).toISOString());
84     for (const l of LANES) series[l].push(0);
85     for (const idea of ideas) {
86       const l = laneAt(idea, t);
87       if (l && series[l]) series[l][k]++;
88     }
89   }
90   return { times, series };
91 }
92 
93 function median(values) {
94   if (!values.length) return null;
95   const sorted = [...values].sort((a, b) => a - b);
96   const mid = sorted.length >> 1;
97   return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
98 }
99 
100 /** The tiles of the Flow view: what is open, what closed in the last 7 days, and how long ideas take. */
101 export function stats(ideas, { now = Date.now() } = {}) {
102   const week = now - 7 * DAY;
103   const open = ideas.filter((i) => OPEN_LANES.includes(lane(i)));
104   const closedSince = (status) => ideas.filter((i) => i.status === status && Date.parse(i.closed) >= week).length;
105   const done = ideas.filter((i) => i.status === 'done' && i.closed);
106   const count = (keys, pick) => Object.fromEntries(keys.map((k) => [k, open.filter((i) => pick(i) === k).length]));
107   return {
108     open: open.length,
109     done_7d: closedSince('done'),
110     dropped_7d: closedSince('dropped'),
111     lead_median_ms: median(done.map((i) => Date.parse(i.closed) - Date.parse(i.created))),
112     cycle_median_ms: median(done.filter((i) => i.started).map((i) => Date.parse(i.closed) - Date.parse(i.started))),
113     oldest_open_ms: open.length ? Math.max(...open.map((i) => now - Date.parse(i.created))) : null,
114     models: count(MODEL_ALIASES, (i) => i.triage?.model),
115     sizes: count(SIZES, (i) => i.triage?.size),
116   };
117 }
118 
119 /** data.json (version 1). The dashboard page reads it; its format is in the README. */
120 export function snapshot(db, { vectors = null, groups = [], generated = new Date().toISOString() } = {}) {
121   const model = config.get('embed_model');
122   const groupThreshold = config.get('group_threshold');
123   const rank = new Map(listIdeas(db, { filter: 'all' }).map((idea, n) => [idea.id, n]));
124   const groupOf = new Map();
125   groups.forEach((g, n) => g.ids.forEach((id) => groupOf.set(id, n)));
126   const ids = db.ideas.map((i) => i.id);
127   return {
128     version: 1,
129     generated,
130     embed: {
131       model,
132       dim: vectors?.size ? vectors.values().next().value.length : 0,
133       query_prefix: embed.queryText(model, ''),
134       search_threshold: config.get('search_threshold'),
135       group_threshold: groupThreshold,
136       available: !!vectors,
137     },
138     counts: counts(db),
139     ideas: db.ideas.map((i) => {
140       const t = i.triage;
141       return {
142         id: i.id,
143         key: `IDEA-${i.id}`,
144         title: i.title,
145         text: i.text,
146         lane: lane(i),
147         status: i.status,
148         rank: rank.get(i.id),
149         tags: i.tags || [],
150         project: i.project ? baseName(i.project) : null,
151         host: i.host || null,
152         created: i.created,
153         updated: i.updated,
154         triaged: t?.at || null,
155         started: i.started || null,
156         closed: i.closed || null,
157         verdict: t?.verdict || null,
158         impact: t?.impact ?? null,
159         size: t?.size || null,
160         model: t?.model || null,
161         why: t?.why || '',
162         brief: t?.brief || '',
163         notes: i.notes || [],
164         dup_of: i.dup_of || null,
165         group: groupOf.has(i.id) ? groupOf.get(i.id) : null,
166         related: vectors ? embed.nearest(i.id, ids, vectors, { k: 3, threshold: groupThreshold }) : [],
167         phases: phases(i),
168         vec: vectors?.has(i.id) ? embed.encodeVec(vectors.get(i.id)) : null,
169       };
170     }),
171     groups: groups.map((g, n) => ({ id: n, label: g.label, ids: g.ids })),
172     flow: flow(db.ideas, { now: Date.parse(generated) }),
173     stats: stats(db.ideas, { now: Date.parse(generated) }),
174   };
175 }
176 
177 /** The snapshot, with groups when the embedding server answers. `note` says why there are none. */
178 export async function build(db, { timeoutMs = 60000 } = {}) {
179   let vectors = null;
180   let groups = [];
181   let note = '';
182   try {
183     vectors = await embed.vectorsFor(db.ideas, { timeoutMs });
184     groups = await embed.groupIdeas(db.ideas, { vectors });
185   } catch (e) {
186     if (!(e instanceof embed.EmbedError)) throw e;
187     vectors = null;
188     note = `published without search by meaning or groups: ${e.message}`;
189   }
190   return { data: snapshot(db, { vectors, groups }), note };
191 }
192 
193 async function put(url, body, type) {
194   let res;
195   try {
196     res = await fetch(url, { method: 'PUT', body, headers: { 'content-type': type }, signal: AbortSignal.timeout(30000) });
197   } catch (e) {
198     throw new Error(`cannot reach ${url} (${e.name === 'TimeoutError' ? 'no answer in 30 s' : e.cause?.code || e.cause?.message || e.message})`);
199   }
200   if (!res.ok) throw new Error(`${url} answered ${res.status} ${res.statusText}`);
201 }
202 
203 const pageHash = () => crypto.createHash('sha256').update(fs.readFileSync(PAGE)).digest('hex').slice(0, 16);
204 
205 function archiveTime() {
206   try {
207     return fs.statSync(dbPath()).mtimeMs;
208   } catch {
209     return 0;
210   }
211 }
212 
213 export function readState() {
214   try {
215     return JSON.parse(fs.readFileSync(statePath(), 'utf8'));
216   } catch {
217     return {};
218   }
219 }
220 
221 function writeState(patch) {
222   fs.mkdirSync(home(), { recursive: true });
223   fs.writeFileSync(statePath(), JSON.stringify({ ...readState(), ...patch }, null, 2) + '\n');
224 }
225 
226 /**
227  * Upload index.html and data.json to `url` (a folder URL on the dashboard server), or write them
228  * to the folder `dir`. Returns what it did, for the caller to show.
229  */
230 export async function publish({ url = config.get('publish_url'), dir = null } = {}) {
231   if (!url && !dir) throw new Error('no dashboard server is set. Run `ideamine publish <url>` once, or pass --dir <folder>');
232   const mtime = archiveTime();
233   const { data, note } = await build(load());
234   const { ASSETS, assetPath } = await import('./serve.js');
235   const files = [
236     ['data.json', JSON.stringify(data), 'application/json'],
237     ['index.html', fs.readFileSync(PAGE), 'text/html; charset=utf-8'],
238     // The manifest, the icons, and the service worker: the page is an app on a phone with them.
239     ...Object.entries(ASSETS).map(([name, type]) => [name, fs.readFileSync(assetPath(name)), type]),
240   ];
241   if (dir) {
242     fs.mkdirSync(dir, { recursive: true });
243     for (const [name, body] of files) fs.writeFileSync(path.join(dir, name), body);
244   } else {
245     const base = url.endsWith('/') ? url : `${url}/`;
246     for (const [name, body, type] of files) await put(new URL(name, base), body, type);
247     writeState({ at: new Date().toISOString(), mtime, page: pageHash(), url, error: null, errorAt: null });
248   }
249   return { where: dir || url, ideas: data.ideas.length, groups: data.groups.length, note };
250 }
251 
252 function isRunning(now) {
253   try {
254     return now - fs.statSync(lockPath()).mtimeMs < LOCK_STALE_MS;
255   } catch {
256     return false;
257   }
258 }
259 
260 /** One background publish, from kick(). Only one runs at a time, and a failure waits 10 minutes. */
261 export async function backgroundPublish() {
262   fs.mkdirSync(home(), { recursive: true });
263   try {
264     fs.mkdirSync(lockPath());
265   } catch (e) {
266     if (e.code !== 'EEXIST' || isRunning(Date.now())) return 'busy';
267     fs.utimesSync(lockPath(), new Date(), new Date()); // take over the lock of a crashed publish
268   }
269   try {
270     await publish();
271     return 'done';
272   } catch (e) {
273     writeState({ error: e.message, errorAt: new Date().toISOString() });
274     return 'error';
275   } finally {
276     fs.rmSync(lockPath(), { recursive: true, force: true });
277   }
278 }
279 
280 function startProcess() {
281   const bin = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url));
282   const child = spawn(process.execPath, [bin, 'publish', '--background'], { cwd: home(), detached: true, stdio: 'ignore', windowsHide: true });
283   // A publish that cannot start must not stop the caller, for example the server of the dashboard.
284   child.on('error', (e) => writeState({ error: `cannot start a publish: ${e.message}`, errorAt: new Date().toISOString() }));
285   child.unref();
286 }
287 
288 /**
289  * Start a background publish when a dashboard server is set, the archive or the page changed since
290  * the last publish, no publish runs, and no publish failed a short time ago.
291  */
292 export function kick({ start = startProcess, now = Date.now() } = {}) {
293   const url = config.get('publish_url');
294   // With sync on, the ideamine server shows the live archive itself: nothing to upload.
295   if (!url || config.get('sync_url')) return false;
296   const state = readState();
297   if (state.errorAt && now - Date.parse(state.errorAt) < WAIT_AFTER_ERROR_MS) return false;
298   if (isRunning(now)) return false;
299   const current = state.url === url && archiveTime() <= (state.mtime || 0) && state.page === pageHash();
300   if (current) return false;
301   start();
302   return true;
303 }
304 
305 /** Where the dashboard goes and how the last publish went. */
306 export function status() {
307   const url = config.get('publish_url');
308   if (!url) return 'ideamine publish: off. `ideamine publish <url>` sets the dashboard server.';
309   const s = readState();
310   const lines = [`ideamine publish: ${url}`];
311   lines.push(s.at ? `last publish ${s.at}` : 'not published yet');
312   if (s.error) lines.push(`the last background publish failed at ${s.errorAt}: ${s.error}`);
313   return lines.join('\n');
314 }