@jossuealcala/madre 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -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;
@@ -309,7 +310,7 @@ viewerUI.review?.addEventListener('click', (event) => {
309
310
  if (viewerUI.menu.hidden) showViewerMenu(rect.left, rect.bottom + 6); else hideViewerMenu();
310
311
  });
311
312
  viewer.dialog.addEventListener('click', (event) => { if (!viewerUI.menu.hidden && !viewerUI.menu.contains(event.target) && event.target !== viewerUI.review) hideViewerMenu(); });
312
- viewer.dialog.addEventListener('close', () => { hideViewerMenu(); setSelection(null, null); });
313
+ viewer.dialog.addEventListener('close', () => { hideViewerMenu(); setSelection(null, null); ripley.frame = null; ripley.history = []; ripleyClearErrors(); if (ripley.nav) ripley.nav.hidden = true; });
313
314
 
314
315
  function codeView(text, { line = null, lines = null } = {}) {
315
316
  const pre = el('pre', 'code');
@@ -345,6 +346,9 @@ const RIPLEY_KIND = (name) => (/\.(html?|xhtml)$/i.test(name ?? '') ? 'html' : /
345
346
  const viewerMode = { button: document.querySelector('#viewer-mode'), preview: false, kind: null, text: '', src: '', line: null, lines: null };
346
347
 
347
348
  function showViewerSource() {
349
+ if (ripley.nav) ripley.nav.hidden = true;
350
+ ripleyClearErrors();
351
+ ripley.frame = null;
348
352
  const pre = codeView(viewerMode.text, { line: viewerMode.line, lines: viewerMode.lines });
349
353
  viewer.body.replaceChildren(pre);
350
354
  if (!state.ripley && viewerMode.kind) {
@@ -356,20 +360,115 @@ function showViewerSource() {
356
360
  else if (viewerMode.line) setSelection(viewerMode.line, viewerMode.line);
357
361
  pre.querySelector('.hl')?.scrollIntoView?.({ block: 'center' });
358
362
  }
359
- // The sealed frame: no scripts, no network, no storage; Markdown renders in place.
363
+ // RIPLEY's frame, its small bar (back, reload), its history inside the project,
364
+ // what the page reports up (which page, which errors), and reloads when an
365
+ // agent changes the file on screen.
366
+ const ripley = {
367
+ nav: document.querySelector('#viewer-nav'),
368
+ back: document.querySelector('#viewer-back'),
369
+ reload: document.querySelector('#viewer-reload'),
370
+ errors: document.querySelector('#viewer-errors'),
371
+ frame: null,
372
+ history: [], // preview URLs visited in this session of the viewer
373
+ current: null, // project-relative path of the page on screen
374
+ root: 'project',
375
+ };
376
+ function ripleyPathFromPreview(pathname) {
377
+ const match = String(pathname ?? '').match(/^\/preview\/(project|attachments)\/(.*)$/);
378
+ return match ? { root: match[1], path: decodeURIComponent(match[2]) } : null;
379
+ }
380
+ function ripleyNavigate(url, { push = true } = {}) {
381
+ if (!ripley.frame) return;
382
+ if (push && ripley.history.at(-1) !== url) ripley.history.push(url);
383
+ ripley.frame.src = url;
384
+ ripley.back.disabled = ripley.history.length < 2;
385
+ }
386
+ function ripleyClearErrors() { if (ripley.errors) { ripley.errors.hidden = true; ripley.errors.replaceChildren(); } }
387
+ function ripleyShowError({ message, source, line }) {
388
+ if (!ripley.errors || !viewerMode.preview) return;
389
+ const where = source ? `${ripleyPathFromPreview(new URL(source, window.location.origin).pathname)?.path ?? source}${line ? `:${line}` : ''}` : (ripley.current ? `${ripley.current}${line ? `:${line}` : ''}` : '');
390
+ const row = el('div', 'err-row');
391
+ row.append(el('span', 'msg', message));
392
+ if (where) row.append(el('span', 'where', where));
393
+ const ask = el('button', null, 'ASK THE ROOM');
394
+ ask.type = 'button';
395
+ ask.title = 'Put this error and the file into the composer';
396
+ ask.addEventListener('click', () => {
397
+ const reference = ripley.current ? `!${ripley.current}${line ? `:${line}` : ''} ` : '';
398
+ els.input.value = `${reference}RIPLEY reports an error in the page: ${message}${where ? ` (${where})` : ''}. Find the cause and propose the fix.`;
399
+ viewer.dialog.close();
400
+ autosize();
401
+ els.input.focus();
402
+ });
403
+ row.append(ask);
404
+ ripley.errors.append(row);
405
+ while (ripley.errors.children.length > 3) ripley.errors.firstChild.remove();
406
+ ripley.errors.hidden = false;
407
+ }
408
+ window.addEventListener?.('message', (event) => {
409
+ const data = event.data;
410
+ if (!data || data.ripley !== 1 || !ripley.frame || event.source !== ripley.frame.contentWindow) return;
411
+ if (data.type === 'page') {
412
+ const at = ripleyPathFromPreview(data.path);
413
+ if (at) {
414
+ ripley.current = at.path;
415
+ ripley.root = at.root;
416
+ viewer.path.textContent = `${at.root === 'project' ? '/' : ''}${at.path}${data.title ? ` · ${data.title}` : ''}`;
417
+ viewer.open.href = `/api/files?root=${at.root}&path=${encodeURIComponent(at.path)}`;
418
+ }
419
+ } else if (data.type === 'error') {
420
+ ripleyShowError(data);
421
+ }
422
+ });
423
+ ripley.back?.addEventListener('click', () => {
424
+ if (ripley.history.length < 2) return;
425
+ ripley.history.pop();
426
+ ripleyClearErrors();
427
+ ripleyNavigate(ripley.history.at(-1), { push: false });
428
+ });
429
+ ripley.reload?.addEventListener('click', () => { if (ripley.frame) { ripleyClearErrors(); ripley.frame.src = ripley.frame.src; } });
430
+ // An agent changed what is on screen (CONTROL) or created it (a lease): reload the page.
431
+ function ripleyMaybeReload(paths) {
432
+ if (!viewerMode.preview || !ripley.frame || !ripley.current || !viewer.dialog.open) return;
433
+ const page = ripley.current;
434
+ const dir = page.includes('/') ? page.slice(0, page.lastIndexOf('/') + 1) : '';
435
+ const hit = paths.some((path) => path === page || (dir ? path.startsWith(dir) : !path.includes('/')));
436
+ if (!hit) return;
437
+ ripleyClearErrors();
438
+ ripley.frame.src = ripley.frame.src;
439
+ toast('MU/TH/UR › RIPLEY reloaded the page: an agent changed it.');
440
+ }
441
+
360
442
  function showViewerPreview() {
361
443
  if (viewerMode.kind === 'markdown') {
444
+ ripley.nav.hidden = true;
362
445
  const doc = el('article', 'viewer-markdown bubble');
363
446
  doc.append(renderMarkdown(viewerMode.text));
364
447
  viewer.body.replaceChildren(doc);
365
448
  return;
366
449
  }
450
+ // Scripts may run; the frame has no origin of its own, no network, no forms, no way up to MADRE.
367
451
  const frame = el('iframe', 'ripley');
368
- frame.setAttribute('sandbox', '');
452
+ frame.setAttribute('sandbox', 'allow-scripts');
369
453
  frame.setAttribute('referrerpolicy', 'no-referrer');
370
454
  frame.title = `RIPLEY preview of ${viewer.path.textContent}`;
371
- frame.src = viewerMode.src.replace('/api/files?', '/api/preview?');
455
+ ripley.frame = frame;
456
+ ripley.history = [];
457
+ ripleyClearErrors();
458
+ const start = previewUrl(viewerMode.src);
459
+ const at = ripleyPathFromPreview(start);
460
+ ripley.current = at?.path ?? null;
461
+ ripley.root = at?.root ?? 'project';
372
462
  viewer.body.replaceChildren(frame);
463
+ ripley.nav.hidden = false;
464
+ ripleyNavigate(start);
465
+ }
466
+ // /api/files?root=project&path=a/b.html → /preview/project/a/b.html, so the page's relative links resolve.
467
+ function previewUrl(filesUrl) {
468
+ const params = new URL(filesUrl, window.location.origin).searchParams;
469
+ const root = params.get('root') === 'attachments' ? 'attachments' : 'project';
470
+ const path = (params.get('path') ?? '').split('/').map(encodeURIComponent).join('/');
471
+ return `/preview/${root}/${path}`;
373
472
  }
374
473
  function syncViewerMode() {
375
474
  if (!viewerMode.button) return;
@@ -629,6 +728,11 @@ function renderPicker() {
629
728
  const text = paint(el('span', 'pick-label'), current.id);
630
729
  text.append('to ');
631
730
  text.append(el('b', null, `@${current.id}`));
731
+ 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
+ text.append(note);
735
+ }
632
736
  const modeChip = el('button', `mode-chip m${state.mode}`);
633
737
  modeChip.type = 'button';
634
738
  modeChip.append(el('b', null, `#${state.mode}`), `${MODES[state.mode].label} ▾`);
@@ -1673,6 +1777,20 @@ function renderEventNode(event) {
1673
1777
  case 'memory.distilled': node = renderDistilled(event); break;
1674
1778
  case 'memory.forgotten': node = renderForgotten(event); break;
1675
1779
  case 'memory.noted': attachMemoryHint(event); return;
1780
+ case 'dataset.exported': return;
1781
+ case 'agents.updated': {
1782
+ for (const agent of event.payload.agents ?? []) {
1783
+ const known = state.agents.get(agent.id);
1784
+ state.agents.set(agent.id, { ...(known ?? { tokens: 0, rawTokens: 0, windowMs: null, rollsOverAt: null, officialPercent: null, officialResetAt: null, officialWindows: null }), ...agent });
1785
+ const option = [...els.target.options].find((item) => item.value === agent.id);
1786
+ if (agent.ready && !option) els.target.add(new Option(agent.label, agent.id));
1787
+ if (!agent.ready && option) option.remove();
1788
+ }
1789
+ for (const id of event.payload.removed ?? []) { state.agents.delete(id); [...els.target.options].find((item) => item.value === id)?.remove(); }
1790
+ renderAgents(); renderPicker(); renderOnboarding();
1791
+ if (!replaying && event.payload.reason) toast(`MU/TH/UR › ${event.payload.reason}`);
1792
+ return;
1793
+ }
1676
1794
  case 'mother.alert': node = renderMotherAlert(event); break;
1677
1795
  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;
1678
1796
  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; }
@@ -1698,12 +1816,12 @@ function renderEventNode(event) {
1698
1816
  case 'lease.missing': node = renderLeaseMissing(event); break;
1699
1817
  case 'mode.requested': node = renderModeRequest(event); break;
1700
1818
  case 'control.started': node = renderControlStarted(event); break;
1701
- case 'control.changed': node = renderControlChanged(event); break;
1819
+ case 'control.changed': node = renderControlChanged(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); break;
1702
1820
  case 'control.reverted': node = renderControlReverted(event); break;
1703
1821
  case 'mode.granted':
1704
1822
  case 'mode.denied': settleModeRequest(event); return;
1705
1823
  case 'plan.ignored': node = renderPlanIgnored(event); break;
1706
- case 'artifacts.created': attachArtifacts(event); return;
1824
+ case 'artifacts.created': attachArtifacts(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); return;
1707
1825
  case 'command.output': node = renderCommandCard(event); break;
1708
1826
  default: return;
1709
1827
  }
@@ -2224,7 +2342,8 @@ function updateCrewLabel() {
2224
2342
  : state.expendable ? 'CREW · EXPENDABLE ›' : 'HUMAN ›';
2225
2343
  }
2226
2344
  function updatePlaceholder() {
2227
- 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;
2345
+ const local = Boolean(state.agents.get(els.target.value)?.local);
2346
+ 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;
2228
2347
  }
2229
2348
  function setOrder937(on, { wink = false } = {}) {
2230
2349
  state.ashCode = on;
@@ -2715,6 +2834,61 @@ function builtinCard(item) {
2715
2834
  for (const line of item.creates ?? []) list.append(el('li', null, line));
2716
2835
  for (const line of item.requires ?? []) list.append(el('li', null, `requires ${line}`));
2717
2836
  card.append(list);
2837
+ if (item.id === 'ollama') {
2838
+ const info = item.ollama ?? { running: false, models: [], settings: {} };
2839
+ const status = el('dl', 'ollama-status');
2840
+ const put = (k, v) => { status.append(el('dt', null, k), el('dd', null, v)); };
2841
+ put('SERVER', info.running ? `running · ${info.host}` : 'not running');
2842
+ put('EMBEDDINGS', info.embedModel ? `${info.embedModel}${info.settings.embeddings === false ? ' · off' : ''}` : 'no embedding model');
2843
+ put('ARCHIVIST', info.chatModel ? `${info.chatModel}${info.settings.archivist === false ? ' · off' : ''}` : 'no chat model');
2844
+ if (info.models?.length) put('MODELS', info.models.map((model) => model.name).join(', '));
2845
+ card.append(status);
2846
+ const actions = el('div', 'actions');
2847
+ const recheck = el('button', null, 'RECHECK');
2848
+ recheck.type = 'button';
2849
+ recheck.addEventListener('click', async () => { recheck.disabled = true; await fetch('/api/ollama/probe', { method: 'POST' }).catch(() => null); await refreshModules(); });
2850
+ actions.append(recheck);
2851
+ if (info.running) {
2852
+ const toggle = el('button', on ? null : 'primary', info.settings.enabled === false ? 'ENABLE OLLAMA' : 'DISABLE OLLAMA');
2853
+ toggle.type = 'button';
2854
+ toggle.addEventListener('click', async () => {
2855
+ toggle.disabled = true;
2856
+ try {
2857
+ const response = await fetch('/api/extensions/ollama/install', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
2858
+ const result = await response.json().catch(() => ({}));
2859
+ if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
2860
+ toast(result.enabled ? 'MU/TH/UR › OLLAMA ON · memory embeds and distils on this machine.' : 'MU/TH/UR › OLLAMA OFF · back to the providers.');
2861
+ await refreshModules();
2862
+ } catch (error) { toast(`Ollama could not change state: ${error.message}`); toggle.disabled = false; }
2863
+ });
2864
+ actions.append(toggle);
2865
+ 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)]]) {
2866
+ if (present) {
2867
+ const box = el('label', 'toggle');
2868
+ const input = el('input'); input.type = 'checkbox'; input.checked = info.settings[role] !== false;
2869
+ 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(); });
2870
+ box.append(input, role === 'agent' ? '@MADRE IN THE ROOM' : role.toUpperCase());
2871
+ actions.append(box);
2872
+ } else {
2873
+ const pull = el('button', 'primary', `PULL ${model}`);
2874
+ pull.type = 'button';
2875
+ pull.title = `Download ${model} into Ollama for ${role}`;
2876
+ pull.addEventListener('click', async () => {
2877
+ pull.disabled = true;
2878
+ const response = await fetch('/api/ollama/pull', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model }) });
2879
+ const result = await response.json().catch(() => ({}));
2880
+ if (!response.ok) { toast(`MU/TH/UR › ${result.error ?? 'could not pull'}`); pull.disabled = false; }
2881
+ else toast(`MU/TH/UR › pulling ${model}; progress shows in the room.`);
2882
+ });
2883
+ actions.append(pull);
2884
+ }
2885
+ }
2886
+ } else {
2887
+ 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.'));
2888
+ }
2889
+ card.append(actions);
2890
+ return card;
2891
+ }
2718
2892
  if (item.id === 'ripley') {
2719
2893
  const actions = el('div', 'actions');
2720
2894
  const toggle = el('button', on ? null : 'primary', on ? 'DISABLE RIPLEY' : 'ENABLE RIPLEY');
@@ -3287,6 +3461,72 @@ function renderSettings() {
3287
3461
  save.disabled = false;
3288
3462
  });
