@jossuealcala/madre 0.3.2 → 0.4.0

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 (75) hide show
  1. package/CHANGELOG.md +425 -0
  2. package/CONTRIBUTING.md +6 -1
  3. package/README.md +67 -183
  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 +3880 -849
  13. package/public/es.js +2050 -0
  14. package/public/i18n.js +66 -0
  15. package/public/index.html +95 -14
  16. package/public/inquiry.js +220 -0
  17. package/public/resay.js +77 -0
  18. package/public/styles.css +626 -68
  19. package/public/troubleshooting.js +173 -51
  20. package/src/adapters/claude.mjs +13 -6
  21. package/src/adapters/codex.mjs +17 -13
  22. package/src/adapters/gemini.mjs +27 -16
  23. package/src/adapters/opencode.mjs +16 -12
  24. package/src/adapters/process.mjs +17 -5
  25. package/src/asking.mjs +128 -0
  26. package/src/auth-probe.mjs +58 -1
  27. package/src/capabilities.mjs +4 -3
  28. package/src/chats.mjs +193 -0
  29. package/src/checkpoint.mjs +1 -1
  30. package/src/cold.mjs +56 -0
  31. package/src/commands.mjs +31 -3
  32. package/src/conversation-context.mjs +35 -3
  33. package/src/credentials.mjs +145 -0
  34. package/src/dataset.mjs +56 -4
  35. package/src/distiller.mjs +12 -5
  36. package/src/event-store.mjs +14 -8
  37. package/src/exam.mjs +240 -0
  38. package/src/extensions.mjs +3 -2
  39. package/src/eyecat-watch.mjs +100 -0
  40. package/src/eyecat.mjs +169 -0
  41. package/src/i18n.mjs +47 -0
  42. package/src/image-studio.mjs +2 -0
  43. package/src/launch.mjs +61 -0
  44. package/src/lease.mjs +5 -3
  45. package/src/maturity.mjs +94 -0
  46. package/src/mcp/image-server.mjs +12 -1
  47. package/src/mcp/memory-server.mjs +1 -1
  48. package/src/memory.mjs +325 -17
  49. package/src/modules/ahp.mjs +9 -7
  50. package/src/modules/ash.mjs +36 -0
  51. package/src/modules/git-pulse.mjs +6 -4
  52. package/src/modules/helpers.mjs +31 -0
  53. package/src/modules/image-studio.mjs +9 -4
  54. package/src/modules/index.mjs +143 -5
  55. package/src/modules/ollama.mjs +66 -10
  56. package/src/modules/playwright.mjs +90 -0
  57. package/src/modules/ripley.mjs +5 -3
  58. package/src/modules/sdk.mjs +104 -2
  59. package/src/modules/updates.mjs +81 -0
  60. package/src/ollama.mjs +5 -2
  61. package/src/outbound.mjs +292 -0
  62. package/src/privacy.mjs +54 -7
  63. package/src/room/context.mjs +4 -4
  64. package/src/room/control.mjs +4 -4
  65. package/src/room/economy.mjs +161 -0
  66. package/src/room/prompt.mjs +118 -43
  67. package/src/room.mjs +465 -58
  68. package/src/runtime-detection.mjs +27 -8
  69. package/src/server.mjs +724 -68
  70. package/src/setup.mjs +1 -1
  71. package/src/updates.mjs +17 -2
  72. package/src/usage-sentinel.mjs +13 -8
  73. package/src/verdict.mjs +74 -0
  74. package/src/ashcode.mjs +0 -64
  75. package/src/modules/ashcode.mjs +0 -28
