@jossuealcala/madre 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/docs/REFERENCE.md +3 -2
- package/docs/SDK.md +19 -5
- package/docs/sdk/hello-module.mjs +8 -0
- package/package.json +1 -1
- package/public/app.js +256 -44
- package/public/es.js +36 -3
- package/public/index.html +2 -0
- package/public/styles.css +102 -11
- package/public/syntax.js +121 -0
- package/public/troubleshooting.js +13 -0
- package/src/adapters/codex.mjs +4 -1
- package/src/modules/index.mjs +23 -5
- package/src/modules/sdk.mjs +15 -0
- package/src/outbound.mjs +1 -1
- package/src/room.mjs +12 -0
- package/src/runtime-detection.mjs +50 -0
- package/src/server.mjs +9 -6
- package/src/updates.mjs +9 -1
package/public/app.js
CHANGED
|
@@ -3,6 +3,7 @@ import { CONDITIONS, allConditions, detectPlatform, diagnose, fixesFor, PLATFORM
|
|
|
3
3
|
import { answerFor, INQUIRIES, STRIKES } from './inquiry.js';
|
|
4
4
|
import { DEFAULT_LANGUAGE, LANGUAGES, isLanguage, language, pick, setLanguage, t } from './i18n.js';
|
|
5
5
|
import { resay } from './resay.js';
|
|
6
|
+
import { familyOf, languageLabel, tokenize } from './syntax.js';
|
|
6
7
|
|
|
7
8
|
// Before anything is written on the screen. The constants below are sentences, and a sentence
|
|
8
9
|
// chosen in the wrong language stays wrong for the life of the page.
|
|
@@ -61,6 +62,7 @@ const els = {
|
|
|
61
62
|
// server (/api/commands) and run there, read-only, as fact cards.
|
|
62
63
|
const CLIENT_COMMANDS = [
|
|
63
64
|
{ name: 'create', title: 'CREATE', usage: '/create <what to make>', summary: t('Arm CREATE for this message: the agent may add new files to the project where they belong.'), available: true, client: true },
|
|
65
|
+
{ name: 'module', title: 'MODULE', usage: '/module <what it should do>', summary: t('Build one MADRE module with the guided SDK flow: the agent writes it, then you review and install it.'), available: true, client: true },
|
|
64
66
|
{ name: 'image', title: t('Image'), usage: '/image <what to draw>', summary: t('Ask for an image: arms CREATE with the image scope and routes to an agent that can generate images.'), available: true, client: true },
|
|
65
67
|
{ name: 'stopall', title: t('STOP ALL'), usage: '/stopall', summary: t('Master brake: halt every plan and turn in flight. Never reaches an agent.'), available: true, client: true },
|
|
66
68
|
];
|
|
@@ -122,6 +124,7 @@ const state = {
|
|
|
122
124
|
pending: [], // attachments uploaded for the next message
|
|
123
125
|
projectRoot: '',
|
|
124
126
|
create: false, // creation lease armed for the next message
|
|
127
|
+
moduleCommand: false, // /module is being composed: CREATE plus the guided SDK contract
|
|
125
128
|
ashInstalled: false,
|
|
126
129
|
ash: false, // Ash asked for on this message: compact replies, nothing rewritten
|
|
127
130
|
chosenModel: {}, // id -> model name picked in the composer
|
|
@@ -138,6 +141,17 @@ const failureKey = (failure) => `${failure.time ?? ''}|${failure.agent ?? ''}|${
|
|
|
138
141
|
const saveDismissed = () => { try { localStorage.setItem('pulse.mother.dismissed', JSON.stringify([...dismissed].slice(-400))); } catch { /* no storage */ } };
|
|
139
142
|
const openFailures = () => state.failures.filter((failure) => !failure.recovered && !dismissed.has(failureKey(failure)));
|
|
140
143
|
|
|
144
|
+
// Which agent last wrote to a path, taken from what the room already recorded: the artifacts a
|
|
145
|
+
// creation lease produced, and the files a CONTROL turn changed. Nothing new is stored and nothing
|
|
146
|
+
// is inferred from the filesystem — if MADRE did not see it happen, the file carries no mark.
|
|
147
|
+
const touched = new Map();
|
|
148
|
+
function markTouched(agent, files) {
|
|
149
|
+
for (const file of files ?? []) {
|
|
150
|
+
const path = typeof file === 'string' ? file : file?.path;
|
|
151
|
+
if (path && agent) touched.set(String(path).replace(/^\.?\//, ''), agent);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
141
155
|
|
|
142
156
|
|
|
143
157
|
/* ---------- helpers ---------- */
|
|
@@ -297,6 +311,8 @@ const viewer = {
|
|
|
297
311
|
const viewerUI = {
|
|
298
312
|
selection: document.querySelector('#viewer-selection'),
|
|
299
313
|
review: document.querySelector('#viewer-review'),
|
|
314
|
+
lang: document.querySelector('#viewer-lang'),
|
|
315
|
+
hintSelect: document.querySelector('#viewer-hint-select'),
|
|
300
316
|
menu: document.querySelector('#viewer-menu'),
|
|
301
317
|
path: null, // project-relative path of the open text file, if any
|
|
302
318
|
from: null, to: null,
|
|
@@ -312,6 +328,9 @@ function setSelection(from, to) {
|
|
|
312
328
|
const has = from !== null;
|
|
313
329
|
viewerUI.selection.hidden = !has;
|
|
314
330
|
viewerUI.review.hidden = !has;
|
|
331
|
+
// The button only exists once something is selected, and nobody guesses that a line number is
|
|
332
|
+
// clickable. While there is nothing selected and the file is readable as text, say how.
|
|
333
|
+
if (viewerUI.hintSelect) viewerUI.hintSelect.hidden = has || !viewerUI.lineNodes.length;
|
|
315
334
|
if (has) viewerUI.selection.textContent = lo === hi ? `L${lo}` : `L${lo}-${hi}`;
|
|
316
335
|
}
|
|
317
336
|
function referenceForSelection() {
|
|
@@ -323,7 +342,11 @@ function referenceForSelection() {
|
|
|
323
342
|
function reviewWith(agentId) {
|
|
324
343
|
const reference = referenceForSelection();
|
|
325
344
|
const current = els.input.value.trim();
|
|
326
|
-
|
|
345
|
+
// The reference and the cursor, nothing else. This used to seed "review this: " — an English
|
|
346
|
+
// phrase in a Spanish room, and worse, a sentence MADRE put in the human's mouth: whatever they
|
|
347
|
+
// typed next landed after it and read like somebody else's words. They just chose an agent and
|
|
348
|
+
// a range; they know what they want to ask.
|
|
349
|
+
els.input.value = current ? `${reference} ${current}` : `${reference} `;
|
|
327
350
|
if (agentId && state.agents.get(agentId)?.ready) { els.target.value = agentId; renderPicker(); }
|
|
328
351
|
hideViewerMenu();
|
|
329
352
|
viewer.dialog.close();
|
|
@@ -333,17 +356,27 @@ function reviewWith(agentId) {
|
|
|
333
356
|
}
|
|
334
357
|
function showViewerMenu(x, y) {
|
|
335
358
|
const menuNode = viewerUI.menu;
|
|
336
|
-
|
|
359
|
+
// Two lines: what is being sent, and the question. One line put the verb at one end and its
|
|
360
|
+
// preposition at the other, and in Spanish the sentence ended on a dangling «CON» that the
|
|
361
|
+
// menu then cut off.
|
|
362
|
+
const head = el('div', 'hint');
|
|
363
|
+
head.append(el('b', null, referenceForSelection()));
|
|
364
|
+
head.append(el('span', null, t('review it with')));
|
|
365
|
+
menuNode.replaceChildren(head);
|
|
337
366
|
for (const agent of state.agents.values()) {
|
|
338
367
|
const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
|
|
339
368
|
item.type = 'button';
|
|
340
|
-
|
|
369
|
+
// `@codex Codex` says one thing twice. The handle is the name; beside it goes only what the
|
|
370
|
+
// handle does not already tell you — the vendor, or why it cannot take this.
|
|
371
|
+
const aside = agent.ready ? (brandOf(agent.id).vendor ?? '') : t('no session');
|
|
372
|
+
item.append(el('b', null, `@${agent.id}`), el('span', null, aside));
|
|
341
373
|
item.disabled = !agent.ready;
|
|
342
374
|
item.addEventListener('click', () => reviewWith(agent.id));
|
|
343
375
|
menuNode.append(item);
|
|
344
376
|
}
|
|
345
377
|
menuNode.hidden = false;
|
|
346
|
-
|
|
378
|
+
// The header is two lines now and the reference can wrap; leave room rather than clip it.
|
|
379
|
+
const width = 260, height = 62 + 36 * state.agents.size;
|
|
347
380
|
menuNode.style.left = `${Math.min(x, window.innerWidth - width - 12)}px`;
|
|
348
381
|
menuNode.style.top = `${Math.min(y, window.innerHeight - height - 12)}px`;
|
|
349
382
|
}
|
|
@@ -355,14 +388,26 @@ viewerUI.review?.addEventListener('click', (event) => {
|
|
|
355
388
|
viewer.dialog.addEventListener('click', (event) => { if (!viewerUI.menu.hidden && !viewerUI.menu.contains(event.target) && event.target !== viewerUI.review) hideViewerMenu(); });
|
|
356
389
|
viewer.dialog.addEventListener('close', () => { hideViewerMenu(); setSelection(null, null); ripley.frame = null; ripley.history = []; ripleyClearErrors(); if (ripley.nav) ripley.nav.hidden = true; });
|
|
357
390
|
|
|
358
|
-
function codeView(text, { line = null, lines = null } = {}) {
|
|
391
|
+
function codeView(text, { line = null, lines = null, name = null } = {}) {
|
|
359
392
|
const pre = el('pre', 'code');
|
|
360
393
|
viewerUI.lineNodes = [];
|
|
361
394
|
const rows = text.replace(/\n$/, '').split('\n');
|
|
395
|
+
// Read by what the file is. The highlighter never rewrites a line — it hands back spans over
|
|
396
|
+
// the same characters — so what is on screen and what is on disk stay the same bytes, and a
|
|
397
|
+
// selection sent to an agent is the file, not a rendering of it.
|
|
398
|
+
const family = familyOf(name ?? '');
|
|
399
|
+
if (viewerUI.lang) {
|
|
400
|
+
const label = languageLabel(name ?? '');
|
|
401
|
+
viewerUI.lang.textContent = label ?? '';
|
|
402
|
+
viewerUI.lang.hidden = !label;
|
|
403
|
+
}
|
|
404
|
+
const carry = { inBlock: false };
|
|
362
405
|
rows.forEach((content, index) => {
|
|
363
406
|
const n = index + 1;
|
|
364
407
|
const ln = el('span', 'ln', String(n));
|
|
365
|
-
const tx = el('span', 'tx'
|
|
408
|
+
const tx = el('span', 'tx');
|
|
409
|
+
if (!content) tx.append(' ');
|
|
410
|
+
else for (const piece of tokenize(content, family, carry)) tx.append(piece.kind ? el('span', `t-${piece.kind}`, piece.text) : piece.text);
|
|
366
411
|
ln.dataset.n = n; tx.dataset.n = n;
|
|
367
412
|
const marked = lines ? n >= lines.from && n <= lines.to : n === line;
|
|
368
413
|
if (marked) { ln.classList.add('hl'); tx.classList.add('hl'); }
|
|
@@ -392,7 +437,7 @@ function showViewerSource() {
|
|
|
392
437
|
if (ripley.nav) ripley.nav.hidden = true;
|
|
393
438
|
ripleyClearErrors();
|
|
394
439
|
ripley.frame = null;
|
|
395
|
-
const pre = codeView(viewerMode.text, { line: viewerMode.line, lines: viewerMode.lines });
|
|
440
|
+
const pre = codeView(viewerMode.text, { line: viewerMode.line, lines: viewerMode.lines, name: viewerUI.path ?? viewer.path.textContent });
|
|
396
441
|
viewer.body.replaceChildren(pre);
|
|
397
442
|
if (!state.ripley && viewerMode.kind) {
|
|
398
443
|
const hint = el('div', 'viewer-hint');
|
|
@@ -772,7 +817,7 @@ function renderPicker() {
|
|
|
772
817
|
text.append(t('to '));
|
|
773
818
|
text.append(el('b', null, `@${current.id}`));
|
|
774
819
|
if (current.local) {
|
|
775
|
-
const note = el('span', 'pick-note', t('memory ·
|
|
820
|
+
const note = el('span', 'pick-note', t('memory · never writes'));
|
|
776
821
|
const measured = state.localModel?.checked;
|
|
777
822
|
note.title = `${t('@madre runs on this machine and speaks for what the room remembers. "@madre, ask the crew …" opens a round with every agent online. To change files, write to a CLI agent.')}${
|
|
778
823
|
measured ? `\n\n${t('Measured against this room {when}: it landed where the crew landed on {matched} of {n} real questions{verdict}', { when: agoWords(measured.at), matched: measured.matched, n: measured.n, verdict: measured.passed ? t('. Ready to be worked in.') : t(' — not yet.') })}` : `\n\n${t('Nobody has measured it against this project yet: MU/TH/UR → the three tests, or the line in the room when it joined.')}`}`;
|
|
@@ -784,12 +829,18 @@ function renderPicker() {
|
|
|
784
829
|
modeChip.title = t('Permission mode for this message · {hint}', { hint: MODES[state.mode].hint });
|
|
785
830
|
modeChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModeMenu(current.id, modeChip); });
|
|
786
831
|
text.append(modeChip);
|
|
832
|
+
// An agent with no models to choose from gets no chooser. @madre answers on whatever chat
|
|
833
|
+
// model Ollama holds, which is decided on Ollama's card and not here, so the chip only opened
|
|
834
|
+
// an empty menu — and it was the piece that pushed this row onto a second line.
|
|
787
835
|
const chosen = state.chosenModel[current.id];
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
836
|
+
const choices = state.models[current.id]?.models ?? null;
|
|
837
|
+
if (!current.local && (choices === null || choices.length)) {
|
|
838
|
+
const modelChip = el('button', 'model-chip', chosen ? `${chosen} ▾` : t('default model ▾'));
|
|
839
|
+
modelChip.type = 'button';
|
|
840
|
+
modelChip.title = t('Choose the model for this agent');
|
|
841
|
+
modelChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModelMenu(current.id, modelChip); });
|
|
842
|
+
text.append(modelChip);
|
|
843
|
+
}
|
|
793
844
|
els.picker.append(text);
|
|
794
845
|
}
|
|
795
846
|
if (typeof renderCreateScopes === 'function') renderCreateScopes();
|
|
@@ -881,7 +932,11 @@ function toggleModeMenu(id, anchor) {
|
|
|
881
932
|
const scopes = state.capabilities[id]?.scopes;
|
|
882
933
|
const canRaise = Boolean(scopes?.write?.capable);
|
|
883
934
|
const title = el('div', 'model-menu-title');
|
|
884
|
-
|
|
935
|
+
// Short enough to sit on one line at any width the menu takes. That it applies to this message
|
|
936
|
+
// and not to every one after it is the mode chip's own tooltip, and the list below is literally
|
|
937
|
+
// the modes — so the header carries the two facts nothing else here does: who, and how far.
|
|
938
|
+
title.append(el('b', null, `@${id}`), t(' · ceiling #{cap}', { cap }));
|
|
939
|
+
title.title = t('Permission mode for this message · {hint}', { hint: MODES[cap].hint });
|
|
885
940
|
modeMenu.append(title);
|
|
886
941
|
const ladder = el('div', 'mode-ladder');
|
|
887
942
|
for (const n of [0, 1, 2, 3, 4]) {
|
|
@@ -1083,19 +1138,20 @@ function bridgeCard(agent) {
|
|
|
1083
1138
|
card.id = `bridge-${agent.id}`;
|
|
1084
1139
|
const session = state.sessions?.[agent.id];
|
|
1085
1140
|
const signedIn = session?.state === 'signed-in';
|
|
1086
|
-
const
|
|
1141
|
+
const routeBlocked = Boolean(agent.route?.custom && !agent.route?.allowed);
|
|
1142
|
+
const stage = !agent.detected ? 'missing' : !agent.ready ? 'inert' : routeBlocked ? 'route-blocked' : signedIn ? 'ready' : 'signed-out';
|
|
1087
1143
|
const head = el('div', 'head');
|
|
1088
1144
|
head.append(avatar(agent.id, { size: 30, status: stage === 'ready' ? 'ready' : agent.detected ? 'detected' : 'offline' }));
|
|
1089
1145
|
const title = el('div', 'title');
|
|
1090
1146
|
title.append(el('b', null, agent.label));
|
|
1091
1147
|
title.append(el('span', 'vendor', brandOf(agent.id).vendor));
|
|
1092
1148
|
head.append(title);
|
|
1093
|
-
head.append(el('span', `state ${stage}`, stage === 'missing' ? t('NOT INSTALLED') : stage === 'inert' ? t('NO ADAPTER') : stage === 'ready' ? t('READY') : t('SIGNED OUT')));
|
|
1149
|
+
head.append(el('span', `state ${stage}`, stage === 'missing' ? t('NOT INSTALLED') : stage === 'inert' ? t('NO ADAPTER') : stage === 'route-blocked' ? t('ROUTE BLOCKED') : stage === 'ready' ? t('READY') : t('SIGNED OUT')));
|
|
1094
1150
|
card.append(head);
|
|
1095
1151
|
const detail = el('div', 'detail');
|
|
1096
1152
|
if (agent.id === 'madre') detail.append(ollamaDetail(agent));
|
|
1097
1153
|
else if (stage === 'missing') detail.append(agent.install ? t('Not on this computer · {command}', { command: agent.install.display }) : t('Not on this computer.'));
|
|
1098
|
-
else detail.append(`${agent.version ?? t('version unknown')}${session?.detail ? ` · ${session.detail}` : ''}`);
|
|
1154
|
+
else detail.append(`${agent.version ?? t('version unknown')}${session?.detail ? ` · ${session.detail}` : ''}${agent.route?.destination ? ` · ${t('route: {destination}', { destination: agent.route.destination })}` : ''}`);
|
|
1099
1155
|
card.append(detail);
|
|
1100
1156
|
// What is behind this door: the account it needs, and whether there is a way in without paying.
|
|
1101
1157
|
if (agent.account) {
|
|
@@ -1445,7 +1501,10 @@ function showReplyMenu(source, x, y) {
|
|
|
1445
1501
|
for (const agent of state.agents.values()) {
|
|
1446
1502
|
const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
|
|
1447
1503
|
item.type = 'button';
|
|
1448
|
-
|
|
1504
|
+
// The same twice-said name as the viewer's menu: the handle IS the name. Beside it goes only
|
|
1505
|
+
// what the handle does not carry — that this is the one who just spoke, its vendor, or why not.
|
|
1506
|
+
const aside = !agent.ready ? t('no session') : agent.id === source.sender ? t('the same agent') : (brandOf(agent.id).vendor ?? '');
|
|
1507
|
+
item.append(el('b', null, `@${agent.id}`), el('span', null, aside));
|
|
1449
1508
|
item.disabled = !agent.ready;
|
|
1450
1509
|
item.addEventListener('click', () => replyWith(agent.id, source));
|
|
1451
1510
|
replyMenu.append(item);
|
|
@@ -1637,6 +1696,11 @@ function renderThinking(event) {
|
|
|
1637
1696
|
return node;
|
|
1638
1697
|
}
|
|
1639
1698
|
|
|
1699
|
+
function warnUnknownModuleFields(installed) {
|
|
1700
|
+
const unknown = installed?.unknown ?? [];
|
|
1701
|
+
if (unknown.length) setTimeout(() => toast(t('MU/TH/UR › {name} declares {fields}, which this MADRE does not know. A typo, or a field from a newer version.', { name: installed.name, fields: unknown.join(', ') })), 2600);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1640
1704
|
// The archivist reports: which agent read which stretch of the room and how many notes it kept.
|
|
1641
1705
|
// module · @codex wrote read-mail.module.mjs · INSTALL FOR EVERY ROOM · INSTALL FOR THIS PROJECT · LATER
|
|
1642
1706
|
function renderModuleProposed(event) {
|
|
@@ -1652,6 +1716,7 @@ function renderModuleProposed(event) {
|
|
|
1652
1716
|
const payload = await fetch('/api/extensions/install-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path, scope }) }).then((response) => response.json());
|
|
1653
1717
|
if (payload.error) throw new Error(payload.error);
|
|
1654
1718
|
toast(t('MU/TH/UR › {name} installed for {where}. Switch it on in MODULES.', { name: payload.installed.name, where: scope === 'project' ? t('this project') : t('every room') }));
|
|
1719
|
+
warnUnknownModuleFields(payload.installed);
|
|
1655
1720
|
modules.items = payload.extensions ?? modules.items; if (modules.dialog.open) renderModules();
|
|
1656
1721
|
} catch (error) { toast(t('The module was not installed: {error}', { error: error.message })); button.disabled = false; }
|
|
1657
1722
|
};
|
|
@@ -2394,12 +2459,12 @@ function renderEventNode(event) {
|
|
|
2394
2459
|
case 'mode.requested': node = renderModeRequest(event); break;
|
|
2395
2460
|
case 'control.started': node = renderControlStarted(event); break;
|
|
2396
2461
|
case 'create.reverted': node = renderCreateReverted(event); break;
|
|
2397
|
-
case 'control.changed': node = renderControlChanged(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); break;
|
|
2462
|
+
case 'control.changed': markTouched(event.payload.agent, event.payload.files); node = renderControlChanged(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); break;
|
|
2398
2463
|
case 'control.reverted': node = renderControlReverted(event); break;
|
|
2399
2464
|
case 'mode.granted':
|
|
2400
2465
|
case 'mode.denied': settleModeRequest(event); return;
|
|
2401
2466
|
case 'plan.ignored': node = renderPlanIgnored(event); break;
|
|
2402
|
-
case 'artifacts.created': attachArtifacts(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); return;
|
|
2467
|
+
case 'artifacts.created': markTouched(event.payload.agent, event.payload.files); attachArtifacts(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); return;
|
|
2403
2468
|
case 'command.output': node = renderCommandCard(event); break;
|
|
2404
2469
|
case 'eyecat.flagged': node = renderEyecat(event); break;
|
|
2405
2470
|
case 'eyecat.settled': settleEyecat(event); return;
|
|
@@ -2563,6 +2628,13 @@ function treeNode(path, entry) {
|
|
|
2563
2628
|
item.append(button, children);
|
|
2564
2629
|
} else {
|
|
2565
2630
|
button.append(el('span', 'caret', ''), el('span', 'name', entry.name), el('span', 'size', formatSize(entry.size)));
|
|
2631
|
+
const by = touched.get(path);
|
|
2632
|
+
if (by) {
|
|
2633
|
+
// Its colour is the agent's own, so the tree reads the same way the thread does.
|
|
2634
|
+
const dot = paint(el('span', 'touched'), by);
|
|
2635
|
+
dot.title = t('{agent} wrote this file in this room', { agent: `@${by}` });
|
|
2636
|
+
button.append(dot);
|
|
2637
|
+
}
|
|
2566
2638
|
button.addEventListener('click', () => { void openViewer({ root: 'project', path, label: path }); });
|
|
2567
2639
|
item.append(button);
|
|
2568
2640
|
}
|
|
@@ -2650,10 +2722,14 @@ async function loadChats() {
|
|
|
2650
2722
|
}
|
|
2651
2723
|
|
|
2652
2724
|
async function openChat(id) {
|
|
2725
|
+
if (id === chats.active) { setChats(false); return; }
|
|
2653
2726
|
const response = await fetch(`/api/chats/${id}`, { method: 'POST' });
|
|
2654
2727
|
const result = await response.json().catch(() => ({}));
|
|
2655
2728
|
if (!response.ok) { toast(`MU/TH/UR › ${result.error ?? 'that conversation could not be opened.'}`); return; }
|
|
2656
|
-
|
|
2729
|
+
const payload = await fetch('/api/state').then((one) => one.json()).catch(() => null);
|
|
2730
|
+
if (!payload) { window.location.reload(); return; }
|
|
2731
|
+
await paintConversation(payload);
|
|
2732
|
+
setChats(false);
|
|
2657
2733
|
}
|
|
2658
2734
|
|
|
2659
2735
|
function setChats(open) {
|
|
@@ -2750,7 +2826,7 @@ document.querySelector('#mother-button')?.addEventListener('mouseleave', endHove
|
|
|
2750
2826
|
document.querySelector('#mother-button')?.addEventListener('click', endHover);
|
|
2751
2827
|
|
|
2752
2828
|
// Debug surface for tests and for the curious: window.__pulse.trackHold(true, t)
|
|
2753
|
-
globalThis.__pulse = { trackHold, armExpendable, disarmExpendable, beginHover, endHover, state, moduleCard, openCore: (...args) => openCore(...args), get core() { return core; } };
|
|
2829
|
+
globalThis.__pulse = { trackHold, armExpendable, disarmExpendable, beginHover, endHover, state, moduleCard, runSlashCommand, syncModuleComposer, openCore: (...args) => openCore(...args), get core() { return core; } };
|
|
2754
2830
|
|
|
2755
2831
|
els.thread.addEventListener('wheel', (event) => trackHold(event.deltaY > 0), { passive: true });
|
|
2756
2832
|
let touchY = null;
|
|
@@ -2825,10 +2901,59 @@ function setConnection(value) {
|
|
|
2825
2901
|
}
|
|
2826
2902
|
if (typeof ResizeObserver === 'function' && els.composer) new ResizeObserver(placeToast).observe(els.composer);
|
|
2827
2903
|
window.addEventListener?.('resize', placeToast);
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
stream
|
|
2831
|
-
stream
|
|
2904
|
+
let stream = null;
|
|
2905
|
+
function connectStream() {
|
|
2906
|
+
stream?.close?.();
|
|
2907
|
+
stream = new EventSource(`/api/events?since=${state.lastSequence}`);
|
|
2908
|
+
stream.onopen = () => { setConnection('live'); replaying = false; };
|
|
2909
|
+
stream.onerror = () => setConnection('reconnecting');
|
|
2910
|
+
stream.onmessage = ({ data }) => renderEvent(JSON.parse(data));
|
|
2911
|
+
}
|
|
2912
|
+
connectStream();
|
|
2913
|
+
|
|
2914
|
+
// Everything the thread on screen knows that belongs to ONE conversation. Switching used to
|
|
2915
|
+
// reload the page, which is the brute way to be sure none of this was left over — and it cost a
|
|
2916
|
+
// whole boot: every fetch again, every panel again, the stream again. Repainting in place is
|
|
2917
|
+
// only safe while this list is complete, so it is written out rather than guessed at.
|
|
2918
|
+
function forgetConversation() {
|
|
2919
|
+
state.seen.clear();
|
|
2920
|
+
state.userMessages.clear();
|
|
2921
|
+
state.ratings.clear();
|
|
2922
|
+
state.ratingNodes.clear();
|
|
2923
|
+
state.running.clear();
|
|
2924
|
+
state.plansRunning.clear();
|
|
2925
|
+
state.agentStats.clear();
|
|
2926
|
+
touched.clear();
|
|
2927
|
+
state.failures.length = 0;
|
|
2928
|
+
state.pending.length = 0;
|
|
2929
|
+
state.replyTo = null;
|
|
2930
|
+
state.lastSender = null;
|
|
2931
|
+
state.lastSequence = 0;
|
|
2932
|
+
state.brakeArmed = false;
|
|
2933
|
+
els.column.replaceChildren();
|
|
2934
|
+
renderReplyQuote();
|
|
2935
|
+
paintMotherBadge();
|
|
2936
|
+
updateStopAll();
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
// The new conversation, painted where the old one was. The room, the crew and the archive do not
|
|
2940
|
+
// change between conversations — only the transcript does.
|
|
2941
|
+
async function paintConversation(payload) {
|
|
2942
|
+
replaying = true;
|
|
2943
|
+
forgetConversation();
|
|
2944
|
+
chats.list = payload.chats?.chats ?? chats.list;
|
|
2945
|
+
chats.active = payload.chats?.active ?? chats.active;
|
|
2946
|
+
renderChats();
|
|
2947
|
+
const here = chats.list.find((chat) => chat.id === chats.active);
|
|
2948
|
+
const mark = document.querySelector('.project.chat-here');
|
|
2949
|
+
if (mark) mark.textContent = here && chats.list.length > 1 ? here.title : '';
|
|
2950
|
+
for (const event of payload.events ?? []) renderEvent(event);
|
|
2951
|
+
renderAgents();
|
|
2952
|
+
renderPicker();
|
|
2953
|
+
renderOnboarding();
|
|
2954
|
+
scrollToEnd();
|
|
2955
|
+
connectStream();
|
|
2956
|
+
}
|
|
2832
2957
|
|
|
2833
2958
|
/* ---------- composer ---------- */
|
|
2834
2959
|
|
|
@@ -2850,12 +2975,52 @@ els.input.addEventListener('input', autosize);
|
|
|
2850
2975
|
els.input.addEventListener('scroll', syncHighlightScroll);
|
|
2851
2976
|
autosize();
|
|
2852
2977
|
|
|
2978
|
+
// /module has its own visual language before it ever reaches an agent. The class is also the
|
|
2979
|
+
// animation trigger: it is added once when the prefix becomes a command, and removed when the
|
|
2980
|
+
// human edits away from it or the message leaves the composer.
|
|
2981
|
+
const MODULE_COMMAND = /^\/module(?:\s|$)/i;
|
|
2982
|
+
// A refusal that only flashes is a refusal the room forgets. `/module` needs #2 because writing a
|
|
2983
|
+
// module is writing a file, so the answer lands in the thread where every other decision of the
|
|
2984
|
+
// room lands — and it carries the way out: the button that arms the mode, without arming it.
|
|
2985
|
+
function moduleModeNotice() {
|
|
2986
|
+
const node = el('div', 'system alert module-notice');
|
|
2987
|
+
node.append(el('b', null, t('MU/TH/UR › ')));
|
|
2988
|
+
node.append(t('/module writes a file, so it needs #2 CREATE. Your request is still in the composer.'));
|
|
2989
|
+
const arm = el('button', 'cmd', t('ARM #2 CREATE'));
|
|
2990
|
+
arm.type = 'button';
|
|
2991
|
+
// The human arms it, here as everywhere: the command asked, the click grants.
|
|
2992
|
+
arm.addEventListener('click', () => { setMode(2, { wink: true }); els.input.focus(); });
|
|
2993
|
+
node.append(arm);
|
|
2994
|
+
removeEmpty();
|
|
2995
|
+
els.column.append(node);
|
|
2996
|
+
node.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
function askForModuleMode() {
|
|
3000
|
+
toast(t('MU/TH/UR › /module needs #2 CREATE. Choose #2 in the mode chip, then send again.'));
|
|
3001
|
+
}
|
|
3002
|
+
function syncModuleComposer() {
|
|
3003
|
+
const active = MODULE_COMMAND.test(els.input.value.trimStart());
|
|
3004
|
+
if (state.moduleCommand === active) return;
|
|
3005
|
+
state.moduleCommand = active;
|
|
3006
|
+
els.composer.classList.toggle('module-command', active);
|
|
3007
|
+
updateCrewLabel();
|
|
3008
|
+
updatePlaceholder();
|
|
3009
|
+
if (active && !state.create) askForModuleMode();
|
|
3010
|
+
}
|
|
3011
|
+
els.input.addEventListener('input', syncModuleComposer);
|
|
3012
|
+
syncModuleComposer();
|
|
3013
|
+
|
|
2853
3014
|
/* ---------- labels inside the field: @agent mentions and /commands ---------- */
|
|
2854
3015
|
|
|
2855
3016
|
function allCommands() { return [...CLIENT_COMMANDS, ...(state.commands ?? [])]; }
|
|
2856
3017
|
function knownAgentIds() { return [...state.agents.keys()]; }
|
|
2857
3018
|
function escapeHtml(text) { return text.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); }
|
|
2858
3019
|
|
|
3020
|
+
// The layer that draws over the field. Its one law: it decorates the characters that are there
|
|
3021
|
+
// and never adds, removes or re-typesets one. The caret belongs to the textarea underneath and is
|
|
3022
|
+
// placed by the textarea's own metrics — so the instant this layer writes a word the human did
|
|
3023
|
+
// not type, or draws it in another font, the two disagree and the caret lands inside a pill.
|
|
2859
3024
|
function renderHighlight() {
|
|
2860
3025
|
const text = els.input.value;
|
|
2861
3026
|
const agents = knownAgentIds();
|
|
@@ -2864,7 +3029,11 @@ function renderHighlight() {
|
|
|
2864
3029
|
const key = name.toLowerCase();
|
|
2865
3030
|
if (sigil === '#') {
|
|
2866
3031
|
if (!/^[0-4]$/.test(name)) return whole;
|
|
2867
|
-
|
|
3032
|
+
// Only what was typed. This used to spell the mode out — `#2` drawn as `#2 CREATE` — and
|
|
3033
|
+
// every character after it sat seven places to the right of its own caret. The name of the
|
|
3034
|
+
// mode is already on the chip above the field; here the colour is enough.
|
|
3035
|
+
const chip = `<span class="chip mode m${name}" title="${escapeHtml(MODES[Number(name)].label)}">#${name}</span>`;
|
|
3036
|
+
return `${lead}${chip}`;
|
|
2868
3037
|
}
|
|
2869
3038
|
if (sigil === '!') {
|
|
2870
3039
|
if (!/\.[A-Za-z0-9]{1,8}(?::\d+(?:-\d+)?)?$/.test(name)) return whole;
|
|
@@ -2961,6 +3130,7 @@ function pickMenu(index = menu.index) {
|
|
|
2961
3130
|
els.input.setSelectionRange(caret, caret);
|
|
2962
3131
|
closeMenu();
|
|
2963
3132
|
autosize();
|
|
3133
|
+
syncModuleComposer();
|
|
2964
3134
|
els.input.focus();
|
|
2965
3135
|
}
|
|
2966
3136
|
els.input.addEventListener('input', () => { menu.index = 0; renderMenu(); });
|
|
@@ -2982,8 +3152,17 @@ async function runSlashCommand(text) {
|
|
|
2982
3152
|
const name = match[1].toLowerCase();
|
|
2983
3153
|
const rest = (match[2] ?? '').trim();
|
|
2984
3154
|
if (name === 'stopall') { await stopAll(); return { handled: true }; }
|
|
2985
|
-
if (name === 'create' || name === 'image') {
|
|
2986
|
-
if (!rest) {
|
|
3155
|
+
if (name === 'create' || name === 'image' || name === 'module') {
|
|
3156
|
+
if (!rest) {
|
|
3157
|
+
toast(name === 'module'
|
|
3158
|
+
? t('MU/TH/UR › /module needs a job, e.g. "/module summarize today’s commits".')
|
|
3159
|
+
: t('MU/TH/UR › /{name} needs a request after it, e.g. "/{name} a poster for the launch".', { name }));
|
|
3160
|
+
return { handled: true };
|
|
3161
|
+
}
|
|
3162
|
+
if (name === 'module' && !state.create) {
|
|
3163
|
+
moduleModeNotice();
|
|
3164
|
+
return { handled: true, preserve: true };
|
|
3165
|
+
}
|
|
2987
3166
|
let target = els.target.value;
|
|
2988
3167
|
if (name === 'image') {
|
|
2989
3168
|
const capable = [...state.agents.values()].filter((agent) => agent.ready && state.capabilities[agent.id]?.scopes?.imageGen?.enabled).map((agent) => agent.id);
|
|
@@ -2996,6 +3175,13 @@ async function runSlashCommand(text) {
|
|
|
2996
3175
|
}
|
|
2997
3176
|
}
|
|
2998
3177
|
if (!state.create) setMode(2);
|
|
3178
|
+
if (name === 'module') {
|
|
3179
|
+
return {
|
|
3180
|
+
handled: false,
|
|
3181
|
+
target,
|
|
3182
|
+
text: t('Create a MADRE module for this request: {request} Write exactly one <id>.module.mjs file for review. Do not install it; MADRE will show the human an install card.', { request: rest }),
|
|
3183
|
+
};
|
|
3184
|
+
}
|
|
2999
3185
|
return { handled: false, text: rest, target };
|
|
3000
3186
|
}
|
|
3001
3187
|
const known = allCommands().find((item) => item.name === name);
|
|
@@ -3093,6 +3279,7 @@ function updateCrewLabel() {
|
|
|
3093
3279
|
const order = state.ash && state.ashInstalled;
|
|
3094
3280
|
els.crewLabel.textContent = state.intruder && state.mode < 3 ? t('INTRUDER ›') : state.mode >= 3 ? `MU/TH/UR · ${MODES[state.mode].label} @${(state.modeArmedFor ?? els.target.value ?? '').toUpperCase()} ›`
|
|
3095
3281
|
: state.mode === 0 ? t('HUMAN · GHOST ›')
|
|
3282
|
+
: state.moduleCommand ? (state.create ? 'MU/TH/UR · MODULE · CREATE ›' : t('MU/TH/UR · MODULE · NEEDS #2 ›'))
|
|
3096
3283
|
: order ? (state.create ? 'MU/TH/UR · ASH · CREATE ›' : 'MU/TH/UR · ASH ›')
|
|
3097
3284
|
: state.create ? t('HUMAN · CREATE ›')
|
|
3098
3285
|
: state.expendable ? t('CREW · EXPENDABLE ›') : t('HUMAN ›');
|
|
@@ -3206,6 +3393,7 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
3206
3393
|
els.input.value = '';
|
|
3207
3394
|
els.composer.classList.remove('stopall');
|
|
3208
3395
|
autosize();
|
|
3396
|
+
syncModuleComposer();
|
|
3209
3397
|
await stopAll();
|
|
3210
3398
|
return;
|
|
3211
3399
|
}
|
|
@@ -3235,7 +3423,13 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
3235
3423
|
els.input.disabled = true;
|
|
3236
3424
|
const result = await runSlashCommand(text).catch((error) => { toast(t('The command failed: {error}', { error: error.message })); return { handled: true }; });
|
|
3237
3425
|
els.input.disabled = false;
|
|
3238
|
-
if (result.handled) {
|
|
3426
|
+
if (result.handled) {
|
|
3427
|
+
if (!result.preserve) els.input.value = '';
|
|
3428
|
+
autosize();
|
|
3429
|
+
syncModuleComposer();
|
|
3430
|
+
els.input.focus();
|
|
3431
|
+
return;
|
|
3432
|
+
}
|
|
3239
3433
|
outgoing = result.text ?? text;
|
|
3240
3434
|
target = result.target ?? target;
|
|
3241
3435
|
}
|
|
@@ -3259,6 +3453,7 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
3259
3453
|
renderPendingAttachments();
|
|
3260
3454
|
resetModeAfterSend(); // one mode per message; standing leases fall back to #2, everyone else to #1
|
|
3261
3455
|
autosize();
|
|
3456
|
+
syncModuleComposer();
|
|
3262
3457
|
}
|
|
3263
3458
|
} catch (error) {
|
|
3264
3459
|
toast(t('Could not reach MADRE: {error}', { error: error.message }));
|
|
@@ -3844,8 +4039,30 @@ function metrics(box, rows) {
|
|
|
3844
4039
|
}
|
|
3845
4040
|
|
|
3846
4041
|
// Every card has the same floors in the same order, whatever the module is: what it is, what it
|
|
3847
|
-
// does, what it touches, how it is doing — and then
|
|
3848
|
-
//
|
|
4042
|
+
// does, what it touches, how it is doing — and then its actions, with the main switch last. A DEV
|
|
4043
|
+
// module also offers uninstall there; a module MADRE ships never does.
|
|
4044
|
+
function removeModuleButton(item, { className = 'remove-module', text = t('UNINSTALL') } = {}) {
|
|
4045
|
+
const remove = el('button', className, text);
|
|
4046
|
+
remove.type = 'button';
|
|
4047
|
+
remove.title = t('Delete module file {file}', { file: item.file });
|
|
4048
|
+
remove.addEventListener('click', async () => {
|
|
4049
|
+
if (!window.confirm(t("Remove {name}? Its file {file} is deleted. MADRE's own modules cannot be removed.", { name: item.name, file: item.file }))) return;
|
|
4050
|
+
remove.disabled = true;
|
|
4051
|
+
try {
|
|
4052
|
+
const response = await fetch(`/api/extensions/${item.id}`, { method: 'DELETE' });
|
|
4053
|
+
const payload = await response.json().catch(() => ({}));
|
|
4054
|
+
if (!response.ok) throw new Error(payload.error ?? `HTTP ${response.status}`);
|
|
4055
|
+
modules.items = payload.extensions ?? modules.items;
|
|
4056
|
+
toast(t('MU/TH/UR › {name} removed.', { name: item.name }));
|
|
4057
|
+
renderModules();
|
|
4058
|
+
} catch (error) {
|
|
4059
|
+
toast(t('It was not removed: {error}', { error: error.message }));
|
|
4060
|
+
remove.disabled = false;
|
|
4061
|
+
}
|
|
4062
|
+
});
|
|
4063
|
+
return remove;
|
|
4064
|
+
}
|
|
4065
|
+
|
|
3849
4066
|
function cardShell(item, { on, state: stateText }) {
|
|
3850
4067
|
const card = el('article', `module-card${on ? '' : ' off'}`);
|
|
3851
4068
|
card.id = `module-${item.id}`;
|
|
@@ -3933,9 +4150,10 @@ function cardShell(item, { on, state: stateText }) {
|
|
|
3933
4150
|
}
|
|
3934
4151
|
|
|
3935
4152
|
// FLOOR 4 · the module's own: what it reads, what it lets you set.
|
|
3936
|
-
// FLOOR 5 ·
|
|
4153
|
+
// FLOOR 5 · actions at the bottom: DEV uninstall first, the main switch last.
|
|
3937
4154
|
const panel = el('div', 'card-panel');
|
|
3938
4155
|
const actions = el('div', 'actions');
|
|
4156
|
+
if (item.external) actions.append(removeModuleButton(item));
|
|
3939
4157
|
card.append(panel, actions);
|
|
3940
4158
|
return { card, panel, actions };
|
|
3941
4159
|
}
|
|
@@ -4012,6 +4230,10 @@ modules.file?.addEventListener('change', async () => {
|
|
|
4012
4230
|
modules.items = result.extensions ?? modules.items;
|
|
4013
4231
|
renderModules();
|
|
4014
4232
|
toast(`MU/TH/UR › ${t('{name} installed', { name: result.installed.name })}${result.installed.version ? ` · v${result.installed.version}` : ''}. ${t('It loads in every room on this computer.')}`);
|
|
4233
|
+
// A key the contract does not answer to. Not fatal — a module written for a newer MADRE may
|
|
4234
|
+
// carry fields this one has not learned — but a misspelling is far likelier, and it used to
|
|
4235
|
+
// install in silence and leave the author wondering why their card came out empty.
|
|
4236
|
+
warnUnknownModuleFields(result.installed);
|
|
4015
4237
|
} catch (error) { toast(t('The module was not installed: {error}', { error: error.message })); }
|
|
4016
4238
|
finally { modules.add.disabled = false; }
|
|
4017
4239
|
});
|
|
@@ -4403,17 +4625,7 @@ function renderModulesDev() {
|
|
|
4403
4625
|
for (const item of yours) {
|
|
4404
4626
|
const chip = el('span', 'dev-chip');
|
|
4405
4627
|
chip.append(el('b', null, item.name), ` · ${item.origin === 'project' ? 'this project' : 'every room'} `);
|
|
4406
|
-
|
|
4407
|
-
remove.addEventListener('click', async () => {
|
|
4408
|
-
if (!window.confirm(t("Remove {name}? Its file {file} is deleted. MADRE's own modules cannot be removed.", { name: item.name, file: item.file }))) return;
|
|
4409
|
-
remove.disabled = true;
|
|
4410
|
-
try {
|
|
4411
|
-
const payload = await fetch(`/api/extensions/${item.id}`, { method: 'DELETE' }).then((response) => response.json());
|
|
4412
|
-
if (payload.error) throw new Error(payload.error);
|
|
4413
|
-
modules.items = payload.extensions ?? modules.items; toast(t('MU/TH/UR › {name} removed.', { name: item.name })); renderModules();
|
|
4414
|
-
} catch (error) { toast(t('It was not removed: {error}', { error: error.message })); remove.disabled = false; }
|
|
4415
|
-
});
|
|
4416
|
-
chip.append(remove);
|
|
4628
|
+
chip.append(removeModuleButton(item, { className: 'act-link', text: t('REMOVE') }));
|
|
4417
4629
|
list.append(chip);
|
|
4418
4630
|
}
|
|
4419
4631
|
body.append(list);
|