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 (9424 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, listIdeas, load } 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 /** data.json (version 1). The dashboard page reads it; its format is in the README. */
50 export function snapshot(db, { vectors = null, groups = [], generated = new Date().toISOString() } = {}) {
51   const model = config.get('embed_model');
52   const groupThreshold = config.get('group_threshold');
53   const rank = new Map(listIdeas(db, { filter: 'all' }).map((idea, n) => [idea.id, n]));
54   const groupOf = new Map();
55   groups.forEach((g, n) => g.ids.forEach((id) => groupOf.set(id, n)));
56   const ids = db.ideas.map((i) => i.id);
57   return {
58     version: 1,
59     generated,
60     embed: {
61       model,
62       dim: vectors?.size ? vectors.values().next().value.length : 0,
63       query_prefix: embed.queryText(model, ''),
64       search_threshold: config.get('search_threshold'),
65       group_threshold: groupThreshold,
66       available: !!vectors,
67     },
68     counts: counts(db),
69     ideas: db.ideas.map((i) => {
70       const t = i.triage;
71       return {
72         id: i.id,
73         key: `IDEA-${i.id}`,
74         title: i.title,
75         text: i.text,
76         lane: lane(i),
77         status: i.status,
78         rank: rank.get(i.id),
79         tags: i.tags || [],
80         project: i.project ? baseName(i.project) : null,
81         host: i.host || null,
82         created: i.created,
83         updated: i.updated,
84         triaged: t?.at || null,
85         started: i.started || null,
86         closed: i.closed || null,
87         verdict: t?.verdict || null,
88         impact: t?.impact ?? null,
89         size: t?.size || null,
90         model: t?.model || null,
91         why: t?.why || '',
92         brief: t?.brief || '',
93         notes: i.notes || [],
94         dup_of: i.dup_of || null,
95         group: groupOf.has(i.id) ? groupOf.get(i.id) : null,
96         related: vectors ? embed.nearest(i.id, ids, vectors, { k: 3, threshold: groupThreshold }) : [],
97         phases: phases(i),
98         vec: vectors?.has(i.id) ? embed.encodeVec(vectors.get(i.id)) : null,
99       };
100     }),
101     groups: groups.map((g, n) => ({ id: n, label: g.label, ids: g.ids })),
102   };
103 }
104 
105 /** The snapshot, with groups when the embedding server answers. `note` says why there are none. */
106 export async function build(db, { timeoutMs = 60000 } = {}) {
107   let vectors = null;
108   let groups = [];
109   let note = '';
110   try {
111     vectors = await embed.vectorsFor(db.ideas, { timeoutMs });
112     groups = await embed.groupIdeas(db.ideas, { vectors });
113   } catch (e) {
114     if (!(e instanceof embed.EmbedError)) throw e;
115     vectors = null;
116     note = `published without search by meaning or groups: ${e.message}`;
117   }
118   return { data: snapshot(db, { vectors, groups }), note };
119 }
120 
121 async function put(url, body, type) {
122   let res;
123   try {
124     res = await fetch(url, { method: 'PUT', body, headers: { 'content-type': type }, signal: AbortSignal.timeout(30000) });
125   } catch (e) {
126     throw new Error(`cannot reach ${url} (${e.name === 'TimeoutError' ? 'no answer in 30 s' : e.cause?.code || e.cause?.message || e.message})`);
127   }
128   if (!res.ok) throw new Error(`${url} answered ${res.status} ${res.statusText}`);
129 }
130 
131 const pageHash = () => crypto.createHash('sha256').update(fs.readFileSync(PAGE)).digest('hex').slice(0, 16);
132 
133 function archiveTime() {
134   try {
135     return fs.statSync(dbPath()).mtimeMs;
136   } catch {
137     return 0;
138   }
139 }
140 
141 export function readState() {
142   try {
143     return JSON.parse(fs.readFileSync(statePath(), 'utf8'));
144   } catch {
145     return {};
146   }
147 }
148 
149 function writeState(patch) {
150   fs.mkdirSync(home(), { recursive: true });
151   fs.writeFileSync(statePath(), JSON.stringify({ ...readState(), ...patch }, null, 2) + '\n');
152 }
153 
154 /**
155  * Upload index.html and data.json to `url` (a folder URL on the dashboard server), or write them
156  * to the folder `dir`. Returns what it did, for the caller to show.
157  */
158 export async function publish({ url = config.get('publish_url'), dir = null } = {}) {
159   if (!url && !dir) throw new Error('no dashboard server is set. Run `ideamine publish <url>` once, or pass --dir <folder>');
160   const mtime = archiveTime();
161   const { data, note } = await build(load());
162   const files = [
163     ['data.json', JSON.stringify(data), 'application/json'],
164     ['index.html', fs.readFileSync(PAGE), 'text/html; charset=utf-8'],
165   ];
166   if (dir) {
167     fs.mkdirSync(dir, { recursive: true });
168     for (const [name, body] of files) fs.writeFileSync(path.join(dir, name), body);
169   } else {
170     const base = url.endsWith('/') ? url : `${url}/`;
171     for (const [name, body, type] of files) await put(new URL(name, base), body, type);
172     writeState({ at: new Date().toISOString(), mtime, page: pageHash(), url, error: null, errorAt: null });
173   }
174   return { where: dir || url, ideas: data.ideas.length, groups: data.groups.length, note };
175 }
176 
177 function isRunning(now) {
178   try {
179     return now - fs.statSync(lockPath()).mtimeMs < LOCK_STALE_MS;
180   } catch {
181     return false;
182   }
183 }
184 
185 /** One background publish, from kick(). Only one runs at a time, and a failure waits 10 minutes. */
186 export async function backgroundPublish() {
187   fs.mkdirSync(home(), { recursive: true });
188   try {
189     fs.mkdirSync(lockPath());
190   } catch (e) {
191     if (e.code !== 'EEXIST' || isRunning(Date.now())) return 'busy';
192     fs.utimesSync(lockPath(), new Date(), new Date()); // take over the lock of a crashed publish
193   }
194   try {
195     await publish();
196     return 'done';
197   } catch (e) {
198     writeState({ error: e.message, errorAt: new Date().toISOString() });
199     return 'error';
200   } finally {
201     fs.rmSync(lockPath(), { recursive: true, force: true });
202   }
203 }
204 
205 function startProcess() {
206   const bin = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url));
207   const child = spawn(process.execPath, [bin, 'publish', '--background'], { cwd: home(), detached: true, stdio: 'ignore', windowsHide: true });
208   // A publish that cannot start must not stop the caller, for example the server of the dashboard.
209   child.on('error', (e) => writeState({ error: `cannot start a publish: ${e.message}`, errorAt: new Date().toISOString() }));
210   child.unref();
211 }
212 
213 /**
214  * Start a background publish when a dashboard server is set, the archive or the page changed since
215  * the last publish, no publish runs, and no publish failed a short time ago.
216  */
217 export function kick({ start = startProcess, now = Date.now() } = {}) {
218   const url = config.get('publish_url');
219   // With sync on, the ideamine server shows the live archive itself: nothing to upload.
220   if (!url || config.get('sync_url')) return false;
221   const state = readState();
222   if (state.errorAt && now - Date.parse(state.errorAt) < WAIT_AFTER_ERROR_MS) return false;
223   if (isRunning(now)) return false;
224   const current = state.url === url && archiveTime() <= (state.mtime || 0) && state.page === pageHash();
225   if (current) return false;
226   start();
227   return true;
228 }
229 
230 /** Where the dashboard goes and how the last publish went. */
231 export function status() {
232   const url = config.get('publish_url');
233   if (!url) return 'ideamine publish: off. `ideamine publish <url>` sets the dashboard server.';
234   const s = readState();
235   const lines = [`ideamine publish: ${url}`];
236   lines.push(s.at ? `last publish ${s.at}` : 'not published yet');
237   if (s.error) lines.push(`the last background publish failed at ${s.errorAt}: ${s.error}`);
238   return lines.join('\n');
239 }