@jossuealcala/madre 0.3.2 → 0.4.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +425 -0
  2. package/CONTRIBUTING.md +6 -1
  3. package/README.md +67 -183
  4. package/SECURITY.md +2 -1
  5. package/bin/madre.mjs +56 -13
  6. package/docs/INTERNALS.md +16 -0
  7. package/docs/REFERENCE.md +249 -0
  8. package/docs/SDK.md +121 -0
  9. package/docs/room.png +0 -0
  10. package/docs/sdk/hello-module.mjs +51 -0
  11. package/package.json +9 -1
  12. package/public/app.js +3880 -849
  13. package/public/es.js +2050 -0
  14. package/public/i18n.js +66 -0
  15. package/public/index.html +95 -14
  16. package/public/inquiry.js +220 -0
  17. package/public/resay.js +77 -0
  18. package/public/styles.css +626 -68
  19. package/public/troubleshooting.js +173 -51
  20. package/src/adapters/claude.mjs +13 -6
  21. package/src/adapters/codex.mjs +17 -13
  22. package/src/adapters/gemini.mjs +27 -16
  23. package/src/adapters/opencode.mjs +16 -12
  24. package/src/adapters/process.mjs +17 -5
  25. package/src/asking.mjs +128 -0
  26. package/src/auth-probe.mjs +58 -1
  27. package/src/capabilities.mjs +4 -3
  28. package/src/chats.mjs +193 -0
  29. package/src/checkpoint.mjs +1 -1
  30. package/src/cold.mjs +56 -0
  31. package/src/commands.mjs +31 -3
  32. package/src/conversation-context.mjs +35 -3
  33. package/src/credentials.mjs +145 -0
  34. package/src/dataset.mjs +56 -4
  35. package/src/distiller.mjs +12 -5
  36. package/src/event-store.mjs +14 -8
  37. package/src/exam.mjs +240 -0
  38. package/src/extensions.mjs +3 -2
  39. package/src/eyecat-watch.mjs +100 -0
  40. package/src/eyecat.mjs +169 -0
  41. package/src/i18n.mjs +47 -0
  42. package/src/image-studio.mjs +2 -0
  43. package/src/launch.mjs +61 -0
  44. package/src/lease.mjs +5 -3
  45. package/src/maturity.mjs +94 -0
  46. package/src/mcp/image-server.mjs +12 -1
  47. package/src/mcp/memory-server.mjs +1 -1
  48. package/src/memory.mjs +325 -17
  49. package/src/modules/ahp.mjs +9 -7
  50. package/src/modules/ash.mjs +36 -0
  51. package/src/modules/git-pulse.mjs +6 -4
  52. package/src/modules/helpers.mjs +31 -0
  53. package/src/modules/image-studio.mjs +9 -4
  54. package/src/modules/index.mjs +143 -5
  55. package/src/modules/ollama.mjs +66 -10
  56. package/src/modules/playwright.mjs +90 -0
  57. package/src/modules/ripley.mjs +5 -3
  58. package/src/modules/sdk.mjs +104 -2
  59. package/src/modules/updates.mjs +81 -0
  60. package/src/ollama.mjs +5 -2
  61. package/src/outbound.mjs +292 -0
  62. package/src/privacy.mjs +54 -7
  63. package/src/room/context.mjs +4 -4
  64. package/src/room/control.mjs +4 -4
  65. package/src/room/economy.mjs +161 -0
  66. package/src/room/prompt.mjs +118 -43
  67. package/src/room.mjs +465 -58
  68. package/src/runtime-detection.mjs +27 -8
  69. package/src/server.mjs +724 -68
  70. package/src/setup.mjs +1 -1
  71. package/src/updates.mjs +17 -2
  72. package/src/usage-sentinel.mjs +13 -8
  73. package/src/verdict.mjs +74 -0
  74. package/src/ashcode.mjs +0 -64
  75. package/src/modules/ashcode.mjs +0 -28
package/src/room.mjs CHANGED
@@ -1,13 +1,20 @@
1
- import { invokeCodex } from './adapters/codex.mjs';
2
- import { invokeClaude } from './adapters/claude.mjs';
3
- import { invokeGemini } from './adapters/gemini.mjs';
4
- import { invokeOpenCode } from './adapters/opencode.mjs';
1
+ import { invokeCodex, buildCodexArgs } from './adapters/codex.mjs';
2
+ import { t } from './i18n.mjs';
3
+ import { invokeClaude, buildClaudeArgs } from './adapters/claude.mjs';
4
+ import { invokeGemini, buildGeminiArgs } from './adapters/gemini.mjs';
5
+ import { invokeOpenCode, buildOpenCodeArgs } from './adapters/opencode.mjs';
5
6
  import { parseMessage } from './router.mjs';
6
7
  import { randomUUID } from 'node:crypto';
7
8
  import { basename } from 'node:path';
8
9
  import { UsageSentinel } from './usage-sentinel.mjs';
9
- import { buildPrompt } from './room/prompt.mjs';
10
+ import { BLOCK_SOURCES, buildPrompt, promptParts, sparedChars } from './room/prompt.mjs';
11
+ import { turnCost, observedRate } from './room/economy.mjs';
10
12
  import { contextFor } from './room/context.mjs';
13
+ import { coldNotes, coldReading } from './cold.mjs';
14
+ import { questionsFor } from './asking.mjs';
15
+ import { coverageExam, consistencyExam, matchExam, exchanges, MATCH_SAMPLE, saysFor } from './exam.mjs';
16
+ import { MADRE_ADAPTER, MADRE_AGENT_ID } from './adapters/madre.mjs';
17
+ import { verdictFor } from './verdict.mjs';
11
18
  import { ControlDesk } from './room/control.mjs';
12
19
  import { Attachments } from './room/attachments.mjs';
13
20
  import { GhostLedger } from './room/ghost.mjs';
@@ -15,6 +22,7 @@ import { Escalation } from './room/escalation.mjs';
15
22
  import { Budget, budgetTokens } from './room/budget.mjs';
16
23
  import { VectorWorker } from './room/vectors.mjs';
17
24
  import { Archivist, distillDefaults } from './room/archivist.mjs';
25
+ import { OLLAMA_ARCHIVIST } from './distiller.mjs';
18
26
  import { memoryServerForTurn } from './memory-tools.mjs';
19
27
  import { CODE000_STRIKES } from './mother.mjs';
20
28
  import { parseDirectives } from './directives.mjs';
@@ -24,11 +32,11 @@ import { createLease, diffSnapshots, snapshot } from './lease.mjs';
24
32
  import { stat as statFile } from 'node:fs/promises';
25
33
  import { basename as baseName, join as joinPath } from 'node:path';
26
34
  import { contentTypeFor } from './files.mjs';
35
+ import { isModuleFile, sdkPaths } from './modules/index.mjs';
27
36
  import { imageStudioFor } from './image-studio.mjs';
28
37
  import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
29
38
  import { resolveReferences } from './files.mjs';
30
- import { compressAshCode } from './ashcode.mjs';
31
- import { isGitRepo } from './checkpoint.mjs';
39
+ import { redactArgs } from './launch.mjs';
32
40
 
33
41
  // Adapters can fail with multi-line stderr or stack traces. The room keeps only
34
42
  // the first meaningful line, bounded, so the event log and the UI stay readable.
