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/embed.js (13507 bytes)

1 // Search by meaning, and groups of ideas that are close in meaning. This follows memstate
2 // (github.com/map588/memstate): nomic-embed-text with its task prefixes, one vector for each idea
3 // that a content hash keeps current, cosine similarity with a threshold, and word search when the
4 // embedding server does not answer. The groups and the "related" links come from the vectors, so
5 // ideamine needs no graph database.
6 
7 import crypto from 'node:crypto';
8 import fs from 'node:fs';
9 import path from 'node:path';
10 import * as config from './config.js';
11 import { home, listIdeas, load, withLock, writeAtomic } from './store.js';
12 import { clip, wordSet } from './text.js';
13 
14 const MAX_CHARS = 1500; // the first cut; embed() halves a text again when the server says it is too long
15 const MIN_CHARS = 100;
16 const BATCH = 32;
17 
18 /** The embedding server cannot answer. Callers then use word search. */
19 export class EmbedError extends Error {}
20 
21 const vectorsPath = () => path.join(home(), 'vectors.json');
22 
23 /** The text that stands for an idea: its title (when the text does not start with it), text, and tags. */
24 export function ideaText(idea) {
25   const text = String(idea.text || '');
26   const title = String(idea.title || '').replace(/…$/, '');
27   const head = text.startsWith(title) ? text : `${idea.title}\n${text}`;
28   return idea.tags?.length ? `${head}\ntags: ${idea.tags.join(', ')}` : head;
29 }
30 
31 // nomic-embed models expect a task prefix on each side of a search. Other models get the raw text.
32 const isNomic = (model) => /^nomic-embed/i.test(model);
33 export const documentText = (model, text) => (isNomic(model) ? `search_document: ${text}` : text);
34 export const queryText = (model, text) => (isNomic(model) ? `search_query: ${text}` : text);
35 
36 /** Base64 of the vector as little-endian float32, the form that data.json and vectors.json use. */
37 export function encodeVec(vec) {
38   const buf = Buffer.alloc(vec.length * 4);
39   for (let i = 0; i < vec.length; i++) buf.writeFloatLE(vec[i], i * 4);
40   return buf.toString('base64');
41 }
42 
43 export function decodeVec(b64) {
44   const buf = Buffer.from(b64, 'base64');
45   const vec = new Float32Array(buf.length >> 2);
46   for (let i = 0; i < vec.length; i++) vec[i] = buf.readFloatLE(i * 4);
47   return vec;
48 }
49 
50 /** The vector scaled to length 1. */
51 export function unit(values) {
52   const vec = Float32Array.from(values);
53   let sum = 0;
54   for (const x of vec) sum += x * x;
55   const norm = Math.sqrt(sum);
56   if (!norm || !Number.isFinite(norm)) throw new EmbedError('the embedding server returned an empty vector');
57   for (let i = 0; i < vec.length; i++) vec[i] /= norm;
58   return vec;
59 }
60 
61 /** Cosine similarity of two unit vectors. */
62 export function cosine(a, b) {
63   let sum = 0;
64   for (let i = 0; i < a.length; i++) sum += a[i] * b[i];
65   return sum;
66 }
67 
68 /** Cut at a code point, never inside a surrogate pair. */
69 const cut = (text, max) => (text.length <= max ? text : [...text].slice(0, max).join(''));
70 
71 async function post(texts, { url, model, timeoutMs }) {
72   const endpoint = `${url.replace(/\/+$/, '')}/embeddings`;
73   let res;
74   try {
75     res = await fetch(endpoint, {
76       method: 'POST',
77       headers: { 'content-type': 'application/json' },
78       body: JSON.stringify({ model, input: texts }),
79       signal: AbortSignal.timeout(timeoutMs),
80     });
81   } catch (e) {
82     const why = e.name === 'TimeoutError' ? `no answer in ${timeoutMs / 1000} s` : e.cause?.code || e.cause?.message || e.message;
83     throw new EmbedError(`cannot reach ${endpoint} (${why})`);
84   }
85   const body = await res.text();
86   if (!res.ok) {
87     const err = new EmbedError(`${endpoint} answered ${res.status}: ${clip(body, 160)}`);
88     err.tooLong = /too large|too long|context/i.test(body);
89     throw err;
90   }
91   let data;
92   try {
93     data = JSON.parse(body).data;
94   } catch {
95     data = null;
96   }
97   if (!Array.isArray(data) || data.length !== texts.length) {
98     throw new EmbedError(`${endpoint} did not return ${texts.length} embeddings: ${clip(body, 160)}`);
99   }
100   return [...data].sort((a, b) => a.index - b.index).map((d) => unit(d.embedding));
101 }
102 
103 /**
104  * Unit vectors for the texts, with the document or the query prefix. When the server says that a
105  * text is too long, it embeds the texts one at a time and halves each text that is still too long.
106  */
107 export async function embed(texts, { query = false, timeoutMs = 30000 } = {}) {
108   const url = config.get('embed_url');
109   const model = config.get('embed_model');
110   const wrap = (t) => (query ? queryText(model, t) : documentText(model, t));
111   const first = texts.map((t) => cut(String(t), MAX_CHARS));
112   try {
113     return await post(first.map(wrap), { url, model, timeoutMs });
114   } catch (e) {
115     if (!e.tooLong) throw e;
116   }
117   const out = [];
118   for (let text of first) {
119     for (;;) {
120       try {
121         out.push((await post([wrap(text)], { url, model, timeoutMs }))[0]);
122         break;
123       } catch (e) {
124         if (!e.tooLong || text.length <= MIN_CHARS) throw e;
125         text = cut(text, Math.floor([...text].length / 2));
126       }
127     }
128   }
129   return out;
130 }
131 
132 const hashOf = (model, text) => crypto.createHash('sha256').update(`${model}\n${text}`).digest('hex').slice(0, 16);
133 
134 function readVectors(model) {
135   try {
136     const file = JSON.parse(fs.readFileSync(vectorsPath(), 'utf8'));
137     return file.model === model && file.items ? file.items : {};
138   } catch {
139     return {};
140   }
141 }
142 
143 /** Add vectors to the cache. Vectors of deleted ideas go, so a deleted idea leaves nothing behind. */
144 function saveVectors(model, entries) {
145   withLock(() => {
146     const live = new Set(load().ideas.map((i) => String(i.id)));
147     const items = Object.fromEntries(Object.entries(readVectors(model)).filter(([id]) => live.has(id)));
148     for (const [id, item] of entries) if (live.has(String(id))) items[id] = item;
149     writeAtomic(vectorsPath(), JSON.stringify({ model, items }) + '\n');
150   });
151 }
152 
153 /** A vector for each idea. Only new and changed ideas go to the server; the others come from the cache. */
154 export async function vectorsFor(ideas, { timeoutMs } = {}) {
155   const model = config.get('embed_model');
156   const cached = readVectors(model);
157   const vectors = new Map();
158   const todo = [];
159   for (const idea of ideas) {
160     const text = ideaText(idea);
161     const hash = hashOf(model, text);
162     if (cached[idea.id]?.hash === hash) vectors.set(idea.id, decodeVec(cached[idea.id].vec));
163     else todo.push({ id: idea.id, text, hash });
164   }
165   for (let i = 0; i < todo.length; i += BATCH) {
166     const part = todo.slice(i, i + BATCH);
167     const vecs = await embed(part.map((t) => t.text), { timeoutMs });
168     part.forEach((t, k) => vectors.set(t.id, vecs[k]));
169     saveVectors(model, part.map((t, k) => [t.id, { hash: t.hash, vec: encodeVec(vecs[k]) }]));
170   }
171   return vectors;
172 }
173 
174 /**
175  * Groups of ideas that are close in meaning, by average-linkage clustering: two groups join while
176  * the mean similarity of the pairs across them is at least `threshold`. The ids are sorted first,
177  * so the order of the input does not change the result. Only groups of 2 or more ideas are returned.
178  */
179 export function groupIds(ids, vectors, threshold) {
180   const sorted = [...new Set(ids)].sort((a, b) => a - b);
181   const n = sorted.length;
182   // sum[i][j]: sum of the similarities of all pairs across clusters i and j.
183   const sum = sorted.map((a) => sorted.map((b) => cosine(vectors.get(a), vectors.get(b))));
184   const clusters = sorted.map((id) => [id]);
185   const alive = new Array(n).fill(true);
186   for (;;) {
187     let best = -Infinity;
188     let pair = null;
189     for (let i = 0; i < n; i++) {
190       if (!alive[i]) continue;
191       for (let j = i + 1; j < n; j++) {
192         if (!alive[j]) continue;
193         const avg = sum[i][j] / (clusters[i].length * clusters[j].length);
194         if (avg > best) {
195           best = avg;
196           pair = [i, j];
197         }
198       }
199     }
200     if (!pair || best < threshold) break;
201     const [i, j] = pair;
202     clusters[i] = [...clusters[i], ...clusters[j]].sort((a, b) => a - b);
203     alive[j] = false;
204     for (let k = 0; k < n; k++) {
205       sum[i][k] += sum[j][k];
206       sum[k][i] = sum[i][k];
207     }
208   }
209   return clusters.filter((c, i) => alive[i] && c.length > 1);
210 }
211 
212 // Words that many ideas use and that say little about a group ("Add full support for ...").
213 const LABEL_SKIP = new Set(
214   'add added also app apps better build built fix full idea ideas made need new nicer now should support want work'.split(' '),
215 );
216 
217 /** A few words that the ideas of a group share and other ideas do not, most distinctive first. */
218 export function groupLabel(members, all) {
219   const words = (idea) => [...wordSet(`${idea.title} ${idea.text} ${(idea.tags || []).join(' ')}`)].filter((w) => !LABEL_SKIP.has(w));
220   const count = (ideas) => {
221     const df = new Map();
222     for (const idea of ideas) for (const w of words(idea)) df.set(w, (df.get(w) || 0) + 1);
223     return df;
224   };
225   const inside = count(members);
226   const overall = count(all);
227   const best = [...inside]
228     .filter(([, n]) => n >= 2)
229     .map(([w, n]) => [w, (n / members.length) * Math.log((all.length + 1) / (overall.get(w) || 1))])
230     .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
231     .slice(0, 3)
232     .map(([w]) => w);
233   return best.length ? best.join(' · ') : clip(members[0].title, 40);
234 }
235 
236 /** Groups for these ideas: [{ ids, label }], the biggest first. Throws EmbedError when the server does not answer. */
237 export async function groupIdeas(ideas, { threshold = config.get('group_threshold'), vectors = null, timeoutMs } = {}) {
238   vectors ||= await vectorsFor(ideas, { timeoutMs });
239   const byId = new Map(ideas.map((i) => [i.id, i]));
240   return groupIds(ideas.map((i) => i.id), vectors, threshold)
241     .map((ids) => ({ ids, label: groupLabel(ids.map((id) => byId.get(id)), ideas) }))
242     .sort((a, b) => b.ids.length - a.ids.length || a.ids[0] - b.ids[0]);
243 }
244 
245 /** The `k` ideas closest to idea `id`, with a similarity of at least `threshold`. */
246 export function nearest(id, ids, vectors, { k = 3, threshold = 0 } = {}) {
247   const own = vectors.get(id);
248   if (!own) return [];
249   return ids
250     .filter((other) => other !== id && vectors.has(other))
251     .map((other) => ({ id: other, score: Math.round(cosine(own, vectors.get(other)) * 1000) / 1000 }))
252     .filter((r) => r.score >= threshold)
253     .sort((a, b) => b.score - a.score || a.id - b.id)
254     .slice(0, k);
255 }
256 
257 /** Word search for when meaning search is not available: more query words found ranks higher. */
258 function byWords(ideas, query) {
259   const words = [...wordSet(query)];
260   if (!words.length) words.push(...String(query).toLowerCase().split(/\s+/).filter(Boolean));
261   return ideas
262     .map((idea) => {
263       const hay = `${ideaText(idea)}\n${idea.triage?.brief || ''}`.toLowerCase();
264       return { idea, score: words.filter((w) => hay.includes(w)).length / words.length };
265     })
266     .filter((r) => r.score > 0)
267     .sort((a, b) => b.score - a.score || b.idea.id - a.idea.id);
268 }
269 
270 /**
271  * Ideas like the query, best first: { mode: 'meaning' | 'words', results: [{ idea, score }], note }.
272  * When the embedding server does not answer, it falls back to word search and says why in `note`.
273  */
274 export async function find(db, query, { filter = 'all', project = null, limit = 10, threshold, timeoutMs = 8000 } = {}) {
275   const ideas = listIdeas(db, { filter, project });
276   threshold ??= config.get('search_threshold');
277   try {
278     const vectors = await vectorsFor(ideas, { timeoutMs });
279     const [q] = await embed([query], { query: true, timeoutMs });
280     const results = ideas
281       .map((idea) => ({ idea, score: cosine(q, vectors.get(idea.id)) }))
282       .filter((r) => r.score >= threshold)
283       .sort((a, b) => b.score - a.score || a.idea.id - b.idea.id);
284     return { mode: 'meaning', results: limit ? results.slice(0, limit) : results, note: '' };
285   } catch (e) {
286     if (!(e instanceof EmbedError)) throw e;
287     const results = byWords(ideas, query);
288     return { mode: 'words', results: limit ? results.slice(0, limit) : results, note: e.message };
289   }
290 }
291 
292 /** Percentile `p` (0 to 1) of sorted numbers. */
293 const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
294 
295 /**
296  * Embed every idea that has no current vector, then describe the set, like memstate's `embed
297  * status`: coverage, the nearest-neighbour similarity of each idea, and how many ideas the
298  * thresholds keep. Use it to set group_threshold and search_threshold for a new model.
299  */
300 export async function status(db, { timeoutMs } = {}) {
301   const url = config.get('embed_url');
302   const model = config.get('embed_model');
303   const groupThreshold = config.get('group_threshold');
304   const lines = [`model ${model} at ${url}`];
305   const vectors = await vectorsFor(db.ideas, { timeoutMs });
306   const ids = db.ideas.map((i) => i.id);
307   const dim = vectors.size ? vectors.values().next().value.length : 0;
308   lines.push(`vectors ${vectors.size}/${ids.length}${dim ? `, ${dim} dimensions` : ''} · ${vectorsPath()}`);
309   if (ids.length > 1) {
310     const nn = ids.map((id) => nearest(id, ids, vectors, { k: 1 })[0]?.score ?? 0).sort((a, b) => a - b);
311     const p = (x) => percentile(nn, x).toFixed(2);
312     lines.push(`nearest neighbour similarity: p10 ${p(0.1)} · p50 ${p(0.5)} · p90 ${p(0.9)} · max ${nn[nn.length - 1].toFixed(2)}`);
313     const groups = groupIds(ids, vectors, groupThreshold);
314     const grouped = groups.reduce((n, g) => n + g.length, 0);
315     lines.push(`group_threshold ${groupThreshold}: ${groups.length} groups hold ${grouped} of ${ids.length} ideas`);
316   }
317   return lines.join('\n');
318 }