3289
3463
  section.append(form);
3464
+
3465
+ // MEMORY: who distils, with whom, how often, where it embeds, how much recall a turn gets. Saves as you change it.
3466
+ const mem = data.settings.memory;
3467
+ if (mem) {
3468
+ section.append(el('h3', null, `MEMORY · ${mem.stats ? `${mem.stats.entries} EXCHANGES · ${mem.stats.memories} MEMORIES · ${mem.stats.pending} WAITING` : 'NO INDEX'}`));
3469
+ 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.'));
3470
+ const mform = el('form', 'room-form memory-form');
3471
+ const save = async (memoryPatch, describe) => { try { await saveSettingNow({ memory: memoryPatch }, describe); await loadSettings(); } catch (error) { toast(`Memory setting was not saved: ${error.message}`); } };
3472
+ const field = (labelText, node) => { const label = el('label'); label.append(labelText); label.append(node); return label; };
3473
+ const archivist = el('select');
3474
+ 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); }
3475
+ archivist.addEventListener('change', () => save({ archivist: archivist.value }, archivist.value === 'auto' ? 'archivist: the cheapest allowed agent goes first.' : `archivist: @${archivist.value} distils first.`));
3476
+ mform.append(field('ARCHIVIST', archivist));
3477
+ const every = el('input'); every.type = 'number'; every.min = '1'; every.step = '1'; every.value = String(mem.every);
3478
+ wireInstantNumber(every, { min: 1, toPatch: (value) => ({ memory: { every: value } }), describe: (value) => `distil every ${value} exchange${value === 1 ? '' : 's'}.` });
3479
+ mform.append(field('DISTIL EVERY · EXCHANGES', every));
3480
+ const idle = el('input'); idle.type = 'number'; idle.min = '1'; idle.step = '1'; idle.value = String(mem.idleMinutes);
3481
+ wireInstantNumber(idle, { min: 1, toPatch: (value) => ({ memory: { idleMinutes: value } }), describe: (value) => `or after ${value} quiet minute${value === 1 ? '' : 's'}.` });
3482
+ mform.append(field('OR AFTER · QUIET MINUTES', idle));
3483
+ const share = el('input'); share.type = 'number'; share.min = '0'; share.max = '60'; share.step = '5'; share.value = String(Math.round(mem.recallShare * 100));
3484
+ 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.` });
3485
+ mform.append(field('RECALL · % OF CONTEXT', share));
3486
+ const embed = el('select');
3487
+ 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']];
3488
+ 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); }
3489
+ embed.addEventListener('change', () => save({ embedProvider: embed.value }, `embeddings: ${embed.options[embed.selectedIndex].textContent.toLowerCase()}.`));
3490
+ const embedLabel = field(`EMBEDDINGS · NOW ${mem.embedder ? mem.embedder.toUpperCase() : 'OFF'}`, embed);
3491
+ mform.append(embedLabel);
3492
+ const who = el('div', 'full');
3493
+ who.append(el('span', 'note', 'MAY DISTIL:'));
3494
+ const allowed = new Set(mem.archivists ?? mem.candidates.map((c) => c.id));
3495
+ for (const candidate of mem.candidates) {
3496
+ const toggle = el('label', 'toggle');
3497
+ const box = el('input'); box.type = 'checkbox'; box.checked = allowed.has(candidate.id);
3498
+ box.addEventListener('change', () => {
3499
+ if (box.checked) allowed.add(candidate.id); else allowed.delete(candidate.id);
3500
+ if (!allowed.size) { box.checked = true; allowed.add(candidate.id); toast('MU/TH/UR › someone has to keep the archive.'); return; }
3501
+ void save({ archivists: allowed.size === mem.candidates.length ? [] : [...allowed] }, `archivists: ${[...allowed].map((id) => `@${id}`).join(', ')}.`);
3502
+ });
3503
+ toggle.append(box, `@${candidate.id.toUpperCase()}${candidate.local ? ' · LOCAL · FREE' : ''}`);
3504
+ who.append(toggle);
3505
+ }
3506
+ mform.append(who);
3507
+ // The dataset behind MADRE AI: export what the room kept, train outside, @madre picks the result up.
3508
+ const dataset = el('div', 'full dataset-row');
3509
+ const exportButton = el('button', null, 'EXPORT DATASET');
3510
+ exportButton.type = 'button';
3511
+ exportButton.title = 'Write train.jsonl and valid.jsonl next to the ledger, redacted, in chat format for mlx-lm';
3512
+ const datasetNote = el('span', 'note', 'LOADING…');
3513
+ const showDataset = (payload) => {
3514
+ 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';
3516
+ };
3517
+ fetch('/api/dataset').then((response) => response.json()).then(showDataset).catch(() => { datasetNote.textContent = 'DATASET UNAVAILABLE'; });
3518
+ exportButton.addEventListener('click', async () => {
3519
+ exportButton.disabled = true;
3520
+ 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}`); }
3521
+ catch (error) { toast(`Dataset export failed: ${error.message}`); }
3522
+ finally { exportButton.disabled = false; }
3523
+ });
3524
+ dataset.append(exportButton, datasetNote);
3525
+ mform.append(dataset);
3526
+ 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
+ mform.addEventListener('submit', (event) => event.preventDefault());
3528
+ section.append(mform);
3529
+ }
3290
3530
  }
