@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/CHANGELOG.md +16 -0
- package/CONTRIBUTING.md +3 -0
- package/README.md +8 -6
- package/package.json +1 -1
- package/public/app.js +89 -32
- package/public/index.html +1 -1
- package/public/styles.css +26 -8
- package/public/troubleshooting.js +5 -5
- package/src/adapters/claude.mjs +12 -6
- package/src/adapters/codex.mjs +16 -13
- package/src/adapters/gemini.mjs +21 -11
- package/src/adapters/opencode.mjs +15 -12
- package/src/capabilities.mjs +4 -3
- package/src/commands.mjs +25 -3
- package/src/lease.mjs +5 -3
- package/src/modules/git-pulse.mjs +3 -3
- package/src/modules/index.mjs +7 -1
- package/src/modules/playwright.mjs +73 -0
- package/src/modules/sdk.mjs +11 -0
- package/src/room/control.mjs +4 -4
- package/src/room/prompt.mjs +6 -3
- package/src/room.mjs +25 -17
- package/src/server.mjs +29 -3
- package/src/updates.mjs +13 -0
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
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (
|
|
364
|
-
return { ok:
|
|
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
|
|
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 ?
|
|
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 ?
|
|
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
|
|
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 ?
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
|
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) <=
|
|
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;
|