@@ -66,10 +74,15 @@ export class Room {
66
74
  #contextMaxChars;
67
75
  #memory; // RoomMemory: durable recall of everything said outside GHOST
68
76
  #recallShare; // fraction of the context budget recall may take
77
+ #cascade; // whether recall also carries what a memory keeps arriving with
78
+ #examRunning = null; // the test in flight, if any
79
+ #examProgress = null;
80
+ #examStop = false;
69
81
  #archivist; // room/archivist: who distils, when, and the bench
70
82
  #vectors; // room/vectors: embeddings filled in the background
71
83
  #escalation; // room/escalation: plan steps waiting for the human
72
84
  #ghost = new GhostLedger();
85
+ #liveListeners = new Set();
73
86
  #memoryServer; // MCP descriptor handed to every turn so the agent can query the memory itself
74
87
  #mother = null; // MotherChannel: her coded words to the crew
75
88
  #shuttingDown = false;
@@ -84,10 +97,11 @@ export class Room {
84
97
  #scopeConfig = {};
85
98
  #delegation;
86
99
  #privacy = null;
100
+ #toolsForTurn = null;
87
101
  #maxPlanSteps;
88
102
  #maxConcurrentTurns;
89
103
  #planMaxAgeMs;
90
- #ashCodeEnabled = false;
104
+ #ashEnabled = false;
91
105
 
92
106
  constructor({
93
107
  store,
@@ -97,10 +111,12 @@ export class Room {
97
111
  contextMaxChars = 16000,
98
112
  memory = null,
99
113
  recallShare = Number(process.env.PULSE_RECALL_SHARE ?? 0.3),
114
+ cascade = process.env.PULSE_RECALL_CASCADE !== '0',
100
115
  distill = {},
101
116
  memoryServer = null,
102
117
  mother = null,
103
118
  privacy = null,
119
+ toolsForTurn = null, // async ({ agent, mode, lease, scratchDir }) => MCP server specs from the modules
104
120
  historicalEvents = [],
105
121
  invokers = defaultInvokers,
106
122
  agentTimeouts = {},
@@ -117,9 +133,11 @@ export class Room {
117
133
  this.#contextMaxChars = contextMaxChars;
118
134
  this.#memory = memory;
119
135
  this.#recallShare = Math.min(0.6, Math.max(0, Number.isFinite(recallShare) ? recallShare : 0.3));
136
+ this.#cascade = cascade !== false;
120
137
  this.#memoryServer = memoryServer;
121
138
  this.#mother = mother;
122
139
  this.#privacy = privacy;
140
+ this.#toolsForTurn = toolsForTurn;
123
141
  this.#invokers = invokers;
124
142
  this.#agentTimeouts = agentTimeouts;
125
143
  this.#maxMessageChars = maxMessageChars;
@@ -131,6 +149,8 @@ export class Room {
131
149
  this.#controlDesk = new ControlDesk({ projectRoot });
132
150
  this.#budget = new Budget({ softBudget: softTokenBudget });
133
151
  this.#budget.seed(historicalEvents, this.#sentinel);
152
+ // What this room has been charged per character, from its own past turns.
153
+ this.#rate = observedRate(historicalEvents);
134
154
  this.#vectors = new VectorWorker({ memory, stopped: () => this.#shuttingDown });
135
155
  this.#archivist = new Archivist({
136
156
  memory,
@@ -216,6 +236,23 @@ export class Room {
216
236
  decideMode(requestId, decision) { return this.#escalation.decide(requestId, decision); }
217
237
 
218
238
  subscribeGhost(listener) { return this.#ghost.subscribe(listener); }
239
+
240
+ // A live meter is not a fact about the room: it says what a turn is reading while it reads it,
241
+ // and a second later the bill says what it really cost. Writing it to the ledger would spend a
242
+ // sequence number on something nothing will ever recall, and shift everything said after it.
243
+ // It goes out to whoever is watching and nowhere else.
244
+ #say(type, payload) {
245
+ const event = { id: `live-${randomUUID()}`, sequence: null, live: true, timestamp: new Date().toISOString(), type, payload };
246
+ for (const listener of this.#liveListeners) listener(event);
247
+ return event;
248
+ }
249
+
250
+ // Whoever is watching the room right now. Separate from the ghost channel, which carries turns
251
+ // that happened off the record: these never happened at all, they are only being reported.
252
+ subscribeLive(listener) {
253
+ this.#liveListeners.add(listener);
254
+ return () => this.#liveListeners.delete(listener);
255
+ }
219
256
  async #emit(type, payload) {
220
257
  if (this.#ghost.isGhost(type, payload)) return this.#ghost.emit(type, payload);
221
258
  const event = await this.#store.append(type, payload);
@@ -232,16 +269,29 @@ export class Room {
232
269
  }
233
270
  embedNow() { return this.#vectors.runNow(); }
234
271
 
235
- #contextFor(priorEvents, { messageId, text, omitSynthetic = false }) {
236
- return contextFor({ memory: this.#memory, priorEvents, messageId, text, contextMaxChars: this.#contextMaxChars, recallShare: this.#recallShare, remember: (events) => this.#remember(events), omitSynthetic });
272
+ // Where the transcript an agent reads begins. Held still across turns on purpose: a window
273
+ // that starts one message later every time is a window a CLI can never match against what it
274
+ // read last turn, and the transcript is most of what a loaded turn costs.
275
+ #contextAnchor = null;
276
+
277
+ // Characters per input token, as this room's own turns have shown. Null until it has one.
278
+ #rate = null;
279
+
280
+ async #contextFor(priorEvents, { messageId, text, omitSynthetic = false, by = null }) {
281
+ const built = await contextFor({ memory: this.#memory, priorEvents, messageId, text, contextMaxChars: this.#contextMaxChars, recallShare: this.#recallShare, remember: (events) => this.#remember(events), omitSynthetic, anchor: this.#contextAnchor, by, cascade: this.#cascade });
282
+ // @madre reads a different transcript to everyone else, so it never sets where the room's
283
+ // window begins; it only borrows it.
284
+ if (!omitSynthetic && Number.isInteger(built.context?.anchor)) this.#contextAnchor = built.context.anchor;
285
+ return built;
237
286
  }
238
287
 
239
288
  // The human tunes the archive from MU/TH/UR; changes apply to the next run.
240
289
  distillSettings() {
241
- return { ...this.#archivist.settings(), recallShare: this.#recallShare, ollama: Boolean(this.#invokers.ollama) };
290
+ return { ...this.#archivist.settings(), recallShare: this.#recallShare, cascade: this.#cascade, ollama: Boolean(this.#invokers.ollama) };
242
291
  }
243
292
  configureDistill(patch = {}) {
244
293
  if (Number.isFinite(Number(patch.recallShare))) this.#recallShare = Math.min(0.6, Math.max(0, Number(patch.recallShare)));
294
+ if (typeof patch.cascade === 'boolean') this.#cascade = patch.cascade;
245
295
  this.#archivist.configure(patch);
246
296
  return this.distillSettings();
247
297
  }
@@ -291,12 +341,197 @@ export class Room {
291
341
  return { ...alert, crew };
292
342
  }
293
343
 
344
+ // What the room is about to hand this turn, measured rather than guessed. The prompt is
345
+ // already built when the turn is announced, so its size is known exactly; only the conversion
346
+ // to tokens is an estimate, and it is made at the rate this room's own turns have shown. Until
347
+ // the room has been billed once there is no rate, and it says so rather than inventing one.
348
+ #reading(parts) {
349
+ const chars = parts.reduce((sum, part) => sum + part.text.length, 0);
350
+ return { chars, rate: this.#rate ? Number(this.#rate.toFixed(2)) : null, tokens: this.#rate ? Math.round(chars / this.#rate) : null };
351
+ }
352
+
353
+ // Who is free to answer a question that is not a turn: the same pool the archivist draws on,
354
+ // with the local model in it when there is one. EYECAT reads this to find an impartial judge.
355
+ bench() {
356
+ const invokers = this.#invokers;
357
+ const agents = this.#agents.filter((agent) => agent.adapter !== 'madre-local');
358
+ return {
359
+ agents: invokers.ollama ? [OLLAMA_ARCHIVIST, ...agents] : agents,
360
+ invokers,
361
+ busy: new Set([...this.#turns.values()].map((turn) => turn.agent).filter(Boolean)),
362
+ benched: new Set(),
363
+ };
364
+ }
365
+
294
366
  /* ---------- NOSTROMO: the human's view of the archive ---------- */
295
367
 
296
368
  // Every distilled note with the links between those that agree, for the map.
369
+ // What one memory has to say about its own life: who it keeps arriving with, who asked for it,
370
+ // and what a refutation did to it or with it. NOSTROMO reads this while a card is open.
371
+ memoryTraffic(id) {
372
+ if (!this.#memory) return null;
373
+ return this.#memory.recallTraffic(id);
374
+ }
375
+
376
+ // Is anything happening in here right now? Asked before a conversation is closed and another
377
+ // opened: a turn in flight is a reason to wait, not something to race.
378
+ working() { return this.#turns.size > 0; }
379
+
297
380
  memoryResearch() {
298
381
  if (!this.#memory) return null;
299
- return { stats: this.memoryStats(), memories: this.#memory.memories({ limit: 500 }), links: this.#memory.memoryLinks() };
382
+ const memories = this.#memory.memories({ limit: 500 });
383
+ const links = this.#memory.memoryLinks();
384
+ // The cold zones: what the archive has never reached for, never linked, and has had plenty
385
+ // of chances to be. Attached to the notes themselves so the map and the card agree.
386
+ const cold = coldNotes({ notes: memories, links, batches: this.#memory.recallBatches(), since: this.#memory.recallsSince() });
387
+ return {
388
+ stats: this.memoryStats(),
389
+ memories: memories.map((note) => (cold.has(note.id) ? { ...note, cold: cold.get(note.id) } : note)),
390
+ links,
391
+ cold: coldReading(cold, memories),
392
+ // What the room should ask next, so a thin archive fills where it is thin instead of
393
+ // deeper where it is already fat. Nothing is sent: the questions are the human's to use.
394
+ ask: questionsFor({ notes: memories, cold, dismissed: this.#dismissedAsks() }),
395
+ };
396
+ }
397
+
398
+ /* ---------- the three tests ---------- */
399
+
400
+ // What the room can measure about itself, and whether each test can run right now. A test that
401
+ // cannot run says why instead of returning a number nobody should trust.
402
+ async exams({ refresh = false, findings = [] } = {}) {
403
+ let last = {};
404
+ try { last = JSON.parse(this.#memory?.metaGet('exams') ?? '{}'); } catch { last = {}; }
405
+ // Said again in the room's language from the numbers that were stored: a reading taken in
406
+ // another language is still a reading, and its sentence is only how it is said.
407
+ last = Object.fromEntries(Object.entries(last).map(([id, result]) => [id, { ...result, says: saysFor(result) ?? result.says }]));
408
+ // The one test that costs nothing is never something to ask for: it is read, not run.
409
+ if (refresh && this.#memory) last = { ...last, consistency: this.#keepExam(consistencyExam({ findings, notes: this.#memory.memories({ limit: 500 }) })) };
410
+ const embedder = this.#memory?.embedder?.model ?? null;
411
+ const localModel = Boolean(this.#invokers[MADRE_ADAPTER]);
412
+ const cases = this.#memory ? exchanges(await this.#store.readAll()) : [];
413
+ return {
414
+ running: this.#examRunning,
415
+ progress: this.#examProgress,
416
+ last,
417
+ can: {
418
+ coverage: { ok: Boolean(this.#memory) && cases.length > 0, why: !this.#memory ? 'the room has no memory' : cases.length ? null : 'no exchange here is long enough to test with yet', method: embedder ? 'meaning' : 'words' },
419
+ consistency: { ok: Boolean(this.#memory), why: this.#memory ? null : 'the room has no memory' },
420
+ match: {
421
+ ok: Boolean(this.#memory) && localModel && Boolean(embedder) && cases.some((one) => !one.local),
422
+ why: !localModel ? 'the local model is not running: open MODULES → OLLAMA'
423
+ : !embedder ? 'this test needs embeddings, which are off'
424
+ : cases.some((one) => !one.local) ? null : 'nothing here was answered by an agent other than the local one',
425
+ sample: Math.min(MATCH_SAMPLE, cases.filter((one) => !one.local).length),
426
+ },
427
+ },
428
+ cases: cases.length,
429
+ embedder,
430
+ };
431
+ }
432
+
433
+ // Embedding in batches, so a remote embedder is asked a few times and not five hundred.
434
+ #embedInBatches() {
435
+ const embedder = this.#memory?.embedder;
436
+ if (!embedder) return null;
437
+ return async (texts) => {
438
+ const out = [];
439
+ for (let start = 0; start < texts.length; start += 64) out.push(...await embedder.embed(texts.slice(start, start + 64)));
440
+ return out;
441
+ };
442
+ }
443
+
444
+ // Running one. Coverage and consistency answer in a moment; the match test asks the local model
445
+ // a real question at a time and takes minutes, so it runs in the background and reports where
446
+ // it is. Nothing here writes to the ledger or spends a provider turn.
447
+ async runExam(which, { findings = [] } = {}) {
448
+ if (!this.#memory) return { error: 'The room has no memory.' };
449
+ if (this.#examRunning) return { error: `${this.#examRunning.toUpperCase()} is already running.` };
450
+ const events = await this.#store.readAll();
451
+ if (which === 'consistency') {
452
+ return this.#keepExam(consistencyExam({ findings, notes: this.#memory.memories({ limit: 500 }) }));
453
+ }
454
+ if (which === 'coverage') {
455
+ this.#examRunning = 'coverage';
456
+ try {
457
+ const recall = async (text, before) => {
458
+ const queryVector = await this.#memory.embedQuery(text);
459
+ const notes = this.#memory.recallMemories(text, { beforeSequence: before, limit: 6, maxChars: 1600, queryVector, fallback: false, track: false, cascade: this.#cascade });
460
+ const quotes = this.#memory.recall(text, { beforeSequence: before, limit: 6, maxChars: 1600, excerptChars: 420, queryVector });
461
+ return [...notes.map((note) => note.text), ...(quotes?.entries ?? []).map((entry) => entry.excerpt ?? entry.text)];
462
+ };
463
+ return this.#keepExam(await coverageExam({ events, recall, embed: this.#embedInBatches() }));
464
+ } finally { this.#examRunning = null; }
465
+ }
466
+ if (which === 'match') {
467
+ const invoke = this.#invokers[MADRE_ADAPTER];
468
+ if (!invoke) return { error: 'The local model is not running. Open MODULES → OLLAMA.' };
469
+ this.#examRunning = 'match';
470
+ this.#examProgress = { done: 0, total: 0 };
471
+ // Answered in the background: the human keeps the room while it runs.
472
+ void (async () => {
473
+ try {
474
+ const result = await matchExam({
475
+ events,
476
+ ask: async (question) => (await invoke({ prompt: question, text: question, timeoutMs: 120000 }))?.text ?? '',
477
+ embed: this.#embedInBatches(),
478
+ onProgress: (at) => { this.#examProgress = at; },
479
+ stop: () => this.#examStop,
480
+ });
481
+ this.#keepExam(result);
482
+ // The one test that takes minutes is the one whose answer a person is waiting for, and
483
+ // until now it landed in a panel they had to go back to. It lands in the room.
484
+ if (result.ran) {
485
+ await this.#emit('local.checked', {
486
+ model: this.#agents.find((agent) => agent.id === MADRE_AGENT_ID)?.version ?? null,
487
+ passed: Boolean(result.passed), matched: result.matched ?? 0, n: result.n ?? 0, says: result.says,
488
+ });
489
+ }
490
+ } catch (error) {
491
+ this.#keepExam({ id: 'match', ran: false, at: new Date().toISOString(), says: `The test could not finish: ${error.message}` });
492
+ } finally { this.#examRunning = null; this.#examStop = false; this.#examProgress = null; }
493
+ })();
494
+ return { started: 'match' };
495
+ }
496
+ return { error: `No such test "${which}".` };
497
+ }
498
+
499
+ // Coverage costs a few seconds of local arithmetic and nothing else, so it is kept fresh
500
+ // behind the screen rather than asked for: once a day, only while the embeddings are local
501
+ // (a remote embedder is the human's money, and money is never spent without being asked), and
502
+ // never in front of anything. What the panel shows is whatever the last run left.
503
+ async freshenCoverage({ now = Date.now(), ttlMs = 24 * 60 * 60 * 1000 } = {}) {
504
+ if (!this.#memory || this.#examRunning) return null;
505
+ if (!this.#memory.embedder?.local) return null;
506
+ let last = {};
507
+ try { last = JSON.parse(this.#memory.metaGet('exams') ?? '{}'); } catch { last = {}; }
508
+ const at = Date.parse(last.coverage?.at ?? '');
509
+ if (Number.isFinite(at) && now - at < ttlMs) return null;
510
+ void this.runExam('coverage').catch(() => null);
511
+ return { started: 'coverage' };
512
+ }
513
+
514
+ stopExam() { if (this.#examRunning === 'match') this.#examStop = true; return { stopping: this.#examRunning }; }
515
+
516
+ #keepExam(result) {
517
+ let last = {};
518
+ try { last = JSON.parse(this.#memory?.metaGet('exams') ?? '{}'); } catch { last = {}; }
519
+ last[result.id] = result;
520
+ this.#memory?.metaSet('exams', JSON.stringify(last));
521
+ return result;
522
+ }
523
+
524
+ // Questions the human has waved off. Kept with the archive, because that is what they are about.
525
+ #dismissedAsks() {
526
+ try { return JSON.parse(this.#memory?.metaGet('asks_dismissed') ?? '[]'); } catch { return []; }
527
+ }
528
+
529
+ // Waving one off is a small, reversible thing: it stops being offered, and nothing else.
530
+ dismissAsk(id) {
531
+ if (!this.#memory || !id) return null;
532
+ const dismissed = [...new Set([...this.#dismissedAsks(), String(id)])].slice(-200);
533
+ this.#memory.metaSet('asks_dismissed', JSON.stringify(dismissed));
534
+ return { dismissed: dismissed.length };
300
535
  }
301
536
 
302
537
  /* ---------- privacy: terms that never travel through the room ---------- */
@@ -357,11 +592,12 @@ export class Room {
357
592
  const wanted = normalizeMode(mode, create === true ? 2 : 1);
358
593
  if (!parsed.target) return { ok: true, mode: wanted };
359
594
  const scopes = this.scopesFor(parsed.target);
360
- if (wanted === 3) {
361
- if (scopes.maxMode < 3) return { ok: false, status: 403, mode: wanted, maxMode: scopes.maxMode, error: `@${parsed.target} is capped at #${scopes.maxMode} ${MODES[scopes.maxMode].label}; raise its MAX MODE to #3 in CONNECTIONS first.` };
362
- if (!(await isGitRepo(this.#projectRoot))) return { ok: false, status: 412, mode: wanted, maxMode: scopes.maxMode, error: 'CONTROL needs the project to be a git repository: the checkpoint that makes UNDO possible is a git commit. Run git init first.' };
363
- if (this.#controlDesk.holder) return { ok: false, status: 409, mode: wanted, maxMode: scopes.maxMode, error: `@${this.#controlDesk.holder.agent} already holds CONTROL of this project; one holder at a time. STOPALL revokes it.` };
364
- return { ok: true, mode: 3, maxMode: scopes.maxMode };
595
+ if (wanted >= 3) {
596
+ // #3 CONTROL and #4 AIRLOCK: the ceiling must allow it, and there is one holder at a time.
597
+ // A project without git still checkpoints, in MADRE's shadow repository.
598
+ if (scopes.maxMode < wanted) return { ok: false, status: 403, mode: wanted, maxMode: scopes.maxMode, error: `@${parsed.target} is capped at #${scopes.maxMode} ${MODES[scopes.maxMode].label}; raise its MAX MODE to #${wanted} in CONNECTIONS to arm ${MODES[wanted].label}.` };
599
+ 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.` };
600
+ return { ok: true, mode: wanted, maxMode: scopes.maxMode };
365
601
  }
366
602
  // #2 above the agent's ceiling still goes out: the room answers read-only
367
603
  // and says why (lease.refused), the way CREATE has always behaved.
@@ -399,6 +635,26 @@ export class Room {
399
635
 
400
636
  budgetWindow() { return this.#budget.view(this.#agents); }
401
637
 
638
+ // What the turn was made of, against what it was charged. A room that cannot see this can only
639
+ // guess at which part of a prompt is expensive, and a guess in characters is not an answer in
640
+ // tokens. The words themselves are never written here, only how many of them each block was.
641
+ async #recordCost(agent, responseMessageId, usage, mode) {
642
+ const shape = this.#promptShape.get(responseMessageId);
643
+ this.#promptShape.delete(responseMessageId);
644
+ if (!shape) return;
645
+ // A ghost turn is off the record in every sense, this one included.
646
+ if (mode === 0) return;
647
+ try {
648
+ const cost = turnCost(shape.parts, usage);
649
+ // Each bill teaches the room what its own words cost, so the next turn can say what it is
650
+ // about to spend before it spends it.
651
+ if (cost.charsPerInputToken) this.#rate = this.#rate ? this.#rate * 0.7 + cost.charsPerInputToken * 0.3 : cost.charsPerInputToken;
652
+ await this.#emit('turn.cost', { agent, mode, responseMessageId, spared: shape.spared, ...cost });
653
+ } catch (error) {
654
+ console.error(`MADRE could not weigh a turn: ${error.message}`);
655
+ }
656
+ }
657
+
402
658
  async #recordUsage(agent, usage, { messageId = null, responseMessageId = null } = {}) {
403
659
  if (!usage) return;
404
660
  const charged = this.#budget.record(agent, usage);
@@ -432,13 +688,13 @@ export class Room {
432
688
  return this.capabilities();
433
689
  }
434
690
 
435
- setAshCode(enabled) {
436
- this.#ashCodeEnabled = Boolean(enabled);
437
- return this.#ashCodeEnabled;
691
+ setAsh(enabled) {
692
+ this.#ashEnabled = Boolean(enabled);
693
+ return this.#ashEnabled;
438
694
  }
439
695
 
440
- ashCodeEnabled() {
441
- return this.#ashCodeEnabled;
696
+ ashEnabled() {
697
+ return this.#ashEnabled;
442
698
  }
443
699
 
444
700
  // A slash command's result, shared with everyone (and with agents through
@@ -513,25 +769,25 @@ export class Room {
513
769
  this.#vectors.stop();
514
770
  }
515
771
 
516
- async send({ text, target, model = null, attachments = [], create = false, ashCode = false, mode = undefined }) {
772
+ async send({ text, target, model = null, attachments = [], create = false, ash = false, mode = undefined }) {
517
773
  const parsed = parseMessage(text, target);
518
774
  const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
519
775
  const gate = await this.modeCheck({ target, text, mode: requestedMode });
520
776
  if (!gate.ok) throw new Error(gate.error);
521
777
  create = requestedMode === 2;
522
778
  const ghost = requestedMode === 0;
523
- const control = requestedMode === 3;
779
+ const control = requestedMode >= 3;
524
780
  const files = (Array.isArray(attachments) ? attachments : []).map((id) => this.attachment(id)).filter(Boolean);
525
781
  if (!parsed.text && !files.length) throw new Error('Write a message first.');
526
782
  if (!parsed.target) throw new Error('Choose an agent or begin with @agent.');
527
783
  if (model !== null && model !== undefined && model !== '' && !isValidModelName(model)) throw new Error('Model name is not valid.');
528
784
  const chosenModel = model || null;
529
- const originalText = parsed.text || `(${files.length} attached file${files.length === 1 ? '' : 's'})`;
530
- const ashActive = ashCode === true && this.#ashCodeEnabled;
531
- const abbreviated = ashActive ? compressAshCode(originalText) : null;
532
- const text2 = abbreviated?.text ?? originalText;
785
+ // What the human wrote, sent as they wrote it. Ash used to rewrite this line before it left
786
+ // the room; it does not any more, so there is no original to keep beside an abbreviation.
787
+ const text2 = parsed.text || `(${files.length} attached file${files.length === 1 ? '' : 's'})`;
788
+ const ashActive = ash === true && this.#ashEnabled;
533
789
  // "!path" tokens point the agent at project files; only existing files count.
534
- const references = await resolveReferences(this.#projectRoot, originalText);
790
+ const references = await resolveReferences(this.#projectRoot, text2);
535
791
 
536
792
  // A standing lease (default #2) needs no arming; a ghost never writes.
537
793
  const standing = !ghost && !create && Boolean(this.scopesFor(parsed.target).write.always);
@@ -539,13 +795,12 @@ export class Room {
539
795
  if (ghost) this.#ghost.add(messageId);
540
796
  await this.#emit('message.created', {
541
797
  messageId,
542
- mode: ghost ? 0 : control ? 3 : (standing || create ? 2 : 1),
798
+ mode: ghost ? 0 : control ? requestedMode : (standing || create ? 2 : 1),
543
799
  role: 'user',
544
800
  sender: 'you',
545
801
  target: parsed.target,
546
802
  text: text2,
547
- originalText: abbreviated?.applied ? originalText : undefined,
548
- ashCode: ashActive ? { active: true, applied: abbreviated.applied, reason: abbreviated.reason, language: abbreviated.language, originalChars: abbreviated.originalChars, encodedChars: abbreviated.encodedChars } : undefined,
803
+ ash: ashActive ? { active: true } : undefined,
549
804
  status: 'sent',
550
805
  model: chosenModel,
551
806
  attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
@@ -609,7 +864,7 @@ export class Room {
609
864
  allowDelegation = false;
610
865
  await this.#alert('plan-active', `A plan by @${[...this.#plans.values()][0].orchestrator} is still running. Your message will be answered, but no second plan will start. Type STOPALL to halt every agent.`, `plan-active:${messageId}`);
611
866
  }
612
- await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, attachments: files, references, lease, ashCode: ashActive, mode: ghost ? 0 : control ? 3 : (lease ? 2 : 1) }));
867
+ 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) }));
613
868
  }
614
869
 
615
870
  // A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
@@ -645,7 +900,7 @@ export class Room {
645
900
  }
646
901
 
647
902
  // Master brake: every plan and every in-flight turn, recorded as one event.
648
- async stopAll(reason = 'STOPALL by the human') {
903
+ async stopAll(reason = t('STOPALL by the human')) {
649
904
  const plans = this.#plans.size;
650
905
  const turns = this.#turns.size;
651
906
  for (const plan of this.#plans.values()) plan.stopped = reason;
@@ -679,7 +934,7 @@ export class Room {
679
934
 
680
935
  // One room turn for one agent. `requester` is who asked ('you' or an
681
936
  // orchestrating agent); `depth` 0 turns may delegate, deeper ones may not.
682
- async #dispatch({ messageId, targetId, text, requester, depth, planId = null, allowDelegation = true, model = null, attachments = [], references = [], lease = null, ashCode = false, mode = 1, escalation = null }) {
937
+ async #dispatch({ messageId, targetId, text, requester, depth, planId = null, allowDelegation = true, model = null, attachments = [], references = [], lease = null, ash = false, mode = 1, escalation = null }) {
683
938
  if (this.#shuttingDown) return null;
684
939
  const agent = this.#agents.find((item) => item.id === targetId);
685
940
  if (!agent?.detected) {
@@ -693,7 +948,7 @@ export class Room {
693
948
 
694
949
  const priorEvents = await this.#store.readAll();
695
950
  // @madre never reads its own canned replies back: a small model would echo them.
696
- const { context, recall, memories } = await this.#contextFor(priorEvents, { messageId, text, omitSynthetic: Boolean(agent.local) });
951
+ const { context, recall, memories } = await this.#contextFor(priorEvents, { messageId, text, omitSynthetic: Boolean(agent.local), by: { agent: targetId, turn: messageId } });
697
952
  let handoffId = null;
698
953
  if (context.previousAgent && context.previousAgent !== targetId) {
699
954
  handoffId = randomUUID();
@@ -724,7 +979,7 @@ export class Room {
724
979
  await this.#emit('agent.started', { messageId, agent: agent.id, handoffId, planId });
725
980
  const controller = new AbortController();
726
981
  const turn = { controller, promise: null, planId, agent: agent.id, startedAt: Date.now() };
727
- turn.promise = this.#runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall, memories, handoffId, signal: controller.signal, model, attachments, references, lease, ashCode, mode, escalation });
982
+ 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 });
728
983
  this.#turns.set(messageId, turn);
729
984
  let outcome = null;
730
985
  try {
@@ -736,13 +991,135 @@ export class Room {
736
991
  // The orchestrator's turn is over before its plan starts, so it is never
737
992
  // counted as in flight while the others work.
738
993
  if (outcome?.directives?.steps.length) {
739
- await this.#runPlan({ orchestrator: agent.id, parentMessageId: outcome.responseMessageId, directives: outcome.directives, lease, ashCode, mode });
994
+ await this.#runPlan({ orchestrator: agent.id, parentMessageId: outcome.responseMessageId, directives: outcome.directives, lease, ash, mode });
740
995
  }
741
996
  return outcome?.responseMessageId ?? null;
742
997
  }
743
998
 
999
+ // What the next turn to an agent would carry, built exactly the way a turn builds it and sent
1000
+ // nowhere. Nothing here counts: a memory read for this was not recalled, the window does not
1001
+ // move, and no process is started. It is the document MADRE writes in the human's name, which
1002
+ // until now existed only for the instant a CLI was reading it.
1003
+ async briefing({ agent: agentId, mode = 1, text = '' } = {}) {
1004
+ const agent = this.#agents.find((one) => one.id === agentId && one.detected);
1005
+ if (!agent) return null;
1006
+ const priorEvents = await this.#store.readAll();
1007
+ const built = await contextFor({
1008
+ memory: this.#memory, priorEvents, messageId: null, text,
1009
+ contextMaxChars: this.#contextMaxChars, recallShare: this.#recallShare,
1010
+ remember: () => {}, omitSynthetic: Boolean(agent.local), anchor: this.#contextAnchor,
1011
+ cascade: this.#cascade, track: false,
1012
+ });
1013
+ const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
1014
+ ? await this.#toolsForTurn({ agent: agent.id, mode, lease: null, scratchDir: null }).catch(() => [])
1015
+ : [];
1016
+ const scopes = this.scopesFor(agent.id);
1017
+ // The lease a turn at this mode would be given, shaped exactly as the turn shapes it but
1018
+ // creating nothing: no folder is made, no checkpoint is taken. Without it the preview would
1019
+ // quietly under-report what a #2 or a #3 turn is actually told it may do.
1020
+ const ceiling = Math.min(mode, scopes.maxMode ?? 0);
1021
+ const lease = ceiling >= 2 && scopes.write?.enabled && scopes.write?.wired
1022
+ ? {
1023
+ leaseId: 'would-be-granted-when-you-send',
1024
+ outDir: this.#projectRoot,
1025
+ relativeDir: '.',
1026
+ scratchDir: '.pulse/out/<this turn>',
1027
+ scopes: { ...Object.fromEntries(SCOPES.map((scope) => [scope, scopes[scope].enabled && scopes[scope].wired])), write: true },
1028
+ create: ceiling === 2,
1029
+ control: ceiling >= 3,
1030
+ airlock: ceiling === 4,
1031
+ }
1032
+ : null;
1033
+ const options = this.#promptOptions({
1034
+ agent, text, requester: 'you', depth: 0, allowDelegation: true,
1035
+ context: built.context, recall: built.recall, memories: built.memories,
1036
+ attachments: [], references: [], lease,
1037
+ scopes: { web: scopes.web.enabled && scopes.web.wired, imageGen: scopes.imageGen.enabled && scopes.imageGen.wired },
1038
+ ash: this.ashEnabled(), mode, escalation: null, mcpServers,
1039
+ });
1040
+ // Each block with what put it here and what would take it away, so "read, never written" can
1041
+ // be checked one block at a time instead of believed.
1042
+ const parts = promptParts(options).map((part) => ({ id: part.id, text: part.text, chars: part.text.length, ...(BLOCK_SOURCES[part.id] ?? { when: null, where: null }) }));
1043
+ return {
1044
+ agent: agent.id, label: agent.label, mode,
1045
+ // What the mode would actually amount to for this agent: its ceiling is its own.
1046
+ ceiling,
1047
+ maxMode: scopes.maxMode ?? 0,
1048
+ lease: Boolean(lease),
1049
+ parts,
1050
+ chars: parts.reduce((sum, part) => sum + part.chars, 0),
1051
+ spared: sparedChars(options),
1052
+ window: { from: built.context?.firstSequence ?? null, through: built.context?.throughSequence ?? null, omitted: built.context?.omittedMessages ?? 0, carried: built.context?.messages?.length ?? 0 },
1053
+ recalled: built.memories?.length ?? 0,
1054
+ quoted: built.recall?.entries?.length ?? 0,
1055
+ };
1056
+ }
1057
+
1058
+ // How this agent's CLI would actually be started: the command line its own adapter builds, the
1059
+ // folder it runs in, the names of what MADRE sets in its environment, and the tool servers
1060
+ // attached for the turn. Built from the same builders the real run uses, and nothing is spawned.
1061
+ //
1062
+ // Values of environment variables are never reported, only names. One of those variables is a
1063
+ // provider key, and a screen that shows what a process is given must not be the easiest place
1064
+ // in the product to read a secret out of.
1065
+ async launch({ agent: agentId, mode = 1 } = {}) {
1066
+ const agent = this.#agents.find((one) => one.id === agentId && one.detected);
1067
+ if (!agent) return null;
1068
+ if (agent.local || agent.adapter === 'madre-local') {
1069
+ return {
1070
+ agent: agent.id, label: agent.label, local: true,
1071
+ says: 'No process is started for @madre: it is the local model answering inside MADRE, through Ollama on this computer.',
1072
+ mcpServers: [], env: [], isolation: [],
1073
+ };
1074
+ }
1075
+ const scopes = this.scopesFor(agent.id);
1076
+ const ceiling = Math.min(mode, scopes.maxMode ?? 0);
1077
+ const mcpServers = this.#toolsForTurn ? await this.#toolsForTurn({ agent: agent.id, mode: ceiling, lease: null, scratchDir: null }).catch(() => []) : [];
1078
+ const prompt = '<the briefing above>';
1079
+ const shared = { projectRoot: this.#projectRoot, prompt, model: null, attachments: [], lease: null, scopes: { web: scopes.web.enabled && scopes.web.wired, imageGen: scopes.imageGen.enabled && scopes.imageGen.wired }, memoryServer: this.#memoryServer, mcpServers };
1080
+ const args = agent.id === 'codex' ? buildCodexArgs(shared)
1081
+ : agent.id === 'claude' ? buildClaudeArgs({ ...shared, attachmentsDir: null, imageStudio: null })
1082
+ : agent.id === 'gemini' ? buildGeminiArgs({ ...shared, policyPath: '<written for this turn, deleted after it>', attachmentsDir: null })
1083
+ : agent.id === 'opencode' ? buildOpenCodeArgs(shared)
1084
+ : null;
1085
+ if (!args) return { agent: agent.id, label: agent.label, local: false, says: `MADRE has no adapter for @${agent.id}.`, mcpServers: [], env: [], isolation: [] };
1086
+ const isolation = {
1087
+ codex: ['--ephemeral: the run keeps no session of its own, so nothing said here reaches another project.'],
1088
+ claude: ['--no-session-persistence: the run keeps no session of its own.', 'Only the project\'s own CLAUDE.md and MADRE\'s briefing are read; hooks, plugins and outside MCP servers are not.'],
1089
+ gemini: ['GEMINI_CLI_HOME points at a temporary home made for this turn and removed after it, so settings, hooks and extensions come from there and not from yours.', 'GEMINI_CLI_NO_RELAUNCH keeps the launcher from spawning a second process MADRE could not stop.'],
1090
+ opencode: ['The run is given the model you chose and nothing else of your session.'],
1091
+ }[agent.id] ?? [];
1092
+ const env = {
1093
+ gemini: [{ name: 'GEMINI_CLI_HOME', note: 'the temporary home for this turn' }, { name: 'GEMINI_CLI_NO_RELAUNCH', note: 'so the launcher stays killable' }],
1094
+ }[agent.id] ?? [];
1095
+ return {
1096
+ agent: agent.id, label: agent.label, local: false,
1097
+ executable: agent.path ?? agent.id,
1098
+ cwd: this.#projectRoot,
1099
+ args: redactArgs(args),
1100
+ promptMarker: prompt,
1101
+ isolation,
1102
+ env,
1103
+ mcpServers: mcpServers.map((server) => ({ name: server.name, command: server.command, args: server.args ?? [], tools: server.tools ?? [], brief: server.brief ?? null, env: Object.keys(server.env ?? {}) })),
1104
+ memoryServer: this.#memoryServer ? { name: this.#memoryServer.name, command: this.#memoryServer.command, args: this.#memoryServer.args ?? [], tools: this.#memoryServer.tools ?? [] } : null,
1105
+ };
1106
+ }
1107
+
1108
+ // What the last prompt for each turn was made of, kept only until its bill arrives.
1109
+ #promptShape = new Map();
1110
+
1111
+ #promptFor(input) {
1112
+ const options = this.#promptOptions(input);
1113
+ const parts = promptParts(options);
1114
+ return { text: parts.map((part) => part.text).join('\n'), parts, spared: sparedChars(options) };
1115
+ }
1116
+
744
1117
  #prompt(input) {
745
- return buildPrompt({
1118
+ return buildPrompt(this.#promptOptions(input));
1119
+ }
1120
+
1121
+ #promptOptions(input) {
1122
+ return ({
746
1123
  ...input,
747
1124
  others: this.delegatesFor(input.agent.id),
748
1125
  delegation: this.#delegation,
@@ -752,11 +1129,12 @@ export class Room {
752
1129
  memoryServer: this.#memoryServer,
753
1130
  controlHolder: this.#controlDesk.holder?.agent ?? null,
754
1131
  privacyMarker: this.#privacy?.marker ?? '[ENTIDAD-ORG]',
1132
+ sdk: sdkPaths(),
755
1133
  madreModel: this.#agents.find((agent) => agent.id === 'madre' && agent.ready)?.version ?? null,
756
1134
  });
757
1135
  }
758
1136
 
759
- async #runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall = null, memories = null, handoffId, signal, model = null, attachments = [], references = [], lease: sharedLease = null, ashCode = false, mode = 1, escalation = null }) {
1137
+ 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 }) {
760
1138
  // The lease is the human's; what each agent may do inside it is that
761
1139
  // agent's own enabled scopes. A delegate without file creation runs
762
1140
  // read-only even while the plan holds a lease.
@@ -768,14 +1146,14 @@ export class Room {
768
1146
  let lease = null;
769
1147
  let controlRun = null;
770
1148
  let createRun = null;
771
- if (mode === 3 && (requester === 'you' || planId) && depth <= 1 && agentScopes.maxMode >= 3 && enabled.write) {
1149
+ if (mode >= 3 && (requester === 'you' || planId) && depth <= 1 && agentScopes.maxMode >= mode && enabled.write) {
772
1150
  // CONTROL: the project itself is the writable root, and a checkpoint
773
1151
  // taken now makes every change of this turn reversible. A plan step gets
774
1152
  // it when the orchestrator, itself in #3, named #3 for that step.
775
1153
  if (this.#controlDesk.holder) {
776
1154
  await this.#alert('control-busy', `@${this.#controlDesk.holder.agent} still holds CONTROL; @${agent.id} answers read-only this turn.`, `control-busy:${agent.id}`);
777
1155
  } else {
778
- const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled });
1156
+ const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode });
779
1157
  controlRun = seat.run;
780
1158
  lease = seat.lease;
781
1159
  await this.#emit('control.started', seat.announcement);
@@ -830,7 +1208,7 @@ export class Room {
830
1208
  ? imageStudioFor({ enabled: true, model: studio.model, outDir: lease.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : lease.outDir })
831
1209
  : null;
832
1210
  // The turn's effective mode: #0 for ghosts, #3 in control, #2 only while it holds a lease.
833
- const turnMode = mode === 0 ? 0 : controlRun ? 3 : lease ? 2 : 1;
1211
+ const turnMode = mode === 0 ? 0 : controlRun ? controlRun.mode : lease ? 2 : 1;
834
1212
  try {
835
1213
  const invoke = this.#invokers[agent.adapter];
836
1214
  if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
@@ -839,11 +1217,33 @@ export class Room {
839
1217
  const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
840
1218
  const others = this.delegatesFor(agent.id);
841
1219
  const mayDelegate = allowDelegation && this.#delegation && depth === 0;
1220
+ // Tools the modules hand to this turn (a browser, say), attached to the CLI for this run only.
1221
+ const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
1222
+ ? await this.#toolsForTurn({ agent: agent.id, mode: turnMode, lease, scratchDir: lease?.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : null }).catch(() => [])
1223
+ : [];
1224
+ // The prompt and the shape it was built from: one is sent, the other is kept until the CLI
1225
+ // says what it cost, so the bill can be attributed to the blocks that caused it.
1226
+ const shaped = this.#promptFor({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ash, 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 });
1227
+ this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared });
1228
+ // What this turn is about to read, said while it is still reading it. The size is exact;
1229
+ // only the conversion to tokens is an estimate, and it is made at this room's own rate.
1230
+ const reading = this.#reading(shaped.parts);
1231
+ this.#say('turn.reading', { messageId, agent: agent.id, ...reading });
1232
+ // And again as the answer arrives. What is counted is the answer itself, not the protocol
1233
+ // around it, so a CLI that hands over one blob at the end honestly reports nothing until
1234
+ // then rather than a number that means the shape of its own output format.
1235
+ let told = 0;
1236
+ const onProgress = ({ chars }) => {
1237
+ if (!Number.isFinite(chars) || chars <= told) return;
1238
+ told = chars;
1239
+ this.#say('turn.reading', { messageId, agent: agent.id, ...reading, out: chars, outTokens: this.#rate ? Math.round(chars / this.#rate) : null });
1240
+ };
842
1241
  const result = await invoke({
1242
+ onProgress,
843
1243
  executable: agent.path,
844
1244
  projectRoot: this.#projectRoot,
845
1245
  text,
846
- prompt: this.#prompt({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ashCode, mode: turnMode, escalation, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null }),
1246
+ prompt: shaped.text,
847
1247
  timeoutMs: this.timeoutFor(agent.id),
848
1248
  signal,
849
1249
  model,
@@ -852,6 +1252,7 @@ export class Room {
852
1252
  scopes: turnScopes,
853
1253
  imageStudio,
854
1254
  memoryServer: memoryServerForTurn(this.#memoryServer, { agent: agent.id, messageId: responseMessageId, mode: turnMode }),
1255
+ mcpServers,
855
1256
  // For @madre: who asks, who it may convene, and whether a plan would run at all.
856
1257
  requester,
857
1258
  crew: others,
@@ -878,16 +1279,19 @@ export class Room {
878
1279
  if (depth > 0 && /```pulse/i.test(result.text)) {
879
1280
  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}`);
880
1281
  }
881
- const abbreviated = ashCode ? compressAshCode(result.text) : null;
882
1282
  await this.#emit('message.created', {
883
1283
  messageId: responseMessageId,
884
1284
  parentMessageId: messageId,
885
1285
  role: 'assistant',
886
1286
  sender: agent.id,
887
1287
  target: requester,
888
- text: abbreviated?.text ?? result.text,
889
- originalText: abbreviated?.applied ? result.text : undefined,
890
- ashCode: ashCode ? { active: true, applied: abbreviated.applied, reason: abbreviated.reason, language: abbreviated.language, originalChars: abbreviated.originalChars, encodedChars: abbreviated.encodedChars } : undefined,
1288
+ text: result.text,
1289
+ // What the archive handed this turn, so the room can see its own memory working. The
1290
+ // whole point of remembering is invisible until the moment it is used.
1291
+ recalled: memories?.length
1292
+ ? memories.slice(0, 6).map((note) => ({ id: note.id, kind: note.kind, text: note.text.length > 150 ? `${note.text.slice(0, 149)}…` : note.text, via: note.via ?? 'search' }))
1293
+ : undefined,
1294
+ ash: ash ? { active: true } : undefined,
891
1295
  status: 'completed',
892
1296
  planId,
893
1297
  model,
@@ -901,6 +1305,10 @@ export class Room {
901
1305
  if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, marker: this.#privacy.marker });
902
1306
  if (artifacts.length) {
903
1307
  await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
1308
+ // A <id>.module.mjs is an agent proposing a module: the human installs it from the room, or not.
1309
+ for (const file of artifacts.filter((item) => isModuleFile(item.path))) {
1310
+ await this.#emit('module.proposed', { agent: agent.id, messageId, responseMessageId, path: file.path, name: file.name });
1311
+ }
904
1312
  }
905
1313
  if (createChanges && (createChanges.existingReverted.length || createChanges.forbiddenReverted.length)) {
906
1314
  await this.#emit('create.reverted', { checkpointId: createChanges.checkpointId, agent: agent.id, messageId, responseMessageId, existing: createChanges.existingReverted, forbidden: createChanges.forbiddenReverted, message: createChanges.message });
@@ -915,6 +1323,7 @@ export class Room {
915
1323
  }
916
1324
  if (controlChanges) await this.#emit('control.changed', { ...controlChanges, responseMessageId });
917
1325
  await this.#recordUsage(agent.id, result.usage, { messageId, responseMessageId });
1326
+ await this.#recordCost(agent.id, responseMessageId, result.usage, turnMode);
918
1327
  await this.#emit('agent.completed', { messageId, agent: agent.id, handoffId, planId });
919
1328
  return { responseMessageId, directives };
920
1329
  } catch (error) {
@@ -927,12 +1336,12 @@ export class Room {
927
1336
  }
928
1337
  }
929
1338
 
930
- async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ashCode = false, mode = 1 }) {
1339
+ async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ash = false, mode = 1 }) {
931
1340
  const planId = randomUUID();
932
1341
  // The plan's ceiling is the human's mode: #3 when the orchestrator held CONTROL, #2 while a
933
1342
  // lease exists, #1 otherwise. A step may ask for a mode ("@codex #2: …"); it gets the lowest
934
1343
  // of what it asked, the ceiling and its own MAX MODE. Without a number it inherits the plan.
935
- const ceiling = mode >= 3 ? 3 : lease ? 2 : 1;
1344
+ const ceiling = mode >= 3 ? mode : lease ? 2 : 1;
936
1345
  const stepMode = (step) => {
937
1346
  const scopes = this.scopesFor(step.agent);
938
1347
  const asked = normalizeMode(step.mode, lease ? 2 : 1);
@@ -956,8 +1365,7 @@ export class Room {
956
1365
  if (plan.stopped) break;
957
1366
  plan.step = index + 1;
958
1367
  const messageId = randomUUID();
959
- const abbreviated = ashCode ? compressAshCode(step.text) : null;
960
- const stepText = abbreviated?.text ?? step.text;
1368
+ const stepText = step.text;
961
1369
  // A creation step in a #1 plan: stop and ask the human before the
962
1370
  // agent starts, once, with a clock. Permission written by the
963
1371
  // orchestrator inside the step text never counts.
@@ -967,7 +1375,7 @@ export class Room {
967
1375
  const wanted = stepMode(step);
968
1376
  // Under a #3 ceiling the orchestrator's word is enough: a #2 step gets its project lease,
969
1377
  // a #3 step gets CONTROL for its turn. Under #1 a creation step still asks the human.
970
- if (ceiling === 3 && !stepLease && wanted === 2) {
1378
+ if (ceiling >= 3 && !stepLease && wanted === 2) {
971
1379
  stepLease = await this.#projectLease({ leaseId: randomUUID(), messageId });
972
1380
  stepLease.scopeCeiling = { write: true, imageGen: true, web: true };
973
1381
  stepLease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, stepScopes[scope].enabled && stepScopes[scope].wired]));
@@ -985,16 +1393,15 @@ export class Room {
985
1393
  sender: orchestrator,
986
1394
  target: step.agent,
987
1395
  text: stepText,
988
- originalText: abbreviated?.applied ? step.text : undefined,
989
- ashCode: ashCode ? { active: true, applied: abbreviated.applied, reason: abbreviated.reason, language: abbreviated.language, originalChars: abbreviated.originalChars, encodedChars: abbreviated.encodedChars } : undefined,
1396
+ ash: ash ? { active: true } : undefined,
990
1397
  status: 'delegated',
991
1398
  planId,
992
- mode: wanted === 3 ? 3 : stepLease && stepScopes.write.enabled ? 2 : 1,
1399
+ mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1,
993
1400
  escalation: escalation ?? undefined,
994
1401
  step: index + 1,
995
1402
  totalSteps: directives.steps.length + (directives.closing ? 1 : 0),
996
1403
  });
997
- await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: wanted === 3 ? null : stepLease, ashCode, mode: wanted === 3 ? 3 : stepLease && stepScopes.write.enabled ? 2 : 1, escalation });
1404
+ 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 });
998
1405
  }
999
1406
  if (!plan.stopped && directives.closing) {
1000
1407
  plan.step = directives.steps.length + 1;
@@ -1010,7 +1417,7 @@ export class Room {
1010
1417
  step: plan.step,
1011
1418
  totalSteps: plan.step,
1012
1419
  });
1013
- await this.#dispatch({ messageId, targetId: orchestrator, text: `${directives.closing}\n(The delegated agents have answered above; this is your closing turn.)`, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease, ashCode });
1420
+ await this.#dispatch({ messageId, targetId: orchestrator, text: `${directives.closing}\n(The delegated agents have answered above; this is your closing turn.)`, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease, ash });
1014
1421
  }
1015
1422
  } finally {
1016
1423
  this.#plans.delete(planId);