@@ -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';
@@ -27,7 +28,7 @@ export const geminiCredentialFiles = ['oauth_creds.json', 'google_accounts.json'
27
28
  // whose file_path argument starts with the lease directory. Plan mode would
28
29
  // block every write regardless of policy, so a lease uses approval "default":
29
30
  // headless Gemini cannot prompt, so anything the policy does not allow fails.
30
- export function geminiLeasePolicy(outDir, { control = false, create = false } = {}) {
31
+ export function geminiLeasePolicy(outDir, { control = false, create = false, airlock = false } = {}) {
31
32
  const escaped = outDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
33
  // The whole project is the lease in #2 and #3: MADRE's zones stay out of reach.
33
34
  const forbidden = control || create ? `
@@ -40,7 +41,15 @@ interactive = false
40
41
  ` : '';
41
42
  // CREATE (#2) adds files: write_file only; CONTROL (#3) also replaces and edits.
42
43
  const tools = create ? '["write_file"]' : '["write_file", "replace", "edit"]';
43
- return `${geminiReadonlyPolicy}
44
+ // AIRLOCK (#4): commands too; the forbidden-zone rule above still outranks this one.
45
+ const commands = airlock ? `
46
+ [[rule]]
47
+ toolName = "run_shell_command"
48
+ decision = "allow"
49
+ priority = 1000
50
+ interactive = false
51
+ ` : '';
52
+ return `${geminiReadonlyPolicy}${commands}
44
53
  [[rule]]
45
54
  toolName = ${tools}
46
55
  argsPattern = '"file_path"\\s*:\\s*"${escaped}/'
@@ -58,12 +67,13 @@ priority = 999
58
67
  interactive = false
59
68
  `;
60
69
 
61
- export function geminiPolicy({ lease = null, scopes = null, imageStudio = null, memoryServer = null } = {}) {
62
- return `${lease ? geminiLeasePolicy(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create) }) : geminiReadonlyPolicy}${scopes?.web ? geminiWebPolicy : ''}${imageStudio && lease ? geminiImagePolicy(imageStudio) : ''}${memoryServer ? geminiMemoryPolicy(memoryServer) : ''}`;
70
+ export function geminiPolicy({ lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] } = {}) {
71
+ return `${lease ? geminiLeasePolicy(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create), airlock: Boolean(lease.airlock) }) : geminiReadonlyPolicy}${scopes?.web ? geminiWebPolicy : ''}${imageStudio && lease ? geminiImagePolicy(imageStudio) : ''}${memoryServer ? geminiMemoryPolicy(memoryServer) : ''}${mcpServers.map(geminiMemoryPolicy).join('')}`;
63
72
  }
64
73
 
74
+ // Allow rules for a server's tools, by bare name and by server-prefixed name; an open tool list allows the server's prefix.
65
75
  export function geminiMemoryPolicy(memoryServer) {
66
- const names = memoryServer.tools.flatMap((tool) => [tool, `${memoryServer.name}__${tool}`]);
76
+ const names = memoryServer.tools?.length ? memoryServer.tools.flatMap((tool) => [tool, `${memoryServer.name}__${tool}`]) : [`${memoryServer.name}__*`];
67
77
  return `
68
78
  [[rule]]
69
79
  toolName = [${names.map((name) => `"${name}"`).join(', ')}]
@@ -112,10 +122,10 @@ export function buildGeminiEnvironment({ runtimeRoot, environment = process.env
112
122
  };
113
123
  }
114
124
 
