@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.
- package/CHANGELOG.md +425 -0
- package/CONTRIBUTING.md +6 -1
- package/README.md +67 -183
- package/SECURITY.md +2 -1
- package/bin/madre.mjs +56 -13
- package/docs/INTERNALS.md +16 -0
- package/docs/REFERENCE.md +249 -0
- package/docs/SDK.md +121 -0
- package/docs/room.png +0 -0
- package/docs/sdk/hello-module.mjs +51 -0
- package/package.json +9 -1
- package/public/app.js +3880 -849
- package/public/es.js +2050 -0
- package/public/i18n.js +66 -0
- package/public/index.html +95 -14
- package/public/inquiry.js +220 -0
- package/public/resay.js +77 -0
- package/public/styles.css +626 -68
- package/public/troubleshooting.js +173 -51
- package/src/adapters/claude.mjs +13 -6
- package/src/adapters/codex.mjs +17 -13
- package/src/adapters/gemini.mjs +27 -16
- package/src/adapters/opencode.mjs +16 -12
- package/src/adapters/process.mjs +17 -5
- package/src/asking.mjs +128 -0
- package/src/auth-probe.mjs +58 -1
- package/src/capabilities.mjs +4 -3
- package/src/chats.mjs +193 -0
- package/src/checkpoint.mjs +1 -1
- package/src/cold.mjs +56 -0
- package/src/commands.mjs +31 -3
- package/src/conversation-context.mjs +35 -3
- package/src/credentials.mjs +145 -0
- package/src/dataset.mjs +56 -4
- package/src/distiller.mjs +12 -5
- package/src/event-store.mjs +14 -8
- package/src/exam.mjs +240 -0
- package/src/extensions.mjs +3 -2
- package/src/eyecat-watch.mjs +100 -0
- package/src/eyecat.mjs +169 -0
- package/src/i18n.mjs +47 -0
- package/src/image-studio.mjs +2 -0
- package/src/launch.mjs +61 -0
- package/src/lease.mjs +5 -3
- package/src/maturity.mjs +94 -0
- package/src/mcp/image-server.mjs +12 -1
- package/src/mcp/memory-server.mjs +1 -1
- package/src/memory.mjs +325 -17
- package/src/modules/ahp.mjs +9 -7
- package/src/modules/ash.mjs +36 -0
- package/src/modules/git-pulse.mjs +6 -4
- package/src/modules/helpers.mjs +31 -0
- package/src/modules/image-studio.mjs +9 -4
- package/src/modules/index.mjs +143 -5
- package/src/modules/ollama.mjs +66 -10
- package/src/modules/playwright.mjs +90 -0
- package/src/modules/ripley.mjs +5 -3
- package/src/modules/sdk.mjs +104 -2
- package/src/modules/updates.mjs +81 -0
- package/src/ollama.mjs +5 -2
- package/src/outbound.mjs +292 -0
- package/src/privacy.mjs +54 -7
- package/src/room/context.mjs +4 -4
- package/src/room/control.mjs +4 -4
- package/src/room/economy.mjs +161 -0
- package/src/room/prompt.mjs +118 -43
- package/src/room.mjs +465 -58
- package/src/runtime-detection.mjs +27 -8
- package/src/server.mjs +724 -68
- package/src/setup.mjs +1 -1
- package/src/updates.mjs +17 -2
- package/src/usage-sentinel.mjs +13 -8
- package/src/verdict.mjs +74 -0
- package/src/ashcode.mjs +0 -64
- package/src/modules/ashcode.mjs +0 -28
package/src/adapters/gemini.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
96
|
+
for (const server of [memoryServer, ...mcpServers].filter(Boolean)) {
|
|
94
97
|
config = {
|
|
95
98
|
...config,
|
|
96
|
-
mcp: { ...(config.mcp ?? {}), [
|
|
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
|
-
[`${
|
|
103
|
-
...Object.fromEntries(
|
|
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',
|
package/src/adapters/process.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/auth-probe.mjs
CHANGED
|
@@ -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
|
-
|
|
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/capabilities.mjs
CHANGED
|
@@ -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 =
|
|
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 ?
|
|
114
|
-
const legacyCap = configured.write === false ? 1 :
|
|
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
|