@jossuealcala/madre 1.0.1 → 1.0.2
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/ADDITIONAL-TERMS.md +93 -0
- package/CHANGELOG.en.md +1945 -0
- package/CHANGELOG.md +34 -1
- package/CONTRIBUTING.md +1 -1
- package/LICENSE +661 -202
- package/NOTICE +26 -14
- package/README.en.md +245 -0
- package/README.md +19 -10
- package/TRADEMARKS.en.md +50 -0
- package/TRADEMARKS.md +33 -16
- package/bin/madre.mjs +2 -0
- package/docs/INTERNALS.md +6 -0
- package/docs/REFERENCE.md +11 -2
- package/docs/en/INTERNALS.md +103 -0
- package/docs/en/REFERENCE.md +339 -0
- package/docs/en/SDK.md +306 -0
- package/docs/en/training/README.md +59 -0
- package/docs/training/README.md +20 -8
- package/docs/training/train.sh +2 -1
- package/package.json +11 -3
- package/public/abduction.js +2 -0
- package/public/app.js +1549 -409
- package/public/attribution.js +71 -0
- package/public/brands.js +3 -1
- package/public/editor.js +2 -0
- package/public/es.js +620 -400
- package/public/i18n.js +25 -1
- package/public/index.html +13 -10
- package/public/inquiry.js +2 -0
- package/public/loader.js +2 -0
- package/public/macroverso.js +2 -0
- package/public/nostromo-engine.js +2 -0
- package/public/resay.js +6 -0
- package/public/review-ask.js +2 -0
- package/public/routing.js +23 -1
- package/public/salas-mac.css +21 -4
- package/public/salas-mac.html +5 -1
- package/public/salas-mac.js +53 -28
- package/public/salas.css +2 -0
- package/public/salas.html +1 -0
- package/public/salas.js +4 -2
- package/public/styles.css +957 -18
- package/public/syntax.js +2 -0
- package/public/touch.js +2 -0
- package/public/tour-chapters.js +2 -0
- package/public/tour.js +37 -0
- package/public/troubleshooting.js +18 -16
- package/src/adapters/claude.mjs +2 -0
- package/src/adapters/codex.mjs +2 -0
- package/src/adapters/gemini.mjs +3 -1
- package/src/adapters/madre.mjs +3 -1
- package/src/adapters/opencode.mjs +7 -3
- package/src/adapters/process.mjs +2 -0
- package/src/asking.mjs +2 -0
- package/src/auth-probe.mjs +3 -1
- package/src/bridge/devices.mjs +2 -0
- package/src/bridge/handoff.mjs +2 -0
- package/src/bridge/host.mjs +2 -0
- package/src/bridge/network.mjs +2 -0
- package/src/bridge/policy.mjs +3 -1
- package/src/bridge/qr.mjs +2 -0
- package/src/bridge/server.mjs +2 -0
- package/src/capabilities.mjs +2 -0
- package/src/chats.mjs +2 -0
- package/src/checkpoint.mjs +2 -0
- package/src/choices.mjs +2 -0
- package/src/cli-args.mjs +2 -0
- package/src/cold.mjs +2 -0
- package/src/commands.mjs +2 -0
- package/src/config.mjs +2 -0
- package/src/conversation-context.mjs +15 -1
- package/src/credentials.mjs +32 -3
- package/src/dataset.mjs +2 -0
- package/src/directives.mjs +2 -0
- package/src/distiller.mjs +2 -0
- package/src/embeddings.mjs +2 -0
- package/src/enlaces.mjs +6 -4
- package/src/event-store.mjs +2 -0
- package/src/exam.mjs +6 -4
- package/src/extensions.mjs +2 -0
- package/src/eyecat-watch.mjs +2 -0
- package/src/eyecat.mjs +2 -0
- package/src/file-ops.mjs +2 -0
- package/src/files.mjs +32 -0
- package/src/github.mjs +2 -0
- package/src/heartbeat.mjs +2 -0
- package/src/i18n.mjs +15 -1
- package/src/image-studio.mjs +2 -0
- package/src/json-file.mjs +2 -0
- package/src/launch.mjs +2 -0
- package/src/lease.mjs +5 -1
- package/src/lobby-work.mjs +2 -0
- package/src/local-ai.mjs +2 -0
- package/src/looks.mjs +2 -0
- package/src/maturity.mjs +3 -1
- package/src/mcp/connect.mjs +345 -0
- package/src/mcp/github-server.mjs +2 -0
- package/src/mcp/image-server.mjs +2 -0
- package/src/mcp/memory-server.mjs +2 -0
- package/src/mcp/proxy.mjs +153 -0
- package/src/mcp/smtp-server.mjs +2 -0
- package/src/memory-dashboard.mjs +2 -0
- package/src/memory-tools.mjs +2 -0
- package/src/memory.mjs +2 -0
- package/src/models.mjs +45 -3
- package/src/modules/ahp.mjs +2 -0
- package/src/modules/ash-policy.mjs +2 -0
- package/src/modules/ash.mjs +3 -1
- package/src/modules/bishop-plan.mjs +5 -3
- package/src/modules/bishop.mjs +13 -11
- package/src/modules/choices.mjs +2 -0
- package/src/modules/correo.mjs +2 -0
- package/src/modules/enlaces.mjs +2 -0
- package/src/modules/git-pulse.mjs +2 -0
- package/src/modules/github.mjs +2 -0
- package/src/modules/helpers.mjs +2 -0
- package/src/modules/image-studio.mjs +3 -1
- package/src/modules/index.mjs +4 -1
- package/src/modules/intents-calibration.mjs +2 -0
- package/src/modules/intents-desk.mjs +4 -2
- package/src/modules/intents-observe.mjs +2 -0
- package/src/modules/intents-policy.mjs +4 -1
- package/src/modules/intents-try.mjs +2 -0
- package/src/modules/intents.mjs +8 -6
- package/src/modules/mcp-connect.mjs +293 -0
- package/src/modules/ollama.mjs +3 -1
- package/src/modules/playwright.mjs +25 -10
- package/src/modules/ripley.mjs +2 -0
- package/src/modules/runs.mjs +5 -3
- package/src/modules/sdk.mjs +2 -0
- package/src/modules/updates.mjs +2 -0
- package/src/mother.mjs +2 -0
- package/src/ollama.mjs +2 -0
- package/src/outbound.mjs +7 -5
- package/src/override.mjs +2 -0
- package/src/ports.mjs +2 -0
- package/src/prices.mjs +2 -0
- package/src/privacy.mjs +2 -0
- package/src/quota-monitor.mjs +2 -0
- package/src/quota-sources.mjs +2 -0
- package/src/request-guard.mjs +2 -0
- package/src/review-ask.mjs +2 -0
- package/src/room/archivist.mjs +2 -0
- package/src/room/attachments.mjs +2 -0
- package/src/room/budget.mjs +2 -0
- package/src/room/context.mjs +2 -0
- package/src/room/control.mjs +7 -1
- package/src/room/copilot.mjs +76 -0
- package/src/room/economy.mjs +2 -0
- package/src/room/escalation.mjs +2 -0
- package/src/room/ghost.mjs +2 -0
- package/src/room/guard.mjs +2 -0
- package/src/room/prompt.mjs +13 -4
- package/src/room/returns.mjs +82 -0
- package/src/room/runs.mjs +4 -0
- package/src/room/vectors.mjs +2 -0
- package/src/room.mjs +209 -31
- package/src/router.mjs +2 -0
- package/src/runs.mjs +2 -0
- package/src/runtime-detection.mjs +2 -0
- package/src/salas.mjs +2 -0
- package/src/sentinel-errors.mjs +2 -0
- package/src/server-sentinel.mjs +2 -0
- package/src/server.mjs +141 -34
- package/src/setup.mjs +2 -0
- package/src/smtp.mjs +2 -0
- package/src/tour.mjs +2 -0
- package/src/updates.mjs +2 -0
- package/src/usage-sentinel.mjs +2 -0
- package/src/vault.mjs +2 -0
- package/src/verdict.mjs +3 -1
- package/src/whats-new.mjs +2 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
3
|
+
// CONNECT MCP: MCP servers a person adds to one project, for its agents (2026-10-14).
|
|
4
|
+
//
|
|
5
|
+
// The settings live in the project's Settings › Connect MCP, and only there: this module has no
|
|
6
|
+
// card in MODULES (`card: 'settings'`). What it does, in the order a server goes through it:
|
|
7
|
+
//
|
|
8
|
+
// 1. Added by hand (a command, or an address with no sign-in, a token or OAuth) or imported from
|
|
9
|
+
// the person's other apps (.mcp.json, Claude Code, Codex, Gemini CLI, Cursor, opencode.json).
|
|
10
|
+
// Secrets go to the vault; src/mcp/connect.mjs says how.
|
|
11
|
+
// 2. Checked at once: MADRE starts it through the same proxy a turn uses, does the MCP
|
|
12
|
+
// handshake and reads its tools. A server that does not answer stays out of every turn and
|
|
13
|
+
// its row says why, so a broken server costs a line in Settings, never a turn.
|
|
14
|
+
// 3. Handed to the turns of this project, through src/mcp/proxy.mjs, with the tools that read
|
|
15
|
+
// from #1 and the ones that act from the mode chosen for it (#4 unless lowered).
|
|
16
|
+
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { defineModule } from './sdk.mjs';
|
|
21
|
+
import { summary as vaultSummary } from '../vault.mjs';
|
|
22
|
+
import { declareDestinations } from '../outbound.mjs';
|
|
23
|
+
import { t } from '../i18n.mjs';
|
|
24
|
+
import {
|
|
25
|
+
ACTION_MODES, SERVER_ID, authorizeUrl, cleanServer, discoverOAuth, exchangeCode, forgetServerSecrets,
|
|
26
|
+
grantFrom, importable, keepOAuth, keepSecrets, pkce, readMcp, readOAuth, registerClient, writeMcp,
|
|
27
|
+
} from '../mcp/connect.mjs';
|
|
28
|
+
|
|
29
|
+
export const PROXY = fileURLToPath(new URL('../mcp/proxy.mjs', import.meta.url));
|
|
30
|
+
const CHECK_MS = 30000;
|
|
31
|
+
|
|
32
|
+
// The argv every CLI is given for a connected server: the proxy, the project and the server. No
|
|
33
|
+
// secret, ever; the allowed tools travel so the proxy can refuse the others.
|
|
34
|
+
export const proxyArgs = ({ stateRoot, projectRoot, id, allow = null }) => [PROXY, '--state', stateRoot, '--project', projectRoot, '--server', id, ...(allow ? ['--allow', allow.join(',')] : [])];
|
|
35
|
+
|
|
36
|
+
// Starts the server the way a turn would and asks it what it has. Resolves with its tools — each
|
|
37
|
+
// with whether it only reads — or with the reason it did not answer.
|
|
38
|
+
export function checkServer({ stateRoot, projectRoot, id, timeoutMs = CHECK_MS, command = process.execPath }) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
let child;
|
|
41
|
+
let out = '';
|
|
42
|
+
let err = '';
|
|
43
|
+
let settled = false;
|
|
44
|
+
const finish = (result) => {
|
|
45
|
+
if (settled) return;
|
|
46
|
+
settled = true;
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
try { child?.stdin.end(); child?.kill('SIGTERM'); } catch { /* gone */ }
|
|
49
|
+
resolve(result);
|
|
50
|
+
};
|
|
51
|
+
const timer = setTimeout(() => finish({ ok: false, problem: t('it did not answer within {seconds}s', { seconds: Math.round(timeoutMs / 1000) }) }), timeoutMs);
|
|
52
|
+
try {
|
|
53
|
+
child = spawn(command, proxyArgs({ stateRoot, projectRoot, id }), { cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'], shell: false, windowsHide: true });
|
|
54
|
+
} catch (error) { finish({ ok: false, problem: error.message }); return; }
|
|
55
|
+
const send = (message) => { if (!settled) child.stdin.write(`${JSON.stringify(message)}\n`); };
|
|
56
|
+
child.stdin.on('error', () => {});
|
|
57
|
+
child.stdout.setEncoding('utf8');
|
|
58
|
+
child.stderr.setEncoding('utf8');
|
|
59
|
+
child.stderr.on('data', (chunk) => { err = `${err}${chunk}`.slice(-2000); });
|
|
60
|
+
child.on('error', (error) => finish({ ok: false, problem: error.message }));
|
|
61
|
+
child.on('close', (code) => finish({ ok: false, problem: lastLine(err) ?? t('it exited with code {code} before it spoke', { code }) }));
|
|
62
|
+
let info = null;
|
|
63
|
+
child.stdout.on('data', (chunk) => {
|
|
64
|
+
out += chunk;
|
|
65
|
+
for (let newline = out.indexOf('\n'); newline >= 0; newline = out.indexOf('\n')) {
|
|
66
|
+
const line = out.slice(0, newline).trim();
|
|
67
|
+
out = out.slice(newline + 1);
|
|
68
|
+
let message;
|
|
69
|
+
try { message = JSON.parse(line); } catch { continue; }
|
|
70
|
+
if (message.id === 1) {
|
|
71
|
+
if (message.error) { finish(authFailure(message.error.message) ?? { ok: false, problem: String(message.error.message ?? 'initialize failed').slice(0, 300) }); return; }
|
|
72
|
+
info = message.result?.serverInfo ?? null;
|
|
73
|
+
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
74
|
+
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
75
|
+
} else if (message.id === 2) {
|
|
76
|
+
if (message.error) { finish(authFailure(message.error.message) ?? { ok: false, problem: String(message.error.message ?? 'tools/list failed').slice(0, 300) }); return; }
|
|
77
|
+
const tools = (message.result?.tools ?? []).filter((tool) => typeof tool?.name === 'string').slice(0, 200).map((tool) => ({
|
|
78
|
+
name: tool.name,
|
|
79
|
+
readOnly: tool.annotations?.readOnlyHint === true,
|
|
80
|
+
description: String(tool.description ?? '').replace(/\s+/g, ' ').trim().slice(0, 160),
|
|
81
|
+
}));
|
|
82
|
+
finish({ ok: true, tools, server: info ? { name: String(info.name ?? ''), version: String(info.version ?? '') } : null });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'madre', version: '1' } } });
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const lastLine = (text) => String(text ?? '').trim().split('\n').filter(Boolean).at(-1)?.replace(/^MADRE MCP [^:]+:\s*/, '').slice(0, 300) ?? null;
|
|
91
|
+
const authFailure = (message) => (/MADRE_AUTH_REQUIRED/.test(String(message ?? '')) ? { ok: false, needsAuth: true, problem: /refused the credentials/.test(String(message)) ? t('The server refused the credentials. Check the token.') : t('Sign in to this server again.') } : null);
|
|
92
|
+
|
|
93
|
+
// What a turn is handed for one server: the tools that read, and the ones that act when the mode
|
|
94
|
+
// allows them. Nothing for a ghost, nothing unchecked, nothing for an agent it was not given to.
|
|
95
|
+
export function serverForTurn(server, { stateRoot, projectRoot, agent, mode }) {
|
|
96
|
+
if (!server.enabled || server.problem || !server.tools?.length || mode === 0) return null;
|
|
97
|
+
if (server.agents && !server.agents.includes(agent)) return null;
|
|
98
|
+
const reading = server.tools.filter((tool) => tool.readOnly).map((tool) => tool.name);
|
|
99
|
+
const acting = server.tools.filter((tool) => !tool.readOnly).map((tool) => tool.name);
|
|
100
|
+
const actionsFrom = ACTION_MODES.includes(server.actions) ? server.actions : 4;
|
|
101
|
+
const acts = mode >= actionsFrom;
|
|
102
|
+
const tools = acts ? [...reading, ...acting] : reading;
|
|
103
|
+
if (!tools.length) return null;
|
|
104
|
+
return {
|
|
105
|
+
name: server.id,
|
|
106
|
+
command: process.execPath,
|
|
107
|
+
args: proxyArgs({ stateRoot, projectRoot, id: server.id, allow: tools }),
|
|
108
|
+
env: {},
|
|
109
|
+
tools,
|
|
110
|
+
// The core keeps the rule for #4 itself: tools that act and need #4 are declared as sending.
|
|
111
|
+
sends: actionsFrom === 4 ? acting : [],
|
|
112
|
+
brief: `${server.label}${server.server?.name && server.server.name !== server.label ? ` (${server.server.name})` : ''}, connected by the human for this project.${acting.length ? acts ? ' Its tools that act are on in this turn: say what you will do before calling one.' : ` Only its tools that read are on; the ones that act need #${actionsFrom}.` : ''}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// What Settings shows: everything but values. Whether a secret is held is said by name.
|
|
117
|
+
async function publicServers(ctx) {
|
|
118
|
+
const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
|
|
119
|
+
const held = new Set((await vaultSummary(ctx.stateRoot, 'mcp').catch(() => [])).map((one) => one.name));
|
|
120
|
+
return Promise.all(servers.map(async (server) => ({
|
|
121
|
+
...server,
|
|
122
|
+
signedIn: server.auth === 'oauth' ? Boolean((await readOAuth(ctx.stateRoot, server.id))?.access_token) : undefined,
|
|
123
|
+
tokenHeld: server.auth === 'token' ? held.has(`${server.id}_token`) : undefined,
|
|
124
|
+
})));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function saveChecked(ctx, server) {
|
|
128
|
+
const check = await checkServer({ stateRoot: ctx.stateRoot, projectRoot: ctx.projectRoot, id: server.id });
|
|
129
|
+
const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
|
|
130
|
+
const next = servers.map((one) => (one.id === server.id ? {
|
|
131
|
+
...one,
|
|
132
|
+
tools: check.ok ? check.tools : one.tools,
|
|
133
|
+
server: check.ok ? check.server : one.server ?? null,
|
|
134
|
+
checkedAt: new Date().toISOString(),
|
|
135
|
+
problem: check.ok ? null : check.problem,
|
|
136
|
+
needsAuth: check.needsAuth || undefined,
|
|
137
|
+
} : one));
|
|
138
|
+
await writeMcp(ctx.stateRoot, ctx.projectRoot, { servers: next });
|
|
139
|
+
await ctx.record('mcp.checked', { id: server.id, ok: check.ok, tools: check.ok ? check.tools.length : undefined, problem: check.ok ? undefined : check.problem });
|
|
140
|
+
return check;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A refusal of cleanServer, in the room's language: its slots filled, a command's reason too.
|
|
144
|
+
const said = ({ error, errorArgs = {} }) => t(error, { ...errorArgs, ...(errorArgs.why ? { why: t(errorArgs.why, errorArgs.whyArgs ?? {}) } : {}) });
|
|
145
|
+
const refuseFromPhone = (viaPhone) => (viaPhone ? { status: 403, body: { error: t('MCP servers are connected from the computer, not from the phone.') } } : null);
|
|
146
|
+
// Sign-ins in flight: the state the server will send back, and what is needed to finish.
|
|
147
|
+
const pending = new Map(); // state -> { id, verifier, clientId, redirectUri, authorization, resource, at }
|
|
148
|
+
|
|
149
|
+
export default defineModule({
|
|
150
|
+
id: 'mcp',
|
|
151
|
+
name: 'Outside tools (MCP)',
|
|
152
|
+
vendor: 'MADRE',
|
|
153
|
+
version: '1.0.0',
|
|
154
|
+
summary: 'MCP servers you add to this project, for its agents: a command on this computer or an address on the internet. Each one is started and checked before any agent can use it; tools that read work from #1, tools that act from the mode you choose.',
|
|
155
|
+
creates: ['the list of servers, kept in MADRE\'s own folder for this project, never inside the project', 'its secrets in MADRE\'s vault, never in the list'],
|
|
156
|
+
requires: ['nothing; a server is checked when you add it'],
|
|
157
|
+
card: 'settings',
|
|
158
|
+
settings: { enabled: true },
|
|
159
|
+
async status(ctx) {
|
|
160
|
+
const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
|
|
161
|
+
const ready = servers.filter((one) => one.enabled && !one.problem && one.tools?.length).length;
|
|
162
|
+
return { status: { installed: true, detail: servers.length ? t('{ready} of {total} tools ready', { ready, total: servers.length }) : t('no tools yet') } };
|
|
163
|
+
},
|
|
164
|
+
async toolsForTurn(ctx, turn) {
|
|
165
|
+
const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
|
|
166
|
+
return servers.map((server) => serverForTurn(server, { stateRoot: ctx.stateRoot, projectRoot: ctx.projectRoot, agent: turn.agent, mode: Number(turn.mode) })).filter(Boolean);
|
|
167
|
+
},
|
|
168
|
+
routes: [
|
|
169
|
+
{ method: 'GET', path: '/api/x/mcp/servers', async handler(ctx) { return { status: 200, body: { servers: await publicServers(ctx) } }; } },
|
|
170
|
+
{
|
|
171
|
+
method: 'POST', path: '/api/x/mcp/servers',
|
|
172
|
+
async handler(ctx, { payload, viaPhone }) {
|
|
173
|
+
const refused = refuseFromPhone(viaPhone); if (refused) return refused;
|
|
174
|
+
const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
|
|
175
|
+
const before = servers.find((one) => one.id === payload?.id);
|
|
176
|
+
const made = cleanServer({ ...payload, replace: Boolean(payload?.replace && before) }, { existing: servers });
|
|
177
|
+
if (made.error) return { status: 400, body: { error: said(made) } };
|
|
178
|
+
const kept = await keepSecrets(ctx.stateRoot, made.secrets);
|
|
179
|
+
if (!kept.ok) return { status: 400, body: { error: t(kept.error) } };
|
|
180
|
+
// An edit keeps what the check learned until it is checked again.
|
|
181
|
+
const server = before ? { ...made.server, tools: before.tools, server: before.server } : made.server;
|
|
182
|
+
await writeMcp(ctx.stateRoot, ctx.projectRoot, { servers: [...servers.filter((one) => one.id !== server.id), server] });
|
|
183
|
+
await ctx.record('mcp.connected', { id: server.id, transport: server.transport, auth: server.auth });
|
|
184
|
+
const check = server.auth === 'oauth' && !(await readOAuth(ctx.stateRoot, server.id))?.access_token ? { ok: false, needsAuth: true } : await saveChecked(ctx, server);
|
|
185
|
+
return { status: 200, body: { check, servers: await publicServers(ctx) } };
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
method: 'POST', path: /^\/api\/x\/mcp\/servers\/([a-z][a-z0-9-]{0,30})\/(check|toggle|actions|agents|remove)$/,
|
|
190
|
+
async handler(ctx, { payload, params: [id, what], viaPhone }) {
|
|
191
|
+
const refused = refuseFromPhone(viaPhone); if (refused) return refused;
|
|
192
|
+
const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
|
|
193
|
+
const server = servers.find((one) => one.id === id);
|
|
194
|
+
if (!server) return { status: 404, body: { error: t('That server is not connected in this project.') } };
|
|
195
|
+
if (what === 'check') return { status: 200, body: { check: await saveChecked(ctx, server), servers: await publicServers(ctx) } };
|
|
196
|
+
if (what === 'remove') {
|
|
197
|
+
await forgetServerSecrets(ctx.stateRoot, server);
|
|
198
|
+
await writeMcp(ctx.stateRoot, ctx.projectRoot, { servers: servers.filter((one) => one.id !== id) });
|
|
199
|
+
await ctx.record('mcp.removed', { id });
|
|
200
|
+
return { status: 200, body: { servers: await publicServers(ctx) } };
|
|
201
|
+
}
|
|
202
|
+
const change = what === 'toggle' ? { enabled: payload?.enabled === true }
|
|
203
|
+
: what === 'actions' ? { actions: ACTION_MODES.includes(Number(payload?.actions)) ? Number(payload.actions) : server.actions }
|
|
204
|
+
: { agents: Array.isArray(payload?.agents) && payload.agents.length ? payload.agents.filter((one) => /^[a-z0-9_-]+$/.test(one)) : null };
|
|
205
|
+
await writeMcp(ctx.stateRoot, ctx.projectRoot, { servers: servers.map((one) => (one.id === id ? { ...one, ...change } : one)) });
|
|
206
|
+
return { status: 200, body: { servers: await publicServers(ctx) } };
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
// What the person's other apps already have: names, places and what kind; never a value.
|
|
210
|
+
{
|
|
211
|
+
method: 'GET', path: '/api/x/mcp/import',
|
|
212
|
+
async handler(ctx, { viaPhone }) {
|
|
213
|
+
const refused = refuseFromPhone(viaPhone); if (refused) return refused;
|
|
214
|
+
const found = await importable({ projectRoot: ctx.projectRoot });
|
|
215
|
+
const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
|
|
216
|
+
return { status: 200, body: { found: found.map((one, index) => ({ index, source: one.source, name: one.name, id: one.id, transport: one.transport, where: one.url ?? one.command?.join(' ') ?? '', env: Object.keys(one.env ?? {}), headers: Object.keys(one.headers ?? {}), disabled: one.disabled, taken: servers.some((server) => server.id === one.id) })) } };
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
method: 'POST', path: '/api/x/mcp/import',
|
|
221
|
+
async handler(ctx, { payload, viaPhone }) {
|
|
222
|
+
const refused = refuseFromPhone(viaPhone); if (refused) return refused;
|
|
223
|
+
const found = await importable({ projectRoot: ctx.projectRoot });
|
|
224
|
+
const picks = (Array.isArray(payload?.picks) ? payload.picks : []).map(Number).filter((index) => Number.isInteger(index) && found[index]);
|
|
225
|
+
const results = [];
|
|
226
|
+
for (const index of picks) {
|
|
227
|
+
const one = found[index];
|
|
228
|
+
const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
|
|
229
|
+
// Every value an app kept is treated as a secret: the vault is the only place it goes.
|
|
230
|
+
const quote = (word) => (/[\s"'\\$`|&;<>()*?[#~]/.test(word) ? `'${String(word).replace(/'/g, '')}'` : word);
|
|
231
|
+
const made = cleanServer({
|
|
232
|
+
id: one.id, label: one.name, transport: one.transport, url: one.url, auth: one.url && Object.keys(one.headers ?? {}).some((name) => name.toLowerCase() === 'authorization') ? 'none' : 'none',
|
|
233
|
+
command: one.command ? one.command.map(quote).join(' ') : '',
|
|
234
|
+
env: Object.entries(one.env ?? {}).map(([name, value]) => ({ name, value: String(value), secret: true })),
|
|
235
|
+
headers: Object.entries(one.headers ?? {}).map(([name, value]) => ({ name, value: String(value), secret: true })),
|
|
236
|
+
enabled: !one.disabled,
|
|
237
|
+
}, { existing: servers });
|
|
238
|
+
if (made.error) { results.push({ id: one.id, ok: false, problem: said(made) }); continue; }
|
|
239
|
+
const kept = await keepSecrets(ctx.stateRoot, made.secrets);
|
|
240
|
+
if (!kept.ok) { results.push({ id: one.id, ok: false, problem: t(kept.error) }); continue; }
|
|
241
|
+
await writeMcp(ctx.stateRoot, ctx.projectRoot, { servers: [...servers, made.server] });
|
|
242
|
+
await ctx.record('mcp.connected', { id: made.server.id, transport: made.server.transport, auth: made.server.auth, from: one.source });
|
|
243
|
+
const check = await saveChecked(ctx, made.server);
|
|
244
|
+
results.push({ id: made.server.id, ok: check.ok, problem: check.problem, needsAuth: check.needsAuth });
|
|
245
|
+
}
|
|
246
|
+
return { status: 200, body: { results, servers: await publicServers(ctx) } };
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
// OAuth: the page asks for the address to open; the server comes back to the callback below.
|
|
250
|
+
{
|
|
251
|
+
method: 'POST', path: /^\/api\/x\/mcp\/servers\/([a-z][a-z0-9-]{0,30})\/signin$/,
|
|
252
|
+
async handler(ctx, { params: [id], viaPhone, url }) {
|
|
253
|
+
const refused = refuseFromPhone(viaPhone); if (refused) return refused;
|
|
254
|
+
const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
|
|
255
|
+
const server = servers.find((one) => one.id === id && one.transport === 'http');
|
|
256
|
+
if (!server) return { status: 404, body: { error: t('That server is not connected in this project.') } };
|
|
257
|
+
// MADRE's own process reaches this server to sign in: the list of what left the machine
|
|
258
|
+
// names it as the server the person connected, not as an address nothing declares.
|
|
259
|
+
declareDestinations('mcp', [{ id: server.id, host: new URL(server.url).hostname, to: t('{name} · an MCP server you connected', { name: server.label }), what: t('the sign-in: how to sign in, the registration of MADRE and the token'), when: t('when you press Sign in on it'), where: t('Settings › Outside tools (MCP)') }]);
|
|
260
|
+
const found = await discoverOAuth(server.url).catch((error) => ({ ok: false, problem: error.message }));
|
|
261
|
+
if (!found.ok) return { status: 409, body: { error: t(found.problem) } };
|
|
262
|
+
const redirectUri = `${url.origin}/api/x/mcp/oauth/callback`;
|
|
263
|
+
const client = await registerClient(found.authorization, redirectUri).catch((error) => ({ ok: false, problem: error.message }));
|
|
264
|
+
if (!client.ok) return { status: 409, body: { error: t('The server did not let MADRE register: {problem}', { problem: client.problem }) } };
|
|
265
|
+
const { verifier, challenge } = pkce();
|
|
266
|
+
const state = randomBytes(18).toString('hex');
|
|
267
|
+
for (const [key, one] of pending) if (Date.now() - one.at > 15 * 60000) pending.delete(key);
|
|
268
|
+
pending.set(state, { id, verifier, clientId: client.clientId, redirectUri, authorization: found.authorization, resource: found.resource, at: Date.now() });
|
|
269
|
+
return { status: 200, body: { open: authorizeUrl({ authorization: found.authorization, clientId: client.clientId, redirectUri, challenge, state, resource: found.resource, scopes: found.scopes }) } };
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
method: 'GET', path: '/api/x/mcp/oauth/callback',
|
|
274
|
+
async handler(ctx, { url }) {
|
|
275
|
+
const page = (title, text) => ({ status: 200, html: `<!doctype html><meta charset="utf-8"><title>MADRE</title><body style="font:15px system-ui;margin:12vh auto;max-width:34em;padding:0 16px;color:#1d2321"><h1 style="font-size:20px">${title}</h1><p>${text}</p></body>` });
|
|
276
|
+
const state = url.searchParams.get('state') ?? '';
|
|
277
|
+
const flow = pending.get(state);
|
|
278
|
+
pending.delete(state);
|
|
279
|
+
if (!flow) return page(t('This sign-in expired'), t('Sign in again from Settings › Outside tools (MCP).'));
|
|
280
|
+
if (url.searchParams.get('error')) return page(t('The sign-in was not completed'), t('The server said: {problem}', { problem: String(url.searchParams.get('error_description') ?? url.searchParams.get('error')).replace(/[<>&]/g, '') }));
|
|
281
|
+
const done = await exchangeCode({ authorization: flow.authorization, clientId: flow.clientId, redirectUri: flow.redirectUri, code: url.searchParams.get('code') ?? '', verifier: flow.verifier, resource: flow.resource }).catch((error) => ({ ok: false, problem: error.message }));
|
|
282
|
+
if (!done.ok) return page(t('The sign-in was not completed'), t('The server said: {problem}', { problem: String(done.problem).replace(/[<>&]/g, '') }));
|
|
283
|
+
await keepOAuth(ctx.stateRoot, flow.id, { ...grantFrom(done.tokens), token_endpoint: flow.authorization.token_endpoint, client_id: flow.clientId, resource: flow.resource });
|
|
284
|
+
const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
|
|
285
|
+
const server = servers.find((one) => one.id === flow.id);
|
|
286
|
+
const check = server ? await saveChecked(ctx, server) : { ok: false };
|
|
287
|
+
return page(check.ok ? t('Connected') : t('Signed in'), check.ok ? t('{name} is connected to this project with {n} tools. You can close this tab and go back to MADRE.', { name: server?.label ?? flow.id, n: check.tools.length }) : t('You are signed in, but the server did not answer yet: {problem}. Check it again from Settings › Outside tools (MCP).', { problem: String(check.problem ?? '').replace(/[<>&]/g, '') }));
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
export { SERVER_ID };
|
package/src/modules/ollama.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// OLLAMA: local intelligence. Embeddings and distillation on this machine when
|
|
2
4
|
// Ollama runs. The server offers the wiring through ctx.services.ollama.
|
|
3
5
|
|
|
@@ -49,7 +51,7 @@ export default defineModule({
|
|
|
49
51
|
name: 'Local AI',
|
|
50
52
|
vendor: 'MADRE · LOCAL INTELLIGENCE',
|
|
51
53
|
tracks: { name: 'ollama', github: 'ollama/ollama' },
|
|
52
|
-
summary: '
|
|
54
|
+
summary: 'Searches and sums up the project\'s memory with local AI (Ollama): free, and nothing leaves your computer.',
|
|
53
55
|
creates: ['nothing in the project', 'a block in ~/.pulse/config.json', 'models in Ollama\'s own store when you press PULL'],
|
|
54
56
|
requires: ['Ollama running (the app, or ollama serve)', 'an embedding model and a chat model \u00b7 MADRE can pull the recommended ones'],
|
|
55
57
|
settings: { enabled: true, embeddings: true, archivist: true, agent: true },
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// PLAYWRIGHT: a headless browser for the crew, pointed only at this MADRE. Agents open the
|
|
2
4
|
// RIPLEY preview of project files, click, read the console and take screenshots into the turn's
|
|
3
5
|
// scratch folder. Runs @playwright/mcp per turn, isolated, with allowed origins limited to the
|
|
@@ -14,6 +16,10 @@ import { terminateProcessTree, lastWords } from '../adapters/process.mjs';
|
|
|
14
16
|
|
|
15
17
|
export const PLAYWRIGHT_PACKAGE = '@playwright/mcp';
|
|
16
18
|
export const PLAYWRIGHT_BROWSERS = ['chromium', 'firefox', 'webkit'];
|
|
19
|
+
// The browser the server downloads for each engine: `install-browser` is `playwright install <name>`
|
|
20
|
+
// underneath, so the name is the engine's own. The remedy used to say chrome-for-testing whatever
|
|
21
|
+
// was chosen, and a person on webkit installed a browser that could never fix it (2026-10-14).
|
|
22
|
+
export const installBrowserLine = (browser = 'chromium') => `npx ${PLAYWRIGHT_PACKAGE} install-browser ${PLAYWRIGHT_BROWSERS.includes(browser) ? browser : 'chromium'}`;
|
|
17
23
|
export const PLAYWRIGHT_SERVER_NAME = 'pulse-playwright';
|
|
18
24
|
// The tools @playwright/mcp exposes that a room turn may use. Screenshots and files land in scratch.
|
|
19
25
|
export const PLAYWRIGHT_TOOLS = ['browser_navigate', 'browser_navigate_back', 'browser_snapshot', 'browser_click', 'browser_type', 'browser_fill_form', 'browser_hover', 'browser_press_key', 'browser_select_option', 'browser_wait_for', 'browser_console_messages', 'browser_network_requests', 'browser_take_screenshot', 'browser_resize', 'browser_tabs', 'browser_close'];
|
|
@@ -101,15 +107,18 @@ export const wantsBrowser = (text) => BROWSER_INTENT.test(String(text ?? ''));
|
|
|
101
107
|
// The last answer to «does it start?», for a switch that was already on: asked again in the
|
|
102
108
|
// background at most every ten minutes, so the card can say the browser is missing and no turn
|
|
103
109
|
// carries a server that would only fail.
|
|
104
|
-
|
|
110
|
+
// The answer belongs to the browser it was asked about: chosen another, it is asked again, or a
|
|
111
|
+
// browser that does not start would ride on the answer of one that does.
|
|
112
|
+
let started = { at: 0, result: null, asking: null, browser: null };
|
|
105
113
|
export function playwrightHealth({ browser = 'chromium', env = process.env, cwd = process.cwd(), now = Date.now(), check = playwrightStarts } = {}) {
|
|
106
114
|
if (env.PULSE_PLAYWRIGHT_CHECK === '0') return started.result;
|
|
115
|
+
if (started.browser !== browser) started = { at: 0, result: null, asking: null, browser };
|
|
107
116
|
if (!started.asking && now - started.at > 10 * 60 * 1000) {
|
|
108
|
-
started.asking = check({ browser, env, cwd }).then((result) => { started = { at: Date.now(), result, asking: null }; return result; }, () => { started.asking = null; return null; });
|
|
117
|
+
started.asking = check({ browser, env, cwd }).then((result) => { if (started.browser === browser) started = { at: Date.now(), result, asking: null, browser }; return result; }, () => { started.asking = null; return null; });
|
|
109
118
|
}
|
|
110
119
|
return started.result;
|
|
111
120
|
}
|
|
112
|
-
export function forgetPlaywrightHealth(result = null) { started = { at: result ? Date.now() : 0, result, asking: null }; }
|
|
121
|
+
export function forgetPlaywrightHealth(result = null, browser = null) { started = { at: result ? Date.now() : 0, result, asking: null, browser }; }
|
|
113
122
|
|
|
114
123
|
// What the browser may open: this room, and the servers the human started from RUNS in this project
|
|
115
124
|
// while they listen on 127.0.0.1. RIPLEY's sealed frame keeps no data, so an app that stores its
|
|
@@ -141,7 +150,7 @@ export default defineModule({
|
|
|
141
150
|
tracks: { name: PLAYWRIGHT_PACKAGE, npm: PLAYWRIGHT_PACKAGE },
|
|
142
151
|
summary: 'Hands an agent a headless browser that reaches only this project and the servers you start from COMMANDS, on the turns that ask to look at a page, click through it, read its console or take screenshots.',
|
|
143
152
|
creates: ['nothing in the project \u00b7 screenshots land in .pulse/out/<turn>/', 'a switch in ~/.pulse/config.json', 'a browser per turn, started and stopped by the CLI', 'no origin but this project is reachable through it'],
|
|
144
|
-
requires: ['@playwright/mcp and
|
|
153
|
+
requires: ['@playwright/mcp and the browser you choose for it (chromium by default) on this machine', 'PREVIEW on, to have pages to open'],
|
|
145
154
|
settings: { enabled: false, browser: 'chromium', headless: true },
|
|
146
155
|
card: 'switch',
|
|
147
156
|
controls: [
|
|
@@ -154,8 +163,8 @@ export default defineModule({
|
|
|
154
163
|
return {
|
|
155
164
|
runs: [{ name: PLAYWRIGHT_PACKAGE, version }],
|
|
156
165
|
settings: { browser: ctx.settings.browser ?? 'chromium', headless: ctx.settings.headless !== false },
|
|
157
|
-
status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? (!version ? t('on · the browser server is not installed') : health?.ok === false ? t('on · the browser does not start: {problem}', { problem: String(health.problem).slice(0, 160) }) : `${t('on')} · ${ctx.settings.browser}`) : version ? t('off') : t('off · the browser server is not installed') },
|
|
158
|
-
preflight: version ? { ok: true, problems: [] } : { ok: false, problems: [
|
|
166
|
+
status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? (!version ? t('on · the browser server is not installed') : health?.ok === false ? t('on · the browser does not start: {problem}. Install it with {command}.', { problem: String(health.problem).replace(/[.\s]+$/, '').slice(0, 160), command: installBrowserLine(ctx.settings.browser) }) : `${t('on')} · ${ctx.settings.browser}`) : version ? t('off') : t('off · the browser server is not installed') },
|
|
167
|
+
preflight: version ? { ok: true, problems: [] } : { ok: false, problems: [`Install the browser server first: npm install -g @playwright/mcp, then its browser: ${installBrowserLine(ctx.settings.browser)}`] },
|
|
159
168
|
install: { display: ctx.settings.enabled ? 'disable PLAYWRIGHT' : 'enable PLAYWRIGHT (config.json)', platforms: ['codex', 'claude', 'gemini', 'opencode'] },
|
|
160
169
|
};
|
|
161
170
|
},
|
|
@@ -187,9 +196,15 @@ export default defineModule({
|
|
|
187
196
|
async toggle(ctx) {
|
|
188
197
|
const enabled = !ctx.settings.enabled;
|
|
189
198
|
if (enabled) {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
const browser = ctx.settings.browser ?? 'chromium';
|
|
200
|
+
const check = await playwrightStarts({ browser, env: ctx.env, cwd: ctx.projectRoot });
|
|
201
|
+
forgetPlaywrightHealth(check, browser);
|
|
202
|
+
// The server is there and only the browser is missing: say which one, and the one line that
|
|
203
|
+
// installs it. Without the server, both lines.
|
|
204
|
+
const version = await playwrightVersion({ env: ctx.env, projectRoot: ctx.projectRoot });
|
|
205
|
+
if (!check.ok) return { status: 412, body: { error: version
|
|
206
|
+
? t('TEST BROWSER did not start, so it stays off: {problem}. The browser server is installed; what is missing is the {browser} browser. Install it with {command} and turn it on again, or choose another browser in its settings.', { problem: String(check.problem).replace(/[.\s]+$/, ''), browser, command: installBrowserLine(browser) })
|
|
207
|
+
: t('TEST BROWSER did not start, so it stays off: {problem}. Install the server and its browser ({server}, then {command}) and turn it on again.', { problem: String(check.problem).replace(/[.\s]+$/, ''), server: `npm install -g ${PLAYWRIGHT_PACKAGE}`, command: installBrowserLine(browser) }), code: 'MU-054' } };
|
|
193
208
|
}
|
|
194
209
|
await ctx.updateConfig({ modules: { playwright: { ...(ctx.config.modules?.playwright ?? {}), enabled } } });
|
|
195
210
|
await ctx.record('extension.toggled', { id: 'playwright', name: 'PLAYWRIGHT', enabled });
|
|
@@ -203,6 +218,6 @@ export default defineModule({
|
|
|
203
218
|
match: /@playwright\/mcp|playwright.*not (found|installed)|browser server/i,
|
|
204
219
|
diagnosis: 'The TEST BROWSER 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.',
|
|
205
220
|
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.',
|
|
206
|
-
fixes: { darwin: ['npm install -g @playwright/mcp', 'npx @playwright/mcp install-browser
|
|
221
|
+
fixes: { darwin: ['npm install -g @playwright/mcp', 'npx @playwright/mcp install-browser chromium'], linux: ['npm install -g @playwright/mcp', 'npx @playwright/mcp install-browser chromium'] },
|
|
207
222
|
}],
|
|
208
223
|
});
|
package/src/modules/ripley.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// RIPLEY: the file viewer renders HTML, SVG and Markdown inside a sealed frame.
|
|
2
4
|
// A switch in config.json, read live by the preview route.
|
|
3
5
|
|
package/src/modules/runs.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// Runs: letting an agent ask the human to run a command instead of asking them to copy it.
|
|
2
4
|
//
|
|
3
5
|
// Below #4 AIRLOCK an agent cannot run anything, and some answers turn on a command anyway: the
|
|
4
6
|
// tests after a fix, a build, a linter. This gives that request a shape — a fenced block the room
|
|
5
|
-
// turns into one button per command — and brings the output back to the room as a card
|
|
6
|
-
// agent
|
|
7
|
+
// turns into one button per command — and brings the output back to the room as a card, and to
|
|
8
|
+
// the agent that asked in a turn of its own once every line has run (src/room/returns.mjs).
|
|
7
9
|
//
|
|
8
10
|
// Nothing an agent writes runs by itself. Each line is pressed by the human, runs alone from the
|
|
9
11
|
// project root with no shell, and is photographed first so UNDO can put the files back. What a
|
|
@@ -22,7 +24,7 @@ export default defineModule({
|
|
|
22
24
|
name: 'Commands',
|
|
23
25
|
vendor: 'MADRE',
|
|
24
26
|
version: '1.0.0',
|
|
25
|
-
summary: 'Lets an agent ask you to run a command it cannot run in its mode (tests, a build, a linter). Each command is a button: nothing runs until you press it, it runs from the project root with no shell, a restore point is taken first, and the output
|
|
27
|
+
summary: 'Lets an agent ask you to run a command it cannot run in its mode (tests, a build, a linter). Each command is a button: nothing runs until you press it, it runs from the project root with no shell, a restore point is taken first, and when it has run the output goes back to the agent that asked, which goes on. Costs about 300 tokens of instruction per turn below FULL ACCESS while it is on.',
|
|
26
28
|
creates: ['nothing in the project', 'a switch in ~/.pulse/config.json'],
|
|
27
29
|
async status(ctx) {
|
|
28
30
|
return {
|
package/src/modules/sdk.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// MADRE's module SDK. A module is one file: what it is, the settings it keeps
|
|
2
4
|
// in ~/.pulse/config.json, how it describes itself to MODULES, what its switch
|
|
3
5
|
// does, the routes it serves and the events it listens to. The server builds a
|
package/src/modules/updates.mjs
CHANGED
package/src/mother.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// MU/TH/UR's private channel. When someone strikes at her memory core, MOTHER
|
|
2
4
|
// tells her children (the agents) in a code the console cannot read; the room
|
|
3
5
|
// shows the code, the agents get the words. The channel lives in
|
package/src/ollama.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// Ollama: the local intelligence MADRE leans on when it is there. Embeddings
|
|
2
4
|
// for recall by meaning and a model to distil memories, both on this machine,
|
|
3
5
|
// so remembering costs no tokens elsewhere and nothing leaves. Nothing here is
|
package/src/outbound.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// What left this machine.
|
|
2
4
|
//
|
|
3
5
|
// MADRE runs on your computer and keeps everything it knows in a file you own, and that sentence
|
|
@@ -48,7 +50,7 @@ export const DESTINATIONS = [
|
|
|
48
50
|
inside: false,
|
|
49
51
|
},
|
|
50
52
|
{
|
|
51
|
-
id: 'embeddings', host: 'generativelanguage.googleapis.com', match: (url) => /batchEmbedContents/.test(url.pathname), to: 'Google · Gemini
|
|
53
|
+
id: 'embeddings', host: 'generativelanguage.googleapis.com', match: (url) => /batchEmbedContents/.test(url.pathname), to: 'Google · Gemini search by meaning',
|
|
52
54
|
what: 'The text of what is being embedded: the first 2000 characters of each memory, and the question a recall is made of.',
|
|
53
55
|
when: 'a memory is written or recalled, while embeddings are set to Gemini',
|
|
54
56
|
where: 'Settings › Memory › Smart search · Ollama keeps it on this computer, off drops back to words',
|
|
@@ -57,7 +59,7 @@ export const DESTINATIONS = [
|
|
|
57
59
|
{
|
|
58
60
|
id: 'image', host: 'generativelanguage.googleapis.com', match: (url) => /generateContent/.test(url.pathname), to: 'Google · Gemini image model',
|
|
59
61
|
what: 'The prompt an agent wrote for an image.',
|
|
60
|
-
when: 'an agent
|
|
62
|
+
when: 'an agent uses the image tool during a permission to create',
|
|
61
63
|
where: 'Modules › Images',
|
|
62
64
|
// It runs in a process of its own and writes its own line into this same log, so it is here.
|
|
63
65
|
inside: true,
|
|
@@ -65,7 +67,7 @@ export const DESTINATIONS = [
|
|
|
65
67
|
{
|
|
66
68
|
id: 'npm', host: 'registry.npmjs.org', to: 'the npm registry',
|
|
67
69
|
what: 'The name of a package, to read the version of its latest release. Nothing about you or your project.',
|
|
68
|
-
when: 'at most once a day per package, while
|
|
70
|
+
when: 'at most once a day per package, while Look for new versions is on',
|
|
69
71
|
where: 'Settings › Version and reports › Look for new versions · or PULSE_UPDATE_CHECK=0',
|
|
70
72
|
inside: true,
|
|
71
73
|
},
|
|
@@ -73,7 +75,7 @@ export const DESTINATIONS = [
|
|
|
73
75
|
// Only the release read: the rest of api.github.com is the GitHub module's, and says so itself.
|
|
74
76
|
id: 'github', host: 'api.github.com', match: (url) => /\/releases\/latest$/.test(url.pathname), to: 'GitHub',
|
|
75
77
|
what: 'The name of a repository, to read the tag of its latest release. Nothing about you or your project.',
|
|
76
|
-
when: 'at most once a day per repository, while
|
|
78
|
+
when: 'at most once a day per repository, while Look for new versions is on',
|
|
77
79
|
where: 'Settings › Version and reports › Look for new versions · or PULSE_UPDATE_CHECK=0',
|
|
78
80
|
inside: true,
|
|
79
81
|
},
|
|
@@ -344,7 +346,7 @@ export function outboundView({ log = null, state = {}, agents = [] } = {}) {
|
|
|
344
346
|
recent: log?.recent({ limit: 40 }) ?? [],
|
|
345
347
|
undeclared,
|
|
346
348
|
says: undeclared
|
|
347
|
-
? (undeclared === 1 ? t('1 request went to an address
|
|
349
|
+
? (undeclared === 1 ? t('1 request went to an address not on this list. A module can do that, but it is always logged.') : t('{n} requests went to addresses not on this list. A module can do that, but it is always logged.', { n: undeclared }))
|
|
348
350
|
: t('Every request this process made went to an address declared above.'),
|
|
349
351
|
};
|
|
350
352
|
}
|
package/src/override.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
4
|
|
|
3
5
|
// How #3 CONTROL and #4 AIRLOCK are unlocked, as the person chose it in AJUSTES › Permissions.
|
package/src/ports.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// What is listening on this computer, so RIPLEY can look at it.
|
|
2
4
|
//
|
|
3
5
|
// A room that renders a project's files but cannot open the project's own dev server is half a
|
package/src/prices.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// What a turn would cost at the provider's API list price, for the CLIs that do not say it
|
|
2
4
|
// themselves. Claude Code reports its own figure (`total_cost_usd`); Codex and Gemini report only
|
|
3
5
|
// tokens, and OpenCode reports 0 when the model runs on a subscription. So the room prices those
|
package/src/privacy.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// Privacy: terms that must never travel through a room. ERROR-001 in a real room
|
|
2
4
|
// showed the channel: an agent's own configuration (organisation instructions,
|
|
3
5
|
// the account it runs under) leaks into its reply, the reply enters the shared
|
package/src/quota-monitor.mjs
CHANGED
package/src/quota-sources.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
import { execFile } from 'node:child_process';
|
|
2
4
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
5
|
import { homedir } from 'node:os';
|
package/src/request-guard.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// The room listens on 127.0.0.1, which keeps other computers out but not other web pages: any
|
|
2
4
|
// site open in this browser can send a request to 127.0.0.1:4317, and an uploaded module is code
|
|
3
5
|
// MADRE runs with your permissions. Two checks, before any route, close that door.
|
package/src/review-ask.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// Asking for a star or a review (2026-10-10): three times at most, and never again once answered.
|
|
2
4
|
//
|
|
3
5
|
// After twenty minutes of MADRE in use, and on the second and the seventh day after it was first
|
package/src/room/archivist.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// The archivist: every so often the cheapest allowed agent reads what nobody
|
|
2
4
|
// has distilled and keeps the few notes worth remembering. Scheduling by count
|
|
3
5
|
// or by quiet, one batch per run, a bench for whoever fails, a report to the
|
package/src/room/attachments.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// Human uploads, stored under the room (never in the project). Registered
|
|
2
4
|
// here so a message can reference them by id; the ledger lets a restarted
|
|
3
5
|
// room rebuild the registry.
|
package/src/room/budget.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// The local token budget: a rolling window per agent (five hours, like the
|
|
2
4
|
// providers' short windows), raw totals for the record, and the seeding of the
|
|
3
5
|
// usage sentinel from the ledger on start.
|
package/src/room/context.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// The context an agent gets: the recent transcript verbatim and, when the
|
|
2
4
|
// room is longer than that window, the older exchanges that match this
|
|
3
5
|
// request, recalled from memory inside the same character budget.
|