@jossuealcala/madre 0.7.2 → 1.0.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 +817 -1
- package/CONTRIBUTING.md +5 -1
- package/NOTICE +20 -1
- package/README.md +99 -34
- package/TRADEMARKS.md +65 -0
- package/bin/madre.mjs +30 -10
- package/docs/INTERNALS.md +2 -2
- package/docs/REFERENCE.md +159 -82
- package/docs/SDK.md +23 -19
- package/docs/madre-banner.png +0 -0
- package/package.json +4 -3
- package/public/app.js +11668 -4219
- package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
- package/public/assets/brand/Geist-latin.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
- package/public/assets/brand/OFL-Geist.txt +93 -0
- package/public/assets/brand/OFL-GeistMono.txt +93 -0
- package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
- package/public/assets/brand/OFL-Sora.txt +93 -0
- package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
- package/public/assets/brand/Sora-latin.woff2 +0 -0
- package/public/assets/brand/madre-app-180.png +0 -0
- package/public/assets/brand/madre-app-192.png +0 -0
- package/public/assets/brand/madre-app-512.png +0 -0
- package/public/assets/brand/madre-app.svg +1 -0
- package/public/assets/brand/madre-favicon.svg +1 -0
- package/public/assets/news/1.0/alerts.webp +0 -0
- package/public/assets/news/1.0/bar.webp +0 -0
- package/public/assets/news/1.0/finder.webp +0 -0
- package/public/assets/news/1.0/github.webp +0 -0
- package/public/assets/news/1.0/intents.webp +0 -0
- package/public/assets/news/1.0/looks.webp +0 -0
- package/public/assets/news/1.0/modules.webp +0 -0
- package/public/assets/news/1.0/mother.webp +0 -0
- package/public/assets/news/1.0/nostromo.webp +0 -0
- package/public/assets/news/1.0/permissions.webp +0 -0
- package/public/assets/news/1.0/room.webp +0 -0
- package/public/assets/news/1.0/settings.webp +0 -0
- package/public/assets/wallpapers/biznaga.webp +0 -0
- package/public/assets/wallpapers/canela.webp +0 -0
- package/public/assets/wallpapers/jarrito.webp +0 -0
- package/public/assets/wallpapers/magna.webp +0 -0
- package/public/assets/wallpapers/oceano.webp +0 -0
- package/public/assets/wallpapers/paraiso.webp +0 -0
- package/public/assets/wallpapers/quila.webp +0 -0
- package/public/assets/wallpapers/tradicion.webp +0 -0
- package/public/assets/wallpapers/tropical.webp +0 -0
- package/public/assets/wallpapers/urbania.webp +0 -0
- package/public/assets/wallpapers/ventura.webp +0 -0
- package/public/assets/wallpapers/xico.webp +0 -0
- package/public/brands.js +28 -2
- package/public/editor.js +744 -0
- package/public/es.js +3443 -1425
- package/public/i18n.js +1 -1
- package/public/index.html +201 -175
- package/public/inquiry.js +11 -11
- package/public/loader.js +79 -0
- package/public/macroverso.js +1517 -0
- package/public/manifest.webmanifest +14 -0
- package/public/nostromo-engine.js +2222 -0
- package/public/resay.js +24 -0
- package/public/review-ask.js +85 -0
- package/public/salas-mac.css +1281 -0
- package/public/salas-mac.html +158 -0
- package/public/salas-mac.js +4593 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6372 -426
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +452 -0
- package/public/tour.js +546 -0
- package/public/troubleshooting.js +160 -141
- package/src/adapters/codex.mjs +46 -6
- package/src/adapters/gemini.mjs +30 -10
- package/src/adapters/madre.mjs +110 -18
- package/src/adapters/process.mjs +42 -2
- package/src/auth-probe.mjs +31 -8
- package/src/bridge/devices.mjs +202 -0
- package/src/bridge/handoff.mjs +45 -0
- package/src/bridge/host.mjs +82 -0
- package/src/bridge/network.mjs +133 -0
- package/src/bridge/policy.mjs +89 -0
- package/src/bridge/qr.mjs +287 -0
- package/src/bridge/server.mjs +269 -0
- package/src/capabilities.mjs +5 -9
- package/src/chats.mjs +59 -22
- package/src/checkpoint.mjs +137 -35
- package/src/choices.mjs +18 -3
- package/src/cli-args.mjs +12 -1
- package/src/commands.mjs +15 -15
- package/src/config.mjs +73 -9
- package/src/conversation-context.mjs +5 -2
- package/src/credentials.mjs +80 -0
- package/src/dataset.mjs +3 -1
- package/src/directives.mjs +6 -11
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +31 -12
- package/src/file-ops.mjs +23 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/heartbeat.mjs +55 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/local-ai.mjs +48 -0
- package/src/looks.mjs +88 -0
- package/src/maturity.mjs +3 -3
- package/src/mcp/github-server.mjs +164 -0
- package/src/mcp/image-server.mjs +1 -1
- package/src/mcp/memory-server.mjs +11 -3
- package/src/memory.mjs +38 -12
- package/src/models.mjs +127 -5
- package/src/modules/ahp.mjs +4 -4
- package/src/modules/ash.mjs +1 -1
- package/src/modules/bishop-plan.mjs +207 -0
- package/src/modules/bishop.mjs +645 -0
- package/src/modules/choices.mjs +2 -2
- package/src/modules/correo.mjs +1 -1
- package/src/modules/enlaces.mjs +255 -0
- package/src/modules/git-pulse.mjs +2 -2
- package/src/modules/github.mjs +322 -0
- package/src/modules/image-studio.mjs +3 -3
- package/src/modules/index.mjs +18 -4
- package/src/modules/intents-calibration.mjs +338 -0
- package/src/modules/intents-desk.mjs +481 -0
- package/src/modules/intents-observe.mjs +266 -0
- package/src/modules/intents-policy.mjs +414 -0
- package/src/modules/intents-try.mjs +91 -0
- package/src/modules/intents.mjs +145 -0
- package/src/modules/ollama.mjs +18 -6
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +2 -2
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +6 -1
- package/src/outbound.mjs +36 -17
- package/src/override.mjs +100 -0
- package/src/ports.mjs +23 -1
- package/src/prices.mjs +72 -0
- package/src/privacy.mjs +30 -11
- package/src/quota-sources.mjs +0 -8
- package/src/request-guard.mjs +1 -1
- package/src/review-ask.mjs +111 -0
- package/src/room/archivist.mjs +55 -37
- package/src/room/budget.mjs +17 -5
- package/src/room/control.mjs +70 -7
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +3 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +22 -15
- package/src/room/runs.mjs +84 -21
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +411 -84
- package/src/router.mjs +3 -1
- package/src/runs.mjs +23 -5
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1864 -178
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +174 -0
- package/src/updates.mjs +48 -8
- package/src/usage-sentinel.mjs +1 -1
- package/src/verdict.mjs +7 -7
- package/src/whats-new.mjs +160 -0
- package/docs/madre-banner.svg +0 -25
package/src/room.mjs
CHANGED
|
@@ -6,16 +6,16 @@ import { invokeOpenCode, buildOpenCodeArgs } from './adapters/opencode.mjs';
|
|
|
6
6
|
import { parseMessage } from './router.mjs';
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
8
|
import { basename } from 'node:path';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
9
10
|
import { UsageSentinel } from './usage-sentinel.mjs';
|
|
10
11
|
import { BLOCK_SOURCES, buildPrompt, promptParts, sparedChars } from './room/prompt.mjs';
|
|
11
12
|
import { turnCost, observedRate } from './room/economy.mjs';
|
|
12
13
|
import { contextFor } from './room/context.mjs';
|
|
13
|
-
import { VERDICTS } from './memory.mjs';
|
|
14
|
+
import { VERDICTS, travels } from './memory.mjs';
|
|
14
15
|
import { coldNotes, coldReading } from './cold.mjs';
|
|
15
16
|
import { questionsFor } from './asking.mjs';
|
|
16
17
|
import { coverageExam, consistencyExam, matchExam, exchanges, MATCH_SAMPLE, saysFor } from './exam.mjs';
|
|
17
18
|
import { MADRE_ADAPTER, MADRE_AGENT_ID } from './adapters/madre.mjs';
|
|
18
|
-
import { verdictFor } from './verdict.mjs';
|
|
19
19
|
import { ControlDesk } from './room/control.mjs';
|
|
20
20
|
import { RunDesk } from './room/runs.mjs';
|
|
21
21
|
import { Attachments } from './room/attachments.mjs';
|
|
@@ -28,19 +28,23 @@ import { OLLAMA_ARCHIVIST } from './distiller.mjs';
|
|
|
28
28
|
import { memoryServerForTurn } from './memory-tools.mjs';
|
|
29
29
|
import { CODE000_STRIKES } from './mother.mjs';
|
|
30
30
|
import { parseDirectives } from './directives.mjs';
|
|
31
|
-
import { isValidModelName, isValidEffort } from './models.mjs';
|
|
31
|
+
import { isValidModelName, isValidEffort, belongsElsewhere, MODEL_NOT_FOUND, codexTakes, readCodexEfforts } from './models.mjs';
|
|
32
|
+
import { withListPrice } from './prices.mjs';
|
|
32
33
|
import { MODES, SCOPES, SCOPE_LABELS, capabilitySummary, normalizeMode, resolveScopes } from './capabilities.mjs';
|
|
33
34
|
import { createLease, diffSnapshots, namedPaths, snapshot } from './lease.mjs';
|
|
34
|
-
import { stat as statFile } from 'node:fs/promises';
|
|
35
|
+
import { stat as statFile, readdir, copyFile, readFile as readFileBytes } from 'node:fs/promises';
|
|
35
36
|
import { basename as baseName, join as joinPath } from 'node:path';
|
|
36
|
-
import {
|
|
37
|
+
import { declaredTypeFor } from './files.mjs';
|
|
37
38
|
import { isModuleFile, sdkPaths } from './modules/index.mjs';
|
|
38
39
|
import { validateAshReply } from './modules/ash-policy.mjs';
|
|
40
|
+
import { PHONE_MAX_MODE } from './bridge/policy.mjs';
|
|
39
41
|
import { parseChoice, withoutChoice } from './choices.mjs';
|
|
40
|
-
import { parseRunRequest, withoutRunRequest, whyText } from './runs.mjs';
|
|
42
|
+
import { parseRunRequest, withoutRunRequest, whyText, expandHome } from './runs.mjs';
|
|
41
43
|
import { imageStudioFor } from './image-studio.mjs';
|
|
42
44
|
import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
|
|
43
45
|
import { resolveReferences } from './files.mjs';
|
|
46
|
+
|
|
47
|
+
const VIA_KINDS = new Set(['iphone', 'ipad', 'android', 'android-tablet', 'other']);
|
|
44
48
|
import { redactArgs } from './launch.mjs';
|
|
45
49
|
|
|
46
50
|
// Adapters can fail with multi-line stderr or stack traces. The room keeps only
|
|
@@ -67,14 +71,19 @@ export { budgetTokens };
|
|
|
67
71
|
// languages the crew actually speaks here.
|
|
68
72
|
const CREATION_VERB = /\b(genera(r|d)?|crea(r|d)?|escrib(e|a|ir)|guarda(r)?|produce|producir|exporta(r)?|redacta(r)?|construye|create|generate|write|save|export|produce|build|render|draw|make)\b/i;
|
|
69
73
|
const CREATION_OBJECT = /\b(archivo|fichero|file|pdf|md|markdown|imagen|image|png|jpe?g|svg|documento|document|docx?|csv|json|xlsx?|pptx?|html|script|carpeta|folder|reporte|report|informe|entregable|poster|logo|diagrama|diagram)\b/i;
|
|
70
|
-
|
|
74
|
+
// Unless it says, in so many words, not to write yet: «dame un plan antes de escribir código» was
|
|
75
|
+
// told it had no CREATE lease, for asking for exactly the thing that needs none.
|
|
76
|
+
const NOT_YET = /\b(antes de|sin|todav[ií]a no|a[uú]n no)\s+(escribir|crear|generar|tocar|cambiar|modificar|programar|codificar)\b|\bno\s+(escribas|crees|generes|toques|cambies|modifiques|programes|edites)\b|\b(before|without)\s+(writing|creating|coding|touching|changing|editing)\b|\b(don'?t|do not|not yet)\s+(write|create|code|touch|change|edit)\b/i;
|
|
77
|
+
export const looksLikeCreation = (text) => !NOT_YET.test(String(text ?? '')) && CREATION_VERB.test(String(text ?? '')) && CREATION_OBJECT.test(String(text ?? ''));
|
|
71
78
|
|
|
72
79
|
// Which decisions the project is standing on, out of what the archive holds. A refuted one is
|
|
73
80
|
// kept by the archive — what was once decided is part of the record — and left out here, because
|
|
74
81
|
// a strip that says "decided" cannot carry something the room has since established is false.
|
|
75
82
|
export function settledDecisions(notes = [], { limit = 3 } = {}) {
|
|
76
83
|
return notes
|
|
77
|
-
|
|
84
|
+
// Only what still travels: a note the human judged bad, or one set aside, is not «decided»
|
|
85
|
+
// (LOG-10 of the 1.0 review; `travels` is the archive's own rule, verdicts included).
|
|
86
|
+
.filter((note) => note && !note.refutedBy && travels(note))
|
|
78
87
|
.slice(0, limit)
|
|
79
88
|
.map((note) => ({ id: note.id, text: note.text, created: note.created, agent: note.agent, fromSequence: note.fromSequence }));
|
|
80
89
|
}
|
|
@@ -91,12 +100,16 @@ export function settledDecisions(notes = [], { limit = 3 } = {}) {
|
|
|
91
100
|
export function openQuestions(notes = [], { dismissed = [], limit = 3 } = {}) {
|
|
92
101
|
const skip = new Set(dismissed ?? []);
|
|
93
102
|
return notes
|
|
94
|
-
.filter((note) => note && note.kind === 'question' && !note.refutedBy && !skip.has(`open:${note.id}`))
|
|
103
|
+
.filter((note) => note && note.kind === 'question' && !note.refutedBy && travels(note) && !skip.has(`open:${note.id}`))
|
|
95
104
|
.sort((a, b) => (Number(b.recalled ?? 0) - Number(a.recalled ?? 0)) || (b.id - a.id))
|
|
96
105
|
.slice(0, limit)
|
|
97
106
|
.map((note) => ({ id: note.id, text: note.text, carried: Number(note.recalled ?? 0) }));
|
|
98
107
|
}
|
|
99
108
|
|
|
109
|
+
// A turn's time limit when the human set none: reading answers, writing builds.
|
|
110
|
+
export const READING_TIMEOUT_MS = 180000;
|
|
111
|
+
export const WRITING_TIMEOUT_MS = 600000;
|
|
112
|
+
|
|
100
113
|
export class Room {
|
|
101
114
|
#store;
|
|
102
115
|
#agents;
|
|
@@ -116,6 +129,7 @@ export class Room {
|
|
|
116
129
|
#escalation; // room/escalation: plan steps waiting for the human
|
|
117
130
|
#ghost = new GhostLedger();
|
|
118
131
|
#liveListeners = new Set();
|
|
132
|
+
#quotaSaid = new Map(); // agent + source -> the last reading written to the record
|
|
119
133
|
#memoryServer; // MCP descriptor handed to every turn so the agent can query the memory itself
|
|
120
134
|
#mother = null; // MotherChannel: her coded words to the crew
|
|
121
135
|
#shuttingDown = false;
|
|
@@ -131,6 +145,11 @@ export class Room {
|
|
|
131
145
|
#delegation;
|
|
132
146
|
#privacy = null;
|
|
133
147
|
#toolsForTurn = null;
|
|
148
|
+
#bishopForTurn = null;
|
|
149
|
+
#linkedForTurn = null;
|
|
150
|
+
// The turns running now, by the id of the answer they will write: which agent, at which mode.
|
|
151
|
+
// What a tool of a turn says it is (S-021) is checked against this, never taken on its word.
|
|
152
|
+
#liveTurns = new Map();
|
|
134
153
|
#maxPlanSteps;
|
|
135
154
|
#maxConcurrentTurns;
|
|
136
155
|
#planMaxAgeMs;
|
|
@@ -154,6 +173,8 @@ export class Room {
|
|
|
154
173
|
mother = null,
|
|
155
174
|
privacy = null,
|
|
156
175
|
toolsForTurn = null, // async ({ agent, mode, lease, scratchDir }) => MCP server specs from the modules
|
|
176
|
+
bishopForTurn = null, // async ({ agent, mode, text, preview, turn }) => bounded BISHOP context in Salas
|
|
177
|
+
linkedForTurn = null, // async ({ agent, mode, text, preview, turn }) => { text, dynamic } notes a link recalls (3e)
|
|
157
178
|
historicalEvents = [],
|
|
158
179
|
invokers = defaultInvokers,
|
|
159
180
|
agentTimeouts = {},
|
|
@@ -162,8 +183,17 @@ export class Room {
|
|
|
162
183
|
maxPlanSteps = 4,
|
|
163
184
|
maxConcurrentTurns = 3,
|
|
164
185
|
planMaxAgeMs = 300000,
|
|
186
|
+
// The project's one CONTROL desk, when the server keeps several conversations alive at once:
|
|
187
|
+
// one holder of the working tree across all of them, and an UNDO that works from any.
|
|
188
|
+
controlDesk = null,
|
|
165
189
|
escalationMs = Number(process.env.PULSE_ESCALATION_MS ?? 180000),
|
|
190
|
+
// Where Codex keeps its config and its models' cache: the levels each model takes.
|
|
191
|
+
codexHome = homedir(),
|
|
192
|
+
// The desk that runs what the human pressed; tests hand in one that sees ports they choose.
|
|
193
|
+
runDesk = null,
|
|
166
194
|
}) {
|
|
195
|
+
this.#codexHome = codexHome;
|
|
196
|
+
if (runDesk) this.#runDesk = runDesk;
|
|
167
197
|
this.#store = store;
|
|
168
198
|
this.#agents = agents;
|
|
169
199
|
this.#projectRoot = projectRoot;
|
|
@@ -175,6 +205,8 @@ export class Room {
|
|
|
175
205
|
this.#mother = mother;
|
|
176
206
|
this.#privacy = privacy;
|
|
177
207
|
this.#toolsForTurn = toolsForTurn;
|
|
208
|
+
this.#bishopForTurn = bishopForTurn;
|
|
209
|
+
this.#linkedForTurn = linkedForTurn;
|
|
178
210
|
this.#invokers = invokers;
|
|
179
211
|
this.#agentTimeouts = agentTimeouts;
|
|
180
212
|
this.#maxMessageChars = maxMessageChars;
|
|
@@ -183,7 +215,7 @@ export class Room {
|
|
|
183
215
|
this.#maxConcurrentTurns = maxConcurrentTurns;
|
|
184
216
|
this.#planMaxAgeMs = planMaxAgeMs;
|
|
185
217
|
this.#escalation = new Escalation({ escalationMs });
|
|
186
|
-
this.#controlDesk = new ControlDesk({ projectRoot });
|
|
218
|
+
this.#controlDesk = controlDesk ?? new ControlDesk({ projectRoot });
|
|
187
219
|
this.#budget = new Budget({ softBudget: softTokenBudget });
|
|
188
220
|
this.#budget.seed(historicalEvents, this.#sentinel);
|
|
189
221
|
// What this room has been charged per character, from its own past turns.
|
|
@@ -228,6 +260,7 @@ export class Room {
|
|
|
228
260
|
// because the tokens were really spent.
|
|
229
261
|
// CONTROL (#3): one holder per room, every turn between two checkpoints.
|
|
230
262
|
#controlDesk;
|
|
263
|
+
#codexHome;
|
|
231
264
|
control() { return this.#controlDesk.status(); }
|
|
232
265
|
async undoControl(checkpointId) {
|
|
233
266
|
const result = await this.#controlDesk.undo(checkpointId);
|
|
@@ -243,18 +276,28 @@ export class Room {
|
|
|
243
276
|
async #askForMode({ planId, plan, step, index, totalSteps, orchestrator, parentMessageId, mode = 2 }) {
|
|
244
277
|
const requestId = randomUUID();
|
|
245
278
|
const expiresAt = new Date(Date.now() + this.#escalation.ms).toISOString();
|
|
246
|
-
const payload = { requestId, planId, agent: step.agent, orchestrator, mode, step: index + 1, totalSteps, text: step.text, expiresAt, message: `@${step.agent} needs #${mode} ${MODES[mode].label} for step ${index + 1}
|
|
279
|
+
const payload = { requestId, planId, agent: step.agent, orchestrator, mode, step: index + 1, totalSteps, text: step.text, expiresAt, message: `@${step.agent} needs #${mode} ${MODES[mode].label} for step ${index + 1}. Grant it once, for the whole plan, or deny.` };
|
|
280
|
+
const pending = this.#escalation.wait(requestId, payload, plan);
|
|
247
281
|
await this.#emit('mode.requested', payload);
|
|
248
|
-
const decision = await
|
|
282
|
+
const decision = await pending;
|
|
249
283
|
if (decision.decision === 'deny' || plan.stopped) {
|
|
250
284
|
const reason = plan.stopped ? 'stopped' : decision.reason ?? 'denied';
|
|
251
|
-
await this.#emit('mode.denied', { requestId, planId, agent: step.agent, mode, step: index + 1, reason, message: reason === 'timeout' ? `No answer in ${Math.round(this.#escalation.ms / 1000)}s: @${step.agent} runs step ${index + 1} at #1.` : reason === 'stopped' ? `The plan was stopped while @${step.agent} waited for #${mode}.` : `Denied: @${step.agent} runs step ${index + 1} at #1 and will say what it could not
|
|
285
|
+
await this.#emit('mode.denied', { requestId, planId, agent: step.agent, mode, step: index + 1, reason, message: reason === 'timeout' ? `No answer in ${Math.round(this.#escalation.ms / 1000)}s: @${step.agent} runs step ${index + 1} at #1.` : reason === 'stopped' ? `The plan was stopped while @${step.agent} waited for #${mode}.` : `Denied: @${step.agent} runs step ${index + 1} at #1 and will say what it could not do.` });
|
|
252
286
|
return { scope: null, reason };
|
|
253
287
|
}
|
|
288
|
+
if (mode >= 3) {
|
|
289
|
+
await this.#emit('mode.granted', { requestId, planId, agent: step.agent, mode, step: index + 1, scope: decision.decision, message: `#${mode} granted to @${step.agent}${decision.decision === 'plan' ? ' for the rest of the plan' : ' for this step'}.` });
|
|
290
|
+
return { scope: decision.decision, mode };
|
|
291
|
+
}
|
|
254
292
|
const scopes = this.scopesFor(step.agent);
|
|
255
293
|
const enabled = Object.fromEntries(SCOPES.map((scope) => [scope, scopes[scope].enabled && scopes[scope].wired]));
|
|
256
294
|
const lease = await this.#projectLease({ leaseId: randomUUID(), messageId: parentMessageId });
|
|
257
|
-
|
|
295
|
+
// The turn reads its scopes from the ceiling, as every lease does (#runTurn); a lease granted
|
|
296
|
+
// here had none, so every scope arrived off — no images — while the room had announced them on
|
|
297
|
+
// (LOG-05 of the 1.0 review). What the human granted is the ceiling; the agent's own switches
|
|
298
|
+
// still decide below it.
|
|
299
|
+
lease.scopeCeiling = { write: true, imageGen: true, web: true };
|
|
300
|
+
lease.scopes = enabled;
|
|
258
301
|
await this.#emit('lease.granted', {
|
|
259
302
|
escalated: decision.decision,
|
|
260
303
|
leaseId: lease.leaseId,
|
|
@@ -271,6 +314,10 @@ export class Room {
|
|
|
271
314
|
}
|
|
272
315
|
// The human's answer to a pending request: 'once', 'plan' or 'deny'.
|
|
273
316
|
decideMode(requestId, decision) { return this.#escalation.decide(requestId, decision); }
|
|
317
|
+
modeAsked(requestId) { return this.#escalation.modeOf(requestId); }
|
|
318
|
+
modeRequest(requestId) { return this.#escalation.requestOf(requestId); }
|
|
319
|
+
// The modes plans are waiting for the human to grant, as asked.
|
|
320
|
+
pendingModes() { return this.#escalation.pending(); }
|
|
274
321
|
|
|
275
322
|
subscribeGhost(listener) { return this.#ghost.subscribe(listener); }
|
|
276
323
|
|
|
@@ -419,6 +466,19 @@ export class Room {
|
|
|
419
466
|
// Is anything happening in here right now? Asked before a conversation is closed and another
|
|
420
467
|
// opened: a turn in flight is a reason to wait, not something to race.
|
|
421
468
|
working() { return this.#turns.size > 0; }
|
|
469
|
+
// TURNS asks two things: how many turns this conversation has in flight, and whether it is still
|
|
470
|
+
// at work at all — a plan between two of its steps has no turn running and is not finished.
|
|
471
|
+
turnsRunning() { return this.#turns.size; }
|
|
472
|
+
busy() { return this.#turns.size > 0 || this.#inflight.size > 0 || this.#plans.size > 0; }
|
|
473
|
+
// A turn running now, by the id of the answer it will write (S-021): its agent and its mode, or
|
|
474
|
+
// null. What a tool of that turn reports about itself is checked against this.
|
|
475
|
+
runningTurn(responseMessageId) {
|
|
476
|
+
const turn = this.#liveTurns.get(String(responseMessageId ?? ''));
|
|
477
|
+
return turn ? { agent: turn.agent, mode: turn.mode } : null;
|
|
478
|
+
}
|
|
479
|
+
// A command the human ran is still alive here (a server, say). Not «working», but a reason to
|
|
480
|
+
// keep this room open rather than close it under the process.
|
|
481
|
+
hasLiveRuns() { return this.#runDesk.busy || this.#runDesk.serving().length > 0; }
|
|
422
482
|
|
|
423
483
|
// The decisions this project is standing on, newest first.
|
|
424
484
|
//
|
|
@@ -440,6 +500,16 @@ export class Room {
|
|
|
440
500
|
return settledDecisions(this.#memory.memories({ kind: 'decision', limit: limit * 4 }), { limit });
|
|
441
501
|
}
|
|
442
502
|
|
|
503
|
+
// The notes the archive holds about something on the human's screen — the lines they selected,
|
|
504
|
+
// the page they pointed at — so a question can be offered from what the room already knows.
|
|
505
|
+
// Matched by words only: opening a menu sends nothing to an embedding provider. Read without
|
|
506
|
+
// counting as a recall, because nothing was recalled into a turn.
|
|
507
|
+
notesAbout(text, { limit = 3 } = {}) {
|
|
508
|
+
if (!this.#memory || !String(text ?? '').trim()) return [];
|
|
509
|
+
const notes = this.#memory.recallMemories(String(text).slice(0, 2000), { limit, maxChars: 900, queryVector: null, fallback: false, track: false, cascade: false });
|
|
510
|
+
return notes.filter((note) => note.kind !== 'aberration').map((note) => ({ id: note.id, kind: note.kind, text: note.text }));
|
|
511
|
+
}
|
|
512
|
+
|
|
443
513
|
memoryResearch() {
|
|
444
514
|
if (!this.#memory) return null;
|
|
445
515
|
const memories = this.#memory.memories({ limit: 500 });
|
|
@@ -508,7 +578,7 @@ export class Room {
|
|
|
508
578
|
// a real question at a time and takes minutes, so it runs in the background and reports where
|
|
509
579
|
// it is. Nothing here writes to the ledger or spends a provider turn.
|
|
510
580
|
async runExam(which, { findings = [] } = {}) {
|
|
511
|
-
if (!this.#memory) return { error: 'The
|
|
581
|
+
if (!this.#memory) return { error: 'The project has no memory.' };
|
|
512
582
|
if (this.#examRunning) return { error: `${this.#examRunning.toUpperCase()} is already running.` };
|
|
513
583
|
const events = await this.#store.readAll();
|
|
514
584
|
if (which === 'consistency') {
|
|
@@ -528,7 +598,7 @@ export class Room {
|
|
|
528
598
|
}
|
|
529
599
|
if (which === 'match') {
|
|
530
600
|
const invoke = this.#invokers[MADRE_ADAPTER];
|
|
531
|
-
if (!invoke) return { error: 'The local model is not running.
|
|
601
|
+
if (!invoke) return { error: 'The local model is not running. Start Ollama, or open Ollama in Modules.' };
|
|
532
602
|
this.#examRunning = 'match';
|
|
533
603
|
this.#examProgress = { done: 0, total: 0 };
|
|
534
604
|
// Answered in the background: the human keeps the room while it runs.
|
|
@@ -658,13 +728,14 @@ export class Room {
|
|
|
658
728
|
const parsed = parseMessage(text ?? '', target);
|
|
659
729
|
const wanted = normalizeMode(mode, create === true ? 2 : 1);
|
|
660
730
|
if (!parsed.target) return { ok: true, mode: wanted };
|
|
661
|
-
const
|
|
731
|
+
const agent = parsed.target;
|
|
732
|
+
const scopes = this.scopesFor(agent);
|
|
662
733
|
if (wanted >= 3) {
|
|
663
734
|
// #3 CONTROL and #4 AIRLOCK: the ceiling must allow it, and there is one holder at a time.
|
|
664
735
|
// A project without git still checkpoints, in MADRE's shadow repository.
|
|
665
|
-
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
|
|
736
|
+
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 Settings › Permissions to arm ${MODES[wanted].label}.` };
|
|
666
737
|
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.` };
|
|
667
|
-
return { ok: true, mode: wanted, maxMode: scopes.maxMode };
|
|
738
|
+
return { ok: true, mode: wanted, maxMode: scopes.maxMode, agent };
|
|
668
739
|
}
|
|
669
740
|
// #2 above the agent's ceiling still goes out: the room answers read-only
|
|
670
741
|
// and says why (lease.refused), the way CREATE has always behaved.
|
|
@@ -696,7 +767,17 @@ export class Room {
|
|
|
696
767
|
observedAt: observedAt ?? undefined,
|
|
697
768
|
official: true,
|
|
698
769
|
};
|
|
699
|
-
|
|
770
|
+
// Written to the record only when it says something new (LOG-12 of the 1.0 review): the poll
|
|
771
|
+
// asks every minute, and an unchanged reading still became a durable event — some 1,440 a day
|
|
772
|
+
// per project, each spending a number of the project's count and making every write read a
|
|
773
|
+
// longer file. An unchanged one goes to whoever is watching, and nowhere else.
|
|
774
|
+
const said = JSON.stringify([Math.round(report.usedPercent * 10), report.resetAt ?? null, Boolean(report.stale), report.windows ?? null]);
|
|
775
|
+
const key = `${agent}\n${source}`;
|
|
776
|
+
if (this.#quotaSaid.get(key) === said) this.#say('quota.updated', report);
|
|
777
|
+
else {
|
|
778
|
+
this.#quotaSaid.set(key, said);
|
|
779
|
+
await this.#emit('quota.updated', report);
|
|
780
|
+
}
|
|
700
781
|
return this.reportLimit(report);
|
|
701
782
|
}
|
|
702
783
|
|
|
@@ -716,12 +797,23 @@ export class Room {
|
|
|
716
797
|
// Each bill teaches the room what its own words cost, so the next turn can say what it is
|
|
717
798
|
// about to spend before it spends it.
|
|
718
799
|
if (cost.charsPerInputToken) this.#rate = this.#rate ? this.#rate * 0.7 + cost.charsPerInputToken * 0.3 : cost.charsPerInputToken;
|
|
719
|
-
|
|
800
|
+
// Which MCP servers rode along: their tool lists go into every call the CLI makes inside the
|
|
801
|
+
// turn, so the economy can set turns with a server against turns without it.
|
|
802
|
+
await this.#emit('turn.cost', { agent, mode, responseMessageId, ash: Boolean(cost.blocks.ash), spared: shape.spared, servers: shape.servers ?? [], ...cost });
|
|
720
803
|
} catch (error) {
|
|
721
804
|
console.error(`MADRE could not weigh a turn: ${error.message}`);
|
|
722
805
|
}
|
|
723
806
|
}
|
|
724
807
|
|
|
808
|
+
// The model a turn ran on, for its price: the one chosen for it, or the CLI's own default when
|
|
809
|
+
// MADRE can read it (Codex's config; OpenCode's PULSE_OPENCODE_MODEL). Unknown is null: no price.
|
|
810
|
+
async #pricedModel(agentId, model) {
|
|
811
|
+
if (model) return model;
|
|
812
|
+
if (agentId === 'codex') return (await readCodexEfforts({ home: this.#codexHome }).catch(() => null))?.model ?? null;
|
|
813
|
+
if (agentId === 'opencode') return process.env.PULSE_OPENCODE_MODEL || null;
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
|
|
725
817
|
async #recordUsage(agent, usage, { messageId = null, responseMessageId = null } = {}) {
|
|
726
818
|
if (!usage) return;
|
|
727
819
|
const charged = this.#budget.record(agent, usage);
|
|
@@ -788,7 +880,7 @@ export class Room {
|
|
|
788
880
|
const command = request?.commands?.[index];
|
|
789
881
|
if (!command) return { ok: false, status: 404, error: 'That command request is not in this room.' };
|
|
790
882
|
if (!command.argv) return { ok: false, status: 422, error: `That line does not run: it ${whyText(command)}.` };
|
|
791
|
-
if (this.#runBusy) return { ok: false, status: 409, error: 'A command is already running; wait for it or STOPALL.' };
|
|
883
|
+
if (this.#runBusy || this.#runDesk.busy) return { ok: false, status: 409, error: 'A command is already running; wait for it or STOPALL.' };
|
|
792
884
|
if (this.#controlDesk.holder) return { ok: false, status: 409, error: `@${this.#controlDesk.holder.agent} holds ${MODES[this.#controlDesk.holder.mode ?? 3].label} of this project; run it when the turn ends.` };
|
|
793
885
|
// Taken before the first await below: two presses in a row must not both get through.
|
|
794
886
|
this.#runBusy = true;
|
|
@@ -802,18 +894,36 @@ export class Room {
|
|
|
802
894
|
try {
|
|
803
895
|
checkpoint = await this.#controlDesk.photograph({ id: `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-run-${responseMessageId.slice(0, 8)}-${index}`, label: `MADRE run @${request.agent}`, command: command.line });
|
|
804
896
|
} catch { /* no photograph, no UNDO: the command still runs, and the card says so */ }
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
897
|
+
// The first of two things: the command ends, or it opens a port and becomes a server. A server
|
|
898
|
+
// answers the press at once and keeps running; its output card comes when it is stopped.
|
|
899
|
+
let opened = () => {};
|
|
900
|
+
const serving = new Promise((resolve) => { opened = resolve; });
|
|
901
|
+
const finished = this.#runDesk.run({ id: `${responseMessageId}:${index}`, argv: expandHome(command), cwd: this.#projectRoot, onServing: (where) => opened(where) })
|
|
902
|
+
.then(async (result) => {
|
|
903
|
+
const changes = checkpoint ? await this.#controlDesk.changesSince(checkpoint).catch(() => null) : null;
|
|
904
|
+
const status = result.stoppedBy ? `stopped (${result.stoppedBy})` : result.exitCode === null ? 'did not start' : `exit ${result.exitCode}`;
|
|
905
|
+
const event = await this.#emit('command.output', {
|
|
906
|
+
name: 'run',
|
|
907
|
+
title: `$ ${command.line} · ${status} · ${(result.durationMs / 1000).toFixed(1)}s · asked by @${request.agent}`,
|
|
908
|
+
text: `${result.text || '(no output)'}${changes?.files.length ? `\n\nFiles changed: ${changes.files.map((file) => `${file.status} ${file.path}`).join(', ')}` : ''}`,
|
|
909
|
+
ok: result.ok,
|
|
910
|
+
args: [],
|
|
911
|
+
run: { ...run, exitCode: result.exitCode, durationMs: result.durationMs, stoppedBy: result.stoppedBy, served: result.serving ?? undefined, checkpointId: checkpoint?.id, files: changes?.files.length ? changes.files.slice(0, 40) : undefined },
|
|
912
|
+
});
|
|
913
|
+
return { ...result, sequence: event.sequence };
|
|
914
|
+
});
|
|
915
|
+
const first = await Promise.race([finished.then((ended) => ({ ended })), serving.then((where) => ({ where }))]);
|
|
916
|
+
if (first.where) {
|
|
917
|
+
await this.#emit('run.serving', { ...run, ...first.where });
|
|
918
|
+
finished.catch(() => null);
|
|
919
|
+
return { ok: true, status: 200, result: { serving: first.where } };
|
|
920
|
+
}
|
|
921
|
+
return { ok: true, status: 200, result: { ok: first.ended.ok, exitCode: first.ended.exitCode, sequence: first.ended.sequence } };
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// The human pressed STOP on one running command's card.
|
|
925
|
+
stopRun({ responseMessageId, index }) {
|
|
926
|
+
return this.#runDesk.stop('STOP', `${responseMessageId}:${Number(index)}`);
|
|
817
927
|
}
|
|
818
928
|
|
|
819
929
|
ashEnabled() {
|
|
@@ -844,8 +954,11 @@ export class Room {
|
|
|
844
954
|
};
|
|
845
955
|
}
|
|
846
956
|
|
|
847
|
-
|
|
848
|
-
|
|
957
|
+
// How long a turn may run. One that writes gets longer unless the human said otherwise: the first
|
|
958
|
+
// «write me the app» in an empty project ran past 180 s and was cut with two files of three on
|
|
959
|
+
// disk. What the human set, for one agent or for all of them, always wins.
|
|
960
|
+
timeoutFor(agentId, { writing = false } = {}) {
|
|
961
|
+
return this.#agentTimeouts[agentId] ?? this.#agentTimeouts.default ?? (writing ? WRITING_TIMEOUT_MS : READING_TIMEOUT_MS);
|
|
849
962
|
}
|
|
850
963
|
|
|
851
964
|
// A process that died mid-turn leaves `agent.started` without a closing
|
|
@@ -880,6 +993,8 @@ export class Room {
|
|
|
880
993
|
this.#archivist.stop();
|
|
881
994
|
this.#vectors.stop();
|
|
882
995
|
this.#shuttingDown = true;
|
|
996
|
+
// A server a command started does not outlive the room that started it.
|
|
997
|
+
this.#runDesk.stop('MADRE is shutting down');
|
|
883
998
|
this.#escalation.settleAll(null, 'stopped');
|
|
884
999
|
for (const plan of this.#plans.values()) plan.stopped = 'MADRE is shutting down';
|
|
885
1000
|
for (const { controller } of this.#turns.values()) controller.abort('MADRE is shutting down');
|
|
@@ -892,13 +1007,36 @@ export class Room {
|
|
|
892
1007
|
this.#vectors.stop();
|
|
893
1008
|
}
|
|
894
1009
|
|
|
895
|
-
async send(
|
|
1010
|
+
async send(options = {}) {
|
|
1011
|
+
await this.#accept(options, null);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// An order from BISHOP (docs/MACROVERSO.md, S-028, D-S11). The same door as the human's message
|
|
1015
|
+
// — the mode gate, the lease, the guard and the limits all run as they do for a message — with
|
|
1016
|
+
// three things fixed here and never read from a request: the origin is `bishop`, the turn runs at
|
|
1017
|
+
// depth 1 and it may not delegate, so a room that receives an order cannot open a plan or pass
|
|
1018
|
+
// anything on. Only #0–#2 come in this way; #3 and #4 are armed by the human in this room. The
|
|
1019
|
+
// caller (the BISHOP module, after it checked the contract) gets back the reply or the failure.
|
|
1020
|
+
async order({ text, target, mode = 1 } = {}) {
|
|
1021
|
+
const wanted = Number(mode);
|
|
1022
|
+
if (!Number.isInteger(wanted) || wanted < 0 || wanted > 2) throw new Error('An order comes in at #2 at most; #3 and #4 are armed by the human in this room.');
|
|
1023
|
+
const accepted = await this.#accept({ text, target, mode: wanted }, { via: 'bishop', depth: 1 });
|
|
1024
|
+
const events = await this.#store.readAll();
|
|
1025
|
+
const reply = accepted?.responseMessageId ? events.find((event) => event.type === 'message.created' && event.payload?.messageId === accepted.responseMessageId)?.payload : null;
|
|
1026
|
+
const failed = accepted?.messageId ? events.findLast((event) => event.type === 'message.failed' && event.payload?.messageId === accepted.messageId)?.payload : null;
|
|
1027
|
+
return { messageId: accepted?.messageId ?? null, responseMessageId: accepted?.responseMessageId ?? null, text: reply?.text ?? null, error: reply ? null : failed?.error ?? 'The turn ended without a reply.' };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async #accept({ text, target, model = null, effort = null, attachments = [], create = false, ash = false, mode = undefined, draft = false, via = null }, order) {
|
|
896
1031
|
const parsed = parseMessage(text, target);
|
|
897
1032
|
const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
|
|
898
1033
|
// This is the decision the human made when pressing Send. Capture it before modeCheck() —
|
|
899
1034
|
// the first await — so a concurrent module toggle cannot change the turn after that click.
|
|
900
1035
|
const ashRequested = ash === true;
|
|
901
1036
|
const ashActive = ashRequested && this.#ashEnabled;
|
|
1037
|
+
// A message that came from a phone never goes above #2, whatever reached this far (TEL-01): the
|
|
1038
|
+
// bridge and the route say so first; the room says it last, for any road added later.
|
|
1039
|
+
if (via && requestedMode > PHONE_MAX_MODE) throw new Error('#3 EDIT and #4 FULL ACCESS are opened from the Mac.');
|
|
902
1040
|
const gate = await this.modeCheck({ target, text, mode: requestedMode });
|
|
903
1041
|
if (!gate.ok) throw new Error(gate.error);
|
|
904
1042
|
create = requestedMode === 2;
|
|
@@ -908,10 +1046,22 @@ export class Room {
|
|
|
908
1046
|
if (!parsed.text && !files.length) throw new Error('Write a message first.');
|
|
909
1047
|
if (!parsed.target) throw new Error('Choose an agent or begin with @agent.');
|
|
910
1048
|
if (model !== null && model !== undefined && model !== '' && !isValidModelName(model)) throw new Error('Model name is not valid.');
|
|
911
|
-
|
|
1049
|
+
// A model chosen for one agent never travels to another. Two ways it used to: a message that
|
|
1050
|
+
// opened with @gemini while the composer pointed at @codex took Codex's model along, and a
|
|
1051
|
+
// name that is plainly another vendor's reached the CLI and came back as an API error that
|
|
1052
|
+
// read like anything but «wrong model». Either way the agent answers on its own default.
|
|
1053
|
+
const redirected = Boolean(target) && parsed.target !== target;
|
|
1054
|
+
const modelIgnored = model && (redirected || belongsElsewhere(parsed.target, model)) ? model : null;
|
|
1055
|
+
const chosenModel = modelIgnored ? null : (model || null);
|
|
912
1056
|
// An effort the target's CLI does not take is dropped, not refused: the composer remembers it
|
|
913
|
-
// per agent, so a stale one should cost the human nothing but the flag.
|
|
914
|
-
|
|
1057
|
+
// per agent, so a stale one should cost the human nothing but the flag. Codex's levels are the
|
|
1058
|
+
// model's, so a level its model does not take is dropped the same way, and the room says so.
|
|
1059
|
+
let chosenEffort = !redirected && isValidEffort(parsed.target, effort) ? effort : null;
|
|
1060
|
+
let effortIgnored = null;
|
|
1061
|
+
if (chosenEffort && parsed.target === 'codex' && !(await codexTakes(chosenModel, chosenEffort, { home: this.#codexHome }))) {
|
|
1062
|
+
effortIgnored = chosenEffort;
|
|
1063
|
+
chosenEffort = null;
|
|
1064
|
+
}
|
|
915
1065
|
// What the human wrote, sent as they wrote it. Ash used to rewrite this line before it left
|
|
916
1066
|
// the room; it does not any more, so there is no original to keep beside an abbreviation.
|
|
917
1067
|
const text2 = parsed.text || `(${files.length} attached file${files.length === 1 ? '' : 's'})`;
|
|
@@ -927,12 +1077,17 @@ export class Room {
|
|
|
927
1077
|
mode: ghost ? 0 : control ? requestedMode : (standing || create ? 2 : 1),
|
|
928
1078
|
role: 'user',
|
|
929
1079
|
sender: 'you',
|
|
1080
|
+
// Written from a paired phone: which kind, said by the bridge (docs/MOBILE.md, MOV-008). An
|
|
1081
|
+
// order says `bishop`, set by `order()` in this process and never taken from a request.
|
|
1082
|
+
via: order ? order.via : VIA_KINDS.has(via) ? via : undefined,
|
|
930
1083
|
target: parsed.target,
|
|
931
1084
|
text: text2,
|
|
932
1085
|
ash: ashRequested ? { requested: true, active: ashActive } : undefined,
|
|
933
1086
|
status: 'sent',
|
|
934
1087
|
model: chosenModel,
|
|
1088
|
+
modelIgnored: modelIgnored ?? undefined,
|
|
935
1089
|
effort: chosenEffort ?? undefined,
|
|
1090
|
+
effortIgnored: effortIgnored ?? undefined,
|
|
936
1091
|
attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
|
|
937
1092
|
references: references.length ? references.map(({ excerpt, ...reference }) => reference) : undefined,
|
|
938
1093
|
create: create === true ? true : undefined,
|
|
@@ -958,20 +1113,25 @@ export class Room {
|
|
|
958
1113
|
await this.#emit('lease.refused', {
|
|
959
1114
|
messageId,
|
|
960
1115
|
agent: parsed.target,
|
|
961
|
-
reason: !scopes.write.capable ? `@${parsed.target} cannot create files from its CLI` : scopes.maxMode < 2 ? `@${parsed.target} is capped at #${scopes.maxMode} ${MODES[scopes.maxMode].label} in
|
|
1116
|
+
reason: !scopes.write.capable ? `@${parsed.target} cannot create files from its CLI` : scopes.maxMode < 2 ? `@${parsed.target} is capped at #${scopes.maxMode} ${MODES[scopes.maxMode].label} in Settings › Permissions` : `file creation is switched off for @${parsed.target}`,
|
|
962
1117
|
unavailable,
|
|
963
1118
|
disabled,
|
|
964
1119
|
alternatives,
|
|
965
|
-
message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (
|
|
1120
|
+
message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (turn it on in Settings › Agents)' : 'its CLI cannot create files'}${unavailableSaid.length ? `; it cannot ${unavailableSaid.join(' or ')}` : ''}.${alternatives.length ? ` For creation ask ${alternatives.join(' or ')}.` : ''}`,
|
|
966
1121
|
});
|
|
967
1122
|
} else {
|
|
968
|
-
|
|
1123
|
+
// `/module` asks for less than #2: one file in its own draft folder. Only a #2 message can
|
|
1124
|
+
// narrow itself this way; a default-#2 lease stays what it is.
|
|
1125
|
+
lease = draft === true && create === true
|
|
1126
|
+
? await this.#draftLease({ leaseId: randomUUID(), messageId })
|
|
1127
|
+
: await this.#projectLease({ leaseId: randomUUID(), messageId });
|
|
969
1128
|
// CREATE authorizes the plan to use each delegate's enabled creation
|
|
970
1129
|
// scopes; a default-#2 lease authorizes files only.
|
|
971
1130
|
lease.scopeCeiling = { write: true, imageGen: !standing, web: true };
|
|
972
1131
|
lease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, enabled.includes(scope) && (!standing || scope === 'write')]));
|
|
973
1132
|
await this.#emit('lease.granted', {
|
|
974
1133
|
standing: standing || undefined,
|
|
1134
|
+
draft: lease.draft || undefined,
|
|
975
1135
|
leaseId: lease.leaseId,
|
|
976
1136
|
messageId,
|
|
977
1137
|
agent: parsed.target,
|
|
@@ -990,14 +1150,16 @@ export class Room {
|
|
|
990
1150
|
target: parsed.target,
|
|
991
1151
|
error: `Message is too long (${parsed.text.length} characters); the limit is ${this.#maxMessageChars}.`,
|
|
992
1152
|
});
|
|
993
|
-
return;
|
|
1153
|
+
return { messageId, responseMessageId: null };
|
|
994
1154
|
}
|
|
995
|
-
|
|
996
|
-
|
|
1155
|
+
// An order never delegates, so a plan already running here has nothing to warn about.
|
|
1156
|
+
let allowDelegation = !order;
|
|
1157
|
+
if (!order && this.#plans.size > 0) {
|
|
997
1158
|
allowDelegation = false;
|
|
998
1159
|
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}`);
|
|
999
1160
|
}
|
|
1000
|
-
await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, effort: chosenEffort, attachments: files, references, lease, ash: ashActive, mode: ghost ? 0 : control ? requestedMode : (lease ? 2 : 1) }));
|
|
1161
|
+
const responseMessageId = await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: order ? order.depth : 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, effort: chosenEffort, attachments: files, references, lease, ash: ashActive, mode: ghost ? 0 : control ? requestedMode : (lease ? 2 : 1) }));
|
|
1162
|
+
return { messageId, responseMessageId };
|
|
1001
1163
|
}
|
|
1002
1164
|
|
|
1003
1165
|
// A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
|
|
@@ -1008,13 +1170,66 @@ export class Room {
|
|
|
1008
1170
|
return { leaseId, messageId, outDir: this.#projectRoot, relativeDir: '.', scratchDir: scratch.relativeDir, create: true };
|
|
1009
1171
|
}
|
|
1010
1172
|
|
|
1173
|
+
// A lease for one folder of its own and nothing else: `/module` writes one file for the human to
|
|
1174
|
+
// read before installing it, and needs no more than that. Every CLI is told the folder and held
|
|
1175
|
+
// to it; nothing in the project can be touched, so there is no checkpoint to take and no other
|
|
1176
|
+
// turn to wait for.
|
|
1177
|
+
async #draftLease({ leaseId, messageId }) {
|
|
1178
|
+
const draft = await createLease({ projectRoot: this.#projectRoot, leaseId });
|
|
1179
|
+
return { ...draft, messageId, draft: true };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1011
1182
|
// Artifacts from a #2 turn: the files the checkpoint saw appear, as the room shows them.
|
|
1183
|
+
// Said once, when a writing turn has to wait for the one already writing in the project.
|
|
1184
|
+
async #sayWaitingToWrite({ messageId, agent }) {
|
|
1185
|
+
const ahead = this.#controlDesk.writer();
|
|
1186
|
+
if (ahead) await this.#emit('write.waiting', { messageId, agent: agent.id, behind: ahead.agent ?? null });
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// A round's answers, for the local model's closing turn: who was asked, what each one said and
|
|
1190
|
+
// under which number, or that it did not answer. Handed the transcript instead, a 7B model gave
|
|
1191
|
+
// one agent's point to another, left one out and quoted itself.
|
|
1192
|
+
async #roundAnswers(planId) {
|
|
1193
|
+
const events = (await this.#store.readAll()).filter((event) => event.payload?.planId === planId);
|
|
1194
|
+
const asked = [...new Set(events.filter((event) => event.type === 'message.created' && event.payload.status === 'delegated' && event.payload.target !== event.payload.sender).map((event) => event.payload.target))];
|
|
1195
|
+
const answers = asked.map((agent) => {
|
|
1196
|
+
const said = events.find((event) => event.type === 'message.created' && event.payload.role === 'assistant' && event.payload.sender === agent && event.payload.status !== 'delegated');
|
|
1197
|
+
if (said) return { agent, sequence: said.sequence, text: said.payload.text ?? '' };
|
|
1198
|
+
const failed = events.find((event) => event.type === 'message.failed' && event.payload.target === agent);
|
|
1199
|
+
return { agent, failed: true, error: failed ? String(failed.payload.error ?? '').slice(0, 160) : null };
|
|
1200
|
+
});
|
|
1201
|
+
return { asked, answers };
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// The bytes of files the turn created, for telling a copy from a new file. Images only, and only
|
|
1205
|
+
// a few: this compares what an image tool made, not the project.
|
|
1206
|
+
async #bytesOf(paths) {
|
|
1207
|
+
const images = paths.filter((path) => /\.(png|jpe?g|webp|gif)$/i.test(path)).slice(0, 16);
|
|
1208
|
+
return (await Promise.all(images.map((path) => readFileBytes(joinPath(this.#projectRoot, path)).catch(() => null)))).filter(Boolean);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
async #draftedIn(scratchDir, { depth = 2 } = {}) {
|
|
1212
|
+
const found = [];
|
|
1213
|
+
const walk = async (relative, level) => {
|
|
1214
|
+
const entries = await readdir(joinPath(this.#projectRoot, relative), { withFileTypes: true }).catch(() => []);
|
|
1215
|
+
for (const entry of entries) {
|
|
1216
|
+
const path = `${relative}/${entry.name}`;
|
|
1217
|
+
if (entry.isDirectory()) { if (level < depth) await walk(path, level + 1); continue; }
|
|
1218
|
+
if (!entry.isFile()) continue;
|
|
1219
|
+
const info = await statFile(joinPath(this.#projectRoot, path)).catch(() => null);
|
|
1220
|
+
found.push({ name: baseName(path), path, size: info?.size ?? 0, contentType: declaredTypeFor(path), status: 'created' });
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
await walk(String(scratchDir).replace(/\/$/, ''), 0);
|
|
1224
|
+
return found.sort((a, b) => a.path.localeCompare(b.path));
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1012
1227
|
async #artifactsFrom(files) {
|
|
1013
1228
|
const out = [];
|
|
1014
1229
|
for (const file of files) {
|
|
1015
1230
|
if (file.status !== 'A') continue;
|
|
1016
1231
|
const info = await statFile(joinPath(this.#projectRoot, file.path)).catch(() => null);
|
|
1017
|
-
out.push({ name: baseName(file.path), path: file.path, size: info?.size ?? 0, contentType:
|
|
1232
|
+
out.push({ name: baseName(file.path), path: file.path, size: info?.size ?? 0, contentType: declaredTypeFor(file.path), status: 'created' });
|
|
1018
1233
|
}
|
|
1019
1234
|
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
1020
1235
|
}
|
|
@@ -1089,11 +1304,16 @@ export class Room {
|
|
|
1089
1304
|
|
|
1090
1305
|
// One room turn for one agent. `requester` is who asked ('you' or an
|
|
1091
1306
|
// orchestrating agent); `depth` 0 turns may delegate, deeper ones may not.
|
|
1092
|
-
async #dispatch({ messageId, targetId, text, requester, depth, planId = null, allowDelegation = true, model = null, effort = null, attachments = [], references = [], lease = null, ash = false, mode = 1, escalation = null }) {
|
|
1307
|
+
async #dispatch({ messageId, targetId, text, requester, depth, planId = null, allowDelegation = true, deferPlan = false, model = null, effort = null, attachments = [], references = [], lease = null, ash = false, mode = 1, escalation = null }) {
|
|
1093
1308
|
if (this.#shuttingDown) return null;
|
|
1094
1309
|
const agent = this.#agents.find((item) => item.id === targetId);
|
|
1095
1310
|
if (!agent?.detected) {
|
|
1096
|
-
|
|
1311
|
+
// @madre is not a CLI to install: it is the room's local agent, here while Ollama runs with a
|
|
1312
|
+
// chat model. Saying "not installed" sent people to install something that does not exist.
|
|
1313
|
+
const error = agent?.local || targetId === 'madre'
|
|
1314
|
+
? `@${targetId} left the room: Ollama is not running. Start Ollama, or open Ollama in Modules.`
|
|
1315
|
+
: `${targetId} is not installed on this computer.`;
|
|
1316
|
+
await this.#emit('message.failed', { messageId, target: targetId, planId, error });
|
|
1097
1317
|
return null;
|
|
1098
1318
|
}
|
|
1099
1319
|
if (!agent.ready) {
|
|
@@ -1143,7 +1363,9 @@ export class Room {
|
|
|
1143
1363
|
await this.#alert('plan-long', `The plan by @${plan.orchestrator} has been running for ${Math.round((Date.now() - plan.startedAt) / 60000)} min. STOPALL halts it.`, `plan-long:${plan.planId}`);
|
|
1144
1364
|
}
|
|
1145
1365
|
}
|
|
1146
|
-
|
|
1366
|
+
// The limit this turn will be held to, said with its start so the room counts down from the
|
|
1367
|
+
// real number and not from a default it guessed.
|
|
1368
|
+
await this.#emit('agent.started', { messageId, agent: agent.id, handoffId, planId, timeoutMs: this.timeoutFor(agent.id, { writing: Number(mode) >= 2 || Boolean(lease) }) });
|
|
1147
1369
|
const controller = new AbortController();
|
|
1148
1370
|
const turn = { controller, promise: null, planId, agent: agent.id, startedAt: Date.now() };
|
|
1149
1371
|
turn.promise = this.#runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall, memories, handoffId, signal: controller.signal, model, effort, attachments, references, lease, ash, mode, escalation });
|
|
@@ -1157,16 +1379,26 @@ export class Room {
|
|
|
1157
1379
|
}
|
|
1158
1380
|
// The orchestrator's turn is over before its plan starts, so it is never
|
|
1159
1381
|
// counted as in flight while the others work.
|
|
1160
|
-
if (outcome?.directives?.steps.length) {
|
|
1161
|
-
|
|
1382
|
+
if (outcome?.directives?.steps.length && !deferPlan) {
|
|
1383
|
+
let next = outcome.directives;
|
|
1384
|
+
let parent = outcome.responseMessageId;
|
|
1385
|
+
const grants = new Map();
|
|
1386
|
+
// The orchestrator may form another wave after seeing the crew's replies. Three waves
|
|
1387
|
+
// bound cost and time; a grant for the plan carries forward, only for its authorized scope.
|
|
1388
|
+
for (let round = 0; next?.steps.length && round < 3; round += 1) {
|
|
1389
|
+
const continued = await this.#runPlan({ orchestrator: agent.id, parentMessageId: parent, directives: next, lease, ash, mode, grants });
|
|
1390
|
+
next = continued?.directives ?? null;
|
|
1391
|
+
parent = continued?.responseMessageId ?? parent;
|
|
1392
|
+
lease = continued?.lease ?? lease;
|
|
1393
|
+
}
|
|
1394
|
+
if (next?.steps.length) await this.#emit('plan.ignored', { orchestrator: agent.id, responseMessageId: parent, reasons: ['The plan reached its three-round limit. Continue with a new message if needed.'] });
|
|
1162
1395
|
}
|
|
1163
1396
|
return outcome?.responseMessageId ?? null;
|
|
1164
1397
|
}
|
|
1165
1398
|
|
|
1166
|
-
// What the next turn to an agent would carry, built
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
// until now existed only for the instant a CLI was reading it.
|
|
1399
|
+
// What the next turn to an agent would carry, built without starting a process or counting a
|
|
1400
|
+
// memory read. Signed linked notes for BISHOP are fetched only on send, so the preview marks
|
|
1401
|
+
// that one dynamic part instead of spending a contract's hourly reading allowance here.
|
|
1170
1402
|
async briefing({ agent: agentId, mode = 1, text = '', ash = false } = {}) {
|
|
1171
1403
|
const agent = this.#agents.find((one) => one.id === agentId && one.detected);
|
|
1172
1404
|
if (!agent) return null;
|
|
@@ -1180,6 +1412,9 @@ export class Room {
|
|
|
1180
1412
|
const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
|
|
1181
1413
|
? await this.#toolsForTurn({ agent: agent.id, mode, lease: null, scratchDir: null }).catch(() => [])
|
|
1182
1414
|
: [];
|
|
1415
|
+
const bishop = this.#bishopForTurn ? await this.#bishopForTurn({ agent: agent.id, mode, text, preview: true }).catch(() => null) : null;
|
|
1416
|
+
// Recall from links reads the other room when the turn runs; the preview only says it will.
|
|
1417
|
+
const linkedPreview = this.#linkedForTurn && agent.adapter !== 'madre-local' && mode !== 0 ? await this.#linkedForTurn({ agent: agent.id, mode, text, preview: true }).catch(() => null) : null;
|
|
1183
1418
|
const scopes = this.scopesFor(agent.id);
|
|
1184
1419
|
// The lease a turn at this mode would be given, shaped exactly as the turn shapes it but
|
|
1185
1420
|
// creating nothing: no folder is made, no checkpoint is taken. Without it the preview would
|
|
@@ -1202,7 +1437,7 @@ export class Room {
|
|
|
1202
1437
|
context: built.context, recall: built.recall, memories: built.memories,
|
|
1203
1438
|
attachments: [], references: [], lease,
|
|
1204
1439
|
scopes: { web: scopes.web.enabled && scopes.web.wired, imageGen: scopes.imageGen.enabled && scopes.imageGen.wired },
|
|
1205
|
-
ash: ash === true && this.ashEnabled(), choices: this.#choicesEnabled && mode !== 0, runs: this.#runsEnabled && mode !== 0 && mode < 4, mode, escalation: null, mcpServers,
|
|
1440
|
+
ash: ash === true && this.ashEnabled(), choices: this.#choicesEnabled && mode !== 0, runs: this.#runsEnabled && mode !== 0 && mode < 4, mode, escalation: null, mcpServers, bishop,
|
|
1206
1441
|
});
|
|
1207
1442
|
// Each block with what put it here and what would take it away, so "read, never written" can
|
|
1208
1443
|
// be checked one block at a time instead of believed.
|
|
@@ -1219,6 +1454,7 @@ export class Room {
|
|
|
1219
1454
|
window: { from: built.context?.firstSequence ?? null, through: built.context?.throughSequence ?? null, omitted: built.context?.omittedMessages ?? 0, carried: built.context?.messages?.length ?? 0 },
|
|
1220
1455
|
recalled: built.memories?.length ?? 0,
|
|
1221
1456
|
quoted: built.recall?.entries?.length ?? 0,
|
|
1457
|
+
dynamic: Boolean(bishop && agent.adapter === 'madre-local') || Boolean(linkedPreview?.dynamic),
|
|
1222
1458
|
};
|
|
1223
1459
|
}
|
|
1224
1460
|
|
|
@@ -1321,7 +1557,9 @@ export class Room {
|
|
|
1321
1557
|
if (this.#controlDesk.holder) {
|
|
1322
1558
|
await this.#alert('control-busy', `@${this.#controlDesk.holder.agent} still holds CONTROL; @${agent.id} answers read-only this turn.`, `control-busy:${agent.id}`);
|
|
1323
1559
|
} else {
|
|
1324
|
-
|
|
1560
|
+
await this.#sayWaitingToWrite({ messageId, agent });
|
|
1561
|
+
const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode, signal }).catch((error) => (error?.code === 'ABORT_ERR' ? null : Promise.reject(error)));
|
|
1562
|
+
if (!seat) { await this.#emit('message.failed', { messageId, target: agent.id, planId, error: `Stopped while waiting for the project: another turn was writing in it.`, stopped: true }); return null; }
|
|
1325
1563
|
controlRun = seat.run;
|
|
1326
1564
|
lease = seat.lease;
|
|
1327
1565
|
await this.#emit('control.started', seat.announcement);
|
|
@@ -1350,8 +1588,11 @@ export class Room {
|
|
|
1350
1588
|
});
|
|
1351
1589
|
}
|
|
1352
1590
|
if (lease?.create && !controlRun) {
|
|
1353
|
-
// CREATE: photograph the project now; after the turn only what appeared stays.
|
|
1354
|
-
|
|
1591
|
+
// CREATE: photograph the project now; after the turn only what appeared stays. Another turn
|
|
1592
|
+
// writing in the project, in this conversation or another, is waited for first.
|
|
1593
|
+
await this.#sayWaitingToWrite({ messageId, agent });
|
|
1594
|
+
const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode: 2, signal }).catch((error) => (error?.code === 'ABORT_ERR' ? null : Promise.reject(error)));
|
|
1595
|
+
if (!seat) { await this.#emit('message.failed', { messageId, target: agent.id, planId, error: `Stopped while waiting for the project: another turn was writing in it.`, stopped: true }); return null; }
|
|
1355
1596
|
createRun = seat.run;
|
|
1356
1597
|
// The folder this turn was told to draft in, so settling it does not undo what it was
|
|
1357
1598
|
// invited to write. Only this turn's own folder; every other `.pulse` path stays forbidden.
|
|
@@ -1385,17 +1626,24 @@ export class Room {
|
|
|
1385
1626
|
if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
|
|
1386
1627
|
const before = lease && !lease.control && !lease.create ? await snapshot(lease.outDir) : null;
|
|
1387
1628
|
responseMessageId = randomUUID();
|
|
1629
|
+
this.#liveTurns.set(responseMessageId, { agent: agent.id, mode: turnMode });
|
|
1388
1630
|
const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
|
|
1389
1631
|
const others = this.delegatesFor(agent.id);
|
|
1390
1632
|
const mayDelegate = allowDelegation && this.#delegation && depth === 0;
|
|
1391
1633
|
// Tools the modules hand to this turn (a browser, say), attached to the CLI for this run only.
|
|
1392
1634
|
const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
|
|
1393
|
-
? await this.#toolsForTurn({ agent: agent.id, mode: turnMode, lease, scratchDir: lease?.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : null }).catch(() => [])
|
|
1635
|
+
? await this.#toolsForTurn({ agent: agent.id, mode: turnMode, text, turn: responseMessageId, lease, scratchDir: lease?.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : null, servers: this.#runDesk.serving().filter((one) => !one.network).map((one) => one.port) }).catch(() => [])
|
|
1394
1636
|
: [];
|
|
1637
|
+
const bishop = this.#bishopForTurn ? await this.#bishopForTurn({ agent: agent.id, mode: turnMode, text, turn: responseMessageId }).catch(() => null) : null;
|
|
1638
|
+
// Notes a link with automatic recall brings to this turn (fase 3e): never in GHOST, never for
|
|
1639
|
+
// @madre (its briefing is its own), bounded by the module and counted as readings.
|
|
1640
|
+
const linked = this.#linkedForTurn && agent.adapter !== 'madre-local' && turnMode !== 0
|
|
1641
|
+
? (await this.#linkedForTurn({ agent: agent.id, mode: turnMode, text, turn: responseMessageId }).catch(() => null))?.text ?? null
|
|
1642
|
+
: null;
|
|
1395
1643
|
// The prompt and the shape it was built from: one is sent, the other is kept until the CLI
|
|
1396
1644
|
// says what it cost, so the bill can be attributed to the blocks that caused it.
|
|
1397
|
-
const shaped = this.#promptFor({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ash, choices: this.#choicesEnabled && turnMode !== 0, runs: this.#runsEnabled && turnMode !== 0 && turnMode < 4, 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 });
|
|
1398
|
-
this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared });
|
|
1645
|
+
const shaped = this.#promptFor({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ash, choices: this.#choicesEnabled && turnMode !== 0, runs: this.#runsEnabled && turnMode !== 0 && turnMode < 4, mode: turnMode, escalation, mcpServers, bishop, linked, 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 });
|
|
1646
|
+
this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared, servers: [this.#memoryServer?.name, ...mcpServers.map((server) => server.name)].filter(Boolean) });
|
|
1399
1647
|
// What this turn is about to read, said while it is still reading it. The size is exact;
|
|
1400
1648
|
// only the conversion to tokens is an estimate, and it is made at this room's own rate.
|
|
1401
1649
|
const reading = this.#reading(shaped.parts);
|
|
@@ -1415,7 +1663,7 @@ export class Room {
|
|
|
1415
1663
|
projectRoot: this.#projectRoot,
|
|
1416
1664
|
text,
|
|
1417
1665
|
prompt: shaped.text,
|
|
1418
|
-
timeoutMs: this.timeoutFor(agent.id),
|
|
1666
|
+
timeoutMs: this.timeoutFor(agent.id, { writing: Boolean(lease) }),
|
|
1419
1667
|
signal,
|
|
1420
1668
|
model,
|
|
1421
1669
|
effort,
|
|
@@ -1425,19 +1673,39 @@ export class Room {
|
|
|
1425
1673
|
imageStudio,
|
|
1426
1674
|
memoryServer: memoryServerForTurn(this.#memoryServer, { agent: agent.id, messageId: responseMessageId, mode: turnMode }),
|
|
1427
1675
|
mcpServers,
|
|
1676
|
+
bishop,
|
|
1428
1677
|
// For @madre: who asks, who it may convene, and whether a plan would run at all.
|
|
1429
1678
|
requester,
|
|
1430
1679
|
crew: others,
|
|
1431
1680
|
delegation: mayDelegate,
|
|
1432
1681
|
maxSteps: this.#maxPlanSteps,
|
|
1682
|
+
// And for its closing turn of a round: what each agent asked actually said, with its number.
|
|
1683
|
+
round: agent.id === MADRE_AGENT_ID && planId && /this is your closing turn/i.test(text) ? await this.#roundAnswers(planId) : undefined,
|
|
1433
1684
|
});
|
|
1434
1685
|
// Privacy: a private term in a reply is replaced before the ledger, the plan parser,
|
|
1435
1686
|
// the archivist or any other agent can see it. The reply says how many, not which.
|
|
1436
1687
|
const guarded = this.#privacy?.redact(result.text ?? '') ?? { text: result.text, hits: 0 };
|
|
1437
1688
|
if (guarded.hits) result.text = guarded.text;
|
|
1438
1689
|
// CREATE: what appeared stays and is shown; what existed before is put back and said.
|
|
1439
|
-
const createChanges = createRun ? await this.#controlDesk.
|
|
1690
|
+
const createChanges = createRun ? await this.#controlDesk.settleOrSay(createRun) : null;
|
|
1691
|
+
// Images an agent's own tool made outside the project (Codex keeps them under its home) come
|
|
1692
|
+
// into this turn's draft folder, so they are kept and shown like anything the turn wrote. One
|
|
1693
|
+
// the agent already copied into the project is shown there, not twice.
|
|
1694
|
+
if (createRun?.scratchDir && Array.isArray(result.generatedImages)) {
|
|
1695
|
+
const placed = await this.#bytesOf((createChanges?.files ?? []).filter((file) => file.status === 'A').map((file) => file.path));
|
|
1696
|
+
for (const image of result.generatedImages.slice(0, 8)) {
|
|
1697
|
+
const bytes = await readFileBytes(image).catch(() => null);
|
|
1698
|
+
if (!bytes || placed.some((one) => one.equals(bytes))) continue;
|
|
1699
|
+
await copyFile(image, joinPath(this.#projectRoot, createRun.scratchDir, baseName(image))).catch(() => null);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1440
1702
|
const artifacts = createChanges ? await this.#artifactsFrom(createChanges.files) : lease && !lease.control ? diffSnapshots(before, await snapshot(lease.outDir), { relativeDir: lease.relativeDir }) : [];
|
|
1703
|
+
// What the turn wrote in its own draft folder. The diff cannot see it in a project that ignores
|
|
1704
|
+
// `.pulse/` (most do, the moment a .gitignore names it), so `/module` wrote its file there and
|
|
1705
|
+
// no install card ever came. The folder is new for this turn: all of it is this turn's.
|
|
1706
|
+
if (createRun?.scratchDir) {
|
|
1707
|
+
for (const drafted of await this.#draftedIn(createRun.scratchDir)) if (!artifacts.some((one) => one.path === drafted.path)) artifacts.push(drafted);
|
|
1708
|
+
}
|
|
1441
1709
|
// A turn that could write, wrote nothing, and answers naming files. That is the shape of a
|
|
1442
1710
|
// write refused without a word — a headless CLI cannot stop to ask, so what the policy does
|
|
1443
1711
|
// not allow simply fails and the model reports the file as created. The room does not read
|
|
@@ -1454,7 +1722,7 @@ export class Room {
|
|
|
1454
1722
|
if (missing.length) await this.#emit('create.missing', { messageId: responseMessageId, agent: agent.id, planId, files: missing });
|
|
1455
1723
|
}
|
|
1456
1724
|
// CONTROL: what really changed in the project, forbidden zones reverted on the spot.
|
|
1457
|
-
const controlChanges = controlRun ? await this.#controlDesk.
|
|
1725
|
+
const controlChanges = controlRun ? await this.#controlDesk.settleOrSay(controlRun) : null;
|
|
1458
1726
|
const directives = mayDelegate
|
|
1459
1727
|
? parseDirectives(result.text, { self: agent.id, available: others, maxSteps: this.#maxPlanSteps })
|
|
1460
1728
|
: { steps: [], closing: null, ignored: [] };
|
|
@@ -1463,7 +1731,9 @@ export class Room {
|
|
|
1463
1731
|
// human sees a plan-shaped reply and nothing happening.
|
|
1464
1732
|
await this.#emit('plan.ignored', { orchestrator: agent.id, responseMessageId, reasons: directives.ignored.map((item) => item.reason) });
|
|
1465
1733
|
}
|
|
1466
|
-
|
|
1734
|
+
// Only a ```pulse fence is a plan, read the way src/directives.mjs reads it; ```pulse-run,
|
|
1735
|
+
// ```pulse-ask and ```pulse-salas are not (S-029 in docs/MACROVERSO.md).
|
|
1736
|
+
if (depth > 0 && /```pulse\s*\n/i.test(result.text)) {
|
|
1467
1737
|
await this.#alert('nested-delegation', `@${agent.id} tried to open a plan from inside a plan. It was ignored; the sequence stays under @${requester}. STOPALL if the room drifts.`, `nested:${agent.id}`);
|
|
1468
1738
|
}
|
|
1469
1739
|
// A question the agent put to the human: it leaves the text and comes back as buttons.
|
|
@@ -1498,9 +1768,9 @@ export class Room {
|
|
|
1498
1768
|
const validation = validateAshReply({ request: text, response: result.text });
|
|
1499
1769
|
await this.#emit('ash.validated', { messageId, responseMessageId, agent: agent.id, ...validation });
|
|
1500
1770
|
}
|
|
1501
|
-
if (asked) await this.#emit('choice.offered', { messageId, responseMessageId, agent: agent.id, question: asked.question, options: asked.options, recommended: asked.recommended ?? undefined });
|
|
1771
|
+
if (asked) await this.#emit('choice.offered', { messageId, responseMessageId, agent: agent.id, question: asked.question, options: asked.options, recommended: asked.recommended ?? undefined, modes: asked.modes ?? undefined });
|
|
1502
1772
|
if (requested) await this.#emit('run.requested', { messageId, responseMessageId, agent: agent.id, reason: requested.reason, commands: requested.commands });
|
|
1503
|
-
if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, marker: this.#privacy.marker });
|
|
1773
|
+
if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, kinds: guarded.kinds, marker: this.#privacy.marker });
|
|
1504
1774
|
if (artifacts.length) {
|
|
1505
1775
|
await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
|
|
1506
1776
|
// A <id>.module.mjs is an agent proposing a module: the human installs it from the room, or not.
|
|
@@ -1520,11 +1790,17 @@ export class Room {
|
|
|
1520
1790
|
}
|
|
1521
1791
|
}
|
|
1522
1792
|
if (controlChanges) await this.#emit('control.changed', { ...controlChanges, responseMessageId });
|
|
1793
|
+
// Codex and OpenCode give tokens, not dollars: the turn is priced at the list price of the
|
|
1794
|
+
// model it ran on (src/prices.mjs), an estimate that says it is one. Gemini prices itself in
|
|
1795
|
+
// its adapter, where its report names each model it used.
|
|
1796
|
+
if (result.usage) result.usage = withListPrice(result.usage, await this.#pricedModel(agent.id, model));
|
|
1523
1797
|
await this.#recordUsage(agent.id, result.usage, { messageId, responseMessageId });
|
|
1524
1798
|
await this.#recordCost(agent.id, responseMessageId, result.usage, turnMode);
|
|
1799
|
+
this.#liveTurns.delete(responseMessageId);
|
|
1525
1800
|
await this.#emit('agent.completed', { messageId, agent: agent.id, handoffId, planId });
|
|
1526
1801
|
return { responseMessageId, directives };
|
|
1527
1802
|
} catch (error) {
|
|
1803
|
+
if (responseMessageId) this.#liveTurns.delete(responseMessageId);
|
|
1528
1804
|
if (responseMessageId && this.#promptShape.has(responseMessageId)) {
|
|
1529
1805
|
const shape = this.#promptShape.get(responseMessageId);
|
|
1530
1806
|
this.#promptShape.delete(responseMessageId);
|
|
@@ -1533,7 +1809,34 @@ export class Room {
|
|
|
1533
1809
|
await this.#emit('turn.cost', { agent: agent.id, mode: turnMode, responseMessageId, ash: Boolean(cost.blocks.ash), failed: true, spared: shape.spared, ...cost });
|
|
1534
1810
|
}
|
|
1535
1811
|
}
|
|
1536
|
-
|
|
1812
|
+
// A CLI that does not know its model says so in its vendor's words, which read like an outage
|
|
1813
|
+
// or a permission problem. MADRE says what it is first; the CLI's own line stays after it,
|
|
1814
|
+
// word for word, so it can still be searched.
|
|
1815
|
+
const said = failureMessage(error);
|
|
1816
|
+
const plain = MODEL_NOT_FOUND.test(String(error?.message ?? error ?? ''))
|
|
1817
|
+
? `${model ? `${agent.label} does not know the model \`${model}\`.` : `${agent.label} does not know the model it was asked to use.`} Choose another on the model chip beside TO @${agent.id}, or the default; permissions and modes have nothing to do with it. CLI: ${said}`
|
|
1818
|
+
: said;
|
|
1819
|
+
// A turn cut short (the time limit, STOP ALL, a crash) may still have written. What it left is
|
|
1820
|
+
// read and kept the way any turn's is, CREATE still putting back what existed before, and the
|
|
1821
|
+
// failure carries the list and its checkpoint: the first «write the app» was cut at 180 s
|
|
1822
|
+
// with two files of three on disk, and the room said only that Claude did not answer.
|
|
1823
|
+
let partial = null;
|
|
1824
|
+
const writer = controlRun ?? createRun;
|
|
1825
|
+
if (writer) {
|
|
1826
|
+
const changes = await this.#controlDesk.settleOrSay(writer);
|
|
1827
|
+
if (changes.files.length || changes.unreadable || changes.existingReverted.length) {
|
|
1828
|
+
const created = await this.#artifactsFrom(changes.files);
|
|
1829
|
+
partial = { checkpointId: changes.checkpointId, mode: changes.mode, files: changes.files, created, existingReverted: changes.existingReverted, unreadable: changes.unreadable ?? undefined };
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
const cutByClock = ['TIMEOUT', 'IDLE'].includes(error?.code);
|
|
1833
|
+
await this.#emit('message.failed', {
|
|
1834
|
+
messageId, target: agent.id, planId,
|
|
1835
|
+
error: this.#privacy ? this.#privacy.redact(plain).text : plain,
|
|
1836
|
+
...(this.#turns.get(messageId)?.stoppedByHuman ? { stopped: true } : {}),
|
|
1837
|
+
...(cutByClock ? { timeout: { ms: this.timeoutFor(agent.id, { writing: Boolean(lease) }), writing: Boolean(lease), idle: error.code === 'IDLE' } } : {}),
|
|
1838
|
+
...(partial ? { partial } : {}),
|
|
1839
|
+
});
|
|
1537
1840
|
return null;
|
|
1538
1841
|
} finally {
|
|
1539
1842
|
// Release CONTROL whichever way the turn ended; the checkpoint stays for UNDO.
|
|
@@ -1542,30 +1845,38 @@ export class Room {
|
|
|
1542
1845
|
}
|
|
1543
1846
|
}
|
|
1544
1847
|
|
|
1545
|
-
async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ash = false, mode = 1 }) {
|
|
1848
|
+
async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ash = false, mode = 1, grants = new Map() }) {
|
|
1546
1849
|
const planId = randomUUID();
|
|
1850
|
+
const closing = directives.closing ?? 'Review the delegated answers and the work completed. If more work is needed within the permission already granted by the human, finish your reply with one new pulse plan. If a command requested from the human has not run, say what is waiting. Ask the human only for a decision or a new #3/#4 key.';
|
|
1547
1851
|
// The plan's ceiling is the human's mode: #3 when the orchestrator held CONTROL, #2 while a
|
|
1548
1852
|
// lease exists, #1 otherwise. A step may ask for a mode ("@codex #2: …"); it gets the lowest
|
|
1549
|
-
// of what it asked, the ceiling and its own MAX MODE. Without a number it
|
|
1853
|
+
// of what it asked, the ceiling and its own MAX MODE. Without a number it reads, or creates
|
|
1854
|
+
// while a lease exists: as in 1.0, #3 has to be asked for by name.
|
|
1550
1855
|
const ceiling = mode >= 3 ? mode : lease ? 2 : 1;
|
|
1856
|
+
// A key for one delegate does not authorize every other provider in the plan, and #4 is never
|
|
1857
|
+
// reached through the human's own #4: a delegate gets it only with its own key.
|
|
1858
|
+
const planGrants = grants;
|
|
1859
|
+
const reach = (agent, asked) => Math.max(asked >= 4 ? Math.min(ceiling, 3) : ceiling, planGrants.get(agent) ?? 0);
|
|
1860
|
+
const askedOf = (step) => normalizeMode(step.mode, lease ? 2 : 1);
|
|
1551
1861
|
const stepMode = (step) => {
|
|
1552
1862
|
const scopes = this.scopesFor(step.agent);
|
|
1553
|
-
const asked =
|
|
1554
|
-
const granted = Math.min(asked,
|
|
1863
|
+
const asked = askedOf(step);
|
|
1864
|
+
const granted = Math.min(asked, reach(step.agent, asked), scopes.maxMode);
|
|
1555
1865
|
return granted >= 2 && !scopes.write.enabled ? 1 : granted;
|
|
1556
1866
|
};
|
|
1557
|
-
const plan = { planId, orchestrator, steps: directives.steps, closing
|
|
1867
|
+
const plan = { planId, orchestrator, steps: directives.steps, closing, step: 0, stopped: null, startedAt: Date.now() };
|
|
1558
1868
|
this.#plans.set(planId, plan);
|
|
1559
1869
|
await this.#emit('plan.created', {
|
|
1560
1870
|
planId,
|
|
1561
1871
|
orchestrator,
|
|
1562
1872
|
parentMessageId,
|
|
1563
1873
|
steps: directives.steps.map((step) => ({ ...step, mode: stepMode(step) })),
|
|
1564
|
-
closing
|
|
1874
|
+
closing,
|
|
1565
1875
|
ignored: directives.ignored,
|
|
1566
1876
|
mode: ceiling,
|
|
1567
1877
|
leaseId: lease?.leaseId ?? null,
|
|
1568
1878
|
});
|
|
1879
|
+
let continuation = null;
|
|
1569
1880
|
try {
|
|
1570
1881
|
for (const [index, step] of directives.steps.entries()) {
|
|
1571
1882
|
if (plan.stopped) break;
|
|
@@ -1578,7 +1889,18 @@ export class Room {
|
|
|
1578
1889
|
let stepLease = lease;
|
|
1579
1890
|
let escalation = null;
|
|
1580
1891
|
const stepScopes = this.scopesFor(step.agent);
|
|
1581
|
-
|
|
1892
|
+
let wanted = stepMode(step);
|
|
1893
|
+
const askedMode = askedOf(step);
|
|
1894
|
+
// #3/#4 are never granted by an agent's text alone. The room waits for the human's
|
|
1895
|
+
// existing key, even when a lower-mode plan asked for a stronger delegate.
|
|
1896
|
+
if (askedMode >= 3 && (askedMode > reach(step.agent, askedMode) || askedMode > stepScopes.maxMode)) {
|
|
1897
|
+
const outcome = await this.#askForMode({ planId, plan, step, index, totalSteps: directives.steps.length + 1, orchestrator, parentMessageId, mode: askedMode });
|
|
1898
|
+
if (outcome.scope === 'plan') planGrants.set(step.agent, askedMode);
|
|
1899
|
+
if (outcome.scope) wanted = Math.min(askedMode, this.scopesFor(step.agent).maxMode);
|
|
1900
|
+
// Denied or unanswered, the step reads, as the line in the room says.
|
|
1901
|
+
else { escalation = outcome.reason; wanted = 1; }
|
|
1902
|
+
if (plan.stopped) break;
|
|
1903
|
+
}
|
|
1582
1904
|
// Under a #3 ceiling the orchestrator's word is enough: a #2 step gets its project lease,
|
|
1583
1905
|
// a #3 step gets CONTROL for its turn. Under #1 a creation step still asks the human.
|
|
1584
1906
|
if (ceiling >= 3 && !stepLease && wanted === 2) {
|
|
@@ -1587,8 +1909,8 @@ export class Room {
|
|
|
1587
1909
|
stepLease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, stepScopes[scope].enabled && stepScopes[scope].wired]));
|
|
1588
1910
|
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 });
|
|
1589
1911
|
}
|
|
1590
|
-
if (ceiling < 3 && !lease && !stepScopes.write.always && (
|
|
1591
|
-
const outcome = await this.#askForMode({ planId, plan, step, index, totalSteps: directives.steps.length +
|
|
1912
|
+
if (ceiling < 3 && !lease && !stepScopes.write.always && askedMode < 3 && (askedMode >= 2 || looksLikeCreation(step.text)) && stepScopes.maxMode >= 2 && stepScopes.write.enabled) {
|
|
1913
|
+
const outcome = await this.#askForMode({ planId, plan, step, index, totalSteps: directives.steps.length + 1, orchestrator, parentMessageId, mode: 2 });
|
|
1592
1914
|
if (outcome.scope === 'plan') lease = outcome.lease;
|
|
1593
1915
|
if (outcome.scope) stepLease = outcome.lease; else escalation = outcome.reason;
|
|
1594
1916
|
if (plan.stopped) break;
|
|
@@ -1605,20 +1927,21 @@ export class Room {
|
|
|
1605
1927
|
mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1,
|
|
1606
1928
|
escalation: escalation ?? undefined,
|
|
1607
1929
|
step: index + 1,
|
|
1608
|
-
totalSteps: directives.steps.length +
|
|
1930
|
+
totalSteps: directives.steps.length + 1,
|
|
1609
1931
|
});
|
|
1610
1932
|
await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: wanted >= 3 ? null : stepLease, ash, mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1, escalation });
|
|
1611
1933
|
}
|
|
1612
|
-
if (!plan.stopped
|
|
1934
|
+
if (!plan.stopped) {
|
|
1613
1935
|
plan.step = directives.steps.length + 1;
|
|
1614
1936
|
const messageId = randomUUID();
|
|
1937
|
+
// A delegate's newly granted #3/#4 never raises the orchestrator's own mode.
|
|
1615
1938
|
const closingMode = ceiling >= 3 ? ceiling : lease ? 2 : 1;
|
|
1616
1939
|
await this.#emit('message.created', {
|
|
1617
1940
|
messageId,
|
|
1618
1941
|
role: 'assistant',
|
|
1619
1942
|
sender: orchestrator,
|
|
1620
1943
|
target: orchestrator,
|
|
1621
|
-
text:
|
|
1944
|
+
text: closing,
|
|
1622
1945
|
ash: ash ? { active: true } : undefined,
|
|
1623
1946
|
mode: closingMode,
|
|
1624
1947
|
status: 'delegated',
|
|
@@ -1626,7 +1949,10 @@ export class Room {
|
|
|
1626
1949
|
step: plan.step,
|
|
1627
1950
|
totalSteps: plan.step,
|
|
1628
1951
|
});
|
|
1629
|
-
await this.#dispatch({ messageId, targetId: orchestrator, text: `${
|
|
1952
|
+
const responseMessageId = await this.#dispatch({ messageId, targetId: orchestrator, text: `${closing}\n(The delegated agents have answered above; this is your closing turn.)`, requester: orchestrator, depth: 0, planId, allowDelegation: true, deferPlan: true, lease, ash, mode: closingMode });
|
|
1953
|
+
const reply = responseMessageId ? (await this.#store.readAll()).find((event) => event.type === 'message.created' && event.payload?.messageId === responseMessageId)?.payload?.text : null;
|
|
1954
|
+
const parsed = reply ? parseDirectives(reply, { self: orchestrator, available: this.delegatesFor(orchestrator), maxSteps: this.#maxPlanSteps }) : null;
|
|
1955
|
+
if (parsed?.steps.length && JSON.stringify(parsed.steps) !== JSON.stringify(directives.steps)) continuation = { responseMessageId, directives: parsed, lease };
|
|
1630
1956
|
}
|
|
1631
1957
|
} finally {
|
|
1632
1958
|
this.#plans.delete(planId);
|
|
@@ -1637,5 +1963,6 @@ export class Room {
|
|
|
1637
1963
|
reason: plan.stopped ?? null,
|
|
1638
1964
|
});
|
|
1639
1965
|
}
|
|
1966
|
+
return continuation;
|
|
1640
1967
|
}
|
|
1641
1968
|
}
|