3291
3531
 
3292
3532
  // Save one setting the moment it changes, the way the scope boxes do; the
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
@@ -217,6 +217,10 @@
217
217
  </dialog>
218
218
  <dialog id="viewer" class="viewer" aria-label="File viewer">
219
219
  <header class="viewer-head">
220
+ <span class="viewer-nav" id="viewer-nav" hidden>
221
+ <button id="viewer-back" class="nav" type="button" title="Back to the previous page" aria-label="Back" disabled>‹</button>
222
+ <button id="viewer-reload" class="nav" type="button" title="Reload the page" aria-label="Reload">↻</button>
223
+ </span>
220
224
  <span id="viewer-path" class="viewer-path"></span>
221
225
  <span id="viewer-selection" class="viewer-selection" hidden></span>
222
226
  <button id="viewer-mode" class="viewer-review viewer-mode" type="button" hidden>SOURCE</button>
@@ -224,6 +228,7 @@
224
228
  <a id="viewer-open" class="viewer-open" target="_blank" rel="noopener noreferrer">OPEN RAW ↗</a>
225
229
  <button id="viewer-close" class="mother-close" type="button">CLOSE ×</button>
226
230
  </header>
231
+ <div id="viewer-errors" class="viewer-errors" hidden></div>
227
232
  <div id="viewer-body" class="viewer-body"></div>
