@jossuealcala/madre 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- 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 +268 -48
- package/public/es.js +40 -3
- package/public/index.html +2 -0
- package/public/styles.css +106 -11
- package/public/syntax.js +175 -0
- package/public/troubleshooting.js +13 -0
- package/src/adapters/codex.mjs +4 -1
- package/src/modules/index.mjs +23 -5
- package/src/modules/sdk.mjs +15 -0
- package/src/outbound.mjs +1 -1
- package/src/room/control.mjs +10 -3
- package/src/room.mjs +15 -0
- package/src/runtime-detection.mjs +50 -0
- package/src/server.mjs +9 -6
- package/src/updates.mjs +9 -1
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
|
@@ -269,7 +269,7 @@ export class OutboundLog {
|
|
|
269
269
|
export function outboundView({ log = null, state = {}, agents = [] } = {}) {
|
|
270
270
|
const counts = log?.counts() ?? {};
|
|
271
271
|
const vendors = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: 'the provider OpenCode is signed in to' };
|
|
272
|
-
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'}`);
|
|
273
273
|
const destinations = DESTINATIONS.map((one) => ({
|
|
274
274
|
id: one.id,
|
|
275
275
|
to: one.id === 'crew' && crew.length ? crew.join(' · ') : one.to,
|
package/src/room/control.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export class ControlDesk {
|
|
|
29
29
|
this.#checkpoints.set(checkpoint.id, checkpoint);
|
|
30
30
|
// Prevention first: .env files and MADRE's folders are read-only for the length of the turn.
|
|
31
31
|
const guard = await guardForbidden(this.#projectRoot);
|
|
32
|
-
const run = { agent: agent.id, messageId, checkpoint, since: new Date().toISOString(), guard, mode };
|
|
32
|
+
const run = { agent: agent.id, messageId, checkpoint, since: new Date().toISOString(), guard, mode, scratchDir: null };
|
|
33
33
|
if (mode >= 3) this.#holder = run;
|
|
34
34
|
const lease = { leaseId: checkpoint.id, outDir: this.#projectRoot, relativeDir: '.', scopes: { ...enabledScopes, write: true }, control: mode >= 3, airlock: mode === 4, create: mode === 2, checkpoint };
|
|
35
35
|
if (mode < 3) return { run, lease, announcement: null };
|
|
@@ -43,7 +43,14 @@ export class ControlDesk {
|
|
|
43
43
|
async settle(run) {
|
|
44
44
|
const diff = await diffCheckpoint(this.#projectRoot, run.checkpoint);
|
|
45
45
|
const additive = run.mode === 2;
|
|
46
|
-
|
|
46
|
+
// The draft folder MADRE itself hands the agent lives under `.pulse/out/`, and `.pulse/` is a
|
|
47
|
+
// forbidden zone — so the room named a place to write, the agent wrote there, and the room
|
|
48
|
+
// undid it. Nobody noticed here because this project ignores `.pulse/` and git never reported
|
|
49
|
+
// the files; in a project that does not, `/module` never reached its install card. A turn's
|
|
50
|
+
// own scratch folder is not a forbidden zone FOR THAT TURN. Every other `.pulse` path still is.
|
|
51
|
+
const ownScratch = run.scratchDir ? `${run.scratchDir.replace(/\/$/, '')}/` : null;
|
|
52
|
+
const mine = (path) => Boolean(ownScratch) && path.startsWith(ownScratch);
|
|
53
|
+
const toRevert = new Set(diff.forbidden.filter((path) => !mine(path)));
|
|
47
54
|
if (additive) for (const file of diff.files) if (file.status !== 'A') toRevert.add(file.path);
|
|
48
55
|
let reverted = [];
|
|
49
56
|
if (toRevert.size) {
|
|
@@ -51,7 +58,7 @@ export class ControlDesk {
|
|
|
51
58
|
reverted = [...restored.restored, ...restored.removed];
|
|
52
59
|
}
|
|
53
60
|
const kept = diff.files.filter((file) => !toRevert.has(file.path));
|
|
54
|
-
const changes = { checkpointId: run.checkpoint.id, agent: run.agent, messageId: run.messageId, mode: run.mode ?? 3, files: kept, stat: diff.stat, forbiddenReverted:
|
|
61
|
+
const changes = { checkpointId: run.checkpoint.id, agent: run.agent, messageId: run.messageId, mode: run.mode ?? 3, files: kept, stat: diff.stat, forbiddenReverted: reverted.filter((path) => diff.forbidden.includes(path) && !mine(path)), existingReverted: additive ? reverted.filter((path) => !diff.forbidden.includes(path)) : [] };
|
|
55
62
|
const count = changes.files.length;
|
|
56
63
|
const notes = [
|
|
57
64
|
changes.forbiddenReverted.length ? `${changes.forbiddenReverted.length} write(s) into forbidden zones were reverted.` : null,
|
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.
|
|
@@ -1185,6 +1197,9 @@ export class Room {
|
|
|
1185
1197
|
// CREATE: photograph the project now; after the turn only what appeared stays.
|
|
1186
1198
|
const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode: 2 });
|
|
1187
1199
|
createRun = seat.run;
|
|
1200
|
+
// The folder this turn was told to draft in, so settling it does not undo what it was
|
|
1201
|
+
// invited to write. Only this turn's own folder; every other `.pulse` path stays forbidden.
|
|
1202
|
+
createRun.scratchDir = lease.scratchDir ?? null;
|
|
1188
1203
|
lease = { ...lease, checkpoint: seat.run.checkpoint };
|
|
1189
1204
|
}
|
|
1190
1205
|
turnScopes.imageGen = Boolean(lease?.scopes?.imageGen);
|
|
@@ -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/server.mjs
CHANGED
|
@@ -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);
|
|
@@ -1474,7 +1477,7 @@ export async function createPulseServer({
|
|
|
1474
1477
|
if (request.method === 'GET' && url.pathname === '/api/extensions') {
|
|
1475
1478
|
const extensions = await describeModules(await moduleContext());
|
|
1476
1479
|
void warmModuleUpdates(extensions);
|
|
1477
|
-
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 });
|
|
1478
1481
|
}
|
|
1479
1482
|
// The human installs a module file an agent wrote (or they did): checked first, then copied into the chosen folder.
|
|
1480
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
|
}
|