@jossuealcala/madre 0.4.1 → 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,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,17 @@ 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
+ // 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
+
141
155
 
142
156
 
143
157
  /* ---------- helpers ---------- */
@@ -297,6 +311,8 @@ const viewer = {
297
311
  const viewerUI = {
298
312
  selection: document.querySelector('#viewer-selection'),
299
313
  review: document.querySelector('#viewer-review'),
314
+ lang: document.querySelector('#viewer-lang'),
315
+ hintSelect: document.querySelector('#viewer-hint-select'),
300
316
  menu: document.querySelector('#viewer-menu'),
301
317
  path: null, // project-relative path of the open text file, if any
302
318
  from: null, to: null,
@@ -312,6 +328,9 @@ function setSelection(from, to) {
312
328
  const has = from !== null;
313
329
  viewerUI.selection.hidden = !has;
314
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;
315
334
  if (has) viewerUI.selection.textContent = lo === hi ? `L${lo}` : `L${lo}-${hi}`;
316
335
  }
317
336
  function referenceForSelection() {
@@ -323,7 +342,11 @@ function referenceForSelection() {
323
342
  function reviewWith(agentId) {
324
343
  const reference = referenceForSelection();
325
344
  const current = els.input.value.trim();
326
- 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} `;
327
350
  if (agentId && state.agents.get(agentId)?.ready) { els.target.value = agentId; renderPicker(); }
328
351
  hideViewerMenu();
329
352
  viewer.dialog.close();
@@ -333,17 +356,27 @@ function reviewWith(agentId) {
333
356
  }
334
357
  function showViewerMenu(x, y) {
335
358
  const menuNode = viewerUI.menu;
336
- 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);
337
366
  for (const agent of state.agents.values()) {
338
367
  const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
339
368
  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) })));
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));
341
373
  item.disabled = !agent.ready;
342
374
  item.addEventListener('click', () => reviewWith(agent.id));
343
375
  menuNode.append(item);
344
376
  }
345
377
  menuNode.hidden = false;
346
- 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;
347
380
  menuNode.style.left = `${Math.min(x, window.innerWidth - width - 12)}px`;
348
381
  menuNode.style.top = `${Math.min(y, window.innerHeight - height - 12)}px`;
349
382
  }
@@ -355,14 +388,26 @@ viewerUI.review?.addEventListener('click', (event) => {
355
388
  viewer.dialog.addEventListener('click', (event) => { if (!viewerUI.menu.hidden && !viewerUI.menu.contains(event.target) && event.target !== viewerUI.review) hideViewerMenu(); });
356
389
  viewer.dialog.addEventListener('close', () => { hideViewerMenu(); setSelection(null, null); ripley.frame = null; ripley.history = []; ripleyClearErrors(); if (ripley.nav) ripley.nav.hidden = true; });
357
390
 
358
- function codeView(text, { line = null, lines = null } = {}) {
391
+ function codeView(text, { line = null, lines = null, name = null } = {}) {
359
392
  const pre = el('pre', 'code');
360
393
  viewerUI.lineNodes = [];
361
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 };
362
405
  rows.forEach((content, index) => {
363
406
  const n = index + 1;
364
407
  const ln = el('span', 'ln', String(n));
365
- 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);
366
411
  ln.dataset.n = n; tx.dataset.n = n;
367
412
  const marked = lines ? n >= lines.from && n <= lines.to : n === line;
368
413
  if (marked) { ln.classList.add('hl'); tx.classList.add('hl'); }
@@ -392,7 +437,7 @@ function showViewerSource() {
392
437
  if (ripley.nav) ripley.nav.hidden = true;
393
438
  ripleyClearErrors();
394
439
  ripley.frame = null;
395
- 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 });
396
441
  viewer.body.replaceChildren(pre);
397
442
  if (!state.ripley && viewerMode.kind) {
398
443
  const hint = el('div', 'viewer-hint');
@@ -772,7 +817,7 @@ function renderPicker() {
772
817
  text.append(t('to '));
773
818
  text.append(el('b', null, `@${current.id}`));
774
819
  if (current.local) {
775
- 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'));
776
821
  const measured = state.localModel?.checked;
777
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.')}${
778
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.')}`}`;
@@ -784,12 +829,18 @@ function renderPicker() {
784
829
  modeChip.title = t('Permission mode for this message · {hint}', { hint: MODES[state.mode].hint });
785
830
  modeChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModeMenu(current.id, modeChip); });
786
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.
787
835
  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);
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
+ }
793
844
  els.picker.append(text);
794
845
  }
795
846
  if (typeof renderCreateScopes === 'function') renderCreateScopes();
@@ -881,7 +932,11 @@ function toggleModeMenu(id, anchor) {
881
932
  const scopes = state.capabilities[id]?.scopes;
882
933
  const canRaise = Boolean(scopes?.write?.capable);
883
934
  const title = el('div', 'model-menu-title');
884
- 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 });
885
940
  modeMenu.append(title);
886
941
  const ladder = el('div', 'mode-ladder');
887
942
  for (const n of [0, 1, 2, 3, 4]) {
@@ -1083,19 +1138,20 @@ function bridgeCard(agent) {
1083
1138
  card.id = `bridge-${agent.id}`;
1084
1139
  const session = state.sessions?.[agent.id];
1085
1140
  const signedIn = session?.state === 'signed-in';
1086
- 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';
1087
1143
  const head = el('div', 'head');
1088
1144
  head.append(avatar(agent.id, { size: 30, status: stage === 'ready' ? 'ready' : agent.detected ? 'detected' : 'offline' }));
1089
1145
  const title = el('div', 'title');
1090
1146
  title.append(el('b', null, agent.label));
1091
1147
  title.append(el('span', 'vendor', brandOf(agent.id).vendor));
1092
1148
  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')));
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')));
1094
1150
  card.append(head);
1095
1151
  const detail = el('div', 'detail');
1096
1152
  if (agent.id === 'madre') detail.append(ollamaDetail(agent));
1097
1153
  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}` : ''}`);
1154
+ else detail.append(`${agent.version ?? t('version unknown')}${session?.detail ? ` · ${session.detail}` : ''}${agent.route?.destination ? ` · ${t('route: {destination}', { destination: agent.route.destination })}` : ''}`);
1099
1155
  card.append(detail);
1100
1156
  // What is behind this door: the account it needs, and whether there is a way in without paying.
1101
1157
  if (agent.account) {
@@ -1445,7 +1501,10 @@ function showReplyMenu(source, x, y) {
1445
1501
  for (const agent of state.agents.values()) {
1446
1502
  const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
1447
1503
  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) })));
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));
1449
1508
  item.disabled = !agent.ready;
1450
1509
  item.addEventListener('click', () => replyWith(agent.id, source));
1451
1510
  replyMenu.append(item);
@@ -1637,6 +1696,11 @@ function renderThinking(event) {
1637
1696
  return node;
1638
1697
  }
1639
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
+
1640
1704
  // The archivist reports: which agent read which stretch of the room and how many notes it kept.
1641
1705
  // module · @codex wrote read-mail.module.mjs · INSTALL FOR EVERY ROOM · INSTALL FOR THIS PROJECT · LATER
1642
1706
  function renderModuleProposed(event) {
@@ -1652,6 +1716,7 @@ function renderModuleProposed(event) {
1652
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());
1653
1717
  if (payload.error) throw new Error(payload.error);
1654
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);
1655
1720
  modules.items = payload.extensions ?? modules.items; if (modules.dialog.open) renderModules();
1656
1721
  } catch (error) { toast(t('The module was not installed: {error}', { error: error.message })); button.disabled = false; }
1657
1722
  };
@@ -2394,12 +2459,12 @@ function renderEventNode(event) {
2394
2459
  case 'mode.requested': node = renderModeRequest(event); break;
2395
2460
  case 'control.started': node = renderControlStarted(event); break;
2396
2461
  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;
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;
2398
2463
  case 'control.reverted': node = renderControlReverted(event); break;
2399
2464
  case 'mode.granted':
2400
2465
  case 'mode.denied': settleModeRequest(event); return;
2401
2466
  case 'plan.ignored': node = renderPlanIgnored(event); break;
2402
- 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;
2403
2468
  case 'command.output': node = renderCommandCard(event); break;
2404
2469
  case 'eyecat.flagged': node = renderEyecat(event); break;
2405
2470
  case 'eyecat.settled': settleEyecat(event); return;
@@ -2563,6 +2628,13 @@ function treeNode(path, entry) {
2563
2628
  item.append(button, children);
2564
2629
  } else {
2565
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
+ }
2566
2638
  button.addEventListener('click', () => { void openViewer({ root: 'project', path, label: path }); });
2567
2639
  item.append(button);
2568
2640
  }
@@ -2650,10 +2722,14 @@ async function loadChats() {
2650
2722
  }
2651
2723
 
2652
2724
  async function openChat(id) {
2725
+ if (id === chats.active) { setChats(false); return; }
2653
2726
  const response = await fetch(`/api/chats/${id}`, { method: 'POST' });
2654
2727
  const result = await response.json().catch(() => ({}));
2655
2728
  if (!response.ok) { toast(`MU/TH/UR › ${result.error ?? 'that conversation could not be opened.'}`); return; }
2656
- 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);
2657
2733
  }
2658
2734
 
2659
2735
  function setChats(open) {
@@ -2750,7 +2826,7 @@ document.querySelector('#mother-button')?.addEventListener('mouseleave', endHove
2750
2826
  document.querySelector('#mother-button')?.addEventListener('click', endHover);
2751
2827
 
2752
2828
  // 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; } };
2829
+ globalThis.__pulse = { trackHold, armExpendable, disarmExpendable, beginHover, endHover, state, moduleCard, runSlashCommand, syncModuleComposer, openCore: (...args) => openCore(...args), get core() { return core; } };
2754
2830
 
2755
2831
  els.thread.addEventListener('wheel', (event) => trackHold(event.deltaY > 0), { passive: true });
2756
2832
  let touchY = null;
@@ -2825,10 +2901,59 @@ function setConnection(value) {
2825
2901
  }
2826
2902
  if (typeof ResizeObserver === 'function' && els.composer) new ResizeObserver(placeToast).observe(els.composer);
2827
2903
  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));
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
+ }
2832
2957
 
2833
2958
  /* ---------- composer ---------- */
2834
2959
 
@@ -2850,12 +2975,52 @@ els.input.addEventListener('input', autosize);
2850
2975
  els.input.addEventListener('scroll', syncHighlightScroll);
2851
2976
  autosize();
2852
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
+
2853
3014
  /* ---------- labels inside the field: @agent mentions and /commands ---------- */
2854
3015
 
2855
3016
  function allCommands() { return [...CLIENT_COMMANDS, ...(state.commands ?? [])]; }
2856
3017
  function knownAgentIds() { return [...state.agents.keys()]; }
2857
3018
  function escapeHtml(text) { return text.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char])); }
2858
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.
2859
3024
  function renderHighlight() {
2860
3025
  const text = els.input.value;
2861
3026
  const agents = knownAgentIds();
@@ -2864,7 +3029,11 @@ function renderHighlight() {
2864
3029
  const key = name.toLowerCase();
2865
3030
  if (sigil === '#') {
2866
3031
  if (!/^[0-4]$/.test(name)) return whole;
2867
- 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}`;
2868
3037
  }
2869
3038
  if (sigil === '!') {
2870
3039
  if (!/\.[A-Za-z0-9]{1,8}(?::\d+(?:-\d+)?)?$/.test(name)) return whole;
@@ -2961,6 +3130,7 @@ function pickMenu(index = menu.index) {
2961
3130
  els.input.setSelectionRange(caret, caret);
2962
3131
  closeMenu();
2963
3132
  autosize();
3133
+ syncModuleComposer();
2964
3134
  els.input.focus();
2965
3135
  }
2966
3136
  els.input.addEventListener('input', () => { menu.index = 0; renderMenu(); });
@@ -2982,8 +3152,17 @@ async function runSlashCommand(text) {
2982
3152
  const name = match[1].toLowerCase();
2983
3153
  const rest = (match[2] ?? '').trim();
2984
3154
  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 }; }
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
+ }
2987
3166
  let target = els.target.value;