228
233
  <div id="viewer-menu" class="viewer-menu" role="menu" hidden></div>
229
234
  </dialog>
package/public/styles.css CHANGED
@@ -381,6 +381,7 @@ textarea::placeholder { color: var(--text-3); }
381
381
  .field button[type="submit"] svg { width: 18px; height: 18px; }
382
382
  .field button[type="submit"]:hover { filter: brightness(1.06); }
383
383
  .field button[type="submit"]:disabled { opacity: .4; cursor: default; box-shadow: none; }
384
+ .pick-label .pick-note { font-family: var(--mono); font-size: 9px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-3); margin-left: 4px; white-space: nowrap; }
384
385
  .safety { margin: 14px auto 18px; color: var(--text-3); font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; text-align: center; }
385
386
 
386
387
  /* toast */
@@ -710,6 +711,17 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
710
711
  .viewer-path { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
711
712
  .viewer-open { color: var(--phosphor); text-decoration: none; letter-spacing: .12em; font-size: 10px; }
712
713
  .viewer-body { height: calc(100% - 46px); overflow: auto; display: grid; place-items: start center; padding: 16px; }
714
+ .viewer-nav { display: inline-flex; gap: 4px; }
715
+ .viewer-nav .nav { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid var(--line-2); background: transparent; color: var(--text-2); border-radius: 7px; cursor: pointer; font: inherit; font-size: 14px; line-height: 1; }
716
+ .viewer-nav .nav:hover { color: var(--text); border-color: var(--text-3); }
717
+ .viewer-nav .nav:disabled { opacity: .35; cursor: default; }
718
+ .viewer-errors { display: grid; gap: 6px; padding: 8px 16px; border-bottom: 1px solid color-mix(in srgb, var(--danger) 40%, transparent); background: color-mix(in srgb, var(--danger) 7%, transparent); font-family: var(--mono); font-size: 11px; }
719
+ .viewer-errors:not([hidden]) + .viewer-body { height: auto; max-height: calc(100% - 46px); }
720
+ .viewer-errors .err-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
721
+ .viewer-errors .msg { color: var(--danger); overflow-wrap: anywhere; }
722
+ .viewer-errors .where { color: var(--text-3); font-size: 10px; }
723
+ .viewer-errors button { margin-left: auto; border: 1px solid color-mix(in srgb, var(--danger) 55%, transparent); background: transparent; color: var(--danger); font: inherit; font-family: var(--mono); font-size: 9.5px; letter-spacing: .14em; padding: 3px 8px; border-radius: 6px; cursor: pointer; }
724
+ .viewer-errors button:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); }
713
725
  .viewer-body img { max-width: 100%; max-height: 100%; border-radius: 8px; }
