@jossuealcala/madre 0.3.2 → 0.3.3

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/src/room.mjs CHANGED
@@ -28,7 +28,6 @@ import { imageStudioFor } from './image-studio.mjs';
28
28
  import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
29
29
  import { resolveReferences } from './files.mjs';
30
30
  import { compressAshCode } from './ashcode.mjs';
31
- import { isGitRepo } from './checkpoint.mjs';
32
31
 
33
32
  // Adapters can fail with multi-line stderr or stack traces. The room keeps only
34
33
  // the first meaningful line, bounded, so the event log and the UI stay readable.
@@ -84,6 +83,7 @@ export class Room {
84
83
  #scopeConfig = {};
85
84
  #delegation;
86
85
  #privacy = null;
86
+ #toolsForTurn = null;
87
87
  #maxPlanSteps;
88
88
  #maxConcurrentTurns;
89
89
  #planMaxAgeMs;
@@ -101,6 +101,7 @@ export class Room {
101
101
  memoryServer = null,
102
102
  mother = null,
103
103
  privacy = null,
104
+ toolsForTurn = null, // async ({ agent, mode, lease, scratchDir }) => MCP server specs from the modules
104
105
  historicalEvents = [],
105
106
  invokers = defaultInvokers,
106
107
  agentTimeouts = {},
@@ -120,6 +121,7 @@ export class Room {
120
121
  this.#memoryServer = memoryServer;
121
122
  this.#mother = mother;
122
123
  this.#privacy = privacy;
124
+ this.#toolsForTurn = toolsForTurn;
123
125
  this.#invokers = invokers;
124
126
  this.#agentTimeouts = agentTimeouts;
125
127
  this.#maxMessageChars = maxMessageChars;
@@ -357,11 +359,12 @@ export class Room {
357
359
  const wanted = normalizeMode(mode, create === true ? 2 : 1);
358
360
  if (!parsed.target) return { ok: true, mode: wanted };
359
361
  const scopes = this.scopesFor(parsed.target);
360
- if (wanted === 3) {
361
- if (scopes.maxMode < 3) return { ok: false, status: 403, mode: wanted, maxMode: scopes.maxMode, error: `@${parsed.target} is capped at #${scopes.maxMode} ${MODES[scopes.maxMode].label}; raise its MAX MODE to #3 in CONNECTIONS first.` };
362
- if (!(await isGitRepo(this.#projectRoot))) return { ok: false, status: 412, mode: wanted, maxMode: scopes.maxMode, error: 'CONTROL needs the project to be a git repository: the checkpoint that makes UNDO possible is a git commit. Run git init first.' };
363
- if (this.#controlDesk.holder) return { ok: false, status: 409, mode: wanted, maxMode: scopes.maxMode, error: `@${this.#controlDesk.holder.agent} already holds CONTROL of this project; one holder at a time. STOPALL revokes it.` };
364
- return { ok: true, mode: 3, maxMode: scopes.maxMode };
362
+ if (wanted >= 3) {
363
+ // #3 CONTROL and #4 AIRLOCK: the ceiling must allow it, and there is one holder at a time.
364
+ // A project without git still checkpoints, in MADRE's shadow repository.
365
+ if (scopes.maxMode < wanted) return { ok: false, status: 403, mode: wanted, maxMode: scopes.maxMode, error: `@${parsed.target} is capped at #${scopes.maxMode} ${MODES[scopes.maxMode].label}; raise its MAX MODE to #${wanted} in CONNECTIONS to arm ${MODES[wanted].label}.` };
366
+ if (this.#controlDesk.holder) return { ok: false, status: 409, mode: wanted, maxMode: scopes.maxMode, error: `@${this.#controlDesk.holder.agent} already holds ${MODES[this.#controlDesk.holder.mode ?? 3].label} of this project; one holder at a time. STOPALL revokes it.` };
367
+ return { ok: true, mode: wanted, maxMode: scopes.maxMode };
365
368
  }
366
369
  // #2 above the agent's ceiling still goes out: the room answers read-only
367
370
  // and says why (lease.refused), the way CREATE has always behaved.
@@ -520,7 +523,7 @@ export class Room {
520
523
  if (!gate.ok) throw new Error(gate.error);
521
524
  create = requestedMode === 2;
522
525
  const ghost = requestedMode === 0;
523
- const control = requestedMode === 3;
526
+ const control = requestedMode >= 3;
524
527
  const files = (Array.isArray(attachments) ? attachments : []).map((id) => this.attachment(id)).filter(Boolean);
525
528
  if (!parsed.text && !files.length) throw new Error('Write a message first.');
526
529
  if (!parsed.target) throw new Error('Choose an agent or begin with @agent.');
@@ -539,7 +542,7 @@ export class Room {
539
542
  if (ghost) this.#ghost.add(messageId);
540
543
  await this.#emit('message.created', {
541
544
  messageId,
542
- mode: ghost ? 0 : control ? 3 : (standing || create ? 2 : 1),
545
+ mode: ghost ? 0 : control ? requestedMode : (standing || create ? 2 : 1),
543
546
  role: 'user',
544
547
  sender: 'you',
545
548
  target: parsed.target,
@@ -609,7 +612,7 @@ export class Room {
609
612
  allowDelegation = false;
610
613
  await this.#alert('plan-active', `A plan by @${[...this.#plans.values()][0].orchestrator} is still running. Your message will be answered, but no second plan will start. Type STOPALL to halt every agent.`, `plan-active:${messageId}`);
611
614
  }
612
- await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, attachments: files, references, lease, ashCode: ashActive, mode: ghost ? 0 : control ? 3 : (lease ? 2 : 1) }));
615
+ await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, attachments: files, references, lease, ashCode: ashActive, mode: ghost ? 0 : control ? requestedMode : (lease ? 2 : 1) }));
613
616
  }
614
617
 
615
618
  // A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
@@ -768,14 +771,14 @@ export class Room {
768
771
  let lease = null;
769
772
  let controlRun = null;
770
773
  let createRun = null;
771
- if (mode === 3 && (requester === 'you' || planId) && depth <= 1 && agentScopes.maxMode >= 3 && enabled.write) {
774
+ if (mode >= 3 && (requester === 'you' || planId) && depth <= 1 && agentScopes.maxMode >= mode && enabled.write) {
772
775
  // CONTROL: the project itself is the writable root, and a checkpoint
773
776
  // taken now makes every change of this turn reversible. A plan step gets
774
777
  // it when the orchestrator, itself in #3, named #3 for that step.
775
778
  if (this.#controlDesk.holder) {
776
779
  await this.#alert('control-busy', `@${this.#controlDesk.holder.agent} still holds CONTROL; @${agent.id} answers read-only this turn.`, `control-busy:${agent.id}`);
777
780
  } else {
778
- const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled });
781
+ const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode });
779
782
  controlRun = seat.run;
780
783
  lease = seat.lease;
781
784
  await this.#emit('control.started', seat.announcement);
@@ -830,7 +833,7 @@ export class Room {
830
833
  ? imageStudioFor({ enabled: true, model: studio.model, outDir: lease.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : lease.outDir })
831
834
  : null;
832
835
  // The turn's effective mode: #0 for ghosts, #3 in control, #2 only while it holds a lease.
833
- const turnMode = mode === 0 ? 0 : controlRun ? 3 : lease ? 2 : 1;
836
+ const turnMode = mode === 0 ? 0 : controlRun ? controlRun.mode : lease ? 2 : 1;
834
837
  try {
835
838
  const invoke = this.#invokers[agent.adapter];
836
839
  if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
@@ -839,11 +842,15 @@ export class Room {
839
842
  const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
840
843
  const others = this.delegatesFor(agent.id);
841
844
  const mayDelegate = allowDelegation && this.#delegation && depth === 0;
845
+ // Tools the modules hand to this turn (a browser, say), attached to the CLI for this run only.
846
+ const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
847
+ ? await this.#toolsForTurn({ agent: agent.id, mode: turnMode, lease, scratchDir: lease?.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : null }).catch(() => [])
848
+ : [];
842
849
  const result = await invoke({
843
850
  executable: agent.path,
844
851
  projectRoot: this.#projectRoot,
845
852
  text,
846
- prompt: this.#prompt({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ashCode, mode: turnMode, escalation, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null }),
853
+ prompt: this.#prompt({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ashCode, mode: turnMode, escalation, mcpServers, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null }),
847
854
  timeoutMs: this.timeoutFor(agent.id),
848
855
  signal,
849
856
  model,
@@ -852,6 +859,7 @@ export class Room {
852
859
  scopes: turnScopes,
853
860
  imageStudio,
854
861
  memoryServer: memoryServerForTurn(this.#memoryServer, { agent: agent.id, messageId: responseMessageId, mode: turnMode }),
862
+ mcpServers,
855
863
  // For @madre: who asks, who it may convene, and whether a plan would run at all.
856
864
  requester,
857
865
  crew: others,
@@ -932,7 +940,7 @@ export class Room {
932
940
  // The plan's ceiling is the human's mode: #3 when the orchestrator held CONTROL, #2 while a
933
941
  // lease exists, #1 otherwise. A step may ask for a mode ("@codex #2: …"); it gets the lowest
934
942
  // of what it asked, the ceiling and its own MAX MODE. Without a number it inherits the plan.
935
- const ceiling = mode >= 3 ? 3 : lease ? 2 : 1;
943
+ const ceiling = mode >= 3 ? mode : lease ? 2 : 1;
936
944
  const stepMode = (step) => {
937
945
  const scopes = this.scopesFor(step.agent);
938
946
  const asked = normalizeMode(step.mode, lease ? 2 : 1);
@@ -967,7 +975,7 @@ export class Room {
967
975
  const wanted = stepMode(step);
968
976
  // Under a #3 ceiling the orchestrator's word is enough: a #2 step gets its project lease,
969
977
  // a #3 step gets CONTROL for its turn. Under #1 a creation step still asks the human.
970
- if (ceiling === 3 && !stepLease && wanted === 2) {
978
+ if (ceiling >= 3 && !stepLease && wanted === 2) {
971
979
  stepLease = await this.#projectLease({ leaseId: randomUUID(), messageId });
972
980
  stepLease.scopeCeiling = { write: true, imageGen: true, web: true };
973
981
  stepLease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, stepScopes[scope].enabled && stepScopes[scope].wired]));
@@ -989,12 +997,12 @@ export class Room {
989
997
  ashCode: ashCode ? { active: true, applied: abbreviated.applied, reason: abbreviated.reason, language: abbreviated.language, originalChars: abbreviated.originalChars, encodedChars: abbreviated.encodedChars } : undefined,
990
998
  status: 'delegated',
991
999
  planId,
992
- mode: wanted === 3 ? 3 : stepLease && stepScopes.write.enabled ? 2 : 1,
1000
+ mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1,
993
1001
  escalation: escalation ?? undefined,
994
1002
  step: index + 1,
995
1003
  totalSteps: directives.steps.length + (directives.closing ? 1 : 0),
996
1004
  });
997
- await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: wanted === 3 ? null : stepLease, ashCode, mode: wanted === 3 ? 3 : stepLease && stepScopes.write.enabled ? 2 : 1, escalation });
1005
+ await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: wanted >= 3 ? null : stepLease, ashCode, mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1, escalation });
998
1006
  }
999
1007
  if (!plan.stopped && directives.closing) {
1000
1008
  plan.step = directives.steps.length + 1;
package/src/server.mjs CHANGED
@@ -13,7 +13,7 @@ import { memoryServerFor } from './memory-tools.mjs';
13
13
  import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
14
14
  import { ErrorSentinel } from './sentinel-errors.mjs';
15
15
  import { probeOllama, ollamaEmbedder, ollamaInvoker, pullModel } from './ollama.mjs';
16
- import { moduleById, describeModules, findModuleRoute } from './modules/index.mjs';
16
+ import { moduleById, describeModules, findModuleRoute, toolsForTurn as modulesToolsForTurn } from './modules/index.mjs';
17
17
  import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
18
18
  import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
19
19
 
@@ -43,7 +43,8 @@ import { extensionById, runInstaller } from './extensions.mjs';
43
43
  import { loginPlanFor, probeAll } from './auth-probe.mjs';
44
44
  import { loadConfig as readConfig, updateConfig } from './config.mjs';
45
45
  import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
46
- import { checkForUpdate, detectInstall, updateCommand, releaseUrl } from './updates.mjs';
46
+ import { checkForUpdate, detectInstall, updateCommand, releaseUrl, applyCommand } from './updates.mjs';
47
+ import { spawn } from 'node:child_process';
47
48
  import { totalmem } from 'node:os';
48
49
  import { discoverModels } from './models.mjs';
49
50
  import { setImageModule } from './capabilities.mjs';
@@ -207,11 +208,15 @@ export async function createPulseServer({
207
208
  const mother = new MotherChannel(join(canonicalProjectRoot, '.pulse', 'mother.env'), { meta: memory ? { get: (key) => memory.metaGet(key), set: (key, value) => memory.metaSet(key, value) } : null });
208
209
  const motherOutcome = await mother.load().catch((error) => { console.error(`MADRE could not open MOTHER's channel: ${error.message}`); return null; });
209
210
  const startupMemory = await memoryConfig();
211
+ let listening = null;
212
+ const listeningPort = () => listening?.address?.()?.port ?? Number(process.env.PULSE_PORT ?? 4317);
210
213
  room = new Room({
211
214
  store,
212
215
  agents,
213
216
  projectRoot,
214
217
  privacy,
218
+ // Modules may hand tools to a turn; the room asks once per turn, the server knows the port.
219
+ toolsForTurn: async (turn) => modulesToolsForTurn(await moduleContext(), { ...turn, port: listeningPort(), roomDir }),
215
220
  softTokenBudget,
216
221
  contextMaxChars,
217
222
  recallShare: Number(process.env.PULSE_RECALL_SHARE ?? startupMemory.recallShare),
@@ -324,7 +329,7 @@ export async function createPulseServer({
324
329
  config.scopes[agentId] = { ...(current[agentId] ?? {}) };
325
330
  for (const scope of ['write', 'imageGen', 'web', 'alwaysCreate']) if (typeof scopes[scope] === 'boolean') config.scopes[agentId][scope] = scopes[scope];
326
331
  // MAX MODE and DEFAULT MODE are the two controls; setting them retires the older flags they replace.
327
- if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 3) { config.scopes[agentId].maxMode = Number(scopes.maxMode); delete config.scopes[agentId].write; }
332
+ if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 4) { config.scopes[agentId].maxMode = Number(scopes.maxMode); delete config.scopes[agentId].write; }
328
333
  if (Number.isInteger(Number(scopes.defaultMode)) && Number(scopes.defaultMode) >= 1 && Number(scopes.defaultMode) <= 2) { config.scopes[agentId].defaultMode = Number(scopes.defaultMode); delete config.scopes[agentId].alwaysCreate; }
329
334
  }
330
335
  room.setScopes(config.scopes);
@@ -786,6 +791,26 @@ export async function createPulseServer({
786
791
  if (request.method === 'GET' && url.pathname === '/api/version') {
787
792
  return sendJson(response, 200, await versionView({ force: url.searchParams.get('force') === '1' }));
788
793
  }
794
+ // One click: install the newer version and come back on the same port. The room records it,
795
+ // closes its listener so the port is free, hands the command to a detached shell and exits.
796
+ if (request.method === 'POST' && url.pathname === '/api/updates/apply') {
797
+ const info = await versionView();
798
+ if (!info.available) return sendJson(response, 409, { error: `Nothing to apply: ${info.current} is the latest MADRE knows of.` });
799
+ const command = applyCommand({ install, name: PACKAGE.name, version: info.latest, port: request.socket.localPort, projectRoot: canonicalProjectRoot });
800
+ if (!command) return sendJson(response, 412, { error: 'This copy runs from source: pull the repository and start it again.' });
801
+ if (room.activeTurns().length || room.activePlans().length) return sendJson(response, 409, { error: 'Agents are still working. STOPALL or wait, then update.' });
802
+ await room.record('room.updating', { from: info.current, to: info.latest, install, command });
803
+ sendJson(response, 202, { restarting: true, from: info.current, to: info.latest, command });
804
+ setTimeout(() => {
805
+ server.close(() => {
806
+ const child = spawn('/bin/sh', ['-c', command], { cwd: canonicalProjectRoot, detached: true, stdio: 'ignore', env: { ...process.env, PULSE_UPDATE_RESTART: '1' } });
807
+ child.unref();
808
+ setTimeout(() => process.exit(0), 200);
809
+ });
810
+ for (const client of clients.keys()) { try { client.end(); } catch { /* gone */ } }
811
+ }, 300);
812
+ return undefined;
813
+ }
789
814
  if (request.method === 'POST' && url.pathname === '/api/updates/settings') {
790
815
  const patch = await body(request).catch(() => ({}));
791
816
  if (typeof patch.check === 'boolean') { updatesConfig = { ...updatesConfig, check: patch.check }; await updateConfig(root, { updates: updatesConfig }); }
@@ -905,6 +930,7 @@ export async function createPulseServer({
905
930
  sendJson(response, 400, { error: error.message });
906
931
  }
907
932
  });
933
+ listening = server;
908
934
  // `server.close()` only resolves once every connection has ended, so the
909
935
  // long-lived SSE responses must be ended before the native close runs.
910
936
  const nativeClose = server.close.bind(server);
package/src/updates.mjs CHANGED
@@ -38,6 +38,19 @@ export function updateCommand(install, name, version = 'latest') {
38
38
  }
39
39
  }
40
40
 
41
+ // One shell line that installs the version and starts the same room on the same port.
42
+ // The room's own process runs it detached after closing its listener, then exits.
43
+ const shq = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`;
44
+ export function applyCommand({ install, name, version, port, projectRoot }) {
45
+ const start = `start --no-open --port ${Number(port) || 4317} --project ${shq(projectRoot)}`;
46
+ switch (install) {
47
+ case 'npx': return `exec npx -y ${name}@${version} ${start}`;
48
+ case 'project': return `npm install ${name}@${version} --no-fund --no-audit && exec npx --no ${name.split('/').pop()} ${start}`;
49
+ case 'global': return `npm install -g ${name}@${version} --no-fund --no-audit && exec ${name.split('/').pop()} ${start}`;
50
+ default: return null; // source: the human pulls
51
+ }
52
+ }
53
+
41
54
  export function releaseUrl(repository, version) {
42
55
  const url = String(repository?.url ?? repository ?? '').replace(/^git\+/, '').replace(/\.git$/, '');
43
56
  return url ? `${url}/releases/tag/v${version}` : null;