@jossuealcala/madre 0.2.3 → 0.3.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/CONTRIBUTING.md +31 -2
  3. package/README.md +145 -187
  4. package/SECURITY.md +1 -1
  5. package/bin/madre.mjs +35 -2
  6. package/docs/INTERNALS.md +75 -0
  7. package/docs/training/Modelfile +5 -0
  8. package/docs/training/README.md +47 -0
  9. package/docs/training/train.sh +17 -0
  10. package/package.json +5 -3
  11. package/public/app.js +353 -5
  12. package/public/brands.js +18 -0
  13. package/public/index.html +2 -0
  14. package/public/styles.css +49 -11
  15. package/public/troubleshooting.js +31 -0
  16. package/src/adapters/madre.mjs +195 -0
  17. package/src/auth-probe.mjs +1 -0
  18. package/src/capabilities.mjs +1 -0
  19. package/src/conversation-context.mjs +4 -2
  20. package/src/dataset.mjs +127 -0
  21. package/src/distiller.mjs +16 -6
  22. package/src/embeddings.mjs +7 -1
  23. package/src/event-store.mjs +29 -1
  24. package/src/extensions.mjs +24 -224
  25. package/src/memory.mjs +64 -4
  26. package/src/modules/ahp.mjs +64 -0
  27. package/src/modules/ashcode.mjs +28 -0
  28. package/src/modules/git-pulse.mjs +26 -0
  29. package/src/modules/helpers.mjs +30 -0
  30. package/src/modules/image-studio.mjs +39 -0
  31. package/src/modules/index.mjs +21 -0
  32. package/src/modules/ollama.mjs +56 -0
  33. package/src/modules/ripley.mjs +20 -0
  34. package/src/modules/sdk.mjs +78 -0
  35. package/src/ollama.mjs +118 -0
  36. package/src/privacy.mjs +109 -0
  37. package/src/room/archivist.mjs +141 -0
  38. package/src/room/attachments.mjs +15 -0
  39. package/src/room/budget.mjs +83 -0
  40. package/src/room/context.mjs +31 -0
  41. package/src/room/control.mjs +66 -0
  42. package/src/room/escalation.mjs +41 -0
  43. package/src/room/ghost.mjs +32 -0
  44. package/src/room/guard.mjs +52 -0
  45. package/src/room/prompt.mjs +66 -0
  46. package/src/room/vectors.mjs +47 -0
  47. package/src/room.mjs +190 -392
  48. package/src/server.mjs +229 -41
  49. package/src/setup.mjs +25 -7
  50. package/src/updates.mjs +79 -0
package/public/app.js CHANGED
@@ -45,6 +45,7 @@ const PLACEHOLDERS = {
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.',
47
47
  expendable: 'Type here, human. MOTHER is listening.',
48
+ memory: 'Ask what the room remembers. @madre answers from memory with citations; it does not act.',
48
49
  };
49
50
  let winkTimer = null;
50
51
  const LINE_PX = 21;
@@ -63,7 +64,9 @@ const state = {
63
64
  budget: null,
64
65
  timeouts: {},
65
66
  seen: new Set(),
66
- 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 }
67
70
  lastSequence: 0,
68
71
  lastSender: null, // for iMessage-style grouping of consecutive bubbles
69
72
  failures: [], // recorded conditions for MU/TH/UR
@@ -727,6 +730,11 @@ function renderPicker() {
727
730
  const text = paint(el('span', 'pick-label'), current.id);
728
731
  text.append('to ');
729
732
  text.append(el('b', null, `@${current.id}`));
733
+ if (current.local) {
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.';
736
+ text.append(note);
737
+ }
730
738
  const modeChip = el('button', `mode-chip m${state.mode}`);
731
739
  modeChip.type = 'button';
732
740
  modeChip.append(el('b', null, `#${state.mode}`), `${MODES[state.mode].label} ▾`);
@@ -1053,7 +1061,7 @@ function renderAssistantMessage(event) {
1053
1061
  bubble.append(renderMarkdown(text));
1054
1062
  if (originalText) bubble.append(ashOriginal(originalText, ashCode));
1055
1063
  if (event.payload.artifacts?.length) bubble.append(artifactTiles(event.payload.artifacts));
1056
- 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 }));
1057
1065
  col.append(bubble);
1058
1066
  const stamp = el('div', 'stamp');
1059
1067
  stamp.id = `usage-${messageId}`;
