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/coop.js (6943 bytes)

1 // The agents of a coop session build the ideas that wait in Doing. On the ideamine server, a loop
2 // takes one waiting idea at a time and sends it to `any` in the coop session: the coop hub gives
3 // it to a free agent. The idea shows that agent. The answer of the agent closes the idea (DONE)
4 // or puts it back in Do with the answer as a note.
5 //
6 // idea.agent: { name: 'any' } waits; { name: 'coop' } is being sent; { name, msg } is with the
7 // agent `name` (agent@machine), and msg is the id of the coop message with the task.
8 
9 import * as config from './config.js';
10 import * as store from './store.js';
11 import { clip } from './text.js';
12 
13 const ROUND_MS = 15000;
14 const SEND_MS = 60000; // a card that stays "being sent" longer than this waits again (the server stopped mid-send)
15 // ponytail: one limit for every card. A limit per size of the idea if real builds need more.
16 const ANSWER_MS = 3 * 3600000;
17 const PAGE = 200;
18 
19 const now = () => new Date().toISOString();
20 
21 /** The hub, the session, and the token, or null when coop is off. The token is only in the environment. */
22 export function settings() {
23   const url = config.get('coop_url');
24   const token = process.env.IDEAMINE_COOP_TOKEN;
25   return url && token ? { url: url.replace(/\/+$/, ''), session: config.get('coop_session'), token } : null;
26 }
27 
28 async function hub(s, method, route, body) {
29   const res = await fetch(`${s.url}/v1/admin/sessions/${encodeURIComponent(s.session)}/${route}`, {
30     method,
31     headers: { authorization: `Bearer ${s.token}`, 'content-type': 'application/json' },
32     body: body && JSON.stringify(body),
33     signal: AbortSignal.timeout(10000),
34   });
35   const json = await res.json().catch(() => ({}));
36   return { status: res.status, json };
37 }
38 
39 const waiting = (i, t) => i.status === 'doing' && (i.agent?.name === 'any' || (i.agent?.name === 'coop' && t - Date.parse(i.agent.at) > SEND_MS));
40 
41 /**
42  * Take the idea that waits longest for an agent. The archive lock makes the take atomic: two
43  * rounds, or two servers, never take the same idea. Null when no idea waits.
44  */
45 export function claim() {
46   if (!store.load().ideas.some((i) => waiting(i, Date.now()))) return null; // no write when nothing waits
47   return store.mutate((db) => {
48     const t = Date.now();
49     const idea = db.ideas.filter((i) => waiting(i, t)).sort((a, b) => a.agent.at.localeCompare(b.agent.at))[0];
50     if (!idea) return null;
51     idea.agent = { name: 'coop', at: now() };
52     return structuredClone(idea);
53   });
54 }
55 
56 /** Change the idea only if it still has the agent state `was` (the user can move a card at any time). */
57 function ifStill(id, was, fn) {
58   return store.mutate((db) => {
59     const idea = store.findIdea(db, id);
60     if (!idea || idea.status !== 'doing' || idea.agent?.name !== was.name || idea.agent?.msg !== was.msg) return false;
61     fn(idea);
62     idea.updated = now();
63     return true;
64   });
65 }
66 
67 /** The message that the agent gets. */
68 export function task(idea) {
69   const t = idea.triage || {};
70   const facts = [
71     t.brief && `Plan from the triage: ${t.brief}`,
72     idea.project && `Project folder on jose's PC: ${idea.project}`,
73     t.model && `Suggested model: ${t.model}`,
74   ].filter(Boolean);
75   return [
76     `Card IDEA-${idea.id} of jose's ideamine board: ${idea.title}`,
77     idea.text,
78     facts.join('\n'),
79     'Answer with DONE: and one line that says what you did and where (commit, branch, file), or with FAILED: and the reason.\n' +
80       'DONE moves the card to Done. Any other answer puts it back in Do with your answer as a note.',
81   ].filter(Boolean).join('\n\n');
82 }
83 
84 /** Send one waiting idea to a free agent. */
85 async function dispatch(s, log) {
86   const idea = claim();
87   if (!idea) return;
88   const sending = { name: 'coop' };
89   const back = (why) => {
90     ifStill(idea.id, sending, (i) => (i.agent = { name: 'any', at: now() }));
91     if (why) log(`coop: IDEA-${idea.id} waits again: ${why}`);
92   };
93   let res;
94   try {
95     res = await hub(s, 'POST', 'messages', { to: 'any', text: task(idea) });
96   } catch (e) {
97     return back(`cannot reach the hub (${e.message})`);
98   }
99   // 404: no agent is free now. A worker is offline for a moment between its rounds, so try again later.
100   if (res.status === 404) return back(null);
101   if (res.status !== 200 || !res.json.id || !res.json.to) return back(`the hub answered ${res.status} ${res.json.error || ''}`.trim());
102   const { id: msg, to } = res.json;
103   ifStill(idea.id, sending, (i) => {
104     i.agent = { name: to, msg: String(msg), at: now() };
105     (i.notes ||= []).push({ at: now(), text: `coop gave it to ${to}` });
106   });
107   log(`coop: IDEA-${idea.id} went to ${to} (message ${msg})`);
108 }
109 
110 /** Read the answers of the agents. DONE closes the idea; any other answer, or none in time, puts it back in Do. */
111 async function collect(s, log) {
112   const out = store.load().ideas.filter((i) => i.status === 'doing' && i.agent?.msg);
113   if (!out.length) return;
114   const replies = new Map();
115   for (let after = Math.min(...out.map((i) => Number(i.agent.msg))); ; ) {
116     const res = await hub(s, 'GET', `messages?after=${after}&limit=${PAGE}`);
117     if (res.status !== 200) throw new Error(`the hub answered ${res.status} to a read of the messages`);
118     const messages = res.json.messages || [];
119     for (const m of messages) if (m.reply_to) replies.set(String(m.reply_to), m);
120     if (messages.length < PAGE) break;
121     after = messages[messages.length - 1].id;
122   }
123   for (const idea of out) {
124     const reply = replies.get(idea.agent.msg);
125     const late = !reply && Date.now() - Date.parse(idea.agent.at) > ANSWER_MS;
126     if (!reply && !late) continue;
127     const done = !!reply && /^\s*DONE\b/i.test(reply.text);
128     const note = reply ? `${reply.from}: ${clip(reply.text, 500)}` : `no answer from ${idea.agent.name} in ${ANSWER_MS / 3600000} h`;
129     ifStill(idea.id, idea.agent, (i) => {
130       i.status = done ? 'done' : i.triage ? 'triaged' : 'inbox';
131       if (done) i.closed = now();
132       delete i.agent;
133       (i.notes ||= []).push({ at: now(), text: note });
134     });
135     log(`coop: IDEA-${idea.id} ${done ? 'done' : 'back in the queue'}: ${note}`);
136   }
137 }
138 
139 /** One round: read the answers, then send one waiting idea. */
140 export async function round(s, { log = () => {} } = {}) {
141   try {
142     await collect(s, log);
143   } catch (e) {
144     log(`coop: ${e.message}`);
145   }
146   await dispatch(s, log);
147 }
148 
149 /** Run a round every 15 s on the ideamine server. Returns a function that stops it, or null when coop is off. */
150 export function start({ log = () => {} } = {}) {
151   const s = settings();
152   if (!s) return null;
153   let running = false;
154   const timer = setInterval(async () => {
155     if (running) return;
156     running = true;
157     try {
158       await round(s, { log });
159     } catch (e) {
160       log(`coop: ${e.message}`);
161     } finally {
162       running = false;
163     }
164   }, ROUND_MS);
165   log(`coop: the agents of session ${s.session} at ${s.url} build the ideas dropped in Doing`);
166   return () => clearInterval(timer);
167 }