714
726
  .viewer-body pre { margin: 0; width: 100%; font-family: var(--mono); font-size: 12.5px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); }
715
727
  .viewer-body iframe { width: 100%; height: 100%; border: 0; background: #fff; border-radius: 8px; }
@@ -1018,4 +1030,12 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1018
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; }
1019
1031
  .mother-section.sentinel button:hover { background: var(--ph-faint); }
1020
1032
  .mother-section.sentinel .sentinel-report.sent { opacity: .65; }
1033
+ .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
+ .settings .memory-form .toggle input { accent-color: #9bff66; width: auto; }
1035
+ .settings .memory-form .full .note { margin-right: 12px; }
1036
+ .settings .memory-form .dataset-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-top: 8px; border-top: 1px solid var(--ph-faint); }
1037
+ .module-card .ollama-status { margin: 0 0 10px; display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; font-size: 10px; letter-spacing: .1em; }
1038
+ .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
+ .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
+ .module-card .actions .toggle input { accent-color: #9bff66; }
1021
1041
  .mother-section.sentinel .note { margin: 0 0 10px; font-size: 10px; letter-spacing: .12em; line-height: 1.7; color: var(--ph-dim); }
@@ -416,10 +416,32 @@ export const CONDITIONS = [
416
416
  severity: 'informational',
417
417
  title: 'RIPLEY: rendering HTML, SVG and Markdown in the viewer',
418
418
  match: /ripley|RIPLEY is off|renders \.html|render(ed|ing)? (the )?(html|svg|markdown)/i,
419
- diagnosis: 'With RIPLEY on (MODULES), the file viewer renders .html and .svg through /api/preview inside a sealed frame (sandbox with no permissions; a policy that allows no scripts, no network, no forms, no storage) and Markdown in place; PREVIEW / SOURCE switches. Off, those files show as text with a note. 412 means RIPLEY is off; 415 means the type is not rendered (only .html, .htm, .svg; .md renders in the viewer itself). Plain /api/files always serves HTML as text.',
420
- remedy: 'Enable RIPLEY in MODULES. If a page looks broken in PREVIEW it is usually because it needs scripts or external resources, which the frame forbids by design: open it with OPEN RAW in a normal tab if you trust it.',
419
+ diagnosis: 'With RIPLEY on (MODULES), the file viewer renders .html and .svg through /preview/project/<path> inside a sealed frame: the page\'s own scripts run, but the frame has no origin, no network, no forms and no way to reach MADRE, and it loads CSS, JS, images and fonts only from the project through MADRE. Relative links work. Markdown renders in place; PREVIEW / SOURCE switches. Off, those files show as text with a note. 412 means RIPLEY is off. Plain /api/files always serves HTML as text.',
420
+ remedy: 'Enable RIPLEY in MODULES. If a page looks broken in PREVIEW it is usually because it fetches something from the internet or another server, which the frame forbids by design: open it with OPEN RAW in a normal tab if you trust it.',
421
421
  fixes: same(['# MODULES → RIPLEY → ENABLE RIPLEY', '# viewer → PREVIEW / SOURCE']),
422
422
  },
423
+ {
424
+ id: 'ollama',
425
+ severity: 'informational',
426
+ title: 'Ollama: local embeddings and a local archivist',
427
+ match: /ollama|11434|local model|nomic-embed|qwen|llama3/i,
428
+ diagnosis: 'When Ollama runs on this machine with an embedding model and a chat model, MADRE embeds the memory locally (recall by meaning without any key) and distils memories with the local model first, before Gemini and the others. Nothing leaves the machine for remembering. Off or absent, everything falls back to the providers. MODULES shows what Ollama has, lets you pull the recommended models, and switches each role.',
429
+ remedy: 'Install Ollama (ollama.com), start it, then in MODULES press RECHECK and PULL the models it suggests. Set PULSE_OLLAMA_MODEL or PULSE_OLLAMA_EMBED_MODEL to prefer others; PULSE_EMBED_PROVIDER=gemini keeps embeddings on Gemini even with Ollama running.',
430
+ fixes: {
431
+ darwin: ['brew install ollama', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b'],
432
+ linux: ['curl -fsSL https://ollama.com/install.sh | sh', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b'],
433
+ win32: ['winget install Ollama.Ollama', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b'],
434
+ },
435
+ },
436
+ {
437
+ id: 'madre-agent',
438
+ severity: 'informational',
439
+ title: '@madre: the room\'s own memory, as an agent',
440
+ match: /@madre|madre-local|needs Ollama running with a chat model/i,
441
+ diagnosis: 'When Ollama runs with a chat model, a fifth agent joins the room: @madre. It answers from the whole archive (distilled notes and exact quotes, cited as [#n]) and from what the message carries, locally, for free. It never writes, draws, browses or delegates; when the room never discussed something it says so. Other agents may delegate a step to it to check what was decided. It leaves the room when Ollama stops, and MU/TH/UR says so.',
442
+ remedy: 'Start Ollama and PULL a chat model in MODULES → OLLAMA; the switch @MADRE IN THE ROOM turns the agent off if you do not want it. Ask it "what did we decide about …" or "did we ever discuss …".',
443
+ fixes: same(['ollama serve', 'ollama pull qwen2.5:3b', '@madre what did we decide about the webhook?']),
444
+ },
423
445
  {
424
446
  id: 'control-changes',
425
447
  severity: 'informational',
@@ -0,0 +1,85 @@
1
+ // @madre: the room's own intelligence. A local model (Ollama) that answers
2
+ // from what the room remembers: the whole archive, not only the recent window,
3
+ // plus the files the human points at. It never writes, never delegates, never
4
+ // leaves the machine. It grows as the memory grows.
5
+
6
+ import { ollamaGenerate } from '../ollama.mjs';
7
+
8
+ export const MADRE_AGENT_ID = 'madre';
9
+ export const MADRE_ADAPTER = 'madre-local';
10
+
11
+ export function madreAgent(ollama, { enabled = true } = {}) {
12
+ const ready = Boolean(enabled && ollama?.running && ollama.chatModel);
13
+ return { id: MADRE_AGENT_ID, label: 'MADRE', detected: Boolean(ollama?.running), ready, adapter: MADRE_ADAPTER, path: null, version: ollama?.chatModel ?? null, local: true };
14
+ }
15
+
16
+ const SYSTEM = `You are @madre, the memory of this MADRE project room, running locally on this machine. You speak only for what the room has said and decided.
17
+ Rules:
18
+ 1. Answer the question directly in the first sentence, then the supporting facts. Brief.
19
+ 2. Use only the ARCHIVE and the RECENT TRANSCRIPT you are given. Every fact taken from them ends with its sequence, like [#123].
20
+ 3. If they contain nothing on the point, say exactly that the room never discussed it, and stop. Do not guess, do not propose plans, do not invent files, decisions or dates.
21
+ 4. You cannot read or change files, run commands or browse. If that is needed, name the agent of the room that can (@codex, @claude, @gemini, @opencode).
22
+ 5. Answer in the language the human wrote in.`;
23
+
24
+ // The room's briefing is written for CLIs with tools; @madre gets only what it can use:
25
+ // the recent transcript, the archive, the question.
26
+ export function briefingFor({ prompt, text, archive }) {
27
+ const transcript = prompt.match(/<context>\n([\s\S]*?)\n<\/context>/)?.[1] ?? '';
28
+ return [
29
+ transcript ? `RECENT TRANSCRIPT (oldest first):\n${transcript}` : null,
30
+ `ARCHIVE (what the room remembers that matches the question; notes first, then exact quotes):\n${archive || '(nothing in the archive matches this question)'}`,
31
+ // Small models weigh the last thing they read: the identity goes here too, after
32
+ // thousands of characters spoken by Claude, Codex, Gemini and OpenCode.
33
+ REMINDER,
34
+ `QUESTION FROM THE HUMAN:\n${text}`,
35
+ ].filter(Boolean).join('\n\n');
36
+ }
37
+
38
+ const REMINDER = `REMINDER: You are @madre, the local memory of this room. You are not Claude, Codex, Gemini or OpenCode; the quotes above are theirs, not yours. Never claim to be another assistant or company. Answer as @madre, from the archive only.`;
39
+
40
+ // Orders @madre cannot carry out: convening, delegating, running, writing. They are answered
41
+ // here, in the human's language, without calling the model. Questions pass through.
42
+ const ACTION_ES = /(^|[\s,.;:!¡])(convoca|convócalos|convócala|reúne|reune|delega|coordina|pídele|pidele|pídeles|pideles|dile|diles|ordena|manda|envía|envia|ejecuta|corre|instala|implementa|refactoriza|despliega|escribe|crea|modifica|edita|borra|elimina|genera|haz|hazlo|realiza|lanza|arranca|reinicia|configura|publica|sube)\b/i;
43
+ const ACTION_EN = /(^|[\s,.;:!])(convene|summon|gather|schedule|delegate|coordinate|ask @|tell @|order|run|execute|install|implement|refactor|deploy|write|create|modify|edit|delete|remove|generate|build|commit|push|publish|launch|restart|configure|make (?:a|the|it)|call a meeting|set up)\b/i;
44
+ const QUESTION = /[?¿]|(^|[^\p{L}])(qué|cuál|cuáles|cuándo|dónde|quién|quiénes|cómo|por qué|what|which|when|where|who|how|why|did|do we|have we|is there)(?!\p{L})/iu;
45
+ const SPANISH = /[áéíóúñ¿¡]|\b(el|la|los|las|una?|que|para|con|de|todos|reunión|reunion)\b/i;
46
+
47
+ export function actionRequest(text = '') {
48
+ const body = String(text).trim().replace(/^@?madre[,:]?\s*/i, '').replace(/^\p{L}+,\s*/u, '');
49
+ if (!body || QUESTION.test(body)) return null;
50
+ if (!ACTION_ES.test(body) && !ACTION_EN.test(body)) return null;
51
+ return SPANISH.test(body)
52
+ ? 'Solo respondo desde la memoria de la sala: no convoco, no delego, no ejecuto ni escribo. Para eso escríbele a @codex, @claude, @gemini u @opencode, los que estén en la fila. Si quieres, pregúntame qué recuerda la sala sobre esto y te lo cito.'
53
+ : 'I only answer from the room\'s memory: I do not convene, delegate, run or write. For that, write to @codex, @claude, @gemini or @opencode, whichever is in the row. If you like, ask me what the room remembers about this and I will quote it.';
54
+ }
55
+
56
+ const NO_USAGE = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, totalTokens: 0, costUsd: 0, source: 'madre', local: true };
57
+
58
+ // What the whole archive says about this request: notes first, then exact quotes.
59
+ export async function gather(memory, text, { maxChars = 7000 } = {}) {
60
+ if (!memory) return { notes: [], quotes: [], text: '' };
61
+ const queryVector = await memory.embedQuery(text);
62
+ const notes = memory.recallMemories(text, { limit: 12, maxChars: Math.floor(maxChars * 0.35), queryVector, fallback: false });
63
+ const spent = notes.reduce((sum, note) => sum + note.text.length + 24, 0);
64
+ const recall = memory.recall(text, { limit: 12, maxChars: maxChars - spent, excerptChars: 420, queryVector });
65
+ const lines = [
66
+ ...notes.map((note) => `- [note · ${note.kind} · #${note.fromSequence}–#${note.throughSequence}] ${note.text}`),
67
+ ...recall.entries.map((entry) => `[#${entry.sequence} · ${(entry.timestamp ?? '').slice(0, 16).replace('T', ' ')} · ${entry.role === 'command' ? entry.sender : `@${entry.sender}`}] ${entry.excerpt}`),
68
+ ];
69
+ return { notes, quotes: recall.entries, text: lines.join('\n') };
70
+ }
71
+
72
+ // The invoker the room calls like any adapter. `text` is the human's request; `prompt` the room's full briefing.
73
+ export function madreInvoker({ memory, ollama, fetchImpl = globalThis.fetch }) {
74
+ return async ({ prompt, text = '', timeoutMs = 180000, model = null }) => {
75
+ const state = typeof ollama === 'function' ? ollama() : ollama;
76
+ if (!state?.running || !state.chatModel) throw new Error('@madre needs Ollama running with a chat model. Open MODULES → OLLAMA.');
77
+ const question = text || prompt.match(/User message: ([\s\S]*)$/)?.[1] || prompt.slice(-2000);
78
+ const declined = actionRequest(question);
79
+ if (declined) return { text: declined, usage: { ...NO_USAGE }, grounded: { notes: 0, quotes: 0 }, declined: 'action' };
80
+ const archive = await gather(memory, question);
81
+ const briefing = briefingFor({ prompt, text: question, archive: archive.text });
82
+ const answer = await ollamaGenerate({ host: state.host, model: model ?? state.chatModel, system: SYSTEM, prompt: briefing, fetchImpl, timeoutMs, temperature: 0.1 });
83
+ return { text: answer.text, usage: { ...answer.usage, local: true }, grounded: { notes: archive.notes.length, quotes: archive.quotes.length } };
84
+ };
85
+ }
@@ -129,6 +129,7 @@ export function loginPlanFor(agent) {
129
129
  }
130
130
 
131
131
  export async function probeAgentAuth(agent) {
132
+ if (agent.id === 'madre') return agent.ready ? { state: 'signed-in', detail: `local · ${agent.version ?? 'Ollama'}` } : { state: 'signed-out', detail: 'Ollama is not running or has no chat model' };
132
133
  if (!agent.detected || !agent.path) return { state: 'not-installed', detail: 'not found on this computer' };
133
134
  switch (agent.id) {
134
135
  case 'codex': return parseCodexLoginStatus(await run(agent.path, ['login', 'status']));
@@ -10,6 +10,7 @@
10
10
  // imageGen generate images (Codex: image_generation feature via ChatGPT)
11
11
  // web fetch or search the web (denied by default in consultation mode)
12
12
 
13
+ // @madre, the room's local memory agent, is not a CLI: capabilityOf gives it the read-only profile.
13
14
  export const CAPABILITIES = {
14
15
  codex: {
15
16
  read: true,