@jossuealcala/madre 0.5.2 → 0.7.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 (47) hide show
  1. package/CHANGELOG.md +265 -1
  2. package/README.md +33 -1
  3. package/docs/SDK.md +173 -6
  4. package/docs/sdk/connector-module.mjs +186 -0
  5. package/package.json +6 -2
  6. package/public/abduction.js +384 -0
  7. package/public/app.js +3450 -540
  8. package/public/assets/abduction-cat-blink.png +0 -0
  9. package/public/assets/abduction-cat-control-left.png +0 -0
  10. package/public/assets/abduction-cat-control-right.png +0 -0
  11. package/public/assets/abduction-cat.png +0 -0
  12. package/public/es.js +354 -54
  13. package/public/index.html +65 -19
  14. package/public/routing.js +74 -0
  15. package/public/styles.css +788 -77
  16. package/src/adapters/claude.mjs +6 -2
  17. package/src/adapters/gemini.mjs +26 -5
  18. package/src/adapters/madre.mjs +5 -0
  19. package/src/choices.mjs +64 -0
  20. package/src/dataset.mjs +11 -6
  21. package/src/distiller.mjs +12 -1
  22. package/src/file-ops.mjs +45 -0
  23. package/src/lease.mjs +37 -2
  24. package/src/mcp/smtp-server.mjs +189 -0
  25. package/src/memory-dashboard.mjs +69 -0
  26. package/src/memory.mjs +222 -18
  27. package/src/modules/ahp.mjs +1 -1
  28. package/src/modules/ash-policy.mjs +67 -0
  29. package/src/modules/ash.mjs +1 -1
  30. package/src/modules/choices.mjs +34 -0
  31. package/src/modules/correo.mjs +107 -0
  32. package/src/modules/index.mjs +48 -4
  33. package/src/modules/playwright.mjs +5 -1
  34. package/src/modules/ripley.mjs +13 -1
  35. package/src/modules/sdk.mjs +44 -9
  36. package/src/outbound.mjs +37 -3
  37. package/src/ports.mjs +91 -0
  38. package/src/request-guard.mjs +26 -0
  39. package/src/room/archivist.mjs +2 -1
  40. package/src/room/economy.mjs +83 -1
  41. package/src/room/prompt.mjs +14 -2
  42. package/src/room.mjs +150 -18
  43. package/src/sentinel-errors.mjs +5 -1
  44. package/src/server.mjs +188 -8
  45. package/src/smtp.mjs +122 -0
  46. package/src/vault.mjs +100 -0
  47. package/src/whats-new.mjs +140 -0
package/src/room.mjs CHANGED
@@ -10,6 +10,7 @@ import { UsageSentinel } from './usage-sentinel.mjs';
10
10
  import { BLOCK_SOURCES, buildPrompt, promptParts, sparedChars } from './room/prompt.mjs';
11
11
  import { turnCost, observedRate } from './room/economy.mjs';
12
12
  import { contextFor } from './room/context.mjs';
13
+ import { VERDICTS } from './memory.mjs';
13
14
  import { coldNotes, coldReading } from './cold.mjs';
14
15
  import { questionsFor } from './asking.mjs';
15
16
  import { coverageExam, consistencyExam, matchExam, exchanges, MATCH_SAMPLE, saysFor } from './exam.mjs';
@@ -28,11 +29,13 @@ import { CODE000_STRIKES } from './mother.mjs';
28
29
  import { parseDirectives } from './directives.mjs';
29
30
  import { isValidModelName } from './models.mjs';
30
31
  import { MODES, SCOPES, SCOPE_LABELS, capabilitySummary, normalizeMode, resolveScopes } from './capabilities.mjs';
31
- import { createLease, diffSnapshots, snapshot } from './lease.mjs';
32
+ import { createLease, diffSnapshots, namedPaths, snapshot } from './lease.mjs';
32
33
  import { stat as statFile } from 'node:fs/promises';
33
34
  import { basename as baseName, join as joinPath } from 'node:path';
34
35
  import { contentTypeFor } from './files.mjs';
