@jossuealcala/madre 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -3,6 +3,7 @@ import { CONDITIONS, allConditions, detectPlatform, diagnose, fixesFor, PLATFORM
3
3
  import { answerFor, INQUIRIES, STRIKES } from './inquiry.js';
4
4
  import { DEFAULT_LANGUAGE, LANGUAGES, isLanguage, language, pick, setLanguage, t } from './i18n.js';
5
5
  import { resay } from './resay.js';
6
+ import { familyOf, languageLabel, tokenize } from './syntax.js';
6
7
 
7
8
  // Before anything is written on the screen. The constants below are sentences, and a sentence
8
9
  // chosen in the wrong language stays wrong for the life of the page.
@@ -61,6 +62,7 @@ const els = {
61
62
  // server (/api/commands) and run there, read-only, as fact cards.
62
63
  const CLIENT_COMMANDS = [
63
64
  { name: 'create', title: 'CREATE', usage: '/create <what to make>', summary: t('Arm CREATE for this message: the agent may add new files to the project where they belong.'), available: true, client: true },
65
+ { name: 'module', title: 'MODULE', usage: '/module <what it should do>', summary: t('Build one MADRE module with the guided SDK flow: the agent writes it, then you review and install it.'), available: true, client: true },
64
66
  { name: 'image', title: t('Image'), usage: '/image <what to draw>', summary: t('Ask for an image: arms CREATE with the image scope and routes to an agent that can generate images.'), available: true, client: true },
65
67
  { name: 'stopall', title: t('STOP ALL'), usage: '/stopall', summary: t('Master brake: halt every plan and turn in flight. Never reaches an agent.'), available: true, client: true },
66
68
  ];
@@ -122,12 +124,35 @@ const state = {
122
124
  pending: [], // attachments uploaded for the next message
123
125
  projectRoot: '',
124
126
  create: false, // creation lease armed for the next message
127
+ moduleCommand: false, // /module is being composed: CREATE plus the guided SDK contract
125
128
  ashInstalled: false,
126
129
  ash: false, // Ash asked for on this message: compact replies, nothing rewritten
127
130
  chosenModel: {}, // id -> model name picked in the composer
128
131
  };
129
132
  try { state.chosenModel = JSON.parse(localStorage.getItem('pulse.chosenModel') ?? '{}') || {}; } catch { state.chosenModel = {}; }
130
133
 
134
+ // What the human has already dealt with. The ledger is history and is never rewritten, so a
135
+ // dismissal is a reading preference and lives where reading preferences live: this browser.
136
+ // Keyed by what a record IS — time, agent and the first line — so the same entry replayed from
137
+ // the ledger on the next load is still the one that was dismissed.
138
+ const dismissed = new Set();
139
+ try { for (const key of JSON.parse(localStorage.getItem('pulse.mother.dismissed') ?? '[]')) dismissed.add(key); } catch { /* no storage */ }
140
+ const failureKey = (failure) => `${failure.time ?? ''}|${failure.agent ?? ''}|${String(failure.error ?? '').split('\n')[0].slice(0, 120)}`;
141
+ const saveDismissed = () => { try { localStorage.setItem('pulse.mother.dismissed', JSON.stringify([...dismissed].slice(-400))); } catch { /* no storage */ } };
142
+ const openFailures = () => state.failures.filter((failure) => !failure.recovered && !dismissed.has(failureKey(failure)));
143
+
144
+ // Which agent last wrote to a path, taken from what the room already recorded: the artifacts a
145
+ // creation lease produced, and the files a CONTROL turn changed. Nothing new is stored and nothing
146
+ // is inferred from the filesystem — if MADRE did not see it happen, the file carries no mark.
147
+ const touched = new Map();
148
+ function markTouched(agent, files) {
149
+ for (const file of files ?? []) {
150
+ const path = typeof file === 'string' ? file : file?.path;
151
+ if (path && agent) touched.set(String(path).replace(/^\.?\//, ''), agent);
152
+ }
153
+ }
154
+
155
+
131
156
 
132
157
  /* ---------- helpers ---------- */
133
158
 
@@ -286,6 +311,8 @@ const viewer = {
286
311
  const viewerUI = {
287
312
  selection: document.querySelector('#viewer-selection'),
288
313
  review: document.querySelector('#viewer-review'),
314
+ lang: document.querySelector('#viewer-lang'),
315
+ hintSelect: document.querySelector('#viewer-hint-select'),
289
316
  menu: document.querySelector('#viewer-menu'),
290
317
  path: null, // project-relative path of the open text file, if any
291
318
  from: null, to: null,
@@ -301,6 +328,9 @@ function setSelection(from, to) {
301
328
  const has = from !== null;
302
329
  viewerUI.selection.hidden = !has;
303
330
  viewerUI.review.hidden = !has;
331
+ // The button only exists once something is selected, and nobody guesses that a line number is
332
+ // clickable. While there is nothing selected and the file is readable as text, say how.
333
+ if (viewerUI.hintSelect) viewerUI.hintSelect.hidden = has || !viewerUI.lineNodes.length;
304
334
  if (has) viewerUI.selection.textContent = lo === hi ? `L${lo}` : `L${lo}-${hi}`;
305
335
  }
306
336
  function referenceForSelection() {
@@ -312,7 +342,11 @@ function referenceForSelection() {
312
342
  function reviewWith(agentId) {
313
343
  const reference = referenceForSelection();
314
344
  const current = els.input.value.trim();
315
- els.input.value = `${reference} ${current ? current : 'review this: '}`;
345
+ // The reference and the cursor, nothing else. This used to seed "review this: " — an English
346
+ // phrase in a Spanish room, and worse, a sentence MADRE put in the human's mouth: whatever they
347
+ // typed next landed after it and read like somebody else's words. They just chose an agent and
348
+ // a range; they know what they want to ask.
349
+ els.input.value = current ? `${reference} ${current}` : `${reference} `;
316
350
  if (agentId && state.agents.get(agentId)?.ready) { els.target.value = agentId; renderPicker(); }
317
351
  hideViewerMenu();
318
352
  viewer.dialog.close();
@@ -322,17 +356,27 @@ function reviewWith(agentId) {
322
356
  }
323
357
  function showViewerMenu(x, y) {
324
358
  const menuNode = viewerUI.menu;
325
- menuNode.replaceChildren(el('div', 'hint', t('REVIEW {what} WITH', { what: referenceForSelection() })));
359
+ // Two lines: what is being sent, and the question. One line put the verb at one end and its
360
+ // preposition at the other, and in Spanish the sentence ended on a dangling «CON» that the
361
+ // menu then cut off.
362
+ const head = el('div', 'hint');
363
+ head.append(el('b', null, referenceForSelection()));
364
+ head.append(el('span', null, t('review it with')));
365
+ menuNode.replaceChildren(head);
326
366
  for (const agent of state.agents.values()) {
327
367
  const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
328
368
  item.type = 'button';
329
- item.append(el('b', null, `@${agent.id}`), el('span', null, agent.ready ? label(agent.id) : t('{label} · not ready', { label: label(agent.id) })));
369
+ // `@codex Codex` says one thing twice. The handle is the name; beside it goes only what the
370
+ // handle does not already tell you — the vendor, or why it cannot take this.
371
+ const aside = agent.ready ? (brandOf(agent.id).vendor ?? '') : t('no session');
372
+ item.append(el('b', null, `@${agent.id}`), el('span', null, aside));
330
373
  item.disabled = !agent.ready;
331
374
  item.addEventListener('click', () => reviewWith(agent.id));
332
375
  menuNode.append(item);
333
376
  }
334
377
  menuNode.hidden = false;
335
- const width = 240, height = 40 + 36 * state.agents.size;
378
+ // The header is two lines now and the reference can wrap; leave room rather than clip it.
379
+ const width = 260, height = 62 + 36 * state.agents.size;
336
380
  menuNode.style.left = `${Math.min(x, window.innerWidth - width - 12)}px`;
337
381
  menuNode.style.top = `${Math.min(y, window.innerHeight - height - 12)}px`;
338
382
  }
@@ -344,14 +388,26 @@ viewerUI.review?.addEventListener('click', (event) => {
344
388
  viewer.dialog.addEventListener('click', (event) => { if (!viewerUI.menu.hidden && !viewerUI.menu.contains(event.target) && event.target !== viewerUI.review) hideViewerMenu(); });
345
389
  viewer.dialog.addEventListener('close', () => { hideViewerMenu(); setSelection(null, null); ripley.frame = null; ripley.history = []; ripleyClearErrors(); if (ripley.nav) ripley.nav.hidden = true; });
346
390
 
347
- function codeView(text, { line = null, lines = null } = {}) {
391
+ function codeView(text, { line = null, lines = null, name = null } = {}) {
348
392
  const pre = el('pre', 'code');
349
393
  viewerUI.lineNodes = [];
350
394
  const rows = text.replace(/\n$/, '').split('\n');
395
+ // Read by what the file is. The highlighter never rewrites a line — it hands back spans over
396
+ // the same characters — so what is on screen and what is on disk stay the same bytes, and a
397
+ // selection sent to an agent is the file, not a rendering of it.
398
+ const family = familyOf(name ?? '');
399
+ if (viewerUI.lang) {
400
+ const label = languageLabel(name ?? '');
401
+ viewerUI.lang.textContent = label ?? '';
402
+ viewerUI.lang.hidden = !label;
403
+ }
404
+ const carry = { inBlock: false };
351
405
  rows.forEach((content, index) => {
352
406
  const n = index + 1;
353
407
  const ln = el('span', 'ln', String(n));
354
- const tx = el('span', 'tx', content || ' ');
408
+ const tx = el('span', 'tx');
409
+ if (!content) tx.append(' ');
410
+ else for (const piece of tokenize(content, family, carry)) tx.append(piece.kind ? el('span', `t-${piece.kind}`, piece.text) : piece.text);
355
411
  ln.dataset.n = n; tx.dataset.n = n;
356
412
  const marked = lines ? n >= lines.from && n <= lines.to : n === line;
357
413
  if (marked) { ln.classList.add('hl'); tx.classList.add('hl'); }
@@ -381,7 +437,7 @@ function showViewerSource() {
381
437
  if (ripley.nav) ripley.nav.hidden = true;
382
438
  ripleyClearErrors();
383
439
  ripley.frame = null;
384
- const pre = codeView(viewerMode.text, { line: viewerMode.line, lines: viewerMode.lines });
440
+ const pre = codeView(viewerMode.text, { line: viewerMode.line, lines: viewerMode.lines, name: viewerUI.path ?? viewer.path.textContent });
385
441
  viewer.body.replaceChildren(pre);
386
442
  if (!state.ripley && viewerMode.kind) {
387
443
  const hint = el('div', 'viewer-hint');
@@ -761,7 +817,7 @@ function renderPicker() {
761
817
  text.append(t('to '));
762
818
  text.append(el('b', null, `@${current.id}`));
763
819
  if (current.local) {
764
- const note = el('span', 'pick-note', t('memory · answers & asks the crew · never writes'));
820
+ const note = el('span', 'pick-note', t('memory · never writes'));
765
821
  const measured = state.localModel?.checked;
766
822
  note.title = `${t('@madre runs on this machine and speaks for what the room remembers. "@madre, ask the crew …" opens a round with every agent online. To change files, write to a CLI agent.')}${
767
823
  measured ? `\n\n${t('Measured against this room {when}: it landed where the crew 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: MU/TH/UR → the three tests, or the line in the room when it joined.')}`}`;
@@ -773,12 +829,18 @@ function renderPicker() {
773
829
  modeChip.title = t('Permission mode for this message · {hint}', { hint: MODES[state.mode].hint });
774
830
  modeChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModeMenu(current.id, modeChip); });
775
831
  text.append(modeChip);
832
+ // An agent with no models to choose from gets no chooser. @madre answers on whatever chat
833
+ // model Ollama holds, which is decided on Ollama's card and not here, so the chip only opened
834
+ // an empty menu — and it was the piece that pushed this row onto a second line.
776
835
  const chosen = state.chosenModel[current.id];
777
- const modelChip = el('button', 'model-chip', chosen ? `${chosen} ▾` : t('default model ▾'));
778
- modelChip.type = 'button';
779
- modelChip.title = t('Choose the model for this agent');
780
- modelChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModelMenu(current.id, modelChip); });
781
- text.append(modelChip);
836
+ const choices = state.models[current.id]?.models ?? null;
837
+ if (!current.local && (choices === null || choices.length)) {
838
+ const modelChip = el('button', 'model-chip', chosen ? `${chosen} ▾` : t('default model ▾'));
839
+ modelChip.type = 'button';
840
+ modelChip.title = t('Choose the model for this agent');
841
+ modelChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModelMenu(current.id, modelChip); });
842
+ text.append(modelChip);
843
+ }
782
844
  els.picker.append(text);
783
845
  }
784
846
  if (typeof renderCreateScopes === 'function') renderCreateScopes();
@@ -870,7 +932,11 @@ function toggleModeMenu(id, anchor) {
870
932
  const scopes = state.capabilities[id]?.scopes;
871
933
  const canRaise = Boolean(scopes?.write?.capable);
872
934
  const title = el('div', 'model-menu-title');
873
- title.append(el('b', null, `@${id}`), t(' · mode for this message · ceiling #{cap}', { cap }));
935
+ // Short enough to sit on one line at any width the menu takes. That it applies to this message
936
+ // and not to every one after it is the mode chip's own tooltip, and the list below is literally
937
+ // the modes — so the header carries the two facts nothing else here does: who, and how far.
938
+ title.append(el('b', null, `@${id}`), t(' · ceiling #{cap}', { cap }));
939
+ title.title = t('Permission mode for this message · {hint}', { hint: MODES[cap].hint });
874
940
  modeMenu.append(title);
875
941
  const ladder = el('div', 'mode-ladder');
876
942
  for (const n of [0, 1, 2, 3, 4]) {
@@ -1072,19 +1138,20 @@ function bridgeCard(agent) {
1072
1138
  card.id = `bridge-${agent.id}`;
1073
1139
  const session = state.sessions?.[agent.id];
1074
1140
  const signedIn = session?.state === 'signed-in';
1075
- const stage = !agent.detected ? 'missing' : !agent.ready ? 'inert' : signedIn ? 'ready' : 'signed-out';
1141
+ const routeBlocked = Boolean(agent.route?.custom && !agent.route?.allowed);
1142
+ const stage = !agent.detected ? 'missing' : !agent.ready ? 'inert' : routeBlocked ? 'route-blocked' : signedIn ? 'ready' : 'signed-out';
1076
1143
  const head = el('div', 'head');
1077
1144
  head.append(avatar(agent.id, { size: 30, status: stage === 'ready' ? 'ready' : agent.detected ? 'detected' : 'offline' }));
1078
1145
  const title = el('div', 'title');
1079
1146
  title.append(el('b', null, agent.label));
1080
1147
  title.append(el('span', 'vendor', brandOf(agent.id).vendor));
1081
1148
  head.append(title);
1082
- head.append(el('span', `state ${stage}`, stage === 'missing' ? t('NOT INSTALLED') : stage === 'inert' ? t('NO ADAPTER') : stage === 'ready' ? t('READY') : t('SIGNED OUT')));
1149
+ head.append(el('span', `state ${stage}`, stage === 'missing' ? t('NOT INSTALLED') : stage === 'inert' ? t('NO ADAPTER') : stage === 'route-blocked' ? t('ROUTE BLOCKED') : stage === 'ready' ? t('READY') : t('SIGNED OUT')));
1083
1150
  card.append(head);
1084
1151
  const detail = el('div', 'detail');
1085
1152
  if (agent.id === 'madre') detail.append(ollamaDetail(agent));
1086
1153
  else if (stage === 'missing') detail.append(agent.install ? t('Not on this computer · {command}', { command: agent.install.display }) : t('Not on this computer.'));
1087
- else detail.append(`${agent.version ?? t('version unknown')}${session?.detail ? ` · ${session.detail}` : ''}`);
1154
+ else detail.append(`${agent.version ?? t('version unknown')}${session?.detail ? ` · ${session.detail}` : ''}${agent.route?.destination ? ` · ${t('route: {destination}', { destination: agent.route.destination })}` : ''}`);
1088
1155
  card.append(detail);
1089
1156
  // What is behind this door: the account it needs, and whether there is a way in without paying.
1090
1157
  if (agent.account) {
@@ -1434,7 +1501,10 @@ function showReplyMenu(source, x, y) {
1434
1501
  for (const agent of state.agents.values()) {
1435
1502
  const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
1436
1503
  item.type = 'button';
1437
- item.append(el('b', null, `@${agent.id}`), el('span', null, agent.ready ? (agent.id === source.sender ? t('the same agent') : label(agent.id)) : t('{label} · not ready', { label: label(agent.id) })));
1504
+ // The same twice-said name as the viewer's menu: the handle IS the name. Beside it goes only
1505
+ // what the handle does not carry — that this is the one who just spoke, its vendor, or why not.
1506
+ const aside = !agent.ready ? t('no session') : agent.id === source.sender ? t('the same agent') : (brandOf(agent.id).vendor ?? '');
1507
+ item.append(el('b', null, `@${agent.id}`), el('span', null, aside));
1438
1508
  item.disabled = !agent.ready;
1439
1509
  item.addEventListener('click', () => replyWith(agent.id, source));
1440
1510
  replyMenu.append(item);
@@ -1626,6 +1696,11 @@ function renderThinking(event) {
1626
1696
  return node;
1627
1697
  }
1628
1698
 
1699
+ function warnUnknownModuleFields(installed) {
1700
+ const unknown = installed?.unknown ?? [];
1701
+ if (unknown.length) setTimeout(() => toast(t('MU/TH/UR › {name} declares {fields}, which this MADRE does not know. A typo, or a field from a newer version.', { name: installed.name, fields: unknown.join(', ') })), 2600);
1702
+ }
1703
+
1629
1704
  // The archivist reports: which agent read which stretch of the room and how many notes it kept.
1630
1705
  // module · @codex wrote read-mail.module.mjs · INSTALL FOR EVERY ROOM · INSTALL FOR THIS PROJECT · LATER
1631
1706
  function renderModuleProposed(event) {
@@ -1641,6 +1716,7 @@ function renderModuleProposed(event) {
1641
1716
  const payload = await fetch('/api/extensions/install-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path, scope }) }).then((response) => response.json());
1642
1717
  if (payload.error) throw new Error(payload.error);
1643
1718
  toast(t('MU/TH/UR › {name} installed for {where}. Switch it on in MODULES.', { name: payload.installed.name, where: scope === 'project' ? t('this project') : t('every room') }));
1719
+ warnUnknownModuleFields(payload.installed);
1644
1720
  modules.items = payload.extensions ?? modules.items; if (modules.dialog.open) renderModules();
1645
1721
  } catch (error) { toast(t('The module was not installed: {error}', { error: error.message })); button.disabled = false; }
1646
1722
  };
@@ -2110,7 +2186,7 @@ function attachArtifacts(event) {
2110
2186
 
2111
2187
  function artifactTiles(files) {
2112
2188
  const wrap = el('div', 'artifacts');
2113
- wrap.append(el('span', 'label', t('created · {n} files', { n: files.length })));
2189
+ wrap.append(el('span', 'label', t('created · {n} {files}', { n: files.length, files: files.length === 1 ? t('file') : t('files') })));
2114
2190
  const tiles = el('div', 'files');
2115
2191
  for (const file of files) {
2116
2192
  const url = `/api/files?path=${encodeURIComponent(file.path)}`;
@@ -2383,12 +2459,12 @@ function renderEventNode(event) {
2383
2459
  case 'mode.requested': node = renderModeRequest(event); break;
2384
2460
  case 'control.started': node = renderControlStarted(event); break;
2385
2461
  case 'create.reverted': node = renderCreateReverted(event); break;
2386
- case 'control.changed': node = renderControlChanged(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); break;
2462
+ case 'control.changed': markTouched(event.payload.agent, event.payload.files); node = renderControlChanged(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); break;
2387
2463
  case 'control.reverted': node = renderControlReverted(event); break;
2388
2464
  case 'mode.granted':
2389
2465
  case 'mode.denied': settleModeRequest(event); return;
2390
2466
  case 'plan.ignored': node = renderPlanIgnored(event); break;
2391
- case 'artifacts.created': attachArtifacts(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); return;
2467
+ case 'artifacts.created': markTouched(event.payload.agent, event.payload.files); attachArtifacts(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); return;
2392
2468
  case 'command.output': node = renderCommandCard(event); break;
2393
2469
  case 'eyecat.flagged': node = renderEyecat(event); break;
2394
2470
  case 'eyecat.settled': settleEyecat(event); return;
@@ -2552,6 +2628,13 @@ function treeNode(path, entry) {
2552
2628
  item.append(button, children);
2553
2629
  } else {
2554
2630
  button.append(el('span', 'caret', ''), el('span', 'name', entry.name), el('span', 'size', formatSize(entry.size)));
2631
+ const by = touched.get(path);
2632
+ if (by) {
2633
+ // Its colour is the agent's own, so the tree reads the same way the thread does.
2634
+ const dot = paint(el('span', 'touched'), by);
2635
+ dot.title = t('{agent} wrote this file in this room', { agent: `@${by}` });
2636
+ button.append(dot);
2637
+ }
2555
2638
  button.addEventListener('click', () => { void openViewer({ root: 'project', path, label: path }); });
2556
2639
  item.append(button);
2557
2640
  }
@@ -2639,10 +2722,14 @@ async function loadChats() {
2639
2722
  }
2640
2723
 
2641
2724
  async function openChat(id) {
2725
+ if (id === chats.active) { setChats(false); return; }
2642
2726
  const response = await fetch(`/api/chats/${id}`, { method: 'POST' });
2643
2727
  const result = await response.json().catch(() => ({}));
2644
2728
  if (!response.ok) { toast(`MU/TH/UR › ${result.error ?? 'that conversation could not be opened.'}`); return; }
2645
- window.location.reload();
2729
+ const payload = await fetch('/api/state').then((one) => one.json()).catch(() => null);
2730
+ if (!payload) { window.location.reload(); return; }
2731
+ await paintConversation(payload);
2732
+ setChats(false);
2646
2733
  }
2647
2734
 
2648
2735
  function setChats(open) {
@@ -2739,7 +2826,7 @@ document.querySelector('#mother-button')?.addEventListener('mouseleave', endHove
2739
2826
  document.querySelector('#mother-button')?.addEventListener('click', endHover);
2740
2827
 
2741
2828
  // Debug surface for tests and for the curious: window.__pulse.trackHold(true, t)
2742
- globalThis.__pulse = { trackHold, armExpendable, disarmExpendable, beginHover, endHover, state, moduleCard, openCore: (...args) => openCore(...args), get core() { return core; } };
2829
+ globalThis.__pulse = { trackHold, armExpendable, disarmExpendable, beginHover, endHover, state, moduleCard, runSlashCommand, syncModuleComposer, openCore: (...args) => openCore(...args), get core() { return core; } };
2743
2830
 
2744
2831
  els.thread.addEventListener('wheel', (event) => trackHold(event.deltaY > 0), { passive: true });
2745
2832
  let touchY = null;
@@ -2814,10 +2901,59 @@ function setConnection(value) {
2814
2901
  }
2815
2902
  if (typeof ResizeObserver === 'function' && els.composer) new ResizeObserver(placeToast).observe(els.composer);
2816
2903
  window.addEventListener?.('resize', placeToast);
2817
- const stream = new EventSource(`/api/events?since=${state.lastSequence}`);
2818
- stream.onopen = () => { setConnection('live'); replaying = false; };
2819
- stream.onerror = () => setConnection('reconnecting');
2820
- stream.onmessage = ({ data }) => renderEvent(JSON.parse(data));
2904
+ let stream = null;
2905
+ function connectStream() {
2906
+ stream?.close?.();
2907
+ stream = new EventSource(`/api/events?since=${state.lastSequence}`);
2908
+ stream.onopen = () => { setConnection('live'); replaying = false; };
2909
+ stream.onerror = () => setConnection('reconnecting');
2910
+ stream.onmessage = ({ data }) => renderEvent(JSON.parse(data));
2911
+ }
2912
+ connectStream();
2913
+
2914
+ // Everything the thread on screen knows that belongs to ONE conversation. Switching used to
2915
+ // reload the page, which is the brute way to be sure none of this was left over — and it cost a
2916
+ // whole boot: every fetch again, every panel again, the stream again. Repainting in place is
2917
+ // only safe while this list is complete, so it is written out rather than guessed at.
2918
+ function forgetConversation() {
2919
+ state.seen.clear();
2920
+ state.userMessages.clear();
2921
+ state.ratings.clear();
2922
+ state.ratingNodes.clear();
2923
+ state.running.clear();
2924
+ state.plansRunning.clear();
2925
+ state.agentStats.clear();
2926
+ touched.clear();
2927
+ state.failures.length = 0;
2928
+ state.pending.length = 0;
2929
+ state.replyTo = null;
2930
+ state.lastSender = null;
2931
+ state.lastSequence = 0;
2932
+ state.brakeArmed = false;
2933
+ els.column.replaceChildren();
2934
+ renderReplyQuote();
2935
+ paintMotherBadge();
2936
+ updateStopAll();
2937
+ }
2938
+
2939
+ // The new conversation, painted where the old one was. The room, the crew and the archive do not
2940
+ // change between conversations — only the transcript does.
2941
+ async function paintConversation(payload) {
2942
+ replaying = true;
2943
+ forgetConversation();
2944
+ chats.list = payload.chats?.chats ?? chats.list;
2945
+ chats.active = payload.chats?.active ?? chats.active;
2946
+ renderChats();
2947
+ const here = chats.list.find((chat) => chat.id === chats.active);
2948
+ const mark = document.querySelector('.project.chat-here');
2949
+ if (mark) mark.textContent = here && chats.list.length > 1 ? here.title : '';
2950
+ for (const event of payload.events ?? []) renderEvent(event);
2951
+ renderAgents();
2952
+ renderPicker();
2953
+ renderOnboarding();
2954
+ scrollToEnd();
2955
+ connectStream();
2956
+ }
2821
2957
 
2822
2958
  /* ---------- composer ---------- */
2823
2959
 
@@ -2839,12 +2975,52 @@ els.input.addEventListener('input', autosize);
2839
2975
  els.input.addEventListener('scroll', syncHighlightScroll);
2840
2976
  autosize();
2841
2977
 
2978
+ // /module has its own visual language before it ever reaches an agent. The class is also the
2979
+ // animation trigger: it is added once when the prefix becomes a command, and removed when the
2980
+ // human edits away from it or the message leaves the composer.
2981
+ const MODULE_COMMAND = /^\/module(?:\s|$)/i;
2982
+ // A refusal that only flashes is a refusal the room forgets. `/module` needs #2 because writing a
2983
+ // module is writing a file, so the answer lands in the thread where every other decision of the
2984
+ // room lands — and it carries the way out: the button that arms the mode, without arming it.
2985
+ function moduleModeNotice() {
2986
+ const node = el('div', 'system alert module-notice');
2987
+ node.append(el('b', null, t('MU/TH/UR › ')));
2988
+ node.append(t('/module writes a file, so it needs #2 CREATE. Your request is still in the composer.'));
2989
+ const arm = el('button', 'cmd', t('ARM #2 CREATE'));
2990
+ arm.type = 'button';
2991
+ // The human arms it, here as everywhere: the command asked, the click grants.
2992
+ arm.addEventListener('click', () => { setMode(2, { wink: true }); els.input.focus(); });
2993
+ node.append(arm);
2994
+ removeEmpty();
2995
+ els.column.append(node);
2996
+ node.scrollIntoView({ behavior: 'smooth', block: 'end' });
2997
+ }
2998
+
2999
+ function askForModuleMode() {
3000
+ toast(t('MU/TH/UR › /module needs #2 CREATE. Choose #2 in the mode chip, then send again.'));
3001
+ }
3002
+ function syncModuleComposer() {
3003
+ const active = MODULE_COMMAND.test(els.input.value.trimStart());
3004
+ if (state.moduleCommand === active) return;
3005
+ state.moduleCommand = active;
3006
+ els.composer.classList.toggle('module-command', active);
3007
+ updateCrewLabel();
3008
+ updatePlaceholder();
3009
+ if (active && !state.create) askForModuleMode();
3010
+ }
3011
+ els.input.addEventListener('input', syncModuleComposer);
3012
+ syncModuleComposer();
3013
+
2842
3014
  /* ---------- labels inside the field: @agent mentions and /commands ---------- */
2843
3015
 
2844
3016
  function allCommands() { return [...CLIENT_COMMANDS, ...(state.commands ?? [])]; }
2845
3017
  function knownAgentIds() { return [...state.agents.keys()]; }
2846
3018
  function escapeHtml(text) { return text.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char])); }
2847
3019
 
3020
+ // The layer that draws over the field. Its one law: it decorates the characters that are there
3021
+ // and never adds, removes or re-typesets one. The caret belongs to the textarea underneath and is
3022
+ // placed by the textarea's own metrics — so the instant this layer writes a word the human did
3023
+ // not type, or draws it in another font, the two disagree and the caret lands inside a pill.
2848
3024
  function renderHighlight() {
2849
3025
  const text = els.input.value;
2850
3026
  const agents = knownAgentIds();
@@ -2853,7 +3029,11 @@ function renderHighlight() {
2853
3029
  const key = name.toLowerCase();
2854
3030
  if (sigil === '#') {
2855
3031
  if (!/^[0-4]$/.test(name)) return whole;
2856
- return `${lead}<span class="chip mode m${name}">#${name} ${MODES[Number(name)].label}</span>`;
3032
+ // Only what was typed. This used to spell the mode out — `#2` drawn as `#2 CREATE` — and
3033
+ // every character after it sat seven places to the right of its own caret. The name of the
3034
+ // mode is already on the chip above the field; here the colour is enough.
3035
+ const chip = `<span class="chip mode m${name}" title="${escapeHtml(MODES[Number(name)].label)}">#${name}</span>`;
3036
+ return `${lead}${chip}`;
2857
3037
  }
2858
3038
  if (sigil === '!') {
2859
3039
  if (!/\.[A-Za-z0-9]{1,8}(?::\d+(?:-\d+)?)?$/.test(name)) return whole;
@@ -2950,6 +3130,7 @@ function pickMenu(index = menu.index) {
2950
3130
  els.input.setSelectionRange(caret, caret);
2951
3131
  closeMenu();
2952
3132
  autosize();
3133
+ syncModuleComposer();
2953
3134
  els.input.focus();
2954
3135
  }
2955
3136
  els.input.addEventListener('input', () => { menu.index = 0; renderMenu(); });
@@ -2971,8 +3152,17 @@ async function runSlashCommand(text) {
2971
3152
  const name = match[1].toLowerCase();
2972
3153
  const rest = (match[2] ?? '').trim();
2973
3154
  if (name === 'stopall') { await stopAll(); return { handled: true }; }
2974
- if (name === 'create' || name === 'image') {
2975
- if (!rest) { toast(t('MU/TH/UR › /{name} needs a request after it, e.g. "/{name} a poster for the launch".', { name })); return { handled: true }; }
3155
+ if (name === 'create' || name === 'image' || name === 'module') {
3156
+ if (!rest) {
3157
+ toast(name === 'module'
3158
+ ? t('MU/TH/UR › /module needs a job, e.g. "/module summarize today’s commits".')
3159
+ : t('MU/TH/UR › /{name} needs a request after it, e.g. "/{name} a poster for the launch".', { name }));
3160
+ return { handled: true };
3161
+ }
3162
+ if (name === 'module' && !state.create) {
3163
+ moduleModeNotice();
3164
+ return { handled: true, preserve: true };
3165
+ }
2976
3166
  let target = els.target.value;
2977
3167
  if (name === 'image') {
2978
3168
  const capable = [...state.agents.values()].filter((agent) => agent.ready && state.capabilities[agent.id]?.scopes?.imageGen?.enabled).map((agent) => agent.id);
@@ -2985,6 +3175,13 @@ async function runSlashCommand(text) {
2985
3175
  }
2986
3176
  }
2987
3177
  if (!state.create) setMode(2);
3178
+ if (name === 'module') {
3179
+ return {
3180
+ handled: false,
3181
+ target,
3182
+ text: t('Create a MADRE module for this request: {request} Write exactly one <id>.module.mjs file for review. Do not install it; MADRE will show the human an install card.', { request: rest }),
3183
+ };
3184
+ }
2988
3185
  return { handled: false, text: rest, target };
2989
3186
  }
2990
3187
  const known = allCommands().find((item) => item.name === name);
@@ -3082,6 +3279,7 @@ function updateCrewLabel() {
3082
3279
  const order = state.ash && state.ashInstalled;
3083
3280
  els.crewLabel.textContent = state.intruder && state.mode < 3 ? t('INTRUDER ›') : state.mode >= 3 ? `MU/TH/UR · ${MODES[state.mode].label} @${(state.modeArmedFor ?? els.target.value ?? '').toUpperCase()} ›`
3084
3281
  : state.mode === 0 ? t('HUMAN · GHOST ›')
3282
+ : state.moduleCommand ? (state.create ? 'MU/TH/UR · MODULE · CREATE ›' : t('MU/TH/UR · MODULE · NEEDS #2 ›'))
3085
3283
  : order ? (state.create ? 'MU/TH/UR · ASH · CREATE ›' : 'MU/TH/UR · ASH ›')
3086
3284
  : state.create ? t('HUMAN · CREATE ›')
3087
3285
  : state.expendable ? t('CREW · EXPENDABLE ›') : t('HUMAN ›');
@@ -3195,6 +3393,7 @@ els.composer.addEventListener('submit', async (event) => {
3195
3393
  els.input.value = '';
3196
3394
  els.composer.classList.remove('stopall');
3197
3395
  autosize();
3396
+ syncModuleComposer();
3198
3397
  await stopAll();
3199
3398
  return;
3200
3399
  }
@@ -3224,7 +3423,13 @@ els.composer.addEventListener('submit', async (event) => {
3224
3423
  els.input.disabled = true;
3225
3424
  const result = await runSlashCommand(text).catch((error) => { toast(t('The command failed: {error}', { error: error.message })); return { handled: true }; });
3226
3425
  els.input.disabled = false;
3227
- if (result.handled) { els.input.value = ''; autosize(); els.input.focus(); return; }
3426
+ if (result.handled) {
3427
+ if (!result.preserve) els.input.value = '';
3428
+ autosize();
3429
+ syncModuleComposer();
3430
+ els.input.focus();
3431
+ return;
3432
+ }
3228
3433
  outgoing = result.text ?? text;
3229
3434
  target = result.target ?? target;
3230
3435
  }
@@ -3248,6 +3453,7 @@ els.composer.addEventListener('submit', async (event) => {
3248
3453
  renderPendingAttachments();
3249
3454
  resetModeAfterSend(); // one mode per message; standing leases fall back to #2, everyone else to #1
3250
3455
  autosize();
3456
+ syncModuleComposer();
3251
3457
  }
3252
3458
  } catch (error) {
3253
3459
  toast(t('Could not reach MADRE: {error}', { error: error.message }));
@@ -3276,27 +3482,63 @@ const mother = {
3276
3482
  bootTimer: null,
3277
3483
  };
3278
3484
 
3279
- function recordFailure(entry) {
3280
- state.failures.push(entry);
3281
- const count = state.failures.filter((failure) => !failure.recovered).length;
3282
- // Called while the transcript is still being replayed, before the MU/TH/UR
3283
- // block below has initialised, so look the badge up directly.
3485
+ function paintMotherBadge() {
3284
3486
  const badge = document.querySelector('#mother-count');
3285
3487
  if (!badge) return;
3488
+ const count = openFailures().length;
3286
3489
  badge.hidden = count === 0;
3287
3490
  badge.textContent = String(count);
3288
3491
  }
3289
3492
 
3493
+ function recordFailure(entry) {
3494
+ state.failures.push(entry);
3495
+ // Called while the transcript is still being replayed, before the MU/TH/UR
3496
+ // block below has initialised, so look the badge up directly.
3497
+ paintMotherBadge();
3498
+ }
3499
+
3500
+ // The recorded conditions as a file. One row per record, the columns a person would ask for,
3501
+ // and the support code beside each so it can be looked up without this room open.
3502
+ function downloadConditionLog(records) {
3503
+ const cell = (value) => `"${String(value ?? '').replace(/"/g, '""').replace(/\r?\n/g, ' ')}"`;
3504
+ const header = ['when', 'agent', 'code', 'condition', 'error'].map(cell).join(',');
3505
+ const rows = [...records].reverse().map((failure) => {
3506
+ const matches = diagnose(failure.error, failure.agent);
3507
+ return [
3508
+ failure.time ?? '',
3509
+ failure.agent ?? 'room',
3510
+ matches.map((condition) => condition.code ?? condition.id).join(' ') || 'unclassified',
3511
+ matches.map((condition) => condition.id).join(' '),
3512
+ resay(String(failure.error ?? '').trim()),
3513
+ ].map(cell).join(',');
3514
+ });
3515
+ // A BOM, or a spreadsheet opens the accents as mojibake.
3516
+ const blob = new Blob([`\uFEFF${[header, ...rows].join('\n')}\n`], { type: 'text/csv;charset=utf-8' });
3517
+ const url = URL.createObjectURL(blob);
3518
+ const link = el('a');
3519
+ link.href = url;
3520
+ link.download = `madre-conditions-${new Date().toISOString().slice(0, 10)}.csv`;
3521
+ document.body.append(link);
3522
+ link.click();
3523
+ link.remove();
3524
+ setTimeout(() => URL.revokeObjectURL(url), 2000);
3525
+ }
3526
+
3290
3527
  function commandBlock(lines) {
3291
3528
  const block = el('div', 'mother-cmd');
3292
- const copy = el('button', 'copy', t('COPY'));
3529
+ const copy = el('button', 'copy');
3293
3530
  copy.type = 'button';
3531
+ copy.title = t('COPY');
3532
+ copy.setAttribute('aria-label', t('COPY'));
3533
+ // Two sheets, drawn rather than written: a word here sat on top of the command it was offering.
3534
+ copy.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true" width="12" height="12"><rect x="5.5" y="5.5" width="8" height="9" rx="1.5" fill="none" stroke="currentColor"/><path d="M10.5 3.5H3.5a1 1 0 0 0-1 1v7" fill="none" stroke="currentColor"/></svg>';
3294
3535
  const runnable = lines.filter((line) => !line.trim().startsWith('#'));
3295
3536
  copy.addEventListener('click', async () => {
3296
3537
  try {
3297
3538
  await navigator.clipboard.writeText(runnable.join('\n'));
3298
- copy.textContent = t('COPIED');
3299
- setTimeout(() => { copy.textContent = t('COPY'); }, 1500);
3539
+ copy.classList.add('done');
3540
+ copy.title = t('COPIED');
3541
+ setTimeout(() => { copy.classList.remove('done'); copy.title = t('COPY'); }, 1500);
3300
3542
  } catch {
3301
3543
  copy.textContent = t('SELECT');
3302
3544
  }
@@ -3304,19 +3546,47 @@ function commandBlock(lines) {
3304
3546
  block.append(copy);
3305
3547
  const pre = el('pre');
3306
3548
  for (const line of lines) {
3307
- const span = el('span', line.trim().startsWith('#') ? 'c' : null, line);
3549
+ // A command is a command in every language and is never touched. A comment is not a command:
3550
+ // it is prose a person reads, and half of them name buttons — which in this room are called
3551
+ // something else. The catalogue's guard walks these the way it walks a condition's title.
3552
+ const comment = line.trim().startsWith('#');
3553
+ const span = el('span', comment ? 'c' : null, comment ? t(line.trim()) : line);
3308
3554
  pre.append(span, '\n');
3309
3555
  }
3310
3556
  block.append(pre);
3311
3557
  return block;
3312
3558
  }
3313
3559
 
3560
+ // A condition's severity, like a memory's kind and a warning's level: an id in the catalogue and
3561
+ // a word on the screen. Keyed in upper case, which is how the tag reads and which keeps `WARNING`
3562
+ // the severity apart from `warning` the quota level — the same English word, two different rooms.
3563
+ const SEVERITY_WORDS = { blocking: t('BLOCKING'), common: t('COMMON'), degraded: t('DEGRADED'), fixed: t('ALREADY FIXED'), informational: t('INFORMATIONAL'), transient: t('TRANSIENT'), tunable: t('TUNABLE'), warning: t('WARNING') };
3564
+ const severityWord = (severity) => SEVERITY_WORDS[severity] ?? String(severity);
3565
+
3566
+ // Some remedies are a command only because nobody wired the button. Signing an agent in,
3567
+ // installing one, turning a scope on: MADRE already does all three from CONNECTIONS, so the
3568
+ // card opens that panel on the agent in question instead of handing out a line to paste. The
3569
+ // commands stay underneath for whoever prefers them, or is on another machine.
3570
+ function connectionsShortcut(condition, agent) {
3571
+ const who = condition.agent ?? agent ?? null;
3572
+ const jump = el('button', 'mother-goto', who ? t('OPEN ⚙ CONNECTIONS · @{agent}', { agent: who }) : t('OPEN ⚙ CONNECTIONS'));
3573
+ jump.type = 'button';
3574
+ jump.addEventListener('click', () => {
3575
+ rememberFold('connections', true);
3576
+ renderSettings();
3577
+ const target = who ? document.getElementById(`conn-${who}`) : settingsUI.section;
3578
+ setTimeout(() => target?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 60);
3579
+ });
3580
+ return jump;
3581
+ }
3582
+
3314
3583
  function conditionCard(condition, { hit = false, agent = null, hintAgent = null } = {}) {
3315
3584
  const card = el('article', `mother-card${hit ? ' hit' : ''}`);
3316
3585
  card.id = `mother-${condition.id}`;
3317
3586
  const tags = el('div', 'tags');
3318
- tags.append(el('span', `sev-${condition.severity}`, condition.severity));
3587
+ tags.append(el('span', `sev-${condition.severity}`, severityWord(condition.severity)));
3319
3588
  if (condition.agent) tags.append(paint(el('span', 'agent', `@${condition.agent}`), condition.agent));
3589
+ if (condition.code) tags.append(el('span', 'code', condition.code));
3320
3590
  tags.append(el('span', null, condition.id));
3321
3591
  card.append(tags);
3322
3592
  card.append(el('h4', null, condition.title));
@@ -3345,6 +3615,8 @@ function conditionCard(condition, { hit = false, agent = null, hintAgent = null
3345
3615
  card.append(bar);
3346
3616
  }
3347
3617
  const chosen = agent ?? condition.agent ?? null;
3618
+ // The button first, the commands under it: this room can do it, and a terminal is a detour.
3619
+ if (condition.solvedIn === 'connections') card.append(connectionsShortcut(condition, chosen));
3348
3620
  card.append(commandBlock(fixesFor(condition, mother.platform, chosen)));
3349
3621
  if (condition.perAgent && !chosen) {
3350
3622
  for (const id of Object.keys(condition.perAgent)) {
@@ -3374,14 +3646,41 @@ function renderMotherRecorded() {
3374
3646
  let collapsed = false;
3375
3647
  // What this section was set to before it folded like the rest; the fold remembers from here on.
3376
3648
  try { collapsed = localStorage.getItem('pulse.mother.log') === 'collapsed'; } catch { /* no storage */ }
3377
- const body = folding(mother.recorded, t('RECORDED CONDITIONS · THIS ROOM · {n}', { n: state.failures.length }), {
3649
+ const shown = state.failures.filter((failure) => !dismissed.has(failureKey(failure)));
3650
+ const body = folding(mother.recorded, t('RECORDED CONDITIONS · THIS ROOM · {n}', { n: shown.length }), {
3378
3651
  key: 'recorded', open: !collapsed,
3379
3652
  });
3380
- if (!state.failures.length) {
3653
+ // Everything dismissed is still in the ledger; this only stops it from being shown again.
3654
+ if (dismissed.size) {
3655
+ const back = el('button', 'mother-clear', t('SHOW THE {n} DISMISSED', { n: dismissed.size }));
3656
+ back.type = 'button';
3657
+ back.addEventListener('click', () => { dismissed.clear(); saveDismissed(); paintMotherBadge(); renderMotherRecorded(); });
3658
+ body.append(back);
3659
+ }
3660
+ if (shown.length) {
3661
+ // A file to send to whoever is helping. CSV because it opens in anything and each row
3662
+ // carries its support code: the reader does not have to know MADRE to read it.
3663
+ const save = el('button', 'mother-clear', t('DOWNLOAD THE LOG'));
3664
+ save.type = 'button';
3665
+ save.addEventListener('click', () => downloadConditionLog(shown));
3666
+ body.append(save);
3667
+ }
3668
+ if (shown.length > 1) {
3669
+ const all = el('button', 'mother-clear', t('DISMISS ALL {n}', { n: shown.length }));
3670
+ all.type = 'button';
3671
+ all.addEventListener('click', () => {
3672
+ for (const failure of shown) dismissed.add(failureKey(failure));
3673
+ saveDismissed();
3674
+ paintMotherBadge();
3675
+ renderMotherRecorded();
3676
+ });
3677
+ body.append(all);
3678
+ }
3679
+ if (!shown.length) {
3381
3680
  body.append(el('p', 'mother-answer', t('NO CONDITIONS RECORDED. ALL SYSTEMS NOMINAL.')));
3382
3681
  return;
3383
3682
  }
3384
- for (const failure of [...state.failures].reverse().slice(0, 40)) {
3683
+ for (const failure of [...shown].reverse().slice(0, 40)) {
3385
3684
  const rowNode = paint(el('div', 'mother-record'), failure.agent);
3386
3685
  rowNode.append(el('span', 't', formatTime(failure.time)));
3387
3686
  rowNode.append(el('span', 'a', failure.agent ?? t('room')));
@@ -3407,7 +3706,8 @@ function renderMotherRecorded() {
3407
3706
  const links = el('span', 'k');
3408
3707
  if (!matches.length) links.append(el('span', 'none', t('UNCLASSIFIED')));
3409
3708
  for (const condition of matches) {
3410
- const jump = el('button', null, condition.id);
3709
+ const jump = el('button', null, condition.code ?? condition.id);
3710
+ jump.title = condition.id;
3411
3711
  jump.type = 'button';
3412
3712
  jump.addEventListener('click', () => {
3413
3713
  mother.input.value = '';
@@ -3419,6 +3719,17 @@ function renderMotherRecorded() {
3419
3719
  links.append(jump);
3420
3720
  }
3421
3721
  rowNode.append(links);
3722
+ // One record, one way out. The ledger keeps it; this room stops counting it.
3723
+ const drop = el('button', 'drop', '×');
3724
+ drop.type = 'button';
3725
+ drop.title = t('Dismiss this record · it stays in the ledger');
3726
+ drop.addEventListener('click', () => {
3727
+ dismissed.add(failureKey(failure));
3728
+ saveDismissed();
3729
+ paintMotherBadge();
3730
+ renderMotherRecorded();
3731
+ });
3732
+ rowNode.append(drop);
3422
3733
  body.append(rowNode);
3423
3734
  }
3424
3735
  }
@@ -3470,7 +3781,7 @@ function renderMother() {
3470
3781
  function bootMother() {
3471
3782
  clearTimeout(mother.bootTimer);
3472
3783
  const online = [...state.agents.values()].filter((agent) => agent.ready).length;
3473
- const open = state.failures.filter((failure) => !failure.recovered).length;
3784
+ const open = openFailures().length;
3474
3785
  const lines = [
3475
3786
  t('INTERFACE 2037 READY FOR INQUIRY'),
3476
3787
  t('CREW: {n} AGENTS · {ready} READY · ROOM /{project}', { n: state.agents.size, ready: online, project: els.project.textContent }),
@@ -3566,7 +3877,7 @@ function renderEyecat(event) {
3566
3877
  const node = el('div', 'command-card eyecat');
3567
3878
  node.dataset.eyecat = finding.key;
3568
3879
  const head = el('div', 'head');
3569
- head.append(el('b', null, t('◉ EYECAT')), el('span', null, finding.kind === 'unsupported' ? 'a note its own sources do not support' : 'two memories that cannot both be true'));
3880
+ 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')));
3570
3881
  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 })));
3571
3882
  else head.append(el('span', 'args', t('judged by @{judge}', { judge: finding.judge })));
3572
3883
  node.append(head);
@@ -3728,8 +4039,30 @@ function metrics(box, rows) {
3728
4039
  }
3729
4040
 
3730
4041
  // Every card has the same floors in the same order, whatever the module is: what it is, what it
3731
- // does, what it touches, how it is doing — and then, last and alone, the switch. The eye learns
3732
- // the shape once and finds the button in the same place on all of them.
4042
+ // does, what it touches, how it is doing — and then its actions, with the main switch last. A DEV
4043
+ // module also offers uninstall there; a module MADRE ships never does.
4044
+ function removeModuleButton(item, { className = 'remove-module', text = t('UNINSTALL') } = {}) {
4045
+ const remove = el('button', className, text);
4046
+ remove.type = 'button';
4047
+ remove.title = t('Delete module file {file}', { file: item.file });
4048
+ remove.addEventListener('click', async () => {
4049
+ if (!window.confirm(t("Remove {name}? Its file {file} is deleted. MADRE's own modules cannot be removed.", { name: item.name, file: item.file }))) return;
4050
+ remove.disabled = true;
4051
+ try {
4052
+ const response = await fetch(`/api/extensions/${item.id}`, { method: 'DELETE' });
4053
+ const payload = await response.json().catch(() => ({}));
4054
+ if (!response.ok) throw new Error(payload.error ?? `HTTP ${response.status}`);
4055
+ modules.items = payload.extensions ?? modules.items;
4056
+ toast(t('MU/TH/UR › {name} removed.', { name: item.name }));
4057
+ renderModules();
4058
+ } catch (error) {
4059
+ toast(t('It was not removed: {error}', { error: error.message }));
4060
+ remove.disabled = false;
4061
+ }
4062
+ });
4063
+ return remove;
4064
+ }
4065
+
3733
4066
  function cardShell(item, { on, state: stateText }) {
3734
4067
  const card = el('article', `module-card${on ? '' : ' off'}`);
3735
4068
  card.id = `module-${item.id}`;
@@ -3817,9 +4150,10 @@ function cardShell(item, { on, state: stateText }) {
3817
4150
  }
3818
4151
 
3819
4152
  // FLOOR 4 · the module's own: what it reads, what it lets you set.
3820
- // FLOOR 5 · and the switch, alone, at the bottom of every card.
4153
+ // FLOOR 5 · actions at the bottom: DEV uninstall first, the main switch last.
3821
4154
  const panel = el('div', 'card-panel');
3822
4155
  const actions = el('div', 'actions');
4156
+ if (item.external) actions.append(removeModuleButton(item));
3823
4157
  card.append(panel, actions);
3824
4158
  return { card, panel, actions };
3825
4159
  }
@@ -3896,6 +4230,10 @@ modules.file?.addEventListener('change', async () => {
3896
4230
  modules.items = result.extensions ?? modules.items;
3897
4231
  renderModules();
3898
4232
  toast(`MU/TH/UR › ${t('{name} installed', { name: result.installed.name })}${result.installed.version ? ` · v${result.installed.version}` : ''}. ${t('It loads in every room on this computer.')}`);
4233
+ // A key the contract does not answer to. Not fatal — a module written for a newer MADRE may
4234
+ // carry fields this one has not learned — but a misspelling is far likelier, and it used to
4235
+ // install in silence and leave the author wondering why their card came out empty.
4236
+ warnUnknownModuleFields(result.installed);
3899
4237
  } catch (error) { toast(t('The module was not installed: {error}', { error: error.message })); }
3900
4238
  finally { modules.add.disabled = false; }
3901
4239
  });
@@ -4287,17 +4625,7 @@ function renderModulesDev() {
4287
4625
  for (const item of yours) {
4288
4626
  const chip = el('span', 'dev-chip');
4289
4627
  chip.append(el('b', null, item.name), ` · ${item.origin === 'project' ? 'this project' : 'every room'} `);
4290
- const remove = el('button', 'act-link', t('REMOVE')); remove.type = 'button'; remove.title = `Delete ${item.file}`;
4291
- remove.addEventListener('click', async () => {
4292
- if (!window.confirm(t("Remove {name}? Its file {file} is deleted. MADRE's own modules cannot be removed.", { name: item.name, file: item.file }))) return;
4293
- remove.disabled = true;
4294
- try {
4295
- const payload = await fetch(`/api/extensions/${item.id}`, { method: 'DELETE' }).then((response) => response.json());
4296
- if (payload.error) throw new Error(payload.error);
4297
- modules.items = payload.extensions ?? modules.items; toast(t('MU/TH/UR › {name} removed.', { name: item.name })); renderModules();
4298
- } catch (error) { toast(t('It was not removed: {error}', { error: error.message })); remove.disabled = false; }
4299
- });
4300
- chip.append(remove);
4628
+ chip.append(removeModuleButton(item, { className: 'act-link', text: t('REMOVE') }));
4301
4629
  list.append(chip);
4302
4630
  }
4303
4631
  body.append(list);
@@ -5203,6 +5531,7 @@ function folding(section, title, { key, open = false, badge = null } = {}) {
5203
5531
 
5204
5532
  function connectionCard(agent) {
5205
5533
  const card = paint(el('article', 'conn-card'), agent.id);
5534
+ card.id = `conn-${agent.id}`;
5206
5535
  const session = settingsUI.data.sessions?.[agent.id];
5207
5536
  const head = el('div', 'head');
5208
5537
  head.append(avatar(agent.id, { size: 26, status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline' }));
@@ -5916,6 +6245,10 @@ async function openNostromo() {
5916
6245
  if (!nostromo.dialog) return;
5917
6246
  mother.dialog?.close?.();
5918
6247
  nostromo.dialog.showModal();
6248
+ // A dialog hands focus to its first focusable child, and with COLD, ASK and RECENTER hidden
6249
+ // that was LEAVE: you pressed Enter to come in and the next Enter threw you out. The map is
6250
+ // what you came for, so the map takes it.
6251
+ nostromo.canvas?.focus?.({ preventScroll: true });
5919
6252
  paintLegend();
5920
6253
  nostromo.sub.textContent = t('MEMORY RESEARCH · LOADING…');
5921
6254
  nostromo.card.hidden = true;
@@ -7595,12 +7928,13 @@ void loadVersion();
7595
7928
 
7596
7929
  /* ---------- MU/TH/UR: the sentinel. Unknown conditions and crashes, redacted, ready to report. ---------- */
7597
7930
 
7598
- const sentinelUI = { section: document.querySelector('#mother-sentinel'), settings: null, feedbackUrl: null, loaded: false };
7931
+ const sentinelUI = { section: document.querySelector('#mother-sentinel'), settings: null, feedbackUrl: null, formUrl: null, loaded: false };
7599
7932
  async function loadSentinel() {
7600
7933
  try {
7601
7934
  const data = await fetch('/api/sentinel').then((response) => response.json());
7602
7935
  sentinelUI.settings = data.settings;
7603
7936
  sentinelUI.feedbackUrl = data.feedbackUrl;
7937
+ sentinelUI.formUrl = data.formUrl;
7604
7938
  for (const report of data.reports ?? []) state.reports.set(report.id, report);
7605
7939
  sentinelUI.loaded = true;
7606
7940
  } catch { /* the room works without it */ }
@@ -7630,10 +7964,17 @@ function renderMotherSentinel() {
7630
7964
  });
7631
7965
  auto.append(box, t('AUTO-REPORT UNKNOWN CONDITIONS') + (settings.canSend ? '' : t(' · NO COLLECTOR CONFIGURED (PULSE_REPORT_URL)')));
7632
7966
  controls.append(auto);
7633
- const feedback = el('button', null, t('✎ FEEDBACK TO THE AUTHOR'));
7967
+ // Whoever arrived from a post in Spanish will not make a GitHub account to say "it does not
7968
+ // detect Codex". The form leads here; the issue stays one click away, and the other way round
7969
+ // in English, where the person reading this is already holding an account.
7970
+ const spanish = language() === 'es';
7971
+ const feedback = el('button', spanish ? null : 'primary', t('✎ FEEDBACK TO THE AUTHOR'));
7634
7972
  feedback.type = 'button';
7635
7973
  feedback.addEventListener('click', () => openFeedback());
7636
- controls.append(feedback);
7974
+ const viaForm = el('button', spanish ? 'primary' : null, t('✎ REPORT ON MADRE.RUN ↗'));
7975
+ viaForm.type = 'button';
7976
+ viaForm.addEventListener('click', () => { if (sentinelUI.formUrl) window.open(sentinelUI.formUrl, '_blank', 'noopener'); });
7977
+ controls.append(spanish ? viaForm : feedback, spanish ? feedback : viaForm);
7637
7978
  sentinelBody.append(controls);
7638
7979
  for (const report of reports.slice(0, 20)) {
7639
7980
  const rowNode = paint(el('div', `mother-record sentinel-report${report.sent?.ok ? ' sent' : ''}`), report.agent ?? 'room');
@@ -7646,6 +7987,8 @@ function renderMotherSentinel() {
7646
7987
  if (report.count > 1) actions.append(el('span', 'none', `×${report.count}`));
7647
7988
  actions.append(el('span', 'none', report.fingerprint));
7648
7989
  if (report.sent?.ok) actions.append(el('span', 'none', t('SENT')));
7990
+ // A crash with a trace belongs in issues, where it deduplicates and the answer helps the next
7991
+ // one. The form is beside it for whoever has no account and would otherwise report nothing.
7649
7992
  const issue = el('button', null, t('REPORT ON GITHUB ↗'));
7650
7993
  issue.type = 'button';
7651
7994
  issue.addEventListener('click', async () => {
@@ -7653,6 +7996,13 @@ function renderMotherSentinel() {
7653
7996
  if (result.url) window.open(result.url, '_blank', 'noopener'); else toast(t('MU/TH/UR › no repository to file this in.'));
7654
7997
  });
7655
7998
  actions.append(issue);
7999
+ const form = el('button', null, t('OR ON MADRE.RUN ↗'));
8000
+ form.type = 'button';
8001
+ form.addEventListener('click', async () => {
8002
+ const result = await fetch(`/api/sentinel/${report.id}/issue`).then((response) => response.json()).catch(() => ({}));
8003
+ if (result.form) window.open(result.form, '_blank', 'noopener');
8004
+ });
8005
+ actions.append(form);
7656
8006
  if (settings.canSend && !report.sent?.ok) {
7657
8007
  const send = el('button', null, t('SEND REPORT'));
7658
8008
  send.type = 'button';