115
- export function isolateGeminiSettings(settings, { imageStudio = null, memoryServer = null } = {}) {
125
+ export function isolateGeminiSettings(settings, { imageStudio = null, memoryServer = null, mcpServers = [] } = {}) {
116
126
  const auth = settings?.security?.auth;
117
127
  const isolated = auth ? { security: { auth } } : {};
118
- for (const server of [memoryServer, imageStudio]) {
128
+ for (const server of [memoryServer, imageStudio, ...mcpServers]) {
119
129
  if (!server) continue;
120
130
  isolated.mcpServers ??= {};
121
131
  isolated.mcpServers[server.name] = { command: server.command, args: server.args, env: server.env, trust: true };
@@ -133,7 +143,7 @@ interactive = false
133
143
  `;
134
144
  }
135
145
 
136
- export async function prepareGeminiHome({ runtimeRoot, sourceHome = join(homedir(), '.gemini'), imageStudio = null, memoryServer = null }) {
146
+ export async function prepareGeminiHome({ runtimeRoot, sourceHome = join(homedir(), '.gemini'), imageStudio = null, memoryServer = null, mcpServers = [] }) {
137
147
  const geminiDir = join(runtimeRoot, '.gemini');
138
148
  await mkdir(geminiDir, { recursive: true, mode: 0o700 });
139
149
  for (const name of geminiCredentialFiles) {
@@ -147,7 +157,7 @@ export async function prepareGeminiHome({ runtimeRoot, sourceHome = join(homedir
147
157
  } catch (error) {
148
158
  if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
149
159
  }
150
- await writeFile(join(geminiDir, 'settings.json'), JSON.stringify(isolateGeminiSettings(settings, { imageStudio, memoryServer })), { mode: 0o600 });
160
+ await writeFile(join(geminiDir, 'settings.json'), JSON.stringify(isolateGeminiSettings(settings, { imageStudio, memoryServer, mcpServers })), { mode: 0o600 });
151
161
  return geminiDir;
152
162
  }
153
163
 
@@ -206,7 +216,7 @@ export function parseGeminiOutput(output) {
206
216
  export async function cleanupRuntimeRoot(runtimeRoot, { attempts = 6, delayMs = 250 } = {}) {
207
217
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
208
218
  try {
209
- await rm(runtimeRoot, { recursive: true, force: true });
219
+ await rm(runtimeRoot, { recursive: true, force: true, maxRetries: 6, retryDelay: 60 });
210
220
  return true;
211
221
  } catch (error) {
212
222
  if (attempt === attempts) {
@@ -225,21 +235,21 @@ export async function cleanupRuntimeRoot(runtimeRoot, { attempts = 6, delayMs =
225
235
  export function diagnoseGeminiStderr(stderr) {
226
236
  const text = String(stderr ?? '');
227
237
  if (/prepayment credits are depleted/i.test(text)) {
228
- 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.') };
229
239
  }
230
240
  if (/status:\s*429|\b429\b|RESOURCE_EXHAUSTED|rate ?limit|quota exceeded/i.test(text)) {
231
241
  const router = /ClassifierStrategy|\.route\b/.test(text);
232
242
  return {
233
243
  code: 'RATE_LIMITED',
234
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.`,
235
- 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.'),
236
246
  };
237
247
  }
238
248
  if (/status:?\s*503|UNAVAILABLE|high demand/i.test(text)) {
239
- 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.') };
240
250
  }
241
251
  if (/status:\s*40[13]|PERMISSION_DENIED|API key not valid|IneligibleTierError/i.test(text)) {
242
- 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.') };
243
253
  }
244
254
  return null;
245
255
  }
@@ -259,6 +269,7 @@ export async function invokeGemini({
259
269
  scopes = null,
260
270
  imageStudio = null,
261
271
  memoryServer = null,
272
+ mcpServers = [],
262
273
  idleTimeoutMs = Number(process.env.PULSE_GEMINI_IDLE_MS ?? 90000),
263
274
  retries = Number(process.env.PULSE_GEMINI_RETRIES ?? 1),
264
275
  fallbackModel = process.env.PULSE_GEMINI_FALLBACK_MODEL ?? 'gemini-2.5-flash',
@@ -268,8 +279,8 @@ export async function invokeGemini({
268
279
  const runtimeRoot = await mkdtemp(join(tmpdir(), 'pulse-gemini-'));
269
280
  const policyPath = join(runtimeRoot, 'readonly.toml');
270
281
  try {
271
- await writeFile(policyPath, geminiPolicy({ lease, scopes, imageStudio: lease ? imageStudio : null, memoryServer }), { mode: 0o600 });
272
- await prepareGeminiHome({ runtimeRoot, imageStudio: lease ? imageStudio : null, memoryServer });
282
+ await writeFile(policyPath, geminiPolicy({ lease, scopes, imageStudio: lease ? imageStudio : null, memoryServer, mcpServers }), { mode: 0o600 });
283
+ await prepareGeminiHome({ runtimeRoot, imageStudio: lease ? imageStudio : null, memoryServer, mcpServers });
273
284
  let attempt = 0;
274
285
  let currentModel = model;
275
286
  let switched = false;
@@ -45,7 +45,7 @@ export function writeRules(relativeDir, { control = false, create = false } = {}
45
45
  return { '*': 'deny', [dir && dir !== '.' ? `${dir}/**` : '**']: 'allow' };
46
46
  }
47
47
 
48
- export function leaseConfig(outDir, { control = false, create = false, relativeDir = null } = {}) {
48
+ export function leaseConfig(outDir, { control = false, create = false, airlock = false, relativeDir = null } = {}) {
49
49
  const rules = writeRules(relativeDir ?? outDir, { control, create });
50
50
  // OpenCode gates its `write` tool behind the `edit` permission as well (denying edit leaves "no
51
51
  // file-writing tool"), so CREATE allows both and the room restores existing files afterwards.
@@ -55,7 +55,9 @@ export function leaseConfig(outDir, { control = false, create = false, relativeD
55
55
  agent: {
56
56
  'pulse-readonly': {
57
57
  ...readonlyConfig.agent['pulse-readonly'],
58
- prompt: control
58
+ prompt: airlock
59
+ ? 'Answer the user directly. AIRLOCK: you are in command of this project and may run commands in it, including git and deploy CLIs with the sessions already on this machine. Say what will leave the machine before it does. Never print secrets. Do not launch subagents.'
60
+ : control
59
61
  ? 'Answer the user directly. You are in CONTROL of this project: create and edit files anywhere inside it except .git, .pulse, .madre and .env files. Do not run commands, browse the web, or launch subagents.'
60
62
  : create
61
63
  ? 'Answer the user directly. You may create new files and folders anywhere in this project where they belong; do not modify or delete existing files. Do not run commands, browse the web, or launch subagents.'
@@ -64,14 +66,15 @@ export function leaseConfig(outDir, { control = false, create = false, relativeD
64
66
  ...readonlyConfig.agent['pulse-readonly'].permission,
65
67
  edit: editRules,
66
68
  write: rules,
69
+ ...(airlock ? { bash: 'allow' } : {}), // AIRLOCK (#4): commands, git, deploy CLIs
67
70
  },
68
71
  },
69
72
  },
70
73
  };
71
74
  }
72
75
 
73
- export function openCodeConfig({ lease = null, scopes = null, imageStudio = null, memoryServer = null } = {}) {
74
- let config = lease ? leaseConfig(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create), relativeDir: lease.relativeDir ?? null }) : readonlyConfig;
76
+ export function openCodeConfig({ lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] } = {}) {
77
+ let config = lease ? leaseConfig(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create), airlock: Boolean(lease.airlock), relativeDir: lease.relativeDir ?? null }) : readonlyConfig;
75
78
  if (scopes?.web) {
76
79
  config = {
77
80
  ...config,
@@ -90,17 +93,17 @@ export function openCodeConfig({ lease = null, scopes = null, imageStudio = null
90
93
  mcp: { ...(config.mcp ?? {}), [imageStudio.name]: { type: 'local', command: [imageStudio.command, ...imageStudio.args], environment: imageStudio.env, enabled: true } },
91
94
  };
92
95
  }
93
- if (memoryServer) {
96
+ for (const server of [memoryServer, ...mcpServers].filter(Boolean)) {
94
97
  config = {
95
98
  ...config,
96
- mcp: { ...(config.mcp ?? {}), [memoryServer.name]: { type: 'local', command: [memoryServer.command, ...memoryServer.args], environment: memoryServer.env, enabled: true } },
99
+ mcp: { ...(config.mcp ?? {}), [server.name]: { type: 'local', command: [server.command, ...(server.args ?? [])], environment: server.env ?? {}, enabled: true } },
97
100
  agent: {
98
101
  'pulse-readonly': {
99
102
  ...config.agent['pulse-readonly'],
100
103
  permission: {
101
104
  ...config.agent['pulse-readonly'].permission,
102
- [`${memoryServer.name}*`]: 'allow',
103
- ...Object.fromEntries(memoryServer.tools.map((tool) => [`${memoryServer.name}_${tool}`, 'allow'])),
105
+ [`${server.name}*`]: 'allow',
106
+ ...Object.fromEntries((server.tools ?? []).map((tool) => [`${server.name}_${tool}`, 'allow'])),
104
107
  },
105
108
  },
106
109
  },
@@ -109,11 +112,11 @@ export function openCodeConfig({ lease = null, scopes = null, imageStudio = null
109
112
  return config;
110
113
  }
111
114
 
112
- export function openCodeEnvironment(environment = process.env, { lease = null, scopes = null, imageStudio = null, memoryServer = null } = {}) {
115
+ export function openCodeEnvironment(environment = process.env, { lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] } = {}) {
113
116
  return {
114
117
  ...environment,
115
118
  OPENCODE_AUTO_SHARE: 'false',
116
- OPENCODE_CONFIG_CONTENT: JSON.stringify(openCodeConfig({ lease, scopes, imageStudio, memoryServer })),
119
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(openCodeConfig({ lease, scopes, imageStudio, memoryServer, mcpServers })),
117
120
  OPENCODE_DISABLE_AUTOUPDATE: 'true',
118
121
  };
119
122
  }
@@ -151,12 +154,13 @@ export function parseOpenCodeOutput(output) {
151
154
  return { text: text.join('').trim(), usage, ...(error ? { error } : {}) };
152
155
  }
153
156
 
154
- export function invokeOpenCode({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null }) {
157
+ export function invokeOpenCode({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [], onProgress = null }) {
155
158
  return runReadonlyProcess({
159
+ onProgress: onProgress ? (out) => onProgress({ chars: out.length }) : null,
156
160
  executable,
157
161
  args: buildOpenCodeArgs({ projectRoot, prompt, attachments, ...(model ? { model } : {}) }),
158
162
  cwd: projectRoot,
159
- env: openCodeEnvironment(process.env, { lease, scopes, imageStudio, memoryServer }),
163
+ env: openCodeEnvironment(process.env, { lease, scopes, imageStudio, memoryServer, mcpServers }),
160
164
  timeoutMs,
161
165
  signal,
162
166
  label: 'OpenCode',
@@ -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;
@@ -41,10 +42,14 @@ export function runReadonlyProcess({
41
42
  label,
42
43
  parse,
43
44
  signal,
45
+ // Called as output arrives, with everything received so far. The adapter decides what that
46
+ // means: a CLI that streams its answer can be read as it writes, one that hands over a single
47
+ // blob at the end will simply say nothing until then, and saying nothing is the honest answer.
48
+ onProgress = null,
44
49
  }) {
45
50
  return new Promise((resolve, reject) => {
46
51
  if (signal?.aborted) {
47
- reject(new Error(`${label} was interrupted before it started: ${typeof signal.reason === 'string' ? signal.reason : 'MADRE is shutting down'}.`));
52
+ reject(new Error(t('{label} was interrupted before it started: {why}.', { label, why: typeof signal.reason === 'string' ? signal.reason : t('MADRE is shutting down') })));
48
53
  return;
49
54
  }
50
55
  const child = spawn(executable, args, {
@@ -77,8 +82,8 @@ export function runReadonlyProcess({
77
82
 
78
83
  const onAbort = () => {
79
84
  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}.`)));
85
+ const reason = typeof signal?.reason === 'string' ? signal.reason : t('MADRE is shutting down');
86
+ finish(() => reject(new Error(t('{label} was interrupted: {why}.', { label, why: reason }))));
82
87
  };
83
88
  const finish = (operation) => {
84
89
  if (settled) return;
@@ -92,7 +97,7 @@ export function runReadonlyProcess({
92
97
  terminateProcessTree(child, { graceMs: killGraceMs });
93
98
  // The last thing the agent said is usually the reason it was slow.
94
99
  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)}` : ''}`);
100
+ 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
101
  error.code = 'TIMEOUT';
97
102
  error.partialOutput = stdout;
98
103
  error.partialStderr = stderr.slice(-2000);
@@ -103,7 +108,14 @@ export function runReadonlyProcess({
103
108
  // Only complete stdout lines count as activity: a CLI's stderr spinner or
104
109
  // progress noise must not keep a silent model alive past the idle limit.
105
110
  // 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(); });
111
+ let toldAt = 0;
112
+ child.stdout.on('data', (chunk) => {
113
+ stdout += chunk;
114
+ if (/\S/.test(String(chunk)) && String(chunk).includes('\n')) lastActivity = Date.now();
115
+ // Throttled: a chatty CLI can produce hundreds of chunks a second and nobody needs to see
116
+ // a number move that fast.
117
+ if (onProgress && Date.now() - toldAt > 400) { toldAt = Date.now(); try { onProgress(stdout); } catch { /* a meter must never break a turn */ } }
118
+ });
107
119
  child.stderr.on('data', (chunk) => {
108
120
  stderr += chunk;
109
121
  if (!watchStderr || settled) return;
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
@@ -87,8 +87,9 @@ export const MODES = {
87
87
  1: { key: 'exchange', label: 'EXCHANGE', hint: 'read the project and coordinate · default' },
88
88
  2: { key: 'create', label: 'CREATE', hint: 'create files and images inside .pulse/out/' },
89
89
  3: { key: 'control', label: 'CONTROL', hint: 'modify the project itself · only this agent · needs the override' },
90
+ 4: { key: 'airlock', label: 'AIRLOCK', hint: 'run commands, push, deploy · what leaves the ship does not come back · override, twice' },
90
91
  };
91
- export const MODE_MAX = 3;
92
+ export const MODE_MAX = 4;
92
93
  export const normalizeMode = (value, fallback = 1) => {
93
94
  const mode = Number(value);
94
95
  return Number.isInteger(mode) && mode >= 0 && mode <= MODE_MAX ? mode : fallback;
@@ -110,8 +111,8 @@ export function resolveScopes(agentId, configured = {}) {
110
111
  }
111
112
  // One ceiling per agent: MAX MODE. Writing follows from it: an agent capped at #1 never
112
113
  // writes, one allowed to #2 or #3 does. A `write: false` from an older config reads as #1.
113
- const capableMax = scopes.write.capable ? 3 : 1;
114
- const legacyCap = configured.write === false ? 1 : 3;
114
+ const capableMax = scopes.write.capable ? 4 : 1;
115
+ const legacyCap = configured.write === false ? 1 : 4;
115
116
  scopes.maxMode = Math.min(normalizeMode(configured.maxMode, scopes.write.capable ? 2 : 1), legacyCap, capableMax);
116
117
  scopes.write.enabled = scopes.write.capable && scopes.maxMode >= 2;
117
118
  // One start per agent: DEFAULT MODE, #1 or #2, never above the ceiling. An older