@jossuealcala/madre 0.4.0 → 0.5.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 +164 -0
- package/README.md +1 -1
- package/docs/REFERENCE.md +3 -2
- package/docs/SDK.md +19 -5
- package/docs/sdk/hello-module.mjs +8 -0
- package/package.json +1 -1
- package/public/app.js +414 -64
- package/public/es.js +246 -5
- package/public/index.html +4 -2
- package/public/styles.css +122 -14
- package/public/syntax.js +121 -0
- package/public/troubleshooting.js +100 -0
- package/src/adapters/codex.mjs +4 -1
- package/src/adapters/process.mjs +62 -15
- package/src/mcp/image-server.mjs +24 -2
- package/src/modules/image-studio.mjs +3 -2
- package/src/modules/index.mjs +23 -5
- package/src/modules/playwright.mjs +9 -5
- package/src/modules/sdk.mjs +15 -0
- package/src/outbound.mjs +7 -2
- package/src/room.mjs +12 -0
- package/src/runtime-detection.mjs +50 -0
- package/src/sentinel-errors.mjs +19 -1
- package/src/server.mjs +23 -12
- package/src/updates.mjs +9 -1
package/src/adapters/process.mjs
CHANGED
|
@@ -25,6 +25,36 @@ export function terminateProcessTree(child, { graceMs = 2000 } = {}) {
|
|
|
25
25
|
child.once('close', () => clearTimeout(escalation));
|
|
26
26
|
}
|
|
27
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
|
+
|
|
28
58
|
export function runReadonlyProcess({
|
|
29
59
|
executable,
|
|
30
60
|
args,
|
|
@@ -52,12 +82,23 @@ export function runReadonlyProcess({
|
|
|
52
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') })));
|
|
53
83
|
return;
|
|
54
84
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 }));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
61
102
|
let stdout = '';
|
|
62
103
|
let stderr = '';
|
|
63
104
|
let settled = false;
|
|
@@ -128,17 +169,23 @@ export function runReadonlyProcess({
|
|
|
128
169
|
finish(() => reject(verdict));
|
|
129
170
|
});
|
|
130
171
|
armIdle();
|
|
131
|
-
child.on('error', (error) => finish(() => {
|
|
132
|
-
|
|
133
|
-
if (error.code === 'ENOENT') {
|
|
134
|
-
error.message = `${label} could not start: ${error.message}. Check that the project folder ${cwd} exists and that ${executable} is still installed.`;
|
|
135
|
-
}
|
|
136
|
-
reject(error);
|
|
137
|
-
}));
|
|
138
|
-
child.on('close', (code) => finish(() => {
|
|
172
|
+
child.on('error', (error) => finish(() => reject(couldNotStart(error, { label, executable, cwd }))));
|
|
173
|
+
child.on('close', (code, closedBy) => finish(() => {
|
|
139
174
|
const response = parse(stdout);
|
|
140
175
|
if (code === 0 && response.text) return resolve(response);
|
|
141
|
-
|
|
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);
|
|
142
189
|
}));
|
|
143
190
|
});
|
|
144
191
|
}
|
package/src/mcp/image-server.mjs
CHANGED
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
// Environment:
|
|
10
10
|
// PULSE_IMAGE_OUT_DIR required, absolute lease directory
|
|
11
11
|
// PULSE_IMAGE_MODEL default gemini-2.5-flash-image
|
|
12
|
-
// GEMINI_API_KEY optional; otherwise the macOS keychain entry the
|
|
12
|
+
// GEMINI_API_KEY optional; otherwise ~/.gemini/.env, then the macOS keychain entry the
|
|
13
13
|
// Gemini CLI stores (service gemini-cli-api-key)
|
|
14
14
|
// PULSE_IMAGE_FAKE=1 write a 1×1 PNG without calling Google (tests)
|
|
15
15
|
|
|
16
16
|
import { execFile } from 'node:child_process';
|
|
17
|
-
import { mkdir, writeFile, realpath } from 'node:fs/promises';
|
|
17
|
+
import { mkdir, readFile, writeFile, realpath } from 'node:fs/promises';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
18
19
|
import { basename, extname, join, resolve, sep } from 'node:path';
|
|
19
20
|
import { promisify } from 'node:util';
|
|
20
21
|
import { OutboundLog } from '../outbound.mjs';
|
|
@@ -25,9 +26,30 @@ const SERVER_VERSION = '0.1.0';
|
|
|
25
26
|
const DEFAULT_MODEL = 'gemini-2.5-flash-image';
|
|
26
27
|
const FAKE_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
|
|
27
28
|
|
|
29
|
+
// Where a Gemini key can be, in the order the room should trust them: what this process was
|
|
30
|
+
// given, then the file the CLI reads on every run — which is also the file MADRE itself writes
|
|
31
|
+
// when a key is pasted into CONNECTIONS — and last the macOS keychain, which only exists when
|
|
32
|
+
// the CLI's own sign-in put it there. Leaving the file out meant MADRE saved a key in a place it
|
|
33
|
+
// then refused to look, and on Linux, where there is no keychain, that was the only place it
|
|
34
|
+
// could have been: Image Studio could never find a key there at all.
|
|
35
|
+
async function keyFromGeminiEnvFile(env) {
|
|
36
|
+
const home = env.GEMINI_CLI_HOME ?? homedir();
|
|
37
|
+
let text;
|
|
38
|
+
try { text = await readFile(join(home, '.gemini', '.env'), 'utf8'); } catch { return null; }
|
|
39
|
+
for (const line of text.split('\n')) {
|
|
40
|
+
const match = line.match(/^\s*(?:export\s+)?(GEMINI_API_KEY|GOOGLE_API_KEY)\s*=\s*(.*)$/);
|
|
41
|
+
if (!match) continue;
|
|
42
|
+
const value = match[2].trim().replace(/^(['"])(.*)\1$/, '$2').trim();
|
|
43
|
+
if (value) return value;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
28
48
|
export async function resolveGeminiKey(env = process.env) {
|
|
29
49
|
if (env.GEMINI_API_KEY) return env.GEMINI_API_KEY;
|
|
30
50
|
if (env.GOOGLE_API_KEY) return env.GOOGLE_API_KEY;
|
|
51
|
+
const fromFile = await keyFromGeminiEnvFile(env);
|
|
52
|
+
if (fromFile) return fromFile;
|
|
31
53
|
if (process.platform !== 'darwin') return null;
|
|
32
54
|
// The keychain occasionally answers empty under concurrent reads; one retry.
|
|
33
55
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
@@ -13,8 +13,9 @@ export default defineModule({
|
|
|
13
13
|
vendor: 'MADRE · Gemini API',
|
|
14
14
|
version: '1.0.0',
|
|
15
15
|
summary: 'Gives Gemini CLI, Claude Code and OpenCode an image tool, through a MADRE-owned MCP server on the Gemini image models and your own key and credits.',
|
|
16
|
-
creates: ['nothing in the project \u00b7 images land in the lease folder', 'an entry in ~/.pulse/config.json', 'an MCP server per turn, started and stopped by the room'
|
|
17
|
-
|
|
16
|
+
creates: ['nothing in the project \u00b7 images land in the lease folder', 'an entry in ~/.pulse/config.json', 'an MCP server per turn, started and stopped by the room'],
|
|
17
|
+
// Not something it writes: a condition for it to attach at all, which is what REQUIRES is for.
|
|
18
|
+
requires: ['a Gemini API key with credits (the one the Gemini CLI stores, or GEMINI_API_KEY)', 'attached only inside a creation lease with the image scope on'],
|
|
18
19
|
models: MODELS,
|
|
19
20
|
settings: { enabled: false, model: MODELS[0] },
|
|
20
21
|
card: 'image-studio',
|
package/src/modules/index.mjs
CHANGED
|
@@ -79,8 +79,20 @@ export async function verifyModuleText({ text, name = null, replace = true }) {
|
|
|
79
79
|
try {
|
|
80
80
|
const probe = join(scratch, name && /\.m?js$/.test(name) ? basename(name) : 'candidate.mjs');
|
|
81
81
|
await writeFile(probe, text);
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
let module;
|
|
83
|
+
try {
|
|
84
|
+
module = checkExternal(await importModuleFile(probe), { replace });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
// The one mistake that looks right and never works: MADRE's own modules import the SDK by
|
|
87
|
+
// relative path because they live inside the package, and anyone — an agent most of all —
|
|
88
|
+
// reading one of them copies that line. Here the file is alone in a scratch folder, so the
|
|
89
|
+
// import cannot resolve and node says only that it cannot find a file. Say what to do.
|
|
90
|
+
if (error?.code === 'ERR_MODULE_NOT_FOUND' || /Cannot find (module|package)/.test(error?.message ?? '')) {
|
|
91
|
+
throw new Error(`${error.message.split('\n')[0]} — a module imports nothing: MADRE loads it from a folder of its own to check it, where no package resolves. Export a plain object, or a function ({ defineModule }) => defineModule({ … }) and MADRE hands you the SDK.`);
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
return { id: module.id, name: module.name, vendor: module.vendor, version: module.version ?? null, summary: module.summary ?? '', updates: module.updates ?? null, unknown: module.unknown ?? [] };
|
|
84
96
|
} finally { await rm(scratch, { recursive: true, force: true, maxRetries: 6, retryDelay: 60 }); }
|
|
85
97
|
}
|
|
86
98
|
|
|
@@ -95,7 +107,7 @@ export async function installModuleText({ text, name = null, scope = 'user', sta
|
|
|
95
107
|
// Where it came from, so the same place can be asked for a newer one later.
|
|
96
108
|
if (source) await writeFile(join(dir, `${module.id}.source.json`), JSON.stringify({ ...source, at: new Date().toISOString(), version: module.version ?? null }, null, 2)).catch(() => {});
|
|
97
109
|
await loadExternalModules({ stateRoot, projectRoot });
|
|
98
|
-
return { id: module.id, name: module.name, version: module.version ?? null, file: target, origin: scope === 'project' ? 'project' : 'user', replaced };
|
|
110
|
+
return { id: module.id, name: module.name, version: module.version ?? null, file: target, origin: scope === 'project' ? 'project' : 'user', replaced, unknown: module.unknown ?? [] };
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
// The same thing from a file already on this computer. `source` must be inside the project or the
|
|
@@ -122,9 +134,15 @@ export async function removeExternalModule({ id, stateRoot, projectRoot }) {
|
|
|
122
134
|
const module = MODULES.find((known) => known.id === id);
|
|
123
135
|
if (!module) throw new Error(`No module "${id}".`);
|
|
124
136
|
if (!module.external) throw new Error(`${module.name} ships with MADRE and cannot be removed; switch it off instead.`);
|
|
125
|
-
|
|
137
|
+
// The origin record goes first: if it cannot be removed, the executable module file remains
|
|
138
|
+
// installed rather than leaving a live registry entry whose file has already disappeared.
|
|
139
|
+
const files = [...new Set([module.file.replace(/\.(?:mjs|js)$/, '.source.json'), module.file])];
|
|
140
|
+
const removedFiles = [];
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
try { await unlink(file); removedFiles.push(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
143
|
+
}
|
|
126
144
|
await loadExternalModules({ stateRoot, projectRoot });
|
|
127
|
-
return { id, name: module.name, file: module.file };
|
|
145
|
+
return { id, name: module.name, file: module.file, removedFiles };
|
|
128
146
|
}
|
|
129
147
|
|
|
130
148
|
export const moduleById = (id) => MODULES.find((module) => module.id === id) ?? null;
|
|
@@ -29,7 +29,10 @@ export function playwrightServerFor({ port, outputDir, browser = 'chromium', hea
|
|
|
29
29
|
return {
|
|
30
30
|
name: PLAYWRIGHT_SERVER_NAME,
|
|
31
31
|
command: 'npx',
|
|
32
|
-
|
|
32
|
+
// `--` or npm eats them: without the separator npm takes --headless, --isolated and the
|
|
33
|
+
// rest as its own config, hands the server only their bare values, and the server exits with
|
|
34
|
+
// "too many arguments" before it speaks a word of MCP. The agent sees CONNECTION_CLOSED.
|
|
35
|
+
args: ['--no', '@playwright/mcp', '--', ...(headless ? ['--headless'] : []), '--isolated', '--browser', browser, '--allowed-origins', `http://127.0.0.1:${port};http://localhost:${port}`, '--blocked-origins', '*', '--output-dir', outputDir, '--no-sandbox'],
|
|
33
36
|
env: {},
|
|
34
37
|
tools: PLAYWRIGHT_TOOLS,
|
|
35
38
|
brief: `a headless browser that reaches only this MADRE at http://127.0.0.1:${port}. Open a project file rendered by RIPLEY at http://127.0.0.1:${port}/preview/project/<path>, click, read the console and network, take screenshots (they land in ${outputDir}). Nothing else on the network is reachable through it.`,
|
|
@@ -56,7 +59,7 @@ export default defineModule({
|
|
|
56
59
|
runs: [{ name: PLAYWRIGHT_PACKAGE, version }],
|
|
57
60
|
settings: { browser: ctx.settings.browser ?? 'chromium', headless: ctx.settings.headless !== false },
|
|
58
61
|
status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? (version ? `${t('on')} · ${ctx.settings.browser}` : t('on · the browser server is not installed')) : version ? t('off') : t('off · the browser server is not installed') },
|
|
59
|
-
preflight: version ? { ok: true, problems: [] } : { ok: false, problems: ['Install the browser server first: npm install -g @playwright/mcp
|
|
62
|
+
preflight: version ? { ok: true, problems: [] } : { ok: false, problems: ['Install the browser server first: npm install -g @playwright/mcp, then its browser: npx @playwright/mcp install-browser chrome-for-testing'] },
|
|
60
63
|
install: { display: ctx.settings.enabled ? 'disable PLAYWRIGHT' : 'enable PLAYWRIGHT (config.json)', platforms: ['codex', 'claude', 'gemini', 'opencode'] },
|
|
61
64
|
};
|
|
62
65
|
},
|
|
@@ -80,11 +83,12 @@ export default defineModule({
|
|
|
80
83
|
},
|
|
81
84
|
conditions: [{
|
|
82
85
|
id: 'playwright-missing',
|
|
86
|
+
code: 'MU-054',
|
|
83
87
|
severity: 'informational',
|
|
84
88
|
title: 'PLAYWRIGHT: the browser server is not installed',
|
|
85
89
|
match: /@playwright\/mcp|playwright.*not (found|installed)|browser server/i,
|
|
86
|
-
diagnosis: 'The PLAYWRIGHT module runs @playwright/mcp per turn.
|
|
87
|
-
remedy: 'Install
|
|
88
|
-
fixes: { darwin: ['npm install -g @playwright/mcp', 'npx playwright install
|
|
90
|
+
diagnosis: 'The PLAYWRIGHT module runs @playwright/mcp per turn. Two different things have to be on this computer and only one of them is the package: the server, and the browser build that this version of it expects. A server that starts without its browser answers every tool call with "Browser … is not installed", and one that cannot start at all reaches the agent as CONNECTION_CLOSED.',
|
|
91
|
+
remedy: 'Install the server once, globally, then its browser — the browser is downloaded by the server itself, not by the playwright CLI — and RECHECK in MODULES.',
|
|
92
|
+
fixes: { darwin: ['npm install -g @playwright/mcp', 'npx @playwright/mcp install-browser chrome-for-testing'], linux: ['npm install -g @playwright/mcp', 'npx @playwright/mcp install-browser chrome-for-testing'] },
|
|
89
93
|
}],
|
|
90
94
|
});
|
package/src/modules/sdk.mjs
CHANGED
|
@@ -51,6 +51,19 @@ export function dependencies(runs) {
|
|
|
51
51
|
return list.filter((dep) => dep && dep.name).map((dep) => ({ name: String(dep.name), version: dep.version ?? null, target: dep.target ?? null }));
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// Every field this contract answers to. Thirty-four of them and all but two optional, which is
|
|
55
|
+
// generous until you misspell one: `sumary` used to install without a word and leave a card with
|
|
56
|
+
// an empty summary. A key MADRE does not know is not fatal — a module written for a newer MADRE
|
|
57
|
+
// may carry fields this one has not learned yet — so it is said out loud instead of refused.
|
|
58
|
+
export const MODULE_FIELDS = new Set([
|
|
59
|
+
'id', 'kind', 'name', 'vendor', 'package', 'version', 'tracks', 'updates', 'summary', 'creates',
|
|
60
|
+
'requires', 'models', 'commands', 'card', 'configKey', 'settings', 'controls', 'routes',
|
|
61
|
+
'conditions', 'slash', 'status', 'preflight', 'detect', 'confirm', 'installCommand',
|
|
62
|
+
'updatePlan', 'toolsForTurn', 'toggle', 'onToggle', 'onSettings', 'onEvent', 'toggledEvent',
|
|
63
|
+
'toggledBody', 'describe',
|
|
64
|
+
]);
|
|
65
|
+
export const unknownFields = (spec) => Object.keys(spec ?? {}).filter((key) => !MODULE_FIELDS.has(key));
|
|
66
|
+
|
|
54
67
|
export function defineModule(spec) {
|
|
55
68
|
if (!spec?.id || !/^[a-z][a-z0-9-]*$/.test(spec.id)) throw new Error(`Module id must be kebab-case: ${spec?.id}`);
|
|
56
69
|
if (!spec.name) throw new Error(`Module ${spec.id} needs a name.`);
|
|
@@ -59,6 +72,8 @@ export function defineModule(spec) {
|
|
|
59
72
|
const defaults = { ...(kind === 'builtin' ? { enabled: false } : {}), ...(spec.settings ?? {}) };
|
|
60
73
|
const base = {
|
|
61
74
|
id: spec.id, kind, name: spec.name, vendor: spec.vendor ?? 'MADRE', package: spec.package ?? null, version: spec.version ?? null,
|
|
75
|
+
// Keys this contract does not answer to. Carried so whoever installs the module is told.
|
|
76
|
+
unknown: unknownFields(spec),
|
|
62
77
|
// What this module's version follows, when it is not its own: { name, npm } or { name, github }.
|
|
63
78
|
// MADRE reads the version from there and looks for a newer one on its own, once a day.
|
|
64
79
|
tracks: spec.tracks ? { name: spec.tracks.name ?? spec.tracks.npm ?? spec.tracks.github ?? null, npm: spec.tracks.npm ?? null, github: spec.tracks.github ?? null } : null,
|
package/src/outbound.mjs
CHANGED
|
@@ -30,7 +30,12 @@ const LOCAL = new Set(['127.0.0.1', 'localhost', '::1', '0.0.0.0', '[::1]']);
|
|
|
30
30
|
|
|
31
31
|
// Where an unknown condition goes when somebody presses SEND. It lives here because this is the
|
|
32
32
|
// file that has to name every address MADRE can reach; the room reads it from here.
|
|
33
|
-
|
|
33
|
+
// The collector, under the product's own name. The address a human reads in the outbound log is
|
|
34
|
+
// part of what they are being asked to trust, and a workers.dev subdomain with an account name in
|
|
35
|
+
// it reads like somebody's scratch endpoint. The old address must keep answering: every room on
|
|
36
|
+
// 0.4.0 and earlier carries it compiled in, and turning it off would stop their reports silently.
|
|
37
|
+
export const DEFAULT_REPORT_URL = 'https://madre.run/v1/reports';
|
|
38
|
+
export const LEGACY_REPORT_URL = 'https://madre-reports.jossue-alcala-o.workers.dev/v1/reports';
|
|
34
39
|
|
|
35
40
|
// The declaration. `what` is the honest sentence: what a request carries, not what it is called.
|
|
36
41
|
export const DESTINATIONS = [
|
|
@@ -264,7 +269,7 @@ export class OutboundLog {
|
|
|
264
269
|
export function outboundView({ log = null, state = {}, agents = [] } = {}) {
|
|
265
270
|
const counts = log?.counts() ?? {};
|
|
266
271
|
const vendors = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: 'the provider OpenCode is signed in to' };
|
|
267
|
-
const crew = agents.filter((agent) => agent.detected && !agent.local).map((agent) => `@${agent.id} → ${vendors[agent.id] ?? 'its own provider'}`);
|
|
272
|
+
const crew = agents.filter((agent) => agent.detected && !agent.local).map((agent) => `@${agent.id} → ${agent.route?.destination ?? vendors[agent.id] ?? 'its own provider'}`);
|
|
268
273
|
const destinations = DESTINATIONS.map((one) => ({
|
|
269
274
|
id: one.id,
|
|
270
275
|
to: one.id === 'crew' && crew.length ? crew.join(' · ') : one.to,
|
package/src/room.mjs
CHANGED
|
@@ -945,6 +945,18 @@ export class Room {
|
|
|
945
945
|
await this.#emit('message.failed', { messageId, target: targetId, planId, error: `${agent.label} was detected, but its MADRE adapter is not enabled yet.` });
|
|
946
946
|
return null;
|
|
947
947
|
}
|
|
948
|
+
if (agent.route?.custom && !agent.route.allowed) {
|
|
949
|
+
const destination = agent.route.host ?? 'an unrecognised host';
|
|
950
|
+
const source = agent.route.source ?? 'a custom endpoint setting';
|
|
951
|
+
const official = agent.id === 'codex' ? 'OpenAI' : agent.id === 'claude' ? 'Anthropic' : 'the agent\'s official service';
|
|
952
|
+
await this.#emit('message.failed', {
|
|
953
|
+
messageId,
|
|
954
|
+
target: targetId,
|
|
955
|
+
planId,
|
|
956
|
+
error: `MADRE blocked @${agent.id} before sending the briefing: ${source} points to ${destination}, not ${official}. Remove that override to use the official service, or set PULSE_ALLOW_CUSTOM_AGENT_ENDPOINTS=${agent.id} only if you trust that endpoint.`,
|
|
957
|
+
});
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
948
960
|
|
|
949
961
|
const priorEvents = await this.#store.readAll();
|
|
950
962
|
// @madre never reads its own canned replies back: a small model would echo them.
|
|
@@ -38,6 +38,55 @@ const definitions = [
|
|
|
38
38
|
},
|
|
39
39
|
];
|
|
40
40
|
|
|
41
|
+
const OFFICIAL_DESTINATIONS = {
|
|
42
|
+
codex: { label: 'OpenAI', hosts: ['api.openai.com'], variable: 'OPENAI_BASE_URL' },
|
|
43
|
+
claude: { label: 'Anthropic', hosts: ['api.anthropic.com'], variable: 'ANTHROPIC_BASE_URL' },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const enabled = (value) => /^(1|true|yes)$/i.test(String(value ?? '').trim());
|
|
47
|
+
|
|
48
|
+
function customRoutesAllowed(agentId, env) {
|
|
49
|
+
const raw = String(env.PULSE_ALLOW_CUSTOM_AGENT_ENDPOINTS ?? '').trim().toLowerCase();
|
|
50
|
+
if (/^(1|true|all)$/.test(raw)) return true;
|
|
51
|
+
return raw.split(',').map((item) => item.trim()).includes(agentId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function routeHost(value) {
|
|
55
|
+
try { return new URL(value).hostname.toLowerCase(); } catch { return null; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A CLI owns its network connection, but MADRE owns the document handed to that CLI. Name the
|
|
59
|
+
// actual destination without ever returning a credential, path or query string. Unknown base URLs
|
|
60
|
+
// are held at the door until the human explicitly trusts that agent's endpoint.
|
|
61
|
+
export function agentRouteFromEnvironment(agentId, env = process.env) {
|
|
62
|
+
if (agentId === 'claude' && enabled(env.CLAUDE_CODE_USE_BEDROCK)) {
|
|
63
|
+
return { kind: 'provider', destination: 'Amazon Bedrock', source: 'CLAUDE_CODE_USE_BEDROCK', host: null, custom: false, allowed: true };
|
|
64
|
+
}
|
|
65
|
+
if (agentId === 'claude' && enabled(env.CLAUDE_CODE_USE_VERTEX)) {
|
|
66
|
+
return { kind: 'provider', destination: 'Google Vertex AI', source: 'CLAUDE_CODE_USE_VERTEX', host: null, custom: false, allowed: true };
|
|
67
|
+
}
|
|
68
|
+
if (agentId === 'claude' && enabled(env.CLAUDE_CODE_USE_FOUNDRY)) {
|
|
69
|
+
return { kind: 'provider', destination: 'Microsoft Foundry', source: 'CLAUDE_CODE_USE_FOUNDRY', host: null, custom: false, allowed: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const official = OFFICIAL_DESTINATIONS[agentId];
|
|
73
|
+
if (!official) return null;
|
|
74
|
+
const raw = String(env[official.variable] ?? '').trim();
|
|
75
|
+
if (!raw) return { kind: 'official', destination: official.label, source: null, host: null, custom: false, allowed: true };
|
|
76
|
+
const host = routeHost(raw);
|
|
77
|
+
const isOfficial = Boolean(host && official.hosts.some((candidate) => host === candidate || host.endsWith(`.${candidate}`)));
|
|
78
|
+
if (isOfficial) return { kind: 'official', destination: official.label, source: official.variable, host, custom: false, allowed: true };
|
|
79
|
+
const allowed = customRoutesAllowed(agentId, env);
|
|
80
|
+
return {
|
|
81
|
+
kind: 'custom',
|
|
82
|
+
destination: host ? `custom endpoint · ${host}` : 'custom endpoint',
|
|
83
|
+
source: official.variable,
|
|
84
|
+
host,
|
|
85
|
+
custom: true,
|
|
86
|
+
allowed,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
41
90
|
async function executable(path) {
|
|
42
91
|
try {
|
|
43
92
|
await access(path, constants.X_OK);
|
|
@@ -99,6 +148,7 @@ export async function detectAgents({ env = process.env } = {}) {
|
|
|
99
148
|
adapter: definition.adapter ?? null,
|
|
100
149
|
path,
|
|
101
150
|
version: path ? await readVersion(path, definition.versionArgs) : null,
|
|
151
|
+
route: agentRouteFromEnvironment(definition.id, env),
|
|
102
152
|
};
|
|
103
153
|
}));
|
|
104
154
|
}
|
package/src/sentinel-errors.mjs
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import { homedir, platform, arch, release } from 'node:os';
|
|
12
12
|
import { diagnose } from '../public/troubleshooting.js';
|
|
13
|
+
import { language } from './i18n.mjs';
|
|
14
|
+
|
|
15
|
+
// The product's own house. The collector lives under it too, so the line a human reads in the
|
|
16
|
+
// outbound log names the product instead of a personal endpoint on someone's cloud account.
|
|
17
|
+
export const HOME_SITE = 'https://madre.run';
|
|
18
|
+
export const REPORT_FORM = { es: `${HOME_SITE}/reportar`, en: `${HOME_SITE}/report` };
|
|
13
19
|
|
|
14
20
|
export const REPORT_WINDOW_MS = 24 * 3600 * 1000;
|
|
15
21
|
const MAX_ERROR_CHARS = 4000;
|
|
@@ -71,7 +77,7 @@ export class ErrorSentinel {
|
|
|
71
77
|
this.#save = save;
|
|
72
78
|
}
|
|
73
79
|
|
|
74
|
-
settings() { return { ...this.#settings, canSend: Boolean(this.#settings.reportUrl), repo: repoFromPackage(this.#pkg) }; }
|
|
80
|
+
settings() { return { ...this.#settings, canSend: Boolean(this.#settings.reportUrl), repo: repoFromPackage(this.#pkg), home: HOME_SITE }; }
|
|
75
81
|
async setSettings(patch = {}) {
|
|
76
82
|
if (typeof patch.autoReport === 'boolean') this.#settings.autoReport = patch.autoReport;
|
|
77
83
|
if (typeof patch.reportUrl === 'string') this.#settings.reportUrl = patch.reportUrl.trim();
|
|
@@ -174,6 +180,18 @@ export class ErrorSentinel {
|
|
|
174
180
|
return `https://github.com/${repo}/issues/new?${new URLSearchParams({ title, body, labels: 'sentinel' })}`;
|
|
175
181
|
}
|
|
176
182
|
|
|
183
|
+
// Where a human goes to say something. GitHub is the better home for a report — it is
|
|
184
|
+
// searchable, it deduplicates, and the answer helps whoever hits it next — but it asks for an
|
|
185
|
+
// account first, and somebody who arrived from a post in Spanish will not make one to say "it
|
|
186
|
+
// does not detect Codex". They get the form; the other road stays one click away, both ways.
|
|
187
|
+
formUrl({ about = '' } = {}) {
|
|
188
|
+
const base = REPORT_FORM[language()] ?? REPORT_FORM.en;
|
|
189
|
+
const env = this.environment();
|
|
190
|
+
const query = new URLSearchParams({ v: env.madre, node: env.node, os: env.platform });
|
|
191
|
+
if (about) query.set('about', about.slice(0, 120));
|
|
192
|
+
return `${base}?${query}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
177
195
|
feedbackUrl({ about = '' } = {}) {
|
|
178
196
|
const repo = repoFromPackage(this.#pkg);
|
|
179
197
|
if (!repo) return null;
|
package/src/server.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { moduleById, describeModules, findModuleRoute, toolsForTurn as modulesTo
|
|
|
18
18
|
import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
|
|
19
19
|
import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
|
|
20
20
|
import { OutboundLog, outboundView, DEFAULT_REPORT_URL } from './outbound.mjs';
|
|
21
|
-
import { setLanguage as setRoomLanguage, t } from './i18n.mjs';
|
|
21
|
+
import { language as roomLanguage, setLanguage as setRoomLanguage, t } from './i18n.mjs';
|
|
22
22
|
|
|
23
23
|
const PACKAGE = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8').catch(() => '{}'));
|
|
24
24
|
let crashHandlersInstalled = false;
|
|
@@ -47,6 +47,9 @@ import { applyKey, keyPlanFor } from './credentials.mjs';
|
|
|
47
47
|
import { loadConfig as readConfig, updateConfig } from './config.mjs';
|
|
48
48
|
import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
|
|
49
49
|
import { checkForUpdate, detectInstall, updateCommand, releaseUrl, applyCommand } from './updates.mjs';
|
|
50
|
+
|
|
51
|
+
// Where a person reads the module contract, in the language the room is speaking.
|
|
52
|
+
const SDK_PAGE = { es: 'https://madre.run/sdk/', en: 'https://madre.run/en/sdk/' };
|
|
50
53
|
import { moduleUpdate } from './modules/updates.mjs';
|
|
51
54
|
import { adoptStrays, chatIndex, chatLedger, createChat, deleteChat, isChatId, listChats, openChat as openChatIndex, projectFloor, renameChat, touchChat, MAIN_CHAT } from './chats.mjs';
|
|
52
55
|
import { spawn } from 'node:child_process';
|
|
@@ -193,7 +196,7 @@ export async function createPulseServer({
|
|
|
193
196
|
const install = detectInstall({ projectRoot: canonicalProjectRoot });
|
|
194
197
|
async function versionView({ force = false } = {}) {
|
|
195
198
|
const check = await checkForUpdate({ name: PACKAGE.name, current: PACKAGE.version, cacheFile: join(root, 'updates.json'), fetchImpl: reportFetch, enabled: updatesEnabled(), force });
|
|
196
|
-
return { project: canonicalProjectRoot, ...check, name: PACKAGE.name, install, command: updateCommand(install, PACKAGE.name, check.latest ?? 'latest'), release: check.latest ? releaseUrl(PACKAGE.repository, check.latest) : null, envWins: process.env.PULSE_UPDATE_CHECK !== undefined };
|
|
199
|
+
return { project: canonicalProjectRoot, ...check, name: PACKAGE.name, install, command: updateCommand(install, PACKAGE.name, check.latest ?? 'latest'), release: check.latest ? releaseUrl(PACKAGE.repository, check.latest, roomLanguage()) : null, envWins: process.env.PULSE_UPDATE_CHECK !== undefined };
|
|
197
200
|
}
|
|
198
201
|
// Keeping the day's cache warm. MODULES is served from what is already on disk, so the looking
|
|
199
202
|
// happens after the screen is answered, never in front of it: at most once an hour per room,
|
|
@@ -250,7 +253,7 @@ export async function createPulseServer({
|
|
|
250
253
|
else if (!fifth.ready && index >= 0) { agents.splice(index, 1); changed = true; }
|
|
251
254
|
if (room) {
|
|
252
255
|
room.setInvoker(MADRE_ADAPTER, fifth.ready ? madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }) : null);
|
|
253
|
-
if (changed) await room.record('agents.updated', { agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local) })), removed: fifth.ready ? [] : [MADRE_AGENT_ID], reason: fifth.ready ? `@madre is in the room · ${fifth.version}` : '@madre left the room: Ollama has no chat model running' });
|
|
256
|
+
if (changed) await room.record('agents.updated', { agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local), route: agent.route ?? null })), removed: fifth.ready ? [] : [MADRE_AGENT_ID], reason: fifth.ready ? `@madre is in the room · ${fifth.version}` : '@madre left the room: Ollama has no chat model running' });
|
|
254
257
|
// That a local model is running is not the same as it being of use on this project, and
|
|
255
258
|
// the second thing is the one a person needs told. Said once per model, in the room, with
|
|
256
259
|
// the one button that settles it — the crew line above fires only when the crew CHANGES,
|
|
@@ -465,7 +468,7 @@ export async function createPulseServer({
|
|
|
465
468
|
const index = agents.findIndex((item) => item.id === next.id);
|
|
466
469
|
if (index === -1) { agents.push(next); changed = true; continue; }
|
|
467
470
|
const before = agents[index];
|
|
468
|
-
if (before.detected !== next.detected || before.ready !== next.ready || before.version !== next.version || before.path !== next.path) {
|
|
471
|
+
if (before.detected !== next.detected || before.ready !== next.ready || before.version !== next.version || before.path !== next.path || JSON.stringify(before.route ?? null) !== JSON.stringify(next.route ?? null)) {
|
|
469
472
|
agents[index] = { ...before, ...next };
|
|
470
473
|
changed = true;
|
|
471
474
|
}
|
|
@@ -473,7 +476,7 @@ export async function createPulseServer({
|
|
|
473
476
|
await refreshSessions();
|
|
474
477
|
if (changed && announce) {
|
|
475
478
|
await room.record('agents.updated', {
|
|
476
|
-
agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local) })),
|
|
479
|
+
agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local), route: agent.route ?? null })),
|
|
477
480
|
removed: [],
|
|
478
481
|
reason: 'the room looked again for the agents on this computer',
|
|
479
482
|
});
|
|
@@ -846,7 +849,7 @@ export async function createPulseServer({
|
|
|
846
849
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
847
850
|
return response.end(html);
|
|
848
851
|
}
|
|
849
|
-
if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js'].includes(url.pathname)) {
|
|
852
|
+
if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js'].includes(url.pathname)) {
|
|
850
853
|
const js = await readFile(join(publicDirectory, url.pathname.slice(1)));
|
|
851
854
|
response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
|
|
852
855
|
return response.end(js);
|
|
@@ -860,8 +863,11 @@ export async function createPulseServer({
|
|
|
860
863
|
return sendJson(response, 200, {
|
|
861
864
|
projectRoot,
|
|
862
865
|
platform: process.platform,
|
|
863
|
-
// What language this machine set MADRE to, for a browser that has never chosen.
|
|
864
|
-
|
|
866
|
+
// What language this machine set MADRE to, for a browser that has never chosen. It is
|
|
867
|
+
// the language the room is SPEAKING, not the one written in the file: PULSE_LANGUAGE
|
|
868
|
+
// wins over the config here as it does everywhere else, and reading the file again
|
|
869
|
+
// would hand the page a different answer from the one the server itself is using.
|
|
870
|
+
language: roomLanguage(),
|
|
865
871
|
// Which conversation this page is looking at, and the others it could open.
|
|
866
872
|
chats: await listChats(roomDir),
|
|
867
873
|
agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })),
|
|
@@ -980,7 +986,7 @@ export async function createPulseServer({
|
|
|
980
986
|
const designationOk = (given) => typeof given === 'string' && given.trim().toLowerCase() === basename(canonicalProjectRoot).toLowerCase();
|
|
981
987
|
// Sentinel: reports, settings, the manual road (a prefilled issue) and the automatic one.
|
|
982
988
|
if (request.method === 'GET' && url.pathname === '/api/sentinel') {
|
|
983
|
-
return sendJson(response, 200, { reports: sentinel.reports(), settings: sentinel.settings(), environment: sentinel.environment(), feedbackUrl: sentinel.feedbackUrl() });
|
|
989
|
+
return sendJson(response, 200, { reports: sentinel.reports(), settings: sentinel.settings(), environment: sentinel.environment(), feedbackUrl: sentinel.feedbackUrl(), formUrl: sentinel.formUrl() });
|
|
984
990
|
}
|
|
985
991
|
if (request.method === 'POST' && url.pathname === '/api/sentinel/settings') {
|
|
986
992
|
const patch = await body(request).catch(() => ({}));
|
|
@@ -991,8 +997,13 @@ export async function createPulseServer({
|
|
|
991
997
|
const sentinelMatch = url.pathname.match(/^\/api\/sentinel\/([a-f0-9]{10})\/(issue|send)$/);
|
|
992
998
|
if (sentinelMatch && request.method === (sentinelMatch[2] === 'issue' ? 'GET' : 'POST')) {
|
|
993
999
|
if (sentinelMatch[2] === 'issue') {
|
|
994
|
-
|
|
995
|
-
|
|
1000
|
+
// Both roads, always: the issue for whoever has an account, the form for whoever does
|
|
1001
|
+
// not. Nobody should hit a login wall while holding a bug report. A report that does
|
|
1002
|
+
// not exist still gets neither: the form does not need the report to be built, so it
|
|
1003
|
+
// would otherwise hand out a link for nothing.
|
|
1004
|
+
const known = sentinel.reports().some((one) => one.id === sentinelMatch[1]);
|
|
1005
|
+
const issue = known ? sentinel.issueUrl(sentinelMatch[1]) : null;
|
|
1006
|
+
return known ? sendJson(response, 200, { url: issue, form: sentinel.formUrl({ about: sentinelMatch[1] }) }) : sendJson(response, 404, { error: t('No such report, or no repository to file it in.') });
|
|
996
1007
|
}
|
|
997
1008
|
const outcome = await sentinel.send(sentinelMatch[1]);
|
|
998
1009
|
return sendJson(response, outcome.ok ? 200 : (outcome.status === 404 || outcome.status === 412 ? outcome.status : 502), outcome);
|
|
@@ -1466,7 +1477,7 @@ export async function createPulseServer({
|
|
|
1466
1477
|
if (request.method === 'GET' && url.pathname === '/api/extensions') {
|
|
1467
1478
|
const extensions = await describeModules(await moduleContext());
|
|
1468
1479
|
void warmModuleUpdates(extensions);
|
|
1469
|
-
return sendJson(response, 200, { installing, extensions, failures: loadFailures, folders: moduleFolders({ stateRoot: root, projectRoot: canonicalProjectRoot }), sdk:
|
|
1480
|
+
return sendJson(response, 200, { installing, extensions, failures: loadFailures, folders: moduleFolders({ stateRoot: root, projectRoot: canonicalProjectRoot }), sdk: SDK_PAGE[roomLanguage()] ?? SDK_PAGE.es });
|
|
1470
1481
|
}
|
|
1471
1482
|
// The human installs a module file an agent wrote (or they did): checked first, then copied into the chosen folder.
|
|
1472
1483
|
if (request.method === 'POST' && url.pathname === '/api/extensions/install-file') {
|
package/src/updates.mjs
CHANGED
|
@@ -51,7 +51,15 @@ export function applyCommand({ install, name, version, port, projectRoot }) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
// Where a person reads what a version brought. The product's own house, in the language the room
|
|
55
|
+
// is speaking — a release page on a code host is written for people who already work there, and
|
|
56
|
+
// whoever pressed this button only wanted to know what changed. The repository stays the fallback
|
|
57
|
+
// for a build with no house to point at.
|
|
58
|
+
export const CHANGES_PAGE = { es: 'https://madre.run/cambios/', en: 'https://madre.run/en/changelog/' };
|
|
59
|
+
|
|
60
|
+
export function releaseUrl(repository, version, language = 'es') {
|
|
61
|
+
const page = CHANGES_PAGE[language] ?? CHANGES_PAGE.es;
|
|
62
|
+
if (page) return version ? `${page}#v${version}` : page;
|
|
55
63
|
const url = String(repository?.url ?? repository ?? '').replace(/^git\+/, '').replace(/\.git$/, '');
|
|
56
64
|
return url ? `${url}/releases/tag/v${version}` : null;
|
|
57
65
|
}
|