@jossuealcala/madre 0.4.1 → 0.5.1

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,6 +124,7 @@ 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
@@ -138,6 +141,22 @@ const failureKey = (failure) => `${failure.time ?? ''}|${failure.agent ?? ''}|${
138
141
  const saveDismissed = () => { try { localStorage.setItem('pulse.mother.dismissed', JSON.stringify([...dismissed].slice(-400))); } catch { /* no storage */ } };
139
142
  const openFailures = () => state.failures.filter((failure) => !failure.recovered && !dismissed.has(failureKey(failure)));
140
143
 
144
+ // Every other vendor is a company and a company is called the same everywhere. @madre's is a
145
+ // sentence, so it is said in the room's language — declared, not computed, so both guards see it.
146
+ const VENDOR_WORDS = { 'the room · local': t('the room · local'), local: t('local') };
147
+ const vendorWord = (id) => { const said = brandOf(id).vendor ?? ''; return VENDOR_WORDS[said] ?? said; };
148
+
149
+ // Which agent last wrote to a path, taken from what the room already recorded: the artifacts a
150
+ // creation lease produced, and the files a CONTROL turn changed. Nothing new is stored and nothing
151
+ // is inferred from the filesystem — if MADRE did not see it happen, the file carries no mark.
152
+ const touched = new Map();
153
+ function markTouched(agent, files) {
154
+ for (const file of files ?? []) {
155
+ const path = typeof file === 'string' ? file : file?.path;
156
+ if (path && agent) touched.set(String(path).replace(/^\.?\//, ''), agent);
157
+ }
158
+ }
159
+
141
160
 
142
161
 
143
162
  /* ---------- helpers ---------- */
@@ -297,6 +316,8 @@ const viewer = {
297
316
  const viewerUI = {
298
317
  selection: document.querySelector('#viewer-selection'),
299
318
  review: document.querySelector('#viewer-review'),
319
+ lang: document.querySelector('#viewer-lang'),
320
+ hintSelect: document.querySelector('#viewer-hint-select'),
300
321
  menu: document.querySelector('#viewer-menu'),
301
322
  path: null, // project-relative path of the open text file, if any
302
323
  from: null, to: null,
@@ -312,6 +333,9 @@ function setSelection(from, to) {
312
333
  const has = from !== null;
313
334
  viewerUI.selection.hidden = !has;
314
335
  viewerUI.review.hidden = !has;
336
+ // The button only exists once something is selected, and nobody guesses that a line number is
337
+ // clickable. While there is nothing selected and the file is readable as text, say how.
338
+ if (viewerUI.hintSelect) viewerUI.hintSelect.hidden = has || !viewerUI.lineNodes.length;
315
339
  if (has) viewerUI.selection.textContent = lo === hi ? `L${lo}` : `L${lo}-${hi}`;
316
340
  }
317
341
  function referenceForSelection() {
@@ -323,7 +347,11 @@ function referenceForSelection() {
323
347
  function reviewWith(agentId) {
324
348
  const reference = referenceForSelection();
325
349
  const current = els.input.value.trim();
326
- els.input.value = `${reference} ${current ? current : 'review this: '}`;
350
+ // The reference and the cursor, nothing else. This used to seed "review this: " — an English
351
+ // phrase in a Spanish room, and worse, a sentence MADRE put in the human's mouth: whatever they
352
+ // typed next landed after it and read like somebody else's words. They just chose an agent and
353
+ // a range; they know what they want to ask.
354
+ els.input.value = current ? `${reference} ${current}` : `${reference} `;
327
355
  if (agentId && state.agents.get(agentId)?.ready) { els.target.value = agentId; renderPicker(); }
328
356
  hideViewerMenu();
329
357
  viewer.dialog.close();
@@ -333,17 +361,27 @@ function reviewWith(agentId) {
333
361
  }
334
362
  function showViewerMenu(x, y) {
335
363
  const menuNode = viewerUI.menu;
336
- menuNode.replaceChildren(el('div', 'hint', t('REVIEW {what} WITH', { what: referenceForSelection() })));
364
+ // Two lines: what is being sent, and the question. One line put the verb at one end and its
365
+ // preposition at the other, and in Spanish the sentence ended on a dangling «CON» that the
366
+ // menu then cut off.
367
+ const head = el('div', 'hint');
368
+ head.append(el('b', null, referenceForSelection()));
369
+ head.append(el('span', null, t('review it with')));
370
+ menuNode.replaceChildren(head);
337
371
  for (const agent of state.agents.values()) {
338
372
  const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
339
373
  item.type = 'button';
340
- item.append(el('b', null, `@${agent.id}`), el('span', null, agent.ready ? label(agent.id) : t('{label} · not ready', { label: label(agent.id) })));
374
+ // `@codex Codex` says one thing twice. The handle is the name; beside it goes only what the
375
+ // handle does not already tell you — the vendor, or why it cannot take this.
376
+ const aside = agent.ready ? vendorWord(agent.id) : t('no session');
377
+ item.append(el('b', null, `@${agent.id}`), el('span', null, aside));
341
378
  item.disabled = !agent.ready;
342
379
  item.addEventListener('click', () => reviewWith(agent.id));
343
380
  menuNode.append(item);
344
381
  }
345
382
  menuNode.hidden = false;
346
- const width = 240, height = 40 + 36 * state.agents.size;
383
+ // The header is two lines now and the reference can wrap; leave room rather than clip it.
384
+ const width = 260, height = 62 + 36 * state.agents.size;
347
385
  menuNode.style.left = `${Math.min(x, window.innerWidth - width - 12)}px`;
348
386
  menuNode.style.top = `${Math.min(y, window.innerHeight - height - 12)}px`;
349
387
  }
@@ -355,14 +393,26 @@ viewerUI.review?.addEventListener('click', (event) => {
355
393
  viewer.dialog.addEventListener('click', (event) => { if (!viewerUI.menu.hidden && !viewerUI.menu.contains(event.target) && event.target !== viewerUI.review) hideViewerMenu(); });
356
394
  viewer.dialog.addEventListener('close', () => { hideViewerMenu(); setSelection(null, null); ripley.frame = null; ripley.history = []; ripleyClearErrors(); if (ripley.nav) ripley.nav.hidden = true; });
357
395
 
358
- function codeView(text, { line = null, lines = null } = {}) {
396
+ function codeView(text, { line = null, lines = null, name = null } = {}) {
359
397
  const pre = el('pre', 'code');
360
398
  viewerUI.lineNodes = [];
361
399
  const rows = text.replace(/\n$/, '').split('\n');
400
+ // Read by what the file is. The highlighter never rewrites a line — it hands back spans over
401
+ // the same characters — so what is on screen and what is on disk stay the same bytes, and a
402
+ // selection sent to an agent is the file, not a rendering of it.
403
+ const family = familyOf(name ?? '');
404
+ if (viewerUI.lang) {
405
+ const label = languageLabel(name ?? '');
406
+ viewerUI.lang.textContent = label ?? '';
407
+ viewerUI.lang.hidden = !label;
408
+ }
409
+ const carry = { inBlock: false };
362
410
  rows.forEach((content, index) => {
363
411
  const n = index + 1;
364
412
  const ln = el('span', 'ln', String(n));
365
- const tx = el('span', 'tx', content || ' ');
413
+ const tx = el('span', 'tx');
414
+ if (!content) tx.append(' ');
415
+ else for (const piece of tokenize(content, family, carry)) tx.append(piece.kind ? el('span', `t-${piece.kind}`, piece.text) : piece.text);
366
416
  ln.dataset.n = n; tx.dataset.n = n;
367
417
  const marked = lines ? n >= lines.from && n <= lines.to : n === line;
368
418
  if (marked) { ln.classList.add('hl'); tx.classList.add('hl'); }
@@ -392,7 +442,7 @@ function showViewerSource() {
392
442
  if (ripley.nav) ripley.nav.hidden = true;
393
443
  ripleyClearErrors();
394
444
  ripley.frame = null;
395
- const pre = codeView(viewerMode.text, { line: viewerMode.line, lines: viewerMode.lines });
445
+ const pre = codeView(viewerMode.text, { line: viewerMode.line, lines: viewerMode.lines, name: viewerUI.path ?? viewer.path.textContent });
396
446
  viewer.body.replaceChildren(pre);
397
447
  if (!state.ripley && viewerMode.kind) {
398
448
  const hint = el('div', 'viewer-hint');
@@ -729,7 +779,7 @@ function renderAgents() {
729
779
  status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline',
730
780
  });
731
781
  node.title = [
732
- `${agent.label}${brandOf(agent.id).vendor ? ` · ${brandOf(agent.id).vendor}` : ''}`,
782
+ `${agent.label}${vendorWord(agent.id) ? ` · ${vendorWord(agent.id)}` : ''}`,
733
783
  agent.ready ? t('Ready') : agent.detected ? t('Detected, adapter pending') : t('Not installed'),
734
784
  agent.version ? agent.version : null,
735
785
  official === null
@@ -772,7 +822,7 @@ function renderPicker() {
772
822
  text.append(t('to '));
773
823
  text.append(el('b', null, `@${current.id}`));
774
824
  if (current.local) {
775
- const note = el('span', 'pick-note', t('memory · answers & asks the crew · never writes'));
825
+ const note = el('span', 'pick-note', t('memory · never writes'));
776
826
  const measured = state.localModel?.checked;
777
827
  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.')}${
778
828
  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.')}`}`;
@@ -784,12 +834,18 @@ function renderPicker() {
784
834
  modeChip.title = t('Permission mode for this message · {hint}', { hint: MODES[state.mode].hint });
785
835
  modeChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModeMenu(current.id, modeChip); });
786
836
  text.append(modeChip);
837
+ // An agent with no models to choose from gets no chooser. @madre answers on whatever chat
838
+ // model Ollama holds, which is decided on Ollama's card and not here, so the chip only opened
839
+ // an empty menu — and it was the piece that pushed this row onto a second line.
787
840
  const chosen = state.chosenModel[current.id];
788
- const modelChip = el('button', 'model-chip', chosen ? `${chosen} ▾` : t('default model ▾'));
789
- modelChip.type = 'button';
790
- modelChip.title = t('Choose the model for this agent');
791
- modelChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModelMenu(current.id, modelChip); });
792
- text.append(modelChip);
841
+ const choices = state.models[current.id]?.models ?? null;
842
+ if (!current.local && (choices === null || choices.length)) {
843
+ const modelChip = el('button', 'model-chip', chosen ? `${chosen} ▾` : t('default model ▾'));
844
+ modelChip.type = 'button';
845
+ modelChip.title = t('Choose the model for this agent');
846
+ modelChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModelMenu(current.id, modelChip); });
847
+ text.append(modelChip);
848
+ }
793
849
  els.picker.append(text);
794
850
  }
795
851
  if (typeof renderCreateScopes === 'function') renderCreateScopes();
@@ -881,7 +937,11 @@ function toggleModeMenu(id, anchor) {
881
937
  const scopes = state.capabilities[id]?.scopes;
882
938
  const canRaise = Boolean(scopes?.write?.capable);
883
939
  const title = el('div', 'model-menu-title');
884
- title.append(el('b', null, `@${id}`), t(' · mode for this message · ceiling #{cap}', { cap }));
940
+ // Short enough to sit on one line at any width the menu takes. That it applies to this message
941
+ // and not to every one after it is the mode chip's own tooltip, and the list below is literally
942
+ // the modes — so the header carries the two facts nothing else here does: who, and how far.
943
+ title.append(el('b', null, `@${id}`), t(' · ceiling #{cap}', { cap }));
944
+ title.title = t('Permission mode for this message · {hint}', { hint: MODES[cap].hint });
885
945
  modeMenu.append(title);
886
946
  const ladder = el('div', 'mode-ladder');
887
947
  for (const n of [0, 1, 2, 3, 4]) {
@@ -1083,19 +1143,20 @@ function bridgeCard(agent) {
1083
1143
  card.id = `bridge-${agent.id}`;
1084
1144
  const session = state.sessions?.[agent.id];
1085
1145
  const signedIn = session?.state === 'signed-in';
1086
- const stage = !agent.detected ? 'missing' : !agent.ready ? 'inert' : signedIn ? 'ready' : 'signed-out';
1146
+ const routeBlocked = Boolean(agent.route?.custom && !agent.route?.allowed);
1147
+ const stage = !agent.detected ? 'missing' : !agent.ready ? 'inert' : routeBlocked ? 'route-blocked' : signedIn ? 'ready' : 'signed-out';
1087
1148
  const head = el('div', 'head');
1088
1149
  head.append(avatar(agent.id, { size: 30, status: stage === 'ready' ? 'ready' : agent.detected ? 'detected' : 'offline' }));
1089
1150
  const title = el('div', 'title');
1090
1151
  title.append(el('b', null, agent.label));
1091
- title.append(el('span', 'vendor', brandOf(agent.id).vendor));
1152
+ title.append(el('span', 'vendor', vendorWord(agent.id)));
1092
1153
  head.append(title);
1093
- head.append(el('span', `state ${stage}`, stage === 'missing' ? t('NOT INSTALLED') : stage === 'inert' ? t('NO ADAPTER') : stage === 'ready' ? t('READY') : t('SIGNED OUT')));
1154
+ 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')));
1094
1155
  card.append(head);
1095
1156
  const detail = el('div', 'detail');
1096
1157
  if (agent.id === 'madre') detail.append(ollamaDetail(agent));
1097
1158
  else if (stage === 'missing') detail.append(agent.install ? t('Not on this computer · {command}', { command: agent.install.display }) : t('Not on this computer.'));
1098
- else detail.append(`${agent.version ?? t('version unknown')}${session?.detail ? ` · ${session.detail}` : ''}`);
1159
+ else detail.append(`${agent.version ?? t('version unknown')}${session?.detail ? ` · ${session.detail}` : ''}${agent.route?.destination ? ` · ${t('route: {destination}', { destination: agent.route.destination })}` : ''}`);
1099
1160
  card.append(detail);
1100
1161
  // What is behind this door: the account it needs, and whether there is a way in without paying.
1101
1162
  if (agent.account) {
@@ -1445,7 +1506,10 @@ function showReplyMenu(source, x, y) {
1445
1506
  for (const agent of state.agents.values()) {
1446
1507
  const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
1447
1508
  item.type = 'button';
1448
- 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) })));
1509
+ // The same twice-said name as the viewer's menu: the handle IS the name. Beside it goes only
1510
+ // what the handle does not carry — that this is the one who just spoke, its vendor, or why not.
1511
+ const aside = !agent.ready ? t('no session') : agent.id === source.sender ? t('the same agent') : vendorWord(agent.id);
1512
+ item.append(el('b', null, `@${agent.id}`), el('span', null, aside));
1449
1513
  item.disabled = !agent.ready;
1450
1514
  item.addEventListener('click', () => replyWith(agent.id, source));
1451
1515
  replyMenu.append(item);
@@ -1637,6 +1701,11 @@ function renderThinking(event) {
1637
1701
  return node;
1638
1702
  }
1639
1703
 
1704
+ function warnUnknownModuleFields(installed) {
1705
+ const unknown = installed?.unknown ?? [];
1706
+ 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);
1707
+ }
1708
+
1640
1709
  // The archivist reports: which agent read which stretch of the room and how many notes it kept.
1641
1710
  // module · @codex wrote read-mail.module.mjs · INSTALL FOR EVERY ROOM · INSTALL FOR THIS PROJECT · LATER
1642
1711
  function renderModuleProposed(event) {
@@ -1652,6 +1721,7 @@ function renderModuleProposed(event) {
1652
1721
  const payload = await fetch('/api/extensions/install-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path, scope }) }).then((response) => response.json());
1653
1722
  if (payload.error) throw new Error(payload.error);
1654
1723
  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') }));
1724
+ warnUnknownModuleFields(payload.installed);
1655
1725
  modules.items = payload.extensions ?? modules.items; if (modules.dialog.open) renderModules();
1656
1726
  } catch (error) { toast(t('The module was not installed: {error}', { error: error.message })); button.disabled = false; }
1657
1727
  };
@@ -2091,7 +2161,10 @@ function renderLease(event) {
2091
2161
  node.style.setProperty('--agent', agentColor(agent));
2092
2162
  const { delegated = false, scratchDir = null, grantedBy = null } = event.payload;
2093
2163
  node.append(el('b', null, standing ? t('default #2 · ') : escalated ? `${t('#2 granted on request')}${escalated === 'plan' ? t(' · whole plan') : ''} · ` : delegated ? t('#2 by @{who} · ', { who: grantedBy }) : t('create · ')));
2094
- node.append(`@${agent} may ${scopes.map((scope) => CAP_LABELS[scope] ?? scope).join(', ') || 'create files'}${unavailable.length ? ` (cannot ${unavailable.join(', ')})` : ''} `);
2164
+ // The verbs were translated and the sentence holding them was not: «@CODEX MAY CREA, GENERA
2165
+ // IMÁGENES». A sentence is translated whole or it is not translated.
2166
+ const can = scopes.map((scope) => CAP_LABELS[scope] ?? scope).join(', ');
2167
+ node.append(`${t('@{agent} may {what}', { agent, what: can || t('create files') })}${unavailable.length ? t(' (cannot {what})', { what: unavailable.map((scope) => CAP_LABELS[scope] ?? scope).join(', ') }) : ''} `);
2095
2168
  if (outDir === '.' || !outDir) {
2096
2169
  node.append(t('anywhere in the project · existing files stay untouched'));
2097
2170
  if (scratchDir) { node.append(t(' · scratch ')); const link = el('a', 'file-link', scratchDir); link.href = '#'; link.addEventListener('click', (ev) => { ev.preventDefault(); }); node.append(link); }
@@ -2394,12 +2467,12 @@ function renderEventNode(event) {
2394
2467
  case 'mode.requested': node = renderModeRequest(event); break;
2395
2468
  case 'control.started': node = renderControlStarted(event); break;
2396
2469
  case 'create.reverted': node = renderCreateReverted(event); break;
2397
- case 'control.changed': node = renderControlChanged(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); break;
2470
+ case 'control.changed': markTouched(event.payload.agent, event.payload.files); node = renderControlChanged(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); break;
2398
2471
  case 'control.reverted': node = renderControlReverted(event); break;
2399
2472
  case 'mode.granted':
2400
2473
  case 'mode.denied': settleModeRequest(event); return;
2401
2474
  case 'plan.ignored': node = renderPlanIgnored(event); break;
2402
- case 'artifacts.created': attachArtifacts(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); return;
2475
+ case 'artifacts.created': markTouched(event.payload.agent, event.payload.files); attachArtifacts(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); return;
2403
2476
  case 'command.output': node = renderCommandCard(event); break;
2404
2477
  case 'eyecat.flagged': node = renderEyecat(event); break;
2405
2478
  case 'eyecat.settled': settleEyecat(event); return;
@@ -2563,6 +2636,13 @@ function treeNode(path, entry) {
2563
2636
  item.append(button, children);
2564
2637
  } else {
2565
2638
  button.append(el('span', 'caret', ''), el('span', 'name', entry.name), el('span', 'size', formatSize(entry.size)));
2639
+ const by = touched.get(path);
2640
+ if (by) {
2641
+ // Its colour is the agent's own, so the tree reads the same way the thread does.
2642
+ const dot = paint(el('span', 'touched'), by);
2643
+ dot.title = t('{agent} wrote this file in this room', { agent: `@${by}` });
2644
+ button.append(dot);
2645
+ }
2566
2646
  button.addEventListener('click', () => { void openViewer({ root: 'project', path, label: path }); });
2567
2647
  item.append(button);
2568
2648
  }
@@ -2650,10 +2730,14 @@ async function loadChats() {
2650
2730
  }
2651
2731
 
2652
2732
  async function openChat(id) {
2733
+ if (id === chats.active) { setChats(false); return; }
2653
2734
  const response = await fetch(`/api/chats/${id}`, { method: 'POST' });
2654
2735
  const result = await response.json().catch(() => ({}));
2655
2736
  if (!response.ok) { toast(`MU/TH/UR › ${result.error ?? 'that conversation could not be opened.'}`); return; }
2656
- window.location.reload();
2737
+ const payload = await fetch('/api/state').then((one) => one.json()).catch(() => null);
2738
+ if (!payload) { window.location.reload(); return; }
2739
+ await paintConversation(payload);
2740
+ setChats(false);
2657
2741
  }
2658
2742
 
2659
2743
  function setChats(open) {
@@ -2750,7 +2834,7 @@ document.querySelector('#mother-button')?.addEventListener('mouseleave', endHove
2750
2834
  document.querySelector('#mother-button')?.addEventListener('click', endHover);
2751
2835
 
2752
2836
  // Debug surface for tests and for the curious: window.__pulse.trackHold(true, t)
2753
- globalThis.__pulse = { trackHold, armExpendable, disarmExpendable, beginHover, endHover, state, moduleCard, openCore: (...args) => openCore(...args), get core() { return core; } };
2837
+ globalThis.__pulse = { trackHold, armExpendable, disarmExpendable, beginHover, endHover, state, moduleCard, runSlashCommand, syncModuleComposer, openCore: (...args) => openCore(...args), get core() { return core; } };
2754
2838
 
2755
2839
  els.thread.addEventListener('wheel', (event) => trackHold(event.deltaY > 0), { passive: true });
2756
2840
  let touchY = null;
@@ -2825,10 +2909,59 @@ function setConnection(value) {
2825
2909
  }
2826
2910
  if (typeof ResizeObserver === 'function' && els.composer) new ResizeObserver(placeToast).observe(els.composer);
2827
2911
  window.addEventListener?.('resize', placeToast);
2828
- const stream = new EventSource(`/api/events?since=${state.lastSequence}`);
2829
- stream.onopen = () => { setConnection('live'); replaying = false; };
2830
- stream.onerror = () => setConnection('reconnecting');
2831
- stream.onmessage = ({ data }) => renderEvent(JSON.parse(data));
2912
+ let stream = null;
2913
+ function connectStream() {
2914
+ stream?.close?.();
2915
+ stream = new EventSource(`/api/events?since=${state.lastSequence}`);
2916
+ stream.onopen = () => { setConnection('live'); replaying = false; };
2917
+ stream.onerror = () => setConnection('reconnecting');
2918
+ stream.onmessage = ({ data }) => renderEvent(JSON.parse(data));
2919
+ }
2920
+ connectStream();
2921
+
2922
+ // Everything the thread on screen knows that belongs to ONE conversation. Switching used to
2923
+ // reload the page, which is the brute way to be sure none of this was left over — and it cost a
2924
+ // whole boot: every fetch again, every panel again, the stream again. Repainting in place is
2925
+ // only safe while this list is complete, so it is written out rather than guessed at.
2926
+ function forgetConversation() {
2927
+ state.seen.clear();
2928
+ state.userMessages.clear();
2929
+ state.ratings.clear();
2930
+ state.ratingNodes.clear();
2931
+ state.running.clear();
2932
+ state.plansRunning.clear();
2933
+ state.agentStats.clear();
2934
+ touched.clear();
2935
+ state.failures.length = 0;
2936
+ state.pending.length = 0;
2937
+ state.replyTo = null;
2938
+ state.lastSender = null;
2939
+ state.lastSequence = 0;
2940
+ state.brakeArmed = false;
2941
+ els.column.replaceChildren();
2942
+ renderReplyQuote();
2943
+ paintMotherBadge();
2944
+ updateStopAll();
2945
+ }
2946
+
2947
+ // The new conversation, painted where the old one was. The room, the crew and the archive do not
2948
+ // change between conversations — only the transcript does.
2949
+ async function paintConversation(payload) {
2950
+ replaying = true;
2951
+ forgetConversation();
2952
+ chats.list = payload.chats?.chats ?? chats.list;
2953
+ chats.active = payload.chats?.active ?? chats.active;
2954
+ renderChats();
2955
+ const here = chats.list.find((chat) => chat.id === chats.active);
2956
+ const mark = document.querySelector('.project.chat-here');
2957
+ if (mark) mark.textContent = here && chats.list.length > 1 ? here.title : '';
2958
+ for (const event of payload.events ?? []) renderEvent(event);
2959
+ renderAgents();
2960
+ renderPicker();
2961
+ renderOnboarding();
2962
+ scrollToEnd();
2963
+ connectStream();
2964
+ }
2832
2965
 
2833
2966
  /* ---------- composer ---------- */
2834
2967
 
@@ -2850,12 +2983,52 @@ els.input.addEventListener('input', autosize);
2850
2983
  els.input.addEventListener('scroll', syncHighlightScroll);
2851
2984
  autosize();
2852
2985
 
2986
+ // /module has its own visual language before it ever reaches an agent. The class is also the
2987
+ // animation trigger: it is added once when the prefix becomes a command, and removed when the
2988
+ // human edits away from it or the message leaves the composer.
2989
+ const MODULE_COMMAND = /^\/module(?:\s|$)/i;
2990
+ // A refusal that only flashes is a refusal the room forgets. `/module` needs #2 because writing a
2991
+ // module is writing a file, so the answer lands in the thread where every other decision of the
2992
+ // room lands — and it carries the way out: the button that arms the mode, without arming it.
2993
+ function moduleModeNotice() {
2994
+ const node = el('div', 'system alert module-notice');
2995
+ node.append(el('b', null, t('MU/TH/UR › ')));
2996
+ node.append(t('/module writes a file, so it needs #2 CREATE. Your request is still in the composer.'));
2997
+ const arm = el('button', 'cmd', t('ARM #2 CREATE'));
2998
+ arm.type = 'button';
2999
+ // The human arms it, here as everywhere: the command asked, the click grants.
3000
+ arm.addEventListener('click', () => { setMode(2, { wink: true }); els.input.focus(); });
3001
+ node.append(arm);
3002
+ removeEmpty();
3003
+ els.column.append(node);
3004
+ node.scrollIntoView({ behavior: 'smooth', block: 'end' });
3005
+ }
3006
+
3007
+ function askForModuleMode() {
3008
+ toast(t('MU/TH/UR › /module needs #2 CREATE. Choose #2 in the mode chip, then send again.'));
3009
+ }
3010
+ function syncModuleComposer() {
3011
+ const active = MODULE_COMMAND.test(els.input.value.trimStart());
3012
+ if (state.moduleCommand === active) return;
3013
+ state.moduleCommand = active;
3014
+ els.composer.classList.toggle('module-command', active);
3015
+ updateCrewLabel();
3016
+ updatePlaceholder();
3017
+ if (active && !state.create) askForModuleMode();
3018
+ }
3019
+ els.input.addEventListener('input', syncModuleComposer);
3020
+ syncModuleComposer();
3021
+
2853
3022
  /* ---------- labels inside the field: @agent mentions and /commands ---------- */
2854
3023
 
2855
3024
  function allCommands() { return [...CLIENT_COMMANDS, ...(state.commands ?? [])]; }
2856
3025
  function knownAgentIds() { return [...state.agents.keys()]; }
2857
3026
  function escapeHtml(text) { return text.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char])); }
2858
3027
 
3028
+ // The layer that draws over the field. Its one law: it decorates the characters that are there
3029
+ // and never adds, removes or re-typesets one. The caret belongs to the textarea underneath and is
3030
+ // placed by the textarea's own metrics — so the instant this layer writes a word the human did
3031
+ // not type, or draws it in another font, the two disagree and the caret lands inside a pill.
2859
3032
  function renderHighlight() {
2860
3033
  const text = els.input.value;
2861
3034
  const agents = knownAgentIds();
@@ -2864,7 +3037,11 @@ function renderHighlight() {
2864
3037
  const key = name.toLowerCase();
2865
3038
  if (sigil === '#') {
2866
3039
  if (!/^[0-4]$/.test(name)) return whole;
2867
- return `${lead}<span class="chip mode m${name}">#${name} ${MODES[Number(name)].label}</span>`;
3040
+ // Only what was typed. This used to spell the mode out — `#2` drawn as `#2 CREATE` — and
3041
+ // every character after it sat seven places to the right of its own caret. The name of the
3042
+ // mode is already on the chip above the field; here the colour is enough.
3043
+ const chip = `<span class="chip mode m${name}" title="${escapeHtml(MODES[Number(name)].label)}">#${name}</span>`;
3044
+ return `${lead}${chip}`;
2868
3045
  }
