@jossuealcala/madre 0.3.3 → 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 (72) hide show
  1. package/CHANGELOG.md +412 -3
  2. package/CONTRIBUTING.md +3 -1
  3. package/README.md +67 -185
  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 +3825 -851
  13. package/public/es.js +2050 -0
  14. package/public/i18n.js +66 -0
  15. package/public/index.html +94 -13
  16. package/public/inquiry.js +220 -0
  17. package/public/resay.js +77 -0
  18. package/public/styles.css +602 -62
  19. package/public/troubleshooting.js +168 -46
  20. package/src/adapters/claude.mjs +2 -1
  21. package/src/adapters/codex.mjs +2 -1
  22. package/src/adapters/gemini.mjs +6 -5
  23. package/src/adapters/opencode.mjs +2 -1
  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/chats.mjs +193 -0
  28. package/src/checkpoint.mjs +1 -1
  29. package/src/cold.mjs +56 -0
  30. package/src/commands.mjs +6 -0
  31. package/src/conversation-context.mjs +35 -3
  32. package/src/credentials.mjs +145 -0
  33. package/src/dataset.mjs +56 -4
  34. package/src/distiller.mjs +12 -5
  35. package/src/event-store.mjs +14 -8
  36. package/src/exam.mjs +240 -0
  37. package/src/extensions.mjs +3 -2
  38. package/src/eyecat-watch.mjs +100 -0
  39. package/src/eyecat.mjs +169 -0
  40. package/src/i18n.mjs +47 -0
  41. package/src/image-studio.mjs +2 -0
  42. package/src/launch.mjs +61 -0
  43. package/src/maturity.mjs +94 -0
  44. package/src/mcp/image-server.mjs +12 -1
  45. package/src/mcp/memory-server.mjs +1 -1
  46. package/src/memory.mjs +325 -17
  47. package/src/modules/ahp.mjs +9 -7
  48. package/src/modules/ash.mjs +36 -0
  49. package/src/modules/git-pulse.mjs +5 -3
  50. package/src/modules/helpers.mjs +31 -0
  51. package/src/modules/image-studio.mjs +9 -4
  52. package/src/modules/index.mjs +141 -9
  53. package/src/modules/ollama.mjs +66 -10
  54. package/src/modules/playwright.mjs +36 -19
  55. package/src/modules/ripley.mjs +5 -3
  56. package/src/modules/sdk.mjs +93 -2
  57. package/src/modules/updates.mjs +81 -0
  58. package/src/ollama.mjs +5 -2
  59. package/src/outbound.mjs +292 -0
  60. package/src/privacy.mjs +54 -7
  61. package/src/room/context.mjs +4 -4
  62. package/src/room/economy.mjs +161 -0
  63. package/src/room/prompt.mjs +118 -46
  64. package/src/room.mjs +443 -44
  65. package/src/runtime-detection.mjs +27 -8
  66. package/src/server.mjs +699 -69
  67. package/src/setup.mjs +1 -1
  68. package/src/updates.mjs +4 -2
  69. package/src/usage-sentinel.mjs +13 -8
  70. package/src/verdict.mjs +74 -0
  71. package/src/ashcode.mjs +0 -64
  72. 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,10 +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';
39
+ import { redactArgs } from './launch.mjs';
31
40
 
32
41
  // Adapters can fail with multi-line stderr or stack traces. The room keeps only
33
42
  // the first meaningful line, bounded, so the event log and the UI stay readable.