2988
3167
  if (name === 'image') {
2989
3168
  const capable = [...state.agents.values()].filter((agent) => agent.ready && state.capabilities[agent.id]?.scopes?.imageGen?.enabled).map((agent) => agent.id);
@@ -2996,6 +3175,13 @@ async function runSlashCommand(text) {
2996
3175
  }
2997
3176
  }
2998
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
+ }
2999
3185
  return { handled: false, text: rest, target };
3000
3186
  }
3001
3187
  const known = allCommands().find((item) => item.name === name);
@@ -3093,6 +3279,7 @@ function updateCrewLabel() {
3093
3279
  const order = state.ash && state.ashInstalled;
3094
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()} ›`
3095
3281
  : state.mode === 0 ? t('HUMAN · GHOST ›')
3282
+ : state.moduleCommand ? (state.create ? 'MU/TH/UR · MODULE · CREATE ›' : t('MU/TH/UR · MODULE · NEEDS #2 ›'))
3096
3283
  : order ? (state.create ? 'MU/TH/UR · ASH · CREATE ›' : 'MU/TH/UR · ASH ›')
3097
3284
  : state.create ? t('HUMAN · CREATE ›')
3098
3285
  : state.expendable ? t('CREW · EXPENDABLE ›') : t('HUMAN ›');
@@ -3206,6 +3393,7 @@ els.composer.addEventListener('submit', async (event) => {
3206
3393
  els.input.value = '';
3207
3394
  els.composer.classList.remove('stopall');
3208
3395
  autosize();
3396
+ syncModuleComposer();
3209
3397
  await stopAll();
3210
3398
  return;
3211
3399
  }
@@ -3235,7 +3423,13 @@ els.composer.addEventListener('submit', async (event) => {
3235
3423
  els.input.disabled = true;
3236
3424
  const result = await runSlashCommand(text).catch((error) => { toast(t('The command failed: {error}', { error: error.message })); return { handled: true }; });
3237
3425
  els.input.disabled = false;
3238
- 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
+ }
3239
3433
  outgoing = result.text ?? text;
3240
3434
  target = result.target ?? target;
3241
3435
  }
@@ -3259,6 +3453,7 @@ els.composer.addEventListener('submit', async (event) => {
3259
3453
  renderPendingAttachments();
3260
3454
  resetModeAfterSend(); // one mode per message; standing leases fall back to #2, everyone else to #1
3261
3455
  autosize();
3456
+ syncModuleComposer();
3262
3457
  }
3263
3458
  } catch (error) {
3264
3459
  toast(t('Could not reach MADRE: {error}', { error: error.message }));
@@ -3844,8 +4039,30 @@ function metrics(box, rows) {
3844
4039
  }
3845
4040
 
3846
4041
  // 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.
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
+
3849
4066
  function cardShell(item, { on, state: stateText }) {
3850
4067
  const card = el('article', `module-card${on ? '' : ' off'}`);
3851
4068
  card.id = `module-${item.id}`;
@@ -3933,9 +4150,10 @@ function cardShell(item, { on, state: stateText }) {
3933
4150
  }
3934
4151
 
3935
4152
  // 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.
4153
+ // FLOOR 5 · actions at the bottom: DEV uninstall first, the main switch last.
3937
4154
  const panel = el('div', 'card-panel');
3938
4155
  const actions = el('div', 'actions');
4156
+ if (item.external) actions.append(removeModuleButton(item));
3939
4157
  card.append(panel, actions);
3940
4158
  return { card, panel, actions };
3941
4159
  }
@@ -4012,6 +4230,10 @@ modules.file?.addEventListener('change', async () => {
4012
4230
  modules.items = result.extensions ?? modules.items;
4013
4231
  renderModules();
4014
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);
4015
4237
  } catch (error) { toast(t('The module was not installed: {error}', { error: error.message })); }
4016
4238
  finally { modules.add.disabled = false; }
4017
4239
  });
@@ -4403,17 +4625,7 @@ function renderModulesDev() {
4403
4625
  for (const item of yours) {
4404
4626
  const chip = el('span', 'dev-chip');
4405
4627
  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);
4628
+ chip.append(removeModuleButton(item, { className: 'act-link', text: t('REMOVE') }));
4417
4629
  list.append(chip);
4418
4630
  }
4419
4631
  body.append(list);