@jossuealcala/madre 0.7.1 → 1.0.0
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 +833 -1
- package/README.md +92 -28
- package/SECURITY.md +1 -0
- package/bin/madre.mjs +30 -10
- package/docs/REFERENCE.md +158 -79
- package/docs/SDK.md +23 -19
- package/package.json +1 -1
- package/public/app.js +11599 -4186
- 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 +2863 -1008
- 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 +1497 -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 +787 -0
- package/public/salas-mac.html +154 -0
- package/public/salas-mac.js +3264 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6303 -414
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +342 -0
- package/public/tour.js +510 -0
- package/public/troubleshooting.js +159 -140
- package/src/adapters/claude.mjs +4 -3
- package/src/adapters/codex.mjs +49 -8
- 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 +2 -2
- 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 +45 -6
- 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/dataset.mjs +3 -1
- package/src/directives.mjs +0 -5
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +0 -2
- package/src/file-ops.mjs +42 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/looks.mjs +55 -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 +109 -1
- 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 +19 -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 +9 -5
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +34 -0
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +25 -1
- package/src/outbound.mjs +19 -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 +88 -8
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +2 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +32 -13
- package/src/room/runs.mjs +151 -0
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +402 -54
- package/src/router.mjs +3 -1
- package/src/runs.mjs +137 -0
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1578 -167
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +159 -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/src/room.mjs
CHANGED
|
@@ -6,17 +6,18 @@ 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
|
+
import { RunDesk } from './room/runs.mjs';
|
|
20
21
|
import { Attachments } from './room/attachments.mjs';
|
|
21
22
|
import { GhostLedger } from './room/ghost.mjs';
|
|
22
23
|
import { Escalation } from './room/escalation.mjs';
|
|
@@ -27,18 +28,23 @@ import { OLLAMA_ARCHIVIST } from './distiller.mjs';
|
|
|
27
28
|
import { memoryServerForTurn } from './memory-tools.mjs';
|
|
28
29
|
import { CODE000_STRIKES } from './mother.mjs';
|
|
29
30
|
import { parseDirectives } from './directives.mjs';
|
|
30
|
-
import { isValidModelName } from './models.mjs';
|
|
31
|
+
import { isValidModelName, isValidEffort, belongsElsewhere, MODEL_NOT_FOUND, codexTakes, readCodexEfforts } from './models.mjs';
|
|
32
|
+
import { withListPrice } from './prices.mjs';
|
|
31
33
|
import { MODES, SCOPES, SCOPE_LABELS, capabilitySummary, normalizeMode, resolveScopes } from './capabilities.mjs';
|
|
32
34
|
import { createLease, diffSnapshots, namedPaths, snapshot } from './lease.mjs';
|
|
33
|
-
import { stat as statFile } from 'node:fs/promises';
|
|
35
|
+
import { stat as statFile, readdir, copyFile, readFile as readFileBytes } from 'node:fs/promises';
|
|
34
36
|
import { basename as baseName, join as joinPath } from 'node:path';
|
|
35
|
-
import {
|
|
37
|
+
import { declaredTypeFor } from './files.mjs';
|
|
36
38
|
import { isModuleFile, sdkPaths } from './modules/index.mjs';
|
|
37
39
|
import { validateAshReply } from './modules/ash-policy.mjs';
|
|
40
|
+
import { PHONE_MAX_MODE } from './bridge/policy.mjs';
|
|
38
41
|
import { parseChoice, withoutChoice } from './choices.mjs';
|
|
42
|
+
import { parseRunRequest, withoutRunRequest, whyText, expandHome } from './runs.mjs';
|
|
39
43
|
import { imageStudioFor } from './image-studio.mjs';
|
|
40
44
|
import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
|
|
41
45
|
import { resolveReferences } from './files.mjs';
|
|
46
|
+
|
|
47
|
+
const VIA_KINDS = new Set(['iphone', 'ipad', 'android', 'android-tablet', 'other']);
|
|
42
48
|
import { redactArgs } from './launch.mjs';
|
|
43
49
|
|
|
44
50
|
// Adapters can fail with multi-line stderr or stack traces. The room keeps only
|
|
@@ -65,14 +71,19 @@ export { budgetTokens };
|
|
|
65
71
|
// languages the crew actually speaks here.
|
|
66
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;
|
|
67
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;
|
|
68
|
-
|
|
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 ?? ''));
|
|
69
78
|
|
|
70
79
|
// Which decisions the project is standing on, out of what the archive holds. A refuted one is
|
|
71
80
|
// kept by the archive — what was once decided is part of the record — and left out here, because
|
|
72
81
|
// a strip that says "decided" cannot carry something the room has since established is false.
|
|
73
82
|
export function settledDecisions(notes = [], { limit = 3 } = {}) {
|
|
74
83
|
return notes
|
|
75
|
-
|
|
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))
|
|
76
87
|
.slice(0, limit)
|
|
77
88
|
.map((note) => ({ id: note.id, text: note.text, created: note.created, agent: note.agent, fromSequence: note.fromSequence }));
|
|
78
89
|
}
|
|
@@ -89,12 +100,16 @@ export function settledDecisions(notes = [], { limit = 3 } = {}) {
|
|
|
89
100
|
export function openQuestions(notes = [], { dismissed = [], limit = 3 } = {}) {
|
|
90
101
|
const skip = new Set(dismissed ?? []);
|
|
91
102
|
return notes
|
|
92
|
-
.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}`))
|
|
93
104
|
.sort((a, b) => (Number(b.recalled ?? 0) - Number(a.recalled ?? 0)) || (b.id - a.id))
|
|
94
105
|
.slice(0, limit)
|
|
95
106
|
.map((note) => ({ id: note.id, text: note.text, carried: Number(note.recalled ?? 0) }));
|
|
96
107
|
}
|
|
97
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
|
+
|
|
98
113
|
export class Room {
|
|
99
114
|
#store;
|
|
100
115
|
#agents;
|
|
@@ -114,6 +129,7 @@ export class Room {
|
|
|
114
129
|
#escalation; // room/escalation: plan steps waiting for the human
|
|
115
130
|
#ghost = new GhostLedger();
|
|
116
131
|
#liveListeners = new Set();
|
|
132
|
+
#quotaSaid = new Map(); // agent + source -> the last reading written to the record
|
|
117
133
|
#memoryServer; // MCP descriptor handed to every turn so the agent can query the memory itself
|
|
118
134
|
#mother = null; // MotherChannel: her coded words to the crew
|
|
119
135
|
#shuttingDown = false;
|
|
@@ -129,11 +145,19 @@ export class Room {
|
|
|
129
145
|
#delegation;
|
|
130
146
|
#privacy = null;
|
|
131
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();
|
|
132
153
|
#maxPlanSteps;
|
|
133
154
|
#maxConcurrentTurns;
|
|
134
155
|
#planMaxAgeMs;
|
|
135
156
|
#ashEnabled = false;
|
|
136
157
|
#choicesEnabled = false;
|
|
158
|
+
#runsEnabled = false;
|
|
159
|
+
#runDesk = new RunDesk();
|
|
160
|
+
#runBusy = false;
|
|
137
161
|
|
|
138
162
|
constructor({
|
|
139
163
|
store,
|
|
@@ -149,6 +173,8 @@ export class Room {
|
|
|
149
173
|
mother = null,
|
|
150
174
|
privacy = null,
|
|
151
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)
|
|
152
178
|
historicalEvents = [],
|
|
153
179
|
invokers = defaultInvokers,
|
|
154
180
|
agentTimeouts = {},
|
|
@@ -157,8 +183,17 @@ export class Room {
|
|
|
157
183
|
maxPlanSteps = 4,
|
|
158
184
|
maxConcurrentTurns = 3,
|
|
159
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,
|
|
160
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,
|
|
161
194
|
}) {
|
|
195
|
+
this.#codexHome = codexHome;
|
|
196
|
+
if (runDesk) this.#runDesk = runDesk;
|
|
162
197
|
this.#store = store;
|
|
163
198
|
this.#agents = agents;
|
|
164
199
|
this.#projectRoot = projectRoot;
|
|
@@ -170,6 +205,8 @@ export class Room {
|
|
|
170
205
|
this.#mother = mother;
|
|
171
206
|
this.#privacy = privacy;
|
|
172
207
|
this.#toolsForTurn = toolsForTurn;
|
|
208
|
+
this.#bishopForTurn = bishopForTurn;
|
|
209
|
+
this.#linkedForTurn = linkedForTurn;
|
|
173
210
|
this.#invokers = invokers;
|
|
174
211
|
this.#agentTimeouts = agentTimeouts;
|
|
175
212
|
this.#maxMessageChars = maxMessageChars;
|
|
@@ -178,7 +215,7 @@ export class Room {
|
|
|
178
215
|
this.#maxConcurrentTurns = maxConcurrentTurns;
|
|
179
216
|
this.#planMaxAgeMs = planMaxAgeMs;
|
|
180
217
|
this.#escalation = new Escalation({ escalationMs });
|
|
181
|
-
this.#controlDesk = new ControlDesk({ projectRoot });
|
|
218
|
+
this.#controlDesk = controlDesk ?? new ControlDesk({ projectRoot });
|
|
182
219
|
this.#budget = new Budget({ softBudget: softTokenBudget });
|
|
183
220
|
this.#budget.seed(historicalEvents, this.#sentinel);
|
|
184
221
|
// What this room has been charged per character, from its own past turns.
|
|
@@ -223,12 +260,13 @@ export class Room {
|
|
|
223
260
|
// because the tokens were really spent.
|
|
224
261
|
// CONTROL (#3): one holder per room, every turn between two checkpoints.
|
|
225
262
|
#controlDesk;
|
|
263
|
+
#codexHome;
|
|
226
264
|
control() { return this.#controlDesk.status(); }
|
|
227
265
|
async undoControl(checkpointId) {
|
|
228
266
|
const result = await this.#controlDesk.undo(checkpointId);
|
|
229
267
|
if (!result.ok) return result;
|
|
230
268
|
const { checkpoint, message, ...rest } = result;
|
|
231
|
-
await this.#emit('control.reverted', { checkpointId, agent: checkpoint.agent, removed: rest.removed, restored: rest.restored, message });
|
|
269
|
+
await this.#emit('control.reverted', { checkpointId, agent: checkpoint.agent, command: checkpoint.command ?? undefined, removed: rest.removed, restored: rest.restored, message });
|
|
232
270
|
return { ok: true, ...rest };
|
|
233
271
|
}
|
|
234
272
|
|
|
@@ -249,7 +287,12 @@ export class Room {
|
|
|
249
287
|
const scopes = this.scopesFor(step.agent);
|
|
250
288
|
const enabled = Object.fromEntries(SCOPES.map((scope) => [scope, scopes[scope].enabled && scopes[scope].wired]));
|
|
251
289
|
const lease = await this.#projectLease({ leaseId: randomUUID(), messageId: parentMessageId });
|
|
252
|
-
|
|
290
|
+
// The turn reads its scopes from the ceiling, as every lease does (#runTurn); a lease granted
|
|
291
|
+
// here had none, so every scope arrived off — no images — while the room had announced them on
|
|
292
|
+
// (LOG-05 of the 1.0 review). What the human granted is the ceiling; the agent's own switches
|
|
293
|
+
// still decide below it.
|
|
294
|
+
lease.scopeCeiling = { write: true, imageGen: true, web: true };
|
|
295
|
+
lease.scopes = enabled;
|
|
253
296
|
await this.#emit('lease.granted', {
|
|
254
297
|
escalated: decision.decision,
|
|
255
298
|
leaseId: lease.leaseId,
|
|
@@ -266,6 +309,9 @@ export class Room {
|
|
|
266
309
|
}
|
|
267
310
|
// The human's answer to a pending request: 'once', 'plan' or 'deny'.
|
|
268
311
|
decideMode(requestId, decision) { return this.#escalation.decide(requestId, decision); }
|
|
312
|
+
modeAsked(requestId) { return this.#escalation.modeOf(requestId); }
|
|
313
|
+
// The modes plans are waiting for the human to grant, as asked.
|
|
314
|
+
pendingModes() { return this.#escalation.pending(); }
|
|
269
315
|
|
|
270
316
|
subscribeGhost(listener) { return this.#ghost.subscribe(listener); }
|
|
271
317
|
|
|
@@ -414,6 +460,19 @@ export class Room {
|
|
|
414
460
|
// Is anything happening in here right now? Asked before a conversation is closed and another
|
|
415
461
|
// opened: a turn in flight is a reason to wait, not something to race.
|
|
416
462
|
working() { return this.#turns.size > 0; }
|
|
463
|
+
// TURNS asks two things: how many turns this conversation has in flight, and whether it is still
|
|
464
|
+
// at work at all — a plan between two of its steps has no turn running and is not finished.
|
|
465
|
+
turnsRunning() { return this.#turns.size; }
|
|
466
|
+
busy() { return this.#turns.size > 0 || this.#inflight.size > 0 || this.#plans.size > 0; }
|
|
467
|
+
// A turn running now, by the id of the answer it will write (S-021): its agent and its mode, or
|
|
468
|
+
// null. What a tool of that turn reports about itself is checked against this.
|
|
469
|
+
runningTurn(responseMessageId) {
|
|
470
|
+
const turn = this.#liveTurns.get(String(responseMessageId ?? ''));
|
|
471
|
+
return turn ? { agent: turn.agent, mode: turn.mode } : null;
|
|
472
|
+
}
|
|
473
|
+
// A command the human ran is still alive here (a server, say). Not «working», but a reason to
|
|
474
|
+
// keep this room open rather than close it under the process.
|
|
475
|
+
hasLiveRuns() { return this.#runDesk.busy || this.#runDesk.serving().length > 0; }
|
|
417
476
|
|
|
418
477
|
// The decisions this project is standing on, newest first.
|
|
419
478
|
//
|
|
@@ -435,6 +494,16 @@ export class Room {
|
|
|
435
494
|
return settledDecisions(this.#memory.memories({ kind: 'decision', limit: limit * 4 }), { limit });
|
|
436
495
|
}
|
|
437
496
|
|
|
497
|
+
// The notes the archive holds about something on the human's screen — the lines they selected,
|
|
498
|
+
// the page they pointed at — so a question can be offered from what the room already knows.
|
|
499
|
+
// Matched by words only: opening a menu sends nothing to an embedding provider. Read without
|
|
500
|
+
// counting as a recall, because nothing was recalled into a turn.
|
|
501
|
+
notesAbout(text, { limit = 3 } = {}) {
|
|
502
|
+
if (!this.#memory || !String(text ?? '').trim()) return [];
|
|
503
|
+
const notes = this.#memory.recallMemories(String(text).slice(0, 2000), { limit, maxChars: 900, queryVector: null, fallback: false, track: false, cascade: false });
|
|
504
|
+
return notes.filter((note) => note.kind !== 'aberration').map((note) => ({ id: note.id, kind: note.kind, text: note.text }));
|
|
505
|
+
}
|
|
506
|
+
|
|
438
507
|
memoryResearch() {
|
|
439
508
|
if (!this.#memory) return null;
|
|
440
509
|
const memories = this.#memory.memories({ limit: 500 });
|
|
@@ -503,7 +572,7 @@ export class Room {
|
|
|
503
572
|
// a real question at a time and takes minutes, so it runs in the background and reports where
|
|
504
573
|
// it is. Nothing here writes to the ledger or spends a provider turn.
|
|
505
574
|
async runExam(which, { findings = [] } = {}) {
|
|
506
|
-
if (!this.#memory) return { error: 'The
|
|
575
|
+
if (!this.#memory) return { error: 'The project has no memory.' };
|
|
507
576
|
if (this.#examRunning) return { error: `${this.#examRunning.toUpperCase()} is already running.` };
|
|
508
577
|
const events = await this.#store.readAll();
|
|
509
578
|
if (which === 'consistency') {
|
|
@@ -523,7 +592,7 @@ export class Room {
|
|
|
523
592
|
}
|
|
524
593
|
if (which === 'match') {
|
|
525
594
|
const invoke = this.#invokers[MADRE_ADAPTER];
|
|
526
|
-
if (!invoke) return { error: 'The local model is not running.
|
|
595
|
+
if (!invoke) return { error: 'The local model is not running. Start Ollama, or open Ollama in Modules.' };
|
|
527
596
|
this.#examRunning = 'match';
|
|
528
597
|
this.#examProgress = { done: 0, total: 0 };
|
|
529
598
|
// Answered in the background: the human keeps the room while it runs.
|
|
@@ -653,13 +722,14 @@ export class Room {
|
|
|
653
722
|
const parsed = parseMessage(text ?? '', target);
|
|
654
723
|
const wanted = normalizeMode(mode, create === true ? 2 : 1);
|
|
655
724
|
if (!parsed.target) return { ok: true, mode: wanted };
|
|
656
|
-
const
|
|
725
|
+
const agent = parsed.target;
|
|
726
|
+
const scopes = this.scopesFor(agent);
|
|
657
727
|
if (wanted >= 3) {
|
|
658
728
|
// #3 CONTROL and #4 AIRLOCK: the ceiling must allow it, and there is one holder at a time.
|
|
659
729
|
// A project without git still checkpoints, in MADRE's shadow repository.
|
|
660
|
-
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
|
|
730
|
+
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}.` };
|
|
661
731
|
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.` };
|
|
662
|
-
return { ok: true, mode: wanted, maxMode: scopes.maxMode };
|
|
732
|
+
return { ok: true, mode: wanted, maxMode: scopes.maxMode, agent };
|
|
663
733
|
}
|
|
664
734
|
// #2 above the agent's ceiling still goes out: the room answers read-only
|
|
665
735
|
// and says why (lease.refused), the way CREATE has always behaved.
|
|
@@ -691,7 +761,17 @@ export class Room {
|
|
|
691
761
|
observedAt: observedAt ?? undefined,
|
|
692
762
|
official: true,
|
|
693
763
|
};
|
|
694
|
-
|
|
764
|
+
// Written to the record only when it says something new (LOG-12 of the 1.0 review): the poll
|
|
765
|
+
// asks every minute, and an unchanged reading still became a durable event — some 1,440 a day
|
|
766
|
+
// per project, each spending a number of the project's count and making every write read a
|
|
767
|
+
// longer file. An unchanged one goes to whoever is watching, and nowhere else.
|
|
768
|
+
const said = JSON.stringify([Math.round(report.usedPercent * 10), report.resetAt ?? null, Boolean(report.stale), report.windows ?? null]);
|
|
769
|
+
const key = `${agent}\n${source}`;
|
|
770
|
+
if (this.#quotaSaid.get(key) === said) this.#say('quota.updated', report);
|
|
771
|
+
else {
|
|
772
|
+
this.#quotaSaid.set(key, said);
|
|
773
|
+
await this.#emit('quota.updated', report);
|
|
774
|
+
}
|
|
695
775
|
return this.reportLimit(report);
|
|
696
776
|
}
|
|
697
777
|
|
|
@@ -711,12 +791,23 @@ export class Room {
|
|
|
711
791
|
// Each bill teaches the room what its own words cost, so the next turn can say what it is
|
|
712
792
|
// about to spend before it spends it.
|
|
713
793
|
if (cost.charsPerInputToken) this.#rate = this.#rate ? this.#rate * 0.7 + cost.charsPerInputToken * 0.3 : cost.charsPerInputToken;
|
|
714
|
-
|
|
794
|
+
// Which MCP servers rode along: their tool lists go into every call the CLI makes inside the
|
|
795
|
+
// turn, so the economy can set turns with a server against turns without it.
|
|
796
|
+
await this.#emit('turn.cost', { agent, mode, responseMessageId, ash: Boolean(cost.blocks.ash), spared: shape.spared, servers: shape.servers ?? [], ...cost });
|
|
715
797
|
} catch (error) {
|
|
716
798
|
console.error(`MADRE could not weigh a turn: ${error.message}`);
|
|
717
799
|
}
|
|
718
800
|
}
|
|
719
801
|
|
|
802
|
+
// The model a turn ran on, for its price: the one chosen for it, or the CLI's own default when
|
|
803
|
+
// MADRE can read it (Codex's config; OpenCode's PULSE_OPENCODE_MODEL). Unknown is null: no price.
|
|
804
|
+
async #pricedModel(agentId, model) {
|
|
805
|
+
if (model) return model;
|
|
806
|
+
if (agentId === 'codex') return (await readCodexEfforts({ home: this.#codexHome }).catch(() => null))?.model ?? null;
|
|
807
|
+
if (agentId === 'opencode') return process.env.PULSE_OPENCODE_MODEL || null;
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
|
|
720
811
|
async #recordUsage(agent, usage, { messageId = null, responseMessageId = null } = {}) {
|
|
721
812
|
if (!usage) return;
|
|
722
813
|
const charged = this.#budget.record(agent, usage);
|
|
@@ -764,6 +855,71 @@ export class Room {
|
|
|
764
855
|
return this.#choicesEnabled;
|
|
765
856
|
}
|
|
766
857
|
|
|
858
|
+
setRuns(enabled) {
|
|
859
|
+
this.#runsEnabled = Boolean(enabled);
|
|
860
|
+
return this.#runsEnabled;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
runsEnabled() {
|
|
864
|
+
return this.#runsEnabled;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// The human pressed one line of a ```pulse-run an agent wrote. The client names the request and
|
|
868
|
+
// the line, never the command: what runs is read back from the room's own record of what the
|
|
869
|
+
// agent asked, so a page cannot run anything that was not shown to the human as a button.
|
|
870
|
+
async runRequested({ responseMessageId, index }) {
|
|
871
|
+
if (!this.#runsEnabled) return { ok: false, status: 412, error: 'Command requests are off. Enable them in MODULES.' };
|
|
872
|
+
const events = await this.#store.readAll();
|
|
873
|
+
const request = events.find((event) => event.type === 'run.requested' && event.payload.responseMessageId === responseMessageId)?.payload;
|
|
874
|
+
const command = request?.commands?.[index];
|
|
875
|
+
if (!command) return { ok: false, status: 404, error: 'That command request is not in this room.' };
|
|
876
|
+
if (!command.argv) return { ok: false, status: 422, error: `That line does not run: it ${whyText(command)}.` };
|
|
877
|
+
if (this.#runBusy || this.#runDesk.busy) return { ok: false, status: 409, error: 'A command is already running; wait for it or STOPALL.' };
|
|
878
|
+
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.` };
|
|
879
|
+
// Taken before the first await below: two presses in a row must not both get through.
|
|
880
|
+
this.#runBusy = true;
|
|
881
|
+
try { return await this.#runCommand({ request, command, responseMessageId, index }); } finally { this.#runBusy = false; }
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async #runCommand({ request, command, responseMessageId, index }) {
|
|
885
|
+
const run = { responseMessageId, index, agent: request.agent, line: command.line };
|
|
886
|
+
await this.#emit('run.started', run);
|
|
887
|
+
let checkpoint = null;
|
|
888
|
+
try {
|
|
889
|
+
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 });
|
|
890
|
+
} catch { /* no photograph, no UNDO: the command still runs, and the card says so */ }
|
|
891
|
+
// The first of two things: the command ends, or it opens a port and becomes a server. A server
|
|
892
|
+
// answers the press at once and keeps running; its output card comes when it is stopped.
|
|
893
|
+
let opened = () => {};
|
|
894
|
+
const serving = new Promise((resolve) => { opened = resolve; });
|
|
895
|
+
const finished = this.#runDesk.run({ id: `${responseMessageId}:${index}`, argv: expandHome(command), cwd: this.#projectRoot, onServing: (where) => opened(where) })
|
|
896
|
+
.then(async (result) => {
|
|
897
|
+
const changes = checkpoint ? await this.#controlDesk.changesSince(checkpoint).catch(() => null) : null;
|
|
898
|
+
const status = result.stoppedBy ? `stopped (${result.stoppedBy})` : result.exitCode === null ? 'did not start' : `exit ${result.exitCode}`;
|
|
899
|
+
const event = await this.#emit('command.output', {
|
|
900
|
+
name: 'run',
|
|
901
|
+
title: `$ ${command.line} · ${status} · ${(result.durationMs / 1000).toFixed(1)}s · asked by @${request.agent}`,
|
|
902
|
+
text: `${result.text || '(no output)'}${changes?.files.length ? `\n\nFiles changed: ${changes.files.map((file) => `${file.status} ${file.path}`).join(', ')}` : ''}`,
|
|
903
|
+
ok: result.ok,
|
|
904
|
+
args: [],
|
|
905
|
+
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 },
|
|
906
|
+
});
|
|
907
|
+
return { ...result, sequence: event.sequence };
|
|
908
|
+
});
|
|
909
|
+
const first = await Promise.race([finished.then((ended) => ({ ended })), serving.then((where) => ({ where }))]);
|
|
910
|
+
if (first.where) {
|
|
911
|
+
await this.#emit('run.serving', { ...run, ...first.where });
|
|
912
|
+
finished.catch(() => null);
|
|
913
|
+
return { ok: true, status: 200, result: { serving: first.where } };
|
|
914
|
+
}
|
|
915
|
+
return { ok: true, status: 200, result: { ok: first.ended.ok, exitCode: first.ended.exitCode, sequence: first.ended.sequence } };
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// The human pressed STOP on one running command's card.
|
|
919
|
+
stopRun({ responseMessageId, index }) {
|
|
920
|
+
return this.#runDesk.stop('STOP', `${responseMessageId}:${Number(index)}`);
|
|
921
|
+
}
|
|
922
|
+
|
|
767
923
|
ashEnabled() {
|
|
768
924
|
return this.#ashEnabled;
|
|
769
925
|
}
|
|
@@ -792,8 +948,11 @@ export class Room {
|
|
|
792
948
|
};
|
|
793
949
|
}
|
|
794
950
|
|
|
795
|
-
|
|
796
|
-
|
|
951
|
+
// How long a turn may run. One that writes gets longer unless the human said otherwise: the first
|
|
952
|
+
// «write me the app» in an empty project ran past 180 s and was cut with two files of three on
|
|
953
|
+
// disk. What the human set, for one agent or for all of them, always wins.
|
|
954
|
+
timeoutFor(agentId, { writing = false } = {}) {
|
|
955
|
+
return this.#agentTimeouts[agentId] ?? this.#agentTimeouts.default ?? (writing ? WRITING_TIMEOUT_MS : READING_TIMEOUT_MS);
|
|
797
956
|
}
|
|
798
957
|
|
|
799
958
|
// A process that died mid-turn leaves `agent.started` without a closing
|
|
@@ -828,6 +987,8 @@ export class Room {
|
|
|
828
987
|
this.#archivist.stop();
|
|
829
988
|
this.#vectors.stop();
|
|
830
989
|
this.#shuttingDown = true;
|
|
990
|
+
// A server a command started does not outlive the room that started it.
|
|
991
|
+
this.#runDesk.stop('MADRE is shutting down');
|
|
831
992
|
this.#escalation.settleAll(null, 'stopped');
|
|
832
993
|
for (const plan of this.#plans.values()) plan.stopped = 'MADRE is shutting down';
|
|
833
994
|
for (const { controller } of this.#turns.values()) controller.abort('MADRE is shutting down');
|
|
@@ -840,13 +1001,36 @@ export class Room {
|
|
|
840
1001
|
this.#vectors.stop();
|
|
841
1002
|
}
|
|
842
1003
|
|
|
843
|
-
async send(
|
|
1004
|
+
async send(options = {}) {
|
|
1005
|
+
await this.#accept(options, null);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// An order from BISHOP (docs/MACROVERSO.md, S-028, D-S11). The same door as the human's message
|
|
1009
|
+
// — the mode gate, the lease, the guard and the limits all run as they do for a message — with
|
|
1010
|
+
// three things fixed here and never read from a request: the origin is `bishop`, the turn runs at
|
|
1011
|
+
// depth 1 and it may not delegate, so a room that receives an order cannot open a plan or pass
|
|
1012
|
+
// anything on. Only #0–#2 come in this way; #3 and #4 are armed by the human in this room. The
|
|
1013
|
+
// caller (the BISHOP module, after it checked the contract) gets back the reply or the failure.
|
|
1014
|
+
async order({ text, target, mode = 1 } = {}) {
|
|
1015
|
+
const wanted = Number(mode);
|
|
1016
|
+
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.');
|
|
1017
|
+
const accepted = await this.#accept({ text, target, mode: wanted }, { via: 'bishop', depth: 1 });
|
|
1018
|
+
const events = await this.#store.readAll();
|
|
1019
|
+
const reply = accepted?.responseMessageId ? events.find((event) => event.type === 'message.created' && event.payload?.messageId === accepted.responseMessageId)?.payload : null;
|
|
1020
|
+
const failed = accepted?.messageId ? events.findLast((event) => event.type === 'message.failed' && event.payload?.messageId === accepted.messageId)?.payload : null;
|
|
1021
|
+
return { messageId: accepted?.messageId ?? null, responseMessageId: accepted?.responseMessageId ?? null, text: reply?.text ?? null, error: reply ? null : failed?.error ?? 'The turn ended without a reply.' };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
async #accept({ text, target, model = null, effort = null, attachments = [], create = false, ash = false, mode = undefined, draft = false, via = null }, order) {
|
|
844
1025
|
const parsed = parseMessage(text, target);
|
|
845
1026
|
const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
|
|
846
1027
|
// This is the decision the human made when pressing Send. Capture it before modeCheck() —
|
|
847
1028
|
// the first await — so a concurrent module toggle cannot change the turn after that click.
|
|
848
1029
|
const ashRequested = ash === true;
|
|
849
1030
|
const ashActive = ashRequested && this.#ashEnabled;
|
|
1031
|
+
// A message that came from a phone never goes above #2, whatever reached this far (TEL-01): the
|
|
1032
|
+
// bridge and the route say so first; the room says it last, for any road added later.
|
|
1033
|
+
if (via && requestedMode > PHONE_MAX_MODE) throw new Error('#3 EDIT and #4 FULL ACCESS are opened from the Mac.');
|
|
850
1034
|
const gate = await this.modeCheck({ target, text, mode: requestedMode });
|
|
851
1035
|
if (!gate.ok) throw new Error(gate.error);
|
|
852
1036
|
create = requestedMode === 2;
|
|
@@ -856,7 +1040,22 @@ export class Room {
|
|
|
856
1040
|
if (!parsed.text && !files.length) throw new Error('Write a message first.');
|
|
857
1041
|
if (!parsed.target) throw new Error('Choose an agent or begin with @agent.');
|
|
858
1042
|
if (model !== null && model !== undefined && model !== '' && !isValidModelName(model)) throw new Error('Model name is not valid.');
|
|
859
|
-
|
|
1043
|
+
// A model chosen for one agent never travels to another. Two ways it used to: a message that
|
|
1044
|
+
// opened with @gemini while the composer pointed at @codex took Codex's model along, and a
|
|
1045
|
+
// name that is plainly another vendor's reached the CLI and came back as an API error that
|
|
1046
|
+
// read like anything but «wrong model». Either way the agent answers on its own default.
|
|
1047
|
+
const redirected = Boolean(target) && parsed.target !== target;
|
|
1048
|
+
const modelIgnored = model && (redirected || belongsElsewhere(parsed.target, model)) ? model : null;
|
|
1049
|
+
const chosenModel = modelIgnored ? null : (model || null);
|
|
1050
|
+
// An effort the target's CLI does not take is dropped, not refused: the composer remembers it
|
|
1051
|
+
// per agent, so a stale one should cost the human nothing but the flag. Codex's levels are the
|
|
1052
|
+
// model's, so a level its model does not take is dropped the same way, and the room says so.
|
|
1053
|
+
let chosenEffort = !redirected && isValidEffort(parsed.target, effort) ? effort : null;
|
|
1054
|
+
let effortIgnored = null;
|
|
1055
|
+
if (chosenEffort && parsed.target === 'codex' && !(await codexTakes(chosenModel, chosenEffort, { home: this.#codexHome }))) {
|
|
1056
|
+
effortIgnored = chosenEffort;
|
|
1057
|
+
chosenEffort = null;
|
|
1058
|
+
}
|
|
860
1059
|
// What the human wrote, sent as they wrote it. Ash used to rewrite this line before it left
|
|
861
1060
|
// the room; it does not any more, so there is no original to keep beside an abbreviation.
|
|
862
1061
|
const text2 = parsed.text || `(${files.length} attached file${files.length === 1 ? '' : 's'})`;
|
|
@@ -872,11 +1071,17 @@ export class Room {
|
|
|
872
1071
|
mode: ghost ? 0 : control ? requestedMode : (standing || create ? 2 : 1),
|
|
873
1072
|
role: 'user',
|
|
874
1073
|
sender: 'you',
|
|
1074
|
+
// Written from a paired phone: which kind, said by the bridge (docs/MOBILE.md, MOV-008). An
|
|
1075
|
+
// order says `bishop`, set by `order()` in this process and never taken from a request.
|
|
1076
|
+
via: order ? order.via : VIA_KINDS.has(via) ? via : undefined,
|
|
875
1077
|
target: parsed.target,
|
|
876
1078
|
text: text2,
|
|
877
1079
|
ash: ashRequested ? { requested: true, active: ashActive } : undefined,
|
|
878
1080
|
status: 'sent',
|
|
879
1081
|
model: chosenModel,
|
|
1082
|
+
modelIgnored: modelIgnored ?? undefined,
|
|
1083
|
+
effort: chosenEffort ?? undefined,
|
|
1084
|
+
effortIgnored: effortIgnored ?? undefined,
|
|
880
1085
|
attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
|
|
881
1086
|
references: references.length ? references.map(({ excerpt, ...reference }) => reference) : undefined,
|
|
882
1087
|
create: create === true ? true : undefined,
|
|
@@ -902,20 +1107,25 @@ export class Room {
|
|
|
902
1107
|
await this.#emit('lease.refused', {
|
|
903
1108
|
messageId,
|
|
904
1109
|
agent: parsed.target,
|
|
905
|
-
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
|
|
1110
|
+
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}`,
|
|
906
1111
|
unavailable,
|
|
907
1112
|
disabled,
|
|
908
1113
|
alternatives,
|
|
909
|
-
message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (
|
|
1114
|
+
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 ')}.` : ''}`,
|
|
910
1115
|
});
|
|
911
1116
|
} else {
|
|
912
|
-
|
|
1117
|
+
// `/module` asks for less than #2: one file in its own draft folder. Only a #2 message can
|
|
1118
|
+
// narrow itself this way; a default-#2 lease stays what it is.
|
|
1119
|
+
lease = draft === true && create === true
|
|
1120
|
+
? await this.#draftLease({ leaseId: randomUUID(), messageId })
|
|
1121
|
+
: await this.#projectLease({ leaseId: randomUUID(), messageId });
|
|
913
1122
|
// CREATE authorizes the plan to use each delegate's enabled creation
|
|
914
1123
|
// scopes; a default-#2 lease authorizes files only.
|
|
915
1124
|
lease.scopeCeiling = { write: true, imageGen: !standing, web: true };
|
|
916
1125
|
lease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, enabled.includes(scope) && (!standing || scope === 'write')]));
|
|
917
1126
|
await this.#emit('lease.granted', {
|
|
918
1127
|
standing: standing || undefined,
|
|
1128
|
+
draft: lease.draft || undefined,
|
|
919
1129
|
leaseId: lease.leaseId,
|
|
920
1130
|
messageId,
|
|
921
1131
|
agent: parsed.target,
|
|
@@ -934,14 +1144,16 @@ export class Room {
|
|
|
934
1144
|
target: parsed.target,
|
|
935
1145
|
error: `Message is too long (${parsed.text.length} characters); the limit is ${this.#maxMessageChars}.`,
|
|
936
1146
|
});
|
|
937
|
-
return;
|
|
1147
|
+
return { messageId, responseMessageId: null };
|
|
938
1148
|
}
|
|
939
|
-
|
|
940
|
-
|
|
1149
|
+
// An order never delegates, so a plan already running here has nothing to warn about.
|
|
1150
|
+
let allowDelegation = !order;
|
|
1151
|
+
if (!order && this.#plans.size > 0) {
|
|
941
1152
|
allowDelegation = false;
|
|
942
1153
|
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}`);
|
|
943
1154
|
}
|
|
944
|
-
await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, attachments: files, references, lease, ash: ashActive, mode: ghost ? 0 : control ? requestedMode : (lease ? 2 : 1) }));
|
|
1155
|
+
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) }));
|
|
1156
|
+
return { messageId, responseMessageId };
|
|
945
1157
|
}
|
|
946
1158
|
|
|
947
1159
|
// A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
|
|
@@ -952,13 +1164,66 @@ export class Room {
|
|
|
952
1164
|
return { leaseId, messageId, outDir: this.#projectRoot, relativeDir: '.', scratchDir: scratch.relativeDir, create: true };
|
|
953
1165
|
}
|
|
954
1166
|
|
|
1167
|
+
// A lease for one folder of its own and nothing else: `/module` writes one file for the human to
|
|
1168
|
+
// read before installing it, and needs no more than that. Every CLI is told the folder and held
|
|
1169
|
+
// to it; nothing in the project can be touched, so there is no checkpoint to take and no other
|
|
1170
|
+
// turn to wait for.
|
|
1171
|
+
async #draftLease({ leaseId, messageId }) {
|
|
1172
|
+
const draft = await createLease({ projectRoot: this.#projectRoot, leaseId });
|
|
1173
|
+
return { ...draft, messageId, draft: true };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
955
1176
|
// Artifacts from a #2 turn: the files the checkpoint saw appear, as the room shows them.
|
|
1177
|
+
// Said once, when a writing turn has to wait for the one already writing in the project.
|
|
1178
|
+
async #sayWaitingToWrite({ messageId, agent }) {
|
|
1179
|
+
const ahead = this.#controlDesk.writer();
|
|
1180
|
+
if (ahead) await this.#emit('write.waiting', { messageId, agent: agent.id, behind: ahead.agent ?? null });
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// A round's answers, for the local model's closing turn: who was asked, what each one said and
|
|
1184
|
+
// under which number, or that it did not answer. Handed the transcript instead, a 7B model gave
|
|
1185
|
+
// one agent's point to another, left one out and quoted itself.
|
|
1186
|
+
async #roundAnswers(planId) {
|
|
1187
|
+
const events = (await this.#store.readAll()).filter((event) => event.payload?.planId === planId);
|
|
1188
|
+
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))];
|
|
1189
|
+
const answers = asked.map((agent) => {
|
|
1190
|
+
const said = events.find((event) => event.type === 'message.created' && event.payload.role === 'assistant' && event.payload.sender === agent && event.payload.status !== 'delegated');
|
|
1191
|
+
if (said) return { agent, sequence: said.sequence, text: said.payload.text ?? '' };
|
|
1192
|
+
const failed = events.find((event) => event.type === 'message.failed' && event.payload.target === agent);
|
|
1193
|
+
return { agent, failed: true, error: failed ? String(failed.payload.error ?? '').slice(0, 160) : null };
|
|
1194
|
+
});
|
|
1195
|
+
return { asked, answers };
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// The bytes of files the turn created, for telling a copy from a new file. Images only, and only
|
|
1199
|
+
// a few: this compares what an image tool made, not the project.
|
|
1200
|
+
async #bytesOf(paths) {
|
|
1201
|
+
const images = paths.filter((path) => /\.(png|jpe?g|webp|gif)$/i.test(path)).slice(0, 16);
|
|
1202
|
+
return (await Promise.all(images.map((path) => readFileBytes(joinPath(this.#projectRoot, path)).catch(() => null)))).filter(Boolean);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
async #draftedIn(scratchDir, { depth = 2 } = {}) {
|
|
1206
|
+
const found = [];
|
|
1207
|
+
const walk = async (relative, level) => {
|
|
1208
|
+
const entries = await readdir(joinPath(this.#projectRoot, relative), { withFileTypes: true }).catch(() => []);
|
|
1209
|
+
for (const entry of entries) {
|
|
1210
|
+
const path = `${relative}/${entry.name}`;
|
|
1211
|
+
if (entry.isDirectory()) { if (level < depth) await walk(path, level + 1); continue; }
|
|
1212
|
+
if (!entry.isFile()) continue;
|
|
1213
|
+
const info = await statFile(joinPath(this.#projectRoot, path)).catch(() => null);
|
|
1214
|
+
found.push({ name: baseName(path), path, size: info?.size ?? 0, contentType: declaredTypeFor(path), status: 'created' });
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
await walk(String(scratchDir).replace(/\/$/, ''), 0);
|
|
1218
|
+
return found.sort((a, b) => a.path.localeCompare(b.path));
|
|
1219
|
+
}
|
|
1220
|
+
|
|
956
1221
|
async #artifactsFrom(files) {
|
|
957
1222
|
const out = [];
|
|
958
1223
|
for (const file of files) {
|
|
959
1224
|
if (file.status !== 'A') continue;
|
|
960
1225
|
const info = await statFile(joinPath(this.#projectRoot, file.path)).catch(() => null);
|
|
961
|
-
out.push({ name: baseName(file.path), path: file.path, size: info?.size ?? 0, contentType:
|
|
1226
|
+
out.push({ name: baseName(file.path), path: file.path, size: info?.size ?? 0, contentType: declaredTypeFor(file.path), status: 'created' });
|
|
962
1227
|
}
|
|
963
1228
|
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
964
1229
|
}
|
|
@@ -983,6 +1248,7 @@ export class Room {
|
|
|
983
1248
|
for (const plan of this.#plans.values()) plan.stopped = reason;
|
|
984
1249
|
this.#escalation.settleAll(null, 'stopped');
|
|
985
1250
|
for (const turn of this.#turns.values()) turn.controller.abort(reason);
|
|
1251
|
+
this.#runDesk.stop('STOPALL');
|
|
986
1252
|
await this.#emit('room.stopped', { reason, plans, turns, agents: [...new Set([...this.#turns.values()].map((turn) => turn.agent))] });
|
|
987
1253
|
await Promise.allSettled([...this.#turns.values()].map((turn) => turn.promise));
|
|
988
1254
|
return { plans, turns };
|
|
@@ -1032,11 +1298,16 @@ export class Room {
|
|
|
1032
1298
|
|
|
1033
1299
|
// One room turn for one agent. `requester` is who asked ('you' or an
|
|
1034
1300
|
// orchestrating agent); `depth` 0 turns may delegate, deeper ones may not.
|
|
1035
|
-
async #dispatch({ messageId, targetId, text, requester, depth, planId = null, allowDelegation = true, model = null, attachments = [], references = [], lease = null, ash = false, mode = 1, escalation = null }) {
|
|
1301
|
+
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 }) {
|
|
1036
1302
|
if (this.#shuttingDown) return null;
|
|
1037
1303
|
const agent = this.#agents.find((item) => item.id === targetId);
|
|
1038
1304
|
if (!agent?.detected) {
|
|
1039
|
-
|
|
1305
|
+
// @madre is not a CLI to install: it is the room's local agent, here while Ollama runs with a
|
|
1306
|
+
// chat model. Saying "not installed" sent people to install something that does not exist.
|
|
1307
|
+
const error = agent?.local || targetId === 'madre'
|
|
1308
|
+
? `@${targetId} left the room: Ollama is not running. Start Ollama, or open Ollama in Modules.`
|
|
1309
|
+
: `${targetId} is not installed on this computer.`;
|
|
1310
|
+
await this.#emit('message.failed', { messageId, target: targetId, planId, error });
|
|
1040
1311
|
return null;
|
|
1041
1312
|
}
|
|
1042
1313
|
if (!agent.ready) {
|
|
@@ -1086,10 +1357,12 @@ export class Room {
|
|
|
1086
1357
|
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}`);
|
|
1087
1358
|
}
|
|
1088
1359
|
}
|
|
1089
|
-
|
|
1360
|
+
// The limit this turn will be held to, said with its start so the room counts down from the
|
|
1361
|
+
// real number and not from a default it guessed.
|
|
1362
|
+
await this.#emit('agent.started', { messageId, agent: agent.id, handoffId, planId, timeoutMs: this.timeoutFor(agent.id, { writing: Number(mode) >= 2 || Boolean(lease) }) });
|
|
1090
1363
|
const controller = new AbortController();
|
|
1091
1364
|
const turn = { controller, promise: null, planId, agent: agent.id, startedAt: Date.now() };
|
|
1092
|
-
turn.promise = this.#runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall, memories, handoffId, signal: controller.signal, model, attachments, references, lease, ash, mode, escalation });
|
|
1365
|
+
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 });
|
|
1093
1366
|
this.#turns.set(messageId, turn);
|
|
1094
1367
|
let outcome = null;
|
|
1095
1368
|
try {
|
|
@@ -1106,10 +1379,9 @@ export class Room {
|
|
|
1106
1379
|
return outcome?.responseMessageId ?? null;
|
|
1107
1380
|
}
|
|
1108
1381
|
|
|
1109
|
-
// What the next turn to an agent would carry, built
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
// until now existed only for the instant a CLI was reading it.
|
|
1382
|
+
// What the next turn to an agent would carry, built without starting a process or counting a
|
|
1383
|
+
// memory read. Signed linked notes for BISHOP are fetched only on send, so the preview marks
|
|
1384
|
+
// that one dynamic part instead of spending a contract's hourly reading allowance here.
|
|
1113
1385
|
async briefing({ agent: agentId, mode = 1, text = '', ash = false } = {}) {
|
|
1114
1386
|
const agent = this.#agents.find((one) => one.id === agentId && one.detected);
|
|
1115
1387
|
if (!agent) return null;
|
|
@@ -1123,6 +1395,9 @@ export class Room {
|
|
|
1123
1395
|
const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
|
|
1124
1396
|
? await this.#toolsForTurn({ agent: agent.id, mode, lease: null, scratchDir: null }).catch(() => [])
|
|
1125
1397
|
: [];
|
|
1398
|
+
const bishop = this.#bishopForTurn ? await this.#bishopForTurn({ agent: agent.id, mode, text, preview: true }).catch(() => null) : null;
|
|
1399
|
+
// Recall from links reads the other room when the turn runs; the preview only says it will.
|
|
1400
|
+
const linkedPreview = this.#linkedForTurn && agent.adapter !== 'madre-local' && mode !== 0 ? await this.#linkedForTurn({ agent: agent.id, mode, text, preview: true }).catch(() => null) : null;
|
|
1126
1401
|
const scopes = this.scopesFor(agent.id);
|
|
1127
1402
|
// The lease a turn at this mode would be given, shaped exactly as the turn shapes it but
|
|
1128
1403
|
// creating nothing: no folder is made, no checkpoint is taken. Without it the preview would
|
|
@@ -1145,7 +1420,7 @@ export class Room {
|
|
|
1145
1420
|
context: built.context, recall: built.recall, memories: built.memories,
|
|
1146
1421
|
attachments: [], references: [], lease,
|
|
1147
1422
|
scopes: { web: scopes.web.enabled && scopes.web.wired, imageGen: scopes.imageGen.enabled && scopes.imageGen.wired },
|
|
1148
|
-
ash: ash === true && this.ashEnabled(), mode, escalation: null, mcpServers,
|
|
1423
|
+
ash: ash === true && this.ashEnabled(), choices: this.#choicesEnabled && mode !== 0, runs: this.#runsEnabled && mode !== 0 && mode < 4, mode, escalation: null, mcpServers, bishop,
|
|
1149
1424
|
});
|
|
1150
1425
|
// Each block with what put it here and what would take it away, so "read, never written" can
|
|
1151
1426
|
// be checked one block at a time instead of believed.
|
|
@@ -1162,6 +1437,7 @@ export class Room {
|
|
|
1162
1437
|
window: { from: built.context?.firstSequence ?? null, through: built.context?.throughSequence ?? null, omitted: built.context?.omittedMessages ?? 0, carried: built.context?.messages?.length ?? 0 },
|
|
1163
1438
|
recalled: built.memories?.length ?? 0,
|
|
1164
1439
|
quoted: built.recall?.entries?.length ?? 0,
|
|
1440
|
+
dynamic: Boolean(bishop && agent.adapter === 'madre-local') || Boolean(linkedPreview?.dynamic),
|
|
1165
1441
|
};
|
|
1166
1442
|
}
|
|
1167
1443
|
|
|
@@ -1244,7 +1520,7 @@ export class Room {
|
|
|
1244
1520
|
});
|
|
1245
1521
|
}
|
|
1246
1522
|
|
|
1247
|
-
async #runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall = null, memories = null, handoffId, signal, model = null, attachments = [], references = [], lease: sharedLease = null, ash = false, mode = 1, escalation = null }) {
|
|
1523
|
+
async #runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall = null, memories = null, handoffId, signal, model = null, effort = null, attachments = [], references = [], lease: sharedLease = null, ash = false, mode = 1, escalation = null }) {
|
|
1248
1524
|
// The lease is the human's; what each agent may do inside it is that
|
|
1249
1525
|
// agent's own enabled scopes. A delegate without file creation runs
|
|
1250
1526
|
// read-only even while the plan holds a lease.
|
|
@@ -1264,7 +1540,9 @@ export class Room {
|
|
|
1264
1540
|
if (this.#controlDesk.holder) {
|
|
1265
1541
|
await this.#alert('control-busy', `@${this.#controlDesk.holder.agent} still holds CONTROL; @${agent.id} answers read-only this turn.`, `control-busy:${agent.id}`);
|
|
1266
1542
|
} else {
|
|
1267
|
-
|
|
1543
|
+
await this.#sayWaitingToWrite({ messageId, agent });
|
|
1544
|
+
const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode, signal }).catch((error) => (error?.code === 'ABORT_ERR' ? null : Promise.reject(error)));
|
|
1545
|
+
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; }
|
|
1268
1546
|
controlRun = seat.run;
|
|
1269
1547
|
lease = seat.lease;
|
|
1270
1548
|
await this.#emit('control.started', seat.announcement);
|
|
@@ -1293,8 +1571,11 @@ export class Room {
|
|
|
1293
1571
|
});
|
|
1294
1572
|
}
|
|
1295
1573
|
if (lease?.create && !controlRun) {
|
|
1296
|
-
// CREATE: photograph the project now; after the turn only what appeared stays.
|
|
1297
|
-
|
|
1574
|
+
// CREATE: photograph the project now; after the turn only what appeared stays. Another turn
|
|
1575
|
+
// writing in the project, in this conversation or another, is waited for first.
|
|
1576
|
+
await this.#sayWaitingToWrite({ messageId, agent });
|
|
1577
|
+
const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode: 2, signal }).catch((error) => (error?.code === 'ABORT_ERR' ? null : Promise.reject(error)));
|
|
1578
|
+
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; }
|
|
1298
1579
|
createRun = seat.run;
|
|
1299
1580
|
// The folder this turn was told to draft in, so settling it does not undo what it was
|
|
1300
1581
|
// invited to write. Only this turn's own folder; every other `.pulse` path stays forbidden.
|
|
@@ -1328,17 +1609,24 @@ export class Room {
|
|
|
1328
1609
|
if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
|
|
1329
1610
|
const before = lease && !lease.control && !lease.create ? await snapshot(lease.outDir) : null;
|
|
1330
1611
|
responseMessageId = randomUUID();
|
|
1612
|
+
this.#liveTurns.set(responseMessageId, { agent: agent.id, mode: turnMode });
|
|
1331
1613
|
const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
|
|
1332
1614
|
const others = this.delegatesFor(agent.id);
|
|
1333
1615
|
const mayDelegate = allowDelegation && this.#delegation && depth === 0;
|
|
1334
1616
|
// Tools the modules hand to this turn (a browser, say), attached to the CLI for this run only.
|
|
1335
1617
|
const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
|
|
1336
|
-
? await this.#toolsForTurn({ agent: agent.id, mode: turnMode, lease, scratchDir: lease?.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : null }).catch(() => [])
|
|
1618
|
+
? 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(() => [])
|
|
1337
1619
|
: [];
|
|
1620
|
+
const bishop = this.#bishopForTurn ? await this.#bishopForTurn({ agent: agent.id, mode: turnMode, text, turn: responseMessageId }).catch(() => null) : null;
|
|
1621
|
+
// Notes a link with automatic recall brings to this turn (fase 3e): never in GHOST, never for
|
|
1622
|
+
// @madre (its briefing is its own), bounded by the module and counted as readings.
|
|
1623
|
+
const linked = this.#linkedForTurn && agent.adapter !== 'madre-local' && turnMode !== 0
|
|
1624
|
+
? (await this.#linkedForTurn({ agent: agent.id, mode: turnMode, text, turn: responseMessageId }).catch(() => null))?.text ?? null
|
|
1625
|
+
: null;
|
|
1338
1626
|
// The prompt and the shape it was built from: one is sent, the other is kept until the CLI
|
|
1339
1627
|
// says what it cost, so the bill can be attributed to the blocks that caused it.
|
|
1340
|
-
const shaped = this.#promptFor({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ash, choices: this.#choicesEnabled, 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 });
|
|
1341
|
-
this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared });
|
|
1628
|
+
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 });
|
|
1629
|
+
this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared, servers: [this.#memoryServer?.name, ...mcpServers.map((server) => server.name)].filter(Boolean) });
|
|
1342
1630
|
// What this turn is about to read, said while it is still reading it. The size is exact;
|
|
1343
1631
|
// only the conversion to tokens is an estimate, and it is made at this room's own rate.
|
|
1344
1632
|
const reading = this.#reading(shaped.parts);
|
|
@@ -1358,28 +1646,49 @@ export class Room {
|
|
|
1358
1646
|
projectRoot: this.#projectRoot,
|
|
1359
1647
|
text,
|
|
1360
1648
|
prompt: shaped.text,
|
|
1361
|
-
timeoutMs: this.timeoutFor(agent.id),
|
|
1649
|
+
timeoutMs: this.timeoutFor(agent.id, { writing: Boolean(lease) }),
|
|
1362
1650
|
signal,
|
|
1363
1651
|
model,
|
|
1652
|
+
effort,
|
|
1364
1653
|
attachments,
|
|
1365
1654
|
lease,
|
|
1366
1655
|
scopes: turnScopes,
|
|
1367
1656
|
imageStudio,
|
|
1368
1657
|
memoryServer: memoryServerForTurn(this.#memoryServer, { agent: agent.id, messageId: responseMessageId, mode: turnMode }),
|
|
1369
1658
|
mcpServers,
|
|
1659
|
+
bishop,
|
|
1370
1660
|
// For @madre: who asks, who it may convene, and whether a plan would run at all.
|
|
1371
1661
|
requester,
|
|
1372
1662
|
crew: others,
|
|
1373
1663
|
delegation: mayDelegate,
|
|
1374
1664
|
maxSteps: this.#maxPlanSteps,
|
|
1665
|
+
// And for its closing turn of a round: what each agent asked actually said, with its number.
|
|
1666
|
+
round: agent.id === MADRE_AGENT_ID && planId && /this is your closing turn/i.test(text) ? await this.#roundAnswers(planId) : undefined,
|
|
1375
1667
|
});
|
|
1376
1668
|
// Privacy: a private term in a reply is replaced before the ledger, the plan parser,
|
|
1377
1669
|
// the archivist or any other agent can see it. The reply says how many, not which.
|
|
1378
1670
|
const guarded = this.#privacy?.redact(result.text ?? '') ?? { text: result.text, hits: 0 };
|
|
1379
1671
|
if (guarded.hits) result.text = guarded.text;
|
|
1380
1672
|
// CREATE: what appeared stays and is shown; what existed before is put back and said.
|
|
1381
|
-
const createChanges = createRun ? await this.#controlDesk.
|
|
1673
|
+
const createChanges = createRun ? await this.#controlDesk.settleOrSay(createRun) : null;
|
|
1674
|
+
// Images an agent's own tool made outside the project (Codex keeps them under its home) come
|
|
1675
|
+
// into this turn's draft folder, so they are kept and shown like anything the turn wrote. One
|
|
1676
|
+
// the agent already copied into the project is shown there, not twice.
|
|
1677
|
+
if (createRun?.scratchDir && Array.isArray(result.generatedImages)) {
|
|
1678
|
+
const placed = await this.#bytesOf((createChanges?.files ?? []).filter((file) => file.status === 'A').map((file) => file.path));
|
|
1679
|
+
for (const image of result.generatedImages.slice(0, 8)) {
|
|
1680
|
+
const bytes = await readFileBytes(image).catch(() => null);
|
|
1681
|
+
if (!bytes || placed.some((one) => one.equals(bytes))) continue;
|
|
1682
|
+
await copyFile(image, joinPath(this.#projectRoot, createRun.scratchDir, baseName(image))).catch(() => null);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1382
1685
|
const artifacts = createChanges ? await this.#artifactsFrom(createChanges.files) : lease && !lease.control ? diffSnapshots(before, await snapshot(lease.outDir), { relativeDir: lease.relativeDir }) : [];
|
|
1686
|
+
// What the turn wrote in its own draft folder. The diff cannot see it in a project that ignores
|
|
1687
|
+
// `.pulse/` (most do, the moment a .gitignore names it), so `/module` wrote its file there and
|
|
1688
|
+
// no install card ever came. The folder is new for this turn: all of it is this turn's.
|
|
1689
|
+
if (createRun?.scratchDir) {
|
|
1690
|
+
for (const drafted of await this.#draftedIn(createRun.scratchDir)) if (!artifacts.some((one) => one.path === drafted.path)) artifacts.push(drafted);
|
|
1691
|
+
}
|
|
1383
1692
|
// A turn that could write, wrote nothing, and answers naming files. That is the shape of a
|
|
1384
1693
|
// write refused without a word — a headless CLI cannot stop to ask, so what the policy does
|
|
1385
1694
|
// not allow simply fails and the model reports the file as created. The room does not read
|
|
@@ -1396,7 +1705,7 @@ export class Room {
|
|
|
1396
1705
|
if (missing.length) await this.#emit('create.missing', { messageId: responseMessageId, agent: agent.id, planId, files: missing });
|
|
1397
1706
|
}
|
|
1398
1707
|
// CONTROL: what really changed in the project, forbidden zones reverted on the spot.
|
|
1399
|
-
const controlChanges = controlRun ? await this.#controlDesk.
|
|
1708
|
+
const controlChanges = controlRun ? await this.#controlDesk.settleOrSay(controlRun) : null;
|
|
1400
1709
|
const directives = mayDelegate
|
|
1401
1710
|
? parseDirectives(result.text, { self: agent.id, available: others, maxSteps: this.#maxPlanSteps })
|
|
1402
1711
|
: { steps: [], closing: null, ignored: [] };
|
|
@@ -1405,18 +1714,22 @@ export class Room {
|
|
|
1405
1714
|
// human sees a plan-shaped reply and nothing happening.
|
|
1406
1715
|
await this.#emit('plan.ignored', { orchestrator: agent.id, responseMessageId, reasons: directives.ignored.map((item) => item.reason) });
|
|
1407
1716
|
}
|
|
1408
|
-
|
|
1717
|
+
// Only a ```pulse fence is a plan, read the way src/directives.mjs reads it; ```pulse-run,
|
|
1718
|
+
// ```pulse-ask and ```pulse-salas are not (S-029 in docs/MACROVERSO.md).
|
|
1719
|
+
if (depth > 0 && /```pulse\s*\n/i.test(result.text)) {
|
|
1409
1720
|
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}`);
|
|
1410
1721
|
}
|
|
1411
1722
|
// A question the agent put to the human: it leaves the text and comes back as buttons.
|
|
1412
1723
|
const asked = this.#choicesEnabled && turnMode !== 0 ? parseChoice(result.text) : null;
|
|
1724
|
+
// Commands it wants the human to run: the same, as buttons. AIRLOCK runs its own.
|
|
1725
|
+
const requested = !asked && this.#runsEnabled && turnMode !== 0 && turnMode < 4 ? parseRunRequest(result.text) : null;
|
|
1413
1726
|
await this.#emit('message.created', {
|
|
1414
1727
|
messageId: responseMessageId,
|
|
1415
1728
|
parentMessageId: messageId,
|
|
1416
1729
|
role: 'assistant',
|
|
1417
1730
|
sender: agent.id,
|
|
1418
1731
|
target: requester,
|
|
1419
|
-
text: asked ? withoutChoice(result.text) : result.text,
|
|
1732
|
+
text: asked ? withoutChoice(result.text) : requested ? withoutRunRequest(result.text) : result.text,
|
|
1420
1733
|
// What the archive handed this turn, so the room can see its own memory working. The
|
|
1421
1734
|
// whole point of remembering is invisible until the moment it is used.
|
|
1422
1735
|
recalled: memories?.length
|
|
@@ -1426,6 +1739,7 @@ export class Room {
|
|
|
1426
1739
|
status: 'completed',
|
|
1427
1740
|
planId,
|
|
1428
1741
|
model,
|
|
1742
|
+
effort: effort ?? undefined,
|
|
1429
1743
|
synthetic: result.synthetic || undefined,
|
|
1430
1744
|
redacted: guarded.hits || undefined,
|
|
1431
1745
|
mode: turnMode,
|
|
@@ -1437,8 +1751,9 @@ export class Room {
|
|
|
1437
1751
|
const validation = validateAshReply({ request: text, response: result.text });
|
|
1438
1752
|
await this.#emit('ash.validated', { messageId, responseMessageId, agent: agent.id, ...validation });
|
|
1439
1753
|
}
|
|
1440
|
-
if (asked) await this.#emit('choice.offered', { messageId, responseMessageId, agent: agent.id, question: asked.question, options: asked.options });
|
|
1441
|
-
if (
|
|
1754
|
+
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 });
|
|
1755
|
+
if (requested) await this.#emit('run.requested', { messageId, responseMessageId, agent: agent.id, reason: requested.reason, commands: requested.commands });
|
|
1756
|
+
if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, kinds: guarded.kinds, marker: this.#privacy.marker });
|
|
1442
1757
|
if (artifacts.length) {
|
|
1443
1758
|
await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
|
|
1444
1759
|
// A <id>.module.mjs is an agent proposing a module: the human installs it from the room, or not.
|
|
@@ -1458,11 +1773,17 @@ export class Room {
|
|
|
1458
1773
|
}
|
|
1459
1774
|
}
|
|
1460
1775
|
if (controlChanges) await this.#emit('control.changed', { ...controlChanges, responseMessageId });
|
|
1776
|
+
// Codex and OpenCode give tokens, not dollars: the turn is priced at the list price of the
|
|
1777
|
+
// model it ran on (src/prices.mjs), an estimate that says it is one. Gemini prices itself in
|
|
1778
|
+
// its adapter, where its report names each model it used.
|
|
1779
|
+
if (result.usage) result.usage = withListPrice(result.usage, await this.#pricedModel(agent.id, model));
|
|
1461
1780
|
await this.#recordUsage(agent.id, result.usage, { messageId, responseMessageId });
|
|
1462
1781
|
await this.#recordCost(agent.id, responseMessageId, result.usage, turnMode);
|
|
1782
|
+
this.#liveTurns.delete(responseMessageId);
|
|
1463
1783
|
await this.#emit('agent.completed', { messageId, agent: agent.id, handoffId, planId });
|
|
1464
1784
|
return { responseMessageId, directives };
|
|
1465
1785
|
} catch (error) {
|
|
1786
|
+
if (responseMessageId) this.#liveTurns.delete(responseMessageId);
|
|
1466
1787
|
if (responseMessageId && this.#promptShape.has(responseMessageId)) {
|
|
1467
1788
|
const shape = this.#promptShape.get(responseMessageId);
|
|
1468
1789
|
this.#promptShape.delete(responseMessageId);
|
|
@@ -1471,7 +1792,34 @@ export class Room {
|
|
|
1471
1792
|
await this.#emit('turn.cost', { agent: agent.id, mode: turnMode, responseMessageId, ash: Boolean(cost.blocks.ash), failed: true, spared: shape.spared, ...cost });
|
|
1472
1793
|
}
|
|
1473
1794
|
}
|
|
1474
|
-
|
|
1795
|
+
// A CLI that does not know its model says so in its vendor's words, which read like an outage
|
|
1796
|
+
// or a permission problem. MADRE says what it is first; the CLI's own line stays after it,
|
|
1797
|
+
// word for word, so it can still be searched.
|
|
1798
|
+
const said = failureMessage(error);
|
|
1799
|
+
const plain = MODEL_NOT_FOUND.test(String(error?.message ?? error ?? ''))
|
|
1800
|
+
? `${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}`
|
|
1801
|
+
: said;
|
|
1802
|
+
// A turn cut short (the time limit, STOP ALL, a crash) may still have written. What it left is
|
|
1803
|
+
// read and kept the way any turn's is, CREATE still putting back what existed before, and the
|
|
1804
|
+
// failure carries the list and its checkpoint: the first «write the app» was cut at 180 s
|
|
1805
|
+
// with two files of three on disk, and the room said only that Claude did not answer.
|
|
1806
|
+
let partial = null;
|
|
1807
|
+
const writer = controlRun ?? createRun;
|
|
1808
|
+
if (writer) {
|
|
1809
|
+
const changes = await this.#controlDesk.settleOrSay(writer);
|
|
1810
|
+
if (changes.files.length || changes.unreadable || changes.existingReverted.length) {
|
|
1811
|
+
const created = await this.#artifactsFrom(changes.files);
|
|
1812
|
+
partial = { checkpointId: changes.checkpointId, mode: changes.mode, files: changes.files, created, existingReverted: changes.existingReverted, unreadable: changes.unreadable ?? undefined };
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
const cutByClock = ['TIMEOUT', 'IDLE'].includes(error?.code);
|
|
1816
|
+
await this.#emit('message.failed', {
|
|
1817
|
+
messageId, target: agent.id, planId,
|
|
1818
|
+
error: this.#privacy ? this.#privacy.redact(plain).text : plain,
|
|
1819
|
+
...(this.#turns.get(messageId)?.stoppedByHuman ? { stopped: true } : {}),
|
|
1820
|
+
...(cutByClock ? { timeout: { ms: this.timeoutFor(agent.id, { writing: Boolean(lease) }), writing: Boolean(lease), idle: error.code === 'IDLE' } } : {}),
|
|
1821
|
+
...(partial ? { partial } : {}),
|
|
1822
|
+
});
|
|
1475
1823
|
return null;
|
|
1476
1824
|
} finally {
|
|
1477
1825
|
// Release CONTROL whichever way the turn ended; the checkpoint stays for UNDO.
|