@@ -65,10 +74,15 @@ export class Room {
65
74
  #contextMaxChars;
66
75
  #memory; // RoomMemory: durable recall of everything said outside GHOST
67
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;
68
81
  #archivist; // room/archivist: who distils, when, and the bench
69
82
  #vectors; // room/vectors: embeddings filled in the background
70
83
  #escalation; // room/escalation: plan steps waiting for the human
71
84
  #ghost = new GhostLedger();
85
+ #liveListeners = new Set();
72
86
  #memoryServer; // MCP descriptor handed to every turn so the agent can query the memory itself
73
87
  #mother = null; // MotherChannel: her coded words to the crew
74
88
  #shuttingDown = false;
@@ -87,7 +101,7 @@ export class Room {
87
101
  #maxPlanSteps;
88
102
  #maxConcurrentTurns;
89
103
  #planMaxAgeMs;
90
- #ashCodeEnabled = false;
104
+ #ashEnabled = false;
91
105
 
92
106
  constructor({
93
107
  store,
@@ -97,6 +111,7 @@ 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,
@@ -118,6 +133,7 @@ export class Room {
118
133
  this.#contextMaxChars = contextMaxChars;
119
134
  this.#memory = memory;
120
135
  this.#recallShare = Math.min(0.6, Math.max(0, Number.isFinite(recallShare) ? recallShare : 0.3));
136
+ this.#cascade = cascade !== false;
121
137
  this.#memoryServer = memoryServer;
122
138
  this.#mother = mother;
123
139
  this.#privacy = privacy;
@@ -133,6 +149,8 @@ export class Room {
133
149
  this.#controlDesk = new ControlDesk({ projectRoot });
134
150
  this.#budget = new Budget({ softBudget: softTokenBudget });
135
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);
136
154
  this.#vectors = new VectorWorker({ memory, stopped: () => this.#shuttingDown });
137
155
  this.#archivist = new Archivist({
138
156
  memory,
@@ -218,6 +236,23 @@ export class Room {
218
236
  decideMode(requestId, decision) { return this.#escalation.decide(requestId, decision); }
219
237
 
220
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
+ }
221
256
  async #emit(type, payload) {
222
257
  if (this.#ghost.isGhost(type, payload)) return this.#ghost.emit(type, payload);
223
258
  const event = await this.#store.append(type, payload);
@@ -234,16 +269,29 @@ export class Room {
234
269
  }
235
270
  embedNow() { return this.#vectors.runNow(); }
236
271
 
237
- #contextFor(priorEvents, { messageId, text, omitSynthetic = false }) {
238
- 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;
239
286
  }
240
287
 
241
288
  // The human tunes the archive from MU/TH/UR; changes apply to the next run.
242
289
  distillSettings() {
243
- 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) };
244
291
  }
245
292
  configureDistill(patch = {}) {
246
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;
247
295
  this.#archivist.configure(patch);
248
296
  return this.distillSettings();
249
297
  }
@@ -293,12 +341,197 @@ export class Room {
293
341
  return { ...alert, crew };
294
342
  }
295
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
+
296
366
  /* ---------- NOSTROMO: the human's view of the archive ---------- */
297
367
 
298
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
+
299
380
  memoryResearch() {
300
381
  if (!this.#memory) return null;
301
- 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 };
302
535
  }
303
536
 
304
537
  /* ---------- privacy: terms that never travel through the room ---------- */
@@ -402,6 +635,26 @@ export class Room {
402
635
 
403
636
  budgetWindow() { return this.#budget.view(this.#agents); }
404
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
+
405
658
  async #recordUsage(agent, usage, { messageId = null, responseMessageId = null } = {}) {
406
659
  if (!usage) return;
407
660
  const charged = this.#budget.record(agent, usage);
@@ -435,13 +688,13 @@ export class Room {
435
688
  return this.capabilities();
436
689
  }
437
690
 
438
- setAshCode(enabled) {
439
- this.#ashCodeEnabled = Boolean(enabled);
440
- return this.#ashCodeEnabled;
691
+ setAsh(enabled) {
692
+ this.#ashEnabled = Boolean(enabled);
693
+ return this.#ashEnabled;
441
694
  }
442
695
 
443
- ashCodeEnabled() {
444
- return this.#ashCodeEnabled;
696
+ ashEnabled() {
697
+ return this.#ashEnabled;
445
698
  }
446
699
 
447
700
  // A slash command's result, shared with everyone (and with agents through
@@ -516,7 +769,7 @@ export class Room {
516
769
  this.#vectors.stop();
517
770
  }
518
771
 
519
- 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 }) {
520
773
  const parsed = parseMessage(text, target);
521
774
  const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
522
775
  const gate = await this.modeCheck({ target, text, mode: requestedMode });
@@ -529,12 +782,12 @@ export class Room {
529
782
  if (!parsed.target) throw new Error('Choose an agent or begin with @agent.');
530
783
  if (model !== null && model !== undefined && model !== '' && !isValidModelName(model)) throw new Error('Model name is not valid.');
531
784
  const chosenModel = model || null;
532
- const originalText = parsed.text || `(${files.length} attached file${files.length === 1 ? '' : 's'})`;
533
- const ashActive = ashCode === true && this.#ashCodeEnabled;
534
- const abbreviated = ashActive ? compressAshCode(originalText) : null;
535
- 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;
536
789
  // "!path" tokens point the agent at project files; only existing files count.
537
- const references = await resolveReferences(this.#projectRoot, originalText);
790
+ const references = await resolveReferences(this.#projectRoot, text2);
538
791
 
539
792
  // A standing lease (default #2) needs no arming; a ghost never writes.
540
793
  const standing = !ghost && !create && Boolean(this.scopesFor(parsed.target).write.always);
@@ -547,8 +800,7 @@ export class Room {
547
800
  sender: 'you',
548
801
  target: parsed.target,
549
802
  text: text2,
550
- originalText: abbreviated?.applied ? originalText : undefined,
551
- 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,
552
804
  status: 'sent',
553
805
  model: chosenModel,
554
806
  attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
@@ -612,7 +864,7 @@ export class Room {
612
864
  allowDelegation = false;
613
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}`);
614
866
  }
615
- await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, attachments: files, references, lease, ashCode: ashActive, mode: ghost ? 0 : control ? requestedMode : (lease ? 2 : 1) }));
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) }));
616
868
  }
617
869
 
618
870
  // A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
@@ -648,7 +900,7 @@ export class Room {
648
900
  }
649
901
 
650
902
  // Master brake: every plan and every in-flight turn, recorded as one event.
651
- async stopAll(reason = 'STOPALL by the human') {
903
+ async stopAll(reason = t('STOPALL by the human')) {
652
904
  const plans = this.#plans.size;
653
905
  const turns = this.#turns.size;
654
906
  for (const plan of this.#plans.values()) plan.stopped = reason;
@@ -682,7 +934,7 @@ export class Room {
682
934
 
683
935
  // One room turn for one agent. `requester` is who asked ('you' or an
684
936
  // orchestrating agent); `depth` 0 turns may delegate, deeper ones may not.
685
- 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 }) {
686
938
  if (this.#shuttingDown) return null;
687
939
  const agent = this.#agents.find((item) => item.id === targetId);
688
940
  if (!agent?.detected) {
@@ -696,7 +948,7 @@ export class Room {
696
948
 
697
949
  const priorEvents = await this.#store.readAll();
698
950
  // @madre never reads its own canned replies back: a small model would echo them.
699
- 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 } });
700
952
  let handoffId = null;
701
953
  if (context.previousAgent && context.previousAgent !== targetId) {
702
954
  handoffId = randomUUID();
@@ -727,7 +979,7 @@ export class Room {
727
979
  await this.#emit('agent.started', { messageId, agent: agent.id, handoffId, planId });
728
980
  const controller = new AbortController();
729
981
  const turn = { controller, promise: null, planId, agent: agent.id, startedAt: Date.now() };
730
- 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 });
731
983
  this.#turns.set(messageId, turn);
732
984
  let outcome = null;
733
985
  try {
@@ -739,13 +991,135 @@ export class Room {
739
991
  // The orchestrator's turn is over before its plan starts, so it is never
740
992
  // counted as in flight while the others work.
741
993
  if (outcome?.directives?.steps.length) {
742
- 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 });
743
995
  }
744
996
  return outcome?.responseMessageId ?? null;
745
997
  }
746
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
+
747
1117
  #prompt(input) {
748
- return buildPrompt({
1118
+ return buildPrompt(this.#promptOptions(input));
1119
+ }
1120
+
1121
+ #promptOptions(input) {
1122
+ return ({
749
1123
  ...input,
750
1124
  others: this.delegatesFor(input.agent.id),
751
1125
  delegation: this.#delegation,
@@ -755,11 +1129,12 @@ export class Room {
755
1129
  memoryServer: this.#memoryServer,
756
1130
  controlHolder: this.#controlDesk.holder?.agent ?? null,
757
1131
  privacyMarker: this.#privacy?.marker ?? '[ENTIDAD-ORG]',
1132
+ sdk: sdkPaths(),
758
1133
  madreModel: this.#agents.find((agent) => agent.id === 'madre' && agent.ready)?.version ?? null,
759
1134
  });
760
1135
  }
761
1136
 
762
- 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 }) {
763
1138
  // The lease is the human's; what each agent may do inside it is that
764
1139
  // agent's own enabled scopes. A delegate without file creation runs
765
1140
  // read-only even while the plan holds a lease.
@@ -846,11 +1221,29 @@ export class Room {
846
1221
  const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
847
1222
  ? await this.#toolsForTurn({ agent: agent.id, mode: turnMode, lease, scratchDir: lease?.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : null }).catch(() => [])
848
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
+ };
849
1241
  const result = await invoke({
1242
+ onProgress,
850
1243
  executable: agent.path,
851
1244
  projectRoot: this.#projectRoot,
852
1245
  text,
853
- prompt: this.#prompt({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ashCode, mode: turnMode, escalation, mcpServers, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null }),
1246
+ prompt: shaped.text,
854
1247
  timeoutMs: this.timeoutFor(agent.id),
855
1248
  signal,
856
1249
  model,
@@ -886,16 +1279,19 @@ export class Room {
886
1279
  if (depth > 0 && /```pulse/i.test(result.text)) {
887
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}`);
888
1281
  }
889
- const abbreviated = ashCode ? compressAshCode(result.text) : null;
890
1282
  await this.#emit('message.created', {
891
1283
  messageId: responseMessageId,
892
1284
  parentMessageId: messageId,
893
1285
  role: 'assistant',
894
1286
  sender: agent.id,
895
1287
  target: requester,
896
- text: abbreviated?.text ?? result.text,
897
- originalText: abbreviated?.applied ? result.text : undefined,
898
- 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,
899
1295
  status: 'completed',
900
1296
  planId,
901
1297
  model,
@@ -909,6 +1305,10 @@ export class Room {
909
1305
  if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, marker: this.#privacy.marker });
910
1306
  if (artifacts.length) {
911
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
+ }
912
1312
  }
913
1313
  if (createChanges && (createChanges.existingReverted.length || createChanges.forbiddenReverted.length)) {
914
1314
  await this.#emit('create.reverted', { checkpointId: createChanges.checkpointId, agent: agent.id, messageId, responseMessageId, existing: createChanges.existingReverted, forbidden: createChanges.forbiddenReverted, message: createChanges.message });
@@ -923,6 +1323,7 @@ export class Room {
923
1323
  }
924
1324
  if (controlChanges) await this.#emit('control.changed', { ...controlChanges, responseMessageId });
925
1325
  await this.#recordUsage(agent.id, result.usage, { messageId, responseMessageId });
1326
+ await this.#recordCost(agent.id, responseMessageId, result.usage, turnMode);
926
1327
  await this.#emit('agent.completed', { messageId, agent: agent.id, handoffId, planId });
927
1328
  return { responseMessageId, directives };
928
1329
  } catch (error) {
@@ -935,7 +1336,7 @@ export class Room {
935
1336
  }
936
1337
  }
937
1338
 
938
- async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ashCode = false, mode = 1 }) {
1339
+ async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ash = false, mode = 1 }) {
939
1340
  const planId = randomUUID();
940
1341
  // The plan's ceiling is the human's mode: #3 when the orchestrator held CONTROL, #2 while a
941
1342
  // lease exists, #1 otherwise. A step may ask for a mode ("@codex #2: …"); it gets the lowest
@@ -964,8 +1365,7 @@ export class Room {
964
1365
  if (plan.stopped) break;
965
1366
  plan.step = index + 1;
966
1367
  const messageId = randomUUID();
967
- const abbreviated = ashCode ? compressAshCode(step.text) : null;
968
- const stepText = abbreviated?.text ?? step.text;
1368
+ const stepText = step.text;
969
1369
  // A creation step in a #1 plan: stop and ask the human before the
970
1370
  // agent starts, once, with a clock. Permission written by the
971
1371
  // orchestrator inside the step text never counts.
@@ -993,8 +1393,7 @@ export class Room {
993
1393
  sender: orchestrator,
994
1394
  target: step.agent,
995
1395
  text: stepText,
996
- originalText: abbreviated?.applied ? step.text : undefined,
997
- 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,
998
1397
  status: 'delegated',
999
1398
  planId,
1000
1399
  mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1,
@@ -1002,7 +1401,7 @@ export class Room {
1002
1401
  step: index + 1,
1003
1402
  totalSteps: directives.steps.length + (directives.closing ? 1 : 0),
1004
1403
  });
1005
- await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: wanted >= 3 ? null : stepLease, ashCode, mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1, escalation });
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 });
1006
1405
  }
1007
1406
  if (!plan.stopped && directives.closing) {
1008
1407
  plan.step = directives.steps.length + 1;
@@ -1018,7 +1417,7 @@ export class Room {
1018
1417
  step: plan.step,
1019
1418
  totalSteps: plan.step,
1020
1419
  });
1021
- 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 });
1022
1421
  }
1023
1422
  } finally {
1024
1423
  this.#plans.delete(planId);