@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.
@@ -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,
@@ -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
- const toRevert = new Set(diff.forbidden);
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: diff.forbidden.length ? reverted.filter((path) => diff.forbidden.includes(path)) : [], existingReverted: additive ? reverted.filter((path) => !diff.forbidden.includes(path)) : [] };
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: 'https://github.com/jossuealcacao-exe/madre/blob/main/docs/SDK.md' });
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
- export function releaseUrl(repository, version) {
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
  }