@jossuealcala/madre 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -33,14 +33,14 @@ const els = {
33
33
  // Built-in commands live in the composer; module commands come from the
34
34
  // server (/api/commands) and run there, read-only, as fact cards.
35
35
  const CLIENT_COMMANDS = [
36
- { name: 'create', title: 'CREATE', usage: '/create <what to make>', summary: 'Arm the creation lease for this message: the agent may create files inside .pulse/out/.', available: true, client: true },
36
+ { name: 'create', title: 'CREATE', usage: '/create <what to make>', summary: 'Arm CREATE for this message: the agent may add new files to the project where they belong.', available: true, client: true },
37
37
  { name: 'image', title: 'Image', usage: '/image <what to draw>', summary: 'Ask for an image: arms CREATE with the image scope and routes to an agent that can generate images.', available: true, client: true },
38
38
  { name: 'stopall', title: 'STOP ALL', usage: '/stopall', summary: 'Master brake: halt every plan and turn in flight. Never reaches an agent.', available: true, client: true },
39
39
  ];
40
40
 
41
41
  const PLACEHOLDERS = {
42
42
  plain: 'Type here, human. Ask the room…',
43
- create: 'Creation lease on: what to create and how it should look. It lands in .pulse/out/',
43
+ create: 'CREATE on: what to make. New files land where they belong in the project; nothing existing changes.',
44
44
  order: 'Priority one. Terse transmissions; the original stays in the record.',
45
45
  ghost: 'Off the record. Ask anything; nothing is saved, nobody else will remember it.',
46
46
  control: 'Control armed. Say what to change in the project; every action runs without asking.',
@@ -55,7 +55,7 @@ const MAX_ROWS = 3;
55
55
  const MODES = {
56
56
  0: { key: 'ghost', label: 'GHOST', hint: 'Off the record. Nothing is saved; gone on reload.' },
57
57
  1: { key: 'exchange', label: 'EXCHANGE', hint: 'Read the project and talk to the room. Writes nothing.' },
58
- 2: { key: 'create', label: 'CREATE', hint: 'Write new files, only inside this turn\'s .pulse/out/.' },
58
+ 2: { key: 'create', label: 'CREATE', hint: 'Add new files where they belong in the project. Existing files stay untouched.' },
59
59
  3: { key: 'control', label: 'CONTROL', hint: 'Edit the project itself, no approval per action. Override required.' },
60
60
  };
61
61
 
@@ -64,7 +64,9 @@ const state = {
64
64
  budget: null,
65
65
  timeouts: {},
66
66
  seen: new Set(),
67
- userMessages: new Map(), // messageId -> { text, target }
67
+ userMessages: new Map(),
68
+ ratings: new Map(), // messageId → good | bad, from the ledger
69
+ ratingNodes: new Map(), // messageId → the buttons of that bubble // messageId -> { text, target }
68
70
  lastSequence: 0,
69
71
  lastSender: null, // for iMessage-style grouping of consecutive bubbles
70
72
  failures: [], // recorded conditions for MU/TH/UR
@@ -729,8 +731,8 @@ function renderPicker() {
729
731
  text.append('to ');
730
732
  text.append(el('b', null, `@${current.id}`));
731
733
  if (current.local) {
732
- const note = el('span', 'pick-note', 'memory · answers, does not act');
733
- note.title = '@madre runs on this machine and speaks only for what the room said. To convene the crew or change files, write to a CLI agent.';
734
+ const note = el('span', 'pick-note', 'memory · answers & asks the crew · never writes');
735
+ note.title = '@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.';
734
736
  text.append(note);
735
737
  }
736
738
  const modeChip = el('button', `mode-chip m${state.mode}`);
@@ -1059,7 +1061,7 @@ function renderAssistantMessage(event) {
1059
1061
  bubble.append(renderMarkdown(text));
1060
1062
  if (originalText) bubble.append(ashOriginal(originalText, ashCode));
1061
1063
  if (event.payload.artifacts?.length) bubble.append(artifactTiles(event.payload.artifacts));
1062
- bubble.append(bubbleActions({ text: originalText ?? text, sender, sequence: event.sequence }));
1064
+ bubble.append(bubbleActions({ text: originalText ?? text, sender, sequence: event.sequence, messageId: delegated ? null : messageId }));
1063
1065
  col.append(bubble);
1064
1066
  const stamp = el('div', 'stamp');
1065
1067
  stamp.id = `usage-${messageId}`;
@@ -1082,7 +1084,27 @@ function iconButton(svg, title, className) {
1082
1084
  button.innerHTML = svg;
1083
1085
  return button;
1084
1086
  }
1085
- function bubbleActions({ text, sender, sequence }) {
1087
+ const ICON_GOOD = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 7.5v5.5H3.2a.7.7 0 0 1-.7-.7V8.2a.7.7 0 0 1 .7-.7h2.3Zm0 0 2.6-4.6a1.3 1.3 0 0 1 2.4.8L10 6.8h2.6a1.3 1.3 0 0 1 1.3 1.5l-.8 3.9a1.3 1.3 0 0 1-1.3 1H5.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
1088
+ const ICON_BAD = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 8.5V3H12.8a.7.7 0 0 1 .7.7v4.1a.7.7 0 0 1-.7.7h-2.3Zm0 0-2.6 4.6a1.3 1.3 0 0 1-2.4-.8L6 9.2H3.4a1.3 1.3 0 0 1-1.3-1.5l.8-3.9A1.3 1.3 0 0 1 4.2 3h6.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
1089
+ // The human's verdict on a reply. Saved in the ledger; the dataset drops what is marked bad.
1090
+ function ratingButtons(messageId, sender) {
1091
+ const good = iconButton(ICON_GOOD, 'Good reply · keep it for MADRE AI', 'rate good');
1092
+ const bad = iconButton(ICON_BAD, 'Bad reply · keep it out of the dataset', 'rate bad');
1093
+ const apply = (rating) => { good.classList.toggle('on', rating === 'good'); bad.classList.toggle('on', rating === 'bad'); };
1094
+ apply(state.ratings.get(messageId) ?? null);
1095
+ const send = async (rating) => {
1096
+ const current = state.ratings.get(messageId) ?? null;
1097
+ const next = current === rating ? 'none' : rating;
1098
+ try {
1099
+ const payload = await fetch('/api/messages/rate', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ messageId, rating: next }) }).then((response) => response.json());
1100
+ if (payload.error) throw new Error(payload.error);
1101
+ } catch (error) { toast(`Rating was not saved: ${error.message}`); }
1102
+ };
1103
+ good.addEventListener('click', (event) => { event.stopPropagation(); void send('good'); });
1104
+ bad.addEventListener('click', (event) => { event.stopPropagation(); void send('bad'); });
1105
+ return { good, bad, apply, sender };
1106
+ }
1107
+ function bubbleActions({ text, sender, sequence, messageId = null }) {
1086
1108
  const bar = el('div', 'bubble-actions');
1087
1109
  const copy = iconButton(ICON_COPY, 'Copy this reply', 'copy');
1088
1110
  copy.addEventListener('click', async (event) => {
@@ -1102,6 +1124,11 @@ function bubbleActions({ text, sender, sequence }) {
1102
1124
  const rect = reply.getBoundingClientRect();
1103
1125
  showReplyMenu({ text, sender, sequence }, rect.left, rect.bottom + 6);
1104
1126
  });
1127
+ if (messageId && sender !== 'madre') {
1128
+ const rating = ratingButtons(messageId, sender);
1129
+ state.ratingNodes.set(messageId, rating);
1130
+ bar.append(rating.good, rating.bad);
1131
+ }
1105
1132
  bar.append(copy, reply);
1106
1133
  return bar;
1107
1134
  }
@@ -1251,6 +1278,20 @@ function renderThinking(event) {
1251
1278
  }
1252
1279
 
1253
1280
  // The archivist reports: which agent read which stretch of the room and how many notes it kept.
1281
+ // privacy · @agent · 2 private terms replaced with [ENTIDAD-ORG]
1282
+ function renderPrivacy(event) {
1283
+ const node = el('div', 'system memory privacy');
1284
+ if (event.type === 'privacy.purged') {
1285
+ const { events, entries, memories } = event.payload;
1286
+ node.append('privacy · ', el('b', 'who', 'PURGE'), ` · ${events} event${events === 1 ? '' : 's'} · ${entries} indexed exchange${entries === 1 ? '' : 's'} · ${memories} memor${memories === 1 ? 'y' : 'ies'} rewritten with the marker`);
1287
+ return node;
1288
+ }
1289
+ const { agent, hits, marker } = event.payload;
1290
+ node.style.setProperty('--agent', agentColor(agent));
1291
+ node.append('privacy · ', el('b', 'who', `@${agent}`), ` · ${hits} private term${hits === 1 ? '' : 's'} replaced with ${marker}`);
1292
+ return node;
1293
+ }
1294
+
1254
1295
  function renderDistilled(event) {
1255
1296
  const { agent, added, considered, fromSequence, throughSequence, remaining, error, skipped, total, next } = event.payload;
1256
1297
  const node = el('div', `system memory${error ? ' warn' : ''}`);
@@ -1385,6 +1426,18 @@ function renderLeaseRefused(event) {
1385
1426
 
1386
1427
  /* ---------- CONTROL: the project itself, between two checkpoints ---------- */
1387
1428
 
1429
+ // create · @codex · 2 existing files put back: CREATE only adds (README.md, src/index.astro)
1430
+ function renderCreateReverted(event) {
1431
+ const { agent, existing = [], forbidden = [] } = event.payload;
1432
+ const node = el('div', 'system memory warn');
1433
+ node.style.setProperty('--agent', agentColor(agent));
1434
+ node.append('create · ', el('b', 'who', `@${agent}`));
1435
+ if (existing.length) node.append(` · ${existing.length} existing file${existing.length === 1 ? '' : 's'} put back, CREATE only adds: ${existing.slice(0, 4).join(', ')}${existing.length > 4 ? '…' : ''}`);
1436
+ if (forbidden.length) node.append(` · ${forbidden.length} write${forbidden.length === 1 ? '' : 's'} into forbidden zones reverted`);
1437
+ node.append(' · need to change existing files? ask again in #3 CONTROL');
1438
+ return node;
1439
+ }
1440
+
1388
1441
  function renderControlStarted(event) {
1389
1442
  const { agent, commit, message } = event.payload;
1390
1443
  const node = el('div', 'system control');
@@ -1511,7 +1564,7 @@ function renderModeRequest(event) {
1511
1564
  for (const other of actions.querySelectorAll('button')) other.disabled = false;
1512
1565
  }
1513
1566
  };
1514
- const once = el('button', 'grant', 'GRANT ONCE'); once.type = 'button'; once.title = `@${agent} creates files for this step only, inside its own .pulse/out/ directory.`;
1567
+ const once = el('button', 'grant', 'GRANT ONCE'); once.type = 'button'; once.title = `@${agent} may add files to the project for this step only.`;
1515
1568
  const plan = el('button', 'grant plan', 'GRANT FOR PLAN'); plan.type = 'button'; plan.title = 'Every remaining writable step of this plan shares one lease directory.';
1516
1569
  const deny = el('button', 'deny', 'DENY'); deny.type = 'button'; deny.title = `@${agent} answers read-only and says what it would have created.`;
1517
1570
  once.addEventListener('click', () => decide('once', once));
@@ -1553,12 +1606,19 @@ function renderLease(event) {
1553
1606
  const { agent, outDir, scopes = [], unavailable = [], standing = false, escalated = null } = event.payload;
1554
1607
  const node = el('div', 'system lease');
1555
1608
  node.style.setProperty('--agent', agentColor(agent));
1556
- node.append(el('b', null, standing ? 'standing lease · ' : escalated ? `lease granted on request${escalated === 'plan' ? ' · whole plan' : ''} · ` : 'creation lease · '));
1557
- node.append(`@${agent} may ${scopes.map((scope) => CAP_LABELS[scope] ?? scope).join(', ') || 'create files'}${unavailable.length ? ` (cannot ${unavailable.join(', ')})` : ''} in `);
1558
- const link = el('a', 'file-link', outDir);
1559
- link.href = '#';
1560
- link.addEventListener('click', (ev) => { ev.preventDefault(); });
1561
- node.append(link);
1609
+ const { delegated = false, scratchDir = null, grantedBy = null } = event.payload;
1610
+ node.append(el('b', null, standing ? 'default #2 · ' : escalated ? `#2 granted on request${escalated === 'plan' ? ' · whole plan' : ''} · ` : delegated ? `#2 by @${grantedBy} · ` : 'create · '));
1611
+ node.append(`@${agent} may ${scopes.map((scope) => CAP_LABELS[scope] ?? scope).join(', ') || 'create files'}${unavailable.length ? ` (cannot ${unavailable.join(', ')})` : ''} `);
1612
+ if (outDir === '.' || !outDir) {
1613
+ node.append('anywhere in the project · existing files stay untouched');
1614
+ if (scratchDir) { node.append(' · scratch '); const link = el('a', 'file-link', scratchDir); link.href = '#'; link.addEventListener('click', (ev) => { ev.preventDefault(); }); node.append(link); }
1615
+ } else {
1616
+ node.append('in ');
1617
+ const link = el('a', 'file-link', outDir);
1618
+ link.href = '#';
1619
+ link.addEventListener('click', (ev) => { ev.preventDefault(); });
1620
+ node.append(link);
1621
+ }
1562
1622
  state.lastSender = null;
1563
1623
  return node;
1564
1624
  }
@@ -1775,6 +1835,10 @@ function renderEventNode(event) {
1775
1835
  case 'message.failed': state.running.delete(event.payload.messageId); updateStopAll(); node = renderFailure(event); break;
1776
1836
  case 'handoff.created': node = renderHandoff(event); break;
1777
1837
  case 'memory.distilled': node = renderDistilled(event); break;
1838
+ case 'message.rated': { const { messageId: rated, rating } = event.payload; if (rating === 'none') state.ratings.delete(rated); else state.ratings.set(rated, rating); state.ratingNodes.get(rated)?.apply(rating === 'none' ? null : rating); return; }
1839
+ case 'privacy.redacted': node = renderPrivacy(event); break;
1840
+ case 'privacy.purged': node = renderPrivacy(event); break;
1841
+ case 'privacy.warning': if (!replaying) toast(`MU/TH/UR › your message carries ${event.payload.hits} private term${event.payload.hits === 1 ? '' : 's'}. Agents will read it as you wrote it; their replies are guarded.`); return;
1778
1842
  case 'memory.forgotten': node = renderForgotten(event); break;
1779
1843
  case 'memory.noted': attachMemoryHint(event); return;
1780
1844
  case 'dataset.exported': return;
@@ -1816,6 +1880,7 @@ function renderEventNode(event) {
1816
1880
  case 'lease.missing': node = renderLeaseMissing(event); break;
1817
1881
  case 'mode.requested': node = renderModeRequest(event); break;
1818
1882
  case 'control.started': node = renderControlStarted(event); break;
1883
+ case 'create.reverted': node = renderCreateReverted(event); break;
1819
1884
  case 'control.changed': node = renderControlChanged(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); break;
1820
1885
  case 'control.reverted': node = renderControlReverted(event); break;
1821
1886
  case 'mode.granted':
@@ -2306,8 +2371,8 @@ function renderCreateScopes() {
2306
2371
  const standing = Boolean(scopes?.write?.always);
2307
2372
  els.createToggle.classList.toggle('standing', standing);
2308
2373
  els.createToggle.title = standing
2309
- ? `Standing lease: every turn of @${id} may create files inside .pulse/out/ (set in CONNECTIONS). Arming CREATE is not needed.`
2310
- : 'Creation lease: let the agent create files for this request, only inside .pulse/out/';
2374
+ ? `@${id} starts in #2 (DEFAULT MODE in CONNECTIONS): every turn may add files to the project. Arming CREATE is not needed.`
2375
+ : 'CREATE: let the agent add new files to the project for this request; existing files stay untouched';
2311
2376
  box.hidden = !state.create || !scopes;
2312
2377
  if (box.hidden) return;
2313
2378
  box.replaceChildren();
@@ -2654,6 +2719,8 @@ function renderMotherRecorded() {
2654
2719
  jump.type = 'button';
2655
2720
  jump.addEventListener('click', () => {
2656
2721
  mother.input.value = '';
2722
+ // Choosing a fix from the log is a request to see it: the list opens and stays open.
2723
+ try { localStorage.setItem('pulse.mother.known', 'expanded'); } catch { /* no storage */ }
2657
2724
  renderMotherKnown(CONDITIONS, new Set(matches.map((item) => item.id)), failure.agent);
2658
2725
  document.getElementById(`mother-${condition.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
2659
2726
  });
@@ -2666,7 +2733,23 @@ function renderMotherRecorded() {
2666
2733
 
2667
2734
  function renderMotherKnown(list = CONDITIONS, hits = new Set(), agent = null) {
2668
2735
  mother.known.replaceChildren();
2669
- mother.known.append(el('h3', null, `KNOWN CONDITIONS · ${list.length} OF ${CONDITIONS.length} · ${PLATFORMS[mother.platform].label.toUpperCase()} / ${PLATFORMS[mother.platform].shell.toUpperCase()}`));
2736
+ let collapsed = false;
2737
+ try { collapsed = localStorage.getItem('pulse.mother.known') === 'collapsed'; } catch { /* no storage */ }
2738
+ // An inquiry that narrowed the list, or a fix chosen from the log, always shows its answer, whatever the stored state.
2739
+ if (list.length !== CONDITIONS.length || hits.size) collapsed = false;
2740
+ const head = el('h3', 'toggle');
2741
+ const headButton = el('button', null, `KNOWN CONDITIONS · ${list.length} OF ${CONDITIONS.length} · ${PLATFORMS[mother.platform].label.toUpperCase()} / ${PLATFORMS[mother.platform].shell.toUpperCase()}`);
2742
+ headButton.type = 'button';
2743
+ headButton.setAttribute('aria-expanded', String(!collapsed));
2744
+ headButton.append(el('span', 'caret', collapsed ? '▸ EXPAND' : '▾ COLLAPSE'));
2745
+ headButton.addEventListener('click', () => {
2746
+ try { localStorage.setItem('pulse.mother.known', collapsed ? 'expanded' : 'collapsed'); } catch { /* no storage */ }
2747
+ renderMotherKnown(list, hits, agent);
2748
+ });
2749
+ head.append(headButton);
2750
+ mother.known.append(head);
2751
+ mother.known.classList.toggle('collapsed', collapsed);
2752
+ if (collapsed) return;
2670
2753
  const grid = el('div', 'mother-grid');
2671
2754
  const ordered = [...list].sort((a, b) => Number(hits.has(b.id)) - Number(hits.has(a.id)));
2672
2755
  for (const condition of ordered) grid.append(conditionCard(condition, { hit: hits.has(condition.id), agent: agent && condition.perAgent ? agent : null, hintAgent: agent }));
@@ -3235,7 +3318,8 @@ function connectionCard(agent) {
3235
3318
  card.append(meta);
3236
3319
  const scopes = el('div', 'scopes');
3237
3320
  const agentScopes = settingsUI.data.settings.capabilities?.[agent.id]?.scopes ?? {};
3238
- for (const [key, labelText] of [['write', 'CREATE FILES'], ['imageGen', 'GENERATE IMAGES'], ['web', 'WEB ACCESS']]) {
3321
+ // Two abilities per agent: images and web. Writing is not an ability, it is the ceiling below.
3322
+ for (const [key, labelText] of [['imageGen', 'GENERATE IMAGES'], ['web', 'WEB ACCESS']]) {
3239
3323
  const scope = agentScopes[key] ?? { capable: false, enabled: false, wired: false };
3240
3324
  const line = el('label', `scope${!scope.capable || !scope.wired ? ' unavailable' : ''}`);
3241
3325
  const box = el('input'); box.type = 'checkbox'; box.checked = Boolean(scope.enabled); box.disabled = !scope.capable || !scope.wired;
@@ -3269,30 +3353,6 @@ function connectionCard(agent) {
3269
3353
  line.append(assist);
3270
3354
  }
3271
3355
  scopes.append(line);
3272
- if (key === 'write' && scope.capable) {
3273
- const always = el('label', `scope sub${scope.enabled ? '' : ' unavailable'}`);
3274
- const alwaysBox = el('input'); alwaysBox.type = 'checkbox'; alwaysBox.checked = Boolean(scope.always); alwaysBox.disabled = !scope.enabled;
3275
- alwaysBox.dataset.agent = agent.id; alwaysBox.dataset.scope = 'alwaysCreate'; alwaysBox.className = 'scope-input';
3276
- alwaysBox.addEventListener('change', async () => {
3277
- alwaysBox.disabled = true;
3278
- try {
3279
- const response = await fetch('/api/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ scopes: { [agent.id]: { alwaysCreate: alwaysBox.checked } } }) });
3280
- const result = await response.json().catch(() => ({}));
3281
- if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
3282
- if (result.settings?.capabilities) { state.capabilities = result.settings.capabilities; settingsUI.data.settings.capabilities = result.settings.capabilities; renderCreateScopes(); }
3283
- toast(`MU/TH/UR › @${agent.id} ${alwaysBox.checked ? 'now holds a standing lease: every turn may create files in .pulse/out/, including plan steps.' : 'creates files only when you arm CREATE.'}`);
3284
- } catch (error) {
3285
- alwaysBox.checked = !alwaysBox.checked;
3286
- toast(`Scope was not saved: ${error.message}`);
3287
- } finally {
3288
- alwaysBox.disabled = !scope.enabled;
3289
- }
3290
- });
3291
- always.append(alwaysBox, 'ALWAYS · STANDING LEASE');
3292
- always.append(el('span', 'why', scope.always ? 'every turn and plan step may create files' : 'only when CREATE is armed'));
3293
- always.title = 'Skip arming CREATE: this agent gets a fresh .pulse/out/ directory on every turn, also when another agent delegates to it.';
3294
- scopes.append(always);
3295
- }
3296
3356
  }
3297
3357
  // The ceiling: how far the composer may take this agent.
3298
3358
  const ceiling = el('div', 'ceiling');
@@ -3318,6 +3378,29 @@ function connectionCard(agent) {
3318
3378
  ceiling.append(seg);
3319
3379
  ceiling.append(el('span', 'why', `${MODES[currentCap].label} · ${MODES[currentCap].hint}`));
3320
3380
  scopes.append(ceiling);
3381
+ // The start: where a message to this agent begins. #2 means every turn may create files without arming CREATE.
3382
+ const start = el('div', 'ceiling');
3383
+ start.append(el('span', 'k', 'DEFAULT MODE'));
3384
+ const startSeg = el('div', 'seg');
3385
+ const currentStart = agentScopes.defaultMode ?? 1;
3386
+ for (const n of [1, 2]) {
3387
+ const button = el('button', `seg-option o${n}${currentStart === n ? ' current' : ''}`, `#${n}`);
3388
+ button.type = 'button';
3389
+ button.title = n === 2 ? 'Every message to this agent starts in CREATE: new files where they belong, existing files untouched. Plan steps to it too.' : 'Messages start read-only; arm CREATE when you want files.';
3390
+ button.disabled = n > currentCap;
3391
+ button.addEventListener('click', async () => {
3392
+ if (n === currentStart) return;
3393
+ for (const other of startSeg.children) other.disabled = true;
3394
+ try {
3395
+ await saveSettingNow({ scopes: { [agent.id]: { defaultMode: n } } }, n === 2 ? `@${agent.id} starts in #2 CREATE: every turn may add files to the project.` : `@${agent.id} starts read-only; arm CREATE when you want files.`);
3396
+ await loadSettings();
3397
+ } catch (error) { toast(`Default mode was not saved: ${error.message}`); for (const other of startSeg.children) other.disabled = false; }
3398
+ });
3399
+ startSeg.append(button);
3400
+ }
3401
+ start.append(startSeg);
3402
+ start.append(el('span', 'why', currentStart === 2 ? 'every turn may create files, plan steps too' : 'read-only until you arm CREATE'));
3403
+ scopes.append(start);
3321
3404
  card.append(scopes);
3322
3405
 
3323
3406
  const row = el('div', 'row');
@@ -3512,7 +3595,11 @@ function renderSettings() {
3512
3595
  const datasetNote = el('span', 'note', 'LOADING…');
3513
3596
  const showDataset = (payload) => {
3514
3597
  const d = payload?.dataset;
3515
- datasetNote.textContent = d ? `${d.pairs} PAIRS · ${d.turns} TURNS · ${d.notes} NOTES · TRAIN ${d.train} · VALID ${d.valid} · ${new Date(d.exportedAt).toLocaleString()}${payload.trained ? ` · TRAINED MODEL ${payload.trained.toUpperCase()} IN USE` : ' · NO TRAINED MODEL YET · SEE docs/training'}` : 'NOT EXPORTED YET · PRESS EXPORT, THEN TRAIN WITH docs/training';
3598
+ const r = payload?.readiness;
3599
+ const live = r ? `${r.pairs} / ${r.target} CLEAN PAIRS${r.ready ? ' · READY TO TRAIN' : ''} · ${r.turns} TURNS · ${r.delegated} DELEGATED · ${r.notes} NOTES${r.good ? ` · ${r.good} RATED GOOD` : ''}${r.bad ? ` · ${r.bad} DROPPED AS BAD` : ''}` : '';
3600
+ const exported = d ? `LAST EXPORT ${new Date(d.exportedAt).toLocaleString()} · TRAIN ${d.train} · VALID ${d.valid}` : 'NOT EXPORTED YET';
3601
+ const trained = payload?.trained ? `TRAINED MODEL ${payload.trained.toUpperCase()} IN USE` : 'NO TRAINED MODEL YET · SEE docs/training';
3602
+ datasetNote.textContent = [live, exported, trained].filter(Boolean).join(' · ');
3516
3603
  };
3517
3604
  fetch('/api/dataset').then((response) => response.json()).then(showDataset).catch(() => { datasetNote.textContent = 'DATASET UNAVAILABLE'; });
3518
3605
  exportButton.addEventListener('click', async () => {
@@ -3523,10 +3610,95 @@ function renderSettings() {
3523
3610
  });
3524
3611
  dataset.append(exportButton, datasetNote);
3525
3612
  mform.append(dataset);
3613
+ // TRAIN: the recipe, with this room's paths and this project's model name filled in. Training runs outside MADRE.
3614
+ const train = el('div', 'full train-card');
3615
+ const trainHead = el('div', 'train-head', 'TRAIN MADRE AI · LOCAL, WITH MLX ON APPLE SILICON · NOTHING LEAVES THIS MACHINE');
3616
+ const trainNote = el('p', 'note', 'EXPORT THE DATASET FIRST. EACH STEP IS ONE COMMAND FOR YOUR TERMINAL; COPY, RUN, COME BACK. WHEN THE MODEL EXISTS IN OLLAMA, @MADRE SWITCHES TO IT AT THE NEXT RECHECK AND EVERY AGENT IS TOLD TO ASK IT FIRST.');
3617
+ const steps = el('ol', 'train-steps');
3618
+ train.append(trainHead, trainNote, steps);
3619
+ const renderTraining = (payload) => {
3620
+ const t = payload?.training;
3621
+ steps.replaceChildren();
3622
+ if (!t) { steps.append(el('li', null, 'TRAINING INFO UNAVAILABLE')); return; }
3623
+ const quote = (path) => `"${path.replace(/\/$/, '')}"`;
3624
+ const items = [
3625
+ ['ONCE · A PYTHON ENVIRONMENT WITH MLX-LM', 'python3 -m venv ~/.madre-train && source ~/.madre-train/bin/activate && pip install mlx-lm'],
3626
+ [`TRAIN THE LORA · BASE ${t.baseModel} FOR ${t.memoryGb} GB`, `source ~/.madre-train/bin/activate && bash ${quote(`${t.recipeDir}train.sh`)} ${quote(t.roomDir)} ${t.baseModel}`],
3627
+ ['FUSE THE ADAPTER INTO THE BASE', `source ~/.madre-train/bin/activate && cd ${quote(t.roomDir)} && mlx_lm.fuse --model ${t.baseModel} --adapter-path adapters --save-path fused`],
3628
+ [`REGISTER IN OLLAMA AS ${t.modelName}`, `cd ${quote(t.roomDir)} && cp ${quote(`${t.recipeDir}Modelfile`)} . && ollama create ${t.modelName} -f Modelfile`],
3629
+ ];
3630
+ for (const [label, command] of items) {
3631
+ const li = el('li');
3632
+ const head = el('div', 'train-step-label', label);
3633
+ const row = el('div', 'update-command');
3634
+ const code = el('code', null, command);
3635
+ const copy = el('button', null, 'COPY'); copy.type = 'button';
3636
+ copy.addEventListener('click', async () => { try { await navigator.clipboard.writeText(command); copy.textContent = 'COPIED'; setTimeout(() => { copy.textContent = 'COPY'; }, 1400); } catch { toast('MU/TH/UR › select the command and copy it.'); } });
3637
+ row.append(code, copy);
3638
+ li.append(head, row);
3639
+ steps.append(li);
3640
+ }
3641
+ steps.append(el('li', 'note', `QWEN NEEDS A GGUF BEFORE OLLAMA READS IT: ${quote(`${t.recipeDir}README.md`)} · SECTION 3 HAS THE TWO LINES. THEN ASK @MADRE TEN THINGS THE ROOM DECIDED AND FIVE IT NEVER DISCUSSED BEFORE TRUSTING IT.`));
3642
+ };
3643
+ fetch('/api/dataset').then((response) => response.json()).then(renderTraining).catch(() => renderTraining(null));
3644
+ mform.append(train);
3526
3645
  if (mem.envWins) mform.append(el('span', 'note full', 'ENVIRONMENT VARIABLES ARE SET FOR MEMORY; THEY WIN OVER THESE VALUES ON THE NEXT LAUNCH.'));
3527
3646
  mform.addEventListener('submit', (event) => event.preventDefault());
3528
3647
  section.append(mform);
3529
3648
  }
3649
+
3650
+ // PRIVACY: terms that never travel through the room. Replaced at every hop: agent replies,
3651
+ // the index, the notes, the dataset. PURGE does the same to what the room already holds.
3652
+ const priv = data.settings.privacy;
3653
+ if (priv) {
3654
+ section.append(el('h3', null, `PRIVACY · ${priv.terms.length ? `${priv.terms.length} PRIVATE TERM${priv.terms.length === 1 ? '' : 'S'}` : 'NO PRIVATE TERMS'}`));
3655
+ section.append(el('p', 'note', 'AN AGENT\'S OWN CONFIGURATION CAN LEAK INTO ITS REPLY: A COMPANY, A BRAND, A DOMAIN. NAME THEM HERE AND MADRE REPLACES THEM BEFORE THE LEDGER, THE ARCHIVIST, THE OTHER AGENTS OR THE DATASET SEE THEM. THE TERMS STAY IN CONFIG.JSON; THE ROOM ONLY EVER RECORDS HOW MANY.'));
3656
+ const pform = el('form', 'room-form privacy-form');
3657
+ const field = (labelText, node) => { const label = el('label'); label.append(labelText); label.append(node); return label; };
3658
+ const terms = el('textarea'); terms.rows = 3; terms.value = priv.terms.join('\n'); terms.placeholder = 'one term per line · a company, a brand, a domain, a name'; terms.spellcheck = false;
3659
+ const marker = el('input'); marker.value = priv.marker; marker.maxLength = 40; marker.spellcheck = false;
3660
+ const exposure = el('span', 'note full', 'CHECKING THE ROOM…');
3661
+ const purge = el('button', null, 'PURGE ROOM');
3662
+ purge.type = 'button';
3663
+ purge.title = 'Replace every private term already in the ledger, the index and the memories with the marker. Asks for the project designation.';
3664
+ const showExposure = (payload) => {
3665
+ const x = payload?.exposure;
3666
+ if (!x) { exposure.textContent = ''; return; }
3667
+ const total = x.events + x.entries + x.memories;
3668
+ exposure.textContent = !payload.terms?.length ? 'WRITE THE TERMS FIRST.' : total ? `STILL IN THE ROOM: ${x.events} EVENT${x.events === 1 ? '' : 'S'} · ${x.entries} INDEXED EXCHANGE${x.entries === 1 ? '' : 'S'} · ${x.memories} MEMOR${x.memories === 1 ? 'Y' : 'IES'} · PURGE REPLACES THEM.` : 'THE ROOM IS CLEAN: NO PRIVATE TERM IN THE LEDGER, THE INDEX OR THE MEMORIES.';
3669
+ purge.disabled = !payload.terms?.length || !total;
3670
+ };
3671
+ const savePrivacy = async (patch, describe) => {
3672
+ try {
3673
+ const payload = await fetch('/api/privacy', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) }).then((response) => response.json());
3674
+ if (payload.error) throw new Error(payload.error);
3675
+ terms.value = payload.terms.join('\n'); marker.value = payload.marker; showExposure(payload);
3676
+ toast(`MU/TH/UR › ${describe(payload)}`);
3677
+ } catch (error) { toast(`Privacy setting was not saved: ${error.message}`); }
3678
+ };
3679
+ terms.addEventListener('change', () => savePrivacy({ terms: terms.value.split('\n') }, (payload) => `${payload.terms.length} private term${payload.terms.length === 1 ? '' : 's'} guarded from now on.`));
3680
+ marker.addEventListener('change', () => savePrivacy({ marker: marker.value }, (payload) => `private terms appear as ${payload.marker}.`));
3681
+ pform.append(field('PRIVATE TERMS · ONE PER LINE', terms));
3682
+ pform.append(field('REPLACED WITH', marker));
3683
+ const row = el('div', 'full dataset-row');
3684
+ purge.addEventListener('click', async () => {
3685
+ const designation = window.prompt('PURGE ROOM · Every private term already recorded becomes the marker, in the ledger, the index and the memories. This cannot be undone. Type the project designation to confirm:');
3686
+ if (designation === null) return;
3687
+ purge.disabled = true;
3688
+ try {
3689
+ const payload = await fetch('/api/privacy/purge', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ designation }) }).then((response) => response.json());
3690
+ if (payload.error) throw new Error(payload.error);
3691
+ toast(`MU/TH/UR › purged: ${payload.purged.events} events · ${payload.purged.entries} exchanges · ${payload.purged.memories} memories. Reloading.`);
3692
+ setTimeout(() => location.reload(), 1600);
3693
+ } catch (error) { toast(`Purge did not run: ${error.message}`); purge.disabled = false; }
3694
+ });
3695
+ row.append(purge, exposure);
3696
+ pform.append(row);
3697
+ if (priv.envWins) pform.append(el('span', 'note full', 'PULSE_PRIVATE_TERMS IS SET; THOSE TERMS ARE ADDED TO THIS LIST ON EVERY LAUNCH.'));
3698
+ pform.addEventListener('submit', (event) => event.preventDefault());
3699
+ section.append(pform);
3700
+ fetch('/api/privacy').then((response) => response.json()).then(showExposure).catch(() => { exposure.textContent = 'EXPOSURE CHECK UNAVAILABLE'; });
3701
+ }
3530
3702
  }
3531
3703
 
3532
3704
  // Save one setting the moment it changes, the way the scope boxes do; the
@@ -4336,6 +4508,60 @@ document.querySelector('#nostromo-forget')?.addEventListener('click', async (eve
4336
4508
  });
4337
4509
 
4338
4510
 
4511
+ /* ---------- Release channel: is there a newer MADRE? A pill in the bar, the command in MU/TH/UR. ---------- */
4512
+
4513
+ const updateUI = { section: document.querySelector('#mother-update'), pill: document.querySelector('#update-pill'), info: null, timer: null };
4514
+ async function loadVersion({ force = false } = {}) {
4515
+ try {
4516
+ updateUI.info = await fetch(`/api/version${force ? '?force=1' : ''}`).then((response) => response.json());
4517
+ } catch { updateUI.info = null; }
4518
+ renderUpdate();
4519
+ clearTimeout(updateUI.timer);
4520
+ updateUI.timer = setTimeout(() => void loadVersion(), 60 * 60 * 1000);
4521
+ updateUI.timer?.unref?.(); // a browser ignores this; the smoke test's Node must not stay alive for it
4522
+ }
4523
+ function renderUpdate() {
4524
+ const info = updateUI.info;
4525
+ const pill = updateUI.pill;
4526
+ if (pill) {
4527
+ pill.hidden = !info?.available;
4528
+ if (info?.available) { pill.replaceChildren(el('span', 'stop-all-glyph update-glyph'), `${info.latest} AVAILABLE`); pill.title = `MADRE ${info.latest} is on npm · you run ${info.current} · open MU/TH/UR for the command`; }
4529
+ }
4530
+ const section = updateUI.section;
4531
+ if (!section) return;
4532
+ section.replaceChildren();
4533
+ if (!info) { section.append(el('h3', null, 'RELEASE CHANNEL · UNAVAILABLE')); return; }
4534
+ const when = info.checkedAt ? new Date(info.checkedAt).toLocaleString() : null;
4535
+ section.append(el('h3', null, info.available ? `RELEASE CHANNEL · ${info.latest} AVAILABLE · YOU RUN ${info.current}` : `RELEASE CHANNEL · MADRE ${info.current}${info.latest ? ' · UP TO DATE' : info.enabled ? ' · NPM NOT REACHED YET' : ' · CHECK OFF'}`));
4536
+ if (info.available) {
4537
+ section.append(el('p', 'note', `A NEWER MADRE IS ON NPM. THIS COPY RUNS ${info.install === 'npx' ? 'FROM THE NPX CACHE' : info.install === 'project' ? 'FROM THIS PROJECT\'S NODE_MODULES' : info.install === 'global' ? 'AS A GLOBAL INSTALL' : 'FROM SOURCE'}; STOP THE ROOM, RUN THIS IN YOUR TERMINAL, START AGAIN. MADRE NEVER UPDATES ITSELF WHILE YOU WORK.`));
4538
+ const row = el('div', 'update-command');
4539
+ const code = el('code', null, info.command);
4540
+ const copy = el('button', null, 'COPY');
4541
+ copy.type = 'button';
4542
+ copy.addEventListener('click', async () => { try { await navigator.clipboard.writeText(info.command); copy.textContent = 'COPIED'; setTimeout(() => { copy.textContent = 'COPY'; }, 1400); } catch { toast('MU/TH/UR › select the command and copy it.'); } });
4543
+ row.append(code, copy);
4544
+ section.append(row);
4545
+ if (info.release) { const link = el('a', 'update-link', `WHAT ${info.latest} SHIPS ↗`); link.href = info.release; link.target = '_blank'; link.rel = 'noopener noreferrer'; section.append(link); }
4546
+ } else {
4547
+ section.append(el('p', 'note', `MADRE ASKS NPM FOR THE LATEST VERSION ONCE A DAY: THE PACKAGE NAME TRAVELS, NOTHING ELSE, THE SAME REQUEST NPX MAKES.${when ? ` LAST CHECK ${when.toUpperCase()}.` : ''}`));
4548
+ }
4549
+ const controls = el('div', 'sentinel-controls');
4550
+ const toggle = el('label', 'toggle');
4551
+ const box = el('input'); box.type = 'checkbox'; box.checked = Boolean(info.enabled); box.disabled = Boolean(info.envWins);
4552
+ box.addEventListener('change', async () => {
4553
+ box.disabled = true;
4554
+ try { await fetch('/api/updates/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ check: box.checked }) }); toast(`MU/TH/UR › release channel ${box.checked ? 'on: one check a day' : 'off: no request leaves for npm'}.`); await loadVersion(); }
4555
+ catch (error) { toast(`Setting was not saved: ${error.message}`); box.disabled = false; }
4556
+ });
4557
+ toggle.append(box, `CHECK NPM FOR NEW VERSIONS ONCE A DAY${info.envWins ? ' · SET BY PULSE_UPDATE_CHECK' : ''}`);
4558
+ controls.append(toggle);
4559
+ if (info.enabled) { const now = el('button', null, 'CHECK NOW'); now.type = 'button'; now.addEventListener('click', () => void loadVersion({ force: true })); controls.append(now); }
4560
+ section.append(controls);
4561
+ }
4562
+ updateUI.pill?.addEventListener('click', () => { document.querySelector('#mother-button')?.click(); updateUI.section?.scrollIntoView({ block: 'start', behavior: 'smooth' }); });
4563
+ void loadVersion();
4564
+
4339
4565
  /* ---------- MU/TH/UR: the sentinel. Unknown conditions and crashes, redacted, ready to report. ---------- */
4340
4566
 
4341
4567
  const sentinelUI = { section: document.querySelector('#mother-sentinel'), settings: null, feedbackUrl: null, loaded: false };
package/public/index.html CHANGED
@@ -23,6 +23,7 @@
23
23
  <div id="connection" class="connection" data-state="connecting" title="Live updates">
24
24
  <span class="connection-dot"></span><span class="connection-label">connecting</span>
25
25
  </div>
26
+ <button id="update-pill" class="stop-all update-pill" type="button" hidden title="A newer MADRE is on npm"></button>
26
27
  <button id="stop-all" class="stop-all" type="button" title="STOPALL · halt every plan and every agent turn" hidden>
27
28
  <span class="stop-all-glyph" aria-hidden="true"></span>STOP ALL
28
29
  </button>
@@ -74,6 +75,7 @@
74
75
  <button id="mother-close" class="mother-close" type="button">END SESSION ×</button>
75
76
  </span>
76
77
  </header>
78
+ <section id="mother-update" class="mother-section update"></section>
77
79
  <section id="mother-settings" class="mother-section settings" hidden></section>
78
80
  <section id="mother-sentinel" class="mother-section sentinel"></section>
79
81
  <pre id="mother-boot" class="mother-boot" aria-live="polite"></pre>
package/public/styles.css CHANGED
@@ -112,6 +112,8 @@ body {
112
112
  grid-template-rows: auto 1fr auto auto auto;
113
113
  }
114
114
  .bar, .onboarding, .composer, .safety, .thread-inner { width: var(--column); margin-left: auto; margin-right: auto; }
115
+ /* The bar is the ship's console: wider than the thread so the project root, the crew and the alerts share one line. */
116
+ .shell > .bar { width: min(1280px, 100% - 40px); }
115
117
  .shell > .bar { grid-row: 1; } .shell > .onboarding { grid-row: 2; } .shell > .thread { grid-row: 3; } .shell > .composer { grid-row: 4; } .shell > .safety { grid-row: 5; }
116
118
  .shell > .bar, .shell > .onboarding, .shell > .thread, .shell > .composer, .shell > .safety, .shell > .tree { grid-column: 1; }
117
119
  .visually-hidden { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@@ -124,7 +126,7 @@ body {
124
126
  font-family: var(--mono); font-size: 11px; letter-spacing: .04em;
125
127
  color: var(--text-2);
126
128
  }
127
- .bar-left { display: flex; align-items: center; gap: 14px; min-width: 0; }
129
+ .bar-left { display: flex; align-items: center; gap: 14px; min-width: 0; flex: 1 1 auto; }
128
130
  .brand { display: flex; align-items: center; gap: 10px; color: var(--text); font-weight: 700; letter-spacing: .22em; font-size: 11px; text-decoration: none; cursor: pointer; }
129
131
  .brand:hover .brand-mark { filter: drop-shadow(0 0 10px var(--phosphor)); } .brand:hover .brand-word { color: var(--phosphor); }
130
132
  .brand-mark { width: 64px; height: 18px; color: var(--phosphor); overflow: visible; filter: drop-shadow(0 0 6px color-mix(in srgb, var(--phosphor) 55%, transparent)); }
@@ -140,9 +142,23 @@ body {
140
142
  .brand-mark .beat-dot { animation: none; opacity: 1; }
141
143
  .brand-word { animation: none; opacity: 1; transform: none; }
142
144
  }
143
- .project { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-3); }
145
+ .project { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-3); }
146
+ .update-command { display: flex; align-items: center; gap: 10px; margin: 8px 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: color-mix(in srgb, var(--text) 4%, transparent); }
147
+ .update-command code { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; font-family: var(--mono); font-size: 11.5px; color: var(--text); }
148
+ .update-command button { font: inherit; font-family: var(--mono); font-size: 9.5px; letter-spacing: .14em; text-transform: uppercase; border: 1px solid var(--line); background: transparent; color: var(--text-2); border-radius: 8px; padding: 4px 10px; cursor: pointer; }
149
+ .mother-section.update .update-command { border: 1px solid var(--ph-dim); border-radius: 0; background: var(--ph-faint); margin: 8px 0 10px; }
150
+ .mother-section.update .update-command code { color: var(--ph); font-size: 11px; letter-spacing: .04em; }
151
+ .mother-section.update .update-command button { border-color: var(--ph-dim); color: var(--ph); border-radius: 0; }
152
+ .mother-section.update .update-link { color: var(--ph); }
153
+ .update-link { display: inline-block; margin: 2px 0 8px; font-family: var(--mono); font-size: 9.5px; letter-spacing: .14em; text-transform: uppercase; color: var(--phosphor); }
154
+ .train-card { margin-top: 8px; }
155
+ .train-head { font-family: var(--mono); font-size: 9.5px; letter-spacing: .16em; text-transform: uppercase; color: var(--text-2); }
156
+ .train-steps { margin: 6px 0 0; padding-left: 18px; }
157
+ .train-steps li { margin: 6px 0; }
158
+ .train-steps li.note { list-style: none; margin-left: -18px; }
159
+ .train-step-label { font-family: var(--mono); font-size: 9px; letter-spacing: .16em; text-transform: uppercase; color: var(--text-3); }
144
160
  .project::before { content: "/ "; }
145
- .agents { display: flex; gap: 8px; margin-left: auto; }
161
+ .agents { display: flex; gap: 8px; margin-left: 0; flex: none; }
146
162
  .connection { display: inline-flex; align-items: center; gap: 6px; color: var(--text-3); text-transform: uppercase; font-size: 10px; }
147
163
  .connection-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); }
148
164
  .connection[data-state="live"] .connection-dot { background: var(--phosphor); box-shadow: 0 0 10px var(--phosphor); }
@@ -315,6 +331,8 @@ dialog.nostromo::backdrop { background: #000; }
315
331
  .nostromo-empty { position: absolute; inset: 50% 0 auto 0; margin: 0; text-align: center; font-size: 11px; letter-spacing: .22em; color: var(--ph-dim); }
316
332
  @media (max-width: 720px) { .nostromo-head { grid-template-columns: auto 1fr auto; } .nostromo-legend { display: none; } .nostromo-card { right: 16px; left: 16px; width: auto; } }
317
333
  .system.memory.forgotten { color: var(--terror); }
334
+ .system.memory.privacy { color: var(--warn); }
335
+ .privacy-form textarea { min-height: 64px; resize: vertical; }
318
336
  .system.mother-alert { display: grid; gap: 6px; justify-items: center; max-width: min(720px, 92%); padding: 12px 16px; border: 1px solid color-mix(in srgb, var(--terror) 60%, transparent); border-radius: 12px; background: color-mix(in srgb, var(--terror) 7%, transparent); color: var(--terror); text-shadow: 0 0 8px color-mix(in srgb, var(--terror) 45%, transparent); }
319
337
  .system.mother-alert .head b { color: var(--terror); font-weight: 700; }
320
338
  .system.mother-alert .clear { color: var(--text); text-shadow: none; text-transform: none; letter-spacing: 0; font-size: 12px; }
@@ -439,8 +457,12 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
439
457
  .mother-os button { border: 0; background: transparent; color: var(--ph-dim); font: inherit; font-size: 10px; letter-spacing: .16em; padding: 6px 10px; cursor: pointer; }
440
458
  .mother-os button[aria-checked="true"] { background: var(--ph); color: #041004; text-shadow: none; }
441
459
  .mother-answer { margin: 14px 0 6px; font-size: 12px; letter-spacing: .14em; min-height: 1.4em; }
442
- .mother-section { margin-top: 22px; }
443
460
  .mother-section h3 { margin: 0 0 12px; font-size: 10px; letter-spacing: .28em; color: var(--ph-dim); font-weight: 400; }
461
+ /* Air between the blocks of one MU/TH/UR screen: every heading after the first starts a new block. */
462
+ .mother-section { margin-top: 34px; }
463
+ .mother-section h3:not(:first-child) { margin-top: 40px; padding-top: 22px; border-top: 1px solid var(--ph-faint); }
464
+ .mother-section h3.toggle:not(:first-child) { margin-top: 40px; }
465
+ .settings .room-form, .settings .conn-grid, .settings .mother-grid { margin-bottom: 8px; }
444
466
  .mother-section h3::before { content: "▌ "; color: var(--ph); }
445
467
  .mother-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr)); }
446
468
  .mother-card { border: 1px solid var(--ph-dim); padding: 14px 16px; background: rgba(2, 6, 2, .6); display: grid; gap: 8px; align-content: start; }
@@ -539,11 +561,16 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
539
561
  .system.plan .stop:disabled { opacity: .4; cursor: default; }
540
562
 
541
563
  /* ================= STOPALL ================= */
542
- .stop-all { display: inline-flex; align-items: center; gap: 7px; height: 26px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--line-2); background: transparent; color: var(--text-3); font-family: var(--mono); font-size: 10px; letter-spacing: .18em; cursor: default; opacity: .55; transition: color .3s, border-color .3s, opacity .3s; }
564
+ .stop-all { display: inline-flex; align-items: center; gap: 7px; height: 26px; padding: 0 10px; white-space: nowrap; flex: none; border-radius: 6px; border: 1px solid var(--line-2); background: transparent; color: var(--text-3); font-family: var(--mono); font-size: 10px; letter-spacing: .18em; cursor: default; opacity: .55; transition: color .3s, border-color .3s, opacity .3s; }
543
565
  .stop-all:disabled { cursor: default; }
544
566
  .stop-all.armed { opacity: 1; cursor: pointer; border-color: var(--terror); background: color-mix(in srgb, var(--terror) 12%, transparent); color: var(--terror); text-shadow: 0 0 8px color-mix(in srgb, var(--terror) 60%, transparent); animation: blink 1.4s infinite; }
545
567
  .stop-all.armed:hover { background: var(--terror); color: #fff; text-shadow: none; animation: none; }
546
568
  .stop-all-glyph { width: 8px; height: 8px; background: currentColor; }
569
+ .update-pill { opacity: 1; cursor: pointer; border-color: var(--warn); background: color-mix(in srgb, var(--warn) 12%, transparent); color: var(--warn); text-shadow: 0 0 8px color-mix(in srgb, var(--warn) 60%, transparent); animation: update-breathe 2.8s ease-in-out infinite; }
570
+ .update-pill:hover { background: var(--warn); color: #050605; text-shadow: none; animation: none; }
571
+ .update-pill .update-glyph { border-radius: 50%; }
572
+ @keyframes update-breathe { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 0%, transparent); } 50% { box-shadow: 0 0 14px 0 color-mix(in srgb, var(--warn) 45%, transparent); } }
573
+ @media (prefers-reduced-motion: reduce) { .update-pill { animation: none; } }
547
574
  .system.alert { color: var(--terror); text-shadow: 0 0 10px color-mix(in srgb, var(--terror) 70%, transparent); letter-spacing: .14em; animation: terror-in 1.4s steps(3, end) 1; }
548
575
  .system.alert b { color: var(--terror); }
549
576
  .system.alert .cmd { display: inline-block; margin-left: 8px; padding: 1px 8px; border: 1px solid var(--terror); letter-spacing: .2em; }
@@ -995,6 +1022,8 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
995
1022
  .bubble-actions .act { width: 26px; height: 26px; display: grid; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--text-3); cursor: pointer; padding: 0; transition: color .15s, background .15s; }
996
1023
  .bubble-actions .act svg { width: 15px; height: 15px; }
997
1024
  .bubble-actions .act:hover { color: var(--text); background: color-mix(in srgb, var(--text) 8%, transparent); }
1025
+ .bubble-actions .act.good.on { color: var(--phosphor); }
1026
+ .bubble-actions .act.bad.on { color: var(--terror); }
998
1027
  .bubble-actions .act.done { color: var(--phosphor); }
999
1028
  .reply-menu { width: min(280px, calc(100vw - 24px)); padding: 8px; }
1000
1029
  .reply-menu .model-menu-title { margin: 4px 8px 6px; }
@@ -1023,12 +1052,12 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1023
1052
  :root[data-scheme="light"] .crew-label { text-shadow: none; animation: none; }
1024
1053
 
1025
1054
  /* MU/TH/UR: the sentinel section. */
1026
- .mother-section.sentinel .sentinel-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin: 6px 0 12px; }
1027
- .mother-section.sentinel .toggle { display: flex; align-items: center; gap: 8px; font-size: 10px; letter-spacing: .16em; color: var(--ph-dim); cursor: pointer; }
1028
- .mother-section.sentinel .toggle input { accent-color: #9bff66; }
1055
+ .mother-section.sentinel .sentinel-controls, .mother-section.update .sentinel-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin: 6px 0 12px; }
1056
+ .mother-section.sentinel .toggle, .mother-section.update .toggle { display: flex; align-items: center; gap: 8px; font-size: 10px; letter-spacing: .16em; color: var(--ph-dim); cursor: pointer; }
1057
+ .mother-section.sentinel .toggle input, .mother-section.update .toggle input { accent-color: #9bff66; }
1029
1058
  .mother-section.sentinel .toggle input:disabled + * , .mother-section.sentinel .toggle:has(input:disabled) { opacity: .6; cursor: default; }
1030
- .mother-section.sentinel button { border: 1px solid var(--ph-dim); background: transparent; color: var(--ph); font: inherit; font-size: 9.5px; letter-spacing: .16em; padding: 6px 10px; cursor: pointer; }
1031
- .mother-section.sentinel button:hover { background: var(--ph-faint); }
1059
+ .mother-section.sentinel button, .mother-section.update button { border: 1px solid var(--ph-dim); background: transparent; color: var(--ph); font: inherit; font-size: 9.5px; letter-spacing: .16em; padding: 6px 10px; cursor: pointer; }
1060
+ .mother-section.sentinel button:hover, .mother-section.update button:hover { background: var(--ph-faint); }
1032
1061
  .mother-section.sentinel .sentinel-report.sent { opacity: .65; }
1033
1062
  .settings .memory-form .toggle { display: inline-flex; align-items: center; gap: 6px; margin-right: 14px; font-size: 9.5px; letter-spacing: .14em; color: var(--ph-dim); cursor: pointer; text-transform: uppercase; }
1034
1063
  .settings .memory-form .toggle input { accent-color: #9bff66; width: auto; }
@@ -1038,4 +1067,4 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1038
1067
  .module-card .ollama-status dt { color: var(--ph-dim); } .module-card .ollama-status dd { margin: 0; color: rgba(210, 255, 190, .85); overflow-wrap: anywhere; }
1039
1068
  .module-card .actions .toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 9.5px; letter-spacing: .14em; color: var(--ph-dim); cursor: pointer; }
1040
1069
  .module-card .actions .toggle input { accent-color: #9bff66; }
1041
- .mother-section.sentinel .note { margin: 0 0 10px; font-size: 10px; letter-spacing: .12em; line-height: 1.7; color: var(--ph-dim); }
1070
+ .mother-section.sentinel .note, .mother-section.update .note { margin: 0 0 10px; font-size: 10px; letter-spacing: .12em; line-height: 1.7; color: var(--ph-dim); }