@jossuealcala/madre 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +497 -3
  2. package/CONTRIBUTING.md +3 -1
  3. package/README.md +68 -186
  4. package/SECURITY.md +2 -1
  5. package/bin/madre.mjs +56 -13
  6. package/docs/INTERNALS.md +16 -0
  7. package/docs/REFERENCE.md +249 -0
  8. package/docs/SDK.md +121 -0
  9. package/docs/room.png +0 -0
  10. package/docs/sdk/hello-module.mjs +51 -0
  11. package/package.json +9 -1
  12. package/public/app.js +3979 -867
  13. package/public/es.js +2258 -0
  14. package/public/i18n.js +66 -0
  15. package/public/index.html +96 -15
  16. package/public/inquiry.js +220 -0
  17. package/public/resay.js +77 -0
  18. package/public/styles.css +622 -65
  19. package/public/troubleshooting.js +255 -46
  20. package/src/adapters/claude.mjs +2 -1
  21. package/src/adapters/codex.mjs +2 -1
  22. package/src/adapters/gemini.mjs +6 -5
  23. package/src/adapters/opencode.mjs +2 -1
  24. package/src/adapters/process.mjs +79 -20
  25. package/src/asking.mjs +128 -0
  26. package/src/auth-probe.mjs +58 -1
  27. package/src/chats.mjs +193 -0
  28. package/src/checkpoint.mjs +1 -1
  29. package/src/cold.mjs +56 -0
  30. package/src/commands.mjs +6 -0
  31. package/src/conversation-context.mjs +35 -3
  32. package/src/credentials.mjs +145 -0
  33. package/src/dataset.mjs +56 -4
  34. package/src/distiller.mjs +12 -5
  35. package/src/event-store.mjs +14 -8
  36. package/src/exam.mjs +240 -0
  37. package/src/extensions.mjs +3 -2
  38. package/src/eyecat-watch.mjs +100 -0
  39. package/src/eyecat.mjs +169 -0
  40. package/src/i18n.mjs +47 -0
  41. package/src/image-studio.mjs +2 -0
  42. package/src/launch.mjs +61 -0
  43. package/src/maturity.mjs +94 -0
  44. package/src/mcp/image-server.mjs +36 -3
  45. package/src/mcp/memory-server.mjs +1 -1
  46. package/src/memory.mjs +325 -17
  47. package/src/modules/ahp.mjs +9 -7
  48. package/src/modules/ash.mjs +36 -0
  49. package/src/modules/git-pulse.mjs +5 -3
  50. package/src/modules/helpers.mjs +31 -0
  51. package/src/modules/image-studio.mjs +10 -4
  52. package/src/modules/index.mjs +141 -9
  53. package/src/modules/ollama.mjs +66 -10
  54. package/src/modules/playwright.mjs +44 -23
  55. package/src/modules/ripley.mjs +5 -3
  56. package/src/modules/sdk.mjs +93 -2
  57. package/src/modules/updates.mjs +81 -0
  58. package/src/ollama.mjs +5 -2
  59. package/src/outbound.mjs +297 -0
  60. package/src/privacy.mjs +54 -7
  61. package/src/room/context.mjs +4 -4
  62. package/src/room/economy.mjs +161 -0
  63. package/src/room/prompt.mjs +118 -46
  64. package/src/room.mjs +443 -44
  65. package/src/runtime-detection.mjs +27 -8
  66. package/src/sentinel-errors.mjs +19 -1
  67. package/src/server.mjs +709 -71
  68. package/src/setup.mjs +1 -1
  69. package/src/updates.mjs +4 -2
  70. package/src/usage-sentinel.mjs +13 -8
  71. package/src/verdict.mjs +74 -0
  72. package/src/ashcode.mjs +0 -64
  73. package/src/modules/ashcode.mjs +0 -28
@@ -101,8 +101,9 @@ export function parseClaudeOutput(output) {
101
101
  }
102
102
  }
103
103
 