2869
3046
  if (sigil === '!') {
2870
3047
  if (!/\.[A-Za-z0-9]{1,8}(?::\d+(?:-\d+)?)?$/.test(name)) return whole;
@@ -2961,6 +3138,7 @@ function pickMenu(index = menu.index) {
2961
3138
  els.input.setSelectionRange(caret, caret);
2962
3139
  closeMenu();
2963
3140
  autosize();
3141
+ syncModuleComposer();
2964
3142
  els.input.focus();
2965
3143
  }
2966
3144
  els.input.addEventListener('input', () => { menu.index = 0; renderMenu(); });
@@ -2982,8 +3160,17 @@ async function runSlashCommand(text) {
2982
3160
  const name = match[1].toLowerCase();
2983
3161
  const rest = (match[2] ?? '').trim();
2984
3162
  if (name === 'stopall') { await stopAll(); return { handled: true }; }
2985
- if (name === 'create' || name === 'image') {
2986
- 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 }; }
3163
+ if (name === 'create' || name === 'image' || name === 'module') {
3164
+ if (!rest) {
3165
+ toast(name === 'module'
3166
+ ? t('MU/TH/UR › /module needs a job, e.g. "/module summarize today’s commits".')
3167
+ : t('MU/TH/UR › /{name} needs a request after it, e.g. "/{name} a poster for the launch".', { name }));
3168
+ return { handled: true };
3169
+ }
3170
+ if (name === 'module' && !state.create) {
3171
+ moduleModeNotice();
3172
+ return { handled: true, preserve: true };
3173
+ }
2987
3174
  let target = els.target.value;
2988
3175
  if (name === 'image') {
2989
3176
  const capable = [...state.agents.values()].filter((agent) => agent.ready && state.capabilities[agent.id]?.scopes?.imageGen?.enabled).map((agent) => agent.id);
@@ -2996,6 +3183,13 @@ async function runSlashCommand(text) {
2996
3183
  }
2997
3184
  }
2998
3185
  if (!state.create) setMode(2);
3186
+ if (name === 'module') {
3187
+ return {
3188
+ handled: false,
3189
+ target,
3190
+ 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 }),
3191
+ };
3192
+ }
2999
3193
  return { handled: false, text: rest, target };
