@jossuealcala/madre 0.4.0 → 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 +164 -0
- package/README.md +1 -1
- 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 +414 -64
- package/public/es.js +246 -5
- package/public/index.html +4 -2
- package/public/styles.css +122 -14
- package/public/syntax.js +121 -0
- package/public/troubleshooting.js +100 -0
- package/src/adapters/codex.mjs +4 -1
- package/src/adapters/process.mjs +62 -15
- package/src/mcp/image-server.mjs +24 -2
- package/src/modules/image-studio.mjs +3 -2
- package/src/modules/index.mjs +23 -5
- package/src/modules/playwright.mjs +9 -5
- package/src/modules/sdk.mjs +15 -0
- package/src/outbound.mjs +7 -2
- package/src/room.mjs +12 -0
- package/src/runtime-detection.mjs +50 -0
- package/src/sentinel-errors.mjs +19 -1
- package/src/server.mjs +23 -12
- 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,12 +124,35 @@ 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
|
|
128
131
|
};
|
|
129
132
|
try { state.chosenModel = JSON.parse(localStorage.getItem('pulse.chosenModel') ?? '{}') || {}; } catch { state.chosenModel = {}; }
|
|
130
133
|
|
|
134
|
+
// What the human has already dealt with. The ledger is history and is never rewritten, so a
|
|
135
|
+
// dismissal is a reading preference and lives where reading preferences live: this browser.
|
|
136
|
+
// Keyed by what a record IS — time, agent and the first line — so the same entry replayed from
|
|
137
|
+
// the ledger on the next load is still the one that was dismissed.
|
|
138
|
+
const dismissed = new Set();
|
|
139
|
+
try { for (const key of JSON.parse(localStorage.getItem('pulse.mother.dismissed') ?? '[]')) dismissed.add(key); } catch { /* no storage */ }
|
|
140
|
+
const failureKey = (failure) => `${failure.time ?? ''}|${failure.agent ?? ''}|${String(failure.error ?? '').split('\n')[0].slice(0, 120)}`;
|
|
141
|
+
const saveDismissed = () => { try { localStorage.setItem('pulse.mother.dismissed', JSON.stringify([...dismissed].slice(-400))); } catch { /* no storage */ } };
|
|
142
|
+
const openFailures = () => state.failures.filter((failure) => !failure.recovered && !dismissed.has(failureKey(failure)));
|
|
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
|
+
|
|
155
|
+
|
|
131
156
|
|
|
132
157
|
/* ---------- helpers ---------- */
|
|
133
158
|
|
|
@@ -286,6 +311,8 @@ const viewer = {
|
|
|
286
311
|
const viewerUI = {
|
|
287
312
|
selection: document.querySelector('#viewer-selection'),
|
|
288
313
|
review: document.querySelector('#viewer-review'),
|
|
314
|
+
lang: document.querySelector('#viewer-lang'),
|
|
315
|
+
hintSelect: document.querySelector('#viewer-hint-select'),
|
|
289
316
|
menu: document.querySelector('#viewer-menu'),
|
|
290
317
|
path: null, // project-relative path of the open text file, if any
|
|
291
318
|
from: null, to: null,
|
|
@@ -301,6 +328,9 @@ function setSelection(from, to) {
|
|
|
301
328
|
const has = from !== null;
|
|
302
329
|
viewerUI.selection.hidden = !has;
|
|
303
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;
|
|
304
334
|
if (has) viewerUI.selection.textContent = lo === hi ? `L${lo}` : `L${lo}-${hi}`;
|
|
305
335
|
}
|
|
306
336
|
function referenceForSelection() {
|
|
@@ -312,7 +342,11 @@ function referenceForSelection() {
|
|
|
312
342
|
function reviewWith(agentId) {
|
|
313
343
|
const reference = referenceForSelection();
|
|
314
344
|
const current = els.input.value.trim();
|
|
315
|
-
|
|
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} `;
|
|
316
350
|
if (agentId && state.agents.get(agentId)?.ready) { els.target.value = agentId; renderPicker(); }
|
|
317
351
|
hideViewerMenu();
|
|
318
352
|
viewer.dialog.close();
|
|
@@ -322,17 +356,27 @@ function reviewWith(agentId) {
|
|
|
322
356
|
}
|
|
323
357
|
function showViewerMenu(x, y) {
|
|
324
358
|
const menuNode = viewerUI.menu;
|
|
325
|
-
|
|
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);
|
|
326
366
|
for (const agent of state.agents.values()) {
|
|
327
367
|
const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
|
|
328
368
|
item.type = 'button';
|
|
329
|
-
|
|
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));
|
|
330
373
|
item.disabled = !agent.ready;
|
|
331
374
|
item.addEventListener('click', () => reviewWith(agent.id));
|
|
332
375
|
menuNode.append(item);
|
|
333
376
|
}
|
|
334
377
|
menuNode.hidden = false;
|
|
335
|
-
|
|
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;
|
|
336
380
|
menuNode.style.left = `${Math.min(x, window.innerWidth - width - 12)}px`;
|
|
337
381
|
menuNode.style.top = `${Math.min(y, window.innerHeight - height - 12)}px`;
|
|
338
382
|
}
|
|
@@ -344,14 +388,26 @@ viewerUI.review?.addEventListener('click', (event) => {
|
|
|
344
388
|
viewer.dialog.addEventListener('click', (event) => { if (!viewerUI.menu.hidden && !viewerUI.menu.contains(event.target) && event.target !== viewerUI.review) hideViewerMenu(); });
|
|
345
389
|
viewer.dialog.addEventListener('close', () => { hideViewerMenu(); setSelection(null, null); ripley.frame = null; ripley.history = []; ripleyClearErrors(); if (ripley.nav) ripley.nav.hidden = true; });
|
|
346
390
|
|
|
347
|
-
function codeView(text, { line = null, lines = null } = {}) {
|
|
391
|
+
function codeView(text, { line = null, lines = null, name = null } = {}) {
|
|
348
392
|
const pre = el('pre', 'code');
|
|
349
393
|
viewerUI.lineNodes = [];
|
|
350
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 };
|
|
351
405
|
rows.forEach((content, index) => {
|
|
352
406
|
const n = index + 1;
|
|
353
407
|
const ln = el('span', 'ln', String(n));
|
|
354
|
-
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);
|
|
355
411
|
ln.dataset.n = n; tx.dataset.n = n;
|
|
356
412
|
const marked = lines ? n >= lines.from && n <= lines.to : n === line;
|
|
357
413
|
if (marked) { ln.classList.add('hl'); tx.classList.add('hl'); }
|
|
@@ -381,7 +437,7 @@ function showViewerSource() {
|
|
|
381
437
|
if (ripley.nav) ripley.nav.hidden = true;
|
|
382
438
|
ripleyClearErrors();
|
|
383
439
|
ripley.frame = null;
|
|
384
|
-
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 });
|
|
385
441
|
viewer.body.replaceChildren(pre);
|
|
386
442
|
if (!state.ripley && viewerMode.kind) {
|
|
387
443
|
const hint = el('div', 'viewer-hint');
|
|
@@ -761,7 +817,7 @@ function renderPicker() {
|
|
|
761
817
|
text.append(t('to '));
|
|
762
818
|
text.append(el('b', null, `@${current.id}`));
|
|
763
819
|
if (current.local) {
|
|
764
|
-
const note = el('span', 'pick-note', t('memory ·
|
|
820
|
+
const note = el('span', 'pick-note', t('memory · never writes'));
|
|
765
821
|
const measured = state.localModel?.checked;
|
|
766
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.')}${
|
|
767
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.')}`}`;
|
|
@@ -773,12 +829,18 @@ function renderPicker() {
|
|
|
773
829
|
modeChip.title = t('Permission mode for this message · {hint}', { hint: MODES[state.mode].hint });
|
|
774
830
|
modeChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModeMenu(current.id, modeChip); });
|
|
775
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.
|
|
776
835
|
const chosen = state.chosenModel[current.id];
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
+
}
|
|
782
844
|
els.picker.append(text);
|
|
783
845
|
}
|
|
784
846
|
if (typeof renderCreateScopes === 'function') renderCreateScopes();
|
|
@@ -870,7 +932,11 @@ function toggleModeMenu(id, anchor) {
|
|
|
870
932
|
const scopes = state.capabilities[id]?.scopes;
|
|
871
933
|
const canRaise = Boolean(scopes?.write?.capable);
|
|
872
934
|
const title = el('div', 'model-menu-title');
|
|
873
|
-
|
|
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 });
|
|
874
940
|
modeMenu.append(title);
|
|
875
941
|
const ladder = el('div', 'mode-ladder');
|
|
876
942
|
for (const n of [0, 1, 2, 3, 4]) {
|
|
@@ -1072,19 +1138,20 @@ function bridgeCard(agent) {
|
|
|
1072
1138
|
card.id = `bridge-${agent.id}`;
|
|
1073
1139
|
const session = state.sessions?.[agent.id];
|
|
1074
1140
|
const signedIn = session?.state === 'signed-in';
|
|
1075
|
-
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';
|
|
1076
1143
|
const head = el('div', 'head');
|
|
1077
1144
|
head.append(avatar(agent.id, { size: 30, status: stage === 'ready' ? 'ready' : agent.detected ? 'detected' : 'offline' }));
|
|
1078
1145
|
const title = el('div', 'title');
|
|
1079
1146
|
title.append(el('b', null, agent.label));
|
|
1080
1147
|
title.append(el('span', 'vendor', brandOf(agent.id).vendor));
|
|
1081
1148
|
head.append(title);
|
|
1082
|
-
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')));
|
|
1083
1150
|
card.append(head);
|
|
1084
1151
|
const detail = el('div', 'detail');
|
|
1085
1152
|
if (agent.id === 'madre') detail.append(ollamaDetail(agent));
|
|
1086
1153
|
else if (stage === 'missing') detail.append(agent.install ? t('Not on this computer · {command}', { command: agent.install.display }) : t('Not on this computer.'));
|
|
1087
|
-
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 })}` : ''}`);
|
|
1088
1155
|
card.append(detail);
|
|
1089
1156
|
// What is behind this door: the account it needs, and whether there is a way in without paying.
|
|
1090
1157
|
if (agent.account) {
|
|
@@ -1434,7 +1501,10 @@ function showReplyMenu(source, x, y) {
|
|
|
1434
1501
|
for (const agent of state.agents.values()) {
|
|
1435
1502
|
const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
|
|
1436
1503
|
item.type = 'button';
|
|
1437
|
-
|
|
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));
|
|
1438
1508
|
item.disabled = !agent.ready;
|
|
1439
1509
|
item.addEventListener('click', () => replyWith(agent.id, source));
|
|
1440
1510
|
replyMenu.append(item);
|
|
@@ -1626,6 +1696,11 @@ function renderThinking(event) {
|
|
|
1626
1696
|
return node;
|
|
1627
1697
|
}
|
|
1628
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
|
+
|
|
1629
1704
|
// The archivist reports: which agent read which stretch of the room and how many notes it kept.
|
|
1630
1705
|
// module · @codex wrote read-mail.module.mjs · INSTALL FOR EVERY ROOM · INSTALL FOR THIS PROJECT · LATER
|
|
1631
1706
|
function renderModuleProposed(event) {
|
|
@@ -1641,6 +1716,7 @@ function renderModuleProposed(event) {
|
|
|
1641
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());
|
|
1642
1717
|
if (payload.error) throw new Error(payload.error);
|
|
1643
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);
|
|
1644
1720
|
modules.items = payload.extensions ?? modules.items; if (modules.dialog.open) renderModules();
|
|
1645
1721
|
} catch (error) { toast(t('The module was not installed: {error}', { error: error.message })); button.disabled = false; }
|
|
1646
1722
|
};
|
|
@@ -2110,7 +2186,7 @@ function attachArtifacts(event) {
|
|
|
2110
2186
|
|
|
2111
2187
|
function artifactTiles(files) {
|
|
2112
2188
|
const wrap = el('div', 'artifacts');
|
|
2113
|
-
wrap.append(el('span', 'label', t('created · {n} files', { n: files.length })));
|
|
2189
|
+
wrap.append(el('span', 'label', t('created · {n} {files}', { n: files.length, files: files.length === 1 ? t('file') : t('files') })));
|
|
2114
2190
|
const tiles = el('div', 'files');
|
|
2115
2191
|
for (const file of files) {
|
|
2116
2192
|
const url = `/api/files?path=${encodeURIComponent(file.path)}`;
|
|
@@ -2383,12 +2459,12 @@ function renderEventNode(event) {
|
|
|
2383
2459
|
case 'mode.requested': node = renderModeRequest(event); break;
|
|
2384
2460
|
case 'control.started': node = renderControlStarted(event); break;
|
|
2385
2461
|
case 'create.reverted': node = renderCreateReverted(event); break;
|
|
2386
|
-
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;
|
|
2387
2463
|
case 'control.reverted': node = renderControlReverted(event); break;
|
|
2388
2464
|
case 'mode.granted':
|
|
2389
2465
|
case 'mode.denied': settleModeRequest(event); return;
|
|
2390
2466
|
case 'plan.ignored': node = renderPlanIgnored(event); break;
|
|
2391
|
-
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;
|
|
2392
2468
|
case 'command.output': node = renderCommandCard(event); break;
|
|
2393
2469
|
case 'eyecat.flagged': node = renderEyecat(event); break;
|
|
2394
2470
|
case 'eyecat.settled': settleEyecat(event); return;
|
|
@@ -2552,6 +2628,13 @@ function treeNode(path, entry) {
|
|
|
2552
2628
|
item.append(button, children);
|
|
2553
2629
|
} else {
|
|
2554
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
|
+
}
|
|
2555
2638
|
button.addEventListener('click', () => { void openViewer({ root: 'project', path, label: path }); });
|
|
2556
2639
|
item.append(button);
|
|
2557
2640
|
}
|
|
@@ -2639,10 +2722,14 @@ async function loadChats() {
|
|
|
2639
2722
|
}
|
|
2640
2723
|
|
|
2641
2724
|
async function openChat(id) {
|
|
2725
|
+
if (id === chats.active) { setChats(false); return; }
|
|
2642
2726
|
const response = await fetch(`/api/chats/${id}`, { method: 'POST' });
|
|
2643
2727
|
const result = await response.json().catch(() => ({}));
|
|
2644
2728
|
if (!response.ok) { toast(`MU/TH/UR › ${result.error ?? 'that conversation could not be opened.'}`); return; }
|
|
2645
|
-
|
|
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);
|
|
2646
2733
|
}
|
|
2647
2734
|
|
|
2648
2735
|
function setChats(open) {
|
|
@@ -2739,7 +2826,7 @@ document.querySelector('#mother-button')?.addEventListener('mouseleave', endHove
|
|
|
2739
2826
|
document.querySelector('#mother-button')?.addEventListener('click', endHover);
|
|
2740
2827
|
|
|
2741
2828
|
// Debug surface for tests and for the curious: window.__pulse.trackHold(true, t)
|
|
2742
|
-
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; } };
|
|
2743
2830
|
|
|
2744
2831
|
els.thread.addEventListener('wheel', (event) => trackHold(event.deltaY > 0), { passive: true });
|
|
2745
2832
|
let touchY = null;
|
|
@@ -2814,10 +2901,59 @@ function setConnection(value) {
|
|
|
2814
2901
|
}
|
|
2815
2902
|
if (typeof ResizeObserver === 'function' && els.composer) new ResizeObserver(placeToast).observe(els.composer);
|
|
2816
2903
|
window.addEventListener?.('resize', placeToast);
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
stream
|
|
2820
|
-
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
|
+
}
|
|
2821
2957
|
|
|
2822
2958
|
/* ---------- composer ---------- */
|
|
2823
2959
|
|
|
@@ -2839,12 +2975,52 @@ els.input.addEventListener('input', autosize);
|
|
|
2839
2975
|
els.input.addEventListener('scroll', syncHighlightScroll);
|
|
2840
2976
|
autosize();
|
|
2841
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
|
+
|
|
2842
3014
|
/* ---------- labels inside the field: @agent mentions and /commands ---------- */
|
|
2843
3015
|
|
|
2844
3016
|
function allCommands() { return [...CLIENT_COMMANDS, ...(state.commands ?? [])]; }
|
|
2845
3017
|
function knownAgentIds() { return [...state.agents.keys()]; }
|
|
2846
3018
|
function escapeHtml(text) { return text.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); }
|
|
2847
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.
|
|
2848
3024
|
function renderHighlight() {
|
|
2849
3025
|
const text = els.input.value;
|
|
2850
3026
|
const agents = knownAgentIds();
|
|
@@ -2853,7 +3029,11 @@ function renderHighlight() {
|
|
|
2853
3029
|
const key = name.toLowerCase();
|
|
2854
3030
|
if (sigil === '#') {
|
|
2855
3031
|
if (!/^[0-4]$/.test(name)) return whole;
|
|
2856
|
-
|
|
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}`;
|
|
2857
3037
|
}
|
|
2858
3038
|
if (sigil === '!') {
|
|
2859
3039
|
if (!/\.[A-Za-z0-9]{1,8}(?::\d+(?:-\d+)?)?$/.test(name)) return whole;
|
|
@@ -2950,6 +3130,7 @@ function pickMenu(index = menu.index) {
|
|
|
2950
3130
|
els.input.setSelectionRange(caret, caret);
|
|
2951
3131
|
closeMenu();
|
|
2952
3132
|
autosize();
|
|
3133
|
+
syncModuleComposer();
|
|
2953
3134
|
els.input.focus();
|
|
2954
3135
|
}
|
|
2955
3136
|
els.input.addEventListener('input', () => { menu.index = 0; renderMenu(); });
|
|
@@ -2971,8 +3152,17 @@ async function runSlashCommand(text) {
|
|
|
2971
3152
|
const name = match[1].toLowerCase();
|
|
2972
3153
|
const rest = (match[2] ?? '').trim();
|
|
2973
3154
|
if (name === 'stopall') { await stopAll(); return { handled: true }; }
|
|
2974
|
-
if (name === 'create' || name === 'image') {
|
|
2975
|
-
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
|
+
}
|
|
2976
3166
|
let target = els.target.value;
|
|
2977
3167
|
if (name === 'image') {
|
|
2978
3168
|
const capable = [...state.agents.values()].filter((agent) => agent.ready && state.capabilities[agent.id]?.scopes?.imageGen?.enabled).map((agent) => agent.id);
|
|
@@ -2985,6 +3175,13 @@ async function runSlashCommand(text) {
|
|
|
2985
3175
|
}
|
|
2986
3176
|
}
|
|
2987
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
|
+
}
|
|
2988
3185
|
return { handled: false, text: rest, target };
|
|
2989
3186
|
}
|
|
2990
3187
|
const known = allCommands().find((item) => item.name === name);
|
|
@@ -3082,6 +3279,7 @@ function updateCrewLabel() {
|
|
|
3082
3279
|
const order = state.ash && state.ashInstalled;
|
|
3083
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()} ›`
|
|
3084
3281
|
: state.mode === 0 ? t('HUMAN · GHOST ›')
|
|
3282
|
+
: state.moduleCommand ? (state.create ? 'MU/TH/UR · MODULE · CREATE ›' : t('MU/TH/UR · MODULE · NEEDS #2 ›'))
|
|
3085
3283
|
: order ? (state.create ? 'MU/TH/UR · ASH · CREATE ›' : 'MU/TH/UR · ASH ›')
|
|
3086
3284
|
: state.create ? t('HUMAN · CREATE ›')
|
|
3087
3285
|
: state.expendable ? t('CREW · EXPENDABLE ›') : t('HUMAN ›');
|
|
@@ -3195,6 +3393,7 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
3195
3393
|
els.input.value = '';
|
|
3196
3394
|
els.composer.classList.remove('stopall');
|
|
3197
3395
|
autosize();
|
|
3396
|
+
syncModuleComposer();
|
|
3198
3397
|
await stopAll();
|
|
3199
3398
|
return;
|
|
3200
3399
|
}
|
|
@@ -3224,7 +3423,13 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
3224
3423
|
els.input.disabled = true;
|
|
3225
3424
|
const result = await runSlashCommand(text).catch((error) => { toast(t('The command failed: {error}', { error: error.message })); return { handled: true }; });
|
|
3226
3425
|
els.input.disabled = false;
|
|
3227
|
-
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
|
+
}
|
|
3228
3433
|
outgoing = result.text ?? text;
|
|
3229
3434
|
target = result.target ?? target;
|
|
3230
3435
|
}
|
|
@@ -3248,6 +3453,7 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
3248
3453
|
renderPendingAttachments();
|
|
3249
3454
|
resetModeAfterSend(); // one mode per message; standing leases fall back to #2, everyone else to #1
|
|
3250
3455
|
autosize();
|
|
3456
|
+
syncModuleComposer();
|
|
3251
3457
|
}
|
|
3252
3458
|
} catch (error) {
|
|
3253
3459
|
toast(t('Could not reach MADRE: {error}', { error: error.message }));
|
|
@@ -3276,27 +3482,63 @@ const mother = {
|
|
|
3276
3482
|
bootTimer: null,
|
|
3277
3483
|
};
|
|
3278
3484
|
|
|
3279
|
-
function
|
|
3280
|
-
state.failures.push(entry);
|
|
3281
|
-
const count = state.failures.filter((failure) => !failure.recovered).length;
|
|
3282
|
-
// Called while the transcript is still being replayed, before the MU/TH/UR
|
|
3283
|
-
// block below has initialised, so look the badge up directly.
|
|
3485
|
+
function paintMotherBadge() {
|
|
3284
3486
|
const badge = document.querySelector('#mother-count');
|
|
3285
3487
|
if (!badge) return;
|
|
3488
|
+
const count = openFailures().length;
|
|
3286
3489
|
badge.hidden = count === 0;
|
|
3287
3490
|
badge.textContent = String(count);
|
|
3288
3491
|
}
|
|
3289
3492
|
|
|
3493
|
+
function recordFailure(entry) {
|
|
3494
|
+
state.failures.push(entry);
|
|
3495
|
+
// Called while the transcript is still being replayed, before the MU/TH/UR
|
|
3496
|
+
// block below has initialised, so look the badge up directly.
|
|
3497
|
+
paintMotherBadge();
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
// The recorded conditions as a file. One row per record, the columns a person would ask for,
|
|
3501
|
+
// and the support code beside each so it can be looked up without this room open.
|
|
3502
|
+
function downloadConditionLog(records) {
|
|
3503
|
+
const cell = (value) => `"${String(value ?? '').replace(/"/g, '""').replace(/\r?\n/g, ' ')}"`;
|
|
3504
|
+
const header = ['when', 'agent', 'code', 'condition', 'error'].map(cell).join(',');
|
|
3505
|
+
const rows = [...records].reverse().map((failure) => {
|
|
3506
|
+
const matches = diagnose(failure.error, failure.agent);
|
|
3507
|
+
return [
|
|
3508
|
+
failure.time ?? '',
|
|
3509
|
+
failure.agent ?? 'room',
|
|
3510
|
+
matches.map((condition) => condition.code ?? condition.id).join(' ') || 'unclassified',
|
|
3511
|
+
matches.map((condition) => condition.id).join(' '),
|
|
3512
|
+
resay(String(failure.error ?? '').trim()),
|
|
3513
|
+
].map(cell).join(',');
|
|
3514
|
+
});
|
|
3515
|
+
// A BOM, or a spreadsheet opens the accents as mojibake.
|
|
3516
|
+
const blob = new Blob([`\uFEFF${[header, ...rows].join('\n')}\n`], { type: 'text/csv;charset=utf-8' });
|
|
3517
|
+
const url = URL.createObjectURL(blob);
|
|
3518
|
+
const link = el('a');
|
|
3519
|
+
link.href = url;
|
|
3520
|
+
link.download = `madre-conditions-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
3521
|
+
document.body.append(link);
|
|
3522
|
+
link.click();
|
|
3523
|
+
link.remove();
|
|
3524
|
+
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3290
3527
|
function commandBlock(lines) {
|
|
3291
3528
|
const block = el('div', 'mother-cmd');
|
|
3292
|
-
const copy = el('button', 'copy'
|
|
3529
|
+
const copy = el('button', 'copy');
|
|
3293
3530
|
copy.type = 'button';
|
|
3531
|
+
copy.title = t('COPY');
|
|
3532
|
+
copy.setAttribute('aria-label', t('COPY'));
|
|
3533
|
+
// Two sheets, drawn rather than written: a word here sat on top of the command it was offering.
|
|
3534
|
+
copy.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true" width="12" height="12"><rect x="5.5" y="5.5" width="8" height="9" rx="1.5" fill="none" stroke="currentColor"/><path d="M10.5 3.5H3.5a1 1 0 0 0-1 1v7" fill="none" stroke="currentColor"/></svg>';
|
|
3294
3535
|
const runnable = lines.filter((line) => !line.trim().startsWith('#'));
|
|
3295
3536
|
copy.addEventListener('click', async () => {
|
|
3296
3537
|
try {
|
|
3297
3538
|
await navigator.clipboard.writeText(runnable.join('\n'));
|
|
3298
|
-
copy.
|
|
3299
|
-
|
|
3539
|
+
copy.classList.add('done');
|
|
3540
|
+
copy.title = t('COPIED');
|
|
3541
|
+
setTimeout(() => { copy.classList.remove('done'); copy.title = t('COPY'); }, 1500);
|
|
3300
3542
|
} catch {
|
|
3301
3543
|
copy.textContent = t('SELECT');
|
|
3302
3544
|
}
|
|
@@ -3304,19 +3546,47 @@ function commandBlock(lines) {
|
|
|
3304
3546
|
block.append(copy);
|
|
3305
3547
|
const pre = el('pre');
|
|
3306
3548
|
for (const line of lines) {
|
|
3307
|
-
|
|
3549
|
+
// A command is a command in every language and is never touched. A comment is not a command:
|
|
3550
|
+
// it is prose a person reads, and half of them name buttons — which in this room are called
|
|
3551
|
+
// something else. The catalogue's guard walks these the way it walks a condition's title.
|
|
3552
|
+
const comment = line.trim().startsWith('#');
|
|
3553
|
+
const span = el('span', comment ? 'c' : null, comment ? t(line.trim()) : line);
|
|
3308
3554
|
pre.append(span, '\n');
|
|
3309
3555
|
}
|
|
3310
3556
|
block.append(pre);
|
|
3311
3557
|
return block;
|
|
3312
3558
|
}
|
|
3313
3559
|
|
|
3560
|
+
// A condition's severity, like a memory's kind and a warning's level: an id in the catalogue and
|
|
3561
|
+
// a word on the screen. Keyed in upper case, which is how the tag reads and which keeps `WARNING`
|
|
3562
|
+
// the severity apart from `warning` the quota level — the same English word, two different rooms.
|
|
3563
|
+
const SEVERITY_WORDS = { blocking: t('BLOCKING'), common: t('COMMON'), degraded: t('DEGRADED'), fixed: t('ALREADY FIXED'), informational: t('INFORMATIONAL'), transient: t('TRANSIENT'), tunable: t('TUNABLE'), warning: t('WARNING') };
|
|
3564
|
+
const severityWord = (severity) => SEVERITY_WORDS[severity] ?? String(severity);
|
|
3565
|
+
|
|
3566
|
+
// Some remedies are a command only because nobody wired the button. Signing an agent in,
|
|
3567
|
+
// installing one, turning a scope on: MADRE already does all three from CONNECTIONS, so the
|
|
3568
|
+
// card opens that panel on the agent in question instead of handing out a line to paste. The
|
|
3569
|
+
// commands stay underneath for whoever prefers them, or is on another machine.
|
|
3570
|
+
function connectionsShortcut(condition, agent) {
|
|
3571
|
+
const who = condition.agent ?? agent ?? null;
|
|
3572
|
+
const jump = el('button', 'mother-goto', who ? t('OPEN ⚙ CONNECTIONS · @{agent}', { agent: who }) : t('OPEN ⚙ CONNECTIONS'));
|
|
3573
|
+
jump.type = 'button';
|
|
3574
|
+
jump.addEventListener('click', () => {
|
|
3575
|
+
rememberFold('connections', true);
|
|
3576
|
+
renderSettings();
|
|
3577
|
+
const target = who ? document.getElementById(`conn-${who}`) : settingsUI.section;
|
|
3578
|
+
setTimeout(() => target?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 60);
|
|
3579
|
+
});
|
|
3580
|
+
return jump;
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3314
3583
|
function conditionCard(condition, { hit = false, agent = null, hintAgent = null } = {}) {
|
|
3315
3584
|
const card = el('article', `mother-card${hit ? ' hit' : ''}`);
|
|
3316
3585
|
card.id = `mother-${condition.id}`;
|
|
3317
3586
|
const tags = el('div', 'tags');
|
|
3318
|
-
tags.append(el('span', `sev-${condition.severity}`, condition.severity));
|
|
3587
|
+
tags.append(el('span', `sev-${condition.severity}`, severityWord(condition.severity)));
|
|
3319
3588
|
if (condition.agent) tags.append(paint(el('span', 'agent', `@${condition.agent}`), condition.agent));
|
|
3589
|
+
if (condition.code) tags.append(el('span', 'code', condition.code));
|
|
3320
3590
|
tags.append(el('span', null, condition.id));
|
|
3321
3591
|
card.append(tags);
|
|
3322
3592
|
card.append(el('h4', null, condition.title));
|
|
@@ -3345,6 +3615,8 @@ function conditionCard(condition, { hit = false, agent = null, hintAgent = null
|
|
|
3345
3615
|
card.append(bar);
|
|
3346
3616
|
}
|
|
3347
3617
|
const chosen = agent ?? condition.agent ?? null;
|
|
3618
|
+
// The button first, the commands under it: this room can do it, and a terminal is a detour.
|
|
3619
|
+
if (condition.solvedIn === 'connections') card.append(connectionsShortcut(condition, chosen));
|
|
3348
3620
|
card.append(commandBlock(fixesFor(condition, mother.platform, chosen)));
|
|
3349
3621
|
if (condition.perAgent && !chosen) {
|
|
3350
3622
|
for (const id of Object.keys(condition.perAgent)) {
|
|
@@ -3374,14 +3646,41 @@ function renderMotherRecorded() {
|
|
|
3374
3646
|
let collapsed = false;
|
|
3375
3647
|
// What this section was set to before it folded like the rest; the fold remembers from here on.
|
|
3376
3648
|
try { collapsed = localStorage.getItem('pulse.mother.log') === 'collapsed'; } catch { /* no storage */ }
|
|
3377
|
-
const
|
|
3649
|
+
const shown = state.failures.filter((failure) => !dismissed.has(failureKey(failure)));
|
|
3650
|
+
const body = folding(mother.recorded, t('RECORDED CONDITIONS · THIS ROOM · {n}', { n: shown.length }), {
|
|
3378
3651
|
key: 'recorded', open: !collapsed,
|
|
3379
3652
|
});
|
|
3380
|
-
|
|
3653
|
+
// Everything dismissed is still in the ledger; this only stops it from being shown again.
|
|
3654
|
+
if (dismissed.size) {
|
|
3655
|
+
const back = el('button', 'mother-clear', t('SHOW THE {n} DISMISSED', { n: dismissed.size }));
|
|
3656
|
+
back.type = 'button';
|
|
3657
|
+
back.addEventListener('click', () => { dismissed.clear(); saveDismissed(); paintMotherBadge(); renderMotherRecorded(); });
|
|
3658
|
+
body.append(back);
|
|
3659
|
+
}
|
|
3660
|
+
if (shown.length) {
|
|
3661
|
+
// A file to send to whoever is helping. CSV because it opens in anything and each row
|
|
3662
|
+
// carries its support code: the reader does not have to know MADRE to read it.
|
|
3663
|
+
const save = el('button', 'mother-clear', t('DOWNLOAD THE LOG'));
|
|
3664
|
+
save.type = 'button';
|
|
3665
|
+
save.addEventListener('click', () => downloadConditionLog(shown));
|
|
3666
|
+
body.append(save);
|
|
3667
|
+
}
|
|
3668
|
+
if (shown.length > 1) {
|
|
3669
|
+
const all = el('button', 'mother-clear', t('DISMISS ALL {n}', { n: shown.length }));
|
|
3670
|
+
all.type = 'button';
|
|
3671
|
+
all.addEventListener('click', () => {
|
|
3672
|
+
for (const failure of shown) dismissed.add(failureKey(failure));
|
|
3673
|
+
saveDismissed();
|
|
3674
|
+
paintMotherBadge();
|
|
3675
|
+
renderMotherRecorded();
|
|
3676
|
+
});
|
|
3677
|
+
body.append(all);
|
|
3678
|
+
}
|
|
3679
|
+
if (!shown.length) {
|
|
3381
3680
|
body.append(el('p', 'mother-answer', t('NO CONDITIONS RECORDED. ALL SYSTEMS NOMINAL.')));
|
|
3382
3681
|
return;
|
|
3383
3682
|
}
|
|
3384
|
-
for (const failure of [...
|
|
3683
|
+
for (const failure of [...shown].reverse().slice(0, 40)) {
|
|
3385
3684
|
const rowNode = paint(el('div', 'mother-record'), failure.agent);
|
|
3386
3685
|
rowNode.append(el('span', 't', formatTime(failure.time)));
|
|
3387
3686
|
rowNode.append(el('span', 'a', failure.agent ?? t('room')));
|
|
@@ -3407,7 +3706,8 @@ function renderMotherRecorded() {
|
|
|
3407
3706
|
const links = el('span', 'k');
|
|
3408
3707
|
if (!matches.length) links.append(el('span', 'none', t('UNCLASSIFIED')));
|
|
3409
3708
|
for (const condition of matches) {
|
|
3410
|
-
const jump = el('button', null, condition.id);
|
|
3709
|
+
const jump = el('button', null, condition.code ?? condition.id);
|
|
3710
|
+
jump.title = condition.id;
|
|
3411
3711
|
jump.type = 'button';
|
|
3412
3712
|
jump.addEventListener('click', () => {
|
|
3413
3713
|
mother.input.value = '';
|
|
@@ -3419,6 +3719,17 @@ function renderMotherRecorded() {
|
|
|
3419
3719
|
links.append(jump);
|
|
3420
3720
|
}
|
|
3421
3721
|
rowNode.append(links);
|
|
3722
|
+
// One record, one way out. The ledger keeps it; this room stops counting it.
|
|
3723
|
+
const drop = el('button', 'drop', '×');
|
|
3724
|
+
drop.type = 'button';
|
|
3725
|
+
drop.title = t('Dismiss this record · it stays in the ledger');
|
|
3726
|
+
drop.addEventListener('click', () => {
|
|
3727
|
+
dismissed.add(failureKey(failure));
|
|
3728
|
+
saveDismissed();
|
|
3729
|
+
paintMotherBadge();
|
|
3730
|
+
renderMotherRecorded();
|
|
3731
|
+
});
|
|
3732
|
+
rowNode.append(drop);
|
|
3422
3733
|
body.append(rowNode);
|
|
3423
3734
|
}
|
|
3424
3735
|
}
|
|
@@ -3470,7 +3781,7 @@ function renderMother() {
|
|
|
3470
3781
|
function bootMother() {
|
|
3471
3782
|
clearTimeout(mother.bootTimer);
|
|
3472
3783
|
const online = [...state.agents.values()].filter((agent) => agent.ready).length;
|
|
3473
|
-
const open =
|
|
3784
|
+
const open = openFailures().length;
|
|
3474
3785
|
const lines = [
|
|
3475
3786
|
t('INTERFACE 2037 READY FOR INQUIRY'),
|
|
3476
3787
|
t('CREW: {n} AGENTS · {ready} READY · ROOM /{project}', { n: state.agents.size, ready: online, project: els.project.textContent }),
|
|
@@ -3566,7 +3877,7 @@ function renderEyecat(event) {
|
|
|
3566
3877
|
const node = el('div', 'command-card eyecat');
|
|
3567
3878
|
node.dataset.eyecat = finding.key;
|
|
3568
3879
|
const head = el('div', 'head');
|
|
3569
|
-
head.append(el('b', null, t('◉ EYECAT')), el('span', null, finding.kind === 'unsupported' ? 'a note its own sources do not support' : 'two memories that cannot both be true'));
|
|
3880
|
+
head.append(el('b', null, t('◉ EYECAT')), el('span', null, finding.kind === 'unsupported' ? t('a note its own sources do not support') : t('two memories that cannot both be true')));
|
|
3570
3881
|
if (finding.confidence !== null && finding.confidence !== undefined) head.append(el('span', 'args', t('{pct}% · judged by @{judge}', { pct: Math.round(finding.confidence * 100), judge: finding.judge })));
|
|
3571
3882
|
else head.append(el('span', 'args', t('judged by @{judge}', { judge: finding.judge })));
|
|
3572
3883
|
node.append(head);
|
|
@@ -3728,8 +4039,30 @@ function metrics(box, rows) {
|
|
|
3728
4039
|
}
|
|
3729
4040
|
|
|
3730
4041
|
// Every card has the same floors in the same order, whatever the module is: what it is, what it
|
|
3731
|
-
// does, what it touches, how it is doing — and then
|
|
3732
|
-
//
|
|
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
|
+
|
|
3733
4066
|
function cardShell(item, { on, state: stateText }) {
|
|
3734
4067
|
const card = el('article', `module-card${on ? '' : ' off'}`);
|
|
3735
4068
|
card.id = `module-${item.id}`;
|
|
@@ -3817,9 +4150,10 @@ function cardShell(item, { on, state: stateText }) {
|
|
|
3817
4150
|
}
|
|
3818
4151
|
|
|
3819
4152
|
// FLOOR 4 · the module's own: what it reads, what it lets you set.
|
|
3820
|
-
// FLOOR 5 ·
|
|
4153
|
+
// FLOOR 5 · actions at the bottom: DEV uninstall first, the main switch last.
|
|
3821
4154
|
const panel = el('div', 'card-panel');
|
|
3822
4155
|
const actions = el('div', 'actions');
|
|
4156
|
+
if (item.external) actions.append(removeModuleButton(item));
|
|
3823
4157
|
card.append(panel, actions);
|
|
3824
4158
|
return { card, panel, actions };
|
|
3825
4159
|
}
|
|
@@ -3896,6 +4230,10 @@ modules.file?.addEventListener('change', async () => {
|
|
|
3896
4230
|
modules.items = result.extensions ?? modules.items;
|
|
3897
4231
|
renderModules();
|
|
3898
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);
|
|
3899
4237
|
} catch (error) { toast(t('The module was not installed: {error}', { error: error.message })); }
|
|
3900
4238
|
finally { modules.add.disabled = false; }
|
|
3901
4239
|
});
|
|
@@ -4287,17 +4625,7 @@ function renderModulesDev() {
|
|
|
4287
4625
|
for (const item of yours) {
|
|
4288
4626
|
const chip = el('span', 'dev-chip');
|
|
4289
4627
|
chip.append(el('b', null, item.name), ` · ${item.origin === 'project' ? 'this project' : 'every room'} `);
|
|
4290
|
-
|
|
4291
|
-
remove.addEventListener('click', async () => {
|
|
4292
|
-
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;
|
|
4293
|
-
remove.disabled = true;
|
|
4294
|
-
try {
|
|
4295
|
-
const payload = await fetch(`/api/extensions/${item.id}`, { method: 'DELETE' }).then((response) => response.json());
|
|
4296
|
-
if (payload.error) throw new Error(payload.error);
|
|
4297
|
-
modules.items = payload.extensions ?? modules.items; toast(t('MU/TH/UR › {name} removed.', { name: item.name })); renderModules();
|
|
4298
|
-
} catch (error) { toast(t('It was not removed: {error}', { error: error.message })); remove.disabled = false; }
|
|
4299
|
-
});
|
|
4300
|
-
chip.append(remove);
|
|
4628
|
+
chip.append(removeModuleButton(item, { className: 'act-link', text: t('REMOVE') }));
|
|
4301
4629
|
list.append(chip);
|
|
4302
4630
|
}
|
|
4303
4631
|
body.append(list);
|
|
@@ -5203,6 +5531,7 @@ function folding(section, title, { key, open = false, badge = null } = {}) {
|
|
|
5203
5531
|
|
|
5204
5532
|
function connectionCard(agent) {
|
|
5205
5533
|
const card = paint(el('article', 'conn-card'), agent.id);
|
|
5534
|
+
card.id = `conn-${agent.id}`;
|
|
5206
5535
|
const session = settingsUI.data.sessions?.[agent.id];
|
|
5207
5536
|
const head = el('div', 'head');
|
|
5208
5537
|
head.append(avatar(agent.id, { size: 26, status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline' }));
|
|
@@ -5916,6 +6245,10 @@ async function openNostromo() {
|
|
|
5916
6245
|
if (!nostromo.dialog) return;
|
|
5917
6246
|
mother.dialog?.close?.();
|
|
5918
6247
|
nostromo.dialog.showModal();
|
|
6248
|
+
// A dialog hands focus to its first focusable child, and with COLD, ASK and RECENTER hidden
|
|
6249
|
+
// that was LEAVE: you pressed Enter to come in and the next Enter threw you out. The map is
|
|
6250
|
+
// what you came for, so the map takes it.
|
|
6251
|
+
nostromo.canvas?.focus?.({ preventScroll: true });
|
|
5919
6252
|
paintLegend();
|
|
5920
6253
|
nostromo.sub.textContent = t('MEMORY RESEARCH · LOADING…');
|
|
5921
6254
|
nostromo.card.hidden = true;
|
|
@@ -7595,12 +7928,13 @@ void loadVersion();
|
|
|
7595
7928
|
|
|
7596
7929
|
/* ---------- MU/TH/UR: the sentinel. Unknown conditions and crashes, redacted, ready to report. ---------- */
|
|
7597
7930
|
|
|
7598
|
-
const sentinelUI = { section: document.querySelector('#mother-sentinel'), settings: null, feedbackUrl: null, loaded: false };
|
|
7931
|
+
const sentinelUI = { section: document.querySelector('#mother-sentinel'), settings: null, feedbackUrl: null, formUrl: null, loaded: false };
|
|
7599
7932
|
async function loadSentinel() {
|
|
7600
7933
|
try {
|
|
7601
7934
|
const data = await fetch('/api/sentinel').then((response) => response.json());
|
|
7602
7935
|
sentinelUI.settings = data.settings;
|
|
7603
7936
|
sentinelUI.feedbackUrl = data.feedbackUrl;
|
|
7937
|
+
sentinelUI.formUrl = data.formUrl;
|
|
7604
7938
|
for (const report of data.reports ?? []) state.reports.set(report.id, report);
|
|
7605
7939
|
sentinelUI.loaded = true;
|
|
7606
7940
|
} catch { /* the room works without it */ }
|
|
@@ -7630,10 +7964,17 @@ function renderMotherSentinel() {
|
|
|
7630
7964
|
});
|
|
7631
7965
|
auto.append(box, t('AUTO-REPORT UNKNOWN CONDITIONS') + (settings.canSend ? '' : t(' · NO COLLECTOR CONFIGURED (PULSE_REPORT_URL)')));
|
|
7632
7966
|
controls.append(auto);
|
|
7633
|
-
|
|
7967
|
+
// Whoever arrived from a post in Spanish will not make a GitHub account to say "it does not
|
|
7968
|
+
// detect Codex". The form leads here; the issue stays one click away, and the other way round
|
|
7969
|
+
// in English, where the person reading this is already holding an account.
|
|
7970
|
+
const spanish = language() === 'es';
|
|
7971
|
+
const feedback = el('button', spanish ? null : 'primary', t('✎ FEEDBACK TO THE AUTHOR'));
|
|
7634
7972
|
feedback.type = 'button';
|
|
7635
7973
|
feedback.addEventListener('click', () => openFeedback());
|
|
7636
|
-
|
|
7974
|
+
const viaForm = el('button', spanish ? 'primary' : null, t('✎ REPORT ON MADRE.RUN ↗'));
|
|
7975
|
+
viaForm.type = 'button';
|
|
7976
|
+
viaForm.addEventListener('click', () => { if (sentinelUI.formUrl) window.open(sentinelUI.formUrl, '_blank', 'noopener'); });
|
|
7977
|
+
controls.append(spanish ? viaForm : feedback, spanish ? feedback : viaForm);
|
|
7637
7978
|
sentinelBody.append(controls);
|
|
7638
7979
|
for (const report of reports.slice(0, 20)) {
|
|
7639
7980
|
const rowNode = paint(el('div', `mother-record sentinel-report${report.sent?.ok ? ' sent' : ''}`), report.agent ?? 'room');
|
|
@@ -7646,6 +7987,8 @@ function renderMotherSentinel() {
|
|
|
7646
7987
|
if (report.count > 1) actions.append(el('span', 'none', `×${report.count}`));
|
|
7647
7988
|
actions.append(el('span', 'none', report.fingerprint));
|
|
7648
7989
|
if (report.sent?.ok) actions.append(el('span', 'none', t('SENT')));
|
|
7990
|
+
// A crash with a trace belongs in issues, where it deduplicates and the answer helps the next
|
|
7991
|
+
// one. The form is beside it for whoever has no account and would otherwise report nothing.
|
|
7649
7992
|
const issue = el('button', null, t('REPORT ON GITHUB ↗'));
|
|
7650
7993
|
issue.type = 'button';
|
|
7651
7994
|
issue.addEventListener('click', async () => {
|
|
@@ -7653,6 +7996,13 @@ function renderMotherSentinel() {
|
|
|
7653
7996
|
if (result.url) window.open(result.url, '_blank', 'noopener'); else toast(t('MU/TH/UR › no repository to file this in.'));
|
|
7654
7997
|
});
|
|
7655
7998
|
actions.append(issue);
|
|
7999
|
+
const form = el('button', null, t('OR ON MADRE.RUN ↗'));
|
|
8000
|
+
form.type = 'button';
|
|
8001
|
+
form.addEventListener('click', async () => {
|
|
8002
|
+
const result = await fetch(`/api/sentinel/${report.id}/issue`).then((response) => response.json()).catch(() => ({}));
|
|
8003
|
+
if (result.form) window.open(result.form, '_blank', 'noopener');
|
|
8004
|
+
});
|
|
8005
|
+
actions.append(form);
|
|
7656
8006
|
if (settings.canSend && !report.sent?.ok) {
|
|
7657
8007
|
const send = el('button', null, t('SEND REPORT'));
|
|
7658
8008
|
send.type = 'button';
|