@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/CHANGELOG.md +38 -0
- package/CONTRIBUTING.md +32 -1
- package/README.md +39 -6
- package/SECURITY.md +1 -1
- package/bin/madre.mjs +37 -3
- package/package.json +4 -3
- package/public/app.js +247 -7
- package/public/brands.js +18 -0
- package/public/index.html +5 -0
- package/public/styles.css +20 -0
- package/public/troubleshooting.js +24 -2
- package/src/adapters/madre.mjs +85 -0
- package/src/auth-probe.mjs +1 -0
- package/src/capabilities.mjs +1 -0
- package/src/dataset.mjs +85 -0
- package/src/distiller.mjs +16 -6
- package/src/embeddings.mjs +7 -1
- package/src/extensions.mjs +24 -224
- package/src/memory.mjs +3 -2
- package/src/modules/ahp.mjs +64 -0
- package/src/modules/ashcode.mjs +28 -0
- package/src/modules/git-pulse.mjs +26 -0
- package/src/modules/helpers.mjs +30 -0
- package/src/modules/image-studio.mjs +39 -0
- package/src/modules/index.mjs +21 -0
- package/src/modules/ollama.mjs +56 -0
- package/src/modules/ripley.mjs +20 -0
- package/src/modules/sdk.mjs +78 -0
- package/src/ollama.mjs +118 -0
- package/src/room/archivist.mjs +141 -0
- package/src/room/attachments.mjs +15 -0
- package/src/room/budget.mjs +83 -0
- package/src/room/context.mjs +31 -0
- package/src/room/control.mjs +66 -0
- package/src/room/escalation.mjs +41 -0
- package/src/room/ghost.mjs +32 -0
- package/src/room/guard.mjs +52 -0
- package/src/room/prompt.mjs +62 -0
- package/src/room/vectors.mjs +47 -0
- package/src/room.mjs +126 -388
- package/src/server.mjs +190 -53
- package/src/setup.mjs +25 -7
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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 /
|
|
420
|
-
remedy: 'Enable RIPLEY in MODULES. If a page looks broken in PREVIEW it is usually because 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
|
+
}
|
package/src/auth-probe.mjs
CHANGED
|
@@ -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']));
|
package/src/capabilities.mjs
CHANGED
|
@@ -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,
|