3000
3194
  }
3001
3195
  const known = allCommands().find((item) => item.name === name);
@@ -3093,6 +3287,7 @@ function updateCrewLabel() {
3093
3287
  const order = state.ash && state.ashInstalled;
3094
3288
  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()} ›`
3095
3289
  : state.mode === 0 ? t('HUMAN · GHOST ›')
3290
+ : state.moduleCommand ? (state.create ? 'MU/TH/UR · MODULE · CREATE ›' : t('MU/TH/UR · MODULE · NEEDS #2 ›'))
3096
3291
  : order ? (state.create ? 'MU/TH/UR · ASH · CREATE ›' : 'MU/TH/UR · ASH ›')
3097
3292
  : state.create ? t('HUMAN · CREATE ›')
3098
3293
  : state.expendable ? t('CREW · EXPENDABLE ›') : t('HUMAN ›');
@@ -3206,6 +3401,7 @@ els.composer.addEventListener('submit', async (event) => {
3206
3401
  els.input.value = '';
3207
3402
  els.composer.classList.remove('stopall');
3208
3403
  autosize();
3404
+ syncModuleComposer();
3209
3405
  await stopAll();
3210
3406
  return;
3211
3407
  }
@@ -3235,7 +3431,13 @@ els.composer.addEventListener('submit', async (event) => {
3235
3431
  els.input.disabled = true;
3236
3432
  const result = await runSlashCommand(text).catch((error) => { toast(t('The command failed: {error}', { error: error.message })); return { handled: true }; });
3237
3433
  els.input.disabled = false;
3238
- if (result.handled) { els.input.value = ''; autosize(); els.input.focus(); return; }
3434
+ if (result.handled) {
3435
+ if (!result.preserve) els.input.value = '';
3436
+ autosize();
3437
+ syncModuleComposer();
3438
+ els.input.focus();
3439
+ return;
3440
+ }
3239
3441
  outgoing = result.text ?? text;
3240
3442
  target = result.target ?? target;
3241
3443
  }
@@ -3259,6 +3461,7 @@ els.composer.addEventListener('submit', async (event) => {
3259
3461
  renderPendingAttachments();
3260
3462
  resetModeAfterSend(); // one mode per message; standing leases fall back to #2, everyone else to #1
3261
3463
  autosize();
3464
+ syncModuleComposer();
3262
3465
  }
3263
3466
  } catch (error) {
3264
3467
  toast(t('Could not reach MADRE: {error}', { error: error.message }));
@@ -3844,8 +4047,30 @@ function metrics(box, rows) {
3844
4047
  }
3845
4048
 
3846
4049
  // Every card has the same floors in the same order, whatever the module is: what it is, what it
3847
- // does, what it touches, how it is doing — and then, last and alone, the switch. The eye learns
3848
- // the shape once and finds the button in the same place on all of them.
4050
+ // does, what it touches, how it is doing — and then its actions, with the main switch last. A DEV
4051
+ // module also offers uninstall there; a module MADRE ships never does.
4052
+ function removeModuleButton(item, { className = 'remove-module', text = t('UNINSTALL') } = {}) {
4053
+ const remove = el('button', className, text);
4054
+ remove.type = 'button';
4055
+ remove.title = t('Delete module file {file}', { file: item.file });
4056
+ remove.addEventListener('click', async () => {
4057
+ 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;
4058
+ remove.disabled = true;
4059
+ try {
4060
+ const response = await fetch(`/api/extensions/${item.id}`, { method: 'DELETE' });
4061
+ const payload = await response.json().catch(() => ({}));
4062
+ if (!response.ok) throw new Error(payload.error ?? `HTTP ${response.status}`);
4063
+ modules.items = payload.extensions ?? modules.items;
4064
+ toast(t('MU/TH/UR › {name} removed.', { name: item.name }));
4065
+ renderModules();
4066
+ } catch (error) {
4067
+ toast(t('It was not removed: {error}', { error: error.message }));
4068
+ remove.disabled = false;
4069
+ }
4070
+ });
4071
+ return remove;
4072
+ }
4073
+
3849
4074
  function cardShell(item, { on, state: stateText }) {
3850
4075
  const card = el('article', `module-card${on ? '' : ' off'}`);
3851
4076
  card.id = `module-${item.id}`;
@@ -3933,9 +4158,10 @@ function cardShell(item, { on, state: stateText }) {
3933
4158
  }
3934
4159
 
3935
4160
  // FLOOR 4 · the module's own: what it reads, what it lets you set.
3936
- // FLOOR 5 · and the switch, alone, at the bottom of every card.
4161
+ // FLOOR 5 · actions at the bottom: DEV uninstall first, the main switch last.
3937
4162
  const panel = el('div', 'card-panel');
3938
4163
  const actions = el('div', 'actions');
4164
+ if (item.external) actions.append(removeModuleButton(item));
3939
4165
  card.append(panel, actions);
3940
4166
  return { card, panel, actions };
3941
4167
  }
@@ -4012,6 +4238,10 @@ modules.file?.addEventListener('change', async () => {
4012
4238
  modules.items = result.extensions ?? modules.items;
4013
4239
  renderModules();
4014
4240
  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.')}`);
