@jossuealcala/madre 0.3.1 → 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 +28 -0
- package/CONTRIBUTING.md +3 -0
- package/README.md +15 -11
- package/docs/INTERNALS.md +7 -1
- package/package.json +1 -1
- package/public/app.js +146 -69
- package/public/index.html +1 -1
- package/public/styles.css +26 -8
- package/public/troubleshooting.js +8 -8
- package/src/adapters/claude.mjs +16 -7
- package/src/adapters/codex.mjs +16 -13
- package/src/adapters/gemini.mjs +27 -14
- package/src/adapters/opencode.mjs +36 -16
- package/src/capabilities.mjs +12 -9
- package/src/checkpoint.mjs +118 -21
- package/src/commands.mjs +25 -3
- package/src/directives.mjs +7 -4
- package/src/lease.mjs +15 -8
- 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 +24 -12
- package/src/room/prompt.mjs +6 -3
- package/src/room.mjs +101 -37
- package/src/server.mjs +31 -3
- package/src/updates.mjs +13 -0
package/src/room.mjs
CHANGED
|
@@ -21,11 +21,13 @@ import { parseDirectives } from './directives.mjs';
|
|
|
21
21
|
import { isValidModelName } from './models.mjs';
|
|
22
22
|
import { MODES, SCOPES, SCOPE_LABELS, capabilitySummary, normalizeMode, resolveScopes } from './capabilities.mjs';
|
|
23
23
|
import { createLease, diffSnapshots, snapshot } from './lease.mjs';
|
|
24
|
+
import { stat as statFile } from 'node:fs/promises';
|
|
25
|
+
import { basename as baseName, join as joinPath } from 'node:path';
|
|
26
|
+
import { contentTypeFor } from './files.mjs';
|
|
24
27
|
import { imageStudioFor } from './image-studio.mjs';
|
|
25
28
|
import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
|
|
26
29
|
import { resolveReferences } from './files.mjs';
|
|
27
30
|
import { compressAshCode } from './ashcode.mjs';
|
|
28
|
-
import { isGitRepo } from './checkpoint.mjs';
|
|
29
31
|
|
|
30
32
|
// Adapters can fail with multi-line stderr or stack traces. The room keeps only
|
|
31
33
|
// the first meaningful line, bounded, so the event log and the UI stay readable.
|
|
@@ -81,6 +83,7 @@ export class Room {
|
|
|
81
83
|
#scopeConfig = {};
|
|
82
84
|
#delegation;
|
|
83
85
|
#privacy = null;
|
|
86
|
+
#toolsForTurn = null;
|
|
84
87
|
#maxPlanSteps;
|
|
85
88
|
#maxConcurrentTurns;
|
|
86
89
|
#planMaxAgeMs;
|
|
@@ -98,6 +101,7 @@ export class Room {
|
|
|
98
101
|
memoryServer = null,
|
|
99
102
|
mother = null,
|
|
100
103
|
privacy = null,
|
|
104
|
+
toolsForTurn = null, // async ({ agent, mode, lease, scratchDir }) => MCP server specs from the modules
|
|
101
105
|
historicalEvents = [],
|
|
102
106
|
invokers = defaultInvokers,
|
|
103
107
|
agentTimeouts = {},
|
|
@@ -117,6 +121,7 @@ export class Room {
|
|
|
117
121
|
this.#memoryServer = memoryServer;
|
|
118
122
|
this.#mother = mother;
|
|
119
123
|
this.#privacy = privacy;
|
|
124
|
+
this.#toolsForTurn = toolsForTurn;
|
|
120
125
|
this.#invokers = invokers;
|
|
121
126
|
this.#agentTimeouts = agentTimeouts;
|
|
122
127
|
this.#maxMessageChars = maxMessageChars;
|
|
@@ -193,8 +198,7 @@ export class Room {
|
|
|
193
198
|
}
|
|
194
199
|
const scopes = this.scopesFor(step.agent);
|
|
195
200
|
const enabled = Object.fromEntries(SCOPES.map((scope) => [scope, scopes[scope].enabled && scopes[scope].wired]));
|
|
196
|
-
const lease = await
|
|
197
|
-
lease.messageId = parentMessageId;
|
|
201
|
+
const lease = await this.#projectLease({ leaseId: randomUUID(), messageId: parentMessageId });
|
|
198
202
|
lease.scopes = decision.decision === 'plan' ? Object.fromEntries(SCOPES.map((scope) => [scope, true])) : enabled;
|
|
199
203
|
await this.#emit('lease.granted', {
|
|
200
204
|
escalated: decision.decision,
|
|
@@ -202,6 +206,7 @@ export class Room {
|
|
|
202
206
|
messageId: parentMessageId,
|
|
203
207
|
agent: step.agent,
|
|
204
208
|
outDir: lease.relativeDir,
|
|
209
|
+
scratchDir: lease.scratchDir,
|
|
205
210
|
scopes: SCOPES.filter((scope) => enabled[scope]),
|
|
206
211
|
unavailable: SCOPES.filter((scope) => !scopes[scope].capable).map((scope) => SCOPE_LABELS[scope]),
|
|
207
212
|
planId,
|
|
@@ -354,11 +359,12 @@ export class Room {
|
|
|
354
359
|
const wanted = normalizeMode(mode, create === true ? 2 : 1);
|
|
355
360
|
if (!parsed.target) return { ok: true, mode: wanted };
|
|
356
361
|
const scopes = this.scopesFor(parsed.target);
|
|
357
|
-
if (wanted
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (
|
|
361
|
-
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 };
|
|
362
368
|
}
|
|
363
369
|
// #2 above the agent's ceiling still goes out: the room answers read-only
|
|
364
370
|
// and says why (lease.refused), the way CREATE has always behaved.
|
|
@@ -517,7 +523,7 @@ export class Room {
|
|
|
517
523
|
if (!gate.ok) throw new Error(gate.error);
|
|
518
524
|
create = requestedMode === 2;
|
|
519
525
|
const ghost = requestedMode === 0;
|
|
520
|
-
const control = requestedMode
|
|
526
|
+
const control = requestedMode >= 3;
|
|
521
527
|
const files = (Array.isArray(attachments) ? attachments : []).map((id) => this.attachment(id)).filter(Boolean);
|
|
522
528
|
if (!parsed.text && !files.length) throw new Error('Write a message first.');
|
|
523
529
|
if (!parsed.target) throw new Error('Choose an agent or begin with @agent.');
|
|
@@ -536,7 +542,7 @@ export class Room {
|
|
|
536
542
|
if (ghost) this.#ghost.add(messageId);
|
|
537
543
|
await this.#emit('message.created', {
|
|
538
544
|
messageId,
|
|
539
|
-
mode: ghost ? 0 : control ?
|
|
545
|
+
mode: ghost ? 0 : control ? requestedMode : (standing || create ? 2 : 1),
|
|
540
546
|
role: 'user',
|
|
541
547
|
sender: 'you',
|
|
542
548
|
target: parsed.target,
|
|
@@ -574,10 +580,9 @@ export class Room {
|
|
|
574
580
|
message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (enable it in CONNECTIONS)' : 'its CLI cannot create files'}${unavailable.length ? `; it cannot ${unavailable.join(' or ')}` : ''}.${alternatives.length ? ` For creation ask ${alternatives.join(' or ')}.` : ''}`,
|
|
575
581
|
});
|
|
576
582
|
} else {
|
|
577
|
-
lease = await
|
|
578
|
-
lease.messageId = messageId;
|
|
583
|
+
lease = await this.#projectLease({ leaseId: randomUUID(), messageId });
|
|
579
584
|
// CREATE authorizes the plan to use each delegate's enabled creation
|
|
580
|
-
// scopes; a
|
|
585
|
+
// scopes; a default-#2 lease authorizes files only.
|
|
581
586
|
lease.scopeCeiling = { write: true, imageGen: !standing, web: true };
|
|
582
587
|
lease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, enabled.includes(scope) && (!standing || scope === 'write')]));
|
|
583
588
|
await this.#emit('lease.granted', {
|
|
@@ -586,6 +591,7 @@ export class Room {
|
|
|
586
591
|
messageId,
|
|
587
592
|
agent: parsed.target,
|
|
588
593
|
outDir: lease.relativeDir,
|
|
594
|
+
scratchDir: lease.scratchDir,
|
|
589
595
|
grantedBy: 'you',
|
|
590
596
|
scopes: standing ? ['write'] : enabled,
|
|
591
597
|
unavailable,
|
|
@@ -606,7 +612,26 @@ export class Room {
|
|
|
606
612
|
allowDelegation = false;
|
|
607
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}`);
|
|
608
614
|
}
|
|
609
|
-
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) }));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
|
|
619
|
+
// under .pulse/out/ for what has no natural place. Each turn under it takes a checkpoint
|
|
620
|
+
// and puts back whatever existed before, so CREATE only ever adds.
|
|
621
|
+
async #projectLease({ leaseId, messageId }) {
|
|
622
|
+
const scratch = await createLease({ projectRoot: this.#projectRoot, leaseId });
|
|
623
|
+
return { leaseId, messageId, outDir: this.#projectRoot, relativeDir: '.', scratchDir: scratch.relativeDir, create: true };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Artifacts from a #2 turn: the files the checkpoint saw appear, as the room shows them.
|
|
627
|
+
async #artifactsFrom(files) {
|
|
628
|
+
const out = [];
|
|
629
|
+
for (const file of files) {
|
|
630
|
+
if (file.status !== 'A') continue;
|
|
631
|
+
const info = await statFile(joinPath(this.#projectRoot, file.path)).catch(() => null);
|
|
632
|
+
out.push({ name: baseName(file.path), path: file.path, size: info?.size ?? 0, contentType: contentTypeFor(file.path), status: 'created' });
|
|
633
|
+
}
|
|
634
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
610
635
|
}
|
|
611
636
|
|
|
612
637
|
// Agents that can receive a delegated step from `self`.
|
|
@@ -714,7 +739,7 @@ export class Room {
|
|
|
714
739
|
// The orchestrator's turn is over before its plan starts, so it is never
|
|
715
740
|
// counted as in flight while the others work.
|
|
716
741
|
if (outcome?.directives?.steps.length) {
|
|
717
|
-
await this.#runPlan({ orchestrator: agent.id, parentMessageId: outcome.responseMessageId, directives: outcome.directives, lease, ashCode });
|
|
742
|
+
await this.#runPlan({ orchestrator: agent.id, parentMessageId: outcome.responseMessageId, directives: outcome.directives, lease, ashCode, mode });
|
|
718
743
|
}
|
|
719
744
|
return outcome?.responseMessageId ?? null;
|
|
720
745
|
}
|
|
@@ -745,13 +770,19 @@ export class Room {
|
|
|
745
770
|
const turnScopes = { web: enabled.web, imageGen: enabled.imageGen };
|
|
746
771
|
let lease = null;
|
|
747
772
|
let controlRun = null;
|
|
748
|
-
|
|
773
|
+
let createRun = null;
|
|
774
|
+
if (mode >= 3 && (requester === 'you' || planId) && depth <= 1 && agentScopes.maxMode >= mode && enabled.write) {
|
|
749
775
|
// CONTROL: the project itself is the writable root, and a checkpoint
|
|
750
|
-
// taken now makes every change of this turn reversible.
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
776
|
+
// taken now makes every change of this turn reversible. A plan step gets
|
|
777
|
+
// it when the orchestrator, itself in #3, named #3 for that step.
|
|
778
|
+
if (this.#controlDesk.holder) {
|
|
779
|
+
await this.#alert('control-busy', `@${this.#controlDesk.holder.agent} still holds CONTROL; @${agent.id} answers read-only this turn.`, `control-busy:${agent.id}`);
|
|
780
|
+
} else {
|
|
781
|
+
const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode });
|
|
782
|
+
controlRun = seat.run;
|
|
783
|
+
lease = seat.lease;
|
|
784
|
+
await this.#emit('control.started', seat.announcement);
|
|
785
|
+
}
|
|
755
786
|
} else if (sharedLease) {
|
|
756
787
|
lease = enabled.write ? {
|
|
757
788
|
...sharedLease,
|
|
@@ -760,8 +791,7 @@ export class Room {
|
|
|
760
791
|
} else if (agentScopes.write.always && enabled.write && requester !== 'you' && requester !== 'mother') {
|
|
761
792
|
// Standing lease for a delegate: the human opted this agent into
|
|
762
793
|
// creating files on every turn, so a plan step gets its own directory.
|
|
763
|
-
lease = await
|
|
764
|
-
lease.messageId = messageId;
|
|
794
|
+
lease = await this.#projectLease({ leaseId: randomUUID(), messageId });
|
|
765
795
|
lease.scopeCeiling = { write: true, imageGen: false, web: true };
|
|
766
796
|
lease.scopes = { ...enabled, imageGen: false };
|
|
767
797
|
await this.#emit('lease.granted', {
|
|
@@ -770,11 +800,18 @@ export class Room {
|
|
|
770
800
|
messageId,
|
|
771
801
|
agent: agent.id,
|
|
772
802
|
outDir: lease.relativeDir,
|
|
803
|
+
scratchDir: lease.scratchDir,
|
|
773
804
|
scopes: ['write'],
|
|
774
805
|
unavailable: SCOPES.filter((scope) => !agentScopes[scope].capable).map((scope) => SCOPE_LABELS[scope]),
|
|
775
806
|
planId,
|
|
776
807
|
});
|
|
777
808
|
}
|
|
809
|
+
if (lease?.create && !controlRun) {
|
|
810
|
+
// CREATE: photograph the project now; after the turn only what appeared stays.
|
|
811
|
+
const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode: 2 });
|
|
812
|
+
createRun = seat.run;
|
|
813
|
+
lease = { ...lease, checkpoint: seat.run.checkpoint };
|
|
814
|
+
}
|
|
778
815
|
turnScopes.imageGen = Boolean(lease?.scopes?.imageGen);
|
|
779
816
|
// The step asks for files but nobody granted a lease: say so now, with a
|
|
780
817
|
// way out, instead of letting the agent's refusal be the only signal.
|
|
@@ -793,23 +830,27 @@ export class Room {
|
|
|
793
830
|
const native = Boolean(CAPABILITIES[agent.id]?.imageGen);
|
|
794
831
|
const studio = imageModuleState();
|
|
795
832
|
const imageStudio = lease?.scopes?.imageGen && enabled.imageGen && !native && studio.enabled
|
|
796
|
-
? imageStudioFor({ enabled: true, model: studio.model, outDir: lease.outDir })
|
|
833
|
+
? imageStudioFor({ enabled: true, model: studio.model, outDir: lease.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : lease.outDir })
|
|
797
834
|
: null;
|
|
798
835
|
// The turn's effective mode: #0 for ghosts, #3 in control, #2 only while it holds a lease.
|
|
799
|
-
const turnMode = mode === 0 ? 0 : controlRun ?
|
|
836
|
+
const turnMode = mode === 0 ? 0 : controlRun ? controlRun.mode : lease ? 2 : 1;
|
|
800
837
|
try {
|
|
801
838
|
const invoke = this.#invokers[agent.adapter];
|
|
802
839
|
if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
|
|
803
|
-
const before = lease && !lease.control ? await snapshot(lease.outDir) : null;
|
|
840
|
+
const before = lease && !lease.control && !lease.create ? await snapshot(lease.outDir) : null;
|
|
804
841
|
const responseMessageId = randomUUID();
|
|
805
842
|
const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
|
|
806
843
|
const others = this.delegatesFor(agent.id);
|
|
807
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
|
+
: [];
|
|
808
849
|
const result = await invoke({
|
|
809
850
|
executable: agent.path,
|
|
810
851
|
projectRoot: this.#projectRoot,
|
|
811
852
|
text,
|
|
812
|
-
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 }),
|
|
813
854
|
timeoutMs: this.timeoutFor(agent.id),
|
|
814
855
|
signal,
|
|
815
856
|
model,
|
|
@@ -818,6 +859,7 @@ export class Room {
|
|
|
818
859
|
scopes: turnScopes,
|
|
819
860
|
imageStudio,
|
|
820
861
|
memoryServer: memoryServerForTurn(this.#memoryServer, { agent: agent.id, messageId: responseMessageId, mode: turnMode }),
|
|
862
|
+
mcpServers,
|
|
821
863
|
// For @madre: who asks, who it may convene, and whether a plan would run at all.
|
|
822
864
|
requester,
|
|
823
865
|
crew: others,
|
|
@@ -828,7 +870,9 @@ export class Room {
|
|
|
828
870
|
// the archivist or any other agent can see it. The reply says how many, not which.
|
|
829
871
|
const guarded = this.#privacy?.redact(result.text ?? '') ?? { text: result.text, hits: 0 };
|
|
830
872
|
if (guarded.hits) result.text = guarded.text;
|
|
831
|
-
|
|
873
|
+
// CREATE: what appeared stays and is shown; what existed before is put back and said.
|
|
874
|
+
const createChanges = createRun ? await this.#controlDesk.settle(createRun) : null;
|
|
875
|
+
const artifacts = createChanges ? await this.#artifactsFrom(createChanges.files) : lease && !lease.control ? diffSnapshots(before, await snapshot(lease.outDir), { relativeDir: lease.relativeDir }) : [];
|
|
832
876
|
// CONTROL: what really changed in the project, forbidden zones reverted on the spot.
|
|
833
877
|
const controlChanges = controlRun ? await this.#controlDesk.settle(controlRun) : null;
|
|
834
878
|
const directives = mayDelegate
|
|
@@ -866,6 +910,9 @@ export class Room {
|
|
|
866
910
|
if (artifacts.length) {
|
|
867
911
|
await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
|
|
868
912
|
}
|
|
913
|
+
if (createChanges && (createChanges.existingReverted.length || createChanges.forbiddenReverted.length)) {
|
|
914
|
+
await this.#emit('create.reverted', { checkpointId: createChanges.checkpointId, agent: agent.id, messageId, responseMessageId, existing: createChanges.existingReverted, forbidden: createChanges.forbiddenReverted, message: createChanges.message });
|
|
915
|
+
}
|
|
869
916
|
// What the agent saved through memory_note during this turn, for the bubble's hint.
|
|
870
917
|
if (this.#memory && turnMode !== 0) {
|
|
871
918
|
const noted = this.#memory.notesSince(notesBefore, { agent: agent.id });
|
|
@@ -884,24 +931,32 @@ export class Room {
|
|
|
884
931
|
} finally {
|
|
885
932
|
// Release CONTROL whichever way the turn ended; the checkpoint stays for UNDO.
|
|
886
933
|
await this.#controlDesk.release(controlRun);
|
|
934
|
+
await this.#controlDesk.release(createRun);
|
|
887
935
|
}
|
|
888
936
|
}
|
|
889
937
|
|
|
890
|
-
async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ashCode = false }) {
|
|
938
|
+
async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ashCode = false, mode = 1 }) {
|
|
891
939
|
const planId = randomUUID();
|
|
892
|
-
// The plan
|
|
893
|
-
// step
|
|
894
|
-
|
|
940
|
+
// The plan's ceiling is the human's mode: #3 when the orchestrator held CONTROL, #2 while a
|
|
941
|
+
// lease exists, #1 otherwise. A step may ask for a mode ("@codex #2: …"); it gets the lowest
|
|
942
|
+
// of what it asked, the ceiling and its own MAX MODE. Without a number it inherits the plan.
|
|
943
|
+
const ceiling = mode >= 3 ? mode : lease ? 2 : 1;
|
|
944
|
+
const stepMode = (step) => {
|
|
945
|
+
const scopes = this.scopesFor(step.agent);
|
|
946
|
+
const asked = normalizeMode(step.mode, lease ? 2 : 1);
|
|
947
|
+
const granted = Math.min(asked, ceiling, scopes.maxMode);
|
|
948
|
+
return granted >= 2 && !scopes.write.enabled ? 1 : granted;
|
|
949
|
+
};
|
|
895
950
|
const plan = { planId, orchestrator, steps: directives.steps, closing: directives.closing, step: 0, stopped: null, startedAt: Date.now() };
|
|
896
951
|
this.#plans.set(planId, plan);
|
|
897
952
|
await this.#emit('plan.created', {
|
|
898
953
|
planId,
|
|
899
954
|
orchestrator,
|
|
900
955
|
parentMessageId,
|
|
901
|
-
steps: directives.steps.map((step) => ({ ...step, mode: stepMode(step
|
|
956
|
+
steps: directives.steps.map((step) => ({ ...step, mode: stepMode(step) })),
|
|
902
957
|
closing: directives.closing,
|
|
903
958
|
ignored: directives.ignored,
|
|
904
|
-
mode:
|
|
959
|
+
mode: ceiling,
|
|
905
960
|
leaseId: lease?.leaseId ?? null,
|
|
906
961
|
});
|
|
907
962
|
try {
|
|
@@ -917,7 +972,16 @@ export class Room {
|
|
|
917
972
|
let stepLease = lease;
|
|
918
973
|
let escalation = null;
|
|
919
974
|
const stepScopes = this.scopesFor(step.agent);
|
|
920
|
-
|
|
975
|
+
const wanted = stepMode(step);
|
|
976
|
+
// Under a #3 ceiling the orchestrator's word is enough: a #2 step gets its project lease,
|
|
977
|
+
// a #3 step gets CONTROL for its turn. Under #1 a creation step still asks the human.
|
|
978
|
+
if (ceiling >= 3 && !stepLease && wanted === 2) {
|
|
979
|
+
stepLease = await this.#projectLease({ leaseId: randomUUID(), messageId });
|
|
980
|
+
stepLease.scopeCeiling = { write: true, imageGen: true, web: true };
|
|
981
|
+
stepLease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, stepScopes[scope].enabled && stepScopes[scope].wired]));
|
|
982
|
+
await this.#emit('lease.granted', { delegated: true, leaseId: stepLease.leaseId, messageId, agent: step.agent, outDir: '.', scratchDir: stepLease.scratchDir, grantedBy: orchestrator, scopes: SCOPES.filter((scope) => stepLease.scopes[scope]), unavailable: [], planId });
|
|
983
|
+
}
|
|
984
|
+
if (ceiling < 3 && !lease && !stepScopes.write.always && (wanted >= 2 || looksLikeCreation(step.text)) && stepScopes.maxMode >= 2 && stepScopes.write.enabled) {
|
|
921
985
|
const outcome = await this.#askForMode({ planId, plan, step, index, totalSteps: directives.steps.length + (directives.closing ? 1 : 0), orchestrator, parentMessageId, mode: 2 });
|
|
922
986
|
if (outcome.scope === 'plan') lease = outcome.lease;
|
|
923
987
|
if (outcome.scope) stepLease = outcome.lease; else escalation = outcome.reason;
|
|
@@ -933,12 +997,12 @@ export class Room {
|
|
|
933
997
|
ashCode: ashCode ? { active: true, applied: abbreviated.applied, reason: abbreviated.reason, language: abbreviated.language, originalChars: abbreviated.originalChars, encodedChars: abbreviated.encodedChars } : undefined,
|
|
934
998
|
status: 'delegated',
|
|
935
999
|
planId,
|
|
936
|
-
mode: stepLease && stepScopes.write.enabled ? 2 : 1,
|
|
1000
|
+
mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1,
|
|
937
1001
|
escalation: escalation ?? undefined,
|
|
938
1002
|
step: index + 1,
|
|
939
1003
|
totalSteps: directives.steps.length + (directives.closing ? 1 : 0),
|
|
940
1004
|
});
|
|
941
|
-
await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: stepLease, ashCode, mode: 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 });
|
|
942
1006
|
}
|
|
943
1007
|
if (!plan.stopped && directives.closing) {
|
|
944
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),
|
|
@@ -323,7 +328,9 @@ export async function createPulseServer({
|
|
|
323
328
|
if (!agents.some((agent) => agent.id === agentId) || !scopes || typeof scopes !== 'object') continue;
|
|
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.
|
|
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; }
|
|
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; }
|
|
327
334
|
}
|
|
328
335
|
room.setScopes(config.scopes);
|
|
329
336
|
}
|
|
@@ -784,6 +791,26 @@ export async function createPulseServer({
|
|
|
784
791
|
if (request.method === 'GET' && url.pathname === '/api/version') {
|
|
785
792
|
return sendJson(response, 200, await versionView({ force: url.searchParams.get('force') === '1' }));
|
|
786
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
|
+
}
|
|
787
814
|
if (request.method === 'POST' && url.pathname === '/api/updates/settings') {
|
|
788
815
|
const patch = await body(request).catch(() => ({}));
|
|
789
816
|
if (typeof patch.check === 'boolean') { updatesConfig = { ...updatesConfig, check: patch.check }; await updateConfig(root, { updates: updatesConfig }); }
|
|
@@ -903,6 +930,7 @@ export async function createPulseServer({
|
|
|
903
930
|
sendJson(response, 400, { error: error.message });
|
|
904
931
|
}
|
|
905
932
|
});
|
|
933
|
+
listening = server;
|
|
906
934
|
// `server.close()` only resolves once every connection has ended, so the
|
|
907
935
|
// long-lived SSE responses must be ended before the native close runs.
|
|
908
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;
|