@@ -1076,7 +1084,27 @@ function iconButton(svg, title, className) {
1076
1084
  button.innerHTML = svg;
1077
1085
  return button;
1078
1086
  }
1079
- 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 }) {
1080
1108
  const bar = el('div', 'bubble-actions');
1081
1109
  const copy = iconButton(ICON_COPY, 'Copy this reply', 'copy');
1082
1110
  copy.addEventListener('click', async (event) => {
@@ -1096,6 +1124,11 @@ function bubbleActions({ text, sender, sequence }) {
1096
1124
  const rect = reply.getBoundingClientRect();
1097
1125
  showReplyMenu({ text, sender, sequence }, rect.left, rect.bottom + 6);
1098
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
+ }
1099
1132
  bar.append(copy, reply);
1100
1133
  return bar;
1101
1134
  }
@@ -1245,6 +1278,20 @@ function renderThinking(event) {
1245
1278
  }
1246
1279
 
1247
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
+
1248
1295
  function renderDistilled(event) {
1249
1296
  const { agent, added, considered, fromSequence, throughSequence, remaining, error, skipped, total, next } = event.payload;
1250
1297
  const node = el('div', `system memory${error ? ' warn' : ''}`);
@@ -1769,8 +1816,26 @@ function renderEventNode(event) {
1769
1816
  case 'message.failed': state.running.delete(event.payload.messageId); updateStopAll(); node = renderFailure(event); break;
1770
1817
  case 'handoff.created': node = renderHandoff(event); break;
1771
1818
  case 'memory.distilled': node = renderDistilled(event); break;
1819
+ 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; }
1820
+ case 'privacy.redacted': node = renderPrivacy(event); break;
1821
+ case 'privacy.purged': node = renderPrivacy(event); break;
1822
+ 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;
1772
1823
  case 'memory.forgotten': node = renderForgotten(event); break;
1773
1824
  case 'memory.noted': attachMemoryHint(event); return;
1825
+ case 'dataset.exported': return;
1826
+ case 'agents.updated': {
1827
+ for (const agent of event.payload.agents ?? []) {
1828
+ const known = state.agents.get(agent.id);
1829
+ state.agents.set(agent.id, { ...(known ?? { tokens: 0, rawTokens: 0, windowMs: null, rollsOverAt: null, officialPercent: null, officialResetAt: null, officialWindows: null }), ...agent });
1830
+ const option = [...els.target.options].find((item) => item.value === agent.id);
1831
+ if (agent.ready && !option) els.target.add(new Option(agent.label, agent.id));
1832
+ if (!agent.ready && option) option.remove();
1833
+ }
1834
+ for (const id of event.payload.removed ?? []) { state.agents.delete(id); [...els.target.options].find((item) => item.value === id)?.remove(); }
1835
+ renderAgents(); renderPicker(); renderOnboarding();
1836
+ if (!replaying && event.payload.reason) toast(`MU/TH/UR › ${event.payload.reason}`);
1837
+ return;
1838
+ }
1774
1839
  case 'mother.alert': node = renderMotherAlert(event); break;
1775
1840
  case 'sentinel.report': state.reports.set(event.payload.id, { ...event.payload }); if (!replaying) { renderMotherSentinel(); toast(`MU/TH/UR › ${event.payload.kind === 'crash' ? 'a crash' : 'an unknown condition'} was recorded by the sentinel. Open MU/TH/UR to report it.`); } return;
1776
1841
  case 'sentinel.sent': { const report = state.reports.get(event.payload.id); if (report) report.sent = { ok: event.payload.ok, status: event.payload.status ?? null, error: event.payload.error ?? null, at: event.timestamp }; if (!replaying) renderMotherSentinel(); return; }
@@ -2322,7 +2387,8 @@ function updateCrewLabel() {
2322
2387
  : state.expendable ? 'CREW · EXPENDABLE ›' : 'HUMAN ›';
2323
2388
  }
2324
2389
  function updatePlaceholder() {
2325
- els.input.placeholder = state.mode === 3 ? PLACEHOLDERS.control : state.mode === 0 ? PLACEHOLDERS.ghost : state.create ? PLACEHOLDERS.create : (state.ashCode && state.ashCodeInstalled) ? PLACEHOLDERS.order : state.expendable ? PLACEHOLDERS.expendable : PLACEHOLDERS.plain;
2390
+ const local = Boolean(state.agents.get(els.target.value)?.local);
2391
+ els.input.placeholder = state.mode === 3 ? PLACEHOLDERS.control : state.mode === 0 ? PLACEHOLDERS.ghost : local ? PLACEHOLDERS.memory : state.create ? PLACEHOLDERS.create : (state.ashCode && state.ashCodeInstalled) ? PLACEHOLDERS.order : state.expendable ? PLACEHOLDERS.expendable : PLACEHOLDERS.plain;
2326
2392
  }
2327
2393
  function setOrder937(on, { wink = false } = {}) {
2328
2394
  state.ashCode = on;
@@ -2633,6 +2699,8 @@ function renderMotherRecorded() {
2633
2699
  jump.type = 'button';
2634
2700
  jump.addEventListener('click', () => {
2635
2701
  mother.input.value = '';
2702
+ // Choosing a fix from the log is a request to see it: the list opens and stays open.
2703
+ try { localStorage.setItem('pulse.mother.known', 'expanded'); } catch { /* no storage */ }
2636
2704
  renderMotherKnown(CONDITIONS, new Set(matches.map((item) => item.id)), failure.agent);
2637
2705
  document.getElementById(`mother-${condition.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
2638
2706
  });
@@ -2645,7 +2713,23 @@ function renderMotherRecorded() {
2645
2713
 
2646
2714
  function renderMotherKnown(list = CONDITIONS, hits = new Set(), agent = null) {
2647
2715
  mother.known.replaceChildren();
2648
- mother.known.append(el('h3', null, `KNOWN CONDITIONS · ${list.length} OF ${CONDITIONS.length} · ${PLATFORMS[mother.platform].label.toUpperCase()} / ${PLATFORMS[mother.platform].shell.toUpperCase()}`));
2716
+ let collapsed = false;
2717
+ try { collapsed = localStorage.getItem('pulse.mother.known') === 'collapsed'; } catch { /* no storage */ }
2718
+ // An inquiry that narrowed the list, or a fix chosen from the log, always shows its answer, whatever the stored state.
2719
+ if (list.length !== CONDITIONS.length || hits.size) collapsed = false;
2720
+ const head = el('h3', 'toggle');
2721
+ const headButton = el('button', null, `KNOWN CONDITIONS · ${list.length} OF ${CONDITIONS.length} · ${PLATFORMS[mother.platform].label.toUpperCase()} / ${PLATFORMS[mother.platform].shell.toUpperCase()}`);
2722
+ headButton.type = 'button';
2723
+ headButton.setAttribute('aria-expanded', String(!collapsed));
2724
+ headButton.append(el('span', 'caret', collapsed ? '▸ EXPAND' : '▾ COLLAPSE'));
2725
+ headButton.addEventListener('click', () => {
2726
+ try { localStorage.setItem('pulse.mother.known', collapsed ? 'expanded' : 'collapsed'); } catch { /* no storage */ }
2727
+ renderMotherKnown(list, hits, agent);
2728
+ });
2729
+ head.append(headButton);
2730
+ mother.known.append(head);
2731
+ mother.known.classList.toggle('collapsed', collapsed);
2732
+ if (collapsed) return;
2649
2733
  const grid = el('div', 'mother-grid');
2650
2734
  const ordered = [...list].sort((a, b) => Number(hits.has(b.id)) - Number(hits.has(a.id)));
2651
2735
  for (const condition of ordered) grid.append(conditionCard(condition, { hit: hits.has(condition.id), agent: agent && condition.perAgent ? agent : null, hintAgent: agent }));
@@ -2813,6 +2897,61 @@ function builtinCard(item) {
2813
2897
  for (const line of item.creates ?? []) list.append(el('li', null, line));
2814
2898
  for (const line of item.requires ?? []) list.append(el('li', null, `requires ${line}`));
2815
2899
  card.append(list);
2900
+ if (item.id === 'ollama') {
2901
+ const info = item.ollama ?? { running: false, models: [], settings: {} };
2902
+ const status = el('dl', 'ollama-status');
2903
+ const put = (k, v) => { status.append(el('dt', null, k), el('dd', null, v)); };
2904
+ put('SERVER', info.running ? `running · ${info.host}` : 'not running');
2905
+ put('EMBEDDINGS', info.embedModel ? `${info.embedModel}${info.settings.embeddings === false ? ' · off' : ''}` : 'no embedding model');
2906
+ put('ARCHIVIST', info.chatModel ? `${info.chatModel}${info.settings.archivist === false ? ' · off' : ''}` : 'no chat model');
2907
+ if (info.models?.length) put('MODELS', info.models.map((model) => model.name).join(', '));
2908
+ card.append(status);
2909
+ const actions = el('div', 'actions');
2910
+ const recheck = el('button', null, 'RECHECK');
2911
+ recheck.type = 'button';
2912
+ recheck.addEventListener('click', async () => { recheck.disabled = true; await fetch('/api/ollama/probe', { method: 'POST' }).catch(() => null); await refreshModules(); });
2913
+ actions.append(recheck);
2914
+ if (info.running) {
2915
+ const toggle = el('button', on ? null : 'primary', info.settings.enabled === false ? 'ENABLE OLLAMA' : 'DISABLE OLLAMA');
2916
+ toggle.type = 'button';
2917
+ toggle.addEventListener('click', async () => {
2918
+ toggle.disabled = true;
2919
+ try {
2920
+ const response = await fetch('/api/extensions/ollama/install', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
2921
+ const result = await response.json().catch(() => ({}));
2922
+ if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
2923
+ toast(result.enabled ? 'MU/TH/UR › OLLAMA ON · memory embeds and distils on this machine.' : 'MU/TH/UR › OLLAMA OFF · back to the providers.');
2924
+ await refreshModules();
2925
+ } catch (error) { toast(`Ollama could not change state: ${error.message}`); toggle.disabled = false; }
2926
+ });
2927
+ actions.append(toggle);
2928
+ for (const [role, model, present] of [['embeddings', item.recommended?.embed ?? 'nomic-embed-text', Boolean(info.embedModel)], ['archivist', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel)], ['agent', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel)]]) {
2929
+ if (present) {
2930
+ const box = el('label', 'toggle');
2931
+ const input = el('input'); input.type = 'checkbox'; input.checked = info.settings[role] !== false;
2932
+ input.addEventListener('change', async () => { input.disabled = true; await fetch('/api/ollama/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ [role]: input.checked }) }).catch(() => null); await refreshModules(); });
2933
+ box.append(input, role === 'agent' ? '@MADRE IN THE ROOM' : role.toUpperCase());
2934
+ actions.append(box);
2935
+ } else {
2936
+ const pull = el('button', 'primary', `PULL ${model}`);
2937
+ pull.type = 'button';
2938
+ pull.title = `Download ${model} into Ollama for ${role}`;
2939
+ pull.addEventListener('click', async () => {
2940
+ pull.disabled = true;
2941
+ const response = await fetch('/api/ollama/pull', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model }) });
2942
+ const result = await response.json().catch(() => ({}));
2943
+ if (!response.ok) { toast(`MU/TH/UR › ${result.error ?? 'could not pull'}`); pull.disabled = false; }
2944
+ else toast(`MU/TH/UR › pulling ${model}; progress shows in the room.`);
2945
+ });
2946
+ actions.append(pull);
2947
+ }
2948
+ }
2949
+ } else {
2950
+ card.append(el('p', 'confirm', 'Start Ollama (the app, or `ollama serve` in a terminal), then RECHECK. Without it the room keeps using its providers.'));
2951
+ }
2952
+ card.append(actions);
2953
+ return card;
2954
+ }
2816
2955
  if (item.id === 'ripley') {
2817
2956
  const actions = el('div', 'actions');
2818
2957
  const toggle = el('button', on ? null : 'primary', on ? 'DISABLE RIPLEY' : 'ENABLE RIPLEY');
@@ -3385,6 +3524,161 @@ function renderSettings() {
3385
3524
  save.disabled = false;
3386
3525
  });
3387
3526
  section.append(form);
3527
+
3528
+ // MEMORY: who distils, with whom, how often, where it embeds, how much recall a turn gets. Saves as you change it.
3529
+ const mem = data.settings.memory;
3530
+ if (mem) {
3531
+ section.append(el('h3', null, `MEMORY · ${mem.stats ? `${mem.stats.entries} EXCHANGES · ${mem.stats.memories} MEMORIES · ${mem.stats.pending} WAITING` : 'NO INDEX'}`));
3532
+ section.append(el('p', 'note', 'THE ARCHIVIST READS WHAT NOBODY HAS DISTILLED AND KEEPS THE FEW NOTES WORTH REMEMBERING. THE CHEAPEST ALLOWED AGENT GOES FIRST; A LOCAL MODEL COSTS NOTHING AND KEEPS EVERYTHING ON THIS MACHINE.'));
3533
+ const mform = el('form', 'room-form memory-form');
3534
+ const save = async (memoryPatch, describe) => { try { await saveSettingNow({ memory: memoryPatch }, describe); await loadSettings(); } catch (error) { toast(`Memory setting was not saved: ${error.message}`); } };
3535
+ const field = (labelText, node) => { const label = el('label'); label.append(labelText); label.append(node); return label; };
3536
+ const archivist = el('select');
3537
+ for (const [value, text] of [['auto', 'AUTO · cheapest allowed'], ...mem.candidates.map((c) => [c.id, `@${c.id}${c.local ? ` · ${c.label}` : ''}`])]) { const option = el('option', null, text); option.value = value; if (value === mem.archivist) option.selected = true; archivist.append(option); }
3538
+ archivist.addEventListener('change', () => save({ archivist: archivist.value }, archivist.value === 'auto' ? 'archivist: the cheapest allowed agent goes first.' : `archivist: @${archivist.value} distils first.`));
3539
+ mform.append(field('ARCHIVIST', archivist));
3540
+ const every = el('input'); every.type = 'number'; every.min = '1'; every.step = '1'; every.value = String(mem.every);
3541
+ wireInstantNumber(every, { min: 1, toPatch: (value) => ({ memory: { every: value } }), describe: (value) => `distil every ${value} exchange${value === 1 ? '' : 's'}.` });
3542
+ mform.append(field('DISTIL EVERY · EXCHANGES', every));
3543
+ const idle = el('input'); idle.type = 'number'; idle.min = '1'; idle.step = '1'; idle.value = String(mem.idleMinutes);
3544
+ wireInstantNumber(idle, { min: 1, toPatch: (value) => ({ memory: { idleMinutes: value } }), describe: (value) => `or after ${value} quiet minute${value === 1 ? '' : 's'}.` });
3545
+ mform.append(field('OR AFTER · QUIET MINUTES', idle));
3546
+ const share = el('input'); share.type = 'number'; share.min = '0'; share.max = '60'; share.step = '5'; share.value = String(Math.round(mem.recallShare * 100));
3547
+ wireInstantNumber(share, { min: 0, toPatch: (value) => ({ memory: { recallShare: Math.min(60, value) / 100 } }), describe: (value) => `recall may take ${Math.min(60, value)}% of each turn's context.` });
3548
+ mform.append(field('RECALL · % OF CONTEXT', share));
3549
+ const embed = el('select');
3550
+ const embedOptions = [['auto', 'AUTO · Ollama if running, else Gemini'], ['ollama', `OLLAMA · local${mem.ollama.embedModel ? ` · ${mem.ollama.embedModel}` : ' · no model yet'}`], ['gemini', 'GEMINI · needs your key'], ['off', 'OFF · words only']];
3551
+ for (const [value, text] of embedOptions) { const option = el('option', null, text); option.value = value; if (value === (mem.embedProvider ?? data.config?.memory?.embedProvider ?? 'auto')) option.selected = true; embed.append(option); }
3552
+ embed.addEventListener('change', () => save({ embedProvider: embed.value }, `embeddings: ${embed.options[embed.selectedIndex].textContent.toLowerCase()}.`));
3553
+ const embedLabel = field(`EMBEDDINGS · NOW ${mem.embedder ? mem.embedder.toUpperCase() : 'OFF'}`, embed);
3554
+ mform.append(embedLabel);
3555
+ const who = el('div', 'full');
3556
+ who.append(el('span', 'note', 'MAY DISTIL:'));
3557
+ const allowed = new Set(mem.archivists ?? mem.candidates.map((c) => c.id));
3558
+ for (const candidate of mem.candidates) {
3559
+ const toggle = el('label', 'toggle');
3560
+ const box = el('input'); box.type = 'checkbox'; box.checked = allowed.has(candidate.id);
3561
+ box.addEventListener('change', () => {
3562
+ if (box.checked) allowed.add(candidate.id); else allowed.delete(candidate.id);
3563
+ if (!allowed.size) { box.checked = true; allowed.add(candidate.id); toast('MU/TH/UR › someone has to keep the archive.'); return; }
3564
+ void save({ archivists: allowed.size === mem.candidates.length ? [] : [...allowed] }, `archivists: ${[...allowed].map((id) => `@${id}`).join(', ')}.`);
3565
+ });
3566
+ toggle.append(box, `@${candidate.id.toUpperCase()}${candidate.local ? ' · LOCAL · FREE' : ''}`);
3567
+ who.append(toggle);
3568
+ }
3569
+ mform.append(who);
3570
+ // The dataset behind MADRE AI: export what the room kept, train outside, @madre picks the result up.
3571
+ const dataset = el('div', 'full dataset-row');
3572
+ const exportButton = el('button', null, 'EXPORT DATASET');
3573
+ exportButton.type = 'button';
3574
+ exportButton.title = 'Write train.jsonl and valid.jsonl next to the ledger, redacted, in chat format for mlx-lm';
3575
+ const datasetNote = el('span', 'note', 'LOADING…');
3576
+ const showDataset = (payload) => {
3577
+ const d = payload?.dataset;
3578
+ const r = payload?.readiness;
3579
+ 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` : ''}` : '';
3580
+ const exported = d ? `LAST EXPORT ${new Date(d.exportedAt).toLocaleString()} · TRAIN ${d.train} · VALID ${d.valid}` : 'NOT EXPORTED YET';
3581
+ const trained = payload?.trained ? `TRAINED MODEL ${payload.trained.toUpperCase()} IN USE` : 'NO TRAINED MODEL YET · SEE docs/training';
3582
+ datasetNote.textContent = [live, exported, trained].filter(Boolean).join(' · ');
3583
+ };
3584
+ fetch('/api/dataset').then((response) => response.json()).then(showDataset).catch(() => { datasetNote.textContent = 'DATASET UNAVAILABLE'; });
3585
+ exportButton.addEventListener('click', async () => {
3586
+ exportButton.disabled = true;
3587
+ try { const payload = await fetch('/api/dataset', { method: 'POST' }).then((response) => response.json()); showDataset(payload); toast(`MU/TH/UR › dataset exported: ${payload.dataset.pairs} pairs in ${payload.dir}`); }
3588
+ catch (error) { toast(`Dataset export failed: ${error.message}`); }
3589
+ finally { exportButton.disabled = false; }
3590
+ });
3591
+ dataset.append(exportButton, datasetNote);
3592
+ mform.append(dataset);
3593
+ // TRAIN: the recipe, with this room's paths and this project's model name filled in. Training runs outside MADRE.
3594
+ const train = el('div', 'full train-card');
3595
+ const trainHead = el('div', 'train-head', 'TRAIN MADRE AI · LOCAL, WITH MLX ON APPLE SILICON · NOTHING LEAVES THIS MACHINE');
3596
+ 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.');
3597
+ const steps = el('ol', 'train-steps');
3598
+ train.append(trainHead, trainNote, steps);
3599
+ const renderTraining = (payload) => {
3600
+ const t = payload?.training;
3601
+ steps.replaceChildren();
3602
+ if (!t) { steps.append(el('li', null, 'TRAINING INFO UNAVAILABLE')); return; }
3603
+ const quote = (path) => `"${path.replace(/\/$/, '')}"`;
3604
+ const items = [
3605
+ ['ONCE · A PYTHON ENVIRONMENT WITH MLX-LM', 'python3 -m venv ~/.madre-train && source ~/.madre-train/bin/activate && pip install mlx-lm'],
3606
+ [`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}`],
3607
+ ['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`],
3608
+ [`REGISTER IN OLLAMA AS ${t.modelName}`, `cd ${quote(t.roomDir)} && cp ${quote(`${t.recipeDir}Modelfile`)} . && ollama create ${t.modelName} -f Modelfile`],
3609
+ ];
3610
+ for (const [label, command] of items) {
3611
+ const li = el('li');
3612
+ const head = el('div', 'train-step-label', label);
3613
+ const row = el('div', 'update-command');
3614
+ const code = el('code', null, command);
3615
+ const copy = el('button', null, 'COPY'); copy.type = 'button';
3616
+ 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.'); } });
3617
+ row.append(code, copy);
3618
+ li.append(head, row);
3619
+ steps.append(li);
3620
+ }
3621
+ 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.`));
3622
+ };
3623
+ fetch('/api/dataset').then((response) => response.json()).then(renderTraining).catch(() => renderTraining(null));
3624
+ mform.append(train);
3625
+ if (mem.envWins) mform.append(el('span', 'note full', 'ENVIRONMENT VARIABLES ARE SET FOR MEMORY; THEY WIN OVER THESE VALUES ON THE NEXT LAUNCH.'));
3626
+ mform.addEventListener('submit', (event) => event.preventDefault());
3627
+ section.append(mform);
3628
+ }
3629
+
3630
+ // PRIVACY: terms that never travel through the room. Replaced at every hop: agent replies,
3631
+ // the index, the notes, the dataset. PURGE does the same to what the room already holds.
3632
+ const priv = data.settings.privacy;
3633
+ if (priv) {
3634
+ section.append(el('h3', null, `PRIVACY · ${priv.terms.length ? `${priv.terms.length} PRIVATE TERM${priv.terms.length === 1 ? '' : 'S'}` : 'NO PRIVATE TERMS'}`));
3635
+ 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.'));
3636
+ const pform = el('form', 'room-form privacy-form');
3637
+ const field = (labelText, node) => { const label = el('label'); label.append(labelText); label.append(node); return label; };
3638
+ 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;
3639
+ const marker = el('input'); marker.value = priv.marker; marker.maxLength = 40; marker.spellcheck = false;
3640
+ const exposure = el('span', 'note full', 'CHECKING THE ROOM…');
3641
+ const purge = el('button', null, 'PURGE ROOM');
3642
+ purge.type = 'button';
3643
+ purge.title = 'Replace every private term already in the ledger, the index and the memories with the marker. Asks for the project designation.';
3644
+ const showExposure = (payload) => {
3645
+ const x = payload?.exposure;
3646
+ if (!x) { exposure.textContent = ''; return; }
3647
+ const total = x.events + x.entries + x.memories;
3648
+ 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.';
3649
+ purge.disabled = !payload.terms?.length || !total;
3650
+ };
3651
+ const savePrivacy = async (patch, describe) => {
3652
+ try {
3653
+ const payload = await fetch('/api/privacy', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) }).then((response) => response.json());
3654
+ if (payload.error) throw new Error(payload.error);
3655
+ terms.value = payload.terms.join('\n'); marker.value = payload.marker; showExposure(payload);
3656
+ toast(`MU/TH/UR › ${describe(payload)}`);
3657
+ } catch (error) { toast(`Privacy setting was not saved: ${error.message}`); }
3658
+ };
3659
+ terms.addEventListener('change', () => savePrivacy({ terms: terms.value.split('\n') }, (payload) => `${payload.terms.length} private term${payload.terms.length === 1 ? '' : 's'} guarded from now on.`));
3660
+ marker.addEventListener('change', () => savePrivacy({ marker: marker.value }, (payload) => `private terms appear as ${payload.marker}.`));
3661
+ pform.append(field('PRIVATE TERMS · ONE PER LINE', terms));
3662
+ pform.append(field('REPLACED WITH', marker));
3663
+ const row = el('div', 'full dataset-row');
3664
+ purge.addEventListener('click', async () => {
3665
+ 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:');
3666
+ if (designation === null) return;
3667
+ purge.disabled = true;
3668
+ try {
3669
+ const payload = await fetch('/api/privacy/purge', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ designation }) }).then((response) => response.json());
3670
+ if (payload.error) throw new Error(payload.error);
3671
+ toast(`MU/TH/UR › purged: ${payload.purged.events} events · ${payload.purged.entries} exchanges · ${payload.purged.memories} memories. Reloading.`);
3672
+ setTimeout(() => location.reload(), 1600);
3673
+ } catch (error) { toast(`Purge did not run: ${error.message}`); purge.disabled = false; }
3674
+ });
3675
+ row.append(purge, exposure);
3676
+ pform.append(row);
3677
+ if (priv.envWins) pform.append(el('span', 'note full', 'PULSE_PRIVATE_TERMS IS SET; THOSE TERMS ARE ADDED TO THIS LIST ON EVERY LAUNCH.'));
3678
+ pform.addEventListener('submit', (event) => event.preventDefault());
3679
+ section.append(pform);
3680
+ fetch('/api/privacy').then((response) => response.json()).then(showExposure).catch(() => { exposure.textContent = 'EXPOSURE CHECK UNAVAILABLE'; });
3681
+ }
3388
3682
  }
3389
3683
 
3390
3684
  // Save one setting the moment it changes, the way the scope boxes do; the
@@ -4194,6 +4488,60 @@ document.querySelector('#nostromo-forget')?.addEventListener('click', async (eve
4194
4488
  });
4195
4489
 
4196
4490
 
4491
+ /* ---------- Release channel: is there a newer MADRE? A pill in the bar, the command in MU/TH/UR. ---------- */
4492
+
4493
+ const updateUI = { section: document.querySelector('#mother-update'), pill: document.querySelector('#update-pill'), info: null, timer: null };
4494
+ async function loadVersion({ force = false } = {}) {
4495
+ try {
4496
+ updateUI.info = await fetch(`/api/version${force ? '?force=1' : ''}`).then((response) => response.json());
4497
+ } catch { updateUI.info = null; }
4498
+ renderUpdate();
4499
+ clearTimeout(updateUI.timer);
4500
+ updateUI.timer = setTimeout(() => void loadVersion(), 60 * 60 * 1000);
4501
+ updateUI.timer?.unref?.(); // a browser ignores this; the smoke test's Node must not stay alive for it
4502
+ }
4503
+ function renderUpdate() {
4504
+ const info = updateUI.info;
4505
+ const pill = updateUI.pill;
4506
+ if (pill) {
4507
+ pill.hidden = !info?.available;
4508
+ 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`; }
4509
+ }
4510
+ const section = updateUI.section;
4511
+ if (!section) return;
4512
+ section.replaceChildren();
4513
+ if (!info) { section.append(el('h3', null, 'RELEASE CHANNEL · UNAVAILABLE')); return; }
4514
+ const when = info.checkedAt ? new Date(info.checkedAt).toLocaleString() : null;
4515
+ 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'}`));
4516
+ if (info.available) {
4517
+ 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.`));
4518
+ const row = el('div', 'update-command');
4519
+ const code = el('code', null, info.command);
4520
+ const copy = el('button', null, 'COPY');
4521
+ copy.type = 'button';
4522
+ 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.'); } });
4523
+ row.append(code, copy);
4524
+ section.append(row);
4525
+ 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); }
4526
+ } else {
4527
+ 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()}.` : ''}`));
4528
+ }
4529
+ const controls = el('div', 'sentinel-controls');
4530
+ const toggle = el('label', 'toggle');
4531
+ const box = el('input'); box.type = 'checkbox'; box.checked = Boolean(info.enabled); box.disabled = Boolean(info.envWins);
4532
+ box.addEventListener('change', async () => {
4533
+ box.disabled = true;
4534
+ 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(); }
4535
+ catch (error) { toast(`Setting was not saved: ${error.message}`); box.disabled = false; }
4536
+ });
4537
+ toggle.append(box, `CHECK NPM FOR NEW VERSIONS ONCE A DAY${info.envWins ? ' · SET BY PULSE_UPDATE_CHECK' : ''}`);
4538
+ controls.append(toggle);
4539
+ if (info.enabled) { const now = el('button', null, 'CHECK NOW'); now.type = 'button'; now.addEventListener('click', () => void loadVersion({ force: true })); controls.append(now); }
4540
+ section.append(controls);
4541
+ }
4542
+ updateUI.pill?.addEventListener('click', () => { document.querySelector('#mother-button')?.click(); updateUI.section?.scrollIntoView({ block: 'start', behavior: 'smooth' }); });
4543
+ void loadVersion();
4544
+
4197
4545
  /* ---------- MU/TH/UR: the sentinel. Unknown conditions and crashes, redacted, ready to report. ---------- */
4198
4546
 
4199
4547
  const sentinelUI = { section: document.querySelector('#mother-sentinel'), settings: null, feedbackUrl: null, loaded: false };
package/public/brands.js CHANGED
@@ -47,6 +47,24 @@ export const BRANDS = {
47
47
  },
48
48
  };
49
49
 
50
+ BRANDS.madre = {
51
+ label: 'MADRE',
52
+ vendor: 'the room · local',
53
+ color: '#9bff66',
54
+ colorLight: '#2f8a1c',
55
+ ink: '#0a1405',
56
+ mark: () => glyph('<path d="M4 16 H11 L13 16 L15 9 L18 23 L20 16 L22 16 L23.5 13 L25 16 H28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'),
57
+ };
58
+
59
+ BRANDS.ollama = {
60
+ label: 'Ollama',
61
+ vendor: 'local',
62
+ color: '#E6E6E6',
63
+ colorLight: '#3A3A3A',
64
+ ink: '#111111',
65
+ mark: () => glyph('<circle cx="16" cy="16" r="9" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12.5" cy="15" r="1.6" fill="currentColor"/><circle cx="19.5" cy="15" r="1.6" fill="currentColor"/>'),
66
+ };
67
+
50
68
  export const fallbackBrand = (id) => ({
51
69
  label: id,
52
70
  vendor: '',
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>