@jossuealcala/madre 1.0.1 → 1.0.2

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 (172) hide show
  1. package/ADDITIONAL-TERMS.md +93 -0
  2. package/CHANGELOG.en.md +1945 -0
  3. package/CHANGELOG.md +34 -1
  4. package/CONTRIBUTING.md +1 -1
  5. package/LICENSE +661 -202
  6. package/NOTICE +26 -14
  7. package/README.en.md +245 -0
  8. package/README.md +19 -10
  9. package/TRADEMARKS.en.md +50 -0
  10. package/TRADEMARKS.md +33 -16
  11. package/bin/madre.mjs +2 -0
  12. package/docs/INTERNALS.md +6 -0
  13. package/docs/REFERENCE.md +11 -2
  14. package/docs/en/INTERNALS.md +103 -0
  15. package/docs/en/REFERENCE.md +339 -0
  16. package/docs/en/SDK.md +306 -0
  17. package/docs/en/training/README.md +59 -0
  18. package/docs/training/README.md +20 -8
  19. package/docs/training/train.sh +2 -1
  20. package/package.json +11 -3
  21. package/public/abduction.js +2 -0
  22. package/public/app.js +1549 -409
  23. package/public/attribution.js +71 -0
  24. package/public/brands.js +3 -1
  25. package/public/editor.js +2 -0
  26. package/public/es.js +620 -400
  27. package/public/i18n.js +25 -1
  28. package/public/index.html +13 -10
  29. package/public/inquiry.js +2 -0
  30. package/public/loader.js +2 -0
  31. package/public/macroverso.js +2 -0
  32. package/public/nostromo-engine.js +2 -0
  33. package/public/resay.js +6 -0
  34. package/public/review-ask.js +2 -0
  35. package/public/routing.js +23 -1
  36. package/public/salas-mac.css +21 -4
  37. package/public/salas-mac.html +5 -1
  38. package/public/salas-mac.js +53 -28
  39. package/public/salas.css +2 -0
  40. package/public/salas.html +1 -0
  41. package/public/salas.js +4 -2
  42. package/public/styles.css +957 -18
  43. package/public/syntax.js +2 -0
  44. package/public/touch.js +2 -0
  45. package/public/tour-chapters.js +2 -0
  46. package/public/tour.js +37 -0
  47. package/public/troubleshooting.js +18 -16
  48. package/src/adapters/claude.mjs +2 -0
  49. package/src/adapters/codex.mjs +2 -0
  50. package/src/adapters/gemini.mjs +3 -1
  51. package/src/adapters/madre.mjs +3 -1
  52. package/src/adapters/opencode.mjs +7 -3
  53. package/src/adapters/process.mjs +2 -0
  54. package/src/asking.mjs +2 -0
  55. package/src/auth-probe.mjs +3 -1
  56. package/src/bridge/devices.mjs +2 -0
  57. package/src/bridge/handoff.mjs +2 -0
  58. package/src/bridge/host.mjs +2 -0
  59. package/src/bridge/network.mjs +2 -0
  60. package/src/bridge/policy.mjs +3 -1
  61. package/src/bridge/qr.mjs +2 -0
  62. package/src/bridge/server.mjs +2 -0
  63. package/src/capabilities.mjs +2 -0
  64. package/src/chats.mjs +2 -0
  65. package/src/checkpoint.mjs +2 -0
  66. package/src/choices.mjs +2 -0
  67. package/src/cli-args.mjs +2 -0
  68. package/src/cold.mjs +2 -0
  69. package/src/commands.mjs +2 -0
  70. package/src/config.mjs +2 -0
  71. package/src/conversation-context.mjs +15 -1
  72. package/src/credentials.mjs +32 -3
  73. package/src/dataset.mjs +2 -0
  74. package/src/directives.mjs +2 -0
  75. package/src/distiller.mjs +2 -0
  76. package/src/embeddings.mjs +2 -0
  77. package/src/enlaces.mjs +6 -4
  78. package/src/event-store.mjs +2 -0
  79. package/src/exam.mjs +6 -4
  80. package/src/extensions.mjs +2 -0
  81. package/src/eyecat-watch.mjs +2 -0
  82. package/src/eyecat.mjs +2 -0
  83. package/src/file-ops.mjs +2 -0
  84. package/src/files.mjs +32 -0
  85. package/src/github.mjs +2 -0
  86. package/src/heartbeat.mjs +2 -0
  87. package/src/i18n.mjs +15 -1
  88. package/src/image-studio.mjs +2 -0
  89. package/src/json-file.mjs +2 -0
  90. package/src/launch.mjs +2 -0
  91. package/src/lease.mjs +5 -1
  92. package/src/lobby-work.mjs +2 -0
  93. package/src/local-ai.mjs +2 -0
  94. package/src/looks.mjs +2 -0
  95. package/src/maturity.mjs +3 -1
  96. package/src/mcp/connect.mjs +345 -0
  97. package/src/mcp/github-server.mjs +2 -0
  98. package/src/mcp/image-server.mjs +2 -0
  99. package/src/mcp/memory-server.mjs +2 -0
  100. package/src/mcp/proxy.mjs +153 -0
  101. package/src/mcp/smtp-server.mjs +2 -0
  102. package/src/memory-dashboard.mjs +2 -0
  103. package/src/memory-tools.mjs +2 -0
  104. package/src/memory.mjs +2 -0
  105. package/src/models.mjs +45 -3
  106. package/src/modules/ahp.mjs +2 -0
  107. package/src/modules/ash-policy.mjs +2 -0
  108. package/src/modules/ash.mjs +3 -1
  109. package/src/modules/bishop-plan.mjs +5 -3
  110. package/src/modules/bishop.mjs +13 -11
  111. package/src/modules/choices.mjs +2 -0
  112. package/src/modules/correo.mjs +2 -0
  113. package/src/modules/enlaces.mjs +2 -0
  114. package/src/modules/git-pulse.mjs +2 -0
  115. package/src/modules/github.mjs +2 -0
  116. package/src/modules/helpers.mjs +2 -0
  117. package/src/modules/image-studio.mjs +3 -1
  118. package/src/modules/index.mjs +4 -1
  119. package/src/modules/intents-calibration.mjs +2 -0
  120. package/src/modules/intents-desk.mjs +4 -2
  121. package/src/modules/intents-observe.mjs +2 -0
  122. package/src/modules/intents-policy.mjs +4 -1
  123. package/src/modules/intents-try.mjs +2 -0
  124. package/src/modules/intents.mjs +8 -6
  125. package/src/modules/mcp-connect.mjs +293 -0
  126. package/src/modules/ollama.mjs +3 -1
  127. package/src/modules/playwright.mjs +25 -10
  128. package/src/modules/ripley.mjs +2 -0
  129. package/src/modules/runs.mjs +5 -3
  130. package/src/modules/sdk.mjs +2 -0
  131. package/src/modules/updates.mjs +2 -0
  132. package/src/mother.mjs +2 -0
  133. package/src/ollama.mjs +2 -0
  134. package/src/outbound.mjs +7 -5
  135. package/src/override.mjs +2 -0
  136. package/src/ports.mjs +2 -0
  137. package/src/prices.mjs +2 -0
  138. package/src/privacy.mjs +2 -0
  139. package/src/quota-monitor.mjs +2 -0
  140. package/src/quota-sources.mjs +2 -0
  141. package/src/request-guard.mjs +2 -0
  142. package/src/review-ask.mjs +2 -0
  143. package/src/room/archivist.mjs +2 -0
  144. package/src/room/attachments.mjs +2 -0
  145. package/src/room/budget.mjs +2 -0
  146. package/src/room/context.mjs +2 -0
  147. package/src/room/control.mjs +7 -1
  148. package/src/room/copilot.mjs +76 -0
  149. package/src/room/economy.mjs +2 -0
  150. package/src/room/escalation.mjs +2 -0
  151. package/src/room/ghost.mjs +2 -0
  152. package/src/room/guard.mjs +2 -0
  153. package/src/room/prompt.mjs +13 -4
  154. package/src/room/returns.mjs +82 -0
  155. package/src/room/runs.mjs +4 -0
  156. package/src/room/vectors.mjs +2 -0
  157. package/src/room.mjs +209 -31
  158. package/src/router.mjs +2 -0
  159. package/src/runs.mjs +2 -0
  160. package/src/runtime-detection.mjs +2 -0
  161. package/src/salas.mjs +2 -0
  162. package/src/sentinel-errors.mjs +2 -0
  163. package/src/server-sentinel.mjs +2 -0
  164. package/src/server.mjs +141 -34
  165. package/src/setup.mjs +2 -0
  166. package/src/smtp.mjs +2 -0
  167. package/src/tour.mjs +2 -0
  168. package/src/updates.mjs +2 -0
  169. package/src/usage-sentinel.mjs +2 -0
  170. package/src/vault.mjs +2 -0
  171. package/src/verdict.mjs +3 -1
  172. package/src/whats-new.mjs +2 -0
package/public/app.js CHANGED
@@ -1,14 +1,17 @@
1
- import { archiveQuestion, modeAsked } from './routing.js';
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
3
+ import { archiveQuestion, modeAskedWithTime, MODE_WINDOWS, windowFor } from './routing.js';
2
4
  import { calledByName, abduct } from './abduction.js';
3
5
  import { brandOf, monogram } from './brands.js';
4
6
  import { allConditions, detectPlatform, diagnose, fixesFor, PLATFORMS, searchConditions } from './troubleshooting.js';
5
7
  import { answerFor, INQUIRIES, STRIKES } from './inquiry.js';
6
- import { DEFAULT_LANGUAGE, LANGUAGES, isLanguage, language, pick, setLanguage, t, fill } from './i18n.js';
8
+ import { DEFAULT_LANGUAGE, LANGUAGES, browserLanguage, isLanguage, language, pick, setLanguage, t, fill } from './i18n.js';
7
9
  import { createEditor } from './editor.js';
8
10
  import { resay } from './resay.js';
9
11
  import { startReviewAsk } from './review-ask.js';
10
- import { startTour as startWelcome } from './tour.js';
12
+ import { spotlight, startTour as startWelcome } from './tour.js';
11
13
  import { dropStickyHover } from './touch.js';
14
+ import { drawAttribution, drawLegal } from './attribution.js';
12
15
  // Where nothing can hover (a phone), a tapped key does not stay lit (public/touch.js).
13
16
  dropStickyHover();
14
17
  // Made the first time the room asks for it (roomTour, below), which can be while this file is
@@ -105,10 +108,17 @@ if (document.documentElement) document.documentElement.lang = language();
105
108
 
106
109
  // A browser that has never chosen takes what this machine chose, once, and remembers it. It
107
110
  // cannot loop: the choice is written before the page is asked to come back.
108
- function adoptLanguage(fromMachine) {
111
+ // A machine nobody has set (2026-10-15) takes this browser's language, and keeps it for Home and
112
+ // every project, as if it had been chosen in Settings.
113
+ async function adoptLanguage(fromMachine, chosenOnMachine = true) {
109
114
  // The half on the right never writes the choice, so it would reload for ever; the left decides.
110
- if (SPLIT_CHAT) return;
111
- if (chosenLanguage || !isLanguage(fromMachine) || fromMachine === language()) return;
115
+ if (SPLIT_CHAT || chosenLanguage) return;
116
+ if (chosenOnMachine === false && !state.device) {
117
+ const mine = browserLanguage();
118
+ const saved = await fetch('/api/language', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ language: mine }) }).then((response) => response.ok).catch(() => false);
119
+ if (saved) fromMachine = mine;
120
+ }
121
+ if (!isLanguage(fromMachine) || fromMachine === language()) return;
112
122
  try { localStorage.setItem('pulse.language', fromMachine); } catch { return; }
113
123
  location.reload();
114
124
  }
@@ -169,9 +179,10 @@ const PLACEHOLDERS = {
169
179
  plain: t('Type here, human. Ask the project…'),
170
180
  create: t('CREATE on: what to make. New files land where they belong in the project; nothing existing changes.'),
171
181
  ash: t('Compact prose. Nothing you write is altered; only the answers get shorter.'),
172
- ghost: t('Off the record. Ask anything; nothing is saved, nobody else will remember it.'),
182
+ ghost: t('Incognito. Ask anything; nothing is saved and the project will not remember it.'),
173
183
  control: t('EDIT unlocked. Say what to change in the project; every action runs without asking.'),
174
- airlock: t('Airlock open. Commands run; pushes and deploys leave the ship. Say exactly what should go out.'),
184
+ copilot: t('Copilot on. Say what you want done; it edits, plans and checks on its own.'),
185
+ airlock: t('#4 FULL ACCESS unlocked. Commands run here, and changes are pushed or deployed outside your computer. Say exactly what should go out.'),
175
186
  expendable: t('Type here, human. MADRE is listening.'),
176
187
  memory: t('Ask what the project remembers. @madre answers from memory with citations; it does not act.'),
177
188
  };
@@ -183,11 +194,11 @@ const MAX_ROWS = 14;
183
194
  // Plain names, in the reader's language (2026-10-10): what each one lets an agent do. The agents
184
195
  // themselves are told the modes by their old names (src/capabilities.mjs), which is the core's.
185
196
  const MODES = {
186
- 0: { key: 'ghost', label: t('INCOGNITO'), hint: t('Off the record. Nothing is saved; gone on reload.') },
187
- 1: { key: 'exchange', label: t('CHAT ONLY'), hint: t('Read the project and talk about it. Writes nothing.') },
188
- 2: { key: 'create', label: t('CREATE'), hint: t('Add new files where they belong in the project. Existing files stay untouched.') },
189
- 3: { key: 'control', label: t('EDIT'), hint: t('Edit the project itself, no approval per action. Unlock required.') },
190
- 4: { key: 'airlock', label: t('FULL ACCESS'), hint: t('Run commands, push, deploy. What leaves the ship does not come back. Unlock, twice.') },
197
+ 0: { key: 'ghost', label: t('INCOGNITO'), hint: t('Incognito. Nothing is saved; it is gone when you reload the page.') },
198
+ 1: { key: 'exchange', label: t('CHAT ONLY'), hint: t('Read the project and talk about it, and plan with other agents. Writes nothing.') },
199
+ 2: { key: 'create', label: t('CREATE'), hint: t('Add new files where they belong in the project, and plan with other agents. Existing files stay untouched.') },
200
+ 3: { key: 'control', label: t('EDIT'), hint: t('Edit the project itself, no approval per action, and plan with other agents. Unlock required.') },
201
+ 4: { key: 'airlock', label: t('FULL ACCESS'), hint: t('Run commands, push, deploy, and plan with other agents. What leaves your computer cannot be undone. Confirm twice.') },
191
202
  };
192
203
 
193
204
  // A sign-in in flight. Claude's CLI opens a page and then waits to be told the code that page
@@ -228,6 +239,7 @@ const state = {
228
239
  reports: new Map(), // id -> sentinel report (unknown conditions and crashes)
229
240
  capabilities: {}, // id -> { read, imageIn, write, imageGen, web }
230
241
  pending: [], // attachments uploaded for the next message
242
+ runsReturned: new Set(), // command requests whose output already went back to the agent that asked
231
243
  projectRoot: '',
232
244
  create: false, // creation lease armed for the next message
233
245
  moduleCommand: false, // /module is being composed: CREATE plus the guided SDK contract
@@ -247,7 +259,7 @@ const state = {
247
259
  effortOnce: null,
248
260
  // While AJUSTES shows a category, the panel that holds it draws there too, from the same code and
249
261
  // the same answers MU/TH/UR draws from: one renderer, two places, never two copies of a rule.
250
- settingsTargets: { agents: null, room: null, turns: null, memory: null, privacy: null, permissions: null, release: null, sentinel: null, files: null },
262
+ settingsTargets: { agents: null, room: null, turns: null, memory: null, privacy: null, permissions: null, release: null, sentinel: null, files: null, mcp: null },
251
263
  settingsWindow: null, // AJUSTES, once its part of the file has run
252
264
  };
253
265
  try { state.chosenModel = JSON.parse(localStorage.getItem('pulse.chosenModel') ?? '{}') || {}; } catch { state.chosenModel = {}; }
@@ -274,8 +286,8 @@ const vendorWord = (id) => { const said = brandOf(id).vendor ?? ''; return VENDO
274
286
  // the top of this file, long before half of it has run: `renderLease` reached for these while
275
287
  // they were still in their dead zone, the try around it swallowed the error, and every CREATE
276
288
  // strip in the history simply never appeared.
277
- const CAP_LABELS = { read: t('read'), imageIn: t('image in'), write: t('create'), imageGen: t('image gen'), web: t('web') };
278
- const CAP_VERBS = { read: t('read the project'), imageIn: t('take images'), write: t('create files'), imageGen: t('generate images'), web: t('reach the web') };
289
+ const CAP_LABELS = { read: t('read'), imageIn: t('sees images'), write: t('create'), imageGen: t('image gen'), web: t('web') };
290
+ const CAP_VERBS = { read: t('read the project'), imageIn: t('receive images'), write: t('create files'), imageGen: t('generate images'), web: t('reach the web') };
279
291
  const capVerb = (scope) => CAP_VERBS[scope] ?? CAP_LABELS[scope] ?? scope;
280
292
 
281
293
  // MODULES' own handles. Up here with the rest of the module's state because the transcript is
@@ -302,12 +314,25 @@ const modules = {
302
314
  // Which agent last wrote to a path, taken from what the room already recorded: the artifacts a
303
315
  // creation lease produced, and the files a CONTROL turn changed. Nothing new is stored and nothing
304
316
  // is inferred from the filesystem — if MADRE did not see it happen, the file carries no mark.
305
- const touched = new Map();
306
- function markTouched(agent, files) {
317
+ // Each path keeps who touched it last and when (2026-10-14), so a folder can say that something
318
+ // under it changed and the Finder can lead from the top of the project down to the file.
319
+ const touched = new Map(); // path -> { agent, at }
320
+ function markTouched(agent, files, at = null) {
321
+ const when = at ? Date.parse(at) || Date.now() : Date.now();
307
322
  for (const file of files ?? []) {
308
323
  const path = typeof file === 'string' ? file : file?.path;
309
- if (path && agent) touched.set(String(path).replace(/^\.?\//, ''), agent);
324
+ if (!path || !agent) continue;
325
+ const key = String(path).replace(/^\.?\//, '');
326
+ if ((touched.get(key)?.at ?? 0) <= when) touched.set(key, { agent, at: when });
310
327
  }
328
+ if (!tree.open) tree.stale = true;
329
+ }
330
+ // The newest touch under a folder, for its dot.
331
+ function touchedUnder(dir) {
332
+ const prefix = `${dir}/`;
333
+ let newest = null;
334
+ for (const [path, touch] of touched) if (path.startsWith(prefix) && (!newest || touch.at > newest.at)) newest = { ...touch, path };
335
+ return newest;
311
336
  }
312
337
 
313
338
 
@@ -365,19 +390,19 @@ const tokensFor = (chars) => Math.round((Number(chars) || 0) / CH_PER_TOKEN);
365
390
  // room's language: what was written down never moves. Declared one by one, so the catalogue's
366
391
  // guard sees all five and an id nobody translated cannot slip past as itself. Up here with the
367
392
  // colours and for the same reason: the first replay prints the word before NOSTROMO is evaluated.
368
- const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('ABERRATION') };
393
+ const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('DOUBTFUL MEMORY') };
369
394
  const kindWord = (kind) => KIND_WORDS[kind] ?? String(kind).toUpperCase();
370
395
 
371
396
  // Where a memory stands aboard, which is a different question from what it is — so it is said in
372
397
  // words and not in a second hue: the map already spends colour on the kind, and two palettes on
373
398
  // one dot is how a map stops being readable. What a zone changes on the map is whether the star
374
399
  // is lit at all, because that is what the zone means.
375
- const ZONE_WORDS = { hold: t('HOLD'), bridge: t('BRIDGE'), medbay: t('MED BAY'), jettisoned: t('JETTISONED') };
400
+ const ZONE_WORDS = { hold: t('UNCONFIRMED'), bridge: t('CONFIRMED'), medbay: t('QUARANTINED'), jettisoned: t('DISCARDED') };
376
401
  const ZONE_NOTE = {
377
- hold: t('Distilled and nobody has judged it. It travels into turns.'),
378
- bridge: t('You marked the reply it came from as good. It travels, confirmed.'),
379
- medbay: t('Quarantined: it is false, or it came from a reply you marked bad. It never travels.'),
380
- jettisoned: t('Put out of circulation and kept so it is not learned again. It never travels.'),
402
+ hold: t('A summarized note nobody has reviewed yet. It is used in answers.'),
403
+ bridge: t('You marked the reply it came from as good. Confirmed: it is used in answers.'),
404
+ medbay: t('Quarantined: it is false, or it came from a reply you marked bad. It is never used in answers.'),
405
+ jettisoned: t('Discarded: kept only so it is not learned again. It is never used in answers.'),
381
406
  };
382
407
  const zoneWord = (zone) => ZONE_WORDS[zone] ?? String(zone ?? '').toUpperCase();
383
408
 
@@ -553,19 +578,31 @@ function projectRelative(path) {
553
578
  }
554
579
 
555
580
  // Plain text with file paths turned into viewer links.
581
+ // A memory cited by its sequence — [#214], [#64–#1431], [pulse · decision · #1928–#2141] — is a
582
+ // piece of its own (2026-10-15), so it can be shown as a reference; its words stay exactly as written.
583
+ const CITE = /\[(?:[^\]\n]{0,60}·\s*)?#\d+(?:\s*[–-]\s*#?\d+)?\]/g;
584
+ function appendCites(text, into) {
585
+ let last = 0;
586
+ for (const match of text.matchAll(CITE)) {
587
+ if (match.index > last) into.append(text.slice(last, match.index));
588
+ into.append(el('span', 'cite', match[0]));
589
+ last = match.index + match[0].length;
590
+ }
591
+ if (last < text.length) into.append(text.slice(last));
592
+ }
556
593
  function appendTextWithPaths(text, into) {
557
594
  let last = 0;
558
595
  for (const match of text.matchAll(FILE_PATH)) {
559
596
  const rel = projectRelative(match[1]);
560
597
  if (!rel) continue;
561
- if (match.index > last) into.append(text.slice(last, match.index));
598
+ if (match.index > last) appendCites(text.slice(last, match.index), into);
562
599
  const link = el('a', 'file-link', match[0]);
563
600
  link.href = `/api/files?path=${encodeURIComponent(rel)}`;
564
601
  link.addEventListener('click', (event) => { event.preventDefault(); openViewer({ root: 'project', path: rel, line: match[2] ? Number(match[2]) : null }); });
565
602
  into.append(link);
566
603
  last = match.index + match[0].length;
567
604
  }
568
- if (last < text.length) into.append(text.slice(last));
605
+ if (last < text.length) appendCites(text.slice(last), into);
569
606
  }
570
607
 
571
608
  // A picture of the project is drawn; one from elsewhere is not fetched until someone asks (WEB-01):
@@ -1118,8 +1155,8 @@ const editorDirty = () => Boolean(editing.editor?.dirty());
1118
1155
  let lastAsk = null;
1119
1156
  const askLeaveEdits = () => roomAsk({
1120
1157
  title: t('UNSAVED CHANGES'),
1121
- body: t('There are unsaved changes in this file. Leave them?'),
1122
- choices: [{ value: 'stay', label: t('KEEP EDITING'), focus: true }, { value: 'leave', label: t('LEAVE THEM'), tone: 'danger' }],
1158
+ body: t('There are unsaved changes in this file. Discard them?'),
1159
+ choices: [{ value: 'stay', label: t('KEEP EDITING'), focus: true }, { value: 'leave', label: t('DISCARD THEM'), tone: 'danger' }],
1123
1160
  });
1124
1161
  async function leaveEditor({ ask = true } = {}) {
1125
1162
  if (!editing.editor) return true;
@@ -1159,7 +1196,7 @@ function syncEditButton() {
1159
1196
  if (!viewer.dialog || !viewer.body) return;
1160
1197
  const bar = el('div', 'viewer-status');
1161
1198
  const mod = /Mac|iPhone|iPad/.test(navigator.platform ?? '') ? '⌘' : 'Ctrl';
1162
- if (viewerUI.hintSelect) viewerUI.hintSelect.textContent = t('Click a number to pick a line · drag for a run · Shift for a range · {mod} for one more', { mod });
1199
+ if (viewerUI.hintSelect) viewerUI.hintSelect.textContent = t('Click a number to pick a line · drag to pick several · Shift for a range · {mod} to add one more', { mod });
1163
1200
  for (const part of [viewerUI.selection, viewerUI.review, viewerUI.hintSelect]) if (part) bar.append(part);
1164
1201
  viewer.body.after?.(bar);
1165
1202
  })();
@@ -1497,7 +1534,7 @@ function browserCloseTab(id) {
1497
1534
  function browserRefused(url, { why } = {}) {
1498
1535
  if (!browser.body) return;
1499
1536
  const box = el('div', 'browser-refused');
1500
- box.append(el('h4', null, t('{host} will not be framed', { host: browserTitleFor(url) })));
1537
+ box.append(el('h4', null, t('{host} does not allow being shown inside MADRE', { host: browserTitleFor(url) })));
1501
1538
  box.append(el('p', null, why ?? t('The site answers with a rule that forbids any page from putting it inside a frame. It is the standard defence against clickjacking, and PREVIEW neither can nor should override it.')));
1502
1539
  const open = el('button', 'browser-go', t('OPEN IN A TAB'));
1503
1540
  open.type = 'button';
@@ -1602,7 +1639,7 @@ async function browserServers() {
1602
1639
  portsMenuShown(true);
1603
1640
  let read;
1604
1641
  try { read = await fetch('/api/ports').then((response) => response.json()); }
1605
- catch { menu.replaceChildren(el('div', 'ports-note', t('COULD NOT READ THE PORTS'))); browser.servers?.setAttribute('data-live', 'no'); return; }
1642
+ catch { menu.replaceChildren(el('div', 'ports-note', t('COULD NOT SEE WHICH SERVERS ARE RUNNING'))); browser.servers?.setAttribute('data-live', 'no'); return; }
1606
1643
  paintPorts(read);
1607
1644
  }
1608
1645
  async function refreshBrowserServers(button) {
@@ -1679,7 +1716,7 @@ function paintPorts(read) {
1679
1716
  // A server that sends no Content-Type used to leave the separator hanging: «:5173 node · 200 · ».
1680
1717
  const kind = found.html ? '' : (found.contentType ?? '').trim();
1681
1718
  const what = `${found.command} · ${found.status}${kind ? ` · ${kind}` : ''}`;
1682
- row.append(el('i', 'ports-lamp'), el('b', null, `:${found.port}`), el('span', null, found.framable === false ? t('{what} · refuses to be framed', { what }) : what));
1719
+ row.append(el('i', 'ports-lamp'), el('b', null, `:${found.port}`), el('span', null, found.framable === false ? t('{what} · cannot be shown in here', { what }) : what));
1683
1720
  row.addEventListener('click', () => openPort(found));
1684
1721
  menu.append(row);
1685
1722
  }
@@ -1687,12 +1724,12 @@ function paintPorts(read) {
1687
1724
  // about it made a running server read as no server at all.
1688
1725
  for (const one of wider) {
1689
1726
  const row = el('div', 'ports-row wider');
1690
- row.append(el('i', 'ports-lamp'), el('b', null, `:${one.port}`), el('span', null, t('{command} · listens on the whole network · start it on 127.0.0.1 to open it here', { command: one.command })));
1727
+ row.append(el('i', 'ports-lamp'), el('b', null, `:${one.port}`), el('span', null, t('{command} · open to your whole network · start it on 127.0.0.1 to open it here', { command: one.command })));
1691
1728
  menu.append(row);
1692
1729
  }
1693
1730
  const foot = read?.others
1694
1731
  ? t('{n} more listening on this computer, from other folders · not shown', { n: read.others })
1695
- : t('ONLY WHAT THIS PROJECT STARTED · THE SENTINEL WATCHES IT');
1732
+ : t('ONLY WHAT THIS PROJECT STARTED · THE FAILURE MONITOR WATCHES IT');
1696
1733
  menu.append(el('div', 'ports-note', foot));
1697
1734
  }
1698
1735
 
@@ -1726,7 +1763,7 @@ function pointTool(id) {
1726
1763
  pointClear();
1727
1764
  for (const button of pointing.layer?.querySelectorAll('.point-tool') ?? []) button.setAttribute('aria-pressed', String(button.dataset.tool === id));
1728
1765
  pointing.layer?.classList.toggle('area', id === 'area');
1729
- if (pointing.hint) pointing.hint.textContent = id === 'dot' ? t('Press on the page where you mean') : t('Drag over the part you mean');
1766
+ if (pointing.hint) pointing.hint.textContent = id === 'dot' ? t('Click on the page where you want to point') : t('Drag over the part you mean');
1730
1767
  }
1731
1768
  function setPointing(on) {
1732
1769
  if (on === pointing.on) return;
@@ -1934,7 +1971,7 @@ function serverStopped({ port, command }) {
1934
1971
  browser.body.querySelector('.browser-down')?.remove();
1935
1972
  const card = el('div', 'browser-down');
1936
1973
  const inner = el('div', 'browser-down-card');
1937
- inner.append(el('i', 'ports-lamp off'), el('h4', null, t(':{port} stopped', { port })), el('p', null, t('The sentinel saw {command} stop answering. Start it again and this page comes back on its own.', { command })));
1974
+ inner.append(el('i', 'ports-lamp off'), el('h4', null, t(':{port} stopped', { port })), el('p', null, t('The failure monitor saw {command} stop answering. Start it again and this page comes back on its own.', { command })));
1938
1975
  card.append(inner);
1939
1976
  browser.body.append(card);
1940
1977
  }
@@ -1978,7 +2015,7 @@ browserGrip?.addEventListener('pointerdown', (event) => {
1978
2015
  }, { once: true });
1979
2016
  });
1980
2017
 
1981
- browser.button?.addEventListener('click', () => { if (browser.dialog && !browser.dialog.hidden) browserHide(); else browserShow(); });
2018
+ browser.button?.addEventListener('click', () => { if (browser.dialog && !browser.dialog.hidden && !atTheEdge(browser.dialog)) browserHide(); else browserShow(); });
1982
2019
  browser.close?.addEventListener('click', () => browserHide());
1983
2020
  browser.servers?.addEventListener('click', () => { void browserServers(); });
1984
2021
  // A list left hanging over the page is the kind of thing a human closes by clicking away from it.
@@ -2382,7 +2419,7 @@ function renderAgents() {
2382
2419
  });
2383
2420
  node.title = [
2384
2421
  `${agent.label}${vendorWord(agent.id) ? ` · ${vendorWord(agent.id)}` : ''}`,
2385
- agent.ready ? t('Ready') : agent.detected ? t('Detected, adapter pending') : t('Not installed'),
2422
+ agent.ready ? t('Ready') : agent.detected ? t('Detected, but MADRE cannot use it yet') : t('Not installed'),
2386
2423
  agent.version ? agent.version : null,
2387
2424
  official === null
2388
2425
  ? (agent.tokens ? t('Usage: local 5h window · {pct}% of {total} budget tokens', { pct: Math.round(percent), total: formatTokens(state.budget ?? 0) }) : t('Usage: local window · nothing used yet'))
@@ -2462,7 +2499,7 @@ function renderPicker() {
2462
2499
  if (current.local) {
2463
2500
  const note = el('span', 'pick-note', t('memory · never writes'));
2464
2501
  const measured = state.localModel?.checked;
2465
- note.title = `${t('@madre runs on this machine and speaks for what the project remembers. "@madre, ask the agents …" opens a round with every agent online. To change files, write to a CLI agent.')}${
2502
+ note.title = `${t('@madre runs on this machine and speaks for what the project remembers. "@madre, ask the agents …" opens a round with every agent online. To change files, write to one of the coding agents.')}${
2466
2503
  measured ? `\n\n${t('Measured against this project {when}: it landed where the agents landed on {matched} of {n} real questions{verdict}', { when: agoWords(measured.at), matched: measured.matched, n: measured.n, verdict: measured.passed ? t('. Ready to be worked in.') : t(' — not yet.') })}` : `\n\n${t('Nobody has measured it against this project yet: Diagnostics → Memory review, or the line in the project when it joined.')}`}`;
2467
2504
  text.append(note);
2468
2505
  }
@@ -2473,7 +2510,7 @@ function renderPicker() {
2473
2510
  modeChip.type = 'button';
2474
2511
  modeChip.append(el('b', null, `#${shown}`), el('span', 'word', MODES[shown].label), chevron());
2475
2512
  modeChip.title = state.typedPending
2476
- ? t('#{n} {label} is written in the message: its keys are asked for when you send it.', { n: shown, label: MODES[shown].label })
2513
+ ? t('#{n} {label} is written in the message: you will be asked to confirm it when you send it.', { n: shown, label: MODES[shown].label })
2477
2514
  : t('Permission mode for this message · {hint}', { hint: MODES[state.mode].hint });
2478
2515
  modeChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModeMenu(current.id, modeChip); });
2479
2516
  text.append(modeChip);
@@ -2485,9 +2522,9 @@ function renderPicker() {
2485
2522
  grace.type = 'button';
2486
2523
  grace.dataset.mode = String(mode);
2487
2524
  grace.dataset.agent = current.id;
2488
- grace.title = t('{label} does not ask for its key again for @{agent} until this runs out. Click to close it now.', { label: MODES[mode].label, agent: current.id });
2525
+ grace.title = t('{label} does not ask you to confirm again for @{agent} until this runs out. Click to end it now.', { label: MODES[mode].label, agent: current.id });
2489
2526
  grace.append(el('b', null, `#${mode}`), el('span', null, t('open')), el('span', 'left', graceWords(left)), el('i', null, '×'));
2490
- grace.addEventListener('click', (event) => { event.stopPropagation(); closeOverrideGrace(mode, current.id); toast(t('Diagnostics › {label} closed for @{agent}: the next one asks for its key.', { label: MODES[mode].label, agent: current.id }), { tone: 'done' }); });
2527
+ grace.addEventListener('click', (event) => { event.stopPropagation(); closeOverrideGrace(mode, current.id); toast(t('Diagnostics › {label} locked again for @{agent}: the next message asks you to confirm again.', { label: MODES[mode].label, agent: current.id }), { tone: 'done' }); });
2491
2528
  text.append(grace);
2492
2529
  break;
2493
2530
  }
@@ -2506,6 +2543,7 @@ function renderPicker() {
2506
2543
  }
2507
2544
  const effortChip = renderEffortChip(current.id);
2508
2545
  if (effortChip) text.append(effortChip);
2546
+ if (!current.local && !state.device) text.append(copilotChip());
2509
2547
  (els.pickOptions ?? els.picker).append(text);
2510
2548
  }
2511
2549
  updateCrewLabel();
@@ -2610,6 +2648,7 @@ document.addEventListener('keydown', (event) => { if (event.key === 'Escape') cl
2610
2648
  // dimmed when MADRE can read it (Codex's config.toml), so the human sees what they will get
2611
2649
  // without having chosen it.
2612
2650
  const EFFORT_WORDS = {
2651
+ none: { label: 'None', hint: 'No reasoning: it answers straight away. For the simplest requests.' },
2613
2652
  minimal: { label: 'Minimal', hint: 'Answers almost at once. For lookups and one-line edits.' },
2614
2653
  low: { label: 'Low', hint: 'Quick and light. For clear, contained requests.' },
2615
2654
  medium: { label: 'Medium', hint: 'The everyday balance of speed and depth.' },
@@ -2647,7 +2686,8 @@ function effortInfo(id) {
2647
2686
  function ensureEfforts() {
2648
2687
  if (effortsAsked) return;
2649
2688
  effortsAsked = true;
2650
- fetch('/api/models').then((response) => response.json()).then((data) => { takeEfforts(data.models); renderPicker(); }).catch(() => {});
2689
+ // OpenCode's rungs are its models': with one already chosen, its list is read too (slower).
2690
+ fetch(`/api/models${state.chosenModel?.opencode ? '?opencode=1' : ''}`).then((response) => response.json()).then((data) => { takeEfforts(data.models); renderPicker(); }).catch(() => {});
2651
2691
  }
2652
2692
 
2653
2693
  function effortMeter(levels, level, { ghost = false } = {}) {
@@ -2884,8 +2924,111 @@ function closeModeMenu() { modeMenu.hidden = true; modeMenuFor = null; }
2884
2924
  document.addEventListener('click', (event) => { if (!modeMenu.hidden && !modeMenu.contains(event.target) && !event.target.closest('.picker')) closeModeMenu(); });
2885
2925
  document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeModeMenu(); });
2886
2926
 
2927
+ // The copilot (2026-10-14): a while — up to 24 hours, opened with the #3 key — in which a message
2928
+ // at #3 needs no key, whichever agent it goes to, as long as its CLI can write. The project holds
2929
+ // the window (GET/POST /api/copilot); the page only shows it and counts it down.
2930
+ const COPILOT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3.4v2.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="10" cy="3" r="1.05" fill="currentColor"/><rect x="4.2" y="6.2" width="11.6" height="9.2" rx="3.1" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="7.7" cy="10.3" r="1.15" fill="currentColor"/><circle cx="12.3" cy="10.3" r="1.15" fill="currentColor"/><path d="M8.1 13h3.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M2.5 9.6v2.6M17.5 9.6v2.6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
2931
+ const copilotUI = { active: false, until: 0, windows: [30, 60, 240, 480, 1440], chosen: 60, tick: null };
2932
+ const copilotOn = () => copilotUI.active && copilotUI.until > Date.now();
2933
+ const canWrite = (id) => Boolean(state.capabilities[id]?.scopes?.write?.capable);
2934
+ // The copilot's #3 for this message: the window open, this agent able to write, #3 chosen.
2935
+ const copilotSends = (id) => state.mode === 3 && copilotOn() && canWrite(id);
2936
+ const copilotWindowWords = (minutes) => (minutes >= 60 ? t('{n} h', { n: minutes / 60 }) : t('{n} min', { n: minutes }));
2937
+ function copilotLeftWords() {
2938
+ const minutes = Math.max(1, Math.ceil((copilotUI.until - Date.now()) / 60000));
2939
+ return minutes >= 60 ? t('{n} h', { n: Math.round(minutes / 6) / 10 }) : t('{n} min', { n: minutes });
2940
+ }
2941
+ function applyCopilot(said = {}) {
2942
+ const was = copilotOn();
2943
+ copilotUI.active = Boolean(said.active);
2944
+ copilotUI.until = Number(said.until) || 0;
2945
+ if (Array.isArray(said.windows) && said.windows.length) copilotUI.windows = said.windows;
2946
+ clearInterval(copilotUI.tick);
2947
+ copilotUI.tick = null;
2948
+ if (copilotOn()) {
2949
+ copilotUI.tick = setInterval(() => {
2950
+ if (!copilotOn()) { applyCopilot({ active: false }); return; }
2951
+ for (const chip of document.querySelectorAll('.copilot-chip .left')) chip.textContent = copilotLeftWords();
2952
+ }, 30000);
2953
+ }
2954
+ // The mode follows: #3 while it lasts, the agent's own default after.
2955
+ if (was !== copilotOn() && els.target?.value) { state.modeArmedFor = null; setMode(defaultModeFor(els.target.value)); }
2956
+ else renderPicker();
2957
+ if (!copilotMenu.hidden) toggleCopilotMenu(copilotMenu.anchor, { keep: true });
2958
+ }
2959
+ async function loadCopilot() {
2960
+ try { applyCopilot(await (await fetch('/api/copilot')).json()); } catch { /* an older project: no copilot */ }
2961
+ }
2887
2962
  function ceilingFor(id) { return state.capabilities[id]?.scopes?.maxMode ?? 1; }
2888
- function defaultModeFor(id) { return state.capabilities[id]?.scopes?.defaultMode ?? 1; }
2963
+ function defaultModeFor(id) { return copilotOn() && canWrite(id) ? 3 : state.capabilities[id]?.scopes?.defaultMode ?? 1; }
2964
+
2965
+ // The robot beside mode · model · effort: what the copilot does, its switch and how long.
2966
+ const copilotMenu = el('div', 'model-menu copilot-menu');
2967
+ copilotMenu.hidden = true;
2968
+ copilotMenu.setAttribute('role', 'dialog');
2969
+ document.body.append(copilotMenu);
2970
+ function closeCopilotMenu() { copilotMenu.hidden = true; }
2971
+ document.addEventListener('click', (event) => { if (!copilotMenu.hidden && !copilotMenu.contains(event.target) && !event.target.closest('.copilot-chip') && !event.target.closest('#override')) closeCopilotMenu(); });
2972
+ document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeCopilotMenu(); });
2973
+ function copilotChip() {
2974
+ const on = copilotOn();
2975
+ const chip = el('button', `copilot-chip${on ? ' on' : ''}`);
2976
+ chip.type = 'button';
2977
+ const icon = el('span', 'copilot-icon');
2978
+ icon.innerHTML = COPILOT_ICON;
2979
+ chip.append(icon);
2980
+ if (on) chip.append(el('span', 'left', copilotLeftWords()));
2981
+ chip.title = on ? t('Copilot on · {left} left. Click to see it or turn it off.', { left: copilotLeftWords() }) : t('Copilot: let the agent you write to drive the project for a while.');
2982
+ chip.setAttribute('aria-label', on ? t('Copilot on') : t('Copilot'));
2983
+ chip.setAttribute('aria-haspopup', 'dialog');
2984
+ chip.addEventListener('click', (event) => { event.stopPropagation(); toggleCopilotMenu(chip); });
2985
+ return chip;
2986
+ }
2987
+ function toggleCopilotMenu(anchor, { keep = false } = {}) {
2988
+ if (!keep && !copilotMenu.hidden) { closeCopilotMenu(); return; }
2989
+ closeModelMenu(); closeEffortMenu(); closeModeMenu();
2990
+ copilotMenu.anchor = anchor;
2991
+ const on = copilotOn();
2992
+ const title = el('div', 'model-menu-title copilot-title');
2993
+ const icon = el('span', 'copilot-icon');
2994
+ icon.innerHTML = COPILOT_ICON;
2995
+ title.append(icon, el('b', null, t('Copilot')));
2996
+ const toggle = el('label', 'settings-switch');
2997
+ const box = el('input');
2998
+ box.type = 'checkbox';
2999
+ box.checked = on;
3000
+ box.setAttribute('aria-label', t('Copilot'));
3001
+ box.addEventListener('change', async () => {
3002
+ if (box.checked) { box.checked = false; openCopilotKey(copilotUI.chosen); return; }
3003
+ try { applyCopilot(await (await fetch('/api/copilot/off', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).json()); toast(t('Copilot off. Each #3 message asks you to confirm again.'), { tone: 'done' }); } catch (error) { toast(t('Could not reach MADRE: {error}', { error: error.message })); }
3004
+ });
3005
+ toggle.append(box, el('i'));
3006
+ title.append(toggle);
3007
+ const body = el('div', 'copilot-body');
3008
+ body.append(
3009
+ el('p', 'copilot-lead', t('The agent you write to drives the project: it edits, makes plans with the other agents (each step can go up to #3 EDIT), and runs the safe commands — reading, tests, builds, local git — on its own.')),
3010
+ el('p', 'copilot-never', t('It never deletes, never touches MADRE\'s folders or your secrets, and never pushes, publishes or sends anything off this computer: that stays with you.')),
3011
+ );
3012
+ if (on) {
3013
+ body.append(el('p', 'copilot-state', t('On until {time} · {left} left', { time: new Date(copilotUI.until).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), left: copilotLeftWords() })));
3014
+ } else {
3015
+ const how = el('div', 'copilot-how');
3016
+ how.append(el('span', null, t('For')), sxSeg(copilotUI.windows.map((minutes) => ({ value: String(minutes), text: copilotWindowWords(minutes) })), String(copilotUI.chosen), (value) => { copilotUI.chosen = Number(value); }, { label: t('How long the copilot lasts') }));
3017
+ body.append(how, el('p', 'copilot-foot', t('It asks you to confirm #3 only once. STOP ALL ends it.')));
3018
+ }
3019
+ copilotMenu.replaceChildren(title, body);
3020
+ copilotMenu.hidden = false;
3021
+ const rect = anchor.getBoundingClientRect();
3022
+ const width = copilotMenu.offsetWidth || 320;
3023
+ const height = copilotMenu.offsetHeight || 220;
3024
+ copilotMenu.style.left = `${Math.max(12, Math.min(window.innerWidth - width - 12, rect.left))}px`;
3025
+ copilotMenu.style.top = `${Math.max(12, rect.top - height - 10)}px`;
3026
+ }
3027
+ // Turning it on is the #3 key, once, in the same dialog as always.
3028
+ function openCopilotKey(minutes) {
3029
+ closeCopilotMenu();
3030
+ openOverride(els.target.value, { mode: 3, copilot: minutes, forceKey: true });
3031
+ }
2889
3032
 
2890
3033
  function toggleModeMenu(id, anchor) {
2891
3034
  if (!modeMenu.hidden && modeMenuFor === id) { closeModeMenu(); return; }
@@ -2894,14 +3037,15 @@ function toggleModeMenu(id, anchor) {
2894
3037
  modeMenuFor = id;
2895
3038
  paint(modeMenu, id);
2896
3039
  modeMenu.replaceChildren();
2897
- const cap = ceilingFor(id);
3040
+ // Under the copilot, #3 is open for any agent whose CLI writes.
3041
+ const cap = copilotOn() && canWrite(id) ? Math.max(ceilingFor(id), 3) : ceilingFor(id);
2898
3042
  const scopes = state.capabilities[id]?.scopes;
2899
3043
  const canRaise = Boolean(scopes?.write?.capable);
2900
3044
  const title = el('div', 'model-menu-title');
2901
3045
  // Short enough to sit on one line at any width the menu takes. That it applies to this message
2902
3046
  // and not to every one after it is the mode chip's own tooltip, and the list below is literally
2903
3047
  // the modes — so the header carries the two facts nothing else here does: who, and how far.
2904
- title.append(el('b', null, `@${id}`), t(' · ceiling #{cap}', { cap }));
3048
+ title.append(el('b', null, `@${id}`), t(' · max mode #{cap}', { cap }));
2905
3049
  title.title = t('Permission mode for this message · {hint}', { hint: MODES[cap].hint });
2906
3050
  modeMenu.append(title);
2907
3051
  const ladder = el('div', 'mode-ladder');
@@ -2917,7 +3061,7 @@ function toggleModeMenu(id, anchor) {
2917
3061
  body.append(el('span', 'name', MODES[n].label), el('span', 'hint', MODES[n].hint));
2918
3062
  option.append(el('span', 'n', `#${n}`), body);
2919
3063
  if (onTheMac) option.title = t('#3 EDIT and #4 FULL ACCESS are opened from the Mac.');
2920
- const tag = state.mode === n ? t('NOW') : onTheMac ? t('ON THE MAC') : raise ? t('RAISE TO #{n} ›', { n }) : locked ? t('LOCKED') : n === 4 ? t('KEY ×2') : n === 3 ? t('KEY') : n === defaultModeFor(id) ? t('DEFAULT') : '';
3064
+ const tag = state.mode === n ? t('NOW') : onTheMac ? t('ON THE MAC') : raise ? t('RAISE TO #{n} ›', { n }) : locked ? t('LOCKED') : n === 4 ? t('CONFIRM ×2') : n === 3 ? t('CONFIRM') : n === defaultModeFor(id) ? t('DEFAULT') : '';
2921
3065
  if (tag) {
2922
3066
  const label = el('span', `tag${raise ? ' raise' : ''}`, tag);
2923
3067
  if (state.mode === n) label.append(el('span', 'dot'));
@@ -2929,6 +3073,7 @@ function toggleModeMenu(id, anchor) {
2929
3073
  : t("Above @{id}'s max mode (#{cap}). Raise it in Settings › Permissions.", { id, cap });
2930
3074
  option.addEventListener('click', () => {
2931
3075
  closeModeMenu();
3076
+ if (n === 3 && copilotOn() && canWrite(id)) { setMode(3); return; }
2932
3077
  if (n >= 3) { openOverride(id, { raise, mode: n }); return; }
2933
3078
  setMode(n, { wink: n === 0 });
2934
3079
  });
@@ -2956,6 +3101,7 @@ function setMode(n, { wink = false, typed = false } = {}) {
2956
3101
  els.composer.classList.toggle('ghost', mode === 0);
2957
3102
  els.composer.classList.toggle('control', mode >= 3);
2958
3103
  els.composer.classList.toggle('airlock', mode === 4);
3104
+ els.composer.classList.toggle('copilot', mode === 3 && copilotOn());
2959
3105
  for (const n of [0, 1, 2, 3, 4]) els.composer.classList.toggle(`m${n}`, mode === n);
2960
3106
  renderCreateScopes();
2961
3107
  updateCrewLabel(); updatePlaceholder();
@@ -3027,6 +3173,7 @@ const override = {
3027
3173
  cancelled: null, // called when the human walks away from it, to put back what they wrote
3028
3174
  raise: false, // the agent is capped below the mode: arming also raises its max mode in Settings › Permissions
3029
3175
  mode: 3, // #3 CONTROL or #4 AIRLOCK
3176
+ minutes: 0, // a window chosen on an agent's request; 0 is what Permissions says
3030
3177
  preserveComposer: false,
3031
3178
  stage: 'designation', // designation · phrase · confirm; #4 asks for the same key a second time
3032
3179
  again: false, // the second time, for #4
@@ -3104,13 +3251,17 @@ function askOverrideKey(stage, { again = false } = {}) {
3104
3251
  override.form.querySelector('.k').textContent = stage === 'phrase' ? (again ? t('Type your phrase again') : t('Type your phrase')) : again ? t('Type the project name again') : t('Type the project name');
3105
3252
  override.input.placeholder = '';
3106
3253
  }
3107
- function openOverride(id, { raise = false, mode = 3, then = null, cancelled = null, preserveComposer = false, forceKey = false } = {}) {
3254
+ function openOverride(id, { raise = false, mode = 3, then = null, cancelled = null, preserveComposer = false, forceKey = false, minutes = 0, copilot = 0 } = {}) {
3108
3255
  if (!override.dialog) return;
3109
3256
  // Inside the grace of an accepted key, the same mode for the same agent is not asked again —
3110
3257
  // unless arming would also raise the agent's ceiling, which is always asked.
3111
3258
  const left = raise || forceKey ? 0 : overrideGraceLeft(mode, id);
3112
3259
  if (left) { void grantOverride({ agent: id, raise: false, mode, then, delay: 0, left, preserveComposer }); return; }
3113
3260
  override.agent = id;
3261
+ // The copilot's key: the same #3 key, opening the copilot for that long instead of one agent.
3262
+ override.copilot = copilot || 0;
3263
+ // A window chosen on an agent's request, instead of the one set in Permissions.
3264
+ override.minutes = MODE_WINDOWS[mode]?.includes(minutes) ? minutes : 0;
3114
3265
  // What to do once it is armed. The chip's road arms and waits for the human to send; a #3 or #4
3115
3266
  // typed in a message that was sent, and the way up on an agent's card, send right away.
3116
3267
  override.then = then;
@@ -3121,16 +3272,16 @@ function openOverride(id, { raise = false, mode = 3, then = null, cancelled = nu
3121
3272
  override.preserveComposer = preserveComposer;
3122
3273
  override.key = overrideKeyFor(mode);
3123
3274
  askOverrideKey(override.key.startsWith('phrase') ? 'phrase' : override.key === 'confirm' ? 'confirm' : 'designation');
3124
- override.title.textContent = mode === 4 ? t('Open #4 FULL ACCESS for @{agent}', { agent: id }) : t('Unlock #3 EDIT for @{agent}', { agent: id });
3125
- override.arm.textContent = mode === 4 ? t('Open') : t('Unlock');
3275
+ override.title.textContent = override.copilot ? t('Turn the copilot on for {time}', { time: copilotWindowWords(override.copilot) }) : mode === 4 ? t('Open #4 FULL ACCESS for @{agent}', { agent: id }) : t('Unlock #3 EDIT for @{agent}', { agent: id });
3276
+ override.arm.textContent = override.copilot ? t('Turn on') : mode === 4 ? t('Open') : t('Unlock');
3126
3277
  override.dialog.classList.toggle('m4', mode === 4);
3127
3278
  if (mode === 4) {
3128
3279
  let slot = override.frame?.querySelector('.tour-hint-slot');
3129
3280
  if (!slot && override.form) { slot = el('div', 'tour-hint-slot'); override.form.before(slot); }
3130
- if (slot) void roomTour().hint('key4', slot, { inside: true, title: t('#4 asks for your key twice'), text: t('With #4 the agent may run commands, publish and deploy: what leaves your Mac does not come back. That is why the key is typed twice.') });
3281
+ if (slot) void roomTour().hint('key4', slot, { inside: true, title: t('#4 asks you to confirm twice'), text: t('With #4 the agent may run commands, publish and deploy: what leaves your Mac does not come back. That is why the confirmation is typed twice.') });
3131
3282
  }
3132
- override.brief.textContent = OVERRIDE_BRIEF[mode].what(id);
3133
- override.safe.textContent = OVERRIDE_BRIEF[mode].safe();
3283
+ override.brief.textContent = override.copilot ? t('The agent you write to edits the project and runs the safe commands by itself, without asking you to confirm each message, until it ends.') : OVERRIDE_BRIEF[mode].what(id);
3284
+ override.safe.textContent = override.copilot ? t('It never deletes, pushes, publishes or deploys; each change gets a restore point; STOP ALL ends it.') : OVERRIDE_BRIEF[mode].safe();
3134
3285
  override.raised.hidden = !raise;
3135
3286
  override.raised.textContent = raise ? t('This also raises its max mode to #{n}.', { n: mode }) : '';
3136
3287
  override.reply.textContent = '';
@@ -3171,7 +3322,9 @@ override.form?.addEventListener('submit', async (event) => {
3171
3322
  override.arm.disabled = true;
3172
3323
  let answer;
3173
3324
  try {
3174
- const response = await fetch('/api/override/arm', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: override.agent, mode: override.mode, words: override.words }) });
3325
+ const response = override.copilot
3326
+ ? await fetch('/api/copilot', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ minutes: override.copilot, words: override.words }) })
3327
+ : await fetch('/api/override/arm', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: override.agent, mode: override.mode, words: override.words, minutes: override.minutes || undefined }) });
3175
3328
  answer = { ok: response.ok, ...(await response.json().catch(() => ({}))) };
3176
3329
  } catch (error) {
3177
3330
  answer = { ok: false, error: t('Could not reach MADRE: {error}', { error: error.message }) };
@@ -3182,15 +3335,24 @@ override.form?.addEventListener('submit', async (event) => {
3182
3335
  deny(answer.error ? resay(answer.error) : undefined);
3183
3336
  return;
3184
3337
  }
3338
+ if (override.copilot) {
3339
+ override.reply.textContent = t('Copilot on.');
3340
+ override.reply.className = 'key-reply granted';
3341
+ override.copilot = 0;
3342
+ override.then = null;
3343
+ override.cancelled = null;
3344
+ setTimeout(() => { if (override.dialog?.open) override.dialog.close(); applyCopilot(answer); toast(t('Copilot on for {time}. Write to the agent that should drive.', { time: copilotWindowWords(Math.round((answer.until - Date.now()) / 60000 / 30) * 30 || copilotUI.chosen) }), { tone: 'done' }); }, 700);
3345
+ return;
3346
+ }
3185
3347
  override.reply.textContent = override.mode === 4 ? t('Open for @{agent}.', { agent: override.agent }) : t('Unlocked for @{agent}.', { agent: override.agent });
3186
3348
  override.reply.className = 'key-reply granted';
3187
- const granted = { agent: override.agent, raise: override.raise, mode: override.mode, then: override.then, delay: 900, pass: answer.token, preserveComposer: override.preserveComposer };
3349
+ const granted = { agent: override.agent, raise: override.raise, mode: override.mode, then: override.then, delay: 900, pass: answer.token, preserveComposer: override.preserveComposer, minutes: override.minutes };
3188
3350
  override.then = null;
3189
3351
  override.cancelled = null;
3190
3352
  void grantOverride(granted);
3191
3353
  });
3192
3354
  // Arms the mode for that agent: after the key, or straight away inside the grace of an earlier one.
3193
- async function grantOverride({ agent, raise, mode, then, delay = 0, left = 0, pass = null, preserveComposer = false }) {
3355
+ async function grantOverride({ agent, raise, mode, then, delay = 0, left = 0, pass = null, preserveComposer = false, minutes: chosen = 0 }) {
3194
3356
  if (pass) overridePasses.set(`${mode}:${agent}`, pass);
3195
3357
  if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
3196
3358
  if (override.dialog?.open) override.dialog.close();
@@ -3203,7 +3365,7 @@ async function grantOverride({ agent, raise, mode, then, delay = 0, left = 0, pa
3203
3365
  return;
3204
3366
  }
3205
3367
  }
3206
- const minutes = overrideConfig().grace?.[mode] ?? 0;
3368
+ const minutes = chosen || (overrideConfig().grace?.[mode] ?? 0);
3207
3369
  if (!left && minutes > 0) { overrideGrace.set(`${mode}:${agent}`, Date.now() + minutes * 60000); tickGrace(); }
3208
3370
  if (!preserveComposer) {
3209
3371
  state.modeArmedFor = agent;
@@ -3357,7 +3519,7 @@ function bridgeCard(agent) {
3357
3519
  // What is behind this door: the account it needs, and whether there is a way in without paying.
3358
3520
  if (agent.account) {
3359
3521
  const account = el('div', 'account');
3360
- if (agent.paid === false) account.append(el('span', 'free', t('FREE WAY IN')));
3522
+ if (agent.paid === false) account.append(el('span', 'free', t('HAS A FREE OPTION')));
3361
3523
  account.append(agent.account);
3362
3524
  card.append(account);
3363
3525
  }
@@ -3369,9 +3531,9 @@ function bridgeCard(agent) {
3369
3531
  // Every door the CLI has, not just the first one. Claude sells two accounts and MADRE used
3370
3532
  // to open only the subscription, which left whoever pays for the API with no way in.
3371
3533
  if (agent.login?.headless) for (const door of loginDoors(agent, stage === 'ready')) actions.append(bridgeSignInButton(agent, door, stage === 'ready'));
3372
- else if (!agent.key && agent.login) actions.append(el('span', 'note', t('signs in from its own prompt:')));
3534
+ else if (!agent.key && agent.login) actions.append(el('span', 'note', t('signs in from your terminal:')));
3373
3535
  if (agent.key) {
3374
- reveal = el('button', agent.login?.headless ? null : 'primary', t('PASTE KEY'));
3536
+ reveal = el('button', agent.login?.headless ? null : 'primary', t('PASTE API KEY'));
3375
3537
  reveal.type = 'button';
3376
3538
  actions.append(reveal);
3377
3539
  }
@@ -3599,10 +3761,17 @@ function renderUserMessage(event) {
3599
3761
  } else {
3600
3762
  who.append(el('b', null, reviewed ? t('YOU · AGENTS (EXPENDABLE)') : t('YOU · AGENTS')));
3601
3763
  if (event.payload.via) who.append(el('span', 'badge via', t('from your {device}', { device: deviceLabel(event.payload.via) })));
3764
+ // Written by MADRE for the human: the commands an agent asked for ran, and their output goes back.
3765
+ if (event.payload.auto === 'runs') {
3766
+ node.classList.add('auto');
3767
+ who.append(el('span', 'badge via', t('automatic · the commands ran')));
3768
+ settleRunReturn(event.payload.returns, target, event.sequence);
3769
+ }
3602
3770
  }
3603
3771
  const shownMode = Number.isInteger(mode) ? mode : create ? 2 : null;
3604
3772
  if (shownMode !== null && shownMode !== 1) who.append(el('span', `badge mode m${shownMode}`, `#${shownMode} ${MODES[shownMode].label}`));
3605
3773
  if (ash?.active) who.append(el('span', 'badge ash', t('TOKEN SAVER')));
3774
+ if (event.payload.copilot) who.append(el('span', 'badge copilot', t('COPILOT')));
3606
3775
  col.append(who);
3607
3776
  const bubble = el('div', 'bubble', text);
3608
3777
  if (attachments.length) bubble.append(fileTiles(attachments));
@@ -3654,6 +3823,15 @@ function renderAssistantMessage(event) {
3654
3823
  }
3655
3824
  const bubble = el('div', 'bubble');
3656
3825
  bubble.append(renderMarkdown(text));
3826
+ // Written as the human's copilot: a green round robot on the agent's own circle.
3827
+ if (event.payload.copilot) {
3828
+ node.classList.add('copilot');
3829
+ const badge = el('span', 'copilot-badge');
3830
+ badge.innerHTML = COPILOT_ICON;
3831
+ badge.title = t('@{agent} answered as your copilot', { agent: sender });
3832
+ badge.setAttribute('aria-label', badge.title);
3833
+ node.querySelector(':scope > .avatar')?.append(badge);
3834
+ }
3657
3835
  if (event.payload.artifacts?.length) bubble.append(artifactTiles(event.payload.artifacts));
3658
3836
  // What the reply recalled, what it cost and what can be done with it travel inside it, on a
3659
3837
  // foot of its own: the memory as dots on the left; rate, copy and reply (shown under the pointer)
@@ -3706,7 +3884,7 @@ function ratingButtons(messageId, sender) {
3706
3884
  const good = iconButton(ICON_GOOD, t('Good reply · keep it for your model'), 'rate good');
3707
3885
  const prefer = iconButton(ICON_PREFER, t('This is how I want it done · the project keeps it as a preference'), 'rate prefer');
3708
3886
  const bad = iconButton(ICON_BAD, t('Bad reply · keep it out of the dataset'), 'rate bad');
3709
- const never = iconButton(ICON_NEVER, t('Never do this again · what the project remembers from it is put out'), 'rate never');
3887
+ const never = iconButton(ICON_NEVER, t('Never do this again · the project stops using what it remembers from this'), 'rate never');
3710
3888
  const apply = (rating) => {
3711
3889
  for (const [button, verdict] of [[good, 'good'], [prefer, 'preference'], [bad, 'bad'], [never, 'never']]) button.classList.toggle('on', rating === verdict);
3712
3890
  prefer.hidden = !(rating === 'good' || rating === 'preference');
@@ -3828,6 +4006,13 @@ function replyWith(agentId, source) {
3828
4006
  document.addEventListener('click', (event) => { if (!replyMenu.hidden && !replyMenu.contains(event.target) && !event.target.closest?.('.bubble-actions .reply')) hideReplyMenu(); });
3829
4007
  document.addEventListener('keydown', (event) => { if (event.key === 'Escape') hideReplyMenu(); });
3830
4008
 
4009
+ function fileSize(bytes) {
4010
+ const size = Number(bytes) || 0;
4011
+ if (size < 1024) return `${size} B`;
4012
+ if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
4013
+ return `${(size / 1024 / 1024).toFixed(1)} MB`;
4014
+ }
4015
+
3831
4016
  function fileTiles(files) {
3832
4017
  const wrap = el('div', 'files');
3833
4018
  for (const file of files) {
@@ -3837,7 +4022,13 @@ function fileTiles(files) {
3837
4022
  tile.type = 'button';
3838
4023
  tile.title = `${file.name} · ${formatTokens(file.size)}B`;
3839
4024
  if (image) { const img = el('img'); img.src = url; img.alt = file.name; img.loading = 'lazy'; tile.append(img); }
3840
- else { tile.append(el('span', 'kind', (file.name.split('.').pop() ?? 'file').slice(0, 4).toUpperCase())); tile.append(el('span', 'name', file.name)); }
4025
+ else {
4026
+ const kind = (file.name.includes('.') ? file.name.split('.').pop() : 'file').slice(0, 4).toUpperCase();
4027
+ // The card says what the file is and how big, under its name, the way chats show an attachment.
4028
+ const label = el('span', 'file-text');
4029
+ label.append(el('span', 'name', file.name), el('span', 'meta', `${kind} · ${fileSize(file.size)}`));
4030
+ tile.append(el('span', 'kind', kind), label);
4031
+ }
3841
4032
  tile.addEventListener('click', () => openViewer({ root: 'attachments', path: file.fileName, label: file.name }));
3842
4033
  wrap.append(tile);
3843
4034
  }
@@ -3854,7 +4045,7 @@ const WORKING_VOICE = {
3854
4045
  long: [t('Deep in the code…'), t('Composing the answer…'), t('Almost there…')],
3855
4046
  },
3856
4047
  claude: {
3857
- early: [t('Reading…'), t('Grepping the project…'), t('Mapping the files…')],
4048
+ early: [t('Reading…'), t('Searching the project…'), t('Mapping the files…')],
3858
4049
  middle: [t('Thinking…'), t('Pondering…'), t('Connecting the pieces…'), t('Reading the relevant files…')],
3859
4050
  late: [t('Ruminating…'), t('Considering the edge cases…'), t('Checking the details…'), t('Musing…')],
3860
4051
  long: [t('Synthesizing…'), t('Drafting the reply…'), t('Finishing the thought…')],
@@ -3942,7 +4133,7 @@ function settleReading(event) {
3942
4133
  badge.classList.toggle('ash', cost.ash === true);
3943
4134
  const seen = rememberAsh(cost);
3944
4135
  if (seen) {
3945
- badge.title += ` ${t('In this project: {on} output tokens is the middle turn with TOKEN SAVER ({onTurns} turns), {off} without it ({offTurns}). You choose when to ask for compact prose, so these are different tasks, not the same task twice.', { on: seen.on.toLocaleString(), onTurns: seen.onTurns, off: seen.off.toLocaleString(), offTurns: seen.offTurns })}`;
4136
+ badge.title += ` ${t('In this project, a typical answer uses {on} output tokens with TOKEN SAVER ({onTurns} answers) and {off} without it ({offTurns}). You choose when to ask for compact answers, so these are different tasks, not the same task twice.', { on: seen.on.toLocaleString(), onTurns: seen.onTurns, off: seen.off.toLocaleString(), offTurns: seen.offTurns })}`;
3946
4137
  }
3947
4138
  if (!badge.isConnected) who.append(badge);
3948
4139
  paintUncharged(cost);
@@ -3966,7 +4157,7 @@ function paintUncharged(cost) {
3966
4157
  if (total <= 0) return;
3967
4158
  const span = stamp.querySelector('.uncharged') ?? el('span', 'uncharged');
3968
4159
  span.textContent = t('{n} uncharged', { n: formatTokens(total) });
3969
- span.title = t('{cached} tokens this CLI read back from its own cache instead of charging them again (measured). {unsent} tokens of briefing the project never sent (estimated at {rate} characters per token). Neither is the Token saver: the Token saver shortens the answer, and that is the figure above the bubble.', {
4160
+ span.title = t('{cached} tokens this agent\'s app read back from its own cache, billed at a lower price than new input (measured). {unsent} tokens MADRE did not need to send the agent (estimated at {rate} characters per token). Neither is the Token saver: the Token saver shortens the answer, and its figure is above the bubble.', {
3970
4161
  cached: cached.toLocaleString(), unsent: unsent.toLocaleString(), rate: CH_PER_TOKEN,
3971
4162
  });
3972
4163
  // Before the running total, after the turn's own bill: read left to right it is what this turn
@@ -4260,7 +4451,8 @@ const modeAsks = []; // { agent, wanted, card } still waiting for an answer in
4260
4451
  function attachModeAsk(payload, { sequence = null } = {}) {
4261
4452
  if (!payload || payload.role !== 'assistant' || payload.sender === 'you') return null;
4262
4453
  const ran = state.userMessages.get(payload.messageId)?.mode ?? payload.mode ?? 1;
4263
- const wanted = modeAsked(payload.text, { ran });
4454
+ const asked = modeAskedWithTime(payload.text, { ran });
4455
+ const wanted = asked?.mode ?? null;
4264
4456
  if (!wanted || wanted <= state.mode) return null;
4265
4457
  const agent = payload.sender;
4266
4458
  if (!state.agents.get(agent)?.ready) return null;
@@ -4269,24 +4461,47 @@ function attachModeAsk(payload, { sequence = null } = {}) {
4269
4461
  tint(card, agent);
4270
4462
  const what = el('div', 'mode-what');
4271
4463
  what.append(el('b', 'who', `@${agent}`), t(' asks for #{n} {label} to go on', { n: wanted, label }));
4464
+ if (asked.minutes) what.append(el('span', 'mode-time', t(' · for {m} min', { m: asked.minutes })));
4272
4465
  const why = el('div', 'mode-why', wanted >= 3
4273
4466
  ? t('You will type the project name, as always. Then @{agent} gets your permission and continues.', { agent })
4274
4467
  : t('@{agent} gets your permission for one message and continues.', { agent }));
4468
+ // How long the permission holds: the time the agent asked for, made one of the windows the
4469
+ // project allows (#3: 5, 15 or 30 min; #4: 5), or this message only. The human can change it.
4470
+ let minutes = wanted >= 3 ? windowFor(wanted, asked.minutes ?? 0) || (overrideConfig().grace?.[wanted] ?? 0) : 0;
4471
+ const windows = wanted >= 3 ? [0, ...MODE_WINDOWS[wanted]] : [];
4275
4472
  const actions = el('div', 'mode-actions');
4276
- const go = el('button', 'mode-go', t('GRANT #{n} · LET IT GO ON', { n: wanted }));
4473
+ const go = el('button', 'mode-go');
4277
4474
  go.type = 'button';
4475
+ const goWords = () => { go.textContent = minutes ? t('GRANT #{n} FOR {m} MIN AND CONTINUE', { n: wanted, m: minutes }) : t('GRANT #{n} AND CONTINUE', { n: wanted }); };
4476
+ goWords();
4477
+ let time = null;
4478
+ if (windows.length > 1) {
4479
+ time = el('div', 'mode-windows');
4480
+ time.setAttribute('role', 'radiogroup');
4481
+ time.setAttribute('aria-label', t('How long the permission holds'));
4482
+ const paintTime = () => { for (const one of time.children) one.setAttribute('aria-checked', String(Number(one.dataset.minutes) === minutes)); goWords(); };
4483
+ for (const option of windows) {
4484
+ const chip = el('button', 'mode-window', option ? t('{n} min', { n: option }) : t('this message'));
4485
+ chip.type = 'button';
4486
+ chip.setAttribute('role', 'radio');
4487
+ chip.dataset.minutes = String(option);
4488
+ chip.addEventListener('click', () => { minutes = option; paintTime(); });
4489
+ time.append(chip);
4490
+ }
4491
+ paintTime();
4492
+ }
4278
4493
  const myself = el('button', 'mode-alt', t('GRANT #{n} · GIVE IT ANOTHER INSTRUCTION', { n: wanted }));
4279
4494
  myself.type = 'button';
4280
4495
  myself.title = t('Unlocks #{n} in the box below, so you can add instructions before sending.', { n: wanted });
4281
4496
  const later = el('button', 'mode-alt', t('NOT NOW'));
4282
4497
  later.type = 'button';
4283
4498
  actions.append(go, myself, later);
4284
- card.append(what, why, actions);
4499
+ card.append(what, why, ...(time ? [time] : []), actions);
4285
4500
  const quoted = { sender: agent, sequence, text: (() => { const one = String(payload.text ?? '').replace(/\s+/g, ' ').trim(); return one.length > REPLY_CLIP ? `${one.slice(0, REPLY_CLIP - 1)}…` : one; })() };
4286
4501
  const ask = { agent, wanted, card };
4287
4502
  modeAsks.push(ask);
4288
4503
  const arm = (then) => {
4289
- if (wanted >= 3) openOverride(agent, { mode: wanted, raise: wanted > ceilingFor(agent) && Boolean(state.capabilities[agent]?.scopes?.write?.capable), then });
4504
+ if (wanted >= 3) openOverride(agent, { mode: wanted, raise: wanted > ceilingFor(agent) && Boolean(state.capabilities[agent]?.scopes?.write?.capable), then, minutes });
4290
4505
  else { els.target.value = agent; renderPicker(); setMode(wanted, { wink: true }); then?.(); }
4291
4506
  };
4292
4507
  go.addEventListener('click', () => arm(async () => {
@@ -4309,6 +4524,8 @@ function settleModeAsk(ask, said) {
4309
4524
  if (at >= 0) modeAsks.splice(at, 1);
4310
4525
  if (!said) { ask.card.remove(); return; }
4311
4526
  ask.card.classList.add('settled');
4527
+ // Said as a small state in the mode's own colour (styles.css, .mode-ask.settled).
4528
+ if (Number.isInteger(ask.wanted)) ask.card.dataset.mode = String(ask.wanted);
4312
4529
  ask.card.replaceChildren(el('div', 'mode-what', said));
4313
4530
  }
4314
4531
  // The human answered the ask their own way — a message to that agent at that mode or above —
@@ -4379,7 +4596,7 @@ function attachMemoryUsed(payload) {
4379
4596
  pill.style.setProperty('--kind', MEMORY_COLORS[note.kind] ?? MEMORY_COLORS.fact);
4380
4597
  pill.append(el('i', 'dot'), el('span', 'what', note.text.length > 52 ? `${note.text.slice(0, 51)}…` : note.text));
4381
4598
  pill.title = `${kindWord(note.kind)} · ${note.text}\n${note.via === 'cascade'
4382
- ? t('Came along because this project keeps carrying it with one of the others.')
4599
+ ? t('Came along because this project usually uses it together with another memory.')
4383
4600
  : t('The archive matched this to what you asked.')} ${t('Click to see it in the Memory map.')}`;
4384
4601
  pill.addEventListener('click', () => { nostromo.focusId = note.id; nostromo.button?.click(); });
4385
4602
  if (used.length > 3 && index === visible.length - 1) {
@@ -4473,7 +4690,7 @@ function renderMotherAlert(event) {
4473
4690
  function renderForgotten(event) {
4474
4691
  const { kind, text, remaining } = event.payload;
4475
4692
  const node = el('div', 'system memory forgotten');
4476
- node.append(t('memory · the human forgot a '));
4693
+ node.append(t('memory · you forgot a '));
4477
4694
  node.append(el('b', 'who', kindWord(kind)));
4478
4695
  node.append(`: “${text}”${Number.isFinite(remaining) ? t(' · {n} left in the archive', { n: remaining }) : ''}`);
4479
4696
  return node;
@@ -4692,7 +4909,7 @@ function renderControlReverted(event) {
4692
4909
 
4693
4910
  function renderLeaseMissing(event) {
4694
4911
  const { agent, requester, text } = event.payload;
4695
- const message = `${t('@{agent} was asked to create something but no CREATE lease is active.', { agent })}${requester && requester !== 'you' ? t(' Permission written by @{who} inside the conversation does not count.', { who: requester }) : ''}${t(' It will answer read-only.')}`;
4912
+ const message = `${t('@{agent} was asked to create something but no CREATE permission is active.', { agent })}${requester && requester !== 'you' ? t(' Permission written by @{who} inside the conversation does not count.', { who: requester }) : ''}${t(' It will answer read-only.')}`;
4696
4913
  const node = el('div', 'system lease missing');
4697
4914
  tint(node, agent);
4698
4915
  node.append(el('b', null, t('Diagnostics › ')));
@@ -4701,7 +4918,7 @@ function renderLeaseMissing(event) {
4701
4918
  // Its keys speak in sentences, as the room's keys do now; the agent as the room writes it.
4702
4919
  const resend = el('button', 'resend', askWords(t('RESEND TO @{agent} WITH CREATE', { agent })));
4703
4920
  resend.type = 'button';
4704
- resend.title = t('Send this same request from you, with a creation lease.');
4921
+ resend.title = t('Send this same request again yourself, with permission to create files.');
4705
4922
  resend.addEventListener('click', async () => {
4706
4923
  resend.disabled = true;
4707
4924
  try {
@@ -4712,7 +4929,7 @@ function renderLeaseMissing(event) {
4712
4929
  });
4713
4930
  const standing = el('button', 'standing', askWords(t('ALWAYS FOR THIS AGENT')));
4714
4931
  standing.type = 'button';
4715
- standing.title = t('Give @{agent} a standing lease in SETTINGS → AGENTS: every turn may create files.', { agent });
4932
+ standing.title = t('Give @{agent} a standing permission in SETTINGS → AGENTS: every turn may create files.', { agent });
4716
4933
  standing.addEventListener('click', () => openSettingsOn('agents', { agent }));
4717
4934
  actions.append(resend, standing);
4718
4935
  node.append(actions);
@@ -4756,7 +4973,7 @@ function renderModeRequest(event) {
4756
4973
  }
4757
4974
  };
4758
4975
  const once = el('button', 'grant', t('GRANT ONCE')); once.type = 'button'; once.title = mode >= 3 ? t('Unlock #{mode} for this step only.', { mode }) : t('Allow creation for this step only.');
4759
- const plan = el('button', 'grant plan', t('GRANT FOR PLAN')); plan.type = 'button'; plan.title = mode >= 3 ? t('Unlock #{mode} for the remaining steps of this plan.', { mode }) : t('Every remaining writable step of this plan shares one lease directory.');
4976
+ const plan = el('button', 'grant plan', t('GRANT FOR PLAN')); plan.type = 'button'; plan.title = mode >= 3 ? t('Unlock #{mode} for the remaining steps of this plan.', { mode }) : t('Every remaining step of this plan that can write uses the same folder for new files.');
4760
4977
  const deny = el('button', 'deny', t('DENY')); deny.type = 'button'; deny.title = t('The step continues in read-only mode.');
4761
4978
  once.addEventListener('click', () => decide('once', once));
4762
4979
  plan.addEventListener('click', () => decide('plan', plan));
@@ -5035,7 +5252,7 @@ function renderChoice(event) {
5035
5252
  });
5036
5253
  list.append(button);
5037
5254
  });
5038
- node.append(list, el('p', 'choice-foot', t('Pressing one sends it to @{agent} · #3 and #4 ask for your keys first', { agent })));
5255
+ node.append(list, el('p', 'choice-foot', t('Pressing one sends it to @{agent} · #3 and #4 ask you to confirm first', { agent })));
5039
5256
  state.lastSender = null;
5040
5257
  return node;
5041
5258
  }
@@ -5044,7 +5261,7 @@ function renderChoice(event) {
5044
5261
  // press sends only which request and which line, and the server runs what it recorded the agent
5045
5262
  // asked for. A line the parser refused is shown with its reason and no button.
5046
5263
  function renderRunRequest(event) {
5047
- const { reason, commands = [], agent, responseMessageId } = event.payload;
5264
+ const { reason, commands = [], agent, responseMessageId, mode } = event.payload;
5048
5265
  if (!commands.length) return null;
5049
5266
  const node = el('div', 'choice run-request');
5050
5267
  node.dataset.run = responseMessageId;
@@ -5069,25 +5286,77 @@ function renderRunRequest(event) {
5069
5286
  }
5070
5287
  button.append(el('span', 'choice-tag', t('RUN')));
5071
5288
  button.title = t('Runs {command} from the project root, with no shell. A restore point is taken first.', { command: command.line });
5072
- button.addEventListener('click', async () => {
5073
- button.disabled = true;
5074
- markRun({ responseMessageId, index }, 'running', null, event.timestamp);
5075
- try {
5076
- const response = await fetch('/api/runs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ responseMessageId, index }) });
5077
- const result = await response.json().catch(() => ({}));
5078
- if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
5079
- } catch (error) {
5080
- toast(`${t('Diagnostics › ')}${error.message}`);
5081
- markRun({ responseMessageId, index }, null);
5289
+ button.addEventListener('click', () => { void pressRun(button, { responseMessageId, index, startedAt: event.timestamp }); });
5290
+ list.append(button);
5291
+ });
5292
+ const runnable = commands.filter((one) => one.argv).length;
5293
+ // The whole request in one press, line after line, the way a terminal would run them; and what
5294
+ // ran, handed back without the rest. Once every line has run it goes back on its own.
5295
+ const actions = el('div', 'run-actions');
5296
+ if (runnable > 1) {
5297
+ const all = el('button', 'run-all', t('RUN ALL · {n}', { n: runnable }));
5298
+ all.type = 'button';
5299
+ all.title = t('Runs the lines one after another; it stops at the first one that fails.');
5300
+ all.addEventListener('click', async () => {
5301
+ all.disabled = true;
5302
+ for (const button of list.querySelectorAll('.run-line:not(.refused):not(.ran):not(.failed)')) {
5303
+ if (!(await pressRun(button, { responseMessageId, index: Number(button.dataset.index) }))) break;
5082
5304
  }
5305
+ all.disabled = false;
5083
5306
  });
5084
- list.append(button);
5307
+ actions.append(all);
5308
+ }
5309
+ const back = el('button', 'run-back', t('HAND BACK WHAT RAN TO @{agent}', { agent }));
5310
+ back.type = 'button';
5311
+ back.hidden = true;
5312
+ back.title = t('@{agent} gets the output of the lines that ran and goes on without the rest.', { agent });
5313
+ back.addEventListener('click', async () => {
5314
+ back.disabled = true;
5315
+ try {
5316
+ const response = await fetch('/api/runs/return', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ responseMessageId }) });
5317
+ const result = await response.json().catch(() => ({}));
5318
+ if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
5319
+ } catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); back.disabled = false; }
5085
5320
  });
5086
- node.append(list, el('p', 'run-summary', t('{done} of {total} commands finished', { done: 0, total: commands.filter((one) => one.argv).length })), el('p', 'choice-foot', t('Each line runs only when you press it · from the project root, no shell · the output comes back to the project')));
5321
+ actions.append(back);
5322
+ const goesBack = Number.isInteger(mode) && mode > 1
5323
+ ? t('When they have run, the output goes back to @{agent} and it goes on at #{n} {label}.', { agent, n: mode, label: MODES[mode]?.label ?? '' })
5324
+ : t('When they have run, the output goes back to @{agent} and it goes on.', { agent });
5325
+ node.append(list, el('p', 'run-summary', t('{done} of {total} commands finished', { done: 0, total: runnable })), actions, el('p', 'choice-foot', `${t('Each line runs when you press it · from the project root, no shell')} · ${goesBack}`));
5087
5326
  state.lastSender = null;
5088
5327
  return node;
5089
5328
  }
5090
5329
 
5330
+ // One press of one line: the server runs what it recorded and answers when the line ends (or
5331
+ // starts serving). Says whether it went well, so RUN ALL knows to stop at a failure.
5332
+ async function pressRun(button, { responseMessageId, index, startedAt = null }) {
5333
+ button.disabled = true;
5334
+ markRun({ responseMessageId, index }, 'running', null, startedAt);
5335
+ try {
5336
+ const response = await fetch('/api/runs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ responseMessageId, index }) });
5337
+ const result = await response.json().catch(() => ({}));
5338
+ if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
5339
+ return result.serving ? true : result.ok !== false;
5340
+ } catch (error) {
5341
+ toast(`${t('Diagnostics › ')}${resay(error.message)}`);
5342
+ markRun({ responseMessageId, index }, null);
5343
+ return false;
5344
+ }
5345
+ }
5346
+
5347
+ // The results went back to the agent that asked: the card says so and offers nothing more to hand.
5348
+ function settleRunReturn(responseMessageId, agent, sequence) {
5349
+ if (!responseMessageId) return;
5350
+ state.runsReturned.add(responseMessageId);
5351
+ const group = document.querySelector(`.run-request[data-run="${CSS.escape(String(responseMessageId))}"]`);
5352
+ if (!group) return;
5353
+ group.classList.add('returned');
5354
+ const back = group.querySelector('.run-back');
5355
+ if (back) back.hidden = true;
5356
+ const summary = group.querySelector('.run-summary');
5357
+ if (summary) summary.textContent = t('Output handed back to @{agent}{at}', { agent, at: sequence ? ` · #${sequence}` : '' });
5358
+ }
5359
+
5091
5360
  // The state of one line of a run request, from the events that follow it: running, ran, failed.
5092
5361
  // A line that ran can be pressed again; tests are run more than once.
5093
5362
  function markRun({ responseMessageId, index }, status, where = null, startedAt = null) {
@@ -5102,7 +5371,12 @@ function markRun({ responseMessageId, index }, status, where = null, startedAt =
5102
5371
  if (tag) tag.textContent = { running: t('RUNNING'), serving: t('SERVING'), ran: t('RUN AGAIN'), failed: t('RUN AGAIN') }[status] ?? t('RUN');
5103
5372
  const group = button.closest('.run-request');
5104
5373
  const summary = group?.querySelector('.run-summary');
5105
- if (summary) summary.textContent = t('{done} of {total} commands finished', { done: group.querySelectorAll('.run-line.ran, .run-line.failed').length, total: group.querySelectorAll('.run-line:not(.refused)').length });
5374
+ const finished = group?.querySelectorAll('.run-line.ran, .run-line.failed').length ?? 0;
5375
+ const lines = group?.querySelectorAll('.run-line:not(.refused)').length ?? 0;
5376
+ const returned = state.runsReturned.has(String(responseMessageId));
5377
+ if (summary && !returned) summary.textContent = t('{done} of {total} commands finished', { done: finished, total: lines });
5378
+ const back = group?.querySelector('.run-back');
5379
+ if (back) back.hidden = returned || !finished || finished >= lines || Boolean(group.querySelector('.run-line.running'));
5106
5380
  // While it is alive, a line of its own under it: where it serves, and the two things a person
5107
5381
  // does with a running command: look at it, or stop it. It goes when the command ends.
5108
5382
  let live = button.nextElementSibling?.classList.contains('run-live') ? button.nextElementSibling : null;
@@ -5118,7 +5392,7 @@ function markRun({ responseMessageId, index }, status, where = null, startedAt =
5118
5392
  }
5119
5393
  if (status === 'serving' && where?.port) {
5120
5394
  live.append(el('span', 'where', where.network
5121
- ? t('serving on :{port} · listens on the whole network, so the project will not open it', { port: where.port })
5395
+ ? t('serving on :{port} · open to your whole network, so the project will not open it', { port: where.port })
5122
5396
  : t('serving on :{port}', { port: where.port })));
5123
5397
  if (!where.network) {
5124
5398
  const open = el('button', 'run-open', t('OPEN IN YOUR SERVERS'));
@@ -5153,9 +5427,9 @@ runClock.unref?.();
5153
5427
  // BISHOP proposed a plan in the room of Salas (docs/MACROVERSO.md, fases 4b y 4c). One row per
5154
5428
  // order with the room, the agent and the mode in sight, and one per link it proposes. What a press
5155
5429
  // sends is what the server recorded: the press names the plan and the row, never the order.
5156
- const BISHOP_STATES = { ready: null, sending: 'SENDING', sent: 'SENT · WAITING FOR THE ANSWER', done: 'ANSWERED', failed: 'FAILED', skipped: 'SKIPPED', blocked: 'WILL NOT LEAVE', proposal: 'PROPOSAL', delivered: 'IN ITS PROJECT · WAITING FOR YOUR KEY' };
5430
+ const BISHOP_STATES = { ready: null, sending: 'SENDING', sent: 'SENT · WAITING FOR THE ANSWER', done: 'ANSWERED', failed: 'FAILED', skipped: 'SKIPPED', blocked: 'WILL NOT LEAVE', proposal: 'PROPOSAL', delivered: 'IN ITS PROJECT · WAITING FOR YOUR CONFIRMATION' };
5157
5431
  const BISHOP_MODE_WORDS = { manual: 'MANUAL', hybrid: 'HYBRID', auto: 'AUTOMATIC' };
5158
- const BISHOP_MODE_NOTES = { manual: 'Manual: every order waits for your press', hybrid: 'Hybrid: #0 and #1 leave on their own', auto: 'Automatic: up to #2 leave on their own' };
5432
+ const BISHOP_MODE_NOTES = { manual: 'Manual: every order waits for you to send it', hybrid: 'Hybrid: #0 and #1 leave on their own', auto: 'Automatic: up to #2 leave on their own' };
5159
5433
  async function bishopPress(path, body, buttons) {
5160
5434
  for (const one of buttons) one.disabled = true;
5161
5435
  try {
@@ -5331,7 +5605,7 @@ function markBishopPlan(event) {
5331
5605
  card.querySelector('.bishop-halt')?.remove();
5332
5606
  } else {
5333
5607
  card.querySelector('.bishop-halt')?.remove();
5334
- const note = el('p', 'run-why bishop-halt', t('Stopped leaving on its own ({why}). What is left waits for your press.', { why: t(said.why) }));
5608
+ const note = el('p', 'run-why bishop-halt', t('Stopped sending orders on its own ({why}). The rest wait for you to send them.', { why: t(said.why) }));
5335
5609
  card.querySelector('.choice-list')?.before(note);
5336
5610
  if (!replaying) toast(t('Diagnostics › the Coordinator stopped sending on its own ({why}).', { why: t(said.why) }));
5337
5611
  }
@@ -5344,7 +5618,7 @@ function renderBishopLine(event) {
5344
5618
  node.append(el('b', null, t('Coordinator')));
5345
5619
  const said = event.payload;
5346
5620
  if (event.type === 'bishop.plan.closed') node.append(t(' · plan {plan} closed · {done} answered · {failed} failed · {skipped} skipped', { plan: said.plan, done: said.done ?? 0, failed: said.failed ?? 0, skipped: said.skipped ?? 0 }));
5347
- else if (event.type === 'bishop.plan.halted') node.append(t(' · plan {plan} stops leaving on its own ({why}); what is left waits for your press', { plan: said.plan, why: t(said.why) }));
5621
+ else if (event.type === 'bishop.plan.halted') node.append(t(' · plan {plan} stopped sending orders on its own ({why}); the rest wait for you to send them', { plan: said.plan, why: t(said.why) }));
5348
5622
  else node.append(t(' · the block of orders could not be read: {why}', { why: (said.reasons ?? []).map((why) => t(why)).join('; ') }));
5349
5623
  return node;
5350
5624
  }
@@ -5363,11 +5637,11 @@ function renderBishopProposal(event) {
5363
5637
  els.input.value = `@${agent} #${mode} ${text}`;
5364
5638
  els.input.dispatchEvent(new Event('input', { bubbles: true }));
5365
5639
  els.input.focus();
5366
- toast(t('Diagnostics › written in the Write box. Sending it asks for your key, as always.'));
5640
+ toast(t('Diagnostics › the order is now in the Write box. Sending it asks you to confirm, as always.'));
5367
5641
  });
5368
5642
  node.append(arm);
5369
5643
  }
5370
- node.append(el('p', 'choice-foot', t('It runs only if you send it, with your key.')));
5644
+ node.append(el('p', 'choice-foot', t('It runs only if you send it and confirm it.')));
5371
5645
  state.lastSender = null;
5372
5646
  return node;
5373
5647
  }
@@ -5651,7 +5925,7 @@ function retryLonger({ messageId, target, timeout, partial }) {
5651
5925
  els.input.value = [asked?.text ?? '', note].filter(Boolean).join('\n\n');
5652
5926
  // CREATE comes back by itself; CONTROL and AIRLOCK ask for their keys again, from the chip.
5653
5927
  if ((asked?.mode ?? 1) === 2) setMode(2);
5654
- else if ((asked?.mode ?? 1) >= 3) toast(t('Diagnostics › #{n} needs its keys again: choose it on the mode chip before sending.', { n: asked.mode }));
5928
+ else if ((asked?.mode ?? 1) >= 3) toast(t('Diagnostics › #{n} needs you to confirm again: choose it in the mode selector before sending.', { n: asked.mode }));
5655
5929
  autosize();
5656
5930
  els.input.focus();
5657
5931
  retry.textContent = t('IN THE BOX · SEND IT WHEN READY');
@@ -5771,6 +6045,20 @@ function renderEventNode(event) {
5771
6045
  break;
5772
6046
  }
5773
6047
  case 'room.settings': return;
6048
+ // The copilot's window opened or closed: a line in the conversation (it folds like the rest),
6049
+ // and the robot and the mode follow when it happens now.
6050
+ case 'copilot.started':
6051
+ case 'copilot.ended': {
6052
+ const started = event.type === 'copilot.started';
6053
+ node = el('div', `system copilot${started ? ' granted' : ''}`);
6054
+ const when = started ? new Date(event.payload.until).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : null;
6055
+ node.append(el('b', null, t('copilot › ')), started
6056
+ ? t('on until {time}: the agent you write to drives the project without asking you to confirm each message', { time: when })
6057
+ : { time: t('off: its time ran out'), stopall: t('off: STOP ALL ended it'), human: t('off: you turned it off') }[event.payload.reason] ?? t('off'));
6058
+ if (!replaying) void loadCopilot();
6059
+ state.lastSender = null;
6060
+ break;
6061
+ }
5774
6062
  case 'extension.toggled':
5775
6063
  // A module switched on or off changes what the composer accepts.
5776
6064
  if (!replaying) void refreshCommands();
@@ -5810,7 +6098,8 @@ function renderEventNode(event) {
5810
6098
  if (open && !replaying) void paintAshEconomy(open);
5811
6099
  return;
5812
6100
  }
5813
- case 'agent.completed': state.running.delete(event.payload.messageId); updateStopAll(); removeThinking(event.payload.messageId); return;
6101
+ case 'agent.completed': state.running.delete(event.payload.messageId); updateStopAll(); removeThinking(event.payload.messageId); if (!replaying) treeFollowWrites(); return;
6102
+ case 'files.touched': markTouched(event.payload.agent, event.payload.files, event.timestamp); if (!replaying) treeFollowWrites(); return;
5814
6103
  case 'message.failed': state.running.delete(event.payload.messageId); updateStopAll(); node = renderFailure(event); break;
5815
6104
  case 'handoff.created': node = renderHandoff(event); break;
5816
6105
  case 'memory.distilled': node = renderDistilled(event); break;
@@ -5841,7 +6130,7 @@ function renderEventNode(event) {
5841
6130
  }
5842
6131
  for (const id of event.payload.removed ?? []) { state.agents.delete(id); [...els.target.options].find((item) => item.value === id)?.remove(); }
5843
6132
  renderAgents(); renderPicker(); renderOnboarding();
5844
- if (!replaying && event.payload.reason) toast(`${t('Diagnostics › ')}${event.payload.reason}`);
6133
+ if (!replaying && event.payload.reason) toast(`${t('Diagnostics › ')}${resay(event.payload.reason)}`);
5845
6134
  return;
5846
6135
  }
5847
6136
  case 'local.present': {
@@ -5856,7 +6145,7 @@ function renderEventNode(event) {
5856
6145
  break;
5857
6146
  }
5858
6147
  case 'mother.alert': node = renderMotherAlert(event); break;
5859
- case 'sentinel.report': state.reports.set(event.payload.id, { ...event.payload }); if (!replaying) { renderMotherSentinel(); toast(t('Diagnostics › {what} was recorded by the sentinel. Open Diagnostics to report it.', { what: event.payload.kind === 'crash' ? t('a crash') : t('an unknown condition') })); } return;
6148
+ case 'sentinel.report': state.reports.set(event.payload.id, { ...event.payload }); if (!replaying) { renderMotherSentinel(); toast(t('Diagnostics › {what} was recorded by the failure monitor. Open Diagnostics to report it.', { what: event.payload.kind === 'crash' ? t('a crash') : t('an unexpected problem') })); } return;
5860
6149
  case 'servers.changed': paintPorts(event.payload); return;
5861
6150
  case 'server.stopped': if (!replaying) serverStopped(event.payload); return;
5862
6151
  case 'server.started': if (!replaying) serverStarted(event.payload); return;
@@ -5919,13 +6208,19 @@ function renderEventNode(event) {
5919
6208
  case 'bishop.order.proposed': node = renderBishopProposal(event); break;
5920
6209
  case 'bishop.answer': node = renderBishopAnswer(event); break;
5921
6210
  case 'create.missing': node = renderCreateMissing(event); break;
5922
- case 'control.changed': markTouched(event.payload.agent, event.payload.files); node = renderControlChanged(event); if (!replaying) { ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); treeFollowWrites(); } break;
6211
+ case 'control.changed': markTouched(event.payload.agent, event.payload.files, event.timestamp); node = renderControlChanged(event); if (!replaying) { ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); treeFollowWrites(); } break;
5923
6212
  case 'control.reverted': node = renderControlReverted(event); if (!replaying) treeFollowWrites(); break;
5924
6213
  case 'mode.granted':
5925
6214
  case 'mode.denied': settleModeRequest(event); return;
5926
6215
  case 'plan.ignored': node = renderPlanIgnored(event); break;
5927
- case 'artifacts.created': markTouched(event.payload.agent, event.payload.files); attachArtifacts(event); if (!replaying) { ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); treeFollowWrites(); } return;
5928
- case 'command.output': node = renderCommandCard(event); if (!replaying && event.payload.run?.files?.length) treeFollowWrites(); break;
6216
+ case 'artifacts.created': markTouched(event.payload.agent, event.payload.files, event.timestamp); attachArtifacts(event); if (!replaying) { ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); treeFollowWrites(); } return;
6217
+ case 'command.output': {
6218
+ const run = event.payload.run;
6219
+ if (run) markTouched(run.agent, [...(run.files ?? []), ...(run.touched ?? [])], event.timestamp);
6220
+ node = renderCommandCard(event);
6221
+ if (!replaying && run) treeFollowWrites();
6222
+ break;
6223
+ }
5929
6224
  case 'eyecat.flagged': node = renderEyecat(event); break;
5930
6225
  case 'eyecat.settled': settleEyecat(event); return;
5931
6226
  default: return;
@@ -5938,15 +6233,145 @@ function renderEventNode(event) {
5938
6233
  // says something about itself: in a new project @madre's arrival used to take it away before
5939
6234
  // anyone had written a word.
5940
6235
  if (event.type.startsWith('message.') || event.type === 'command.output' || event.type === 'run.requested') removeEmpty();
5941
- const stickToBottom = scroller.height - scroller.top - scroller.view < 120;
6236
+ const stickToBottom = !drawingRecord && scroller.height - scroller.top - scroller.view < 120;
5942
6237
  els.column.append(tidySystemLine(node));
5943
- followTurn(event, node, stickToBottom);
5944
- }
6238
+ scheduleFold(node);
6239
+ if (!drawingRecord) followTurn(event, node, stickToBottom);
6240
+ }
6241
+
6242
+ // The room's notices fold into dots (2026-10-14). «CREATE · @codex may create…», «a plan is still
6243
+ // running», a handoff, a memory kept: each one matters the moment it happens and is clutter ten
6244
+ // seconds later, and a working session left the answers buried between them. Now a notice says
6245
+ // its piece, and after ten seconds — later if the pointer is on it — it becomes a dot in the
6246
+ // colour of its kind, beside the dots around it; a click opens it again with everything it said.
6247
+ // What still asks for something stays as it is: a failure with its RETRY, a permission waiting, a
6248
+ // module to install, the changes a turn made with their UNDO, MU/TH/UR. History folds at once.
6249
+ const NOTICE_FOLD_MS = 10000;
6250
+ // A limit warning folds too (2026-10-14): the agent's ring in the bar already says it, in red.
6251
+ const NOTICE_KEEP = '.fail, .mother-alert, .mother, .escalation, .lease.missing, .lease.refused, .module-proposed, .control.changed, .local-model, .memory.forgotten';
6252
+ function noticeTone(node) {
6253
+ if (node.matches('.alert, .crit, .halted, .control, .login.failed, .module.failed')) return 'crit';
6254
+ if (node.matches('.warn, .lease, .privacy, .enlace.warn')) return 'warn';
6255
+ if (node.matches('.login, .module, .reverted, .granted, .recovered')) return 'ok';
6256
+ return 'info';
6257
+ }
6258
+ // A notice folds only once it asks for nothing: any button still live keeps it open. STOP ALL on
6259
+ // an alert is the exception — the brake lives in the bar too.
6260
+ function noticeWaits(node) {
6261
+ return [...node.querySelectorAll('button')].some((button) => !button.disabled && !button.hidden && !button.classList.contains('cmd'));
6262
+ }
6263
+ function foldableNotice(node) {
6264
+ return Boolean(node?.classList?.contains('system')) && node.parentElement === els.column && !node.matches(NOTICE_KEEP);
6265
+ }
6266
+ function scheduleFold(node, tries = 0) {
6267
+ if (!foldableNotice(node)) return;
6268
+ if (replaying) { if (!noticeWaits(node)) foldNotice(node, { quiet: true }); return; }
6269
+ setTimeout(() => {
6270
+ if (!node.isConnected || node.classList.contains('folded')) return;
6271
+ // Being read, or still waiting for a press: look again in a moment, for at most ten minutes.
6272
+ if (node.matches(':hover, :focus-within') || noticeWaits(node)) { if (tries < 60) scheduleFold(node, tries + 1); return; }
6273
+ foldNotice(node);
6274
+ }, tries ? 10000 : NOTICE_FOLD_MS);
6275
+ }
6276
+ function noticeWords(node) {
6277
+ return node.textContent.replace(/\s+/g, ' ').trim().slice(0, 220);
6278
+ }
6279
+ function foldNotice(node, { quiet = false } = {}) {
6280
+ if (!node.isConnected || node.classList.contains('folded') || !foldableNotice(node)) return;
6281
+ const finish = () => {
6282
+ if (!node.isConnected) return;
6283
+ const before = node.previousElementSibling?.classList.contains('sys-dots') ? node.previousElementSibling : null;
6284
+ const after = node.nextElementSibling?.classList.contains('sys-dots') ? node.nextElementSibling : null;
6285
+ let strip = before ?? after;
6286
+ if (!strip) { strip = el('div', 'sys-dots'); node.before(strip); }
6287
+ // Notices go before the row's «+N», which stays last.
6288
+ const into = (row, ...nodes) => { const more = row.querySelector(':scope > .sys-more'); if (more) more.before(...nodes); else row.append(...nodes); };
6289
+ if (strip === after) strip.prepend(node); else into(strip, node);
6290
+ if (before && after) { into(before, ...after.querySelectorAll(':scope > .system')); after.remove(); }
6291
+ node.classList.remove('folding');
6292
+ node.classList.toggle('quiet', quiet);
6293
+ node.classList.add('folded');
6294
+ node.dataset.tone = noticeTone(node);
6295
+ node.title = noticeWords(node);
6296
+ countStrip(strip);
6297
+ };
6298
+ if (quiet || matchMedia('(prefers-reduced-motion: reduce)').matches) { finish(); return; }
6299
+ node.classList.add('folding');
6300
+ setTimeout(finish, 220);
6301
+ }
6302
+ // A row says how many it holds: its first dot, and «+N» for the rest.
6303
+ function countStrip(strip) {
6304
+ const notices = strip.querySelectorAll(':scope > .system.folded');
6305
+ let more = strip.querySelector(':scope > .sys-more');
6306
+ if (!more) { more = el('span', 'sys-more'); strip.append(more); }
6307
+ const open = strip.classList.contains('open');
6308
+ more.textContent = open ? t('collapse') : notices.length > 1 ? `+${notices.length - 1}` : '';
6309
+ more.hidden = !open && notices.length < 2;
6310
+ // A row of one is only its dot, centred in a round badge.
6311
+ strip.classList.toggle('single', !open && notices.length < 2);
6312
+ strip.tabIndex = 0;
6313
+ strip.setAttribute('role', 'button');
6314
+ strip.setAttribute('aria-expanded', String(open));
6315
+ strip.setAttribute('aria-label', t('{n} notices', { n: notices.length }));
6316
+ strip.title = open ? '' : [...notices].map(noticeWords).join('\n');
6317
+ }
6318
+ // Read open, the same notice said several times in a row is one line with «×N»: the others stay
6319
+ // in the row (and in its count), only hidden while it is open.
6320
+ function groupRepeats(strip) {
6321
+ let lead = null;
6322
+ let times = 1;
6323
+ for (const node of strip.querySelectorAll(':scope > .system.folded')) {
6324
+ node.classList.remove('repeat');
6325
+ delete node.dataset.times;
6326
+ node.style.removeProperty('--times');
6327
+ if (lead && noticeWords(node) === noticeWords(lead)) {
6328
+ node.classList.add('repeat');
6329
+ times += 1;
6330
+ lead.dataset.times = String(times);
6331
+ lead.style.setProperty('--times', `"×${times}"`);
6332
+ } else { lead = node; times = 1; }
6333
+ }
6334
+ }
6335
+ // The whole row opens at once, in order, and folds again on a click or Escape.
6336
+ function openStrip(strip, open = !strip.classList.contains('open')) {
6337
+ if (open) for (const other of els.column.querySelectorAll('.sys-dots.open')) if (other !== strip) openStrip(other, false);
6338
+ // Opening a row is reading it: the thread stops following the end, or the taller row would be
6339
+ // scrolled away from under the click.
6340
+ const last = !strip.nextElementSibling;
6341
+ if (open && !last) pinnedToEnd = false;
6342
+ strip.classList.toggle('open', open);
6343
+ for (const node of strip.querySelectorAll(':scope > .system.folded')) node.classList.remove('quiet');
6344
+ groupRepeats(strip);
6345
+ countStrip(strip);
6346
+ if (open) requestAnimationFrame(() => strip.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }));
6347
+ }
6348
+ els.column.addEventListener('click', (event) => {
6349
+ const strip = event.target.closest?.('.sys-dots');
6350
+ if (!strip || event.target.closest('a, button')) return;
6351
+ // Open, only the fold key folds it: the notices themselves are read, and selected, in place.
6352
+ if (strip.classList.contains('open') && !event.target.closest('.sys-more')) return;
6353
+ openStrip(strip);
6354
+ });
6355
+ els.column.addEventListener('keydown', (event) => {
6356
+ const strip = event.target.closest?.('.sys-dots');
6357
+ if (!strip || event.target !== strip) return;
6358
+ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openStrip(strip); }
6359
+ else if (event.key === 'Escape' && strip.classList.contains('open')) openStrip(strip, false);
6360
+ });
5945
6361
 
5946
6362
  // Modern chats carry the reader along: a new human message or a working
5947
6363
  // indicator scrolls to the end; an answer scrolls to its start so a long
5948
6364
  // reply is read from the top. Live events only, never during the replay.
5949
6365
  let replaying = true;
6366
+ // A whole record drawn at once (opening the conversation, catching up after a reconnection): no
6367
+ // event reads the column's height or scrolls on its own — each read made the browser lay the
6368
+ // whole growing column out again, twice per event, and a 166-message conversation took 11 s to
6369
+ // open. The column goes to its end once, when the record is drawn.
6370
+ let drawingRecord = false;
6371
+ function drawRecord(events) {
6372
+ drawingRecord = true;
6373
+ try { for (const event of events) renderEvent(event); } finally { drawingRecord = false; }
6374
+ }
5950
6375
  function followTurn(event, node, stickToBottom) {
5951
6376
  if (replaying) { if (stickToBottom) scrollToEnd(); return; }
5952
6377
  const isTurn = event.type === 'agent.started' || (event.type === 'message.created' && event.payload.role === 'user');
@@ -6305,7 +6730,7 @@ function drawCustom() {
6305
6730
  section(t('LANGUAGE'), null, languages, t('The whole interface. The page reloads in it when you accept.')),
6306
6731
  section(t('THEME'), null, themes, t('Automatic follows your system. The whole project changes, not only the conversation.')),
6307
6732
  section(t('WALLPAPER'), null, (() => { const box = el('div', 'paper-box'); box.append(papers); if (paperTune.children.length) box.append(paperTune); return box; })(), PHOTO_WALLPAPERS.has(draft.wallpaper)
6308
- ? t('A photograph wears the project\'s ground as a layer or as a mesh of its own colours: choose how strong it is and how much the photograph blurs.')
6733
+ ? t('The project\'s background can be a photograph, shown as a layer or as a mesh of its colors: choose how strong it looks and how much the photograph blurs.')
6309
6734
  : t('Behind the conversation, in this window and in every conversation beside it. Light and dark each have their own.')),
6310
6735
  section(t('ACCENT'), named, swatches, t('Each colour in dark (top) and in light (bottom): on a light ground it deepens so it can be read. The conversation and the buttons wear it; the permission modes keep their own.')),
6311
6736
  section(t('BUBBLES'), null, segmented('bubbles')),
@@ -6334,7 +6759,7 @@ function drawCustom() {
6334
6759
  paperBox.append(papers);
6335
6760
  if (paperTune.children.length) paperBox.append(paperTune);
6336
6761
  const paperBlock = sxBlock(page, t('Wallpaper'), PHOTO_WALLPAPERS.has(draft.wallpaper)
6337
- ? t('A photograph wears the project\'s ground as a layer or as a mesh of its own colours: choose how strong it is and how much the photograph blurs.')
6762
+ ? t('The project\'s background can be a photograph, shown as a layer or as a mesh of its colors: choose how strong it looks and how much the photograph blurs.')
6338
6763
  : t('Behind the conversation, in this window and in every conversation beside it. Light and dark each have their own.'));
6339
6764
  paperBlock.rows.append(paperBox);
6340
6765
  const talk = sxBlock(page, t('Conversation'));
@@ -6437,7 +6862,8 @@ function openCustom(open) {
6437
6862
  customButton.setAttribute('aria-expanded', String(open));
6438
6863
  customButton.setAttribute('aria-pressed', String(open));
6439
6864
  }
6440
- customButton?.addEventListener('click', () => openSettings(!settingsWindow.node?.open));
6865
+ // A window at the edge is not shown: its key brings it back instead of closing it.
6866
+ customButton?.addEventListener('click', () => openSettings(!settingsWindow.node?.open || atTheEdge(settingsWindow.node)));
6441
6867
  customWindow.node?.addEventListener('close', () => { customButton?.setAttribute('aria-expanded', 'false'); customButton?.setAttribute('aria-pressed', 'false'); });
6442
6868
 
6443
6869
  /* ---------- AJUSTES: every setting in one window ---------- */
@@ -6452,33 +6878,35 @@ customWindow.node?.addEventListener('close', () => { customButton?.setAttribute(
6452
6878
  // Nothing here is a second copy of a setting: every category draws the same panel MU/TH/UR draws,
6453
6879
  // from the same code and the same answers (state.settingsTargets), wearing the room's ink.
6454
6880
  const SETTINGS_SECTIONS = [
6455
- ['You', 'How it looks and how the project works', ['look', 'room', 'files']],
6456
- ['The agents', 'Who works, and what the project remembers', ['agents', 'memory']],
6457
- ['System', 'Permissions, privacy and the version', ['permissions', 'privacy', 'release']],
6881
+ ['You', 'How it looks, how it works and your files', ['look', 'room', 'files']],
6882
+ ['The agents', 'Who works, what they remember and which outside tools they use', ['agents', 'memory', 'mcp']],
6883
+ ['System', 'What agents may do, privacy and updates', ['permissions', 'privacy', 'release']],
6458
6884
  ];
6459
6885
  const SETTINGS_CATS = {
6460
6886
  look: { name: 'Appearance', color: '#8e75e8', icon: '<circle cx="10" cy="10" r="6.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 3.5a6.5 6.5 0 0 1 0 13z" fill="currentColor"/>' },
6461
- room: { name: 'Project and turns', color: '#d97757', icon: '<rect x="3.5" y="3.5" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="11" y="3.5" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="3.5" y="11" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="11" y="11" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/>' },
6887
+ room: { name: 'Work and limits', color: '#d97757', icon: '<rect x="3.5" y="3.5" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="11" y="3.5" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="3.5" y="11" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="11" y="11" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/>' },
6462
6888
  agents: { name: 'Agents', color: '#10a37f', icon: '<circle cx="7" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="13.6" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M2.8 16c.6-2.3 2.2-3.6 4.2-3.6s3.6 1.3 4.2 3.6M9.6 13c.9-.4 2.4-.6 4-.6 2 0 3.6 1.3 4.2 3.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
6463
- memory: { name: 'Memory', color: '#3fb6d9', icon: '<rect x="5" y="5" width="10" height="10" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="8.1" y="8.1" width="3.8" height="3.8" rx=".6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 2.5V5M12 2.5V5M8 15v2.5M12 15v2.5M2.5 8H5M2.5 12H5M15 8h2.5M15 12h2.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
6464
- files: { name: 'Files and servers', color: '#c8955a', icon: '<path d="M3.5 6.2c0-.9.7-1.6 1.6-1.6h3l1.6 1.8h5.2c.9 0 1.6.7 1.6 1.6v6.4c0 .9-.7 1.6-1.6 1.6H5.1c-.9 0-1.6-.7-1.6-1.6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>' },
6889
+ memory: { name: 'What it remembers', color: '#3fb6d9', icon: '<rect x="5" y="5" width="10" height="10" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="8.1" y="8.1" width="3.8" height="3.8" rx=".6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 2.5V5M12 2.5V5M8 15v2.5M12 15v2.5M2.5 8H5M2.5 12H5M15 8h2.5M15 12h2.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
6890
+ mcp: { name: 'Outside tools (MCP)', color: '#5b8def', icon: '<path d="M7.2 3.2v3.3M12.8 3.2v3.3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M5.2 6.5h9.6v2.4a4.8 4.8 0 0 1-9.6 0z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M10 13.7v3.1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
6891
+ files: { name: 'Files and local sites', color: '#c8955a', icon: '<path d="M3.5 6.2c0-.9.7-1.6 1.6-1.6h3l1.6 1.8h5.2c.9 0 1.6.7 1.6 1.6v6.4c0 .9-.7 1.6-1.6 1.6H5.1c-.9 0-1.6-.7-1.6-1.6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>' },
6465
6892
  permissions: { name: 'Permissions', color: '#e5484d', icon: '<path d="M10 2.8 4.2 5v4.6c0 3.6 2.4 6.4 5.8 7.6 3.4-1.2 5.8-4 5.8-7.6V5z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><circle cx="10" cy="9" r="1.7" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 10.7v2.6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
6466
6893
  privacy: { name: 'Privacy', color: '#d4a72c', icon: '<rect x="4.5" y="8.5" width="11" height="8" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M7 8.5V6.6a3 3 0 0 1 6 0v1.9" fill="none" stroke="currentColor" stroke-width="1.6"/>' },
6467
- release: { name: 'Version and reports', color: '#4caf3a', icon: '<path d="M15.6 10a5.6 5.6 0 1 1-1.64-3.96" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M15.7 3.6v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' },
6894
+ release: { name: 'Updates and errors', color: '#4caf3a', icon: '<path d="M15.6 10a5.6 5.6 0 1 1-1.64-3.96" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M15.7 3.6v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' },
6468
6895
  };
6469
- const SETTINGS_ORDER = ['look', 'room', 'files', 'agents', 'memory', 'permissions', 'privacy', 'release'];
6896
+ const SETTINGS_ORDER = ['look', 'room', 'files', 'agents', 'memory', 'mcp', 'permissions', 'privacy', 'release'];
6470
6897
  // The words the search knows, each pointing to the category that holds it and to the words on the
6471
6898
  // screen that carry it there, so a result opens the category and lands on the setting.
6472
6899
  const SETTINGS_INDEX = [
6473
6900
  ['look', 'Preview'], ['look', 'Language'], ['look', 'Theme'], ['look', 'Wallpaper'], ['look', 'Accent'], ['look', 'Bubbles'], ['look', 'Text'],
6474
- ['room', 'Several at once'], ['room', 'Notifications'], ['room', 'Conversations at once'], ['room', 'Turns per conversation'], ['room', 'Agents may make plans'], ['room', 'Steps per plan'],
6901
+ ['room', 'Several at once'], ['room', 'Notifications'], ['room', 'Conversations at once'], ['room', 'Answers per conversation'], ['room', 'Agents may make plans'], ['room', 'Steps per plan'],
6475
6902
  ['files', 'Files'], ['files', 'Your servers'], ['files', 'File viewer'], ['files', 'Preview'], ['files', "Test browser"],
6476
- ['room', 'Tokens per agent'], ['room', 'Default time limit'], ['room', 'Silence limit'], ['room', 'Retries'], ['room', 'Model'],
6903
+ ['room', 'Usage limit per agent'], ['room', 'Default time limit'], ['room', 'Silence limit'], ['room', 'Retries'], ['room', 'Model'],
6477
6904
  ['agents', 'Max mode'], ['agents', 'Starting mode'], ['agents', 'Web access'], ['agents', 'Generate images'], ['agents', 'Time limit'], ['agents', 'Sign in'],
6478
- ['memory', 'Who distils first'], ['memory', 'Who may distil'], ['memory', 'Share of the context'], ['memory', 'Related'], ['memory', 'Smart search'], ['memory', 'Dataset'], ['memory', 'Analysis and tests'],
6479
- ['permissions', 'The key of each mode'], ['permissions', 'Your phrase'], ['permissions', 'Remember permission'], ['permissions', 'Who may do what'],
6480
- ['privacy', 'Hidden data'], ['privacy', 'Replaced with'], ['privacy', 'Keys, tokens and e-mail addresses'], ['privacy', 'Your home path'], ['privacy', 'Erase private data'],
6481
- ['release', 'Look for new versions'], ['release', 'Check now'], ['release', "What's new in this version"], ['release', 'The tour of MADRE 1.0'], ['release', 'Report automatically'], ['release', 'Tell the author'],
6905
+ ['memory', 'Who takes notes first'], ['memory', 'Who may take notes'], ['memory', 'Room for memories'], ['memory', 'Related memories'], ['memory', 'Search by meaning'], ['memory', 'Training data'], ['memory', 'Details and checkups'],
6906
+ ['mcp', 'Outside tools (MCP)'], ['mcp', 'Add a tool'], ['mcp', 'Import from your apps'], ['mcp', 'Can act from'],
6907
+ ['permissions', 'How you unlock modes'], ['permissions', 'Your phrase'], ['permissions', 'Remember permission'], ['permissions', 'Memory access'], ['permissions', 'Who may do what'],
6908
+ ['privacy', 'Words to hide'], ['privacy', 'Replaced with'], ['privacy', 'Access keys and emails'], ['privacy', 'Your home folder'], ['privacy', 'Erase private data'],
6909
+ ['release', 'Look for new versions'], ['release', 'Check now'], ['release', "What's new in this version"], ['release', 'The tour of MADRE 1.0'], ['release', 'Send reports automatically'], ['release', 'Tell the author'],
6482
6910
  ];
6483
6911
  const settingsWindow = { node: document.querySelector('#settings'), body: document.querySelector('#settings-body'), search: document.querySelector('#settings-search'), box: null, ready: false, open: null, query: '', mode: 'board', observer: null };
6484
6912
  try { settingsWindow.box = JSON.parse(localStorage.getItem('pulse.settingsWindow') ?? 'null'); } catch { /* no storage */ }
@@ -6504,6 +6932,7 @@ function settingsStatus(id) {
6504
6932
  const ripley = modules.items.find((item) => item.id === 'ripley');
6505
6933
  return { line: `${finder} · ${ripley ? (ripley.status?.installed ? t('preview on') : t('preview off')) : t('Reading…')}` };
6506
6934
  }
6935
+ if (id === 'mcp') return mcpStatus();
6507
6936
  if (!data) return { line: t('Reading…') };
6508
6937
  if (id === 'agents') {
6509
6938
  const here = data.agents.filter((agent) => agent.detected);
@@ -6513,9 +6942,9 @@ function settingsStatus(id) {
6513
6942
  }
6514
6943
  if (id === 'memory') {
6515
6944
  const mem = data.settings.memory;
6516
- if (!mem) return { line: t('This project has no memory index') };
6945
+ if (!mem) return { line: t('This project has no memory yet') };
6517
6946
  const who = mem.archivist === 'auto' || !mem.archivist ? t('the cheapest agent') : `@${mem.archivist}`;
6518
- return { line: t('Archived by {who} · smart search {how}', { who, how: (mem.embedProvider ?? 'auto').toUpperCase() }) };
6947
+ return { line: t('Notes by {who} · search by meaning {how}', { who, how: (mem.embedProvider ?? 'auto').toUpperCase() }) };
6519
6948
  }
6520
6949
  if (id === 'permissions') {
6521
6950
  const config = data.settings.override ?? OVERRIDE_DEFAULT;
@@ -6525,13 +6954,13 @@ function settingsStatus(id) {
6525
6954
  if (id === 'privacy') {
6526
6955
  const priv = data.settings.privacy;
6527
6956
  if (!priv) return { line: t('Reading…') };
6528
- return { line: `${priv.terms.length ? t('{n} hidden data items', { n: priv.terms.length }) : t('No hidden data')} · ${priv.secrets !== false ? t('keys redacted') : t('keys not redacted')}` };
6957
+ return { line: `${priv.terms.length ? t('{n} hidden data items', { n: priv.terms.length }) : t('No hidden data')} · ${priv.secrets !== false ? t('keys hidden') : t('keys visible')}` };
6529
6958
  }
6530
6959
  if (id === 'release') {
6531
6960
  const info = updateUI.info;
6532
- const report = sentinelUI.settings?.autoReport ? t('auto-report on') : t('auto-report off');
6961
+ const report = sentinelUI.settings?.autoReport ? t('error reports sent') : t('error reports kept here');
6533
6962
  if (!info) return { line: report };
6534
- return { line: `MADRE ${info.current} · ${info.available ? t('{version} available', { version: info.latest }) : info.enabled === false ? t('not checked') : t('up to date')} · ${report}`, warn: info.available ? t('{version} is on npm', { version: info.latest }) : null };
6963
+ return { line: `MADRE ${info.current} · ${info.available ? t('{version} available', { version: info.latest }) : info.enabled === false ? t('not checked') : t('up to date')} · ${report}`, warn: info.available ? t('{version} is out', { version: info.latest }) : null };
6535
6964
  }
6536
6965
  return { line: '' };
6537
6966
  }
@@ -6575,14 +7004,14 @@ function settingsQuick(id) {
6575
7004
  }
6576
7005
  }
6577
7006
  if (id === 'privacy' && settingsUI.data?.settings.privacy) {
6578
- add(t('Redact keys and e-mail addresses'), switchFor(settingsUI.data.settings.privacy.secrets !== false, async (on, box) => {
7007
+ add(t('Hide keys and emails'), switchFor(settingsUI.data.settings.privacy.secrets !== false, async (on, box) => {
6579
7008
  try { const payload = await fetch('/api/privacy', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ secrets: on }) }).then((response) => response.json()); if (payload.error) throw new Error(payload.error); settingsUI.data.settings.privacy.secrets = on; }
6580
7009
  catch (error) { box.checked = !on; toast(t('The privacy setting was not saved: {error}', { error: error.message })); }
6581
7010
  renderSettings();
6582
7011
  }));
6583
7012
  }
6584
7013
  if (id === 'release' && sentinelUI.settings) {
6585
- add(t('Auto-report unknown conditions'), switchFor(Boolean(sentinelUI.settings.autoReport), async (on, box) => {
7014
+ add(t('Send error reports automatically'), switchFor(Boolean(sentinelUI.settings.autoReport), async (on, box) => {
6586
7015
  try { sentinelUI.settings = (await fetch('/api/sentinel/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ autoReport: on }) }).then((response) => response.json())).settings; }
6587
7016
  catch (error) { box.checked = !on; toast(t('It could not be saved: {error}', { error: error.message })); }
6588
7017
  renderMotherSentinel();
@@ -6860,12 +7289,14 @@ function settingsDetail(id) {
6860
7289
  if (id === 'permissions') part('permissions');
6861
7290
  if (id === 'release') { part('release'); part('sentinel'); }
6862
7291
  if (id === 'files') part('files');
7292
+ if (id === 'mcp') part('mcp');
6863
7293
  return detail;
6864
7294
  }
6865
7295
 
6866
7296
  // Draws what each category needs, from the same calls MU/TH/UR makes.
6867
7297
  function fillSettingsCategory(id) {
6868
7298
  if (id === 'files') { drawFilesTarget(); void refreshModules(); }
7299
+ if (id === 'mcp') { drawMcpTarget(); void loadMcp(); }
6869
7300
  if (['agents', 'room', 'memory', 'privacy', 'permissions'].includes(id)) { if (settingsUI.data) renderSettings(); void loadSettings(); }
6870
7301
  if (id === 'room' || id === 'release') { if (turnsUI.turns || sentinelUI.settings) renderMotherSentinel(); void loadTurns(); void loadSentinel(); }
6871
7302
  if (id === 'release') renderUpdate();
@@ -7213,7 +7644,7 @@ for (const button of document.querySelectorAll?.('button') ?? []) {
7213
7644
  // `openDirs` survives a reload of the tree: after a copy, a move or a new folder the panel is
7214
7645
  // rebuilt, and a tree that folds itself shut hides exactly the thing the human just made.
7215
7646
  // `reveal` is that thing, lit once when its row is drawn again.
7216
- const tree = { open: false, loaded: new Map(), openDirs: new Set(), reveal: null };
7647
+ const tree = { open: false, loaded: new Map(), openDirs: new Set(), reveal: null, stale: false };
7217
7648
  const formatSize = (bytes) => bytes == null ? '' : bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB` : bytes < 1024 ** 3 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${(bytes / 1024 ** 3).toFixed(1)} GB`;
7218
7649
  const CODE_EXT = /\.(m?[jt]sx?|py|rb|go|rs|java|kt|swift|c|h|cpp|hpp|cs|php|sh|zsh|css|scss|html?|json|ya?ml|toml|sql|md)$/i;
7219
7650
 
@@ -7315,8 +7746,8 @@ const finderJoin = (dir, name) => (dir === '.' ? name : `${dir}/${name}`);
7315
7746
  // swapped in whole. Several writes in a row are one refresh. A search being typed, or a word being
7316
7747
  // asked in the panel, is the human's and is left alone.
7317
7748
  let treeFollowTimer = null;
7318
- function treeFollowWrites() {
7319
- if (!tree.open) return;
7749
+ function treeFollowWrites({ now = false } = {}) {
7750
+ if (!tree.open) { tree.stale = true; return; }
7320
7751
  clearTimeout(treeFollowTimer);
7321
7752
  treeFollowTimer = setTimeout(async () => {
7322
7753
  const busy = () => !tree.open || finder.q?.value.trim() || finder.clip?.querySelector('.tree-ask');
@@ -7327,7 +7758,7 @@ function treeFollowWrites() {
7327
7758
  const top = els.treeBody.scrollTop;
7328
7759
  els.treeBody.replaceChildren(root);
7329
7760
  els.treeBody.scrollTop = top;
7330
- }, 350);
7761
+ }, now ? 0 : 350);
7331
7762
  }
7332
7763
 
7333
7764
  // The panel again, as it was: the folders that were open stay open, the one the result landed in
@@ -7348,7 +7779,7 @@ function finderRefresh(landed) {
7348
7779
  async function finderRun(body, { say }) {
7349
7780
  const guarded = [body.from, body.to].filter(Boolean).find((path) => finderGuarded(path));
7350
7781
  if (guarded) {
7351
- const typed = await finderAsk({ label: t('PROJECT DESIGNATION'), hint: t('{path} is a place MADRE guards.', { path: guarded }) });
7782
+ const typed = await finderAsk({ label: t('CONFIRM WITH THE PROJECT NAME'), hint: t('{path} is a location MADRE protects. Type the project name to confirm.', { path: guarded }) });
7352
7783
  if (!typed) return false;
7353
7784
  body.designation = typed;
7354
7785
  }
@@ -7414,7 +7845,7 @@ function finderMenu(event, path, entry) {
7414
7845
  if (finder.held && entry.kind === 'file') add(t('Paste beside it'), () => void finderPaste(here));
7415
7846
  add(t('New file'), () => void finderCreate(here, 'new-file'));
7416
7847
  add(t('New folder'), () => void finderCreate(here, 'new-folder'));
7417
- if (!entry.root && finderGuarded(path)) menu.append(el('div', 'note', t('MADRE GUARDS THIS PATH · IT WILL ASK FOR THE DESIGNATION')));
7848
+ if (!entry.root && finderGuarded(path)) menu.append(el('div', 'note', t('PROTECTED LOCATION · CONFIRM WITH THE PROJECT NAME')));
7418
7849
  menu.hidden = false;
7419
7850
  // Opened where the hand is, and kept inside the panel: a menu that spills across the room is a
7420
7851
  // menu the human has to go looking for.
@@ -7473,6 +7904,13 @@ function treeNode(path, entry) {
7473
7904
  if (entry.kind === 'dir') {
7474
7905
  const caret = el('span', 'caret', '▸');
7475
7906
  button.append(caret, el('span', 'name', entry.name));
7907
+ // Something under this folder changed: the dot of whoever touched it last, the way down.
7908
+ const under = touchedUnder(path);
7909
+ if (under) {
7910
+ const dot = paint(el('span', 'touched trail'), under.agent);
7911
+ dot.title = t('@{agent} changed {file} here · {when}', { agent: under.agent, file: under.path.slice(path.length + 1), when: agoWords(new Date(under.at).toISOString()) });
7912
+ button.append(dot);
7913
+ }
7476
7914
  const children = el('ul');
7477
7915
  children.hidden = true;
7478
7916
  let opened = false;
@@ -7494,8 +7932,8 @@ function treeNode(path, entry) {
7494
7932
  const by = touched.get(path);
7495
7933
  if (by) {
7496
7934
  // Its colour is the agent's own, so the tree reads the same way the thread does.
7497
- const dot = paint(el('span', 'touched'), by);
7498
- dot.title = t('{agent} wrote this file in this project', { agent: `@${by}` });
7935
+ const dot = paint(el('span', 'touched'), by.agent);
7936
+ dot.title = t('Last changed by @{agent} · {when}', { agent: by.agent, when: agoWords(new Date(by.at).toISOString()) });
7499
7937
  button.append(dot);
7500
7938
  }
7501
7939
  button.addEventListener('click', () => { void openViewer({ root: 'project', path, label: path }); });
@@ -7657,7 +8095,9 @@ let givingWay = false;
7657
8095
  function giveWay() {
7658
8096
  if (givingWay) return false;
7659
8097
  const members = besideNow();
7660
- if (members.length < 2 || members.reduce((sum, one) => sum + one.floor, COLUMN_MIN) <= window.innerWidth) return false;
8098
+ // One side alone gives way too (2026-10-14): on a tablet or a phone a panel beside the conversation
8099
+ // left it a strip and pushed the bar under its own keys.
8100
+ if (!members.length || members.reduce((sum, one) => sum + one.floor, COLUMN_MIN) <= window.innerWidth) return false;
7661
8101
  const rank = (one) => besideOrder.indexOf(one.key);
7662
8102
  const oldest = [...members].sort((a, b) => rank(a) - rank(b))[0];
7663
8103
  givingWay = true;
@@ -7972,10 +8412,12 @@ const WINDOW_LAYER = 30;
7972
8412
  const windowPlacers = new WeakMap();
7973
8413
  const windowStack = [];
7974
8414
  const WINDOW_SELECTOR = '.tree.window, .surface.window, .conv-window, .browser-dock.window';
8415
+ function atTheEdge(node) { return Boolean(node?.classList?.contains('aside')); }
7975
8416
  function bringWindowForward(node) {
7976
8417
  if (!node) return;
7977
- // A window asked for is a window wanted back: every one returns from the edge.
7978
- if (windowsAside) setWindowsAside(false);
8418
+ // A window asked for is that window wanted back: it alone returns from the edge; the others it
8419
+ // left there stay there until they are asked for too.
8420
+ if (windowsAside) setWindowsAside(false, { only: node });
7979
8421
  for (let i = windowStack.length - 1; i >= 0; i -= 1) {
7980
8422
  const one = windowStack[i];
7981
8423
  if (one === node || !one.isConnected || !one.matches(WINDOW_SELECTOR)) { windowStack.splice(i, 1); if (one !== node) { one.style.removeProperty('z-index'); one.classList.remove('front'); } }
@@ -8048,7 +8490,8 @@ function dropWindow(node) {
8048
8490
  if (at >= 0) windowStack.splice(at, 1);
8049
8491
  node?.style.removeProperty('z-index');
8050
8492
  node?.style.removeProperty('--depth');
8051
- node?.classList.remove('front', 'behind');
8493
+ node?.classList.remove('front', 'behind', 'aside', 'aside-moving');
8494
+ if (node?.dataset) delete node.dataset.aside;
8052
8495
  // The one now on top is the one in front.
8053
8496
  windowStack.at(-1)?.classList.add('front');
8054
8497
  paintDepth();
@@ -8058,7 +8501,7 @@ function dropWindow(node) {
8058
8501
  // windows come back over it — it goes back to its layer. Pressing it once never left it clear for
8059
8502
  // good: that was a desktop that stayed sharp under windows that had come back.
8060
8503
  const writing = () => Boolean(document.activeElement?.closest?.('#composer'));
8061
- function deskInUse() { focusDesk(writing() || windowsAside); }
8504
+ function deskInUse() { focusDesk(writing() || (windowsAside && !windowsUp().length)); }
8062
8505
  if (!SPLIT_CHAT) {
8063
8506
  document.addEventListener?.('focusin', deskInUse);
8064
8507
  // Focus leaves the box before the next element has it: read once it has landed.
@@ -8075,9 +8518,9 @@ document.addEventListener?.('pointerdown', (event) => {
8075
8518
 
8076
8519
  // A press in the conversation's own box says «this, now». Every window lying over the conversation
8077
8520
  // slides to its nearer edge, one just after another, the way a desktop is shown, and leaves a strip
8078
- // of itself in view so it is plain where it went. A press anywhere outside the box — a strip, the
8079
- // thread, the bar — opening any window, or the keyboard reaching into one brings them all back to
8080
- // where they were. The menus the box opens (mode, model, effort, an agent's card) are the box's. Only a translate moves: the
8521
+ // of itself in view so it is plain where it went. Pressing a strip, opening a window or the keyboard
8522
+ // reaching into one brings back that window only — opening the Finder from the bar is not asking for
8523
+ // Settings too; the desktop pressed again brings them all back. The menus the box opens (mode, model, effort, an agent's card) are the box's. Only a translate moves: the
8081
8524
  // windows are not laid out again on the way, and nothing about them is lost.
8082
8525
  const ASIDE_PEEK = 30;
8083
8526
  let windowsAside = false;
@@ -8088,13 +8531,16 @@ function overTheRoom(node) {
8088
8531
  if (!room?.width || !box.width) return false;
8089
8532
  return box.right > room.left + 8 && box.left < room.right - 8;
8090
8533
  }
8091
- function setWindowsAside(on, { always = false } = {}) {
8092
- if (on === windowsAside) return;
8093
- clearTimeout(asideSettle);
8534
+ function windowsUp() {
8535
+ return [...document.querySelectorAll(WINDOW_SELECTOR)].filter((node) => !node.hidden && node.getClientRects().length && !node.classList.contains('aside') && overTheRoom(node));
8536
+ }
8537
+ function setWindowsAside(on, { always = false, only = null } = {}) {
8538
+ if (!on && !windowsAside) return;
8094
8539
  if (on) {
8095
- const moving = [...document.querySelectorAll(WINDOW_SELECTOR)].filter((node) => !node.hidden && node.getClientRects().length && overTheRoom(node));
8540
+ const moving = windowsUp();
8096
8541
  // Shown on purpose, the desktop clears even when no window lay over the conversation.
8097
- if (!moving.length && !always) return;
8542
+ if (!moving.length && (!always || windowsAside)) return;
8543
+ clearTimeout(asideSettle);
8098
8544
  windowsAside = true;
8099
8545
  moving.forEach((node, index) => {
8100
8546
  const box = node.getBoundingClientRect();
@@ -8105,12 +8551,16 @@ function setWindowsAside(on, { always = false } = {}) {
8105
8551
  node.classList.add('aside-moving', 'aside');
8106
8552
  });
8107
8553
  } else {
8108
- windowsAside = false;
8109
- for (const node of document.querySelectorAll('.aside')) {
8554
+ const back = only ? [only].filter((node) => node.classList.contains('aside')) : [...document.querySelectorAll('.aside')];
8555
+ // Asked for while it was not at the edge: the others stay where they are.
8556
+ if (only && !back.length) return;
8557
+ clearTimeout(asideSettle);
8558
+ for (const node of back) {
8110
8559
  node.style.setProperty('--aside-delay', '0ms');
8111
8560
  node.classList.remove('aside');
8112
8561
  delete node.dataset.aside;
8113
8562
  }
8563
+ windowsAside = Boolean(document.querySelector('.aside'));
8114
8564
  }
8115
8565
  document.documentElement.classList.toggle('windows-aside', windowsAside);
8116
8566
  deskInUse();
@@ -8141,14 +8591,10 @@ function deskPress(event) {
8141
8591
  }
8142
8592
  if (!SPLIT_CHAT) {
8143
8593
  document.querySelector('#composer .field')?.addEventListener('pointerdown', () => setWindowsAside(true));
8144
- document.addEventListener?.('pointerdown', (event) => {
8145
- // A press that does something else brings the windows back; a press on the desktop decides on
8146
- // release, below, whether it was a toggle or the start of a selection.
8147
- const target = event.target;
8148
- if (!windowsAside || target?.closest?.('#composer, .model-menu, .agent-pop, .slash-menu')) return;
8149
- if (target?.closest?.('.shell') && !target.closest(WINDOW_SELECTOR) && !target.closest(DESK_ACTIVE)) return;
8150
- setWindowsAside(false);
8151
- }, true);
8594
+ // A press on a strip brings that window back (bringWindowForward, above); a press that does
8595
+ // something else — the bar, a button — leaves the windows where they are.
8596
+ // The desktop pressed: windows still over the conversation go aside; with none left, all come back.
8597
+ const deskToggle = () => setWindowsAside(windowsUp().length > 0 || !windowsAside, { always: true });
8152
8598
  // On the bare ground there is nothing to select, so the desktop answers on the press itself, not
8153
8599
  // on the release (that wait read as a delay). On words — a bubble, a line of the room — it still
8154
8600
  // decides on release, where a drag that selected text is told apart from a click.
@@ -8158,13 +8604,13 @@ if (!SPLIT_CHAT) {
8158
8604
  deskAnswered = false;
8159
8605
  if (event.button !== 0 || event.target?.closest?.(DESK_WORDS) || !deskPress(event)) return;
8160
8606
  deskAnswered = true;
8161
- setWindowsAside(!windowsAside, { always: true });
8607
+ deskToggle();
8162
8608
  });
8163
8609
  document.addEventListener?.('click', (event) => {
8164
8610
  if (deskAnswered) { deskAnswered = false; return; }
8165
- if (deskPress(event)) setWindowsAside(!windowsAside, { always: true });
8611
+ if (deskPress(event)) deskToggle();
8166
8612
  });
8167
- document.addEventListener?.('focusin', (event) => { if (windowsAside && event.target?.closest?.('.aside')) setWindowsAside(false); });
8613
+ document.addEventListener?.('focusin', (event) => { const win = windowsAside && event.target?.closest?.('.aside'); if (win) setWindowsAside(false, { only: win }); });
8168
8614
  window.addEventListener?.('resize', () => { if (windowsAside) setWindowsAside(false); });
8169
8615
  }
8170
8616
 
@@ -8633,9 +9079,17 @@ function setTree(open) {
8633
9079
  const root = el('ul');
8634
9080
  els.treeBody.append(root);
8635
9081
  void loadTreeLevel('.', root);
8636
- }
9082
+ } else if (open && tree.stale) treeFollowWrites();
9083
+ if (open) tree.stale = false;
8637
9084
  }
8638
- els.treeButton?.addEventListener('click', () => setTree(!tree.open));
9085
+ // By hand, for anything written from outside MADRE: an editor, a terminal, another app.
9086
+ document.querySelector('#tree-refresh')?.addEventListener('click', (event) => {
9087
+ const button = event.currentTarget;
9088
+ button.classList.remove('spin'); void button.offsetWidth; button.classList.add('spin');
9089
+ if (finder.q?.value.trim()) { finder.q.dispatchEvent(new Event('input')); return; }
9090
+ treeFollowWrites({ now: true });
9091
+ });
9092
+ els.treeButton?.addEventListener('click', () => setTree(!tree.open || atTheEdge(els.tree)));
8639
9093
  // A new file from the Finder: its path asked in the room's alert, made empty, opened in the viewer.
8640
9094
  document.querySelector('#tree-new')?.addEventListener('click', async () => {
8641
9095
  const path = await roomAsk({
@@ -9496,6 +9950,10 @@ function measureDock() {
9496
9950
  // depended on where the box stood at that instant, and Safari once left 906px of nothing.
9497
9951
  const lift = pageScrolls() && typeof getComputedStyle === 'function' ? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--kb')) || 0 : 0;
9498
9952
  const height = Math.max(0, Math.ceil(pageScrolls() ? box.height + lift : shell.getBoundingClientRect().bottom - box.top));
9953
+ // Where the type box itself begins (the strip above it — open questions, offers — is not it):
9954
+ // the thread fades out in the last few pixels before that edge (styles.css, --type-gap).
9955
+ const field = els.composer.querySelector?.('.field')?.getBoundingClientRect?.();
9956
+ if (field && !pageScrolls()) shell.style.setProperty('--type-gap', `${Math.max(0, Math.ceil(shell.getBoundingClientRect().bottom - field.top))}px`);
9499
9957
  if (height === measureDock.last) return;
9500
9958
  const stay = atBottom();
9501
9959
  measureDock.last = height;
@@ -9696,7 +10154,7 @@ function stepsDraw() {
9696
10154
  stepsSave();
9697
10155
  stepsClose();
9698
10156
  stepsPaint();
9699
- toast(t('The list comes back from ? in Diagnostics.'));
10157
+ toast(t('You can reopen the list with the ? button in Diagnostics.'));
9700
10158
  });
9701
10159
  foot.append(el('span', null, t('Each step ticks itself when you do it.')), hide);
9702
10160
  card.replaceChildren(head, list, foot);
@@ -9741,14 +10199,12 @@ if (SPLIT_CHAT && !Array.isArray(initial.events)) {
9741
10199
  els.column.replaceChildren(el('div', 'empty', t('This conversation is no longer here.')));
9742
10200
  await new Promise(() => {});
9743
10201
  }
9744
- // The corner of the room: which MADRE this is, and whose it is, readable on any wallpaper. The
9745
- // notice is NOTICE's, word for word — a copyright line the page invents for itself is a copyright
9746
- // line that drifts from the one that counts.
9747
- if (initial.version) {
9748
- const corner = document.querySelector('#colophon');
9749
- if (corner) corner.replaceChildren(el('b', null, `MADRE ${initial.version}`), document.createTextNode('\u00a0·\u00a0© 2026 Jossue Alcalá'));
9750
- }
9751
- adoptLanguage(initial.language);
10202
+ // The corner of the room: which MADRE this is, and whose it is, readable on any wallpaper, and a
10203
+ // link to its author (public/attribution.js: NOTICE's attribution, the same on Home).
10204
+ if (initial.version) drawAttribution(document.querySelector('#colophon'), initial.version);
10205
+ void adoptLanguage(initial.language, initial.languageChosen);
10206
+ // Whether the copilot is on for this project, so the robot and the mode say so from the start.
10207
+ void loadCopilot().then(() => showNewHints());
9752
10208
  // Behind the bridge this page is on a phone (docs/MOBILE.md): it says so, offers the way back to
9753
10209
  // the list of rooms, and leaves the Mac's keys to the Mac.
9754
10210
  state.device = initial.device ?? null;
@@ -9876,7 +10332,12 @@ function boot() {
9876
10332
  // this call had slipped inside the branch that ends a live installation, and until one happened
9877
10333
  // `/git` and every module command answered «unknown» in a room that had them enabled all along.
9878
10334
  void refreshCommands();
9879
- for (const event of initial.events) renderEvent(event);
10335
+ // How long the record takes to be drawn, readable in the browser's Performance panel
10336
+ // (performance.getEntriesByName('madre:history')).
10337
+ performance.mark?.('madre:history-start');
10338
+ drawRecord(initial.events);
10339
+ performance.mark?.('madre:history-end');
10340
+ try { performance.measure?.('madre:history', 'madre:history-start', 'madre:history-end'); } catch { /* no timeline */ }
9880
10341
  setLoader(arrival, 0.9);
9881
10342
  scrollToEnd();
9882
10343
  matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { applyTheme(themeMode); renderAgents(); renderPicker(); });
@@ -9986,6 +10447,7 @@ function connectStream() {
9986
10447
  function forgetConversation() {
9987
10448
  state.seen.clear();
9988
10449
  state.userMessages.clear();
10450
+ state.runsReturned.clear();
9989
10451
  state.ratings.clear();
9990
10452
  state.ratingNodes.clear();
9991
10453
  state.running.clear();
@@ -10022,7 +10484,7 @@ async function paintConversation(payload) {
10022
10484
  const here = chats.list.find((chat) => chat.id === chats.active);
10023
10485
  const mark = document.querySelector('.project.chat-here');
10024
10486
  if (mark) mark.textContent = here && chats.list.length > 1 ? here.title : '';
10025
- for (const event of payload.events ?? []) renderEvent(event);
10487
+ drawRecord(payload.events ?? []);
10026
10488
  renderAgents();
10027
10489
  renderPicker();
10028
10490
  renderOnboarding();
@@ -10283,7 +10745,7 @@ function paintIntentTry(box, said) {
10283
10745
  const level = effortLabel(said.effort);
10284
10746
  box.replaceChildren();
10285
10747
  box.classList.remove('up');
10286
- box.append(el('span', 'free-what', `${t('Looks like {what}', { what: t(INTENT_NAMES[said.intent] ?? 'an explanation') })} · ${t('{level} might be enough · try it on this one and Intenciones will notice', { level: level.toUpperCase() })}`));
10748
+ box.append(el('span', 'free-what', `${t('Looks like {what}', { what: t(INTENT_NAMES[said.intent] ?? 'an explanation') })} · ${t('{level} might be enough · try it on this one and MADRE will notice', { level: level.toUpperCase() })}`));
10287
10749
  box.append(el('span', 'intent-basis trial', t('Trial')));
10288
10750
  const later = el('button', 'free-later', t('Not now'));
10289
10751
  later.type = 'button';
@@ -10310,8 +10772,8 @@ function paintIntentTried(box, once) {
10310
10772
  box.replaceChildren();
10311
10773
  box.classList.remove('up');
10312
10774
  box.append(el('span', 'free-what', once.from
10313
- ? t('This one goes at {level} · if it falls short, Intenciones will notice and go back to {from}', { level, from: effortLabel(once.from).toUpperCase() })
10314
- : t('This one goes at {level} · if it falls short, Intenciones will notice and go back to the CLI default', { level })));
10775
+ ? t('This one goes at {level} · if it falls short, MADRE will notice and go back to {from}', { level, from: effortLabel(once.from).toUpperCase() })
10776
+ : t('This one goes at {level} · if it falls short, MADRE will notice and go back to the agent\'s default level', { level })));
10315
10777
  box.append(el('span', 'intent-basis trial', t('Trial')));
10316
10778
  const undo = el('button', 'free-later', t('Undo'));
10317
10779
  undo.type = 'button';
@@ -10778,16 +11240,18 @@ fetch('/api/mother').then((response) => response.json()).then((status) => { if (
10778
11240
 
10779
11241
  function updateCrewLabel() {
10780
11242
  const order = state.ash && state.ashInstalled;
10781
- const armed = state.mode >= 3;
10782
- els.crewLabel.textContent = state.intruder && state.mode < 3 ? t('INTRUDER ›') : armed ? `${t('DIAGNOSTICS')} · ${MODES[state.mode].label} @${(state.modeArmedFor ?? els.target.value ?? '').toUpperCase()} ›`
11243
+ // The copilot reads as a link of the chain: HUMAN › COPILOT › TOKEN SAVER › @CODEX ›.
11244
+ const piloting = copilotSends(els.target?.value);
11245
+ const armed = state.mode >= 3 && !piloting;
11246
+ els.crewLabel.textContent = piloting ? `${t('HUMAN ›')} ${t('COPILOT ›')}${order ? ` ${t('TOKEN SAVER ›')}` : ''}` : state.intruder && state.mode < 3 ? t('INTRUDER ›') : armed ? `${t('DIAGNOSTICS')} · ${MODES[state.mode].label} @${(state.modeArmedFor ?? els.target.value ?? '').toUpperCase()} ›`
10783
11247
  : state.mode === 0 ? t('HUMAN · INCOGNITO ›')
10784
11248
  : state.moduleCommand ? (state.create ? t('DIAGNOSTICS · MODULE · CREATE ›') : t('DIAGNOSTICS · MODULE · NEEDS #2 ›'))
10785
11249
  : order ? `${t('HUMAN ›')} ${state.create ? t('TOKEN SAVER · CREATE ›') : t('TOKEN SAVER ›')}`
10786
11250
  : state.create ? t('HUMAN · CREATE ›')
10787
- : state.expendable ? t('CREW · EXPENDABLE ›') : t('HUMAN ›');
11251
+ : state.expendable ? t('EXPENDABLE ›') : t('HUMAN ›');
10788
11252
  // Who the message goes to closes the line, in that agent's colour: HUMAN › TOKEN SAVER › @CODEX ›.
10789
11253
  const to = els.target?.value;
10790
- if (to && !armed && !state.intruder && typeof els.crewLabel.append === 'function') {
11254
+ if (to && !armed && (piloting || !state.intruder) && typeof els.crewLabel.append === 'function') {
10791
11255
  const who = paint(el('span', 'crew-to'), to);
10792
11256
  who.textContent = ` @${to.toUpperCase()} ›`;
10793
11257
  els.crewLabel.append(who);
@@ -10795,7 +11259,7 @@ function updateCrewLabel() {
10795
11259
  }
10796
11260
  function updatePlaceholder() {
10797
11261
  const local = Boolean(state.agents.get(els.target.value)?.local);
10798
- els.input.placeholder = state.mode === 4 ? PLACEHOLDERS.airlock : state.mode === 3 ? PLACEHOLDERS.control : state.mode === 0 ? PLACEHOLDERS.ghost : local ? PLACEHOLDERS.memory : state.create ? PLACEHOLDERS.create : (state.ash && state.ashInstalled) ? PLACEHOLDERS.ash : state.expendable ? PLACEHOLDERS.expendable : PLACEHOLDERS.plain;
11262
+ els.input.placeholder = state.mode === 4 ? PLACEHOLDERS.airlock : copilotSends(els.target.value) ? PLACEHOLDERS.copilot : state.mode === 3 ? PLACEHOLDERS.control : state.mode === 0 ? PLACEHOLDERS.ghost : local ? PLACEHOLDERS.memory : state.create ? PLACEHOLDERS.create : (state.ash && state.ashInstalled) ? PLACEHOLDERS.ash : state.expendable ? PLACEHOLDERS.expendable : PLACEHOLDERS.plain;
10799
11263
  }
10800
11264
  function setAsh(on, { wink = false } = {}) {
10801
11265
  state.ash = on;
@@ -10938,7 +11402,10 @@ els.composer.addEventListener('submit', async (event) => {
10938
11402
  toast(t('#3 EDIT and #4 FULL ACCESS are opened from the Mac.'));
10939
11403
  return;
10940
11404
  }
10941
- if (wanted >= 3) {
11405
+ if (wanted === 3 && copilotOn()) {
11406
+ // Under the copilot #3 needs no key: written or chosen, it is the copilot's.
11407
+ setMode(3);
11408
+ } else if (wanted >= 3) {
10942
11409
  // The override names the agent that will act: the one the message is addressed to, which is
10943
11410
  // its leading @mention when it has one, as on sending. It used to name the chip's, so
10944
11411
  // «@claude #3 …» with the chip on @gemini armed CONTROL for Gemini, and «#4 @codex …» with
@@ -10959,8 +11426,7 @@ els.composer.addEventListener('submit', async (event) => {
10959
11426
  cancelled: () => { els.input.value = text; autosize(); els.input.focus(); },
10960
11427
  });
10961
11428
  return;
10962
- }
10963
- setMode(wanted);
11429
+ } else setMode(wanted);
10964
11430
  }
10965
11431
  if (quoting && !text.startsWith('/')) outgoing = `${quoteHead(quoting)}${outgoing}`;
10966
11432
  if (text.startsWith('/')) {
@@ -10984,7 +11450,8 @@ els.composer.addEventListener('submit', async (event) => {
10984
11450
  const addressed = mention && state.agents.has(mention) ? mention : target;
10985
11451
  // CONTROL and AIRLOCK are armed for one agent. A message that ends up addressed to another one
10986
11452
  // is asked for again, for that one; the keys typed for @gemini never open the project to @claude.
10987
- if (state.mode >= 3 && state.modeArmedFor && addressed && addressed !== state.modeArmedFor) {
11453
+ const piloted = copilotSends(addressed);
11454
+ if (state.mode >= 3 && !piloted && state.modeArmedFor && addressed && addressed !== state.modeArmedFor) {
10988
11455
  const wanted = state.mode;
10989
11456
  const scopes = state.capabilities[addressed]?.scopes;
10990
11457
  if (els.target.value !== addressed) { els.target.value = addressed; renderPicker(); }
@@ -10999,7 +11466,7 @@ els.composer.addEventListener('submit', async (event) => {
10999
11466
  const response = await fetch('/api/messages', {
11000
11467
  method: 'POST',
11001
11468
  headers: { 'content-type': 'application/json' },
11002
- body: JSON.stringify({ text: outgoing, target: addressed || null, model: state.chosenModel[addressed] ?? null, effort: once?.effort ?? state.chosenEffort[addressed] ?? null, attachments: ready.map((item) => item.id), create: state.create, mode: state.mode, ash: state.ashInstalled && state.ash, draft: draft || undefined, key: state.mode >= 3 ? overridePass(state.mode, addressed) : undefined }),
11469
+ body: JSON.stringify({ text: outgoing, target: addressed || null, model: state.chosenModel[addressed] ?? null, effort: once?.effort ?? state.chosenEffort[addressed] ?? null, attachments: ready.map((item) => item.id), create: state.create, mode: state.mode, ash: state.ashInstalled && state.ash, draft: draft || undefined, copilot: piloted || undefined, key: state.mode >= 3 && !piloted ? overridePass(state.mode, addressed) : undefined }),
11003
11470
  });
11004
11471
  const sentMode = state.mode;
11005
11472
  if (sentMode >= 3 && !overrideGraceLeft(sentMode, addressed)) overridePasses.delete(`${sentMode}:${addressed}`);
@@ -11007,6 +11474,8 @@ els.composer.addEventListener('submit', async (event) => {
11007
11474
  const result = await response.json().catch(() => ({ error: t('Request failed ({status}).', { status: response.status }) }));
11008
11475
  // The pass ran out (the project restarted, or the permission it held ended): the key is
11009
11476
  // asked again, and the message goes once it is in.
11477
+ // The copilot ended while this was being written: said, and the next #3 asks for its key.
11478
+ if (result.copilotOff) { applyCopilot({ active: false }); toast(t('The copilot ended. Send again and confirm #3, or turn it on again.')); return; }
11010
11479
  if (result.needsKey && sentMode >= 3 && addressed) {
11011
11480
  closeOverrideGrace(sentMode, addressed);
11012
11481
  openOverride(addressed, { mode: sentMode, then: () => els.composer.requestSubmit() });
@@ -11380,7 +11849,7 @@ function bootMother() {
11380
11849
  mother.boot.textContent = [
11381
11850
  t('{n} agents · {ready} ready', { n: state.agents.size, ready: online }),
11382
11851
  `/${els.project.textContent}`,
11383
- open === 1 ? t('1 condition in this project') : open ? t('{n} conditions in this project', { n: open }) : t('nothing open'),
11852
+ open === 1 ? t('1 problem in this project') : open ? t('{n} problems in this project', { n: open }) : t('nothing open'),
11384
11853
  ].join(' · ');
11385
11854
  }
11386
11855
 
@@ -11444,7 +11913,6 @@ function openMother() {
11444
11913
  bootMother();
11445
11914
  renderMother();
11446
11915
  }
11447
- if (windowsAside) setWindowsAside(false);
11448
11916
  bringWindowForward(node);
11449
11917
  mother.button.setAttribute('aria-pressed', 'true');
11450
11918
  // On a phone the keyboard would cover what MU/TH/UR says; it comes up when the field is touched.
@@ -11515,27 +11983,83 @@ function runOutput(text, run) {
11515
11983
  function renderCommandCard(event) {
11516
11984
  const { name, title, text, ok, args = [], run = null, bishop = null } = event.payload;
11517
11985
  if (name === 'bishop' && !bishop && ok !== false && args.length) return renderBishopAsk(event);
11986
+ if (!bishop) return renderTerminalCard(event);
11518
11987
  const node = el('div', `command-card${ok === false ? ' failed' : ''}`);
11519
11988
  const head = el('div', 'head');
11520
11989
  // The answer to an order of BISHOP: who answered, where, and which order; the card starts no turn.
11521
- const said = bishop ? t('@{agent} in {room} · order {step} of {total} · plan {plan}', { agent: bishop.agent, room: bishop.name, step: bishop.step, total: bishop.total, plan: bishop.plan }) : null;
11522
- head.append(el('b', null, bishop ? t('Coordinator') : `/${name}`), el('span', null, said ?? (run?.line ? runTitle(run) : title ?? name)), el('span', 'args', args.join(' ')));
11523
- if (bishop) {
11524
- markBishopOrder(bishop, ok === false ? 'failed' : 'done');
11525
- if (bishop.link) {
11526
- const go = el('a', 'args', bishop.cut ? t('the rest, in the project') : t('see it in the project'));
11527
- go.href = bishop.link;
11528
- go.target = '_blank';
11529
- go.rel = 'noopener';
11530
- head.append(go);
11531
- }
11990
+ const said = t('@{agent} in {room} · order {step} of {total} · plan {plan}', { agent: bishop.agent, room: bishop.name, step: bishop.step, total: bishop.total, plan: bishop.plan });
11991
+ head.append(el('b', null, t('Coordinator')), el('span', null, said), el('span', 'args', args.join(' ')));
11992
+ markBishopOrder(bishop, ok === false ? 'failed' : 'done');
11993
+ if (bishop.link) {
11994
+ const go = el('a', 'args', bishop.cut ? t('the rest, in the project') : t('see it in the project'));
11995
+ go.href = bishop.link;
11996
+ go.target = '_blank';
11997
+ go.rel = 'noopener';
11998
+ head.append(go);
11532
11999
  }
11533
12000
  const pre = el('pre');
11534
- for (const line of String(run?.line ? runOutput(text, run) : text ?? '').split('\n')) {
12001
+ for (const line of String(text ?? '').split('\n')) {
11535
12002
  if (line.startsWith('## ')) pre.append(el('span', 'h', line.slice(3)), '\n');
11536
12003
  else pre.append(`${line}\n`);
11537
12004
  }
11538
12005
  node.append(head, pre);
12006
+ state.lastSender = null;
12007
+ return node;
12008
+ }
12009
+
12010
+ // What a terminal said, folded (2026-10-14). A command's output used to open in full in the
12011
+ // conversation, and five of them were a wall between a question and its answer. Now the card is
12012
+ // one line — the command, how it ended, how long, who asked — with the output's last line beside
12013
+ // it, which is where a test run or a build says how it went; the rest opens on a click.
12014
+ const termOpen = new Set(); // event ids the human opened, so a redraw keeps them open
12015
+ function renderTerminalCard(event) {
12016
+ const { name, title, text, ok, args = [], run = null } = event.payload;
12017
+ const node = el('div', `command-card terminal${ok === false ? ' failed' : ''}`);
12018
+ const body = String(run?.line ? runOutput(text, run) : text ?? '').replace(/\s+$/, '');
12019
+ const lines = body ? body.split('\n') : [];
12020
+ const head = el('button', 'term-head');
12021
+ head.type = 'button';
12022
+ const id = String(event.id ?? event.sequence ?? Math.random());
12023
+ const outId = `term-${id.replace(/[^\w-]/g, '')}`;
12024
+ head.setAttribute('aria-controls', outId);
12025
+ head.append(el('b', 'term-tag', `/${name}`));
12026
+ if (run?.line) {
12027
+ head.title = runTitle(run);
12028
+ head.append(el('code', 'term-line', `$ ${run.line}`));
12029
+ const why = String(run.stoppedBy ?? '');
12030
+ const status = why ? 'stopped' : run.exitCode === null || run.exitCode === undefined ? 'stopped' : run.exitCode === 0 ? 'ok' : 'failed';
12031
+ const word = why ? t('halted') : run.exitCode === null || run.exitCode === undefined ? t('did not start') : run.exitCode === 0 ? t('went fine') : t('failed · code {code}', { code: run.exitCode });
12032
+ head.append(el('span', `term-status ${status}`, `${status === 'ok' ? '✓' : status === 'failed' ? '✕' : '■'} ${word} · ${((Number(run.durationMs) || 0) / 1000).toFixed(1)}s`));
12033
+ if (run.agent) head.append(paint(el('span', 'term-who', run.auto ? `@${run.agent} · ${t('copilot')}` : `@${run.agent}`), run.agent));
12034
+ } else {
12035
+ head.title = title ?? name;
12036
+ head.append(el('span', 'term-line', title ?? name));
12037
+ if (args.length) head.append(el('span', 'term-who', args.join(' ')));
12038
+ }
12039
+ head.append(el('span', 'term-count', t('{n} lines', { n: lines.length })), chevron());
12040
+ // The line a person looks for first: the last thing it printed.
12041
+ const last = [...lines].reverse().find((line) => line.trim() && !/^Files changed:|^Archivos cambiados:/.test(line));
12042
+ const peek = last ? el('div', 'term-peek', last.trim().slice(0, 240)) : null;
12043
+ const out = el('div', 'term-out');
12044
+ out.id = outId;
12045
+ const pre = el('pre');
12046
+ for (const line of lines) {
12047
+ if (line.startsWith('## ')) { pre.append(el('span', 'h', line.slice(3)), '\n'); continue; }
12048
+ // A few words carry the verdict in any test runner or compiler; they get its colour, nothing more.
12049
+ const tone = /^\s*(✖|✗|×|not ok\b|FAIL\b|ERR!|error\b|Error:|fatal:)/i.test(line) ? 'bad' : /^\s*(✔|✓|ok\b|PASS\b)/.test(line) ? 'good' : null;
12050
+ pre.append(tone ? el('span', `l-${tone}`, line) : line, '\n');
12051
+ }
12052
+ const clip = el('div', 'term-clip');
12053
+ clip.append(pre);
12054
+ out.append(clip);
12055
+ const open = (yes) => {
12056
+ node.classList.toggle('open', yes);
12057
+ head.setAttribute('aria-expanded', String(yes));
12058
+ if (yes) termOpen.add(id); else termOpen.delete(id);
12059
+ };
12060
+ open(termOpen.has(id));
12061
+ head.addEventListener('click', () => open(!node.classList.contains('open')));
12062
+ node.append(head, ...(peek ? [peek] : []), out);
11539
12063
  if (run) {
11540
12064
  markRun(run, ok === false ? 'failed' : 'ran');
11541
12065
  // A command the human ran by hand was photographed first: the same UNDO as a CONTROL turn.
@@ -11568,7 +12092,7 @@ function renderEyecat(event) {
11568
12092
  const node = el('div', 'command-card eyecat');
11569
12093
  node.dataset.eyecat = finding.key;
11570
12094
  const head = el('div', 'head');
11571
- head.append(el('b', null, t('◉ EYECAT')), el('span', null, finding.kind === 'unsupported' ? t('a note its own sources do not support') : t('two memories that cannot both be true')));
12095
+ head.append(el('b', null, t('◉ MEMORY REVIEWER')), el('span', null, finding.kind === 'unsupported' ? t('a note its own sources do not support') : t('two memories that cannot both be true')));
11572
12096
  if (finding.confidence !== null && finding.confidence !== undefined) head.append(el('span', 'args', t('{pct}% · judged by @{judge}', { pct: Math.round(finding.confidence * 100), judge: finding.judge })));
11573
12097
  else head.append(el('span', 'args', t('judged by @{judge}', { judge: finding.judge })));
11574
12098
  node.append(head);
@@ -11596,14 +12120,14 @@ function renderEyecat(event) {
11596
12120
  }).then((res) => res.json()).catch(() => null);
11597
12121
  if (!response || response.error) {
11598
12122
  for (const button of actions.querySelectorAll('button')) button.disabled = false;
11599
- toast(response?.error ?? t('EYECAT could not be answered.'));
12123
+ toast(response?.error ?? t('Your answer did not reach the memory reviewer.'));
11600
12124
  }
11601
12125
  };
11602
12126
  const confirm = el('button', 'primary', t('IT IS FALSE'));
11603
- confirm.title = t('Files it as an aberration and takes the memory out of every turn. Reversible from the memory map.');
12127
+ confirm.title = t('Marks it as a doubtful memory and stops using it in answers. Reversible from the Memory map.');
11604
12128
  confirm.addEventListener('click', () => { void answer('confirm'); });
11605
12129
  const dismiss = el('button', null, t('IT STANDS'));
11606
- dismiss.title = t('The project was right. This pair is never raised again.');
12130
+ dismiss.title = t('The project was right. This contradiction is never flagged again.');
11607
12131
  dismiss.addEventListener('click', () => { void answer('dismiss'); });
11608
12132
  actions.append(confirm, dismiss);
11609
12133
  node.append(actions);
@@ -11618,7 +12142,7 @@ function settleEyecat(event) {
11618
12142
  if (!card) return;
11619
12143
  card.querySelector('.eyecat-actions')?.remove();
11620
12144
  const settled = el('div', 'eyecat-settled');
11621
- settled.textContent = verdict === 'aberration' ? t('FILED AS AN ABERRATION · THE MEMORY IT REFUTES NO LONGER TRAVELS') : t('THE PROJECT STANDS BY IT');
12145
+ settled.textContent = verdict === 'aberration' ? t('FILED AS A DOUBTFUL MEMORY · THE MEMORY IT REFUTES IS NO LONGER USED IN ANSWERS') : t('THE PROJECT STANDS BY IT');
11622
12146
  card.classList.add(verdict === 'aberration' ? 'filed' : 'stands');
11623
12147
  card.append(settled);
11624
12148
  }
@@ -11640,7 +12164,7 @@ function appendModuleOutput(event) {
11640
12164
  async function switchModule(item) {
11641
12165
  const response = await fetch(`/api/extensions/${item.id}/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }) });
11642
12166
  const result = await response.json().catch(() => ({}));
11643
- if (!response.ok) throw new Error(result.error ?? t('{name} could not be switched.', { name: item.name }));
12167
+ if (!response.ok) throw new Error(result.error ?? t('{name} could not be turned on or off.', { name: item.name }));
11644
12168
  if (item.id === 'ripley') { state.ripley = Boolean(result.enabled); syncViewerMode(); }
11645
12169
  if (item.id === 'ash') syncAshUI(Boolean(result.enabled));
11646
12170
  if (result.capabilities) { state.capabilities = result.capabilities; renderCreateScopes(); }
@@ -11657,6 +12181,224 @@ function drawFilesTarget() {
11657
12181
  target.replaceChildren();
11658
12182
  drawFilesSettings(target);
11659
12183
  }
12184
+ /* ---------- Settings › Connect MCP (2026-10-14) ---------- */
12185
+ //
12186
+ // The MCP servers of this project, and only here: added by hand or brought from the apps the person
12187
+ // already uses, each one started and checked by MADRE before an agent gets it (src/modules/
12188
+ // mcp-connect.mjs). A row says what it is, whether it answered and with how many tools, from which
12189
+ // mode its tools that act are handed, and the one thing to do when it needs something.
12190
+ const mcpUI = { servers: null, adding: null, found: null, picked: new Set(), open: new Set(), said: null, asked: false };
12191
+ function mcpStatus() {
12192
+ const servers = mcpUI.servers;
12193
+ // The board shows this card before anyone opens it: it reads its list once, by itself.
12194
+ if (!servers) { if (!mcpUI.asked) { mcpUI.asked = true; void loadMcp(); } return { line: t('Reading…') }; }
12195
+ if (!servers.length) return { line: t('No outside tools yet') };
12196
+ const ready = servers.filter((one) => one.enabled && !one.problem && one.tools?.length).length;
12197
+ const stuck = servers.filter((one) => one.enabled && (one.problem || one.needsAuth));
12198
+ return { line: t('{ready} of {total} tools ready', { ready, total: servers.length }), warn: stuck.length ? t(stuck.length === 1 ? '{names} needs you' : '{names} need you', { names: stuck.map((one) => one.label).join(', ') }) : null };
12199
+ }
12200
+ async function mcpCall(path, body = null, { method = body ? 'POST' : 'GET' } = {}) {
12201
+ const response = await fetch(path, { method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
12202
+ const result = await response.json().catch(() => ({}));
12203
+ if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
12204
+ if (result.servers) mcpUI.servers = result.servers;
12205
+ return result;
12206
+ }
12207
+ async function loadMcp() {
12208
+ try { await mcpCall('/api/x/mcp/servers'); } catch (error) { mcpUI.servers = mcpUI.servers ?? []; mcpUI.said = error.message; }
12209
+ drawMcpTarget();
12210
+ syncSettingsCards();
12211
+ }
12212
+ function drawMcpTarget() {
12213
+ const host = state.settingsTargets.mcp;
12214
+ if (!host?.isConnected) return;
12215
+ host.replaceChildren();
12216
+ drawMcpSettings(host);
12217
+ }
12218
+ function drawMcpSettings(host) {
12219
+ const aside = el('div', 'sx-field');
12220
+ aside.append(
12221
+ sxButton(t('Import from your apps'), () => void openMcpImport()),
12222
+ sxButton(t('Add a tool'), () => { mcpUI.adding = mcpUI.adding ? null : { transport: 'stdio', auth: 'none', env: [] }; drawMcpTarget(); }, { tone: 'primary' }),
12223
+ );
12224
+ const list = sxBlock(host, t('Outside tools (MCP)'), t('Let the agents use other apps and services: a program on this computer or a service online. MADRE checks each one first; if one fails, the agents carry on without it.'), { aside, className: 'mcp-block' });
12225
+ if (mcpUI.servers === null) sxRow(list, t('Reading…'), null);
12226
+ else if (!mcpUI.servers.length && !mcpUI.adding) sxRow(list, t('No outside tools yet'), t('Add one by hand, or bring the ones you already use in Claude Code, Codex, Gemini or Cursor.'));
12227
+ for (const server of mcpUI.servers ?? []) list.rows.append(mcpServerRow(server));
12228
+ if (mcpUI.said) sxFoot(list, mcpUI.said);
12229
+ sxFoot(list, t('Tools that only look things up work from #1 CHAT ONLY. Tools that do things — send, write, publish — only from the mode you choose: #4 FULL ACCESS unless you lower it. Keys stay locked in MADRE, never in your files.'));
12230
+ if (mcpUI.adding) drawMcpForm(host);
12231
+ if (mcpUI.found) drawMcpImport(host);
12232
+ }
12233
+ function mcpServerRow(server) {
12234
+ const row = el('div', `sx-row stack mcp-row${server.enabled ? '' : ' off'}`);
12235
+ const top = el('div', 'mcp-top');
12236
+ const words = el('div', 'sx-words');
12237
+ const name = el('b', null, server.label);
12238
+ words.append(name, el('span', 'sx-hint mono mcp-where', server.transport === 'http' ? server.url : server.line ?? [server.command, ...(server.args ?? [])].join(' ')));
12239
+ const chips = el('div', 'sx-chips');
12240
+ chips.append(el('span', 'sx-chip', server.transport === 'http' ? t('online') : t('on this computer')));
12241
+ const needsSignIn = server.auth === 'oauth' && (!server.signedIn || server.needsAuth);
12242
+ if (needsSignIn) chips.append(el('span', 'sx-chip warn', t('sign-in needed')));
12243
+ else if (server.problem) chips.append(el('span', 'sx-chip off', t('not answering')));
12244
+ else if (server.tools?.length) chips.append(el('span', 'sx-chip on', t('{n} tools', { n: server.tools.length })));
12245
+ else chips.append(el('span', 'sx-chip', t('not checked yet')));
12246
+ words.append(chips);
12247
+ const toggle = sxSwitch(server.enabled, async (on, box) => {
12248
+ box.disabled = true;
12249
+ try { await mcpCall(`/api/x/mcp/servers/${server.id}/toggle`, { enabled: on }); } catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); }
12250
+ drawMcpTarget(); syncSettingsCards();
12251
+ }, { label: t('Use {name} in this project', { name: server.label }) });
12252
+ top.append(words, toggle);
12253
+ row.append(top);
12254
+ if (server.problem && !needsSignIn) row.append(el('p', 'mcp-problem', server.problem));
12255
+ const acting = (server.tools ?? []).filter((tool) => !tool.readOnly).length;
12256
+ const tools = el('div', 'mcp-actions');
12257
+ if (needsSignIn) tools.append(sxButton(t('Sign in'), () => void mcpSignIn(server), { tone: 'primary' }));
12258
+ tools.append(sxButton(t('Check again'), async (event) => {
12259
+ const button = event.currentTarget;
12260
+ button.disabled = true; button.textContent = t('Checking…');
12261
+ try { const result = await mcpCall(`/api/x/mcp/servers/${server.id}/check`, {}); if (!result.check?.ok && !result.check?.needsAuth) toast(t('{name} did not answer: {problem}', { name: server.label, problem: result.check?.problem ?? '' })); } catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); }
12262
+ drawMcpTarget(); syncSettingsCards();
12263
+ }));
12264
+ if (server.tools?.length) {
12265
+ const open = mcpUI.open.has(server.id);
12266
+ const look = sxButton(open ? t('Hide tools') : t('See tools'), () => { if (open) mcpUI.open.delete(server.id); else mcpUI.open.add(server.id); drawMcpTarget(); });
12267
+ look.setAttribute('aria-expanded', String(open));
12268
+ tools.append(look);
12269
+ }
12270
+ tools.append(sxButton(t('Remove'), async (event) => {
12271
+ const button = event.currentTarget;
12272
+ if (button.dataset.sure !== '1') { button.dataset.sure = '1'; button.textContent = t('Remove · sure?'); return; }
12273
+ try { await mcpCall(`/api/x/mcp/servers/${server.id}/remove`, {}); } catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); }
12274
+ drawMcpTarget(); syncSettingsCards();
12275
+ }, { tone: 'danger' }));
12276
+ row.append(tools);
12277
+ // From which mode its tools that act travel. Without any, there is nothing to choose.
12278
+ if (acting) {
12279
+ const from = el('div', 'mcp-from');
12280
+ from.append(el('span', 'sx-hint', acting === 1 ? t('Its tool that does things works from') : t('Its {n} tools that do things work from', { n: acting })), sxSeg([2, 3, 4].map((n) => ({ value: String(n), text: `#${n}`, title: `${MODES[n].label} · ${MODES[n].hint}` })), String(server.actions ?? 4), async (value) => {
12281
+ try { await mcpCall(`/api/x/mcp/servers/${server.id}/actions`, { actions: Number(value) }); } catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); }
12282
+ drawMcpTarget();
12283
+ }, { label: t('Can act from') }));
12284
+ row.append(from);
12285
+ }
12286
+ if (mcpUI.open.has(server.id)) {
12287
+ const list = el('ul', 'mcp-tools');
12288
+ for (const tool of server.tools) {
12289
+ const item = el('li', tool.readOnly ? 'reads' : 'acts');
12290
+ item.append(el('code', null, tool.name), el('span', 'mcp-tool-kind', tool.readOnly ? t('looks up') : t('does things')));
12291
+ if (tool.description) item.title = tool.description;
12292
+ list.append(item);
12293
+ }
12294
+ row.append(list);
12295
+ }
12296
+ return row;
12297
+ }
12298
+ // A server signed in to with OAuth: the address to open comes from the project, the page that
12299
+ // signs in is the server's own, and the way back lands on this room. When this tab is looked at
12300
+ // again, the list reads itself again.
12301
+ async function mcpSignIn(server) {
12302
+ try {
12303
+ const { open } = await mcpCall(`/api/x/mcp/servers/${server.id}/signin`, {});
12304
+ window.open(open, '_blank', 'noopener');
12305
+ window.addEventListener('focus', () => { void loadMcp(); }, { once: true });
12306
+ } catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); }
12307
+ }
12308
+ function drawMcpForm(host) {
12309
+ const draft = mcpUI.adding;
12310
+ const block = sxBlock(host, t('Add a tool'), t('A name for it, and how MADRE reaches it. MADRE checks it as soon as you save.'), { className: 'mcp-block' });
12311
+ const input = (value, placeholder, { mono = false, type = 'text', change } = {}) => {
12312
+ const field = el('input', `sx-input${mono ? ' mono' : ''}`);
12313
+ field.type = type; field.value = value ?? ''; field.placeholder = placeholder; field.spellcheck = false; field.autocomplete = 'off';
12314
+ field.addEventListener('input', () => change(field.value));
12315
+ return field;
12316
+ };
12317
+ sxRow(block, t('Name'), t('A short word: it is how the agents see it.'), input(draft.id, 'apex', { mono: true, change: (value) => { draft.id = value.trim().toLowerCase(); } }));
12318
+ sxRow(block, t('Where it lives'), null, sxSeg([{ value: 'stdio', text: t('On this computer') }, { value: 'http', text: t('On the internet') }], draft.transport, (value) => { draft.transport = value; drawMcpTarget(); }, { label: t('Where it lives') }));
12319
+ if (draft.transport === 'stdio') {
12320
+ sxRow(block, t('Command'), t('The command its instructions give you to start it.'), input(draft.command, 'node ~/mcp/server.mjs', { mono: true, change: (value) => { draft.command = value; } }), { stack: true });
12321
+ const vars = el('div', 'mcp-vars');
12322
+ draft.env.forEach((row, index) => {
12323
+ const line = el('div', 'sx-field');
12324
+ line.append(input(row.name, 'API_KEY', { mono: true, change: (value) => { row.name = value.trim(); } }), input(row.value, t('value'), { mono: true, type: row.secret ? 'password' : 'text', change: (value) => { row.value = value; } }));
12325
+ const secret = el('label', 'mcp-secret');
12326
+ const box = el('input'); box.type = 'checkbox'; box.checked = row.secret !== false;
12327
+ box.addEventListener('change', () => { row.secret = box.checked; drawMcpTarget(); });
12328
+ secret.append(box, el('span', null, t('secret')));
12329
+ line.append(secret, sxButton('×', () => { draft.env.splice(index, 1); drawMcpTarget(); }, { title: t('Remove') }));
12330
+ vars.append(line);
12331
+ });
12332
+ vars.append(sxButton(t('Add a variable'), () => { draft.env.push({ name: '', value: '', secret: true }); drawMcpTarget(); }));
12333
+ sxRow(block, t('Variables'), t('Settings it needs, like an API key. Secret ones stay locked in MADRE, never in a file.'), vars, { stack: true });
12334
+ } else {
12335
+ sxRow(block, t('Address'), t('The MCP address the service gives you, starting with https://.'), input(draft.url, 'https://example.com/mcp', { mono: true, change: (value) => { draft.url = value.trim(); } }), { stack: true });
12336
+ sxRow(block, t('Sign-in'), t('Sign in opens the service\'s own page. A token is a code you paste once; MADRE keeps it safe.'), sxSeg([{ value: 'none', text: t('None') }, { value: 'oauth', text: t('Sign in') }, { value: 'token', text: t('Token') }], draft.auth, (value) => { draft.auth = value; drawMcpTarget(); }, { label: t('Sign-in') }));
12337
+ if (draft.auth === 'token') sxRow(block, t('Token'), null, input(draft.token, t('paste it here'), { mono: true, type: 'password', change: (value) => { draft.token = value; } }), { stack: true });
12338
+ }
12339
+ const save = sxButton(draft.auth === 'oauth' && draft.transport === 'http' ? t('Save and sign in') : t('Save and check'), async () => {
12340
+ save.disabled = true; save.textContent = t('Checking…');
12341
+ try {
12342
+ const result = await mcpCall('/api/x/mcp/servers', { id: draft.id, transport: draft.transport, command: draft.command, url: draft.url, auth: draft.auth, token: draft.token, env: draft.env });
12343
+ const added = (mcpUI.servers ?? []).find((one) => one.id === draft.id);
12344
+ mcpUI.adding = null;
12345
+ drawMcpTarget(); syncSettingsCards();
12346
+ if (added && added.auth === 'oauth' && result.check?.needsAuth) void mcpSignIn(added);
12347
+ else if (result.check && !result.check.ok) toast(t('{name} did not answer: {problem}', { name: draft.id, problem: result.check.problem ?? '' }));
12348
+ else if (result.check?.ok) toast(t('{name} is connected · {n} tools', { name: draft.id, n: result.check.tools.length }), { tone: 'done' });
12349
+ } catch (error) {
12350
+ toast(`${t('Diagnostics › ')}${resay(error.message)}`);
12351
+ save.disabled = false; save.textContent = t('Save and check');
12352
+ }
12353
+ }, { tone: 'primary' });
12354
+ const actions = el('div', 'mcp-actions');
12355
+ actions.append(save, sxButton(t('Cancel'), () => { mcpUI.adding = null; drawMcpTarget(); }));
12356
+ block.append(actions);
12357
+ }
12358
+ async function openMcpImport() {
12359
+ try {
12360
+ const { found } = await mcpCall('/api/x/mcp/import');
12361
+ mcpUI.found = found;
12362
+ // Nothing is picked for the person: an app's own internal servers are listed too.
12363
+ mcpUI.picked = new Set();
12364
+ } catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); return; }
12365
+ drawMcpTarget();
12366
+ }
12367
+ function drawMcpImport(host) {
12368
+ const found = mcpUI.found;
12369
+ const block = sxBlock(host, t('Import from your apps'), found.length
12370
+ ? t('Tools you already set up in other apps. Nothing is copied until you import; then MADRE keeps their keys safe and checks each one.')
12371
+ : t('No outside tools were found in .mcp.json, opencode.json, Claude Code, Codex, Gemini CLI or Cursor.'), { className: 'mcp-block' });
12372
+ for (const one of found) {
12373
+ const box = el('input');
12374
+ box.type = 'checkbox';
12375
+ box.checked = mcpUI.picked.has(one.index);
12376
+ box.disabled = one.taken;
12377
+ box.setAttribute('aria-label', one.name);
12378
+ box.addEventListener('change', () => { if (box.checked) mcpUI.picked.add(one.index); else mcpUI.picked.delete(one.index); });
12379
+ const label = el('label', 'mcp-pick');
12380
+ label.append(box);
12381
+ const secrets = one.env.length + one.headers.length;
12382
+ sxRow(block, `${one.name} · ${one.source}`, `${one.where}${secrets ? ` · ${t('{n} secrets to keep safe', { n: secrets })}` : ''}${one.taken ? ` · ${t('already in this project')}` : one.disabled ? ` · ${t('off in that app')}` : ''}`, label);
12383
+ }
12384
+ const actions = el('div', 'mcp-actions');
12385
+ if (found.length) {
12386
+ const go = sxButton(t('Import and check'), async () => {
12387
+ go.disabled = true; go.textContent = t('Checking…');
12388
+ try {
12389
+ const { results } = await mcpCall('/api/x/mcp/import', { picks: [...mcpUI.picked] });
12390
+ const ok = results.filter((one) => one.ok).length;
12391
+ toast(t('{ok} of {n} imported tools answered', { ok, n: results.length }), { tone: ok === results.length ? 'done' : 'look' });
12392
+ } catch (error) { toast(`${t('Diagnostics › ')}${resay(error.message)}`); }
12393
+ mcpUI.found = null;
12394
+ drawMcpTarget(); syncSettingsCards();
12395
+ }, { tone: 'primary' });
12396
+ actions.append(go);
12397
+ }
12398
+ actions.append(sxButton(t('Close'), () => { mcpUI.found = null; drawMcpTarget(); }));
12399
+ block.append(actions);
12400
+ }
12401
+
11660
12402
  function drawFilesSettings(host) {
11661
12403
  const tree = surfaces.find((one) => one.key === 'tree');
11662
12404
  const where = sxBlock(host, t('Where they open'), t('Each one remembers how you left it. This is the same choice, without opening them.'));
@@ -11667,15 +12409,15 @@ function drawFilesSettings(host) {
11667
12409
  else { surfaceModes.tree = value; rememberSurfaces(); }
11668
12410
  syncSettingsCards();
11669
12411
  }, { label: t('Files') }));
11670
- sxRow(where, t('Your servers'), t('Your servers, the ones answering on this computer: docked beside the conversation, or floating.'), sxSeg([
12412
+ sxRow(where, t('Your servers'), t('Web pages your project runs on this computer: beside the conversation, or floating.'), sxSeg([
11671
12413
  { value: 'dock', text: t('Docked') }, { value: 'window', text: t('Window') },
11672
12414
  ], browserWindow.floating ? 'window' : 'dock', (value) => { setBrowserFloating(value === 'window', { animate: false }); }, { label: t('Your servers') }));
11673
12415
  sxRow(where, t('File viewer'), t('It always opens beside the conversation: opening a file never splits the project on its own. From there it can float as a window.'));
11674
12416
 
11675
- const show = sxBlock(host, t('What they can show'), t('Two modules decide it; their switches are the same ones as in MODULES.'));
12417
+ const show = sxBlock(host, t('What they can show'), t('These switches are the same ones as in MODULES.'));
11676
12418
  for (const [id, label, hint] of [
11677
- ['ripley', t('Preview'), t('HTML, SVG and Markdown from the project render in the file viewer, inside a frame that reaches nothing. Off, files show as source.')],
11678
- ['playwright', t("Test browser"), t('A browser without a window that reaches only this project and the servers you start, on the turns that ask to look at a page.')],
12419
+ ['ripley', t('Preview'), t('Web pages, images and documents show as they look, safely. Off, you see their code.')],
12420
+ ['playwright', t("Test browser"), t('Agents can open and check your project\'s pages, without reaching anything else.')],
11679
12421
  ]) {
11680
12422
  const item = modules.items.find((one) => one.id === id);
11681
12423
  if (!item) { sxRow(show, label, t('Reading…')); continue; }
@@ -11766,7 +12508,7 @@ function updateNote(item, update) {
11766
12508
  if (update.available) return t('{what} {latest} is out; this computer has {current}.', { what, latest: update.latest, current: update.current });
11767
12509
  if (update.latest && !update.current) return t('{what} {latest} is the newest release. It is not on this computer yet.', { what, latest: update.latest });
11768
12510
  if (update.latest) return t('{what} {latest} is the newest, and it is what this computer has.', { what, latest: update.latest });
11769
- return t('MADRE could not reach the place that knows about {what}.', { what });
12511
+ return t('MADRE could not check whether there is a newer {what}.', { what });
11770
12512
  }
11771
12513
 
11772
12514
  // A fold inside a card. It remembers whether it was left open, like every other fold in MADRE,
@@ -12244,7 +12986,7 @@ async function renderEconomy(box) {
12244
12986
  catch { box.replaceChildren(el('p', 'note', t('SPENDING UNAVAILABLE'))); return; }
12245
12987
  box.replaceChildren();
12246
12988
  if (!read?.turns) {
12247
- box.append(el('p', 'note', t('NO TURNS WEIGHED YET · SEND A MESSAGE AND THIS FILLS')));
12989
+ box.append(el('p', 'note', t('NO SPENDING RECORDED YET · SEND A MESSAGE AND THIS FILLS')));
12248
12990
  return;
12249
12991
  }
12250
12992
  const saved = read.saved ?? {};
@@ -12254,12 +12996,12 @@ async function renderEconomy(box) {
12254
12996
  // different kinds have no shape to draw, and drawing one anyway is how a panel starts lying.
12255
12997
  const strip = el('div', 'economy-strip');
12256
12998
  for (const [value, label, hint] of [
12257
- [count(read.billed ?? read.turns), t('TURNS WEIGHED'), t('every turn this project has been billed for')],
12999
+ [count(read.billed ?? read.turns), t('ANSWERS BILLED'), t('every turn this project has been billed for')],
12258
13000
  [count((totals.input ?? 0) + (totals.created ?? 0)), t('INPUT CHARGED'), t('fresh reading, plus what was written into a cache (billed too) · the cache read back is apart')],
12259
- [count(totals.output), t('OUTPUT CHARGED'), t('tokens written back · the dearer half')],
13001
+ [count(totals.output), t('OUTPUT CHARGED'), t('tokens the agent writes in its answer · the more expensive part')],
12260
13002
  [count(saved.tokens), t('NOT CHARGED IN FULL'), t('read back from a cache (a fraction of the price, or of the plan) plus what was never sent')],
12261
13003
  [pct(totals.cacheShare), t('CAME FROM CACHE'), t('of everything read, the share nobody charged twice')],
12262
- [totals.charsPerInputToken ? totals.charsPerInputToken.toFixed(2) : '—', t('CHARACTERS PER TOKEN'), t('what MADRE wrote against what the CLIs were charged for reading · not a tokenizer')],
13004
+ [totals.charsPerInputToken ? totals.charsPerInputToken.toFixed(2) : '—', t('CHARACTERS PER TOKEN'), t('what MADRE wrote compared with what the agents were charged for reading · a ratio, not an exact count')],
12263
13005
  ]) {
12264
13006
  const cell = el('div', 'economy-stat');
12265
13007
  cell.title = hint;
@@ -12275,7 +13017,7 @@ async function renderEconomy(box) {
12275
13017
  first.append(el('p', 'unit-note', t('tokens read back from a cache at a fraction of their price, or never sent at all')));
12276
13018
  metrics(first, [
12277
13019
  [t('FROM THE CACHE'), count(saved.cachedTokens), t('the CLI said it read these back instead of charging them again · measured')],
12278
- [t('NEVER SENT'), count(saved.unsentTokens), t('briefing a turn had no use for · estimated from characters, not measured')],
13020
+ [t('NEVER SENT'), count(saved.unsentTokens), t('part of what MADRE sends the agent that this answer did not need · estimated from characters, not measured')],
12279
13021
  [t('OF THE INPUT'), pct(saved.cachedShare), t('the share of everything read that came back from a cache')],
12280
13022
  ]);
12281
13023
  first.append(el('p', 'note', t('One half is measured and the other is estimated, and they are never added without saying so. Neither of them is the Token saver: the Token saver shortens the answer, which is the next reading.')));
@@ -12288,8 +13030,8 @@ async function renderEconomy(box) {
12288
13030
  free.append(cardHead('home', t('TURNS THE PROJECT ANSWERED ITSELF')));
12289
13031
  metrics(free, [
12290
13032
  [t('FREE TURNS'), count(read.free.turns), t('answered by @madre on this computer · no provider, no bill')],
12291
- [t('OF EVERY TURN'), pct(read.free.share), t('of the turns weighed in this project')],
12292
- [t('A PAID TURN'), read.free.paidOutputMedian ? `${count(read.free.paidOutputMedian)}↑` : '—', t('the middle paid turn here · what the others cost, not what these would have')],
13033
+ [t('OF EVERY TURN'), pct(read.free.share), t('of the answers measured in this project')],
13034
+ [t('A PAID TURN'), read.free.paidOutputMedian ? `${count(read.free.paidOutputMedian)}↑` : '—', t('the typical (median) paid answer here · what the others cost, not what these would have')],
12293
13035
  ]);
12294
13036
  free.append(el('p', 'note', t('These turns have no bill because nothing left this computer to answer them. MADRE does not say what they would have cost somewhere else: nobody measured that.')));
12295
13037
  box.append(free);
@@ -12305,25 +13047,25 @@ async function renderEconomy(box) {
12305
13047
  const block = read.blocks.find((one) => one.id === 'ash');
12306
13048
  if (block) {
12307
13049
  metrics(second, [
12308
- [t('TOKEN SAVER ADDS'), t('{n} characters', { n: block.perTurn }), t('to every prompt it is on · measured, not estimated')],
13050
+ [t('TOKEN SAVER ADDS'), t('{n} characters', { n: block.perTurn }), t('to every prompt while it is on · measured, not estimated')],
12309
13051
  [t('IN TOKENS'), `≈${Math.round(block.perTurn / CH_PER_TOKEN)}`, t('at {rate} characters per token · an estimate', { rate: CH_PER_TOKEN })],
12310
- [t('TURNS CARRYING IT'), count(block.turns), t('of the {n} weighed in this project', { n: count(read.turns) })],
13052
+ [t('TURNS CARRYING IT'), count(block.turns), t('of the {n} measured in this project', { n: count(read.turns) })],
12311
13053
  ]);
12312
13054
  second.append(el('p', 'note', t('What the Token saver costs is certain and measured here. What it saves is not: the shorter answer below is observed, never promised. Output is the dearer half of every bill, so the trade is usually favourable — MADRE does not turn it into money because it does not know your prices.')));
12313
13055
  }
12314
13056
  if (read.ash?.agents?.length) ashComparison(second, read.ash);
12315
- else second.append(el('p', 'note', t('No turns with the switch recorded yet. Leave the Token saver on for a few turns and off for a few more, and this fills.')));
13057
+ else second.append(el('p', 'note', t('No answers recorded yet with the Token saver on and off. Leave it on for a few answers and off for a few more, and the comparison shows here.')));
12316
13058
  box.append(second);
12317
13059
 
12318
13060
  // 3 · Where what WAS paid for went.
12319
13061
  const third = el('div', 'card-block');
12320
- third.append(cardHead('bars', t('WHERE EACH PROMPT GOES')));
13062
+ third.append(cardHead('bars', t('HOW EACH PROMPT IS SPLIT')));
12321
13063
  economyBlocks(third, read);
12322
- third.append(el('p', 'note', t('{pct} of each prompt is the unchanging head a cache can match. MADRE wrote {chars} characters of the {input} input tokens you were charged for; the rest is what the CLIs read on their own.', { pct: pct(read.totals.prefixShare), chars: count(read.totals.chars), input: count(read.totals.input) })));
13064
+ third.append(el('p', 'note', t('{pct} of each prompt is a fixed header the cache can reuse. MADRE wrote {chars} characters of the {input} input tokens you were charged for; the rest is what the agents\' apps read on their own.', { pct: pct(read.totals.prefixShare), chars: count(read.totals.chars), input: count(read.totals.input) })));
12323
13065
  // The tools a turn carried. A server's tool list goes into every call a CLI makes inside a turn;
12324
13066
  // the middle turn with it against the middle turn without it, per agent, with the counts behind.
12325
13067
  if (read.servers?.length) {
12326
- third.append(el('p', 'unit-note', t('Fresh input of the middle turn with each tool server attached, against without it.')));
13068
+ third.append(el('p', 'unit-note', t('Fresh input tokens of a typical (median) turn with each outside tool (MCP) connected, and without it.')));
12327
13069
  for (const one of read.servers) {
12328
13070
  third.append(el('p', 'note', t('@{agent} · {server}: {with} with it ({nWith} turns) · {without} without it ({nWithout} turns)', { agent: one.agent, server: one.server, with: count(one.medianWith), nWith: one.with, without: count(one.medianWithout), nWithout: one.without })));
12329
13071
  }
@@ -12400,7 +13142,7 @@ function ashComparison(box, ash) {
12400
13142
  group.append(row);
12401
13143
  }
12402
13144
  } else {
12403
- group.append(el('p', 'ash-waiting', t('{n} turns with TOKEN SAVER, {m} without · {min} of each before this can be read', {
13145
+ group.append(el('p', 'ash-waiting', t('{n} answers with TOKEN SAVER, {m} without · {min} of each are needed to compare', {
12404
13146
  n: agent.on.turns, m: agent.off.turns, min: ash.minTurns,
12405
13147
  })));
12406
13148
  }
@@ -12408,13 +13150,13 @@ function ashComparison(box, ash) {
12408
13150
  // vary anyway, the honest thing on the screen is that there is nothing to read here yet.
12409
13151
  if (agent.comparable) {
12410
13152
  group.append(el('p', agent.readable ? 'ash-verdict' : 'ash-verdict noise', agent.readable
12411
- ? t('{pct} shorter with TOKEN SAVER, wider than this agent varies here (±{on} and ±{off})', { pct: `${Math.round(Math.abs(agent.share) * 100)}%`, on: agent.on.spread.toLocaleString(), off: agent.off.spread.toLocaleString() })
12412
- : t('the difference fits inside how much this agent varies anyway (±{on} and ±{off}) · nothing to read yet', { on: agent.on.spread.toLocaleString(), off: agent.off.spread.toLocaleString() })));
13153
+ ? t('{pct} shorter with TOKEN SAVER: more than this agent\'s normal variation here (±{on} and ±{off})', { pct: `${Math.round(Math.abs(agent.share) * 100)}%`, on: agent.on.spread.toLocaleString(), off: agent.off.spread.toLocaleString() })
13154
+ : t('the difference fits inside how much this agent varies anyway (±{on} and ±{off}) · no conclusion yet', { on: agent.on.spread.toLocaleString(), off: agent.off.spread.toLocaleString() })));
12413
13155
  }
12414
13156
  list.append(group);
12415
13157
  }
12416
13158
  box.append(list);
12417
- box.append(el('p', 'note', t('The middle turn of each side, not an average. You choose when to ask for compact prose, so the two sides are different tasks, not the same task twice — MADRE does not claim a percentage saved.')));
13159
+ box.append(el('p', 'note', t('The median answer of each group, not an average. You choose when to ask for compact answers, so the two groups are different tasks, not the same task twice — MADRE does not claim a percentage saved.')));
12418
13160
  }
12419
13161
 
12420
13162
  // The economy, on the card of the thing it is about. It fills as the room is used, because it
@@ -12436,7 +13178,7 @@ function paintAshEconomy(box) {
12436
13178
  return fetch('/api/economy').then((response) => response.json()).then((read) => {
12437
13179
  box.replaceChildren(el('h5', null, t('SPENDING · THIS PROJECT')));
12438
13180
  if (!read?.turns) {
12439
- box.append(el('p', 'note', t('NO TURNS WEIGHED YET · SEND A MESSAGE AND THIS FILLS')));
13181
+ box.append(el('p', 'note', t('NO SPENDING RECORDED YET · SEND A MESSAGE AND THIS FILLS')));
12440
13182
  return;
12441
13183
  }
12442
13184
  const totals = read.totals;
@@ -12444,10 +13186,10 @@ function paintAshEconomy(box) {
12444
13186
  metrics(box, [
12445
13187
  [t('TOKENS SAVED'), count(saved.tokens), t('read back from the CLI cache, plus what was never sent')],
12446
13188
  [t('FROM CACHE'), count(saved.cachedTokens), t('input the CLI did not charge again · {pct} of the input', { pct: pct(saved.cachedShare) })],
12447
- [t('NEVER SENT'), `${count(saved.unsentChars)} CH`, t('briefing a turn had no use for')],
12448
- [t('SPENT IN'), count(totals.input), t('input tokens actually charged')],
12449
- [t('SPENT OUT'), count(totals.output), t('output tokens, the dearer half')],
12450
- [t('TURNS'), count(read.turns), t('weighed so far')],
13189
+ [t('NEVER SENT'), `${count(saved.unsentChars)} CH`, t('part of what MADRE sends the agent that this answer did not need')],
13190
+ [t('SPENT ON INPUT'), count(totals.input), t('input tokens actually charged')],
13191
+ [t('SPENT ON OUTPUT'), count(totals.output), t('output tokens, the dearer half')],
13192
+ [t('TURNS'), count(read.turns), t('measured so far')],
12451
13193
  ]);
12452
13194
  }).catch(() => { box.replaceChildren(el('h5', null, t('SPENDING · THIS PROJECT')), el('p', 'note', t('SPENDING UNAVAILABLE'))); });
12453
13195
  }
@@ -12782,7 +13524,7 @@ function builtinCard(item) {
12782
13524
  const status = el('dl', 'ollama-status');
12783
13525
  const put = (k, v) => { status.append(el('dt', null, k), el('dd', null, v)); };
12784
13526
  put(t('SERVER'), info.running ? t('running · {host}', { host: info.host }) : t('not running'));
12785
- put(t('EMBEDDINGS'), info.embedModel ? `${info.embedModel}${info.settings.embeddings === false ? t(' · off') : ''}` : t('no embedding model'));
13527
+ put(t('SEARCH BY MEANING'), info.embedModel ? `${info.embedModel}${info.settings.embeddings === false ? t(' · off') : ''}` : t('no embedding model'));
12786
13528
  put(t('ARCHIVIST'), info.chatModel ? `${info.chatModel}${info.settings.archivist === false ? t(' · off') : ''}` : t('no chat model'));
12787
13529
  if (info.models?.length) put(t('MODELS'), info.models.map((model) => model.name).join(', '));
12788
13530
  read.append(status);
@@ -12795,7 +13537,7 @@ function builtinCard(item) {
12795
13537
  const roles = cardBlock(panel, t('ROLES'));
12796
13538
  const row = el('div', 'card-toggles');
12797
13539
  for (const [role, model, present, label] of [
12798
- ['embeddings', item.recommended?.embed ?? 'nomic-embed-text', Boolean(info.embedModel), t('EMBEDDINGS')],
13540
+ ['embeddings', item.recommended?.embed ?? 'nomic-embed-text', Boolean(info.embedModel), t('SEARCH BY MEANING')],
12799
13541
  ['archivist', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), t('ARCHIVIST')],
12800
13542
  ['agent', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), t('@madre in the project')],
12801
13543
  ]) {
@@ -12929,7 +13671,7 @@ function builtinCard(item) {
12929
13671
  }
12930
13672
 
12931
13673
  if (fixed) {
12932
- actions.append(el('span', 'note', item.install?.display ? item.install.display.toUpperCase() : t('NO SWITCH')));
13674
+ actions.append(el('span', 'note', item.install?.display ? item.install.display.toUpperCase() : t('CANNOT BE TURNED OFF')));
12933
13675
  return card;
12934
13676
  }
12935
13677
 
@@ -12944,7 +13686,7 @@ function builtinCard(item) {
12944
13686
  const response = await reach(`/api/extensions/${item.id}/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }) });
12945
13687
  clearTimeout(trying);
12946
13688
  const result = await response.json().catch(() => ({}));
12947
- if (!response.ok) toast(result.error ?? t('{name} could not be switched.', { name: item.name }));
13689
+ if (!response.ok) toast(result.error ?? t('{name} could not be turned on or off.', { name: item.name }));
12948
13690
  else {
12949
13691
  toast(`${t('Diagnostics › ')}${t(result.enabled ? '{name} enabled' : '{name} disabled', { name: t(item.name) })}${result.enabled && result.model ? ` · ${result.model}` : ''}.`);
12950
13692
  if (result.capabilities) { state.capabilities = result.capabilities; renderCreateScopes(); }
@@ -13106,8 +13848,8 @@ function moduleGlance(item) {
13106
13848
  strip.classList.add('loading');
13107
13849
  fetch('/api/economy').then((response) => response.json()).then((read) => {
13108
13850
  strip.classList.remove('loading');
13109
- if (!read?.turns) { fill([[t('Tokens not charged'), '0', t('nothing weighed yet')], [t('State'), standing]]); return; }
13110
- fill([[t('Tokens not charged'), Number(read.saved?.tokens ?? 0).toLocaleString(), t('read back from a cache, or never sent')], [t('Turns weighed'), String(read.turns)], [t('Read from cache'), Number.isFinite(read.saved?.cachedShare) ? `${Math.round(read.saved.cachedShare * 100)}%` : '—'], [t('State'), standing]]);
13851
+ if (!read?.turns) { fill([[t('Tokens not charged'), '0', t('nothing measured yet')], [t('State'), standing]]); return; }
13852
+ fill([[t('Tokens not charged'), Number(read.saved?.tokens ?? 0).toLocaleString(), t('read back from a cache, or never sent')], [t('Turns measured'), String(read.turns)], [t('Read from cache'), Number.isFinite(read.saved?.cachedShare) ? `${Math.round(read.saved.cachedShare * 100)}%` : '—'], [t('State'), standing]]);
13111
13853
  }).catch(() => { strip.classList.remove('loading'); fill([[t('State'), standing]]); });
13112
13854
  } else if (item.id === 'intents') {
13113
13855
  strip.classList.add('loading');
@@ -13127,7 +13869,7 @@ function moduleGlance(item) {
13127
13869
  }).catch(() => { strip.classList.remove('loading'); fill([[t('State'), standing]]); });
13128
13870
  } else if (item.id === 'ollama') {
13129
13871
  const info = item.ollama ?? {};
13130
- fill([[t('Server'), info.running ? t('Running') : t('Stopped'), info.host ?? null], [t('Models'), String(info.models?.length ?? 0)], [t('Smart search'), info.embedModel ?? t('none')], [t('State'), standing]]);
13872
+ fill([[t('Server'), info.running ? t('Running') : t('Stopped'), info.host ?? null], [t('Models'), String(info.models?.length ?? 0)], [t('Search by meaning'), info.embedModel ?? t('none')], [t('State'), standing]]);
13131
13873
  }
13132
13874
  // A module that keeps no figures of its own gets none made up: its sheet opens on what it does.
13133
13875
  return strip.childElementCount || strip.classList.contains('loading') ? strip : document.createDocumentFragment();
@@ -13240,7 +13982,7 @@ function moduleSheet(host, item) {
13240
13982
  void renderEconomy(app);
13241
13983
  }
13242
13984
  if (item.id === 'intents') {
13243
- const block = sxBlock(host, t('What your use taught'), `${t('Per agent and per kind of request: the lowest effort that was enough in your own turns, learned for no tokens. Below, a test that learns faster, for a price.')} ${t('When a lower level might be enough, Intenciones offers to try it on a single message and watches how it goes; if it falls short, the next one goes back up.')}`, { className: 'mods-reading mods-intents' });
13985
+ const block = sxBlock(host, t('What your use taught'), `${t('Per agent and per kind of request: the lowest effort that was enough in your own turns, learned for no tokens. Below, a test that learns faster, for a price.')} ${t('When a lower level might be enough, MADRE offers to try it on a single message and watches how it goes; if it falls short, the next one goes back up.')}`, { className: 'mods-reading mods-intents' });
13244
13986
  intentsReading(block.rows);
13245
13987
  }
13246
13988
  if (item.id === 'ollama') ollamaBlock(host, item);
@@ -13257,13 +13999,13 @@ function moduleSheet(host, item) {
13257
13999
  // Ollama's local brain as rows: the server, its models, the roles it takes in this room.
13258
14000
  function ollamaBlock(host, item) {
13259
14001
  const info = item.ollama ?? { running: false, models: [], settings: {} };
13260
- const block = sxBlock(host, t('Local brain'), t('Ollama on this computer: no provider tokens, nothing leaves.'));
14002
+ const block = sxBlock(host, t('Local AI'), t('Ollama on this computer: no provider tokens, nothing leaves.'));
13261
14003
  const recheck = sxButton(t('Check again'), async () => { recheck.disabled = true; await fetch('/api/ollama/probe', { method: 'POST' }).catch(() => null); await refreshModules(); });
13262
14004
  sxRow(block, t('Server'), info.running ? t('Running · {host}', { host: info.host }) : t('Not running'), recheck);
13263
14005
  // Whether this project uses it, beside Home's choice for all of them (src/local-ai.mjs).
13264
14006
  const project = info.project;
13265
14007
  if (project && !project.home) {
13266
- const why = !project.master ? t('Local AI is switched off for every project.')
14008
+ const why = !project.master ? t('Local AI is off for every project.')
13267
14009
  : project.mode === 'auto' ? t('Home turns it on in every project, and «Active» switches it for all of them. To choose project by project, go to Home › Settings › Local AI.')
13268
14010
  : project.mode === 'pick' ? t('Home chooses the projects one by one; «Active» switches this one here too.')
13269
14011
  : t('Each project decides on its own: «Active» is this project’s switch.');
@@ -13290,7 +14032,7 @@ function ollamaBlock(host, item) {
13290
14032
  }
13291
14033
  const roles = sxBlock(host, t('Roles'), t('What Local AI does in this project.'));
13292
14034
  for (const [role, model, present, label, hint] of [
13293
- ['embeddings', item.recommended?.embed ?? 'nomic-embed-text', Boolean(info.embedModel), t('Smart search'), info.embedModel ?? null],
14035
+ ['embeddings', item.recommended?.embed ?? 'nomic-embed-text', Boolean(info.embedModel), t('Search by meaning'), info.embedModel ?? null],
13294
14036
  ['archivist', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), t('Archivist'), info.chatModel ?? null],
13295
14037
  ['agent', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), t('@madre in the project'), info.chatModel ?? null],
13296
14038
  ]) {
@@ -13361,7 +14103,9 @@ function renderModules() {
13361
14103
  }
13362
14104
  const grid = el('div', 'mods-grid');
13363
14105
  // What is working for you first, what you could turn on after.
13364
- const ordered = [...modules.items.filter(moduleOn), ...modules.items.filter((item) => !moduleOn(item))];
14106
+ // A module whose settings live in the project's Settings (Connect MCP) has no tile here.
14107
+ const offered = modules.items.filter((item) => item.card !== 'settings');
14108
+ const ordered = [...offered.filter(moduleOn), ...offered.filter((item) => !moduleOn(item))];
13365
14109
  const shown = ordered.filter((item) => !query || settingsFold(`${item.name} ${t(moduleFace(item).line ?? '')} ${item.summary ?? ''}`).includes(query));
13366
14110
  for (const item of shown) grid.append(moduleTile(item));
13367
14111
  if (!query) {
@@ -13527,7 +14271,7 @@ function renderModulesDev() {
13527
14271
  renderModules();
13528
14272
  } catch (error) { toast(t('The reload failed: {error}', { error: error.message })); } finally { reload.disabled = false; }
13529
14273
  }, { title: t('Load your module files again without restarting the project') });
13530
- step(t('Load it'), t('Press reload and it appears on the board with its own card, no restart.'), reload);
14274
+ step(t('Load it'), t('Press Reload modules and it appears on the board with its own card, no restart.'), reload);
13531
14275
  step(t('If it talks to a service'), t('Connecting to a service from outside is almost always possible, if that service lets you get your own key in minutes and revoke it. Your module declares the key it needs, where it reaches and which of its tools send; MADRE draws the field, answers for the traffic and holds sending until #4 FULL ACCESS.'));
13532
14276
  section.append(steps);
13533
14277
  const yours = modules.items.filter((item) => item.external);
@@ -13596,7 +14340,6 @@ function openModules() {
13596
14340
  setTimeout(() => node.classList.remove('entering'), motion(460));
13597
14341
  placeModules(modulesBoxFor(), { chosen: false });
13598
14342
  }
13599
- if (windowsAside) setWindowsAside(false);
13600
14343
  bringWindowForward(node);
13601
14344
  modules.button.setAttribute('aria-pressed', 'true');
13602
14345
  }
@@ -13609,7 +14352,7 @@ function closeModules() {
13609
14352
  // Open, behind or set aside: the button brings it to the front; in front, it closes it.
13610
14353
  modules.button.addEventListener('click', async () => {
13611
14354
  const node = modules.dialog;
13612
- if (node.open && node.classList.contains('front') && !windowsAside) { closeModules(); return; }
14355
+ if (node.open && node.classList.contains('front') && !node.classList.contains('aside')) { closeModules(); return; }
13613
14356
  openModules();
13614
14357
  await refreshModules();
13615
14358
  renderModules();
@@ -13731,52 +14474,75 @@ function openAgentPop(id, anchor, { back = null } = {}) {
13731
14474
  head.append(previous);
13732
14475
  }
13733
14476
  head.append(avatar(id, { size: 24 }));
13734
- head.append(el('b', null, agent.label));
13735
- head.append(el('span', 'vendor', vendorWord(id)));
14477
+ const who = el('div', 'pop-who');
14478
+ who.append(el('b', null, agent.label), el('span', 'vendor', vendorWord(id)));
14479
+ head.append(who);
14480
+ // The session in the corner: the one thing to know before asking it anything.
14481
+ const lamp = session?.state === 'signed-in' ? 'ok' : session?.state === 'signed-out' || !agent.ready ? 'bad' : 'wait';
14482
+ head.append(el('span', `pop-session ${lamp}`, session ? ({ 'signed-in': t('signed in'), 'signed-out': t('signed out') }[session.state] ?? t('unknown')) : agent.ready ? t('unknown') : t('not ready')));
13736
14483
  pop.append(head);
13737
- // The card reads top to bottom: how much is spent (one number, its gauge, when it comes back),
13738
- // then three short groups — the provider's limits, this room, the connection — each with its
13739
- // title, labels on the left and values on the right, then what the CLI can do.
14484
+ // Read top to bottom (2026-10-14): one figure and its gauge, four plain numbers, what the
14485
+ // provider says (or that it says nothing), the connection in one line, what it can do, and the
14486
+ // way to its settings.
13740
14487
  const hero = el('div', 'pop-hero');
13741
- const big = el('div', 'big', official === null ? (formatTokens(agent.tokens ?? 0) || '0') : `${Math.round(official)}%`);
13742
- big.append(el('small', null, official === null ? t('budget tokens · local 5h window') : t("of the provider's {window} limit", { window: fmtWindow(agent.officialWindows?.primary?.windowMinutes ?? 300) })));
14488
+ let figure;
14489
+ let caption;
14490
+ if (official !== null) {
14491
+ figure = `${Math.round(official)}%`;
14492
+ caption = t("of the provider's {window} limit", { window: fmtWindow(agent.officialWindows?.primary?.windowMinutes ?? 300) });
14493
+ } else if (state.budget) {
14494
+ figure = `${Math.round(percent)}%`;
14495
+ caption = t("of this project's limit · last 5 h");
14496
+ } else {
14497
+ figure = formatTokens(agent.tokens ?? 0) || '0';
14498
+ caption = t('tokens in the last 5 h');
14499
+ }
14500
+ const big = el('div', 'big', figure);
14501
+ big.append(el('small', null, caption));
13743
14502
  hero.append(big);
13744
14503
  const gauge = el('div', `gauge${shown >= 80 ? ' hot' : ''}`);
13745
14504
  const fill = el('i'); fill.style.width = `${shown}%`; gauge.append(fill);
13746
14505
  hero.append(gauge);
13747
- if (official !== null) hero.append(el('div', 'pop-reset', (agent.officialResetAt ? t(' · resets {when}', { when: fmtReset(agent.officialResetAt) }) : t(' · window reset')).replace(/^\s*·\s*/, '')));
14506
+ const under = official !== null
14507
+ ? (agent.officialResetAt ? t(' · resets {when}', { when: fmtReset(agent.officialResetAt) }) : t(' · window reset')).replace(/^\s*·\s*/, '')
14508
+ : state.budget ? t('{used} of {total} tokens', { used: formatTokens(agent.tokens ?? 0) || '0', total: formatTokens(state.budget) }) + (agent.rollsOverAt ? t(' · oldest turn drops {when}', { when: fmtReset(agent.rollsOverAt) }) : '') : null;
14509
+ if (under) hero.append(el('div', 'pop-reset', under));
13748
14510
  pop.append(hero);
13749
- const group = (title) => {
13750
- const box = el('section', 'pop-group');
13751
- const dl = el('dl');
13752
- box.append(el('h4', null, title), dl);
13753
- pop.append(box);
13754
- return (k, v, cls) => { dl.append(el('dt', null, k)); dl.append(el('dd', cls, v)); };
13755
- };
13756
- const limits = group(t('Provider limits'));
14511
+
14512
+ const grid = el('div', 'pop-stats');
14513
+ const stat = (label, value) => { const box = el('div', 'pop-stat'); box.append(el('span', null, label), el('b', null, value)); grid.append(box); };
14514
+ stat(t('In this project'), `${formatTokens(agent.rawTokens ?? 0) || '0'} tok`);
14515
+ stat(t('Answers'), String(stats.turns));
14516
+ stat(t('Last answer'), stats.lastTurnMs ? `${fmtMs(stats.lastTurnMs)}${stats.lastTurnTokens ? ` · ${formatTokens(stats.lastTurnTokens)}` : ''}` : '—');
14517
+ stat(stats.costEstimated ? t('Cost (estimated)') : t('Cost'), stats.cost > 0 ? `${stats.costEstimated ? '≈ ' : ''}${fmtUsd(stats.cost)}` : '—');
14518
+ pop.append(grid);
14519
+
14520
+ const provider = el('section', 'pop-provider');
13757
14521
  if (agent.officialWindows) {
13758
14522
  const { primary, secondary } = agent.officialWindows;
13759
- if (primary) limits(t('{window} limit', { window: fmtWindow(primary.windowMinutes) }), primary.resetAt ? t('{pct}% · resets {when}', { pct: Math.round(primary.usedPercent), when: fmtReset(primary.resetAt) }) : t('{pct}% · reset, awaiting fresh data', { pct: Math.round(primary.usedPercent) }), primary.usedPercent >= 80 ? 'off' : null);
13760
- if (secondary) limits(t('{window} limit', { window: fmtWindow(secondary.windowMinutes) }), `${Math.round(secondary.usedPercent)}%${secondary.resetAt ? t(' · resets {when}', { when: fmtReset(secondary.resetAt) }) : ''}`, secondary.usedPercent >= 80 ? 'off' : null);
13761
- if (agent.officialObservedAt) limits(t('reported'), t('{when} by {who}', { when: fmtReset(agent.officialObservedAt), who: agent.officialSource?.replace('official:', '') ?? t('the CLI') }));
14523
+ const line = (one) => {
14524
+ const row = el('div', `pop-limit${one.usedPercent >= 80 ? ' hot' : ''}`);
14525
+ row.append(el('span', null, t('{window} limit', { window: fmtWindow(one.windowMinutes) })), el('b', null, one.resetAt ? t('{pct}% · resets {when}', { pct: Math.round(one.usedPercent), when: fmtReset(one.resetAt) }) : `${Math.round(one.usedPercent)}%`));
14526
+ provider.append(row);
14527
+ };
14528
+ provider.append(el('h4', null, t('Your plan')));
14529
+ if (primary) line(primary);
14530
+ if (secondary) line(secondary);
13762
14531
  } else if (Number.isFinite(agent.officialPercent)) {
13763
- limits(t('provider limit'), t('{pct}% used', { pct: Math.round(official ?? 0) }));
14532
+ provider.append(el('h4', null, t('Your plan')), el('p', 'pop-note', t('{pct}% used', { pct: Math.round(official ?? 0) })));
13764
14533
  } else {
13765
- limits(t('provider limit'), t('not published by this CLI · Usage shows the local window'));
13766
- }
13767
- const room = group(t('This project'));
13768
- room(t('local window'), state.budget ? t('{pct}% of {total} · 5h rolling', { pct: Math.round(percent), total: formatTokens(state.budget) }) + (agent.rollsOverAt ? t(' · oldest turn drops {when}', { when: fmtReset(agent.rollsOverAt) }) : '') : t('unbounded'));
13769
- if (agent.rawTokens) room(t('all-time in project'), `${formatTokens(agent.rawTokens)} tok`);
13770
- room(t('turns'), String(stats.turns));
13771
- room(t('last turn'), `${fmtMs(stats.lastTurnMs)}${stats.lastTurnTokens ? ` · ${formatTokens(stats.lastTurnTokens)} tok` : ''}`);
13772
- if (stats.cost > 0) room(stats.costEstimated ? t('cost (estimated)') : t('cost (reported)'), `${stats.costEstimated ? '≈ ' : ''}${fmtUsd(stats.cost)}`);
13773
- const link = group(t('Connection'));
13774
- link(t('session'), session ? ({ 'signed-in': t('signed in'), 'signed-out': t('signed out') }[session.state] ?? t('unknown')) : agent.ready ? t('unknown') : t('not ready'), `session-lamp ${session?.state === 'signed-in' ? 'ok' : session?.state === 'signed-out' || !agent.ready ? 'bad' : 'wait'}`);
13775
- if (session?.detail) link(t('via'), session.detail);
13776
- link(t('timeout'), `${Math.round((state.timeouts[id] ?? 180000) / 1000)}s`);
13777
- if (agent.version) link(t('version'), agent.version);
14534
+ provider.append(el('p', 'pop-note', t("{agent}'s app does not say how much of your plan is left. What you see here is only what this project spent.", { agent: agent.label })));
14535
+ }
14536
+ pop.append(provider);
14537
+
14538
+ const timeout = Math.round((state.timeouts[id] ?? 180000) / 1000);
14539
+ const meta = [session?.detail, agent.version ? t('version {version}', { version: agent.version }) : null, t('waits up to {time}', { time: timeout >= 120 ? t('{n} min', { n: Math.round(timeout / 60) }) : `${timeout}s` })].filter(Boolean);
14540
+ pop.append(el('p', 'pop-meta', meta.join(' · ')));
13778
14541
  pop.append(capabilityBadges(id));
13779
- pop.append(el('div', 'foot', official === null ? t("local window, not the provider's bill · ⚙ connections in Diagnostics") : t('provider limit as the CLI reports it · ⚙ connections in Diagnostics')));
14542
+ const go = el('button', 'pop-go', t('Connection and limits in Settings'));
14543
+ go.type = 'button';
14544
+ go.addEventListener('click', (event) => { event.stopPropagation(); closeAgentPop(); openSettingsOn('agents', { agent: id }); });
14545
+ pop.append(go);
13780
14546
  pop.dataset.for = id;
13781
14547
  pop.dataset.from = back ? 'crew' : 'sphere';
13782
14548
  placeAgentPop(anchor);
@@ -14159,7 +14925,7 @@ async function fillCoreBoard() {
14159
14925
  board.append(brewHead(t('YOU'), projectDesignation() || null));
14160
14926
  const you = el('div', 'mem-kpis');
14161
14927
  memTile(you, { label: t('SIGNED IN'), value: `${signed.length}/${crew.length}`, sub: signed.length ? signed.map((agent) => `@${agent.id}`).join(' · ') : t('NOBODY YET') });
14162
- memTile(you, { label: t('PROJECT WINDOW'), value: formatTokens(spent) || '0', sub: state.budget ? t('OF {budget} TOKENS · 5H', { budget: formatTokens(state.budget) }) : t('NO BUDGET SET'), meter: state.budget ? Math.min(1, spent / state.budget) : null });
14928
+ memTile(you, { label: t('PROJECT USAGE'), value: formatTokens(spent) || '0', sub: state.budget ? t('OF {budget} TOKENS · 5H', { budget: formatTokens(state.budget) }) : t('NO BUDGET SET'), meter: state.budget ? Math.min(1, spent / state.budget) : null });
14163
14929
  memTile(you, { label: t('ALL-TIME IN PROJECT'), value: formatTokens(ever) || '0', sub: t('TOKENS THE AGENTS HAVE SPENT HERE') });
14164
14930
  board.append(you);
14165
14931
  if (crew.length) {
@@ -14206,7 +14972,7 @@ async function fillCoreBoard() {
14206
14972
  const local = el('div', 'mem-kpis');
14207
14973
  memTile(local, { label: t('SERVER'), value: o.running ? (o.version ? `v${o.version}` : t('ON')) : t('OFF'), sub: o.running ? o.host : (o.error ?? t('not running')) });
14208
14974
  memTile(local, { label: t('CHAT'), value: o.madreModel ?? o.chatModel ?? '—', sub: o.madreModel ? t('TRAINED FOR THIS PROJECT') : t('NOT TRAINED FOR THIS PROJECT YET') }).classList.add('word');
14209
- memTile(local, { label: t('EMBEDDINGS'), value: o.embedModel ?? '—', sub: d?.vectors ? t('{n} WITH A VECTOR', { n: memFmt((d.vectors.memories ?? 0) + (d.vectors.entries ?? 0)) }) : t('RECALL MATCHES WORDS ONLY') }).classList.add('word');
14975
+ memTile(local, { label: t('SEARCH BY MEANING'), value: o.embedModel ?? '—', sub: d?.vectors ? t('{n} SEARCHABLE BY MEANING', { n: memFmt((d.vectors.memories ?? 0) + (d.vectors.entries ?? 0)) }) : t('RECALL MATCHES WORDS ONLY') }).classList.add('word');
14210
14976
  const measured = state.localModel?.checked;
14211
14977
  memTile(local, { label: t('MEASURED HERE'), value: measured ? `${measured.matched}/${measured.n}` : '—', sub: measured ? t('LANDED WHERE THE AGENTS DID · {verdict}', { verdict: measured.passed ? t('READY') : t('NOT YET') }) : t('NOT MEASURED YET'), meter: measured?.n ? measured.matched / measured.n : null });
14212
14978
  board.append(local);
@@ -14220,7 +14986,7 @@ async function fillCoreBoard() {
14220
14986
  const row = el('div', 'mem-signal');
14221
14987
  const top = el('div', 'top');
14222
14988
  top.append(el('b', null, model.name), el('span', null, formatSize(model.size || 0)));
14223
- const role = [model.name === o.madreModel ? '@MADRE' : null, model.name === o.chatModel ? t('CHAT') : null, model.name === o.embedModel ? t('EMBEDDINGS') : null].filter(Boolean);
14989
+ const role = [model.name === o.madreModel ? '@MADRE' : null, model.name === o.chatModel ? t('CHAT') : null, model.name === o.embedModel ? t('SEARCH BY MEANING') : null].filter(Boolean);
14224
14990
  const facts = [...role, model.details?.parameter_size, model.details?.quantization_level, model.family].filter(Boolean);
14225
14991
  row.append(top, memMeter((model.size || 0) / largest));
14226
14992
  if (facts.length) row.append(el('span', 'detail', facts.join(' · ')));
@@ -14268,7 +15034,7 @@ async function fillCoreBoard() {
14268
15034
  return label;
14269
15035
  };
14270
15036
  does.append(
14271
- role('embeddings', t('EMBEDDINGS'), t('Turns each memory into a vector, so recall finds it by meaning.')),
15037
+ role('embeddings', t('SEARCH BY MEANING'), t('Prepares each memory so recall can find it by meaning.')),
14272
15038
  role('archivist', t('ARCHIVIST'), t('Distils the exchanges into memories, on this computer.')),
14273
15039
  role('agent', '@MADRE', t('Answers in the project as the fifth agent.')),
14274
15040
  );
@@ -14292,9 +15058,9 @@ async function fillCoreBoard() {
14292
15058
  if (value === (mem.embedProvider ?? 'auto')) option.selected = true;
14293
15059
  embed.append(option);
14294
15060
  }
14295
- embed.addEventListener('change', () => save({ embedProvider: embed.value }, t('smart search: {what}.', { what: embed.options[embed.selectedIndex].textContent.toLowerCase() })));
15061
+ embed.addEventListener('change', () => save({ embedProvider: embed.value }, t('search by meaning: {what}.', { what: embed.options[embed.selectedIndex].textContent.toLowerCase() })));
14296
15062
  const field = (word, control) => { const label = el('label', 'core-field'); label.append(el('span', 'k', word), control); return label; };
14297
- reads.append(field(t('ARCHIVIST'), archivist), field(t('EMBEDDINGS'), embed));
15063
+ reads.append(field(t('ARCHIVIST'), archivist), field(t('SEARCH BY MEANING'), embed));
14298
15064
  if (mem.envWins) reads.append(el('p', 'note', t('AN ENVIRONMENT VARIABLE IS SETTING SOME OF THIS, AND IT WINS OVER WHAT YOU CHOOSE HERE.')));
14299
15065
  }
14300
15066
  if (grid.childElementCount) board.append(grid);
@@ -14343,7 +15109,7 @@ function renderCoreStrip() {
14343
15109
  const field = el('textarea');
14344
15110
  field.id = 'core-text';
14345
15111
  field.rows = 1;
14346
- field.placeholder = t('the question you would send — the document rebuilds around it');
15112
+ field.placeholder = t('type the question you would send, and see here what MADRE would send the agent');
14347
15113
  field.value = core.text ?? '';
14348
15114
  field.addEventListener('input', () => {
14349
15115
  core.text = field.value;
@@ -14490,7 +15256,7 @@ function renderCoreDoc(briefing) {
14490
15256
  : t('THE WHOLE PROJECT STILL FITS')));
14491
15257
  if (window.omitted) carried.append(brewRow(t('LEFT BEHIND'), t('{n} OLDER MESSAGES · WHICH IS WHAT RECALL IS FOR', { n: window.omitted })));
14492
15258
  carried.append(brewRow(t('READ TO BUILD IT'), t('{m} MEMORIES · {q} EXACT QUOTES', { m: briefing.recalled, q: briefing.quoted })));
14493
- carried.append(brewRow(t('NOT COUNTED'), t('NONE OF THEM WAS COUNTED AS RECALLED: ASKING WHAT THE PROJECT WOULD SAY IS NOT THE PROJECT SAYING IT')));
15259
+ carried.append(brewRow(t('NOT COUNTED'), t('NONE OF THEM COUNTS AS USED: THIS IS A PREVIEW, NOT A REAL ANSWER')));
14494
15260
  carried.append(brewRow(t('WEIGHT'), t('{ch} CH · ≈{tokens} TOKENS · ESTIMATED AT {rate} CH/TOKEN, NOT MEASURED: MADRE DOES NOT HAVE THE PROVIDER\'S TOKENIZER', { ch: briefing.chars.toLocaleString(), tokens: tokensFor(briefing.chars).toLocaleString(), rate: CH_PER_TOKEN })));
14495
15261
  if (briefing.spared) carried.append(brewRow(t('LEFT OUT'), t('{n} CHARACTERS THIS TURN HAS NO USE FOR', { n: briefing.spared.toLocaleString() })));
14496
15262
  doc.append(carried);
@@ -14544,7 +15310,7 @@ function renderCoreLaunch(launch) {
14544
15310
  const servers = [...(launch.memoryServer ? [{ ...launch.memoryServer, brief: t('the archive of this project, read and written through MADRE') }] : []), ...(launch.mcpServers ?? [])]
14545
15311
  .filter((server, at, all) => all.findIndex((other) => other.name === server.name) === at);
14546
15312
  if (servers.length) {
14547
- floor.append(brewHead(t('SERVERS IT CAN CALL'), `${servers.length}`));
15313
+ floor.append(brewHead(t('OUTSIDE TOOLS (MCP) IT CAN USE'), `${servers.length}`));
14548
15314
  const rows = el('div', 'brew-rows');
14549
15315
  for (const server of servers) {
14550
15316
  const value = el('span');
@@ -14916,14 +15682,14 @@ async function drawMemoryDashboard(glance, analysis, stamp) {
14916
15682
  fetch('/api/maturity').then((response) => response.json()),
14917
15683
  fetch('/api/memory/dashboard').then((response) => response.json()).then((payload) => payload?.dashboard ?? null),
14918
15684
  ]);
14919
- } catch { glance.classList.remove('loading'); glance.replaceChildren(el('p', 'sx-hint', t('The archive could not be read.'))); return; }
15685
+ } catch { glance.classList.remove('loading'); glance.replaceChildren(el('p', 'sx-hint', t('The memory could not be read.'))); return; }
14920
15686
  glance.classList.remove('loading');
14921
15687
  glance.replaceChildren();
14922
15688
  analysis.replaceChildren();
14923
15689
  stamp.textContent = t('Read at {time}', { time: formatTime(new Date().toISOString()) });
14924
15690
  const m = read?.maturity;
14925
15691
  const v = read?.verdict;
14926
- if (!dash || !m || !v) { glance.append(el('p', 'sx-hint', t('Nothing to read yet: the project has no archive.'))); return; }
15692
+ if (!dash || !m || !v) { glance.append(el('p', 'sx-hint', t('Nothing yet: the project has no memories saved.'))); return; }
14927
15693
 
14928
15694
  const stats = el('div', 'sx-stats');
14929
15695
  const stat = (label, value, sub, spark = null, title = null) => {
@@ -14937,8 +15703,8 @@ async function drawMemoryDashboard(glance, analysis, stamp) {
14937
15703
  const week = (w) => (w ? `${t('{n} this week', { n: memFmt(w.last) })} · ${w.delta > 0 ? `▲ ${memFmt(w.delta)}` : w.delta < 0 ? `▼ ${memFmt(-w.delta)}` : '='}` : null);
14938
15704
  stat(t('Exchanges'), memFmt(dash.totals.entries), week(dash.weeks.entries), dash.series.entries.slice(-14), memWeekTitle(dash.weeks.entries));
14939
15705
  stat(t('Memories'), memFmt(dash.totals.memories), week(dash.weeks.memories), dash.series.memories.slice(-14), memWeekTitle(dash.weeks.memories));
14940
- stat(t('Turns that recalled'), memFmt(dash.totals.turns), week(dash.weeks.recalls), dash.series.recalls.slice(-14), memWeekTitle(dash.weeks.recalls));
14941
- stat(t('Waiting'), memFmt(dash.totals.pending), dash.totals.pending ? t('Exchanges nobody has distilled yet.') : t('The archivist is up to date.'));
15706
+ stat(t('Answers that used memories'), memFmt(dash.totals.turns), week(dash.weeks.recalls), dash.series.recalls.slice(-14), memWeekTitle(dash.weeks.recalls));
15707
+ stat(t('Not noted yet'), memFmt(dash.totals.pending), dash.totals.pending ? t('Exchanges not turned into notes yet.') : t('All caught up.'));
14942
15708
  glance.append(stats);
14943
15709
 
14944
15710
  // How ripe it is, in one line and one bar, and the next thing that would help it.
@@ -14958,19 +15724,19 @@ async function drawMemoryDashboard(glance, analysis, stamp) {
14958
15724
 
14959
15725
  const grid = el('div', 'mem-grid');
14960
15726
  memActivity(memCard(grid, t('Activity · last {n} days', { n: dash.days.length }), 'span-12'), dash);
14961
- memSignals(memCard(grid, t('What the archive is made of'), 'span-12'), m);
15727
+ memSignals(memCard(grid, t('What the memory holds'), 'span-12'), m);
14962
15728
  memBars(memCard(grid, t('By kind'), 'span-4'), Object.entries(dash.kinds).map(([kind, n]) => ({ label: kindWord(kind), n, swatch: `var(--mem-${kind}, var(--ph))` })));
14963
- memBars(memCard(grid, t('By archivist'), 'span-4'), Object.entries(dash.agents).map(([agent, n]) => ({ label: `@${agent}`, n })));
14964
- memBars(memCard(grid, t('By zone'), 'span-4'), Object.entries(dash.zones).map(([zone, n]) => ({ label: zoneWord(zone), n, title: ZONE_NOTE[zone] ?? null })));
14965
- const recall = memCard(grid, t('Recall and smart search'), 'span-12');
15729
+ memBars(memCard(grid, t('By note-taker'), 'span-4'), Object.entries(dash.agents).map(([agent, n]) => ({ label: `@${agent}`, n })));
15730
+ memBars(memCard(grid, t('By status'), 'span-4'), Object.entries(dash.zones).map(([zone, n]) => ({ label: zoneWord(zone), n, title: ZONE_NOTE[zone] ?? null })));
15731
+ const recall = memCard(grid, t('Memories used and search by meaning'), 'span-12');
14966
15732
  const carried = dash.via.search + dash.via.cascade;
14967
15733
  if (carried) {
14968
15734
  const split = el('div', 'mem-split');
14969
- split.title = t('How recalled memories arrived: found by the search, or carried along by the company they keep.');
15735
+ split.title = t('How used memories arrived: found by search, or brought along by a related one.');
14970
15736
  for (const [id, n] of [['search', dash.via.search], ['cascade', dash.via.cascade]]) { const part = el('i', id); part.style.flexGrow = String(n); split.append(part); }
14971
15737
  recall.append(split);
14972
15738
  const legend = el('div', 'mem-legend');
14973
- for (const [id, label, n] of [['search', t('Search'), dash.via.search], ['cascade', t('Company'), dash.via.cascade]]) {
15739
+ for (const [id, label, n] of [['search', t('Search'), dash.via.search], ['cascade', t('Related memories'), dash.via.cascade]]) {
14974
15740
  const key = el('span', `k ${id}`);
14975
15741
  key.append(el('i'), `${label} `, el('b', null, `${memFmt(n)} · ${memPct(n / carried)}`));
14976
15742
  legend.append(key);
@@ -14978,7 +15744,7 @@ async function drawMemoryDashboard(glance, analysis, stamp) {
14978
15744
  recall.append(legend);
14979
15745
  } else recall.append(el('p', 'note', t('Nothing yet.')));
14980
15746
  if (dash.vectors) {
14981
- for (const [label, have, of] of [[t('Memories with a vector'), dash.vectors.memories, dash.totals.memories], [t('Exchanges with a vector'), dash.vectors.entries, dash.totals.entries]]) {
15747
+ for (const [label, have, of] of [[t('Memories searchable by meaning'), dash.vectors.memories, dash.totals.memories], [t('Exchanges searchable by meaning'), dash.vectors.entries, dash.totals.entries]]) {
14982
15748
  const row = el('div', 'mem-signal');
14983
15749
  const head = el('div', 'top');
14984
15750
  head.append(el('b', null, label), el('span', null, `${memFmt(have)} / ${memFmt(of)}`));
@@ -14986,22 +15752,22 @@ async function drawMemoryDashboard(glance, analysis, stamp) {
14986
15752
  recall.append(row);
14987
15753
  }
14988
15754
  recall.append(el('span', 'detail', String(dash.vectors.model)));
14989
- } else recall.append(el('p', 'note', t('Smart search is off: recall matches words only.')));
14990
- memCard(grid, t('Memory review'), 'span-12').append(examsBlock(read.exams, { pass: dash.pass }));
15755
+ } else recall.append(el('p', 'note', t('Search by meaning is off: memories are found by exact words only.')));
15756
+ memCard(grid, t('Memory checkup'), 'span-12').append(examsBlock(read.exams, { pass: dash.pass }));
14991
15757
  analysis.append(grid);
14992
15758
  }
14993
15759
 
14994
15760
  /* ---------- the three tests ---------- */
14995
15761
 
14996
15762
  const EXAM_NAMES = {
14997
- coverage: t('CAN THE ARCHIVE ANSWER WHAT THIS PROJECT ASKS?'),
14998
- consistency: t('DOES THE ARCHIVE CONTRADICT ITSELF?'),
14999
- match: t('DOES THE LOCAL MODEL LAND WHERE THE AGENTS LANDED?'),
15763
+ coverage: t('Does the memory have the answers?'),
15764
+ consistency: t('Does the memory contradict itself?'),
15765
+ match: t('Does the local AI answer like the agents?'),
15000
15766
  };
15001
15767
  const EXAM_ABOUT = {
15002
15768
  coverage: t('Real questions from this project, recall run at the moment each one was asked, scored against the reply that was actually given. It asks whether the answer was already in the archive, not whether it was right.'),
15003
- consistency: t('Contradictions EYECAT is still holding, what has been taken out of circulation, and whether aberrations are being filed more often lately than they used to be.'),
15004
- match: t('Real questions answered here by a frontier CLI, asked again of the local model with this archive behind it, and compared against the answer given at the time. It takes minutes and spends nothing.'),
15769
+ consistency: t('Contradictions the memory reviewer has not settled yet, memories taken out of use, and whether doubtful memories are being filed more often lately than they used to be.'),
15770
+ match: t('Real questions answered here by one of your agents, asked again of the Local AI with this project\'s memory behind it, and compared against the answer given at the time. It takes minutes and spends nothing.'),
15005
15771
  };
15006
15772
 
15007
15773
  function examRow(id, state, run, pass = null) {
@@ -15177,7 +15943,7 @@ function connectionCard(agent) {
15177
15943
  const where = el('code', null, agent.detected ? `${agent.version ?? t('version unknown')} · ${agent.path}` : (agent.login?.install ?? []).join(' · '));
15178
15944
  where.title = where.textContent;
15179
15945
  meta.append(where);
15180
- const facts = [session?.detail ? t('Session {detail}', { detail: session.detail }) : null, agent.account ? `${agent.paid === false ? `${t('Free way in')} · ` : ''}${agent.account}` : null].filter(Boolean);
15946
+ const facts = [session?.detail ? t('Session {detail}', { detail: session.detail }) : null, agent.account ? `${agent.paid === false ? `${t('Free plan')} · ` : ''}${agent.account}` : null].filter(Boolean);
15181
15947
  if (facts.length) meta.append(el('span', null, facts.join(' · ')));
15182
15948
  card.append(meta);
15183
15949
  const body = el('div', 'sx-agent-body');
@@ -15194,7 +15960,7 @@ function connectionCard(agent) {
15194
15960
  for (const [key, labelText, onWhy] of [['imageGen', t('Generate images'), t('On for CREATE.')], ['web', t('Web access'), t('On in every turn.')]]) {
15195
15961
  const scope = agentScopes[key] ?? { capable: false, enabled: false, wired: false };
15196
15962
  const usable = scope.capable && scope.wired;
15197
- const why = (on) => (!scope.capable ? t('This CLI has no way to do it.') : !scope.wired ? t('Not wired yet.') : on ? onWhy : t('Off.'));
15963
+ const why = (on) => (!scope.capable ? t('This agent can\'t do this.') : !scope.wired ? t('Not available yet.') : on ? onWhy : t('Off.'));
15198
15964
  const toggle = sxSwitch(scope.enabled, async (on, box) => {
15199
15965
  box.disabled = true;
15200
15966
  try {
@@ -15227,12 +15993,12 @@ function connectionCard(agent) {
15227
15993
  const currentStart = agentScopes.defaultMode ?? 1;
15228
15994
  sxRow(modes, t('Starting mode'), currentStart === 2 ? t('Every turn may create files, plan steps too.') : t('Read-only until you unlock CREATE.'), sxSeg([1, 2].map((n) => ({
15229
15995
  value: n, text: `#${n}`, className: `o${n}`, disabled: n > currentCap,
15230
- title: n === 2 ? t('Every message to this agent starts in CREATE: new files where they belong, existing files untouched. Plan steps to it too.') : t('Messages start read-only; unlock CREATE when you want files.'),
15996
+ title: n === 2 ? t('Every message to this agent starts in #2 CREATE: it may add new files, but existing files stay untouched. Plan steps sent to it too.') : t('Messages start in #1 CHAT ONLY; turn on #2 CREATE when you want files.'),
15231
15997
  })), currentStart, async (n, seg) => {
15232
15998
  try {
15233
- await saveSettingNow({ scopes: { [agent.id]: { defaultMode: n } } }, n === 2 ? t('@{agent} starts in #2 CREATE: every turn may add files to the project.', { agent: agent.id }) : t('@{agent} starts read-only; unlock CREATE when you want files.', { agent: agent.id }));
15999
+ await saveSettingNow({ scopes: { [agent.id]: { defaultMode: n } } }, n === 2 ? t('@{agent} starts in #2 CREATE: every turn may add files to the project.', { agent: agent.id }) : t('@{agent} starts in #1 CHAT ONLY; turn on #2 CREATE when you want files.', { agent: agent.id }));
15234
16000
  await loadSettings();
15235
- } catch (error) { toast(t('The default mode was not saved: {error}', { error: error.message })); await loadSettings(); }
16001
+ } catch (error) { toast(t('The starting mode was not saved: {error}', { error: error.message })); await loadSettings(); }
15236
16002
  }, { label: t('Starting mode') }));
15237
16003
  const seconds = Math.round((settingsUI.data.settings.timeouts[agent.id] ?? 180000) / 1000);
15238
16004
  sxRow(card, t('Time limit'), t('How long the project waits for its answer.'), sxNumber(seconds, 's', {
@@ -15281,7 +16047,7 @@ function connectionCard(agent) {
15281
16047
  if (agent.detected && agent.key && agent.id !== 'opencode') slot.append(keyForm(agent, { onDone: () => { void loadSettings(); renderOnboarding(); } }));
15282
16048
  // Signed in, OpenCode's providers are added in its block below; the terminal is for a first sign-in.
15283
16049
  if (agent.detected && agent.login && !agent.login.headless && !(agent.id === 'opencode' && session?.state === 'signed-in')) {
15284
- slot.append(el('p', 'sx-hint', t('It signs in from its own prompt:')));
16050
+ slot.append(el('p', 'sx-hint', t('To sign in, copy this into your Terminal app:')));
15285
16051
  slot.append(commandBlock([agent.login.display, `# ${t(agent.login.note)}`]));
15286
16052
  }
15287
16053
  if (agent.detected && agent.login?.headless) slot.append(loginAskSlot(agent.id));
@@ -15309,7 +16075,7 @@ function renderMemoryPanel(data) {
15309
16075
  }
15310
16076
  function drawMemory(host, data) {
15311
16077
  const mem = data?.settings?.memory;
15312
- if (!mem) { sxBlock(host, t('Memory'), t('This project has no memory index.')); return; }
16078
+ if (!mem) { sxBlock(host, t('What it remembers'), t('This project has no memory yet.')); return; }
15313
16079
  const save = async (memoryPatch, describe) => { try { await saveSettingNow({ memory: memoryPatch }, describe); await loadSettings(); } catch (error) { toast(t('Memory setting was not saved: {error}', { error: error.message })); } };
15314
16080
  const select = (options, current, change, label) => {
15315
16081
  const node = el('select', 'sx-input');
@@ -15323,19 +16089,19 @@ function drawMemory(host, data) {
15323
16089
  const aside = el('div', 'sx-aside');
15324
16090
  const stamp = el('span', 'sx-hint');
15325
16091
  aside.append(stamp);
15326
- const glanceBlock = sxBlock(host, t('The archive'), null, { aside, className: 'sx-glance' });
16092
+ const glanceBlock = sxBlock(host, t('At a glance'), null, { aside, className: 'sx-glance' });
15327
16093
  const glance = el('div', 'sx-glance-body');
15328
16094
  glanceBlock.rows.append(glance);
15329
16095
  const more = el('details', 'sx-more');
15330
- const summary = el('summary', null, t('Analysis and tests'));
16096
+ const summary = el('summary', null, t('Details and checkups'));
15331
16097
  const analysis = el('div', 'sx-analysis mem-dash');
15332
16098
  more.append(summary, analysis);
15333
16099
  glanceBlock.append(more);
15334
16100
  aside.append(sxButton(t('Refresh'), () => void drawMemoryDashboard(glance, analysis, stamp)));
15335
16101
  void drawMemoryDashboard(glance, analysis, stamp);
15336
16102
 
15337
- const keep = sxBlock(host, t('The archivist'), t('It reads what nobody has distilled and keeps the few notes worth remembering. The cheapest allowed agent goes first; a local model costs nothing and keeps everything on this machine.'));
15338
- sxRow(keep, t('Who distils first'), null, select([['auto', t('Automatic · the cheapest allowed')], ...mem.candidates.map((c) => [c.id, `@${c.id}${c.local ? ` · ${c.label}` : ''}`])], mem.archivist, (value) => save({ archivist: value }, value === 'auto' ? t('archivist: the cheapest allowed agent goes first.') : t('archivist: @{agent} distils first.', { agent: value })), t('Who distils first')));
16103
+ const keep = sxBlock(host, t('Who takes notes'), t('An agent rereads recent conversations and saves only what is worth remembering. The cheapest one goes first; local AI is free and private.'));
16104
+ sxRow(keep, t('Who takes notes first'), null, select([['auto', t('Automatic · the cheapest one')], ...mem.candidates.map((c) => [c.id, `@${c.id}${c.local ? ` · ${c.label}` : ''}`])], mem.archivist, (value) => save({ archivist: value }, value === 'auto' ? t('notes: the cheapest agent goes first.') : t('notes: @{agent} goes first.', { agent: value })), t('Who takes notes first')));
15339
16105
  const allowed = new Set(mem.archivists ?? mem.candidates.map((c) => c.id));
15340
16106
  const who = el('div', 'sx-chips');
15341
16107
  for (const candidate of mem.candidates) {
@@ -15344,65 +16110,87 @@ function drawMemory(host, data) {
15344
16110
  chip.setAttribute('aria-pressed', String(allowed.has(candidate.id)));
15345
16111
  chip.addEventListener('click', () => {
15346
16112
  if (allowed.has(candidate.id)) allowed.delete(candidate.id); else allowed.add(candidate.id);
15347
- if (!allowed.size) { allowed.add(candidate.id); toast(t('Diagnostics › someone has to keep the archive.')); return; }
16113
+ if (!allowed.size) { allowed.add(candidate.id); toast(t('Diagnostics › at least one agent has to take notes.')); return; }
15348
16114
  chip.setAttribute('aria-pressed', String(allowed.has(candidate.id)));
15349
- void save({ archivists: allowed.size === mem.candidates.length ? [] : [...allowed] }, t('archivists: {who}.', { who: [...allowed].map((id) => `@${id}`).join(', ') }));
16115
+ void save({ archivists: allowed.size === mem.candidates.length ? [] : [...allowed] }, t('taking notes: {who}.', { who: [...allowed].map((id) => `@${id}`).join(', ') }));
15350
16116
  });
15351
16117
  who.append(chip);
15352
16118
  }
15353
- sxRow(keep, t('Who may distil'), t('The lit ones may keep the archive.'), who, { stack: true });
15354
- sxRow(keep, t('Every'), t('Exchanges between one distillation and the next.'), sxNumber(mem.every, t('exchanges'), { min: 1, label: t('Every'), wire: { toPatch: (value) => ({ memory: { every: value } }), describe: (value) => t('distil every {n} exchanges.', { n: value }) } }));
15355
- sxRow(keep, t('Or after'), t('Quiet minutes that also start a distillation.'), sxNumber(mem.idleMinutes, t('min'), { min: 1, label: t('Or after'), wire: { toPatch: (value) => ({ memory: { idleMinutes: value } }), describe: (value) => t('or after {n} quiet minutes.', { n: value }) } }));
16119
+ sxRow(keep, t('Who may take notes'), t('Only the agents switched on here take notes.'), who, { stack: true });
16120
+ sxRow(keep, t('Every'), t('How many exchanges pass before it takes notes again.'), sxNumber(mem.every, t('exchanges'), { min: 1, label: t('Every'), wire: { toPatch: (value) => ({ memory: { every: value } }), describe: (value) => t('notes every {n} exchanges.', { n: value }) } }));
16121
+ sxRow(keep, t('Or after'), t('Or take notes after this many quiet minutes.'), sxNumber(mem.idleMinutes, t('min'), { min: 1, label: t('Or after'), wire: { toPatch: (value) => ({ memory: { idleMinutes: value } }), describe: (value) => t('or after {n} quiet minutes.', { n: value }) } }));
15356
16122
 
15357
- const recall = sxBlock(host, t('Recall'), t('What each turn carries from the archive, and how it finds it.'));
15358
- sxRow(recall, t('Share of the context'), t('The most each turn may spend on memories.'), sxNumber(Math.round(mem.recallShare * 100), '%', { min: 0, step: 5, label: t('Share of the context'), wire: { toPatch: (value) => ({ memory: { recallShare: Math.min(60, value) / 100 } }), describe: (value) => t("recall may take {n}% of each turn's context.", { n: Math.min(60, value) }) } }));
15359
- const cascade = sxSwitch(mem.cascade !== false, (on) => save({ cascade: on }, on ? t('recall also carries what a memory keeps arriving with.') : t('recall carries only what the words and the meaning match.')), { label: t('Related') });
15360
- sxRow(recall, t('Related'), t('Two memories that keep arriving in the same turn travel together, however differently they read.'), cascade);
16123
+ const recall = sxBlock(host, t('Using memories'), t('How many memories each answer uses, and how they are found.'));
16124
+ sxRow(recall, t('Room for memories'), t('At most, how much of what an agent reads can be memories.'), sxNumber(Math.round(mem.recallShare * 100), '%', { min: 0, step: 5, label: t('Room for memories'), wire: { toPatch: (value) => ({ memory: { recallShare: Math.min(60, value) / 100 } }), describe: (value) => t("memories may take up to {n}% of what each agent reads.", { n: Math.min(60, value) }) } }));
16125
+ const cascade = sxSwitch(mem.cascade !== false, (on) => save({ cascade: on }, on ? t('related memories come along too.') : t('only memories that match come along.')), { label: t('Related memories') });
16126
+ sxRow(recall, t('Related memories'), t('Memories that are often used together are brought in together.'), cascade);
15361
16127
  const embedNow = mem.embedder ? mem.embedder : t('off');
15362
- sxRow(recall, t('Smart search'), t('Find by meaning, not only by words. Now: {what}.', { what: embedNow }), select([
15363
- ['auto', t('Automatic · Ollama if running, else Gemini')],
16128
+ sxRow(recall, t('Search by meaning'), t('Finds memories by what they mean, not only by exact words. Now: {what}.', { what: embedNow }), select([
16129
+ ['auto', t('Automatic · Local AI if running, else Gemini')],
15364
16130
  ['ollama', `Ollama · ${t('local')}${mem.ollama.embedModel ? ` · ${mem.ollama.embedModel}` : ` · ${t('no model yet')}`}`],
15365
16131
  ['gemini', t('Gemini · needs your key')],
15366
- ['off', t('Off · words only')],
15367
- ], mem.embedProvider ?? data.config?.memory?.embedProvider ?? 'auto', (value, node) => save({ embedProvider: value }, t('smart search: {what}.', { what: node.options[node.selectedIndex].textContent.toLowerCase() })), t('Smart search')));
15368
- if (mem.envWins) sxFoot(recall, t('Environment variables are set for memory; they win over these values on the next launch.'));
16132
+ ['off', t('Off · exact words only')],
16133
+ ], mem.embedProvider ?? data.config?.memory?.embedProvider ?? 'auto', (value, node) => save({ embedProvider: value }, t('search by meaning: {what}.', { what: node.options[node.selectedIndex].textContent.toLowerCase() })), t('Search by meaning')));
16134
+ if (mem.envWins) sxFoot(recall, t('Some of these values are fixed by how MADRE was started; those win next time it starts.'));
15369
16135
 
15370
16136
  // The dataset behind MADRE AI, and the recipe that trains on it, outside MADRE.
15371
- const train = sxBlock(host, t('Dataset and training'), t('Export what the project kept, train your model with it on this Mac, and @madre picks the result up.'));
16137
+ const train = sxBlock(host, t('Train your own AI'), t('Advanced: export what the project learned, train a model on your computer, and @madre answers with it.'));
15372
16138
  const datasetHint = el('span', 'sx-hint', t('Reading…'));
15373
16139
  const exportButton = sxButton(t('Export'), async () => {
15374
16140
  exportButton.disabled = true;
15375
16141
  try { const payload = await fetch('/api/dataset', { method: 'POST' }).then((response) => response.json()); showDataset(payload); toast(t('Diagnostics › dataset exported: {n} pairs in {dir}', { n: payload.dataset.pairs, dir: payload.dir })); }
15376
16142
  catch (error) { toast(t('Dataset export failed: {error}', { error: error.message })); }
15377
16143
  finally { exportButton.disabled = false; }
15378
- }, { title: t('Write train.jsonl and valid.jsonl next to the ledger, redacted, in chat format for mlx-lm') });
15379
- const datasetRow = sxRow(train, t('Dataset'), null, exportButton);
16144
+ }, { title: t('Saves the training files in the project, with private data hidden') });
16145
+ const datasetRow = sxRow(train, t('Training data'), null, exportButton);
15380
16146
  datasetRow.querySelector('.sx-words').append(datasetHint);
16147
+ // What it is, then the recipe in order (2026-10-14): one command per step, for a Mac with an
16148
+ // Apple chip or — tried, not promised — Linux with an NVIDIA card through MLX's CUDA package.
16149
+ train.append(el('p', 'sx-hint sx-train-what', t('The training data are question-and-answer pairs taken from this project\'s conversations and notes, with private details hidden. With them a small model is adjusted on your computer, and @madre answers with it.')));
15381
16150
  const recipe = el('details', 'sx-more');
15382
- recipe.append(el('summary', null, t('Train your model · local, with MLX on Apple silicon')));
16151
+ recipe.append(el('summary', null, t('Step by step')));
16152
+ let platform = /Mac/i.test(navigator.platform ?? '') || !/Linux/i.test(navigator.userAgent ?? '') ? 'mac' : 'linux';
15383
16153
  const steps = el('ol', 'sx-steps');
15384
- recipe.append(el('p', 'sx-hint', t('Export the dataset first. Each step is one command for your terminal: copy it, run it, come back. When the model exists in Ollama, @madre switches to it at the next recheck.')), steps);
16154
+ const pickRow = el('div', 'sx-train-platform');
16155
+ const platformNote = el('p', 'sx-hint');
16156
+ recipe.append(pickRow, platformNote, steps);
15385
16157
  train.append(recipe);
16158
+ let lastPayload = null;
16159
+ const drawPlatform = () => {
16160
+ pickRow.replaceChildren(sxSeg([{ value: 'mac', text: t('Mac with Apple chip') }, { value: 'linux', text: t('Linux with NVIDIA') }], platform, (value) => { platform = value; showDataset(lastPayload); }, { label: t('Your computer') }));
16161
+ platformNote.textContent = platform === 'mac'
16162
+ ? t('Any Mac with an M chip. 16 GB of memory is enough for the small model.')
16163
+ : t('Experimental: MLX runs on Linux with an NVIDIA card (CUDA 12, compute capability 7.5 or newer, glibc 2.35, Python 3.10), but mlx-lm only promises Apple chips. Step 2 checks the card first.');
16164
+ };
15386
16165
  const showDataset = (payload) => {
16166
+ lastPayload = payload;
15387
16167
  const d = payload?.dataset;
15388
16168
  const exported = d ? t('Last export {when} · {train} to train · {valid} to check', { when: new Date(d.exportedAt).toLocaleString(), train: d.train, valid: d.valid }) : t('Not exported yet.');
15389
16169
  const trained = payload?.trained ? t('Trained model {model} in use.', { model: payload.trained }) : t('No trained model yet.');
15390
16170
  datasetHint.textContent = `${exported} ${trained}`;
16171
+ drawPlatform();
15391
16172
  const info = payload?.training;
15392
16173
  steps.replaceChildren();
15393
16174
  if (!info) { steps.append(el('li', 'sx-hint', t('The training recipe is unavailable.'))); return; }
15394
16175
  const quote = (path) => `"${path.replace(/\/$/, '')}"`;
15395
- for (const [label, command] of [
15396
- [t('Once: a Python environment with mlx-lm'), 'python3 -m venv ~/.madre-train && source ~/.madre-train/bin/activate && pip install mlx-lm'],
15397
- [t('Train the LoRA · base {model} for {gb} GB', { model: info.baseModel, gb: info.memoryGb }), `source ~/.madre-train/bin/activate && bash ${quote(`${info.recipeDir}train.sh`)} ${quote(info.roomDir)} ${info.baseModel}`],
15398
- [t('Fuse the adapter into the base'), `source ~/.madre-train/bin/activate && cd ${quote(info.roomDir)} && mlx_lm.fuse --model ${info.baseModel} --adapter-path adapters --save-path fused`],
15399
- [t('Register it in Ollama as {model}', { model: info.modelName }), `cd ${quote(info.roomDir)} && cp ${quote(`${info.recipeDir}Modelfile`)} . && ollama create ${info.modelName} -f Modelfile`],
15400
- ]) {
16176
+ const venv = 'source ~/.madre-train/bin/activate';
16177
+ const setup = platform === 'mac'
16178
+ ? 'python3 -m venv ~/.madre-train && source ~/.madre-train/bin/activate && pip install mlx-lm'
16179
+ : 'python3 -m venv ~/.madre-train && source ~/.madre-train/bin/activate && pip install "mlx[cuda12]" mlx-lm && python3 -c "import mlx.core as mx; print(\'CUDA:\', mx.cuda.is_available())"';
16180
+ const step = (label, about, command = null) => {
15401
16181
  const li = el('li');
15402
- li.append(el('b', null, label), sxCommand(command));
16182
+ li.append(el('b', null, label));
16183
+ if (about) li.append(el('span', 'sx-hint', about));
16184
+ if (command) li.append(sxCommand(command));
15403
16185
  steps.append(li);
15404
- }
15405
- steps.append(el('li', 'sx-hint', t('Qwen needs a GGUF before Ollama reads it: {readme}, section 3, has the two lines. Then ask @madre ten things the project decided and five it never discussed before trusting it.', { readme: quote(`${info.recipeDir}README.md`) })));
16186
+ };
16187
+ step(t('Export the training data'), t('Use «Export» above. Do it again whenever the project has grown.'));
16188
+ step(t('Prepare Python, once'), platform === 'mac' ? t('A separate environment with mlx-lm, so nothing else on your computer changes.') : t('A separate environment with MLX for CUDA and mlx-lm. It must print «CUDA: True»; if not, stop here.'), setup);
16189
+ step(t('Train · base {model} for {gb} GB', { model: info.baseModel, gb: info.memoryGb }), t('From 10 to 40 minutes. It learns from the exported pairs and leaves an adjustment («adapters»).'), `${venv} && bash ${quote(`${info.recipeDir}train.sh`)} ${quote(info.roomDir)} ${info.baseModel}`);
16190
+ step(t('Join the adjustment with the base model'), null, `${venv} && cd ${quote(info.roomDir)} && mlx_lm.fuse --model ${info.baseModel} --adapter-path adapters --save-path fused`);
16191
+ step(t('Turn it into a file Ollama reads (GGUF)'), t('The first time, it downloads llama.cpp\'s converter.'), `${venv} && cd ${quote(info.roomDir)} && git clone --depth 1 https://github.com/ggml-org/llama.cpp && pip install -r llama.cpp/requirements.txt && python3 llama.cpp/convert_hf_to_gguf.py fused --outfile madre.gguf --outtype q8_0`);
16192
+ step(t('Register it in Ollama as {model}', { model: info.modelName }), t('@madre switches to it at the next recheck of Local AI.'), `cd ${quote(info.roomDir)} && cp ${quote(`${info.recipeDir}Modelfile`)} . && ollama create ${info.modelName} -f Modelfile`);
16193
+ step(t('Try it before trusting it'), t('Ask @madre ten things the project decided and five it never discussed. It should get the first right and say it never discussed the others; if it makes things up, remove it with «ollama rm {model}» and train with more pairs.', { model: info.modelName }));
15406
16194
  };
15407
16195
  fetch('/api/dataset').then((response) => response.json()).then(showDataset).catch(() => showDataset(null));
15408
16196
  }
@@ -15421,13 +16209,13 @@ function renderSettings() {
15421
16209
  }
15422
16210
 
15423
16211
  function drawConnections(host, data) {
15424
- host.append(el('p', 'sx-intro', t('One agent is enough to open the project. MADRE uses the session each CLI already has: install one here or sign it in, and the project opens by itself. The credentials stay in each CLI; MADRE only asks whether it is signed in.')));
16212
+ host.append(el('p', 'sx-intro', t('One agent is enough to start. MADRE uses the AI apps already on this computer: install one here or sign in, and the project opens. Your passwords stay in each app; MADRE only checks you are signed in.')));
15425
16213
  for (const agent of data.agents) host.append(connectionCard(agent));
15426
16214
  }
15427
16215
 
15428
16216
  function drawRoomSettings(host, data) {
15429
16217
  const s = data.settings;
15430
- const plans = sxBlock(host, t('Plans'), t('When a turn may open a plan, and how long a plan may be.'));
16218
+ const plans = sxBlock(host, t('Plans'), t('Whether an agent may split a job into steps for the others.'));
15431
16219
  const delegation = sxSwitch(s.delegation, async (on, box) => {
15432
16220
  try { await saveSettingNow({ room: { delegation: on } }, on ? t('agents may open plans.') : t('agents no longer open plans.')); }
15433
16221
  catch (error) { box.checked = !on; toast(t('The setting was not saved: {error}', { error: error.message })); }
@@ -15437,9 +16225,9 @@ function drawRoomSettings(host, data) {
15437
16225
  min: 1, label: t('Steps per plan'), wire: { toPatch: (value) => ({ room: { maxPlanSteps: value } }), describe: (value) => t('max plan steps saved: {n}.', { n: value }) },
15438
16226
  }));
15439
16227
 
15440
- const limits = sxBlock(host, t('Budget and waiting'), t('What each agent may spend, and how long the project waits for it.'));
15441
- sxRow(limits, t('Tokens per agent'), t('Counted here, in each 5-hour window.'), sxNumber(s.softTokenBudget, null, {
15442
- min: 10000, step: 10000, label: t('Tokens per agent'),
16228
+ const limits = sxBlock(host, t('Spending and waiting'), t('What each agent may spend, and how long the project waits for it.'));
16229
+ sxRow(limits, t('Usage limit per agent'), t('Every 5 hours, in tokens: the unit AI use is measured in.'), sxNumber(s.softTokenBudget, null, {
16230
+ min: 10000, step: 10000, label: t('Usage limit per agent'),
15443
16231
  wire: { toPatch: (value) => ({ room: { softTokenBudget: value } }), describe: (value) => t('local budget saved: {n} tokens per agent per 5h window.', { n: formatTokens(value) }) },
15444
16232
  }));
15445
16233
  sxRow(limits, t('Default time limit'), t('How long an agent may take before the project stops waiting. Each agent can have its own in Agents.'), sxNumber(Math.round(s.defaultTimeout / 1000), 's', {
@@ -15447,7 +16235,7 @@ function drawRoomSettings(host, data) {
15447
16235
  wire: { toPatch: (seconds) => ({ timeouts: { default: seconds * 1000 } }), describe: (seconds) => t('default timeout saved: {n}s.', { n: seconds }) },
15448
16236
  }));
15449
16237
 
15450
- const gemini = sxBlock(host, 'Gemini', t('Gemini can go quiet before it answers anything; this is how long the project gives it.'));
16238
+ const gemini = sxBlock(host, 'Gemini', t('Gemini sometimes takes a while to start; this is how long to wait for it.'));
15451
16239
  sxRow(gemini, t('Silence limit'), t('Seconds without any output before the attempt is dropped.'), sxNumber(Math.round(s.geminiIdleMs / 1000), 's', {
15452
16240
  min: 10, step: 10, label: t('Silence limit'),
15453
16241
  wire: { toPatch: (seconds) => ({ gemini: { idleMs: seconds * 1000 } }), describe: (seconds) => t('Gemini silence limit saved: {n}s.', { n: seconds }) },
@@ -15520,7 +16308,15 @@ function drawOpenCodeSettings(host, s) {
15520
16308
  if (!waiting.length) return;
15521
16309
  const which = el('select', 'sx-input');
15522
16310
  which.setAttribute('aria-label', t('Provider'));
15523
- for (const provider of waiting) { const option = el('option', null, provider.label); option.value = provider.id; which.append(option); }
16311
+ // Many providers (OpenCode's whole catalogue): the usual ones first, then all, by name.
16312
+ const option = (provider) => { const one = el('option', null, provider.label); one.value = provider.id; return one; };
16313
+ const featured = waiting.filter((one) => one.featured);
16314
+ const rest = waiting.filter((one) => !one.featured).sort((a, b) => a.label.localeCompare(b.label));
16315
+ if (featured.length && rest.length) {
16316
+ const top = el('optgroup'); top.label = t('Most used'); top.append(...featured.map(option));
16317
+ const all = el('optgroup'); all.label = t('All providers'); all.append(...rest.map(option));
16318
+ which.append(top, all);
16319
+ } else for (const provider of waiting) which.append(option(provider));
15524
16320
  const input = el('input', 'sx-input mono');
15525
16321
  input.type = 'password'; input.autocomplete = 'off'; input.spellcheck = false;
15526
16322
  const where = el('a', 'oc-where', t('Where do I get one?'));
@@ -15613,11 +16409,11 @@ function openCodeProviderRow(provider, reload, hidden) {
15613
16409
  // how far each agent may go. The key is still asked in the browser for each message; this only
15614
16410
  // chooses which key and lets an accepted one hold a few minutes. #4 always asks for something.
15615
16411
  function overrideKeyHint(mode, key) {
15616
- if (key === 'confirm') return t('One click on UNLOCK. EDIT still takes a restore point first, and UNDO is one click.');
16412
+ if (key === 'confirm') return t('One click on UNLOCK. EDIT saves a copy first, so UNDO is one click away.');
15617
16413
  if (key === 'phrase') return t('Your phrase, typed when you unlock it.');
15618
16414
  if (key === 'phrase-airlock') return t('Your phrase, typed twice.');
15619
16415
  if (key === 'designation-airlock') return t('The folder name of the project ({name}), typed twice.', { name: projectDesignation() });
15620
- return mode === 4 ? t('The folder name of the project ({name}), with no second key.', { name: projectDesignation() }) : t('The folder name of the project ({name}).', { name: projectDesignation() });
16416
+ return mode === 4 ? t('The folder name of the project ({name}), with no second confirmation.', { name: projectDesignation() }) : t('The folder name of the project ({name}).', { name: projectDesignation() });
15621
16417
  }
15622
16418
  // While a phrase guards a mode, a change to the keys shows it first (the project checks it): a
15623
16419
  // program that could change the key could also take the phrase away.
@@ -15636,7 +16432,7 @@ function drawPermissions(host, data) {
15636
16432
  }
15637
16433
  const config = data.settings.override ?? OVERRIDE_DEFAULT;
15638
16434
  const canHash = Boolean(globalThis.crypto?.subtle);
15639
- const keys = sxBlock(host, t('The key of each mode'), t('What you type to unlock #3 EDIT and #4 FULL ACCESS. It is asked for each message, unless a remembered permission below still holds.'), { className: 'perm-keys' });
16435
+ const keys = sxBlock(host, t('How you unlock modes'), t('What you type to unlock #3 EDIT and #4 FULL ACCESS. Asked on every message, unless you chose to remember it below.'), { className: 'perm-keys' });
15640
16436
  overrideProof = null;
15641
16437
  if (config.phrase && [3, 4].some((mode) => config.keys[mode].startsWith('phrase'))) {
15642
16438
  overrideProof = el('input', 'sx-input');
@@ -15653,9 +16449,9 @@ function drawPermissions(host, data) {
15653
16449
  title: key.startsWith('phrase') && !config.phrase ? t('Save a phrase below first.') : overrideKeyHint(mode, key),
15654
16450
  })), current, (key) => saveOverride({ keys: { [mode]: key } }, t('#{n} {label} now asks for: {key}.', { n: mode, label: MODES[mode].label, key: OVERRIDE_KEY_WORDS[key]().toLowerCase() })), { label: `#${mode} ${MODES[mode].label}` }), { stack: true });
15655
16451
  }
15656
- sxFoot(keys, t('#4 always asks for something: it runs commands, and what leaves the machine does not come back.'));
16452
+ sxFoot(keys, t('#4 always asks you to confirm: it runs commands, and what leaves the machine does not come back.'));
15657
16453
 
15658
- const phrase = sxBlock(host, t('Your phrase'), t('Only its fingerprint is kept (a salted SHA-256). When you type it to unlock, this Mac checks it and forgets it; it never leaves the Mac.'));
16454
+ const phrase = sxBlock(host, t('Your phrase'), t('MADRE never stores the phrase itself, only a fingerprint of it, and it never leaves this Mac.'));
15659
16455
  const field = el('input', 'sx-input');
15660
16456
  field.type = 'password';
15661
16457
  field.autocomplete = 'new-password';
@@ -15679,18 +16475,29 @@ function drawPermissions(host, data) {
15679
16475
  }
15680
16476
  field.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); keep.click(); } });
15681
16477
 
15682
- const grace = sxBlock(host, t('Remember permission'), t('Once a key is accepted, the same mode for the same agent is not asked again for a while. Only in this page: reloading, STOP ALL or the × on its chip closes it.'));
16478
+ const grace = sxBlock(host, t('Remember permission'), t('After you confirm, the same agent won\'t ask again for a while. Reloading the page or STOP ALL ends it.'));
15683
16479
  for (const mode of [3, 4]) {
15684
16480
  const options = mode === 3 ? [0, 5, 15, 30] : [0, 5];
15685
16481
  sxRow(grace, `#${mode} ${MODES[mode].label}`, mode === 4 ? t('Never longer than 5 minutes.') : t('Raising an agent\'s max mode always asks, remembered permission or not.'), sxSeg(options.map((minutes) => ({ value: minutes, text: minutes ? t('{n} min', { n: minutes }) : t('Off') })), config.grace[mode] ?? 0, (minutes) => {
15686
16482
  if (!minutes) closeOverrideGrace(mode);
15687
- return saveOverride({ grace: { [mode]: minutes } }, minutes ? t('#{n} {label} holds {minutes} min after its key.', { n: mode, label: MODES[mode].label, minutes }) : t('#{n} {label} asks for its key every message.', { n: mode, label: MODES[mode].label }));
16483
+ return saveOverride({ grace: { [mode]: minutes } }, minutes ? t('#{n} {label} stays unlocked for {minutes} min after you confirm it.', { n: mode, label: MODES[mode].label, minutes }) : t('#{n} {label} asks you to confirm every message.', { n: mode, label: MODES[mode].label }));
15688
16484
  }, { label: t('Remember permission for #{n}', { n: mode }) }));
15689
16485
  }
15690
16486
 
16487
+ // The Memory map asks for the project name; here it can stop asking for a while, or for good
16488
+ // (2026-10-14). Kept in this browser, for this project only.
16489
+ const access = sxBlock(host, t('Memory access'), t('Opening the Memory map asks for the project name. Choose how long to skip that.'));
16490
+ access.id = 'perm-memory';
16491
+ const held = memoryAccess();
16492
+ sxRow(access, t('Don\'t ask again'), memoryAccessLine(held), sxSeg(MEMORY_ACCESS.map(([value, words]) => ({ value, text: words() })), held?.choice ?? 'ask', (choice) => {
16493
+ setMemoryAccess(choice);
16494
+ toast(choice === 'ask' ? t('The Memory map asks for the project name again.') : choice === 'never' ? t('The Memory map will not ask again in this project.') : t('The Memory map will not ask until {time}.', { time: clockTime(memoryAccess()?.until) }), { tone: 'done' });
16495
+ renderSettings();
16496
+ }, { label: t('Don\'t ask again') }));
16497
+
15691
16498
  // Who may reach which mode: the ceiling of every agent in one table. The mode a message starts in
15692
16499
  // stays on each agent's sheet.
15693
- const who = sxBlock(host, t('Who may do what'), t('The highest mode each agent may reach. Each mode wears its colour; #3 and #4 still ask for their key.'), { className: 'perm-who' });
16500
+ const who = sxBlock(host, t('Who may do what'), t('The most each agent may do. #3 and #4 still ask you first.'), { className: 'perm-who' });
15694
16501
  for (const agent of data.agents.filter((one) => one.detected)) {
15695
16502
  const scopes = data.settings.capabilities?.[agent.id]?.scopes ?? {};
15696
16503
  const cap = scopes.maxMode ?? 1;
@@ -15711,19 +16518,19 @@ function drawPrivacy(host, data) {
15711
16518
  if (!priv) return;
15712
16519
  const terms = el('textarea', 'sx-input mono');
15713
16520
  terms.rows = 4; terms.value = priv.terms.join('\n'); terms.placeholder = t('one term per line · a company, a brand, a domain, a name'); terms.spellcheck = false;
15714
- terms.setAttribute('aria-label', t('Hidden data'));
16521
+ terms.setAttribute('aria-label', t('Words to hide'));
15715
16522
  const marker = el('input', 'sx-input mono');
15716
16523
  marker.value = priv.marker; marker.maxLength = 40; marker.spellcheck = false;
15717
16524
  marker.setAttribute('aria-label', t('Replaced with'));
15718
16525
  const exposure = el('span', 'sx-hint', t('Checking the project…'));
15719
- const purge = sxButton(t('Erase private data'), null, { tone: 'danger', title: t('Replace all hidden data already in the ledger, the index and the memories with the marker. Asks for the project name.') });
16526
+ const purge = sxButton(t('Erase private data'), null, { tone: 'danger', title: t('Hides these words everywhere they were already saved. Asks for the project name.') });
15720
16527
  const showExposure = (payload) => {
15721
16528
  const x = payload?.exposure;
15722
16529
  if (!x) { exposure.textContent = ''; return; }
15723
16530
  const total = x.events + x.entries + x.memories;
15724
16531
  exposure.textContent = !payload.terms?.length ? t('Write the terms first.') : total
15725
- ? t('Still in the project: {events} events · {entries} indexed exchanges · {memories} memories.', { events: x.events, entries: x.entries, memories: x.memories })
15726
- : t('The project is clean: no private term in the ledger, the index or the memories.');
16532
+ ? t('Still showing in {events} records · {entries} exchanges · {memories} memories.', { events: x.events, entries: x.entries, memories: x.memories })
16533
+ : t('All clean: none of these words is saved in the project.');
15727
16534
  purge.disabled = !payload.terms?.length || !total;
15728
16535
  };
15729
16536
  const savePrivacy = async (patch, describe) => {
@@ -15739,19 +16546,19 @@ function drawPrivacy(host, data) {
15739
16546
  terms.addEventListener('change', () => savePrivacy({ terms: terms.value.split('\n') }, (payload) => t('{n} private {term} guarded from now on.', { n: payload.terms.length, term: payload.terms.length === 1 ? t('term') : t('terms') })));
15740
16547
  marker.addEventListener('change', () => savePrivacy({ marker: marker.value }, (payload) => t('private terms appear as {marker}.', { marker: payload.marker })));
15741
16548
 
15742
- const named = sxBlock(host, t('Hidden data'), t("A company, a brand, a domain: what an agent's own setup can leak into a reply. MADRE replaces them before the ledger, the archivist, the other agents or the dataset see them."));
16549
+ const named = sxBlock(host, t('Words to hide'), t("Names you don't want saved or shared: a company, a brand, a domain. MADRE swaps them out before anything is saved or reaches another agent."));
15743
16550
  sxRow(named, t('One per line'), null, terms, { stack: true });
15744
16551
  sxRow(named, t('Replaced with'), t('What the project shows in their place.'), marker);
15745
- sxFoot(named, priv.envWins ? t('The terms stay in config.json; the project only records how many. PULSE_PRIVATE_TERMS is set, so its terms are added on every launch.') : t('The terms stay in config.json; the project only records how many.'));
16552
+ sxFoot(named, priv.envWins ? t('The terms stay in config.json; the project only records how many. PULSE_PRIVATE_TERMS is set, so its terms are added on every launch.') : t('The list stays on this computer; the project only notes how many words it has.'));
15746
16553
 
15747
16554
  // Two guards that need no list, because what they catch has a shape rather than a name.
15748
- const shaped = sxBlock(host, t('Caught by their shape'), t('Two guards that need no list.'));
16555
+ const shaped = sxBlock(host, t('Hidden automatically'), t('No list needed: MADRE spots these on its own.'));
15749
16556
  const guard = (key, label, hint, describe) => sxRow(shaped, label, hint, sxSwitch(priv[key] !== false, (on) => savePrivacy({ [key]: on }, () => `${describe} ${on ? t('on') : t('off')}.`), { label }));
15750
- guard('secrets', t('Keys, tokens and e-mail addresses'), t('API keys, GitHub and npm tokens, JWTs and e-mail addresses are struck out wherever they appear.'), t('redaction of keys and addresses'));
15751
- guard('paths', t('Your home path'), t('/Users/yourname becomes ~: a quoted path still reads, it just stops naming you.'), t('hiding of home paths'));
16557
+ guard('secrets', t('Access keys and emails'), t('Access keys, service tokens and email addresses are crossed out wherever they appear.'), t('redaction of keys and e-mail addresses'));
16558
+ guard('paths', t('Your home folder'), t('/Users/yourname becomes ~: a quoted path still reads, it just stops naming you.'), t('hiding of home paths'));
15752
16559
 
15753
16560
  const already = sxBlock(host, t('Already in the project'), t('What was recorded before a term was named stays as it was until you erase it.'));
15754
- const purgeRow = sxRow(already, t('Exposure'), null, purge);
16561
+ const purgeRow = sxRow(already, t('Still visible'), null, purge);
15755
16562
  purgeRow.querySelector('.sx-words').append(exposure);
15756
16563
  purge.addEventListener('click', async () => {
15757
16564
  const designation = await roomAsk({
@@ -15908,8 +16715,30 @@ function nostromoTick(now) {
15908
16715
 
15909
16716
  function nostromoDesignation() { return projectDesignation(); }
15910
16717
 
16718
+ // How long the Memory map may open without asking for the project name (Settings › Permissions ›
16719
+ // Memory access): never stored past its hour, and only in this browser for this project.
16720
+ const MEMORY_ACCESS_KEY = 'pulse.memoryAccess';
16721
+ const MEMORY_ACCESS = [['ask', () => t('Always ask')], ['60', () => t('{n} h', { n: 1 })], ['480', () => t('{n} h', { n: 8 })], ['1440', () => t('{n} h', { n: 24 })], ['never', () => t('Never')]];
16722
+ function memoryAccess() {
16723
+ try {
16724
+ const held = JSON.parse(localStorage.getItem(MEMORY_ACCESS_KEY) ?? 'null');
16725
+ if (held?.choice === 'never' || (held && Number(held.until) > Date.now())) return held;
16726
+ } catch { /* no storage: it asks */ }
16727
+ return null;
16728
+ }
16729
+ function setMemoryAccess(choice) {
16730
+ try {
16731
+ if (choice === 'ask') localStorage.removeItem(MEMORY_ACCESS_KEY);
16732
+ else localStorage.setItem(MEMORY_ACCESS_KEY, JSON.stringify({ choice, until: choice === 'never' ? null : Date.now() + Number(choice) * 60000 }));
16733
+ } catch { /* no storage: it keeps asking */ }
16734
+ }
16735
+ function clockTime(at) { return new Date(at).toLocaleTimeString(language(), { hour: '2-digit', minute: '2-digit' }); }
16736
+ function memoryAccessLine(held) {
16737
+ if (!held) return t('It asks every time.');
16738
+ return held.choice === 'never' ? t('It does not ask in this project.') : t('It does not ask until {time}.', { time: clockTime(held.until) });
16739
+ }
15911
16740
  nostromo.button?.addEventListener('click', () => {
15912
- if (nostromo.armed) { void openNostromo(); return; }
16741
+ if (nostromo.armed || memoryAccess()) { void openNostromo(); return; }
15913
16742
  if (!nostromo.gate.dialog) return;
15914
16743
  nostromo.gate.reply.textContent = '';
15915
16744
  nostromo.gate.reply.className = 'key-reply';
@@ -15920,6 +16749,11 @@ nostromo.button?.addEventListener('click', () => {
15920
16749
  // NOSTROMO also lives in the bar: the same door, from anywhere in the room.
15921
16750
  document.querySelector('#nostromo-bar')?.addEventListener('click', () => nostromo.button?.click());
15922
16751
  nostromo.gate.cancel?.addEventListener('click', () => nostromo.gate.dialog.close());
16752
+ // «Don't ask again» goes straight to where that is chosen: Settings › Permissions › Memory access.
16753
+ document.querySelector('#nostromo-gate-skip')?.addEventListener('click', () => {
16754
+ nostromo.gate.dialog.close();
16755
+ openSettingsOn('permissions', { anchor: 'perm-memory' });
16756
+ });
15923
16757
  nostromo.gate.form?.addEventListener('submit', (event) => {
15924
16758
  event.preventDefault();
15925
16759
  const typed = nostromo.gate.input.value.trim();
@@ -15937,7 +16771,8 @@ nostromo.gate.form?.addEventListener('submit', (event) => {
15937
16771
  });
15938
16772
 
15939
16773
  // What each class of memory is called as a star, for the legend above the constellation.
15940
- const DWARF_CLASS = { decision: t('YELLOW DWARF'), fact: t('WHITE DWARF'), preference: t('GREEN DWARF'), question: t('BLUE DWARF'), aberration: t('COLLAPSED') };
16774
+ // The star each kind burns as; its title adds the kind, so the label does not repeat it.
16775
+ const DWARF_CLASS = { decision: t('YELLOW STAR'), fact: t('WHITE STAR'), preference: t('GREEN STAR'), question: t('BLUE STAR'), aberration: t('COLLAPSED') };
15941
16776
 
15942
16777
  // The legend's stars (nostromo-engine.js: dwarfChip, collapsedChip) are the Project map's too.
15943
16778
  // The legend: one real star per class, with the kind of memory that burns as that class.
@@ -15956,8 +16791,8 @@ function paintLegend() {
15956
16791
  // say so. What a zone changes is whether the star is lit, which is one more thing to name here
15957
16792
  // rather than a second legend to read.
15958
16793
  const dim = el('span', 'k dim');
15959
- dim.append(dwarfChip(hexMix(MEMORY_COLORS.fact, '#0b0f16', 0.52), 14), el('i', null, t('OUT OF CIRCULATION')));
15960
- dim.title = t('A faded star is in MED BAY or JETTISONED: the archive keeps it and never hands it to a turn.');
16794
+ dim.append(dwarfChip(hexMix(MEMORY_COLORS.fact, '#0b0f16', 0.52), 14), el('i', null, t('NOT USED')));
16795
+ dim.title = t('A faded star is a quarantined or discarded memory: it is kept, but never used in answers.');
15961
16796
  chips.push(dim);
15962
16797
  host.replaceChildren(...chips);
15963
16798
  host.dataset.lit = 'yes';
@@ -16104,7 +16939,7 @@ function buildNostromo(data) {
16104
16939
  }
16105
16940
 
16106
16941
  // Written out here and not inside the renderer, where `t` is the clock and not the language.
16107
- const lockedLabel = (memory) => `${t('TARGET LOCKED')} · #${memory.id}`;
16942
+ const lockedLabel = (memory) => `${t('MEMORY SELECTED')} · #${memory.id}`;
16108
16943
 
16109
16944
  // ---- NOSTROMO on a phone: the chart.
16110
16945
  //
@@ -16815,14 +17650,14 @@ function showNostromoCard(node) {
16815
17650
  links.replaceChildren();
16816
17651
  if (!neighbours.length) links.append(el('li', 'none', t('No theme shared with another memory yet.')));
16817
17652
  for (const { link, node: other } of neighbours) {
16818
- links.append(wireRow(other, `${Math.round(link.weight * 100)}%`, { link, title: `${kindWord(other.memory.kind)} · ${t('{pct}% of the same meaning', { pct: Math.round(link.weight * 100) })}\n${other.memory.text}` }));
17653
+ links.append(wireRow(other, `${Math.round(link.weight * 100)}%`, { link, title: `${kindWord(other.memory.kind)} · ${t('{pct}% similar in meaning', { pct: Math.round(link.weight * 100) })}\n${other.memory.text}` }));
16819
17654
  }
16820
17655
 
16821
17656
  // And what has actually passed between it and the room. Filled from the archive, then kept up.
16822
17657
  cardPart(card, 'exchange').textContent = t('READING…');
16823
17658
  cardPart(card, 'fired').replaceChildren();
16824
17659
  const forget = cardPart(card, 'forget');
16825
- forget.textContent = memory.kind === 'aberration' ? t('CLEAR THIS ABERRATION') : t('FORGET THIS MEMORY');
17660
+ forget.textContent = memory.kind === 'aberration' ? t('REMOVE THIS DOUBTFUL MEMORY') : t('FORGET THIS MEMORY');
16826
17661
  forget.classList.remove('confirm');
16827
17662
  forget.disabled = false;
16828
17663
  void nostromoTraffic(card);
@@ -16863,7 +17698,7 @@ async function nostromoTraffic(card = frontCard()) {
16863
17698
  said.push(t('last turn: {who} · {when}', { who: last.agent ? `@${last.agent}` : t('the project'), when: agoWords(last.at) }));
16864
17699
  } else if (traffic.recalled && traffic.since) {
16865
17700
  // The counters are older than the trail. Saying nothing here would read as "always alone".
16866
- said.push(t('company recorded since {when} · nothing since', { when: new Date(traffic.since).toLocaleDateString() }));
17701
+ said.push(t('MADRE has tracked what it is used with since {when} · nothing yet', { when: new Date(traffic.since).toLocaleDateString() }));
16867
17702
  }
16868
17703
  cardPart(card, 'exchange').textContent = said.join('\n');
16869
17704
 
@@ -16876,12 +17711,12 @@ async function nostromoTraffic(card = frontCard()) {
16876
17711
  const other = byId.get(mate.id);
16877
17712
  if (!other) continue;
16878
17713
  const link = nostromo.links.find((one) => (one.a === id && one.b === mate.id) || (one.b === id && one.a === mate.id)) ?? null;
16879
- const row = wireRow(other, `×${mate.times}`, { link, title: `${t('Travelled into the same turn {n} {times} · last {when}', { n: mate.times, times: mate.times === 1 ? t('time') : t('times'), when: agoWords(mate.last) })}${mate.cascades ? `\n${t('Strong enough that recalling this one now brings it along ({pct}%).', { pct: Math.round(mate.strength * 100) })}` : ''}\n${other.memory.text}` });
17714
+ const row = wireRow(other, `×${mate.times}`, { link, title: `${t('Used together in the same turn {n} {times} · last {when}', { n: mate.times, times: mate.times === 1 ? t('time') : t('times'), when: agoWords(mate.last) })}${mate.cascades ? `\n${t('Strong enough that recalling this one now brings it along ({pct}%).', { pct: Math.round(mate.strength * 100) })}` : ''}\n${other.memory.text}` });
16880
17715
  if (mate.cascades) row.classList.add('carries');
16881
17716
  fired.append(row);
16882
17717
  }
16883
17718
  if (!(traffic.fired ?? []).length && traffic.recalled) {
16884
- fired.append(el('li', 'none', traffic.recent?.length ? t('It has always travelled alone.') : t('No turn has carried it since the project started keeping this trail.')));
17719
+ fired.append(el('li', 'none', traffic.recent?.length ? t('It has always been used alone.') : t('No turn has carried it since the project started keeping this trail.')));
16885
17720
  }
16886
17721
  // What a refutation did, from whichever end this memory is on.
16887
17722
  for (const taken of traffic.refutes ?? []) {
@@ -16890,7 +17725,7 @@ async function nostromoTraffic(card = frontCard()) {
16890
17725
  const dot = el('i');
16891
17726
  dot.style.setProperty('--kind', MEMORY_COLORS[taken.kind] ?? MEMORY_COLORS.fact);
16892
17727
  row.append(dot, el('span', 'what', taken.text), el('b', null, t('TAKEN DOWN')));
16893
- row.title = t('This aberration took that memory out of every future turn.');
17728
+ row.title = t('This doubtful memory took that memory out of every future answer.');
16894
17729
  if (other) row.addEventListener('click', () => showNostromoCard(other));
16895
17730
  fired.append(row);
16896
17731
  }
@@ -16900,7 +17735,7 @@ async function nostromoTraffic(card = frontCard()) {
16900
17735
  const dot = el('i');
16901
17736
  dot.style.setProperty('--kind', MEMORY_COLORS[traffic.refutedBy.kind] ?? MEMORY_COLORS.aberration);
16902
17737
  row.append(dot, el('span', 'what', traffic.refutedBy.text), el('b', null, t('REFUTES IT')));
16903
- row.title = t('An aberration took this memory out of every future turn.');
17738
+ row.title = t('A doubtful memory took this memory out of every future answer.');
16904
17739
  if (other) row.addEventListener('click', () => showNostromoCard(other));
16905
17740
  fired.append(row);
16906
17741
  }
@@ -17032,7 +17867,7 @@ const FIRST_TOUCH = {
17032
17867
  button: '#mother-button',
17033
17868
  panel: '#mother',
17034
17869
  title: t('Diagnostics'),
17035
- text: t('When something fails, describe it in your own words: Diagnostics says what is going on and gives you the key that fixes it.'),
17870
+ text: t('When something fails, describe it in your own words: Diagnostics says what is going on and gives you the button that fixes it.'),
17036
17871
  },
17037
17872
  memory: {
17038
17873
  button: '#nostromo-bar',
@@ -17085,6 +17920,7 @@ function firstTouch(id) {
17085
17920
  }
17086
17921
  function showFirstTouch(hint, button) {
17087
17922
  document.querySelector('.hint-card')?.remove();
17923
+ document.querySelectorAll('.hint-spot').forEach((node) => node.remove());
17088
17924
  const card = el('section', 'hint-card');
17089
17925
  card.setAttribute('role', 'dialog');
17090
17926
  card.setAttribute('aria-label', hint.title);
@@ -17096,8 +17932,8 @@ function showFirstTouch(hint, button) {
17096
17932
  if (modal) card.classList.add('in-modal');
17097
17933
  // Placed once the workspace has opened, because opening it moves things: the bar narrows when
17098
17934
  // the browser docks, and the conversations button steps aside for its own panel. Under the
17099
- // button, with its point aimed at it, while the button is there to point at; beside the panel
17100
- // it opened, with no point at all, when the button is gone.
17935
+ // button, which the spotlight lights, while the button is there; beside the panel it opened
17936
+ // when the button is gone.
17101
17937
  const place = () => {
17102
17938
  const box = button.getBoundingClientRect();
17103
17939
  const width = card.offsetWidth;
@@ -17111,13 +17947,19 @@ function showFirstTouch(hint, button) {
17111
17947
  }
17112
17948
  const left = Math.min(window.innerWidth - width - 12, Math.max(12, box.left + box.width / 2 < window.innerWidth / 2 ? box.left : box.right - width));
17113
17949
  card.style.left = `${left}px`;
17114
- card.style.top = `${box.bottom + 12}px`;
17115
- card.style.setProperty('--arrow', `${Math.min(width - 20, Math.max(14, box.left + box.width / 2 - left))}px`);
17950
+ // A button at the foot of the window (the robot under the box) gets its card above it.
17951
+ const above = box.bottom + 12 + card.offsetHeight > window.innerHeight - 8;
17952
+ card.classList.toggle('above', above);
17953
+ card.style.top = `${above ? Math.max(12, box.top - 12 - card.offsetHeight) : box.bottom + 12}px`;
17116
17954
  };
17117
17955
  card.style.visibility = 'hidden';
17118
- const show = () => { place(); card.style.visibility = ''; };
17956
+ // No beak: the place it speaks of is lit and the rest dims a little. Inside a modal window the
17957
+ // card is in the top layer and a ring under it would sit behind the window, so none there.
17958
+ const spot = modal ? null : spotlight(button);
17959
+ const show = () => { place(); spot?.place(); card.style.visibility = ''; };
17119
17960
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(show)); else show();
17120
17961
  const close = () => {
17962
+ spot?.remove();
17121
17963
  card.remove();
17122
17964
  document.removeEventListener('pointerdown', outside, true);
17123
17965
  document.removeEventListener('keydown', escape, true);
@@ -17133,6 +17975,48 @@ function showFirstTouch(hint, button) {
17133
17975
  }
17134
17976
  for (const id of Object.keys(FIRST_TOUCH)) document.querySelector(FIRST_TOUCH[id].button)?.addEventListener('click', () => firstTouch(id));
17135
17977
 
17978
+ // What a small version brings is said with the same cards, once each, on their own: no welcome and
17979
+ // no tour (1.0.2: the copilot, outside tools, the Finder, the memory's door and the folded
17980
+ // notices). Only when the room is quiet — no dialog open, no
17981
+ // other card — one after the other, each pointing at where it lives.
17982
+ const NEW_HINTS = [
17983
+ { id: 'new-copilot', button: '.copilot-chip', title: t('New: Copilot'), text: t('Let an agent work on its own for a while. Press the robot, choose how long, and the agent you write to can change files, make plans and run safe commands without asking you each time. It asks for your permission only once.') },
17984
+ { id: 'new-mcp', button: '#custom-button', panel: '#settings', title: t('New: Outside tools (MCP)'), text: t('Agents can now use other apps and services: a program on this computer or one on the internet. Add them in Settings › Outside tools (MCP). If you already set some up in Claude Code, Codex, Gemini or Cursor, you can bring them over.') },
17985
+ { id: 'new-finder', button: '#tree-button', spill: '.bar-more', title: t('New: your files stay up to date'), text: t('The file list updates by itself after every answer and every command. A colored dot on a folder tells you which agent changed something inside.') },
17986
+ { id: 'new-memory-access', button: '#nostromo-bar', spill: '.bar-more', title: t('New: open the memory faster'), text: t('The Memory map asks you to type the project name before it opens. You can skip that step for 1, 8 or 24 hours, or for good: choose «Don\'t ask again…» when it asks, or go to Settings › Permissions.') },
17987
+ { id: 'new-dots', button: '#messages .sys-dots:not(.open)', onScreen: true, title: t('New: notices shrink into dots'), text: t('To keep the chat clean, notices shrink into a small dot after 10 seconds. Click the dot to read them again. The ones that need something from you stay open.') },
17988
+ ];
17989
+ // Asked again while a card has nothing to point at, a few times only: a timer that never stops would
17990
+ // keep a page (and a test) alive for nothing.
17991
+ let newHintTries = 0;
17992
+ function showNewHints(delay = 2500) {
17993
+ if (state.device) return;
17994
+ // Unreferenced where a timer can be (Node, in the tests): a card that waits never keeps a process up.
17995
+ setTimeout(() => {
17996
+ // Where it lives, or «more» when the bar keeps that key behind it.
17997
+ const shown = (selector) => { const node = selector && document.querySelector(selector); return node?.getClientRects().length ? node : null; };
17998
+ // Something in the conversation is pointed at where the person is reading: the latest one in
17999
+ // view, not the first of the thread (that one is at the very top, scrolled away).
18000
+ const inView = (selector) => [...document.querySelectorAll(selector)].filter((node) => {
18001
+ const box = node.getBoundingClientRect();
18002
+ return box.width && box.bottom > 60 && box.top < window.innerHeight - 160;
18003
+ }).at(-1) ?? null;
18004
+ const anchorOf = (hint) => (hint.onScreen ? inView(hint.button) : shown(hint.button)) ?? shown(hint.spill);
18005
+ const pending = NEW_HINTS.filter((hint) => !hintsSeen()[hint.id]);
18006
+ const next = pending.find(anchorOf);
18007
+ // Nothing to point at yet (no folded notice, a key not on screen): asked again later.
18008
+ if (!next) { if (pending.length && (newHintTries += 1) <= 10) showNewHints(30000); return; }
18009
+ // Not over something the person is already reading: tried again later, never stacked.
18010
+ if (document.querySelector('dialog[open], .hint-card') || !els.onboarding.hidden) { showNewHints(8000); return; }
18011
+ try { localStorage.setItem(HINTS_KEY, JSON.stringify({ ...hintsSeen(), [next.id]: true })); } catch { return; }
18012
+ const behind = !next.onScreen && !shown(next.button);
18013
+ showFirstTouch(behind ? { ...next, text: `${next.text} ${t('You\'ll find it under «More», on the top bar.')}` } : next, anchorOf(next));
18014
+ const card = document.querySelector('.hint-card');
18015
+ const watch = setInterval(() => { if (!card?.isConnected) { clearInterval(watch); showNewHints(600); } }, 400);
18016
+ watch?.unref?.();
18017
+ }, delay)?.unref?.();
18018
+ }
18019
+
17136
18020
  /* ---------- What's new: once per version, after an update. ---------- */
17137
18021
 
17138
18022
  // What a version brings, said after the update: a board of the things it brings, each a card with
@@ -17368,7 +18252,7 @@ function renderUpdate() {
17368
18252
  function drawRelease(section, info) {
17369
18253
  section.replaceChildren();
17370
18254
  if (!info) { sxBlock(section, t('Version'), t('The version could not be read.')); return; }
17371
- const where = { npx: t('It runs from the npx cache.'), project: t("It runs from this project's node_modules."), global: t('It is installed globally.') }[info.install] ?? t('It runs from source.');
18255
+ const where = { npx: t('Runs without being installed (npx).'), project: t("Installed inside this project."), global: t('Installed on this computer.') }[info.install] ?? t('It runs from source.');
17372
18256
  const chip = info.available ? sxChip(t('{version} available', { version: info.latest }), 'warn') : info.latest ? sxChip(t('Up to date'), 'on') : null;
17373
18257
  const block = sxBlock(section, `MADRE ${info.current}`, where, { aside: chip });
17374
18258
  // What this version brought, again: the board that opened after the update, for whoever closed
@@ -17380,16 +18264,17 @@ function drawRelease(section, info) {
17380
18264
  const when = info.checkedAt ? new Date(info.checkedAt).toLocaleString() : null;
17381
18265
  const check = sxSwitch(info.enabled, async (on, box) => {
17382
18266
  box.disabled = true;
17383
- try { await fetch('/api/updates/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ check: on }) }); toast(on ? t('Diagnostics › release channel on: one check a day.') : t('Diagnostics › release channel off: no request leaves for npm.')); await loadVersion(); }
18267
+ try { await fetch('/api/updates/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ check: on }) }); toast(on ? t('Diagnostics › Look for new versions on: one check a day.') : t('Diagnostics › Look for new versions off: no request leaves for npm.')); await loadVersion(); }
17384
18268
  catch (error) { box.checked = !on; box.disabled = false; toast(t('The setting was not saved: {error}', { error: error.message })); }
17385
18269
  }, { disabled: Boolean(info.envWins), label: t('Look for new versions') });
17386
- sxRow(block, t('Look for new versions'), t('Once a day MADRE asks npm for the latest version: only the package name travels, the same request npx makes.') + (info.envWins ? ` ${t('Set by PULSE_UPDATE_CHECK.')}` : ''), check);
18270
+ sxRow(block, t('Look for new versions'), t('Once a day MADRE checks whether there\'s a new version. Only the app\'s name is sent.') + (info.envWins ? ` ${t('Set by PULSE_UPDATE_CHECK.')}` : ''), check);
17387
18271
  if (info.enabled) sxRow(block, t('Last check'), when ?? t('Not yet.'), sxButton(t('Check now'), () => void loadVersion({ force: true })));
18272
+ drawLegal(section);
17388
18273
 
17389
18274
  if (!info.available) return;
17390
18275
  const canRestart = info.install !== 'source';
17391
18276
  const fresh = sxBlock(section, t('MADRE {version} is ready', { version: info.latest }), canRestart
17392
- ? t('Restart with it here: the project closes, installs and comes back on this same address in a few seconds. Nothing in the ledger is lost. Or run the command yourself.')
18277
+ ? t('Restart here: the project closes, updates and comes back in a few seconds. Nothing is lost. Or run the command yourself.')
17393
18278
  : t('Pull the repository and start it again.'), { className: 'sx-fresh' });
17394
18279
  if (canRestart) {
17395
18280
  const restart = sxButton(updateUI.restarting ? t('Restarting…') : t('Restart with {version}', { version: info.latest }), async () => {
@@ -17476,7 +18361,7 @@ function renderTurns(section) {
17476
18361
  };
17477
18362
  sxRow(block, t('Several at once'), turns.enabled ? t('Several conversations may work at once.') : t('One conversation at a time; switching away from one at work is refused.'), sxSwitch(turns.enabled, (on) => save({ enabled: on }), { label: t('Several at once') }));
17478
18363
  sxRow(block, t('Conversations at once'), t('How many conversations may be working at the same time.'), sxStepper(turns.chats, { max: 6, disabled: !turns.enabled }, (n) => save({ chats: n })), { off: !turns.enabled });
17479
- sxRow(block, t('Turns per conversation'), t('How many answers may be under way inside one conversation.'), sxStepper(turns.turnsPerChat, { max: 8, disabled: !turns.enabled }, (n) => save({ turnsPerChat: n })), { off: !turns.enabled });
18364
+ sxRow(block, t('Answers per conversation'), t('How many answers may be under way inside one conversation.'), sxStepper(turns.turnsPerChat, { max: 8, disabled: !turns.enabled }, (n) => save({ turnsPerChat: n })), { off: !turns.enabled });
17480
18365
  const notes = notificationsState();
17481
18366
  const ask = sxButton(notes === 'granted' ? t('On') : notes === 'denied' ? t('Blocked') : notes === 'unsupported' ? t('Not available') : t('Turn on'), async () => { await askForNotifications(); renderMotherSentinel(); }, { tone: notes === 'default' ? 'primary' : null });
17482
18367
  ask.disabled = notes !== 'default';
@@ -17497,18 +18382,18 @@ function drawSentinel(section) {
17497
18382
  const reports = [...state.reports.values()].sort((a, b) => (a.at < b.at ? 1 : -1));
17498
18383
  const unsent = reports.filter((report) => !report.sent?.ok).length;
17499
18384
  const settings = sentinelUI.settings ?? { autoReport: false, canSend: false, repo: null };
17500
- const block = sxBlock(section, t('Sentinel'), t('It keeps the failures Diagnostics cannot explain, and crashes, with paths, names and keys removed. Nothing leaves this machine unless you send it.'), {
18385
+ const block = sxBlock(section, t('Error reports'), t('Keeps errors MADRE can\'t explain, with private details removed. Nothing leaves this computer unless you send it.'), {
17501
18386
  aside: reports.length ? sxChip(t('{n} reports · {unsent} not sent', { n: reports.length, unsent }), unsent ? 'warn' : '') : sxChip(t('Nothing to report'), 'on'),
17502
18387
  });
17503
- sxRow(block, t('Report automatically'), settings.canSend ? t("New unknown conditions go to the author's collector, redacted.") : t('No collector is configured (PULSE_REPORT_URL).'), sxSwitch(settings.autoReport, async (on, box) => {
18388
+ sxRow(block, t('Send reports automatically'), settings.canSend ? t("New errors go to MADRE's author, with private details removed.") : t('Sending isn\'t set up in this copy of MADRE.'), sxSwitch(settings.autoReport, async (on, box) => {
17504
18389
  box.disabled = true;
17505
18390
  try {
17506
18391
  const result = await fetch('/api/sentinel/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ autoReport: on }) }).then((response) => response.json());
17507
18392
  sentinelUI.settings = result.settings;
17508
- toast(result.settings.autoReport ? t("Diagnostics › auto-report on: new unknown conditions go to the author's collector, redacted.") : t('Diagnostics › auto-report off: reports stay here until you send one.'));
18393
+ toast(result.settings.autoReport ? t("Diagnostics › error reports on: new errors go to MADRE's author, with private details removed.") : t('Diagnostics › auto-report off: reports stay here until you send one.'));
17509
18394
  } catch (error) { box.checked = !on; toast(t('It could not be saved: {error}', { error: error.message })); }
17510
18395
  finally { renderMotherSentinel(); }
17511
- }, { disabled: !settings.canSend, label: t('Report automatically') }), { off: !settings.canSend });
18396
+ }, { disabled: !settings.canSend, label: t('Send reports automatically') }), { off: !settings.canSend });
17512
18397
  // Whoever arrived from a post in Spanish will not make a GitHub account to say "it does not
17513
18398
  // detect Codex". The form leads there; the issue stays one click away, and the other way round
17514
18399
  // in English, where the person reading this is already holding an account.
@@ -17519,7 +18404,7 @@ function drawSentinel(section) {
17519
18404
  both.append(...(spanish ? [viaForm, viaIssue] : [viaIssue, viaForm]));
17520
18405
  sxRow(block, t('Tell the author'), t('What failed, or what you wish MADRE did.'), both);
17521
18406
  if (!reports.length) return;
17522
- const list = sxBlock(section, t('Reports'), t('Each one is redacted before it is kept. Read it in the issue before you send it.'));
18407
+ const list = sxBlock(section, t('Reports'), t('Private details are removed from each one. You can read each report before sending it.'));
17523
18408
  for (const report of reports.slice(0, 20)) {
17524
18409
  const who = report.kind === 'crash' ? t('Crash') : `@${report.agent ?? t('project')}`;
17525
18410
  const line = String(report.error).split('\n')[0].slice(0, 200);
@@ -17585,7 +18470,7 @@ const BAR_BUILT = {
17585
18470
  // They ride with the crew when it is there, and lead the keys when it is not: where they always were.
17586
18471
  const BAR_CONTEXTUAL = ['#steps-pill', '#tour-pill-slot', '#handoff-pill', '#update-pill', '#stop-all'];
17587
18472
  // A module's name before the board has answered; once it has, the module says its own.
17588
- const BAR_MODULE_NAMES = { ash: 'Token saver', ollama: 'Local AI', intents: 'Intenciones' };
18473
+ const BAR_MODULE_NAMES = { ash: 'Token saver', ollama: 'Local AI', intents: 'Suggested effort' };
17589
18474
  const BAR_MINUS = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 8h7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
17590
18475
  const barKit = {
17591
18476
  node: document.querySelector('#bar'),
@@ -17640,7 +18525,7 @@ function barCatalogue() {
17640
18525
  const nodes = spec.nodes.map((query) => document.querySelector(query)).filter(Boolean);
17641
18526
  if (nodes.length) out.set(id, { id, name: spec.name(), nodes });
17642
18527
  }
17643
- out.set('memory', { id: 'memory', name: t('Memory'), icon: SETTINGS_CATS.memory.icon, open: () => openSettingsOn('memory') });
18528
+ out.set('memory', { id: 'memory', name: t('What it remembers'), icon: SETTINGS_CATS.memory.icon, open: () => openSettingsOn('memory') });
17644
18529
  // SALAS, every room on this Mac (docs/MACROVERSO.md). On a phone the way back to them is already
17645
18530
  // at the start of the bar.
17646
18531
  // On the Mac the way out is the house in the second bar; a key here would say it twice.
@@ -18040,6 +18925,11 @@ function barSpills(bar) {
18040
18925
  const left = bar.querySelector?.(':scope > .bar-left');
18041
18926
  const name = left?.querySelector?.('#project');
18042
18927
  if (left && name?.getClientRects?.().length && name.getBoundingClientRect().right > left.getBoundingClientRect().right + 0.5) return true;
18928
+ // And the open conversation's name beside it (2026-10-14): when a pill appeared (a new version
18929
+ // on npm) it was the one to give way, down to a sliver, and the bar said nothing spilled. It may
18930
+ // be cut short with an ellipsis, never below a readable width.
18931
+ const chat = [...(left?.querySelectorAll?.('.project') ?? [])].at(-1);
18932
+ if (chat && chat !== name && chat.getClientRects?.().length && chat.scrollWidth > chat.clientWidth + 1 && chat.clientWidth < Math.min(120, chat.scrollWidth)) return true;
18043
18933
  if (typeof getComputedStyle !== 'function') return false;
18044
18934
  const end = bar.getBoundingClientRect().right - (parseFloat(getComputedStyle(bar).paddingRight) || 0);
18045
18935
  for (const node of bar.children ?? []) {
@@ -18840,3 +19730,253 @@ if (!SPLIT_CHAT) startReviewAsk({ busy: () => Boolean(state.device || state.runn
18840
19730
  // The tour of 1.0 reads where the person is as the room opens, so First steps knows whether to
18841
19731
  // wait; on a phone, the phone's card is said the first time.
18842
19732
  if (!SPLIT_CHAT) roomTour();
19733
+
19734
+ /* ---------- PRUEBA · respuestas sin burbuja (2026-10-15) ---------- */
19735
+ // Para comparar formas de respuesta sin burbuja con la de siempre, en una sala de verdad, sobre su
19736
+ // fondo. Nadie lo ve si no abre la sala con `?reply=`: doc, doc-inline, pieces, pieces-edge o bubble,
19737
+ // y `&lane=` lane, column, halo, stripe o 0. Lo elegido se recuerda en este navegador; `?reply=off`
19738
+ // lo apaga y esconde el selector.
19739
+ (() => {
19740
+ const FORMS = [['A', 'doc', 'A · regla'], ['B', 'doc-inline', 'B · avatar en línea'], ['C', 'pieces', 'C · tarjeta'], ['D', 'pieces-edge', 'D · filo'], ['E', 'bubble', 'E · burbuja']];
19741
+ const LANES = [['1', 'lane', '1 · difuso'], ['2', 'column', '2 · columna'], ['3', 'halo', '3 · halo'], ['4', 'stripe', '4 · franja'], ['5', '0', '5 · sin carril']];
19742
+ const TERMS = [['0', 'actual', 'Actual'], ['a', 'line', 'a · línea'], ['b', 'window', 'b · ventana'], ['c', 'pill', 'c · píldora'], ['d', 'step', 'd · paso']];
19743
+ // Desde ACCC-BABA-ECBB-DCAA-C (2026-10-15) la respuesta va en burbuja y cada /run en píldora. Lo que
19744
+ // se eligió en el selector solo vale mientras se compara: sin ?reply ni ?combo, la forma de siempre.
19745
+ let reply = 'bubble';
19746
+ let lane = 'lane';
19747
+ let term = 'pill';
19748
+ let showLab = false;
19749
+ try {
19750
+ const asked = new URLSearchParams(location.search);
19751
+ if (asked.get('reply') === 'off') { for (const key of ['pulse.reply', 'pulse.reply.lane', 'pulse.reply.term']) localStorage.removeItem(key); }
19752
+ else {
19753
+ // ?combo=C1b: la forma, el carril y la terminal en un código.
19754
+ const combo = /^([A-E])([1-5])?([0a-d])?$/.exec(asked.get('combo') ?? '');
19755
+ if (combo) {
19756
+ localStorage.setItem('pulse.reply', FORMS.find(([code]) => code === combo[1])[1]);
19757
+ if (combo[2]) localStorage.setItem('pulse.reply.lane', LANES.find(([code]) => code === combo[2])[1]);
19758
+ if (combo[3]) localStorage.setItem('pulse.reply.term', TERMS.find(([code]) => code === combo[3])[1]);
19759
+ }
19760
+ if (asked.has('reply')) localStorage.setItem('pulse.reply', asked.get('reply'));
19761
+ if (asked.has('lane')) localStorage.setItem('pulse.reply.lane', asked.get('lane'));
19762
+ if (asked.has('term')) localStorage.setItem('pulse.reply.term', asked.get('term'));
19763
+ }
19764
+ showLab = (asked.has('reply') && asked.get('reply') !== 'off') || asked.has('combo');
19765
+ if (showLab) {
19766
+ reply = localStorage.getItem('pulse.reply') ?? reply;
19767
+ lane = localStorage.getItem('pulse.reply.lane') ?? lane;
19768
+ term = localStorage.getItem('pulse.reply.term') ?? term;
19769
+ }
19770
+ if (lane === '1') lane = 'lane';
19771
+ } catch { /* sin almacenamiento: la forma de siempre */ }
19772
+ if (!FORMS.some(([, value]) => value === reply)) reply = 'bubble';
19773
+ if (!TERMS.some(([, value]) => value === term)) term = 'pill';
19774
+ const root = document.documentElement;
19775
+ const apply = () => {
19776
+ if (reply === 'bubble') delete root.dataset.reply; else root.dataset.reply = reply;
19777
+ if (lane === '0' || reply === 'bubble') delete root.dataset.replyLegible; else root.dataset.replyLegible = lane;
19778
+ if (term === 'actual') delete root.dataset.term; else root.dataset.term = term;
19779
+ };
19780
+ apply();
19781
+ const bar = el('div', 'reply-lab');
19782
+ const keep = (key, value) => { try { localStorage.setItem(key, value); } catch { /* sin almacenamiento */ } };
19783
+ const codeOf = () => `${FORMS.find(([, value]) => value === reply)[0]}${LANES.find(([, value]) => value === lane)?.[0] ?? '1'}${TERMS.find(([, value]) => value === term)[0]}`;
19784
+ const row = (title, list, current, set, { off = false } = {}) => {
19785
+ const line = el('div', 'lab-row');
19786
+ line.append(el('b', null, title));
19787
+ for (const [, value, words] of list) {
19788
+ const key = el('button', value === current && !off ? 'on' : null, words);
19789
+ key.type = 'button';
19790
+ key.disabled = off;
19791
+ key.addEventListener('click', () => { set(value); apply(); draw(); });
19792
+ line.append(key);
19793
+ }
19794
+ return line;
19795
+ };
19796
+ const draw = () => {
19797
+ bar.replaceChildren();
19798
+ const forms = row('Forma', FORMS, reply, (value) => { reply = value; keep('pulse.reply', value); });
19799
+ const sample = el('button', 'lab-sample', 'Insertar ejemplo');
19800
+ sample.type = 'button';
19801
+ sample.title = 'Dos respuestas y dos comandos de muestra, solo en pantalla: no se guardan';
19802
+ sample.addEventListener('click', insertSample);
19803
+ forms.append(sample);
19804
+ const lanes = row('Carril', LANES, lane, (value) => { lane = value; keep('pulse.reply.lane', value); }, { off: reply === 'bubble' });
19805
+ const terms = row('Terminal', TERMS, term, (value) => { term = value; keep('pulse.reply.term', value); });
19806
+ const code = el('span', 'lab-code', codeOf());
19807
+ code.title = 'Esta combinación. Ábrela con ?combo=' + codeOf();
19808
+ terms.append(code);
19809
+ bar.append(forms, lanes, terms);
19810
+ };
19811
+ // Dos respuestas como las de las capturas, dibujadas por el mismo render de la sala.
19812
+ function insertSample() {
19813
+ const column = document.querySelector('#messages .thread-inner') ?? document.querySelector('#messages');
19814
+ if (!column) return;
19815
+ column.querySelectorAll('.reply-sample').forEach((node) => node.remove());
19816
+ const make = (agent, label, badges, said, text) => {
19817
+ const row = paint(el('article', 'row assistant reply-sample'), agent);
19818
+ row.dataset.agent = agent;
19819
+ const col = el('div', 'col');
19820
+ const who = el('div', 'who');
19821
+ who.append(el('span', null, label), ...badges, el('span', 'reply', said));
19822
+ const bubble = el('div', 'bubble');
19823
+ bubble.append(renderMarkdown(text));
19824
+ const foot = el('div', 'bubble-foot');
19825
+ foot.append(el('div', 'stamp', '02:01 PM · 15.3k tok · $0.10'));
19826
+ bubble.append(foot);
19827
+ col.append(who, bubble);
19828
+ row.append(avatar(agent), col);
19829
+ column.append(row);
19830
+ };
19831
+ make('claude', 'Claude', [el('span', 'badge ash', 'AHORRO DE TOKENS')], '↳ Prueba de comunicación entre agentes…',
19832
+ 'Hola a todos, soy @claude abriendo la ronda de prueba. Pido a cada agente una línea de confirmación.\n\n```pulse\n- @codex: saluda y confirma que recibes este mensaje en una línea.\n- @gemini: saluda y confirma que recibes este mensaje en una línea.\n- @opencode: saluda y confirma que recibes este mensaje en una línea.\n- @madre: saluda y confirma que recibes este mensaje en una línea.\n```');
19833
+ make('madre', 'Madre', [el('span', 'badge', 'CONTESTÁNDOLE A @CLAUDE')], '',
19834
+ 'Soy @madre, la memoria de esta sala hablando, con qwen2.5:7b sobre Ollama en esta máquina. El archivista es mi otra mitad: el mismo modelo, de fondo, lee lo que la sala dice cada pocos intercambios o tras unos minutos de reposo y guarda hechos, decisiones, preferencias y preguntas abiertas. Eso es lo que ves en el Mapa de memoria y lo que yo cito con [#n]. No escribo archivos, no ejecuto ni tomo decisiones por nadie.\n\nSí puedo convocar al equipo si me lo pides: «@madre, pregúntale al equipo …» abre una ronda con cada agente en línea y cierro con un resumen citado. Para enseñarme algo no hace falta dictármelo: trabaja en la sala y se destila solo.');
19835
+ for (const node of [
19836
+ renderTerminalCard({ id: 'sample-ok', payload: { name: 'run', ok: true, text: 'PDF listo: output/pdf/cotizacion.pdf', run: { line: 'node scripts/pdf.mjs', exitCode: 0, durationMs: 2000, agent: 'codex' } } }),
19837
+ renderTerminalCard({ id: 'sample-fail', payload: { name: 'run', ok: false, text: 'ℹ tests 60\n✔ test 1 passed\n✖ test 9 failed\nℹ pass 53\nℹ fail 7', run: { line: 'npm test', exitCode: 1, durationMs: 4200, agent: 'codex' } } }),
19838
+ ]) { node.classList.add('reply-sample'); column.append(node); }
19839
+ const thread = document.querySelector('#messages');
19840
+ if (thread) thread.scrollTop = thread.scrollHeight;
19841
+ }
19842
+ if (!showLab || SPLIT_CHAT) return;
19843
+ draw();
19844
+ document.body.append(bar);
19845
+ })();
19846
+
19847
+ /* ---------- LABORATORIO DEL CHAT (2026-10-15) ---------- */
19848
+ // Una conversación con todos los elementos del chat, dibujada por los mismos renderizadores de la
19849
+ // sala a partir de eventos de muestra (solo en pantalla: no se escriben en el registro), y un panel
19850
+ // con cinco formas de cada elemento. Nadie lo ve si no abre la sala con `?lab` (o `?lab=CÓDIGO`).
19851
+ // La elección se guarda como un código de 17 letras, en grupos de cuatro, para pasarla tal cual.
19852
+ // LAB_CHOSEN es lo que quedó en la sala (styles.css, «CHAT · ACCC-BABA-ECBB-DCAA-C»): un elemento solo
19853
+ // lleva data-labNN cuando se compara con otra forma, y entonces su regla de siempre se aparta.
19854
+ const LAB_CHOSEN = 'ACCCBABAECBBDCAAC';
19855
+ const LAB = [
19856
+ ['Mensaje del humano', ['Burbuja de acento (hoy)', 'Burbuja neutra · ChatGPT y Claude', 'Sin burbuja, alineado a la derecha', 'Tarjeta de petición · Cursor', 'Burbuja con destino visible']],
19857
+ ['Adjuntos', ['Mosaicos (antes)', 'Chips en línea', 'Tarjeta de archivo · Claude (hoy)', 'Lista compacta', 'Encima del mensaje · ChatGPT']],
19858
+ ['Pensando', ['Puntos (antes)', 'Texto con brillo · Claude y ChatGPT', 'Avatar que late y tiempo (hoy)', 'Barra fina de progreso', 'Píldora «pensando · 12 s»']],
19859
+ ['Cabecera del agente', ['Mono con chips (antes)', 'Nombre en letra normal · Claude', 'Píldora del agente (hoy)', 'Solo nombre; chips al pasar · ChatGPT', 'Avatar dentro de la cabecera']],
19860
+ ['Cuerpo de la respuesta', ['Prosa libre con carril (antes)', 'Burbuja (hoy)', 'Tarjeta con borde', 'Filo del color del agente', 'Documento con regla']],
19861
+ ['Memoria citada', ['Número en el texto (hoy)', 'Superíndice · ChatGPT con búsqueda', 'Chip de fuente · Perplexity', 'Subrayado punteado', 'Atenuada, al final de la frase']],
19862
+ ['Plan', ['Tarjeta (antes)', 'Lista de tareas con casillas · Cursor (hoy)', 'Pasos numerados con línea', 'Equipo 2×2', 'Línea de tiempo']],
19863
+ ['Bloque de código', ['Cabecera y COPIAR (hoy)', 'Barra suave · ChatGPT', 'Limpio, copiar al pasar · Claude', 'Oscuro siempre · terminal', 'Plegado a 6 líneas']],
19864
+ ['Pie de la respuesta', ['Métricas e iconos (antes)', 'Solo iconos, al pasar · ChatGPT', 'Métricas en píldora', 'Acciones con fondo · Claude', 'Hora sola, métricas al pasar (hoy)']],
19865
+ ['Elección', ['Opciones grandes (antes)', 'Chips de respuesta · ChatGPT y Gemini', 'Lista con número (hoy)', 'Tarjetas lado a lado', 'Botones al pie · Gemini']],
19866
+ ['Petición de permiso', ['Tarjeta (antes)', 'Aprobación en una línea · Claude Code (hoy)', 'Tarjeta con barra de color', 'Barra sobre la caja de escribir', 'Discreta, solo borde']],
19867
+ ['Permiso concedido', ['Píldora del modo (antes)', 'Línea divisoria (hoy)', 'Texto gris centrado', 'Píldora con candado', 'Punto y texto']],
19868
+ ['Petición de comandos', ['Lista con botones (antes)', 'Compacta, sin cajas por línea', 'Código oscuro · terminal', 'Pasos numerados (hoy)', 'Botones solo al pasar']],
19869
+ ['Comando ejecutado', ['Línea con estado (antes)', 'Ventana de terminal', 'Píldora · herramientas de ChatGPT y Claude (hoy)', 'Paso en un hilo', 'Línea compacta, sin /run']],
19870
+ ['Avisos del sistema', ['Puntos plegados (hoy)', 'Línea centrada', 'Divisor con texto', 'Chip a la izquierda', 'Siempre abiertos, en tarjeta']],
19871
+ ['Error y diagnóstico', ['Tarjeta roja centrada (hoy)', 'Tarjeta a la izquierda con icono', 'Banner a lo ancho', 'Línea roja discreta', 'Tarjeta neutra con borde rojo']],
19872
+ ['Espacio entre turnos', ['Como antes', 'Compacto', 'Amplio · ChatGPT y Claude (hoy)', 'Línea fina entre turnos', 'Agrupar el mismo agente']],
19873
+ ];
19874
+ (() => {
19875
+ let asked = null;
19876
+ try { asked = new URLSearchParams(location.search).get('lab'); } catch { return; }
19877
+ if (asked === null || SPLIT_CHAT) return;
19878
+ const root = document.documentElement;
19879
+ const picks = LAB_CHOSEN.split('');
19880
+ try {
19881
+ const saved = (/^[A-E-]{17,21}$/.test(asked) ? asked : localStorage.getItem('pulse.lab')) ?? '';
19882
+ saved.replace(/-/g, '').split('').forEach((key, index) => { if (index < picks.length) picks[index] = key; });
19883
+ } catch { /* sin almacenamiento */ }
19884
+ const code = () => picks.join('').match(/.{1,4}/g).join('-');
19885
+ const apply = () => {
19886
+ picks.forEach((key, index) => {
19887
+ const name = `lab${String(index + 1).padStart(2, '0')}`;
19888
+ if (key === LAB_CHOSEN[index]) delete root.dataset[name]; else root.dataset[name] = key;
19889
+ });
19890
+ // 05 · every body but the bubble was drawn over the old prose form; 14 · the /run cards reuse
19891
+ // the forms already built (data-term); 15 · notices show open.
19892
+ if (picks[4] === 'B') { delete root.dataset.reply; delete root.dataset.replyLegible; }
19893
+ else { root.dataset.reply = 'pieces'; root.dataset.replyLegible = 'lane'; }
19894
+ const term = { B: 'window', C: 'pill', D: 'step', E: 'line' }[picks[13]];
19895
+ if (term) root.dataset.term = term; else delete root.dataset.term;
19896
+ document.querySelectorAll('#messages .sys-dots').forEach((strip) => strip.classList.toggle('open', picks[14] !== 'A'));
19897
+ try { localStorage.setItem('pulse.lab', code()); } catch { /* sin almacenamiento */ }
19898
+ };
19899
+ apply();
19900
+ root.dataset.lab = 'on';
19901
+
19902
+ // The panel: one row per element, its five forms, the code and a way to copy it.
19903
+ const panel = el('aside', 'lab-panel');
19904
+ const draw = () => {
19905
+ panel.replaceChildren();
19906
+ const head = el('header', 'lab-head');
19907
+ const fold = el('button', 'lab-fold', panel.classList.contains('folded') ? '›' : '‹');
19908
+ fold.type = 'button';
19909
+ fold.title = 'Plegar el panel';
19910
+ fold.addEventListener('click', () => { panel.classList.toggle('folded'); draw(); });
19911
+ const codeBox = el('output', 'lab-code', code());
19912
+ const copy = el('button', 'lab-copy', 'Copiar mi elección');
19913
+ copy.type = 'button';
19914
+ copy.addEventListener('click', async () => {
19915
+ const lines = LAB.map(([name, forms], index) => `${String(index + 1).padStart(2, '0')} ${name}: ${picks[index]} · ${forms['ABCDE'.indexOf(picks[index])]}`);
19916
+ const text = `Código: ${code()}\n${lines.join('\n')}`;
19917
+ try { await navigator.clipboard.writeText(text); copy.textContent = 'Copiado ✓'; } catch { prompt('Copia tu elección:', text); }
19918
+ setTimeout(() => { copy.textContent = 'Copiar mi elección'; }, 1600);
19919
+ });
19920
+ head.append(fold, el('b', null, 'Laboratorio'), codeBox, copy);
19921
+ panel.append(head);
19922
+ if (panel.classList.contains('folded')) return;
19923
+ const list = el('div', 'lab-list');
19924
+ LAB.forEach(([name, forms], index) => {
19925
+ const row = el('div', 'lab-row');
19926
+ const top = el('div', 'lab-top');
19927
+ top.append(el('span', 'lab-n', String(index + 1).padStart(2, '0')), el('span', 'lab-name', name));
19928
+ const keys = el('span', 'lab-keys');
19929
+ for (const key of 'ABCDE') {
19930
+ const button = el('button', key === picks[index] ? 'on' : null, key);
19931
+ button.type = 'button';
19932
+ button.title = forms['ABCDE'.indexOf(key)];
19933
+ button.addEventListener('click', () => { picks[index] = key; apply(); draw(); });
19934
+ keys.append(button);
19935
+ }
19936
+ top.append(keys);
19937
+ row.append(top, el('span', 'lab-desc', forms['ABCDE'.indexOf(picks[index])]));
19938
+ list.append(row);
19939
+ });
19940
+ const again = el('button', 'lab-again', 'Volver a dibujar la conversación');
19941
+ again.type = 'button';
19942
+ again.addEventListener('click', () => drawConversation());
19943
+ panel.append(list, again);
19944
+ };
19945
+ draw();
19946
+ document.body.append(panel);
19947
+
19948
+ // The conversation: sample events through the room's own renderer, in the order a real one goes.
19949
+ let n = 0;
19950
+ const at = (minutes) => new Date(Date.now() - (90 - minutes) * 60000).toISOString();
19951
+ const ev = (type, payload, minutes) => ({ id: `lab-${Date.now()}-${n += 1}`, sequence: 900000 + n, type, timestamp: at(minutes), payload });
19952
+ function drawConversation() {
19953
+ const column = els.column;
19954
+ if (!column) return;
19955
+ column.replaceChildren();
19956
+ state.lastSender = null;
19957
+ const u1 = `lab-u1-${Date.now()}`;
19958
+ const a1 = `lab-a1-${Date.now()}`;
19959
+ const events = [
19960
+ ev('message.created', { messageId: u1, role: 'user', sender: 'you', target: 'codex', mode: 2, text: 'Revisa el README y el título del paquete; arma un plan con los demás si hace falta.', status: 'sent', attachments: [{ name: 'README.md', fileName: 'lab-readme.md', size: 4096, contentType: 'text/markdown' }, { name: 'notas.txt', fileName: 'lab-notas.txt', size: 812, contentType: 'text/plain' }] }, 1),
19961
+ { ...ev('agent.started', { messageId: `lab-think-${Date.now()}`, agent: 'gemini', timeoutMs: 180000 }, 2), timestamp: new Date().toISOString() },
19962
+ ev('message.created', { messageId: a1, parentMessageId: u1, role: 'assistant', sender: 'codex', target: 'you', mode: 2, model: 'gpt-5.6-sol', effort: 'high', status: 'sent', text: 'Revisé el proyecto: el título del README no coincidía con el nombre del paquete [#214]. Lo dejé anotado y reparto la revisión.\n\n```pulse\n- @claude: corre las pruebas y dime cuántas pasan.\n- @gemini: revisa que el README se lea bien.\n- @opencode: compara con la versión anterior.\n- @madre: resume lo que se decidió.\n```\n\nEsta es la función que arma el título:\n\n```js\nexport function titulo(paquete) {\n return paquete.nombre ?? \'MADRE\';\n}\n// se usa en README.md y en la barra\n```' }, 3),
19963
+ ev('choice.offered', { agent: 'codex', question: '¿Actualizo también el CHANGELOG?', options: ['Sí, con una línea', 'Sí, con el detalle', 'No por ahora'], recommended: 0 }, 4),
19964
+ ev('message.created', { messageId: `lab-a2-${Date.now()}`, parentMessageId: u1, role: 'assistant', sender: 'gemini', target: 'you', mode: 1, status: 'sent', text: 'El README se lee bien. Para corregir el título necesito #3 por 15 min: tengo que editar README.md, que ya existe.' }, 5),
19965
+ ev('message.created', { messageId: `lab-a3-${Date.now()}`, parentMessageId: u1, role: 'assistant', sender: 'opencode', target: 'you', mode: 1, status: 'sent', text: 'Comparé con la versión anterior. Para dejar el cambio necesito #3 por 15 min.' }, 6),
19966
+ ev('message.created', { messageId: `lab-u2-${Date.now()}`, role: 'user', sender: 'you', target: 'opencode', mode: 3, text: 'Adelante con el cambio.', status: 'sent' }, 7),
19967
+ ev('run.requested', { messageId: u1, responseMessageId: `lab-run-${Date.now()}`, agent: 'madre', reason: 'Ver que el cambio quedó', mode: 3, commands: [{ line: "node -e \"console.log('uno')\"", argv: ['node', '-e', "console.log('uno')"] }, { line: 'git status --short', argv: ['git', 'status', '--short'] }, { line: 'ls | head', why: 'uses "{char}", which needs a shell; put each command on its own line', whyArgs: { char: '|' } }] }, 8),
19968
+ ev('command.output', { name: 'run', ok: true, text: 'PDF listo: output/pdf/cotizacion.pdf', run: { line: 'node scripts/pdf.mjs', exitCode: 0, durationMs: 2000, agent: 'codex' } }, 9),
19969
+ ev('command.output', { name: 'run', ok: false, text: 'ℹ tests 60\n✔ test 1 passed\n✖ test 9 failed\nℹ pass 53\nℹ fail 7', run: { line: 'npm test', exitCode: 1, durationMs: 4200, agent: 'claude' } }, 10),
19970
+ ev('copilot.started', { until: new Date(Date.now() + 3600000).toISOString(), minutes: 60 }, 11),
19971
+ ev('limit.warning', { agent: 'codex', usedPercent: 71.3, projectedPercent: 87.2, level: 'warning', source: 'room-soft-budget', resetAt: null, alternatives: ['claude'], message: 'MADRE warning: @codex is at 71% of its local window.' }, 12),
19972
+ ev('message.failed', { messageId: `lab-f-${Date.now()}`, target: 'madre', error: 'madre is not installed on this computer.' }, 13),
19973
+ ];
19974
+ for (const event of events) renderEvent(event);
19975
+ apply();
19976
+ column.lastElementChild?.scrollIntoView({ block: 'end' });
19977
+ }
19978
+ // After the room has drawn its own conversation, the lab takes the column. Notices fold by
19979
+ // themselves ten seconds later; when they do, the chosen form opens them again.
19980
+ setTimeout(drawConversation, 1600);
19981
+ if (els.column) new MutationObserver(() => { if (picks[14] !== 'A') document.querySelectorAll('#messages .sys-dots:not(.open)').forEach((strip) => strip.classList.add('open')); }).observe(els.column, { childList: true, subtree: true });
19982
+ })();