4241
+ // A key the contract does not answer to. Not fatal — a module written for a newer MADRE may
4242
+ // carry fields this one has not learned — but a misspelling is far likelier, and it used to
4243
+ // install in silence and leave the author wondering why their card came out empty.
4244
+ warnUnknownModuleFields(result.installed);
4015
4245
  } catch (error) { toast(t('The module was not installed: {error}', { error: error.message })); }
4016
4246
  finally { modules.add.disabled = false; }
4017
4247
  });
@@ -4403,17 +4633,7 @@ function renderModulesDev() {
4403
4633
  for (const item of yours) {
4404
4634
  const chip = el('span', 'dev-chip');
4405
4635
  chip.append(el('b', null, item.name), ` · ${item.origin === 'project' ? 'this project' : 'every room'} `);
4406
- const remove = el('button', 'act-link', t('REMOVE')); remove.type = 'button'; remove.title = `Delete ${item.file}`;
4407
- remove.addEventListener('click', async () => {
4408
- 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;
4409
- remove.disabled = true;
4410
- try {
4411
- const payload = await fetch(`/api/extensions/${item.id}`, { method: 'DELETE' }).then((response) => response.json());
4412
- if (payload.error) throw new Error(payload.error);
4413
- modules.items = payload.extensions ?? modules.items; toast(t('MU/TH/UR › {name} removed.', { name: item.name })); renderModules();
4414
- } catch (error) { toast(t('It was not removed: {error}', { error: error.message })); remove.disabled = false; }
4415
- });
4416
- chip.append(remove);
4636
+ chip.append(removeModuleButton(item, { className: 'act-link', text: t('REMOVE') }));
4417
4637
  list.append(chip);
4418
4638
  }
4419
4639
  body.append(list);
@@ -4487,7 +4707,7 @@ function openAgentPop(id, anchor) {
4487
4707
  const head = el('div', 'head');
4488
4708
  head.append(avatar(id, { size: 24 }));
4489
4709
  head.append(el('b', null, agent.label));
4490
- head.append(el('span', 'vendor', brandOf(id).vendor));
4710
+ head.append(el('span', 'vendor', vendorWord(id)));
4491
4711
  pop.append(head);
4492
4712
  const big = el('div', 'big', official === null ? (formatTokens(agent.tokens ?? 0) || '0') : `${Math.round(official)}%`);
4493
4713
  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) }) + (agent.officialResetAt ? t(' · resets {when}', { when: fmtReset(agent.officialResetAt) }) : t(' · window reset'))));