35
36
  import { isModuleFile, sdkPaths } from './modules/index.mjs';
37
+ import { validateAshReply } from './modules/ash-policy.mjs';
38
+ import { parseChoice, withoutChoice } from './choices.mjs';
36
39
  import { imageStudioFor } from './image-studio.mjs';
37
40
  import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
38
41
  import { resolveReferences } from './files.mjs';
@@ -64,6 +67,34 @@ const CREATION_VERB = /\b(genera(r|d)?|crea(r|d)?|escrib(e|a|ir)|guarda(r)?|prod
64
67
  const CREATION_OBJECT = /\b(archivo|fichero|file|pdf|md|markdown|imagen|image|png|jpe?g|svg|documento|document|docx?|csv|json|xlsx?|pptx?|html|script|carpeta|folder|reporte|report|informe|entregable|poster|logo|diagrama|diagram)\b/i;
65
68
  export const looksLikeCreation = (text) => CREATION_VERB.test(String(text ?? '')) && CREATION_OBJECT.test(String(text ?? ''));
66
69
 
70
+ // Which decisions the project is standing on, out of what the archive holds. A refuted one is
71
+ // kept by the archive — what was once decided is part of the record — and left out here, because
72
+ // a strip that says "decided" cannot carry something the room has since established is false.
73
+ export function settledDecisions(notes = [], { limit = 3 } = {}) {
74
+ return notes
75
+ .filter((note) => note && !note.refutedBy)
76
+ .slice(0, limit)
77
+ .map((note) => ({ id: note.id, text: note.text, created: note.created, agent: note.agent, fromSequence: note.fromSequence }));
78
+ }
79
+
80
+ // Open questions the room could put back on the table, newest and most-carried first.
81
+ //
82
+ // `questionsFor` has three wells; this reads only the first. The other two — memories nobody has
83
+ // ever reached for, a kind of note the archive is short of — are about the health of the archive
84
+ // and belong in NOSTROMO beside the map that shows them. What belongs under a composer is the
85
+ // narrower thing: a question this room asked, recorded as open, and never answered.
86
+ //
87
+ // Refuted and dismissed are out. Carried into more turns ranks higher, because a question the
88
+ // room keeps dragging along unanswered is the one most worth asking out loud.
89
+ export function openQuestions(notes = [], { dismissed = [], limit = 3 } = {}) {
90
+ const skip = new Set(dismissed ?? []);
91
+ return notes
92
+ .filter((note) => note && note.kind === 'question' && !note.refutedBy && !skip.has(`open:${note.id}`))
93
+ .sort((a, b) => (Number(b.recalled ?? 0) - Number(a.recalled ?? 0)) || (b.id - a.id))
94
+ .slice(0, limit)
95
+ .map((note) => ({ id: note.id, text: note.text, carried: Number(note.recalled ?? 0) }));
96
+ }
97
+
67
98
  export class Room {
68
99
  #store;
69
100
  #agents;
@@ -102,6 +133,7 @@ export class Room {
102
133
  #maxConcurrentTurns;
103
134
  #planMaxAgeMs;
104
135
  #ashEnabled = false;
136
+ #choicesEnabled = false;
105
137
 
106
138
  constructor({
107
139
  store,
@@ -226,7 +258,7 @@ export class Room {
226
258
  outDir: lease.relativeDir,
227
259
  scratchDir: lease.scratchDir,
228
260
  scopes: SCOPES.filter((scope) => enabled[scope]),
229
- unavailable: SCOPES.filter((scope) => !scopes[scope].capable).map((scope) => SCOPE_LABELS[scope]),
261
+ unavailable: SCOPES.filter((scope) => !scopes[scope].capable),
230
262
  planId,
231
263
  });
232
264
  await this.#emit('mode.granted', { requestId, planId, agent: step.agent, mode, step: index + 1, scope: decision.decision, leaseId: lease.leaseId, message: decision.decision === 'plan' ? `#${mode} granted for the rest of the plan; every writable agent creates inside one lease.` : `#${mode} granted to @${step.agent} for step ${index + 1} only.` });
@@ -314,6 +346,12 @@ export class Room {
314
346
  } catch { return null; }
315
347
  }
316
348
 
349
+ // Counts only, for the MEMORY dashboard. See RoomMemory#activity.
350
+ memoryActivity({ days = 30 } = {}) {
351
+ if (!this.#memory) return null;
352
+ try { return this.#memory.activity({ days }); } catch { return null; }
353
+ }
354
+
317
355
  /* ---------- MU/TH/UR's channel ---------- */
318
356
 
319
357
  motherStatus() { return this.#mother ? this.#mother.status() : null; }
@@ -377,6 +415,26 @@ export class Room {
377
415
  // opened: a turn in flight is a reason to wait, not something to race.
378
416
  working() { return this.#turns.size > 0; }
379
417
 
418
+ // The decisions this project is standing on, newest first.
419
+ //
420
+ // Continuity is the point: a conversation opened a week later, or by another agent, starts
421
+ // knowing what was already settled instead of relitigating it. Read-only, and it costs no
422
+ // prompt — these never enter a briefing from here, they are shown to the human.
423
+ //
424
+ // A refuted decision is left out. The archive keeps it, because what was once decided is part
425
+ // of the record, but showing it in a strip titled "what we decided" would be the room asserting
426
+ // something it has since established is false.
427
+ // The same shape as decisions(): read from the archive, shown to the human, never in a prompt.
428
+ questions({ limit = 3 } = {}) {
429
+ if (!this.#memory) return [];
430
+ return openQuestions(this.#memory.memories({ kind: 'question', limit: limit * 6 }), { dismissed: this.#dismissedAsks(), limit });
431
+ }
432
+
433
+ decisions({ limit = 3 } = {}) {
434
+ if (!this.#memory) return [];
435
+ return settledDecisions(this.#memory.memories({ kind: 'decision', limit: limit * 4 }), { limit });
436
+ }
437
+
380
438
  memoryResearch() {
381
439
  if (!this.#memory) return null;
382
440
  const memories = this.#memory.memories({ limit: 500 });
@@ -565,11 +623,15 @@ export class Room {
565
623
  return result;
566
624
  }
567
625
 
568
- // The human's verdict on a reply: good, bad, or cleared. The dataset listens.
626
+ // The human's verdict on a reply: good, bad, or cleared. The dataset listens, and so does the
627
+ // archive: what the room distilled from that reply changes standing, without a word of it being
628
+ // rewritten. The text of a note is what the archivist heard; where it stands is what you said.
569
629
  async rateMessage(messageId, rating) {
570
630
  if (typeof messageId !== 'string' || !messageId.trim()) throw new Error('Which reply? Give its messageId.');
571
- if (!['good', 'bad', 'none'].includes(rating)) throw new Error('A rating is good, bad or none.');
572
- return this.#emit('message.rated', { messageId, rating, by: 'you' });
631
+ if (!VERDICTS.includes(rating)) throw new Error(`A verdict is one of ${VERDICTS.join(', ')}.`);
632
+ const event = await this.#emit('message.rated', { messageId, rating, by: 'you' });
633
+ this.#memory?.judge(messageId, rating);
634
+ return event;
573
635
  }
574
636
 
575
637
  // Forgetting is recorded in the ledger like anything else the human does to the room.
@@ -649,7 +711,7 @@ export class Room {
649
711
  // Each bill teaches the room what its own words cost, so the next turn can say what it is
650
712
  // about to spend before it spends it.
651
713
  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 });
714
+ await this.#emit('turn.cost', { agent, mode, responseMessageId, ash: Boolean(cost.blocks.ash), spared: shape.spared, ...cost });
653
715
  } catch (error) {
654
716
  console.error(`MADRE could not weigh a turn: ${error.message}`);
655
717
  }
@@ -693,6 +755,15 @@ export class Room {
693
755
  return this.#ashEnabled;
694
756
  }
695
757
 
758
+ setChoices(enabled) {
759
+ this.#choicesEnabled = Boolean(enabled);
760
+ return this.#choicesEnabled;
761
+ }
762
+
763
+ choicesEnabled() {
764
+ return this.#choicesEnabled;
765
+ }
766
+
696
767
  ashEnabled() {
697
768
  return this.#ashEnabled;
698
769
  }
@@ -772,6 +843,10 @@ export class Room {
772
843
  async send({ text, target, model = null, attachments = [], create = false, ash = false, mode = undefined }) {
773
844
  const parsed = parseMessage(text, target);
774
845
  const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
846
+ // This is the decision the human made when pressing Send. Capture it before modeCheck() —
847
+ // the first await — so a concurrent module toggle cannot change the turn after that click.
848
+ const ashRequested = ash === true;
849
+ const ashActive = ashRequested && this.#ashEnabled;
775
850
  const gate = await this.modeCheck({ target, text, mode: requestedMode });
776
851
  if (!gate.ok) throw new Error(gate.error);
777
852
  create = requestedMode === 2;
@@ -785,7 +860,6 @@ export class Room {
785
860
  // What the human wrote, sent as they wrote it. Ash used to rewrite this line before it left
786
861
  // the room; it does not any more, so there is no original to keep beside an abbreviation.
787
862
  const text2 = parsed.text || `(${files.length} attached file${files.length === 1 ? '' : 's'})`;
788
- const ashActive = ash === true && this.#ashEnabled;
789
863
  // "!path" tokens point the agent at project files; only existing files count.
790
864
  const references = await resolveReferences(this.#projectRoot, text2);
791
865
 
@@ -800,7 +874,7 @@ export class Room {
800
874
  sender: 'you',
801
875
  target: parsed.target,
802
876
  text: text2,
803
- ash: ashActive ? { active: true } : undefined,
877
+ ash: ashRequested ? { requested: true, active: ashActive } : undefined,
804
878
  status: 'sent',
805
879
  model: chosenModel,
806
880
  attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
@@ -818,7 +892,10 @@ export class Room {
818
892
  if (create === true || standing) {
819
893
  const scopes = this.scopesFor(parsed.target);
820
894
  const enabled = SCOPES.filter((scope) => scopes[scope].enabled && scopes[scope].wired);
821
- const unavailable = SCOPES.filter((scope) => !scopes[scope].capable).map((scope) => SCOPE_LABELS[scope]);
895
+ // The ids travel; the labels only build the English sentence below. A label is already a
896
+ // word, so the client's translator had nothing to look up and left it as it found it.
897
+ const unavailable = SCOPES.filter((scope) => !scopes[scope].capable);
898
+ const unavailableSaid = unavailable.map((scope) => SCOPE_LABELS[scope]);
822
899
  const disabled = SCOPES.filter((scope) => scopes[scope].capable && !scopes[scope].enabled && scopes[scope].wired).map((scope) => SCOPE_LABELS[scope]);
823
900
  if (!enabled.includes('write') || scopes.maxMode < 2) {
824
901
  const alternatives = this.#agents.filter((agent) => agent.ready && agent.id !== parsed.target && this.scopesFor(agent.id).maxMode >= 2).map((agent) => `@${agent.id}`);
@@ -829,7 +906,7 @@ export class Room {
829
906
  unavailable,
830
907
  disabled,
831
908
  alternatives,
832
- message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (enable it in CONNECTIONS)' : 'its CLI cannot create files'}${unavailable.length ? `; it cannot ${unavailable.join(' or ')}` : ''}.${alternatives.length ? ` For creation ask ${alternatives.join(' or ')}.` : ''}`,
909
+ message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (enable it in CONNECTIONS)' : 'its CLI cannot create files'}${unavailableSaid.length ? `; it cannot ${unavailableSaid.join(' or ')}` : ''}.${alternatives.length ? ` For creation ask ${alternatives.join(' or ')}.` : ''}`,
833
910
  });
834
911
  } else {
835
912
  lease = await this.#projectLease({ leaseId: randomUUID(), messageId });
@@ -919,6 +996,27 @@ export class Room {
919
996
  await this.#emit('room.alert', { code, message, plans: this.activePlans().length, turns: this.#turns.size });
920
997
  }
921
998
 
999
+ // Human brake for ONE answer, which is a different thing from the master brake. STOPALL takes
1000
+ // down every plan and every agent in the room and is meant for when things have run away;
1001
+ // this takes down the one turn you are looking at, because an agent that has hung or started
1002
+ // down the wrong road should not cost the whole room to stop. The agent's process dies with the
1003
+ // abort and the turn records its own failure the way any other failure does.
1004
+ async stopTurn(messageId, reason = t('stopped by the human')) {
1005
+ const turn = this.#turns.get(messageId);
1006
+ if (!turn) return false;
1007
+ // Marked before the abort lands, so the failure it causes carries the mark: the turn ends
1008
+ // like any other failure because the process really was killed, and only this says it was
1009
+ // asked for.
1010
+ turn.stoppedByHuman = true;
1011
+ await this.#emit('turn.stopped', { messageId, agent: turn.agent, planId: turn.planId, reason, by: 'you' });
1012
+ turn.controller.abort(reason);
1013
+ // Waited out, the way the master brake waits: aborting only asks, and the turn still has to
1014
+ // kill its process and record how it ended. Returning before that leaves the caller — and
1015
+ // anything tidying up after it — racing a write that has not happened yet.
1016
+ await turn.promise?.catch(() => {});
1017
+ return true;
1018
+ }
1019
+
922
1020
  // Human brake: stops the remaining steps of a running plan and interrupts
923
1021
  // the step in flight. Recorded in the log like everything else.
924
1022
  async stopPlan(planId, reason = 'stopped by the human') {
@@ -1012,7 +1110,7 @@ export class Room {
1012
1110
  // nowhere. Nothing here counts: a memory read for this was not recalled, the window does not
1013
1111
  // move, and no process is started. It is the document MADRE writes in the human's name, which
1014
1112
  // until now existed only for the instant a CLI was reading it.
1015
- async briefing({ agent: agentId, mode = 1, text = '' } = {}) {
1113
+ async briefing({ agent: agentId, mode = 1, text = '', ash = false } = {}) {
1016
1114
  const agent = this.#agents.find((one) => one.id === agentId && one.detected);
1017
1115
  if (!agent) return null;
1018
1116
  const priorEvents = await this.#store.readAll();
@@ -1047,7 +1145,7 @@ export class Room {
1047
1145
  context: built.context, recall: built.recall, memories: built.memories,
1048
1146
  attachments: [], references: [], lease,
1049
1147
  scopes: { web: scopes.web.enabled && scopes.web.wired, imageGen: scopes.imageGen.enabled && scopes.imageGen.wired },
1050
- ash: this.ashEnabled(), mode, escalation: null, mcpServers,
1148
+ ash: ash === true && this.ashEnabled(), mode, escalation: null, mcpServers,
1051
1149
  });
1052
1150
  // Each block with what put it here and what would take it away, so "read, never written" can
1053
1151
  // be checked one block at a time instead of believed.
@@ -1158,6 +1256,7 @@ export class Room {
1158
1256
  let lease = null;
1159
1257
  let controlRun = null;
1160
1258
  let createRun = null;
1259
+ let responseMessageId = null;
1161
1260
  if (mode >= 3 && (requester === 'you' || planId) && depth <= 1 && agentScopes.maxMode >= mode && enabled.write) {
1162
1261
  // CONTROL: the project itself is the writable root, and a checkpoint
1163
1262
  // taken now makes every change of this turn reversible. A plan step gets
@@ -1189,7 +1288,7 @@ export class Room {
1189
1288
  outDir: lease.relativeDir,
1190
1289
  scratchDir: lease.scratchDir,
1191
1290
  scopes: ['write'],
1192
- unavailable: SCOPES.filter((scope) => !agentScopes[scope].capable).map((scope) => SCOPE_LABELS[scope]),
1291
+ unavailable: SCOPES.filter((scope) => !agentScopes[scope].capable),
1193
1292
  planId,
1194
1293
  });
1195
1294
  }
@@ -1228,7 +1327,7 @@ export class Room {
1228
1327
  const invoke = this.#invokers[agent.adapter];
1229
1328
  if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
1230
1329
  const before = lease && !lease.control && !lease.create ? await snapshot(lease.outDir) : null;
1231
- const responseMessageId = randomUUID();
1330
+ responseMessageId = randomUUID();
1232
1331
  const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
1233
1332
  const others = this.delegatesFor(agent.id);
1234
1333
  const mayDelegate = allowDelegation && this.#delegation && depth === 0;
@@ -1238,7 +1337,7 @@ export class Room {
1238
1337
  : [];
1239
1338
  // The prompt and the shape it was built from: one is sent, the other is kept until the CLI
1240
1339
  // says what it cost, so the bill can be attributed to the blocks that caused it.
1241
- 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 });
1340
+ const shaped = this.#promptFor({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ash, choices: this.#choicesEnabled, mode: turnMode, escalation, mcpServers, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null });
1242
1341
  this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared });
1243
1342
  // What this turn is about to read, said while it is still reading it. The size is exact;
1244
1343
  // only the conversion to tokens is an estimate, and it is made at this room's own rate.
@@ -1281,6 +1380,21 @@ export class Room {
1281
1380
  // CREATE: what appeared stays and is shown; what existed before is put back and said.
1282
1381
  const createChanges = createRun ? await this.#controlDesk.settle(createRun) : null;
1283
1382
  const artifacts = createChanges ? await this.#artifactsFrom(createChanges.files) : lease && !lease.control ? diffSnapshots(before, await snapshot(lease.outDir), { relativeDir: lease.relativeDir }) : [];
1383
+ // A turn that could write, wrote nothing, and answers naming files. That is the shape of a
1384
+ // write refused without a word — a headless CLI cannot stop to ask, so what the policy does
1385
+ // not allow simply fails and the model reports the file as created. The room does not read
1386
+ // the prose for a claim; it takes the paths the reply names and asks the disk. Only what is
1387
+ // named AND absent is said, so an agent talking about files that exist says nothing here.
1388
+ if (lease && !lease.control && artifacts.length === 0) {
1389
+ const roots = [this.#projectRoot, lease.outDir, lease.realOutDir].filter(Boolean);
1390
+ const named = namedPaths(result.text, { roots });
1391
+ const missing = [];
1392
+ for (const path of named.slice(0, 12)) {
1393
+ const full = path.startsWith('/') ? path : joinPath(this.#projectRoot, path);
1394
+ if (!(await statFile(full).then(() => true, () => false))) missing.push(path);
1395
+ }
1396
+ if (missing.length) await this.#emit('create.missing', { messageId: responseMessageId, agent: agent.id, planId, files: missing });
1397
+ }
1284
1398
  // CONTROL: what really changed in the project, forbidden zones reverted on the spot.
1285
1399
  const controlChanges = controlRun ? await this.#controlDesk.settle(controlRun) : null;
1286
1400
  const directives = mayDelegate
@@ -1294,13 +1408,15 @@ export class Room {
1294
1408
  if (depth > 0 && /```pulse/i.test(result.text)) {
1295
1409
  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}`);
1296
1410
  }
1411
+ // A question the agent put to the human: it leaves the text and comes back as buttons.
1412
+ const asked = this.#choicesEnabled && turnMode !== 0 ? parseChoice(result.text) : null;
1297
1413
  await this.#emit('message.created', {
1298
1414
  messageId: responseMessageId,
1299
1415
  parentMessageId: messageId,
1300
1416
  role: 'assistant',
1301
1417
  sender: agent.id,
1302
1418
  target: requester,
1303
- text: result.text,
1419
+ text: asked ? withoutChoice(result.text) : result.text,
1304
1420
  // What the archive handed this turn, so the room can see its own memory working. The
1305
1421
  // whole point of remembering is invisible until the moment it is used.
1306
1422
  recalled: memories?.length
@@ -1317,6 +1433,11 @@ export class Room {
1317
1433
  artifacts: artifacts.length ? artifacts : undefined,
1318
1434
  delegates: directives.steps.length ? directives.steps.map((step) => step.agent) : undefined,
1319
1435
  });
1436
+ if (ash && turnMode !== 0) {
1437
+ const validation = validateAshReply({ request: text, response: result.text });
1438
+ await this.#emit('ash.validated', { messageId, responseMessageId, agent: agent.id, ...validation });
1439
+ }
1440
+ if (asked) await this.#emit('choice.offered', { messageId, responseMessageId, agent: agent.id, question: asked.question, options: asked.options });
1320
1441
  if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, marker: this.#privacy.marker });
1321
1442
  if (artifacts.length) {
1322
1443
  await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
@@ -1342,7 +1463,15 @@ export class Room {
1342
1463
  await this.#emit('agent.completed', { messageId, agent: agent.id, handoffId, planId });
1343
1464
  return { responseMessageId, directives };
1344
1465
  } catch (error) {
1345
- await this.#emit('message.failed', { messageId, target: agent.id, planId, error: this.#privacy ? this.#privacy.redact(failureMessage(error)).text : failureMessage(error) });
1466
+ if (responseMessageId && this.#promptShape.has(responseMessageId)) {
1467
+ const shape = this.#promptShape.get(responseMessageId);
1468
+ this.#promptShape.delete(responseMessageId);
1469
+ if (turnMode !== 0) {
1470
+ const cost = turnCost(shape.parts, null);
1471
+ await this.#emit('turn.cost', { agent: agent.id, mode: turnMode, responseMessageId, ash: Boolean(cost.blocks.ash), failed: true, spared: shape.spared, ...cost });
1472
+ }
1473
+ }
1474
+ await this.#emit('message.failed', { messageId, target: agent.id, planId, error: this.#privacy ? this.#privacy.redact(failureMessage(error)).text : failureMessage(error), ...(this.#turns.get(messageId)?.stoppedByHuman ? { stopped: true } : {}) });
1346
1475
  return null;
1347
1476
  } finally {
1348
1477
  // Release CONTROL whichever way the turn ended; the checkpoint stays for UNDO.
@@ -1421,18 +1550,21 @@ export class Room {
1421
1550
  if (!plan.stopped && directives.closing) {
1422
1551
  plan.step = directives.steps.length + 1;
1423
1552
  const messageId = randomUUID();
1553
+ const closingMode = ceiling >= 3 ? ceiling : lease ? 2 : 1;
1424
1554
  await this.#emit('message.created', {
1425
1555
  messageId,
1426
1556
  role: 'assistant',
1427
1557
  sender: orchestrator,
1428
1558
  target: orchestrator,
1429
1559
  text: directives.closing,
1560
+ ash: ash ? { active: true } : undefined,
1561
+ mode: closingMode,
1430
1562
  status: 'delegated',
1431
1563
  planId,
1432
1564
  step: plan.step,
1433
1565
  totalSteps: plan.step,
1434
1566
  });
1435
- 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 });
1567
+ 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, mode: closingMode });
1436
1568
  }
1437
1569
  } finally {
1438
1570
  this.#plans.delete(planId);
@@ -100,8 +100,12 @@ export class ErrorSentinel {
100
100
  // A room event goes by: a failure MU/TH/UR cannot classify is worth a report.
101
101
  async observe(event) {
102
102
  if (event?.type !== 'message.failed') return null;
103
- const { target, error } = event.payload ?? {};
103
+ const { target, error, stopped } = event.payload ?? {};
104
104
  if (typeof error !== 'string' || !error.trim()) return null;
105
+ // A turn the human stopped is not a condition. It ends exactly like a failure because the
106
+ // process really was killed, but nothing went wrong and there is nothing for anyone to
107
+ // diagnose — filing it would be reporting the brake as a fault.
108
+ if (stopped) return null;
105
109
  const known = diagnose(error, target ?? null);
106
110
  if (known.length) return null;
107
111
  return this.record({ kind: 'unknown', agent: target ?? null, error, at: event.timestamp });