104
- export function invokeClaude({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] }) {
104
+ export function invokeClaude({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [], onProgress = null }) {
105
105
  return runReadonlyProcess({
106
+ onProgress: onProgress ? (out) => onProgress({ chars: (() => { try { return String(JSON.parse(out)?.result ?? '').length; } catch { return 0; } })() }) : null,
106
107
  executable,
107
108
  args: buildClaudeArgs({ prompt, model, attachmentsDir: attachments[0]?.dir ?? null, lease, scopes, imageStudio, memoryServer, mcpServers }),
108
109
  cwd: projectRoot,
@@ -73,8 +73,9 @@ export function parseCodexOutput(output) {
73
73
  return { text: text.trim(), usage };
74
74
  }
75
75
 
76
- export function invokeCodex({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, memoryServer = null, mcpServers = [] }) {
76
+ export function invokeCodex({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, memoryServer = null, mcpServers = [], onProgress = null }) {
77
77
  return runReadonlyProcess({
78
+ onProgress: onProgress ? (out) => onProgress({ chars: parseCodexOutput(out).text.length }) : null,
78
79
  executable,
79
80
  args: buildCodexArgs({ projectRoot, prompt, model, attachments, lease, scopes, memoryServer, mcpServers }),
80
81
  cwd: lease ? lease.outDir : projectRoot,
@@ -1,4 +1,5 @@
1
1
  import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { t } from '../i18n.mjs';
2
3
  import { homedir, tmpdir } from 'node:os';
3
4
  import { join } from 'node:path';
4
5
  import { runReadonlyProcess } from './process.mjs';
@@ -215,7 +216,7 @@ export function parseGeminiOutput(output) {
215
216
  export async function cleanupRuntimeRoot(runtimeRoot, { attempts = 6, delayMs = 250 } = {}) {
216
217
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
217
218
  try {
218
- await rm(runtimeRoot, { recursive: true, force: true });
219
+ await rm(runtimeRoot, { recursive: true, force: true, maxRetries: 6, retryDelay: 60 });
219
220
  return true;
220
221
  } catch (error) {
221
222
  if (attempt === attempts) {
@@ -234,21 +235,21 @@ export async function cleanupRuntimeRoot(runtimeRoot, { attempts = 6, delayMs =
234
235
  export function diagnoseGeminiStderr(stderr) {
235
236
  const text = String(stderr ?? '');
236
237
  if (/prepayment credits are depleted/i.test(text)) {
237
- return { code: 'CREDITS_DEPLETED', message: 'Google says the AI Studio project behind this Gemini key has no prepaid credits left; every request is refused (HTTP 429) until it is topped up.', hint: 'Add credits at https://ai.studio/projects, or switch the Gemini CLI to another key.' };
238
+ return { code: 'CREDITS_DEPLETED', message: t('Google says the AI Studio project behind this Gemini key has no prepaid credits left; every request is refused (HTTP 429) until it is topped up.'), hint: t('Add credits at https://ai.studio/projects, or switch the Gemini CLI to another key.') };
238
239
  }
239
240
  if (/status:\s*429|\b429\b|RESOURCE_EXHAUSTED|rate ?limit|quota exceeded/i.test(text)) {
240
241
  const router = /ClassifierStrategy|\.route\b/.test(text);
241
242
  return {
242
243
  code: 'RATE_LIMITED',
243
244
  message: `Google is rate-limiting this Gemini key (HTTP 429)${router ? ' while its "auto" router picked a model' : ''}; the CLI kept retrying with backoff.`,
244
- hint: 'Wait a minute, or pick an explicit model such as gemini-3-flash-preview to skip the router; check the key\'s quota at aistudio.google.com.',
245
+ hint: t('Wait a minute, or pick an explicit model such as gemini-3-flash-preview to skip the router; check the key\'s quota at aistudio.google.com.'),
245
246
  };
246
247
  }
247
248
  if (/status:?\s*503|UNAVAILABLE|high demand/i.test(text)) {
248
- return { code: 'UNAVAILABLE', message: 'Google reported the model as unavailable (HTTP 503) and the CLI kept retrying.', hint: 'Try again shortly or choose another model.' };
249
+ return { code: 'UNAVAILABLE', message: t('Google reported the model as unavailable (HTTP 503) and the CLI kept retrying.'), hint: t('Try again shortly or choose another model.') };
249
250
  }
250
251
  if (/status:\s*40[13]|PERMISSION_DENIED|API key not valid|IneligibleTierError/i.test(text)) {
251
- return { code: 'AUTH', message: 'Google rejected the Gemini credentials.', hint: 'Run `gemini` and use /auth, or check GEMINI_API_KEY.' };
252
+ return { code: 'AUTH', message: t('Google rejected the Gemini credentials.'), hint: t('Run `gemini` and use /auth, or check GEMINI_API_KEY.') };
252
253
  }
253
254
  return null;
254
255
  }
@@ -154,8 +154,9 @@ export function parseOpenCodeOutput(output) {
154
154
  return { text: text.join('').trim(), usage, ...(error ? { error } : {}) };
155
155
  }
156
156
 
157
- export function invokeOpenCode({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] }) {
157
+ export function invokeOpenCode({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [], onProgress = null }) {
158
158
  return runReadonlyProcess({
159
+ onProgress: onProgress ? (out) => onProgress({ chars: out.length }) : null,
159
160
  executable,
160
161
  args: buildOpenCodeArgs({ projectRoot, prompt, attachments, ...(model ? { model } : {}) }),
161
162
  cwd: projectRoot,
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { t } from '../i18n.mjs';
2
3
 
3
4
  function signalProcessGroup(child, signal) {
4
5
  if (!child.pid) return false;
@@ -24,6 +25,36 @@ export function terminateProcessTree(child, { graceMs = 2000 } = {}) {
24
25
  child.once('close', () => clearTimeout(escalation));
25
26
  }
26
27
 
28
+ // A number is not a reason. A process that was signalled reports it two ways — node hands the
29
+ // signal itself when it killed the child, and a CLI that handles SIGTERM exits 128+n on its own —
30
+ // and both used to surface as a bare `exited with code 143`, which reads like a crash and is not
31
+ // one: something STOPPED it. Naming the signal is the difference between looking for a bug in the
32
+ // agent and looking for whoever pulled the plug.
33
+ const SIGNAL_MEANING = {
34
+ SIGTERM: 'was stopped (SIGTERM) — something asked it to quit: STOP ALL, a timeout, or the room shutting down',
35
+ SIGKILL: 'was killed outright (SIGKILL) — usually this machine running out of memory',
36
+ SIGINT: 'was interrupted (SIGINT) — a Ctrl+C reached it',
37
+ SIGHUP: 'lost its terminal (SIGHUP)',
38
+ };
39
+ function endedBy(code, closedBy) {
40
+ const name = closedBy ?? (Number.isInteger(code) && code > 128 ? Object.keys(SIGNAL_MEANING).find((key) => code === 128 + { SIGHUP: 1, SIGINT: 2, SIGKILL: 9, SIGTERM: 15 }[key]) : null);
41
+ if (name && SIGNAL_MEANING[name]) return `${SIGNAL_MEANING[name]}.`;
42
+ if (name) return `ended on ${name}.`;
43
+ return `exited with code ${code}.`;
44
+ }
45
+
46
+ // One explanation for a process that never ran, whichever way spawn refused. ENOENT is the
47
+ // ambiguous one: node says it for a missing binary and for a missing working directory alike,
48
+ // so the sentence names both instead of guessing which.
49
+ function couldNotStart(error, { label, executable, cwd }) {
50
+ if (error?.code === 'ENOENT') {
51
+ error.message = `${label} could not start: ${error.message}. Check that the project folder ${cwd} exists and that ${executable} is still installed — a CLI that updates itself can move out from under a room that is already open, and reopening it finds the new one.`;
52
+ } else if (error && !String(error.message ?? '').startsWith(label)) {
53
+ error.message = `${label} could not start: ${error.message}`;
54
+ }
55
+ return error;
56
+ }
57
+
27
58
  export function runReadonlyProcess({
28
59
  executable,
29
60
  args,
@@ -41,18 +72,33 @@ export function runReadonlyProcess({
41
72
  label,
42
73
  parse,
43
74
  signal,
75
+ // Called as output arrives, with everything received so far. The adapter decides what that
76
+ // means: a CLI that streams its answer can be read as it writes, one that hands over a single
77
+ // blob at the end will simply say nothing until then, and saying nothing is the honest answer.
78
+ onProgress = null,
44
79
  }) {
45
80
  return new Promise((resolve, reject) => {
46
81
  if (signal?.aborted) {
47
- reject(new Error(`${label} was interrupted before it started: ${typeof signal.reason === 'string' ? signal.reason : 'MADRE is shutting down'}.`));
82
+ reject(new Error(t('{label} was interrupted before it started: {why}.', { label, why: typeof signal.reason === 'string' ? signal.reason : t('MADRE is shutting down') })));
83
+ return;
84
+ }
85
+ // spawn fails two ways, and only one of them used to be explained. The asynchronous one
86
+ // raises 'error' on the child; the synchronous one throws right here, before there is a
87
+ // child to listen to — a binary replaced underneath a running room by an app that updates
88
+ // itself, an option the platform refuses. That path handed the human a bare `spawn … ENOENT`
89
+ // with nothing to act on, which is how a self-updating CLI looked like a broken product.
90
+ let child;
91
+ try {
92
+ child = spawn(executable, args, {
93
+ cwd,
94
+ env,
95
+ stdio: ['ignore', 'pipe', 'pipe'],
96
+ detached: process.platform !== 'win32',
97
+ });
98
+ } catch (error) {
99
+ reject(couldNotStart(error, { label, executable, cwd }));
48
100
  return;
49
101
  }
50
- const child = spawn(executable, args, {
51
- cwd,
52
- env,
53
- stdio: ['ignore', 'pipe', 'pipe'],
54
- detached: process.platform !== 'win32',
55
- });
56
102
  let stdout = '';
57
103
  let stderr = '';
58
104
  let settled = false;
@@ -77,8 +123,8 @@ export function runReadonlyProcess({
77
123
 
78
124
  const onAbort = () => {
79
125
  terminateProcessTree(child, { graceMs: killGraceMs });
80
- const reason = typeof signal?.reason === 'string' ? signal.reason : 'MADRE is shutting down';
81
- finish(() => reject(new Error(`${label} was interrupted: ${reason}.`)));
126
+ const reason = typeof signal?.reason === 'string' ? signal.reason : t('MADRE is shutting down');
127
+ finish(() => reject(new Error(t('{label} was interrupted: {why}.', { label, why: reason }))));
82
128
  };
83
129
  const finish = (operation) => {
84
130
  if (settled) return;
@@ -92,7 +138,7 @@ export function runReadonlyProcess({
92
138
  terminateProcessTree(child, { graceMs: killGraceMs });
93
139
  // The last thing the agent said is usually the reason it was slow.
94
140
  const lastLine = `${stderr}\n${stdout}`.split('\n').map((line) => line.trim()).filter(Boolean).at(-1);
95
- const error = new Error(`${label} did not respond before the timeout (${Math.round(timeoutMs / 1000)}s).${lastLine ? ` Last output: ${lastLine.slice(0, 200)}` : ''}`);
141
+ const error = new Error(t('{label} did not respond before the timeout ({seconds}s).', { label, seconds: Math.round(timeoutMs / 1000) }) + (lastLine ? t(' Last output: {output}', { output: lastLine.slice(0, 200) }) : ''));
96
142
  error.code = 'TIMEOUT';
97
143
  error.partialOutput = stdout;
98
144
  error.partialStderr = stderr.slice(-2000);
@@ -103,7 +149,14 @@ export function runReadonlyProcess({
103
149
  // Only complete stdout lines count as activity: a CLI's stderr spinner or
104
150
  // progress noise must not keep a silent model alive past the idle limit.
105
151
  // Blank keep-alive lines are not activity either.
106
- child.stdout.on('data', (chunk) => { stdout += chunk; if (/\S/.test(String(chunk)) && String(chunk).includes('\n')) lastActivity = Date.now(); });
152
+ let toldAt = 0;
153
+ child.stdout.on('data', (chunk) => {
154
+ stdout += chunk;
155
+ if (/\S/.test(String(chunk)) && String(chunk).includes('\n')) lastActivity = Date.now();
156
+ // Throttled: a chatty CLI can produce hundreds of chunks a second and nobody needs to see
157
+ // a number move that fast.
158
+ if (onProgress && Date.now() - toldAt > 400) { toldAt = Date.now(); try { onProgress(stdout); } catch { /* a meter must never break a turn */ } }
159
+ });
107
160
  child.stderr.on('data', (chunk) => {
108
161
  stderr += chunk;
109
162
  if (!watchStderr || settled) return;
@@ -116,17 +169,23 @@ export function runReadonlyProcess({
116
169
  finish(() => reject(verdict));
117
170
  });
118
171
  armIdle();
119
- child.on('error', (error) => finish(() => {
120
- // spawn reports ENOENT for a missing cwd as well as a missing binary.
121
- if (error.code === 'ENOENT') {
122
- error.message = `${label} could not start: ${error.message}. Check that the project folder ${cwd} exists and that ${executable} is still installed.`;
123
- }
124
- reject(error);
125
- }));
126
- child.on('close', (code) => finish(() => {
172
+ child.on('error', (error) => finish(() => reject(couldNotStart(error, { label, executable, cwd }))));
173
+ child.on('close', (code, closedBy) => finish(() => {
127
174
  const response = parse(stdout);
128
175
  if (code === 0 && response.text) return resolve(response);
129
- reject(new Error(stderr.trim() || response.error || `${label} exited with code ${code}.`));
176
+ // What happened first, what the CLI printed second, and never one dressed as the other.
177
+ // This used to be `stderr.trim() || …`, so anything a CLI wrote on the way out became the
178
+ // reason: a notice about an unrelated setting, a line saying it was reading stdin. The
179
+ // facts that actually diagnose the turn — the exit code, or a clean exit with nothing to
180
+ // show — were discarded by the `||` and never reached the human. A warning is not a cause.
181
+ const printed = stderr.trim();
182
+ const why = response.error ?? (code === 0
183
+ ? `${label} exited cleanly without an answer.`
184
+ : `${label} ${endedBy(code, closedBy)}`);
185
+ const error = new Error(printed ? `${why}\n\n${label} printed:\n${printed.slice(-600)}` : why);
186
+ error.partialStderr = printed.slice(-2000);
187
+ error.exitCode = code;
188
+ reject(error);
130
189
  }));
131
190
  });
132
191
  }
package/src/asking.mjs ADDED
@@ -0,0 +1,128 @@
1
+ // What the room should ask next.
2
+ //
3
+ // The maturity reading says where the archive is thin; it does not say what to do about it on a
4
+ // Tuesday afternoon. This does. It reads the archive the room already has and writes the handful
5
+ // of questions whose answers are missing — and it writes them from the archive itself, word for
6
+ // word, never inventing a subject the room has not raised.
7
+ //
8
+ // Three wells, because there are three different kinds of hole:
9
+ //
10
+ // · questions the archivist already recorded as open, which nobody ever went back to. This is
11
+ // the archive saying out loud what it does not know.
12
+ // · cold memories: nothing has ever reached for them. Asking is the only way to find out
13
+ // whether they are noise or simply never came up, and it is the honest alternative to
14
+ // forgetting something that was never given a chance.
15
+ // · a kind of note the archive is short of. A room with forty facts and two decisions is
16
+ // recording what is true and not what was chosen, and no amount of use fixes that on its own.
17
+ //
18
+ // Nothing here spends a turn or writes anything. It hands the human a question; sending it is
19
+ // theirs, as is ignoring it.
20
+
21
+ export const ASK_LIMIT = 6;
22
+ export const THIN_SHARE = 0.12; // a kind under this much of the archive is thin
23
+ export const THIN_FLOOR = 20; // and only once there is enough archive for shares to mean anything
24
+
25
+ // Open questions are deliberately not here. An archive short of them is not a problem that
26
+ // asking for more questions solves; what is wanted is answers, and those come from the first
27
+ // well. Only the three kinds a thin archive is genuinely poorer for.
28
+ const KIND_ASK = {
29
+ decision: 'What have we decided lately that is not written down anywhere?',
30
+ preference: 'What do I keep asking for that nobody has written down as a preference?',
31
+ fact: 'What is true about this project that a new agent would have to be told?',
32
+ };
33
+
34
+ const KIND_WHY = {
35
+ decision: 'decisions',
36
+ preference: 'preferences',
37
+ fact: 'facts',
38
+ };
39
+
40
+ const quote = (text) => `"${String(text ?? '').replace(/\s+/g, ' ').trim()}"`;
41
+
42
+ // Two ways of asking the same thing are one question. The archivist writes on several passes and
43
+ // sometimes records a question twice in slightly different words; offering both of them back is
44
+ // how a list of six turns into a list of four.
45
+ const words = (text) => new Set(String(text ?? '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').match(/[a-z0-9]{3,}/g) ?? []);
46
+ export function sameQuestion(a, b, floor = 0.72) {
47
+ const one = words(a);
48
+ const two = words(b);
49
+ if (!one.size || !two.size) return false;
50
+ let shared = 0;
51
+ for (const word of one) if (two.has(word)) shared += 1;
52
+ // Against the union, not the shorter of the two: "who owns the billing" and "who owns the
53
+ // migration" are three words apiece in common and are not the same question at all. But one
54
+ // question wholly inside another is the same question said at more length, and the union
55
+ // punishes exactly that, so containment is asked separately and asked strictly.
56
+ const inside = shared / Math.min(one.size, two.size);
57
+ return shared / (one.size + two.size - shared) >= floor || inside >= 0.9;
58
+ }
59
+
60
+ // The questions, most worth asking first, each one traceable to what raised it.
61
+ export function questionsFor({ notes = [], cold = new Map(), dismissed = [], limit = ASK_LIMIT } = {}) {
62
+ const skip = new Set(dismissed ?? []);
63
+ const standing = notes.filter((note) => note && note.kind !== 'aberration' && !note.refutedBy);
64
+ const asks = { open: [], cold: [], thin: [] };
65
+
66
+ // What the archive itself recorded as open. One the room keeps reaching for and still has no
67
+ // answer to is the most worth asking of anything here.
68
+ for (const note of standing.filter((note) => note.kind === 'question')) {
69
+ asks.open.push({
70
+ id: `open:${note.id}`,
71
+ source: 'open',
72
+ memoryId: note.id,
73
+ text: note.text,
74
+ why: Number(note.recalled ?? 0) > 0
75
+ ? `the room has carried this open question into ${note.recalled} turn${Number(note.recalled) === 1 ? '' : 's'} and still has no answer`
76
+ : 'the archivist recorded this as open and nothing has answered it',
77
+ weight: 100 + Number(note.recalled ?? 0),
78
+ });
79
+ }
80
+
81
+ // What nothing has ever reached for. Asking settles it either way.
82
+ for (const note of standing) {
83
+ const chill = cold.get?.(note.id) ?? null;
84
+ if (!chill) continue;
85
+ asks.cold.push({
86
+ id: `cold:${note.id}`,
87
+ source: 'cold',
88
+ memoryId: note.id,
89
+ text: `Is this still true, and does it still matter here? ${quote(note.text)}`,
90
+ why: `the archive has been opened ${chill.chances} times since this was written and never once carried it`,
91
+ weight: 50 + chill.chances,
92
+ });
93
+ }
94
+
95
+ // What the archive is short of. Not a memory: a shape.
96
+ if (standing.length >= THIN_FLOOR) {
97
+ const counts = new Map();
98
+ for (const note of standing) counts.set(note.kind, (counts.get(note.kind) ?? 0) + 1);
99
+ for (const kind of Object.keys(KIND_ASK)) {
100
+ const count = counts.get(kind) ?? 0;
101
+ if (count / standing.length >= THIN_SHARE) continue;
102
+ asks.thin.push({
103
+ id: `thin:${kind}`,
104
+ source: 'thin',
105
+ memoryId: null,
106
+ text: KIND_ASK[kind],
107
+ why: `${count} of ${standing.length} notes are ${KIND_WHY[kind]}`,
108
+ weight: 40 - count,
109
+ });
110
+ }
111
+ }
112
+
113
+ for (const list of Object.values(asks)) list.sort((a, b) => b.weight - a.weight);
114
+ // Taken in turn rather than by weight alone, so one full well cannot be the whole list: six
115
+ // variations on the same cold note is not a plan, it is a loop.
116
+ const order = ['open', 'cold', 'thin'];
117
+ const chosen = [];
118
+ while (chosen.length < limit && order.some((well) => asks[well].length)) {
119
+ for (const well of order) {
120
+ if (chosen.length >= limit) break;
121
+ const next = asks[well].shift();
122
+ if (!next || skip.has(next.id)) continue;
123
+ if (chosen.some((already) => sameQuestion(already.text, next.text))) continue;
124
+ chosen.push(next);
125
+ }
126
+ }
127
+ return chosen;
128
+ }
@@ -1,4 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { t } from './i18n.mjs';
2
3
  import { access, readFile } from 'node:fs/promises';
3
4
  import { homedir } from 'node:os';
4
5
  import { join } from 'node:path';
@@ -78,7 +79,9 @@ export async function probeGemini({ env = process.env } = {}) {
78
79
  settings = null;
79
80
  }
80
81
  const hasOauth = await exists(join(dir, 'oauth_creds.json'));
81
- const hasApiKey = Boolean(env.GEMINI_API_KEY || env.GOOGLE_API_KEY) || await geminiHasKeychainKey();
82
+ // The CLI reads ~/.gemini/.env as well as the environment and the system keychain.
83
+ const dotEnv = await readFile(join(dir, '.env'), 'utf8').catch(() => '');
84
+ const hasApiKey = Boolean(env.GEMINI_API_KEY || env.GOOGLE_API_KEY) || /^\s*(GEMINI_API_KEY|GOOGLE_API_KEY)\s*=\s*\S/m.test(dotEnv) || await geminiHasKeychainKey();
82
85
  return geminiAuthState({ settings, hasOauth, hasApiKey });
83
86
  }
84
87
 
@@ -90,26 +93,80 @@ export const AGENT_SETUP = {
90
93
  login: ['login'],
91
94
  loginNote: 'Opens your browser to sign in with ChatGPT.',
92
95
  browser: true,
96
+ // What a newcomer needs to know before choosing this door: which account, and whether
97
+ // there is a way in without paying. `paid: null` means it depends on the provider you pick.
98
+ vendor: 'OpenAI',
99
+ account: 'Signs in with a ChatGPT account. An OpenAI API key works too.',
100
+ paid: true,
93
101
  },
94
102
  claude: {
95
103
  install: ['npm install -g @anthropic-ai/claude-code', 'or: brew install --cask claude-code'],
96
104
  login: ['auth', 'login'],
97
105
  loginNote: 'Opens your browser to sign in with your Claude account.',
98
106
  browser: true,
107
+ vendor: 'Anthropic',
108
+ account: 'Signs in with a Claude account. An Anthropic API key works too.',
109
+ paid: true,
99
110
  },
100
111
  gemini: {
101
112
  install: ['npm install -g @google/gemini-cli'],
102
113
  login: [],
103
114
  loginNote: 'Gemini signs in from its own prompt: run `gemini`, type /auth, pick "Use Gemini API key" (get one at aistudio.google.com/app/apikey) or Google login.',
104
115
  interactive: true,
116
+ vendor: 'Google',
117
+ account: 'Signs in with a Google account and has a free tier. A Gemini API key from AI Studio works too.',
118
+ paid: false,
105
119
  },
106
120
  opencode: {
107
121
  install: ['brew install opencode', 'or: npm install -g opencode-ai'],
108
122
  login: ['auth', 'login'],
109
123
  loginNote: 'Pick a provider and paste its key or complete its OAuth flow.',
124
+ vendor: 'OpenCode',
125
+ account: 'Brings no model of its own: you point it at a provider you already use, in the cloud or on this computer.',
126
+ paid: null,
110
127
  },
111
128
  };
112
129
 
130
+ // Installing a CLI from the room itself. npm is the common denominator: every one of these
131
+ // ships an npm package, and whoever reached MADRE through npx already has npm on the machine.
132
+ // The prose in AGENT_SETUP.install stays as the alternatives a human may prefer.
133
+ export const AGENT_PACKAGE = {
134
+ codex: '@openai/codex',
135
+ claude: '@anthropic-ai/claude-code',
136
+ gemini: '@google/gemini-cli',
137
+ opencode: 'opencode-ai',
138
+ };
139
+ // One honest line per agent about the account it needs, for the bridge and for CONNECTIONS.
140
+ // Which agents take a pasted key instead of a browser flow.
141
+ export const TAKES_KEY = new Set(['gemini', 'opencode']);
142
+
143
+ export function accountNoteFor(id) {
144
+ // Said in the room's language when it is asked for, not when this file is imported.
145
+ if (id === 'madre') return { account: t("Free and local through Ollama: no account, no tokens. It answers from the room's memory."), paid: false, vendor: 'MADRE' };
146
+ const setup = AGENT_SETUP[id];
147
+ return setup ? { account: t(setup.account), paid: setup.paid, vendor: setup.vendor } : null;
148
+ }
149
+
150
+ // npm cannot always write to the system folders: with Node installed from its own installer,
151
+ // a global install asks for an administrator. Rather than send the human to a terminal with
152
+ // sudo, MADRE installs into a folder of its own and finds the CLI there.
153
+ export const NEEDS_ADMIN = /EACCES|EPERM|permission denied|Missing write access|operation not permitted|npm ERR! code E401.*sudo|need (?:root|sudo)/i;
154
+ export const looksLikeAdminProblem = (output) => NEEDS_ADMIN.test(String(output ?? ''));
155
+
156
+ export function installPlanFor(agent, { prefix = null } = {}) {
157
+ const name = AGENT_PACKAGE[agent?.id];
158
+ if (!name) return null;
159
+ return {
160
+ package: name,
161
+ command: 'npm',
162
+ args: ['install', '-g', name, '--no-fund', '--no-audit'],
163
+ display: `npm install -g ${name}`,
164
+ // The same install, into MADRE's own prefix: no administrator, nothing outside ~/.pulse.
165
+ fallback: prefix ? { command: 'npm', args: ['install', '-g', name, '--prefix', prefix, '--no-fund', '--no-audit'], display: `npm install -g ${name} --prefix ${prefix}` } : null,
166
+ alternatives: (AGENT_SETUP[agent.id]?.install ?? []).slice(1),
167
+ };
168
+ }
169
+
113
170
  // How the room can (re)connect an agent. Codex and Claude sign in with a
114
171
  // browser flow their own CLI drives, so the server can run them and stream the
115
172
  // URL; Gemini and OpenCode need their interactive prompt, so the user gets the
package/src/chats.mjs ADDED
@@ -0,0 +1,193 @@
1
+ // A project has one memory and many conversations.
2
+ //
3
+ // The archive, the crew, the modules and the privacy list belong to the project: they are what
4
+ // MADRE knows about this codebase, and they do not start over because somebody opened a new
5
+ // thread. A conversation is only the record of one line of work — its own ledger, its own
6
+ // transcript, its own window — and everything it says feeds the same archive.
7
+ //
8
+ // The first conversation is the ledger that was always there, kept exactly where it was: no file
9
+ // is moved to add this, so a room that existed before opens as it always did and simply gains a
10
+ // name. New conversations get a folder of their own next to it.
11
+ //
12
+ // <room>/events.jsonl the first conversation
13
+ // <room>/chats/<id>/events.jsonl every one after it
14
+ // <room>/chats.json their names, and which one is open
15
+ //
16
+ // Nothing here starts a room or reads a ledger. It keeps the list.
17
+
18
+ import { readFile, writeFile, mkdir, rm, readdir, open, stat } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ import { randomUUID } from 'node:crypto';
21
+
22
+ export const MAIN_CHAT = 'main';
23
+ export const CHAT_TITLE_MAX = 80;
24
+ const FILE = 'chats.json';
25
+
26
+ export const isChatId = (id) => typeof id === 'string' && (id === MAIN_CHAT || /^[a-z0-9]{8,32}$/.test(id));
27
+
28
+ // Where one conversation's ledger lives. The first one never moved.
29
+ export function chatLedger(roomDir, id) {
30
+ return id === MAIN_CHAT ? join(roomDir, 'events.jsonl') : join(roomDir, 'chats', id, 'events.jsonl');
31
+ }
32
+
33
+ async function readIndex(roomDir) {
34
+ try {
35
+ const raw = JSON.parse(await readFile(join(roomDir, FILE), 'utf8'));
36
+ if (raw && typeof raw === 'object' && raw.chats && typeof raw.chats === 'object') return raw;
37
+ } catch { /* no index yet, or an unreadable one: the room still opens */ }
38
+ return null;
39
+ }
40
+
41
+ async function writeIndex(roomDir, index) {
42
+ await mkdir(roomDir, { recursive: true }).catch(() => {});
43
+ await writeFile(join(roomDir, FILE), JSON.stringify(index, null, 2));
44
+ return index;
45
+ }
46
+
47
+ // The list, with the first conversation always in it. A room that predates conversations reads
48
+ // as one conversation, which is what it was.
49
+ export async function chatIndex(roomDir) {
50
+ const index = await readIndex(roomDir);
51
+ const now = new Date().toISOString();
52
+ if (!index) return { active: MAIN_CHAT, chats: { [MAIN_CHAT]: { title: null, createdAt: now, updatedAt: now, messages: 0, preview: null } } };
53
+ if (!index.chats[MAIN_CHAT]) index.chats[MAIN_CHAT] = { title: null, createdAt: now, updatedAt: now, messages: 0, preview: null };
54
+ if (!index.chats[index.active]) index.active = MAIN_CHAT;
55
+ return index;
56
+ }
57
+
58
+ const shape = (id, entry, active) => ({
59
+ id,
60
+ title: entry.title || (id === MAIN_CHAT ? 'First conversation' : 'Untitled'),
61
+ named: Boolean(entry.title),
62
+ createdAt: entry.createdAt ?? null,
63
+ updatedAt: entry.updatedAt ?? entry.createdAt ?? null,
64
+ messages: Number(entry.messages ?? 0),
65
+ preview: entry.preview ?? null,
66
+ active: id === active,
67
+ });
68
+
69
+ // Newest first, because that is the order a person looks for a conversation in.
70
+ export async function listChats(roomDir) {
71
+ const index = await chatIndex(roomDir);
72
+ const chats = Object.entries(index.chats)
73
+ .map(([id, entry]) => shape(id, entry, index.active))
74
+ .sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')));
75
+ return { active: index.active, chats };
76
+ }
77
+
78
+ export async function createChat(roomDir, { title = null, now = new Date().toISOString() } = {}) {
79
+ const index = await chatIndex(roomDir);
80
+ const id = randomUUID().replace(/-/g, '').slice(0, 16);
81
+ index.chats[id] = { title: title ? String(title).slice(0, CHAT_TITLE_MAX) : null, createdAt: now, updatedAt: now, messages: 0, preview: null };
82
+ index.active = id;
83
+ await mkdir(join(roomDir, 'chats', id), { recursive: true });
84
+ await writeIndex(roomDir, index);
85
+ return shape(id, index.chats[id], index.active);
86
+ }
87
+
88
+ export async function openChat(roomDir, id) {
89
+ const index = await chatIndex(roomDir);
90
+ if (!index.chats[id]) return null;
91
+ index.active = id;
92
+ await writeIndex(roomDir, index);
93
+ return shape(id, index.chats[id], index.active);
94
+ }
95
+
96
+ export async function renameChat(roomDir, id, title) {
97
+ const index = await chatIndex(roomDir);
98
+ if (!index.chats[id]) return null;
99
+ const named = String(title ?? '').trim().slice(0, CHAT_TITLE_MAX);
100
+ index.chats[id].title = named || null;
101
+ await writeIndex(roomDir, index);
102
+ return shape(id, index.chats[id], index.active);
103
+ }
104
+
105
+ // Deleting a conversation takes its transcript and nothing else: whatever the archivist distilled
106
+ // from it is the project's memory, not the conversation's, and it stays. The last conversation
107
+ // cannot be deleted — a room always has somewhere to talk.
108
+ export async function deleteChat(roomDir, id) {
109
+ const index = await chatIndex(roomDir);
110
+ if (!index.chats[id]) return null;
111
+ if (Object.keys(index.chats).length < 2) return { error: 'A room always has one conversation. Start another before deleting this one.' };
112
+ delete index.chats[id];
113
+ if (index.active === id) index.active = Object.keys(index.chats)[0];
114
+ if (id === MAIN_CHAT) await rm(join(roomDir, 'events.jsonl'), { force: true });
115
+ else await rm(join(roomDir, 'chats', id), { recursive: true, force: true, maxRetries: 6, retryDelay: 60 });
116
+ await writeIndex(roomDir, index);
117
+ return { deleted: id, active: index.active };
118
+ }
119
+
120
+ // What the list shows about a conversation, kept as it happens rather than by reading every
121
+ // ledger to draw a sidebar. A conversation with no name of its own takes the first thing the
122
+ // human said in it, which is what they will look for.
123
+ export async function touchChat(roomDir, id, { text = null, now = new Date().toISOString(), counts = true } = {}) {
124
+ const index = await chatIndex(roomDir);
125
+ const entry = index.chats[id];
126
+ if (!entry) return null;
127
+ entry.updatedAt = now;
128
+ if (counts) entry.messages = Number(entry.messages ?? 0) + 1;
129
+ if (text) {
130
+ const line = String(text).replace(/\s+/g, ' ').trim().slice(0, 120);
131
+ if (line) {
132
+ entry.preview = line;
133
+ if (!entry.title) entry.title = line.slice(0, CHAT_TITLE_MAX);
134
+ }
135
+ }
136
+ await writeIndex(roomDir, index);
137
+ return shape(id, entry, index.active);
138
+ }
139
+
140
+ // Conversations whose folder is on disk but which no index knows about: a room copied by hand, or
141
+ // an index lost. They are listed rather than ignored, because a transcript is not disposable.
142
+ export async function adoptStrays(roomDir) {
143
+ let folders = [];
144
+ try { folders = await readdir(join(roomDir, 'chats')); } catch { return { adopted: [] }; }
145
+ const index = await chatIndex(roomDir);
146
+ const adopted = [];
147
+ for (const id of folders) {
148
+ if (!isChatId(id) || index.chats[id]) continue;
149
+ index.chats[id] = { title: null, createdAt: null, updatedAt: null, messages: 0, preview: null };
150
+ adopted.push(id);
151
+ }
152
+ if (adopted.length) await writeIndex(roomDir, index);
153
+ return { adopted };
154
+ }
155
+
156
+ // The last sequence a ledger reached, read from its tail rather than by loading it. A project
157
+ // numbers its exchanges once across every conversation, so opening one has to know where the
158
+ // others got to; doing that by reading whole ledgers would make opening a conversation cost more
159
+ // the longer the project has been worked in.
160
+ export async function lastSequenceOf(file, { window = 65536 } = {}) {
161
+ let handle;
162
+ try {
163
+ const size = (await stat(file)).size;
164
+ if (!size) return 0;
165
+ handle = await open(file, 'r');
166
+ const length = Math.min(window, size);
167
+ const buffer = Buffer.alloc(length);
168
+ await handle.read(buffer, 0, length, size - length);
169
+ const lines = buffer.toString('utf8').split('\n').filter(Boolean);
170
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
171
+ try {
172
+ const event = JSON.parse(lines[i]);
173
+ if (Number.isInteger(event?.sequence)) return event.sequence;
174
+ } catch { /* a half line at the window's edge, or a torn write: keep looking back */ }
175
+ }
176
+ return 0;
177
+ } catch {
178
+ return 0;
179
+ } finally {
180
+ await handle?.close().catch(() => {});
181
+ }
182
+ }
183
+
184
+ // Where the project's numbering stands, across every conversation but the one being opened.
185
+ export async function projectFloor(roomDir, { except = null } = {}) {
186
+ const { chats } = await listChats(roomDir);
187
+ let floor = 0;
188
+ for (const chat of chats) {
189
+ if (chat.id === except) continue;
190
+ floor = Math.max(floor, await lastSequenceOf(chatLedger(roomDir, chat.id)));
191
+ }
192
+ return floor;
193
+ }
@@ -86,7 +86,7 @@ export async function worktreeTree(root, { exclude = [] } = {}) {
86
86
  }
87
87
  return (await git(root, ['write-tree'], { env })).trim();
88
88
  } finally {
89
- await rm(dir, { recursive: true, force: true });
89
+ await rm(dir, { recursive: true, force: true, maxRetries: 6, retryDelay: 60 });
90
90
  }
91
91
  }
92
92